// 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 (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 = { 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//...), 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 : 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 (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 * 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 (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 ' }; 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; }