credential broker: Play tokens from POST /api/google/token (cached, single flight, local key fallback), grants and APIs per package in validate, Play write preflight, apps check and the desktop Accounts view; Google 403 names the API or grant, no retry

This commit is contained in:
CRTkafa 2026-10-25 22:50:52 +07:00
parent 304407e523
commit 2c5e30090f
27 changed files with 1145 additions and 43 deletions

View file

@ -26,6 +26,8 @@ The [README](../README.md) is the short tour; everything in detail lives here.
- [Skills](skills.md)
- [MCP server and the desktop machine interface](mcp.md)
- [Safety model](safety.md)
- [Google access through a credential broker](credential-broker.md): Play tokens and permission checks from a server of
yours, no Google key on the machine
- [Idempotency: every write converges](idempotency.md)
- [Pitfalls: lessons from real use](pitfalls.md)

85
docs/credential-broker.md Normal file
View file

@ -0,0 +1,85 @@
# Google access through a credential broker
A credential broker is a server of yours that holds the Google service-account keys, the Cloud project facts and the
Play permission grants. With one configured, storebound keeps no Google key on the machine: it asks the broker for a
short-lived access token for each Google API call and reads the package's Play permissions from it before a command
writes anything. The local key (`PLAY_SERVICE_ACCOUNT`) keeps working and is the fallback.
## Setup
`~/.storebound/broker.json`:
```json
{
"url": "https://broker.example.com",
"apiKeyFile": "~/.storebound/broker.key",
"account": "play-publisher@example.com"
}
```
- `url`: the broker's base URL. Only `https://` (plain `http://` only for `localhost`, a local test broker).
- `apiKeyFile`: a file with one scoped API key on one line, with the scopes `google:read` and `google:token`. On macOS
and Linux it must be `chmod 600`: storebound refuses a key file that the group or others can read. Default:
`broker.key` next to `broker.json`. The key is never printed, never written into a config or a log.
- `account` (optional): the service account e-mail the broker mints tokens for. A credential profile or the project
can name its own with `PLAY_SERVICE_ACCOUNT_EMAIL` (env, `~/.storebound/credentials/<profile>.json`,
`.storebound/credentials.json`); without either, the `client_email` of a local key is used, and for Play commands
the account the broker names for the package.
Overrides: `STOREBOUND_BROKER_URL`, `STOREBOUND_BROKER_KEY_FILE` (env wins over the file), `STOREBOUND_BROKER_CONFIG`
(another broker.json). Off: `STOREBOUND_BROKER=off` for one run, `"broker": false` in a credential profile (that profile
keeps its key file), or env `STOREBOUND_PROFILE_<NAME>_BROKER=off`.
## What storebound calls
| call | when |
|---|---|
| `POST /api/google/token` `{ "account", "scopes": ["androidpublisher"] }` -> `{ "accessToken", "expiresAt" }` | before Google calls. Kept in memory until 5 minutes before `expiresAt`; parallel calls share one request |
| `GET /api/google/play/<package>/storebound` | `storebound validate`, the preflight of Play write commands, `apps check` and the desktop's Accounts view |
Scopes: `androidpublisher` for the Play Developer API, `cloud-platform.read-only` for Play statistics exports and Play
Games configuration reads. The broker hands out no scope for the Play Developer Reporting API (vitals, the app list of
`apps list`) or for Play Games configuration writes: those use the local key, and say so when there is none.
## Fallback and failures
- Broker not configured: the local key, as before.
- Broker not answering (network error, timeout, 5xx) or not knowing the account (400 / 404 on a token): the local key,
with one warning per run. No local key: the error names the broker.
- Broker refusing the API key (401 / 403): storebound stops with "the key lacks google:token" and does not retry or
fall back. Give the key its scopes on the broker.
- A package the broker does not know (404 on `/storebound`): a warning to register it there. It is not a missing
Play access; the command still runs and Google answers for itself.
## Permission checks
The broker's answer lists the service account's Play permissions for the package and the APIs enabled in its Cloud
projects. Before a Play command writes, storebound compares them with what the command needs and stops before any
Play call, naming the exact permission or API and the link to fix it:
| work | Play permission | commands |
|---|---|---|
| uploads, releases, rollouts | `CAN_MANAGE_TRACK_APKS` | `play upload`, `play release`, `play rollout`, `play share` |
| store listing and images | `CAN_MANAGE_PUBLIC_LISTING` | `play listing`, `play images` |
| products and subscriptions | `CAN_MANAGE_ORDERS` | `play products`, `play subscriptions` |
| Data safety, app content | `CAN_MANAGE_APP_CONTENT` | `play data-safety` |
| review replies | `CAN_REPLY_TO_REVIEWS` | (listed in the checks) |
| Play Games Services | API `gamesconfiguration.googleapis.com` | `play games` |
Every Play call also needs `androidpublisher.googleapis.com` enabled. A dry run (`--dry`) and reads only warn.
`storebound validate` reports the same gaps as blockers, the permissions and APIs the broker lists, and SDK config
files (`google-services.json`, `GoogleService-Info.plist`) the broker reports as drifted or whose sha256 differs from
the repo's copy.
When Google itself answers 403 `SERVICE_DISABLED` or `PERMISSION_DENIED`, the error names the API (with its enable
link) or the missing app access (with the Users and permissions link), adds what the broker lists for the package, and
is never retried.
## Where it shows
- `storebound doctor`: `Play: tokens from the credential broker <url> for p***@...` (or the local key, and why the
broker is not usable). `--json`: `play.source` is `"broker"` or `"file"`.
- `storebound accounts`: a "Play via" column (credential broker / key file) per profile.
- `storebound validate --json` (and MCP `validate`): `credentials.play.source`.
- `storebound apps check --store play --json` and the desktop's Accounts view: `source` and, per Play app of the
machine, the permissions and APIs the broker lists and what is missing.

View file

@ -115,12 +115,16 @@ Store accounts belong to the machine, everything else to the project:
`.storebound/credentials.json`.
- **Order:** environment variables win over everything, then a profile the project names, then the project file,
then the default profile.
- **Google keys on a server of yours:** with a credential broker in `~/.storebound/broker.json`, Play tokens come from
it and the machine needs no Google key; the local key stays the fallback. See
[Google access through a credential broker](credential-broker.md).
The names (env, profile and project file alike; the project file is gitignored by `init`):
| name | what |
|---|---|
| `PLAY_SERVICE_ACCOUNT` | path to the Google Cloud service account JSON key (or the JSON itself) with access to your app in Play Console |
| `PLAY_SERVICE_ACCOUNT_EMAIL` | with a [credential broker](credential-broker.md): the service account the broker mints tokens for (no key file needed) |
| `ASC_KEY_ID`, `ASC_ISSUER_ID` | App Store Connect API team key (Users and Access > Integrations) |
| `ASC_P8` | path to `AuthKey_<id>.p8` (default `~/.appstoreconnect/private_keys/AuthKey_<id>.p8`) |
| `ASC_VENDOR_NUMBER` | vendor number for `reports apple-sales` / `apple-finance` (Payments and Financial Reports) |

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 95 | 2026-10 | The Google service-account keys moved to the owner's credential broker, but storebound still wanted a local `PLAY_SERVICE_ACCOUNT`, and a Play write with a missing permission ran its edit before Google's bare 403. | `cli/lib/broker.ts` (broker.json, key file mode 600, token cache and single flight, grants, `playPreflight` before Play write commands), `playToken` in `cli/lib/google-auth.ts` (broker first, local key fallback when it does not answer), `cli/lib/google-access.ts` (403 SERVICE_DISABLED / PERMISSION_DENIED -> exact API or grant with its link, never retried), `validate` blockers; `tests/credential-broker.test.ts` against a local fake broker. Skill pitfall 122, docs/credential-broker.md. |
| 94 | 2026-10 | An app with managed publishing was approved but never went live: `play status` said "In review / ready to publish" with no next step, and the Publishing overview "Publish N changes" button (production + Data safety) was easy to miss or to confuse with Discard changes. | `console plan play-publish-changes` (final "Publish N changes" is the owner's, Discard changes / Stop managed publishing never-press), `console verify play-publish-changes` (API), the hint in `play status`, `status` and the desktop Releases tab (`cli/lib/play-publish.ts`, `tests/console-play-plans.test.ts`). |
| 93 | 2026-10 | A git-ignored, never-captured frames folder rendered store images with no app or game in them; render only logged 404s and `shots check` said 0 errors. | `shots render` refuses to start while a scene frame is missing (`framesForRender` in `cli/shots/lib.ts`) and fails when any load failed; report.json `missing` is a `frame-missing` error in `shots check` / `validate` (`cli/shots/report.ts`, `tests/shots-missing-frames.test.ts`). |
| 90 | 2026-10 | `apple submit --build N --version X --yes` after `apple submit --no-submit --yes` (the documented two-step flow) printed the plan (every item "in it already", then "submit PATCH submitted: true"), logged "not added ...: already in the draft submission" and ended with "nothing to submit" without the PATCH: the submit only ran for new items or an UNRESOLVED_ISSUES submission. | `willSubmit` (cli/lib/review-submit.ts) also submits a READY_FOR_REVIEW draft that already holds the named app version (iap-submit: one of the asked-for items), with the same "not ready yet" retry. `tests/resubmit.test.ts` ("the second step submits the prepared draft"). |