142 lines
9.6 KiB
TypeScript
142 lines
9.6 KiB
TypeScript
// Write gating for `storebound api`: which operations read, which write, and which only the owner may run.
|
|
//
|
|
// read GET (and a few POSTs that only compute or query, listed below): runs right away.
|
|
// write any other POST / PATCH / PUT / DELETE: prints the exact request; sent only with --yes in an interactive
|
|
// terminal, or with --yes --confirm <operationId> (equal to the resolved id) from an agent's shell without a
|
|
// terminal; never under MCP (`storebound mcp`) or the desktop app (`storebound serve`).
|
|
// owner writes that submit, release, delete, cancel, change prices, invite or remove testers, send review replies,
|
|
// start or stop experiments, refund or rotate keys (AGENTS.md rule 1): a write that also needs the operation id
|
|
// typed at the prompt. Never reachable through MCP or serve.
|
|
// Pure; tests/api.test.ts covers every rule.
|
|
|
|
export type Store = 'apple' | 'play';
|
|
export type GateKind = 'read' | 'write' | 'owner';
|
|
|
|
/** POSTs that change nothing (vitals queries, price conversion, batch reads). */
|
|
export const READ_POSTS: RegExp[] = [
|
|
/^playdeveloperreporting\..*\.query$/,
|
|
/^androidpublisher\.monetization\.convertRegionPrices$/,
|
|
/^androidpublisher\.monetization\..*\.batchGet$/,
|
|
];
|
|
|
|
/** [pattern over "METHOD operationId", why]: the curated owner-only list. */
|
|
export const OWNER_ONLY: Record<Store, [RegExp, string][]> = {
|
|
apple: [
|
|
[/^DELETE /, 'deletes'],
|
|
[/^(POST|PATCH) \w*Submissions?(V\d)?_(create|update)Instance$/, 'submits for review (or cancels a submission)'],
|
|
[/^POST (?!appStoreVersionPhasedReleases)\w*(Releases|ReleaseRequests)_createInstance$/, 'releases'],
|
|
[/^PATCH appStoreVersionPhasedReleases_updateInstance$/, 'pauses, resumes or completes a phased release'],
|
|
[/^PATCH appStoreVersionExperiments(V2)?_updateInstance$/, 'starts or stops a product page test'],
|
|
[/^POST (appPriceSchedules|inAppPurchasePriceSchedules|subscriptionPrices)_createInstance$/, 'changes prices'],
|
|
[/^POST customerReviewResponses_createInstance$/, 'sends a public review reply'],
|
|
[/^POST (betaTesterInvitations|betaTesters|userInvitations)_createInstance$/, 'invites testers or users'],
|
|
[/^POST (betaGroups_betaTesters|betaTesters_betaGroups|betaTesters_builds|builds_individualTesters)_createToManyRelationship$/, 'adds testers'],
|
|
[/^(POST|PATCH) users_(updateInstance|visibleApps_\w+)$/, 'changes a team member\'s access'],
|
|
],
|
|
play: [
|
|
[/^DELETE /, 'deletes'],
|
|
[/^POST \S+\.(batchDelete|deleteall)$/, 'deletes'],
|
|
[/^POST androidpublisher\.edits\.commit$/, 'publishes the edit (releases, listing and graphics go live)'],
|
|
[/^POST \S+\.(cancel|refund|refundexternaltransaction|reviewrefund|revoke)$/, 'cancels, refunds or revokes'],
|
|
[/^POST androidpublisher\.apprecovery\.deploy$/, 'deploys an app recovery action'],
|
|
[/^POST \S+\.(activate|deactivate|archive|batchUpdateStates)$/, 'makes products or offers live (or takes them down)'],
|
|
[/^POST androidpublisher\.appstoreappsreview\.updateappstorehostedapppublishstatus$/, 'changes a publish status'],
|
|
[/^POST \S+\.(migratePrices|batchMigratePrices)$/, 'changes prices for existing subscribers'],
|
|
[/^POST androidpublisher\.reviews\.reply$/, 'sends a public review reply'],
|
|
[/^(PUT|PATCH) androidpublisher\.edits\.testers\.(update|patch)$/, 'changes the testers of a track'],
|
|
[/^(POST|PATCH) androidpublisher\.(users|grants)\.(create|patch)$/, 'invites users or changes their permissions'],
|
|
[/^POST androidpublisher\.appsigning\.(enrollApp|rotateAppSigningKey)$/, 'changes app signing (irreversible)'],
|
|
],
|
|
};
|
|
|
|
/** Why an operation is owner-only, or null. */
|
|
export function ownerReason(store: Store, id: string, method: string): string | null {
|
|
const key = `${method.toUpperCase()} ${id}`;
|
|
return OWNER_ONLY[store].find(([re]) => re.test(key))?.[1] ?? null;
|
|
}
|
|
|
|
/** Resource of an operation, for counts: Apple's first path segment (/v1/<apps>/...), Google's id without the method. */
|
|
export const resourceOf = (r: { store: Store; id: string; path: string }): string =>
|
|
r.store === 'apple' ? r.path.split('/')[2]! : r.id.split('.').slice(0, -1).join('.');
|
|
|
|
export function gateOf(store: Store, id: string, method: string): GateKind {
|
|
const m = method.toUpperCase();
|
|
if (m === 'GET') return 'read';
|
|
if (store === 'play' && m === 'POST' && READ_POSTS.some(re => re.test(id))) return 'read';
|
|
return ownerReason(store, id, m) ? 'owner' : 'write';
|
|
}
|
|
|
|
export interface GateInput {
|
|
kind: GateKind;
|
|
method: string;
|
|
/** --dry */
|
|
dry: boolean;
|
|
/** --yes */
|
|
yes: boolean;
|
|
/** stdin and stdout are a terminal */
|
|
tty: boolean;
|
|
/** under `storebound mcp` or `storebound serve` (STOREBOUND_MCP / STOREBOUND_SERVE) */
|
|
agent: boolean;
|
|
/** `storebound api get` (the MCP api_get tool): plain GETs only */
|
|
getOnly: boolean;
|
|
/** --confirm <operationId>: the non-interactive confirmation for an agent in a shell (plain writes only) */
|
|
confirm?: string;
|
|
/** the operation id the call resolved to (what --confirm must equal) */
|
|
opId?: string;
|
|
/** the owner turned bypass mode on (storebound settings bypass on): owner-only operations take --confirm too */
|
|
bypass?: boolean;
|
|
}
|
|
|
|
export type Decision =
|
|
| { action: 'send' }
|
|
| { action: 'confirm'; typed: true }
|
|
| { action: 'dry'; why: string }
|
|
| { action: 'refuse'; why: string };
|
|
|
|
/** --yes without a terminal: refused loudly (exit 1, stderr), never a silent dry run (pitfall 77). */
|
|
export const NOT_A_TTY = 'refused: writes through `storebound api` need an interactive terminal (stdout is not a TTY: piped or redirected). Nothing was sent. Run it without a pipe or redirect, add --confirm <operationId> (agents, plain writes only: --yes --confirm bundleIds_createInstance for the operation bundleIds_createInstance), or use the dedicated storebound command.';
|
|
|
|
/** --confirm never stands in for the owner's typed id on an owner-only operation. */
|
|
export const OWNER_NO_CONFIRM = 'refused: owner-only operation: --confirm does not apply; the owner types the operation id in an interactive terminal. Nothing was sent.';
|
|
|
|
/** What to do with one call. Pure. A plain write without a terminal is sent only with --yes and --confirm <operationId>
|
|
* equal to the resolved operation id (the agent path); owner-only operations always need the owner's terminal. */
|
|
export function decide(g: GateInput): Decision {
|
|
if (g.getOnly && g.method.toUpperCase() !== 'GET') return { action: 'refuse', why: 'api get runs GET operations only' };
|
|
if (g.kind === 'read') return g.dry ? { action: 'dry', why: '--dry' } : { action: 'send' };
|
|
if (g.agent && !g.bypass) return { action: 'refuse', why: g.kind === 'owner'
|
|
? 'owner-only operation: never through MCP or the desktop app; the owner runs it in a terminal'
|
|
: 'writes through `storebound api` are not available to MCP or the desktop app; run it in a terminal' };
|
|
if (g.dry || !g.yes) return { action: 'dry', why: g.dry ? '--dry' : g.kind === 'owner' ? 'writes are dry by default: add --yes (in a terminal; owner-only operations also ask for the typed operation id)' : `writes are dry by default: add --yes in a terminal, or from an agent's shell --yes --confirm <operationId> (e.g. \`storebound api apple ${g.opId ?? 'bundleIds_createInstance'} --body body.json --yes --confirm ${g.opId ?? 'bundleIds_createInstance'}\`)` };
|
|
if (g.confirm !== undefined) {
|
|
// an agent's typed confirmation: only for plain writes, only when it names this exact operation
|
|
if (g.kind === 'owner' && !g.bypass) return { action: 'refuse', why: OWNER_NO_CONFIRM };
|
|
if (!g.opId || g.confirm !== g.opId) return { action: 'refuse', why: `refused: --confirm ${g.confirm} does not equal the operation id ${g.opId ?? '?'}. Nothing was sent.` };
|
|
return { action: 'send' };
|
|
}
|
|
if (g.agent) return { action: 'refuse', why: 'bypass mode: an agent sends writes with --yes --confirm <operationId>' };
|
|
if (!g.tty) return { action: 'refuse', why: NOT_A_TTY };
|
|
return g.kind === 'owner' ? { action: 'confirm', typed: true } : { action: 'send' };
|
|
}
|
|
|
|
/** storebound commands that do the common writes of a resource the safe way (dry run, read-back, idempotent):
|
|
* named when a raw `api` write is held, so an agent uses them instead of a terminal. First match wins. */
|
|
const DEDICATED: [Store, RegExp, string][] = [
|
|
['apple', /^bundleIdCapabilities_/, 'storebound apple signing ensure --dry, then without --dry (adds apple.signing.capabilities to the App ID and reads them back)'],
|
|
['apple', /^bundleIds_/, 'storebound apple bundle --dry, then without --dry'],
|
|
['apple', /^(certificates|profiles)_/, 'storebound apple signing ensure (manual signing; automatic signing leaves certificates and profiles to Xcode)'],
|
|
['apple', /^devices_/, 'storebound apple adhoc register --dry (adhoc-distribution skill)'],
|
|
['apple', /^(inAppPurchases|inAppPurchaseLocalizations|inAppPurchasePriceSchedules)/, 'storebound apple iap --dry'],
|
|
['apple', /^(subscriptions|subscriptionGroups|subscriptionLocalizations|subscriptionPrices)/, 'storebound apple subscriptions --dry'],
|
|
['apple', /^(appStoreVersionLocalizations|appInfoLocalizations)_/, 'storebound apple listing --dry'],
|
|
['apple', /^(appScreenshotSets|appScreenshots)_/, 'storebound apple screenshots --dry'],
|
|
['apple', /^betaGroups_|^betaTesters_/, 'storebound apple testflight --dry'],
|
|
['play', /\.(listings|details)\./, 'storebound play listing --dry'],
|
|
['play', /\.images\./, 'storebound play images --dry'],
|
|
['play', /\.(inappproducts|onetimeproducts)\./, 'storebound play products --dry'],
|
|
['play', /\.subscriptions\./, 'storebound play subscriptions --dry'],
|
|
];
|
|
|
|
export function dedicatedCommand(store: Store, opId: string): string | null {
|
|
return DEDICATED.find(([s, re]) => s === store && re.test(opId))?.[2] ?? null;
|
|
}
|