@davidvornholt/standards 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,13 +22,14 @@ standards creds add cloudflare --dest ci:ci.eu_r2_read --bucket eu-assets --juri
22
22
  standards creds add github --dest ci:ci.broker_app
23
23
  standards creds plan
24
24
  standards creds apply
25
+ standards creds revoke --token-id <token-id>
25
26
  standards poller --config <path>
26
27
  standards poller --print-units --config <path>
27
28
  ```
28
29
 
29
30
  `dependabot --write` regenerates the composed `.github/dependabot.yml` from the canonical `.github/dependabot.base.yml` and the optional repo-owned `.github/dependabot.local.yml`; `init` and `sync` do the same automatically. `dependabot --check` verifies the generated file still matches its sources, and `doctor`/`check` include the same verification. The overlay is deliberately lean and additive only: new update blocks for repo-specific ecosystems, top-level private registry definitions, and `ignore` or `registries` entries appended by repeating a canonical block's normalized target. Matching blocks cannot add labels, groups, cooldowns, pull-request limits, or other policy. Update blocks for the same ecosystem and target branch must not select intersecting directories after Dependabot's path normalization. A plural `directories` target is expanded as a glob when it contains `*`, `?`, or both `[` and `]`: `*` and bare `**` stay within one path segment, `**/` matches zero or more directory segments, `?` matches one non-separator character, and bracket expressions match character sets or ranges. Singular `directory` values are always literal.
30
31
 
31
- `dev-env` decrypts the SOPS-encrypted `secrets/dev.yaml` — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`), each mapping portable environment variable names to string values — and transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything. Generation then stages every output in its destination directory and rolls the whole set back if staging or commit fails, preserving prior contents, modes, and absent files; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration.
32
+ `dev-env` composes each workspace's dev environment from three layers sharing one shape — keyed by workspace group and a one-segment kebab-case workspace name (`apps.<name>`, `packages.<name>`), each mapping portable environment variable names to string values: the tracked plain `config/dev.yaml`, the SOPS-encrypted `secrets/dev.yaml` (required, decrypted via sops), and the gitignored plain `config/dev.local.yaml`, applied in that order with later layers overriding per key. A key declared in both tracked shared layers is a validation problem — a value is either configuration or a secret — while a local override of either is the file's purpose. Plain layers are optional and may be comment-only; `config/dev.local.yaml` must itself be gitignored because it may override secret values. The command transactionally writes every declared workspace's `.env.local` with owner-only permissions and a do-not-edit header naming the contributing sources. It also discovers direct `apps/*` and `packages/*` directories containing `package.json` and removes a stale `.env.local` only when its first line is that exact header; hand-owned files, edited headers, nested directories, and symlinked paths are left untouched. Writes and removals commit or roll back as one transaction, preserving prior contents, modes, and absent files. The final files and the transaction's actual random `.env.local.standards-*.tmp` and `.env.local.standards-*.bak` paths must all be gitignored before staging starts. Existing consumers must add those two artifact patterns themselves because `.gitignore` is seeded only during `init`; `sync` does not change it. Its Bun-specific dotenv codec exactly round-trips every value it accepts without adding helper environment variables; a value the codec cannot encode exactly is gathered as a validation problem instead of being changed silently. Validation gathers all document, containment, destination-type, workspace, and gitignore problems before writing anything; symlink destinations, duplicate resolved destinations, changed parent-directory identities, and symlinked workspace paths are rejected without following the link. Cleanup trouble after a completed commit is reported loudly as a warning rather than falsely reporting generation failure. Parent identities are checked immediately before each path operation; an unavoidable swap in the syscall-sized interval after a check remains outside the non-hostile maintainer threat model. The canonical `justfile` exposes the command as `just dev-env-generate`, and `just dev-refresh` chains a `just secrets edit dev` session into regeneration.
32
33
 
33
34
  `check` also rejects the raw token formed by `biome-` + `ignore` anywhere in lock-listed canonical files. The intentionally blunt policy keeps synced sources compatible with consumer Biome configurations without invoking Biome or maintaining language-aware comment parsing.
34
35
 
@@ -38,7 +39,7 @@ standards poller --print-units --config <path>
38
39
 
39
40
  `poller` runs one tick of the fix poller: host-level automation that turns maintainer-approved GitHub issues into verified draft PRs and maintainer-approved draft PRs into completed review-fix cycles, using headless Codex runs. All workflow state lives in GitHub labels, comments, and draft PRs (the labels are canonical declared settings — see `github --apply`), so ticks are stateless, re-runnable, and safe to schedule from any host. A tick claims work by label (`fix-in-progress` / `review-in-progress`), releases claims stale for longer than `staleClaimHours`, and fails loudly — a red tick means jobs stalled, never silence. A separate acknowledgement tick validates pending approvals and posts one deduplicated queued message per approval generation; it never claims or starts work, so it can keep giving maintainers prompt feedback while the worker is occupied. When a worker claims a request, it posts a concise start message. The exact approval and distributed claim records remain in hidden comment metadata, and losing claim contenders remove their duplicate comments. Trust is verified per event, not per configuration: the approval labels (`approved-for-fix`, `approved-for-review`) only count when the applying user holds the `admin` or `maintain` role, bind to the exact approved issue revision or PR head, and are revalidated before publication; only admin/maintain comments are treated as answers to agent questions. Approved Codex runs inherit the poller's GitHub token and use authenticated `gh` for relevant reads and comments; review runs also maintain the PR review ledger, apply authorized title or body edits, post fix threads, and file deferred findings directly. The run works in a throwaway git worktree, grants Codex write access to that worktree and its exact Git common directory so commits can update the linked repository metadata, and hands results back through a structured outcome file. The poller then verifies the approved head, commits, and protected paths before pushing or publishing; it replies to and resolves agent-posted fix threads only after the reviewed head is pushed, and it alone changes protocol labels, posts the final report, and flips a reviewed draft ready, while required CI remains the quality gate. If publishing a sealed fix or review result fails, the poller releases only its owned in-progress claim and keeps the approval and sealed output, so the next worker tick retries publication without another Codex run. Sealed review plans from the earlier protocol are rejected and their output branches are ignored, so the review reruns on a fresh protocol-versioned branch rather than silently dropping legacy deferred work. Host mutation is declarative-only: `--print-units` emits four correctly escaped systemd units for the polling host's infrastructure repository to adapt and deploy: the worker service and timer plus an independent lightweight acknowledgement service and timer. That repository owns the service identity, writable HOME, PATH, authenticated `gh`, lingering, and deployment. The worker service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
40
41
 
41
- `creds` is the credential broker: it mints scoped Cloudflare tokens, rotates and revokes those tokens, and places GitHub App credentials into SOPS-encrypted targets — secret values never touch stdout, argv, or an agent's context; commands print only metadata (token name, scopes, expiry, destination). Cloudflare reconciliation has exactly two sources of truth: the plaintext key structure of the SOPS files and the provider token list under the deterministic naming scheme `standards/<owner>/<repo>/<sops-target>/<dotted.key>`; there is no separate credential manifest. `plan` and `apply` reconcile Cloudflare only: each run uses the stored bootstrap credential to verify its current provider token ID, excludes that ID from reconciliation regardless of its current name, and aborts if the identity cannot be correlated with the complete account token list; a brokered token whose SOPS key was deleted is revoked, while one nearing expiry (30 days) is renewed by creating a fresh-expiry replacement with the live token's policies, durably writing and verifying the new value, and only then revoking the old token; secrets the broker did not mint are never touched. Without a manifest there is no desired-policy record, so live policy drift is not detected, and a repository rename or transfer changes the deterministic namespace and requires the visible old token to be re-minted under the new name and revoked. `add cloudflare` mints an account-owned API token (default TTL 90 days) scoped to the named permission groups (`permissions` lists the valid names) and writes it to `--dest <target>:<dotted.key>`, where `<target>` resolves like the canonical `just secrets` targets (`ci` → `secrets/ci.yaml`, `prod-1` → `infra/hosts/prod-1/secrets.yaml`). `--zone <zone-id>[,<zone-id>...]` adds a zone resource so zone-scoped groups such as DNS Write can be minted. Zones are named by their 32-character hexadecimal ID, shown on the zone's dashboard overview and not a secret; resolving a domain name instead would need Zone Read on the bootstrap token, which holds Account API Tokens / Edit and nothing else. With zones named, the selection is split by what each group can target: every group Cloudflare reports as zone-scoped forms one policy holding all named zones, and groups that can only target an account keep their own account policy on the same token, so one credential can hold DNS access next to account-wide access. A group Cloudflare reports as both takes the zone policy, which is the narrower reading of `--zone`. A group matching neither resource is named and rejected, `--zone` requires at least one zone-scoped group, and `--zone` cannot be combined with `--bucket`, which is a separate credential. The mint summary prints the resources the token reaches, so an account policy alongside the zones is visible rather than implied. `--bucket <name>` switches the policy resource to a single R2 bucket; `--jurisdiction default|eu` selects the provider jurisdiction and defaults to `default`, producing `com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>` or `com.cloudflare.edge.r2.bucket.<account_id>_eu_<bucket>`. FedRAMP jurisdictions are not supported. Bucket destinations accept only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write, and account-scoped groups are rejected. `--s3` requires `--bucket` and stores the S3-compatible credential pair Cloudflare derives from an API token — access key ID is the token ID, secret access key is the SHA-256 hex digest of the token value — as `<dotted.key>.access_key_id` and `<dotted.key>.secret_access_key` under the destination instead of the raw token value; the default endpoint is `https://<account_id>.r2.cloudflarestorage.com`, while EU uses `https://<account_id>.eu.r2.cloudflarestorage.com`. Reconciliation infers each destination's concrete write footprint from the plaintext SOPS key structure — a bearer owns the leaf at its key, while an S3 token owns both pair leaves — and aborts before mutation when managed footprints intersect. `plan` and `apply` renew and revoke S3 destinations like any other brokered token, but renewal first proves the stored access key ID matches the current provider token; an incomplete pair, unreadable value, or identity mismatch is surfaced as a finding and never mutated automatically. `add github` selects the private broker App whose owner matches the destination repository's origin owner, checks both local SOPS leaves before authenticating, proves that the exact selected App is installed on that repository, and writes only while that full App identity still holds the synchronized broker-store entry; missing, duplicate, replaced, or mismatched owners fail before SOPS changes. Durable GitHub tokens cannot be created via API (PAT and OAuth-app creation are UI-only), so workflows mint short-lived installation tokens from the App at runtime (`actions/create-github-app-token`), scoped per repository and permission. The canonical weekly sync expects `bun standards creds add github --dest ci:ci.broker_app`, then mints a current-repository token with Contents read and Pull requests write; missing credentials or minting failures stop it without a fallback. Bootstrap is once per machine and per GitHub owner: `login github` creates a private personal App, while `login github --org <org>` creates or replaces only that organization's entry; each manifest conversion records GitHub's authoritative owner, and the command opens the installation page, where the App must be bound to selected repositories rather than all repositories. GitHub App names are globally unique and limited to 34 characters, so organization defaults include owners up to 17 characters; a longer owner or a colliding replacement name requires an explicit shorter unused `--name`. The first GitHub-specific command against an existing singleton broker store authenticates as that App, resolves its registration owner, and atomically migrates the old mapping to that one owner; a failed lookup leaves the persisted bytes untouched. If the singleton was a public App installed on repositories owned by other users or organizations, those repositories stop matching after migration because owner selection no longer treats that App as shared. For each affected owner, run `login github` while signed in as that personal owner or `login github --org <owner>`, install the new private App only on the selected repositories, run `bun standards creds add github --dest <target>:<key>` in every consumer, and verify its workflows can mint tokens. Retire the old public App only after every repository owner has its own entry, every intended SOPS destination has been reprovisioned, and token minting has been verified; until then, keep its key and installation available for any destinations not yet migrated. `login cloudflare` guides the one dashboard paste Cloudflare requires for the first account token (named `standards-broker` to match the App, created from the "Create additional tokens" token template because its "Account API Tokens: Edit" permission is unavailable in the custom token builder, and holding that permission only), verifies that it is active, correlates its verified ID with the complete account token list and an active list record, and stores it; a bootstrap token named inside the minted `standards/` namespace is rejected before storage to keep its role unambiguous, while any other divergence from the recommended name only warns. `plan` and `apply` do not rotate GitHub App keys; create a replacement key on the App's settings page, atomically replace that owner's `private_key` in the protected machine-global broker store while preserving its `0600` mode, then update every intended SOPS destination, verify workflows can mint installation tokens, and revoke the old key manually. Root credentials live in the machine-global broker store (see Configuration); SOPS writes go through `sops edit` with a non-interactive editor, preserving comments and reusing the file's data key.
42
+ `creds` is the credential broker: it mints scoped Cloudflare tokens, rotates and revokes those tokens, and places GitHub App credentials into SOPS-encrypted targets — secret values never touch stdout, argv, or an agent's context; commands print only metadata (token name, scopes, expiry, destination). Cloudflare reconciliation has exactly two sources of truth: the plaintext key structure of the SOPS files and the provider token list under the deterministic naming scheme `standards/<owner>/<repo>/<sops-target>/<dotted.key>`; there is no separate credential manifest. `plan` and `apply` reconcile Cloudflare only: each run uses the stored bootstrap credential to verify its current provider token ID, excludes that ID from reconciliation regardless of its current name, and aborts if the identity cannot be correlated with the complete account token list; a brokered token whose SOPS key was deleted is revoked, while one nearing expiry (30 days) is renewed by creating a fresh-expiry replacement with the live token's policies, durably writing and verifying the new value, and only then revoking the old token; secrets the broker did not mint are never touched. Both also report what reconciliation will never touch, in two separately labelled and separately counted blocks that never block: unmanaged tokens, meaning every unexpired token in the account whose name falls outside the reserved `standards/` namespace — with one exception, this machine's own bootstrap credential, which every run removes by verified ID before anything is classified — and tokens brokered to another repository, each printed with the repository whose `apply` owns it. An expired token is omitted from the unmanaged listing because it grants nothing. Neither block is idle information: nothing here renews or revokes those tokens, and a forgotten one — the original half of a hand-made credential that was replaced rather than retired, or a brokered token whose repository was renamed away — stays valid indefinitely, so a listed token is normally printed with the exact command that retires it, carrying `--account` and, for a token brokered elsewhere, `--force` and the one condition that justifies it. Two rows are exceptions. A token named `standards-broker` holds the reserved name for a machine's bootstrap credential, so it is either another machine's or a superseded one on this machine; `revoke` refuses it by name, so the row names the Cloudflare dashboard rather than a command that would always fail. The other exception looks like an ordinary brokered-elsewhere row, and its command fails. `plan` classifies by exact token name, so a token whose brokered repository differs from this checkout's origin only in capitalisation lands in the brokered-elsewhere block with a `--force` command; `revoke` compares repository names case-insensitively, reads the same token as this checkout's own, and refuses. Row and block header both read that token as foreign — the header labels the block tokens brokered to another repository, and the row explains the `--force` by the owning repository having been renamed, transferred, or deleted. Neither holds there: GitHub names are case-insensitive, so the owning repository is this one and it is still alive. Re-point the origin remote at the capitalisation the token carries, or retire the token in the Cloudflare dashboard. A name that claims this repository's brokered namespace without being one the broker mints is a finding instead, and findings abort reconciliation before any mutation, because reconciliation cannot own that token; plain `revoke --token-id` takes it once you decide it should go. `revoke --token-id <id>` deletes exactly one Cloudflare token, named by its 32-character hexadecimal ID, with `--account <id>` required whenever more than one account is configured; it records no ownership, so the power is momentary rather than standing and a human names the target every time. It refuses an ID the account does not hold, the bootstrap credential this machine has stored (matched by verified ID, so a rename cannot disguise it), and any other token named `standards-broker`, the reserved name for a machine's bootstrap (matched by name, because that one is another machine's bootstrap or a superseded one, retired in the Cloudflare dashboard where you can confirm whose it is — the broker cannot re-mint its own root credential). A token brokered to this repository is refused with the remedy that works here: delete its SOPS key in this repository and run `apply`, which revokes the token and keeps the secret in step. A token whose brokered name differs from this checkout's origin only in capitalisation is refused too, and without the `apply` remedy: reconciliation matches the name exactly, so from here it would neither renew nor revoke the token. Its remedies are re-pointing the origin remote at the capitalisation the token carries, which brings it back under reconciliation, or retiring it in the Cloudflare dashboard. A token brokered to a repository that is genuinely a different one is refused with that repository named, because the same `apply` remedy runs there. `--force` widens exactly that last refusal, and nothing else: it lets `revoke` delete a token whose brokered repository is not this checkout's. It verifies nothing about the state of that repository, so it is justified only when nothing reconciles the token any more — the owning repository was renamed, transferred, or deleted, and no checkout resolves to its name. `revoke` decides ownership from the origin remote and compares it case-insensitively, as GitHub names are; `plan` classifies by exact name instead, which is why the two disagree about a case-only difference. When the origin remote resolves to no GitHub repository, every brokered token is refused, `--force` included, because the check it widens is the one that could not be run. `--force` never bypasses either bootstrap guard, the case-only refusal, or that unresolvable-origin refusal. Without a manifest there is no desired-policy record, so live policy drift is not detected, and a repository rename or transfer changes the deterministic namespace and requires the visible old token to be re-minted under the new name and revoked. `add cloudflare` mints an account-owned API token (default TTL 90 days) scoped to the named permission groups (`permissions` lists the valid names) and writes it to `--dest <target>:<dotted.key>`, where `<target>` resolves like the canonical `just secrets` targets (`ci` → `secrets/ci.yaml`, `prod-1` → `infra/hosts/prod-1/secrets.yaml`). `--zone <zone-id>[,<zone-id>...]` adds a zone resource so zone-scoped groups such as DNS Write can be minted. Zones are named by their 32-character hexadecimal ID, shown on the zone's dashboard overview and not a secret; resolving a domain name instead would need Zone Read on the bootstrap token, which holds Account API Tokens / Edit and nothing else. With zones named, the selection is split by what each group can target: every group Cloudflare reports as zone-scoped forms one policy holding all named zones, and groups that can only target an account keep their own account policy on the same token, so one credential can hold DNS access next to account-wide access. A group Cloudflare reports as both takes the zone policy, which is the narrower reading of `--zone`. A group matching neither resource is named and rejected, `--zone` requires at least one zone-scoped group, and `--zone` cannot be combined with `--bucket`, which is a separate credential. The mint summary prints the resources the token reaches, so an account policy alongside the zones is visible rather than implied. `--bucket <name>` switches the policy resource to a single R2 bucket; `--jurisdiction default|eu` selects the provider jurisdiction and defaults to `default`, producing `com.cloudflare.edge.r2.bucket.<account_id>_default_<bucket>` or `com.cloudflare.edge.r2.bucket.<account_id>_eu_<bucket>`. FedRAMP jurisdictions are not supported. Bucket destinations accept only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write, and account-scoped groups are rejected. `--s3` requires `--bucket` and stores the S3-compatible credential pair Cloudflare derives from an API token — access key ID is the token ID, secret access key is the SHA-256 hex digest of the token value — as `<dotted.key>.access_key_id` and `<dotted.key>.secret_access_key` under the destination instead of the raw token value; the default endpoint is `https://<account_id>.r2.cloudflarestorage.com`, while EU uses `https://<account_id>.eu.r2.cloudflarestorage.com`. Reconciliation infers each destination's concrete write footprint from the plaintext SOPS key structure — a bearer owns the leaf at its key, while an S3 token owns both pair leaves — and aborts before mutation when managed footprints intersect. `plan` and `apply` renew and revoke S3 destinations like any other brokered token, but renewal first proves the stored access key ID matches the current provider token; an incomplete pair, unreadable value, or identity mismatch is surfaced as a finding and never mutated automatically. `add github` selects the private broker App whose owner matches the destination repository's origin owner, checks both local SOPS leaves before authenticating, proves that the exact selected App is installed on that repository, and writes only while that full App identity still holds the synchronized broker-store entry; missing, duplicate, replaced, or mismatched owners fail before SOPS changes. Durable GitHub tokens cannot be created via API (PAT and OAuth-app creation are UI-only), so workflows mint short-lived installation tokens from the App at runtime (`actions/create-github-app-token`), scoped per repository and permission. The canonical weekly sync expects `bun standards creds add github --dest ci:ci.broker_app`, then mints a current-repository token with Contents read and Pull requests write; missing credentials or minting failures stop it without a fallback. Bootstrap is once per machine and per GitHub owner: `login github` creates a private personal App, while `login github --org <org>` creates or replaces only that organization's entry; each manifest conversion records GitHub's authoritative owner, and the command opens the installation page, where the App must be bound to selected repositories rather than all repositories. GitHub App names are globally unique and limited to 34 characters, so organization defaults include owners up to 17 characters; a longer owner or a colliding replacement name requires an explicit shorter unused `--name`. The first GitHub-specific command against an existing singleton broker store authenticates as that App, resolves its registration owner, and atomically migrates the old mapping to that one owner; a failed lookup leaves the persisted bytes untouched. If the singleton was a public App installed on repositories owned by other users or organizations, those repositories stop matching after migration because owner selection no longer treats that App as shared. For each affected owner, run `login github` while signed in as that personal owner or `login github --org <owner>`, install the new private App only on the selected repositories, run `bun standards creds add github --dest <target>:<key>` in every consumer, and verify its workflows can mint tokens. Retire the old public App only after every repository owner has its own entry, every intended SOPS destination has been reprovisioned, and token minting has been verified; until then, keep its key and installation available for any destinations not yet migrated. `login cloudflare` guides the one dashboard paste Cloudflare requires for the first account token (named `standards-broker` to match the App, created from the "Create additional tokens" token template because its "Account API Tokens: Edit" permission is unavailable in the custom token builder, and holding that permission only), verifies that it is active, correlates its verified ID with the complete account token list and an active list record, and stores it; a bootstrap token named inside the minted `standards/` namespace is rejected before storage to keep its role unambiguous, while any other divergence from the recommended name only warns. `plan` and `apply` do not rotate GitHub App keys; create a replacement key on the App's settings page, atomically replace that owner's `private_key` in the protected machine-global broker store while preserving its `0600` mode, then update every intended SOPS destination, verify workflows can mint installation tokens, and revoke the old key manually. Root credentials live in the machine-global broker store (see Configuration); SOPS writes go through `sops edit` with a non-interactive editor, preserving comments and reusing the file's data key.
42
43
 
43
44
  `structure --profile source` validates the standards source repository itself, which is deliberately not a consumer. The profile pins its intentional exceptions so they cannot drift silently: exact, ordered root gate scripts that run this CLI from the local checkout (`structure --profile source`, `dependabot --check` or `dependabot --write`, `github --check`, and the Turbo gate) instead of a recursive `standards check`, and a non-private published bin-only CLI workspace that carries a stable release SemVer and exactly maps the `standards` bin to `src/cli.ts` without `exports`. Every other consumer rule still applies.
44
45
 
@@ -48,7 +49,7 @@ standards poller --print-units --config <path>
48
49
  - **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run; deliberate syncs then run locally because the secret-bearing workflow is schedule-only. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
49
50
  - **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
50
51
  - **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum and five-minute acknowledgement cooldown keep supported mixed fix/review queues and persisted review-plan recovery below the 5,000-request/hour GitHub API budget with at least 1,400 requests/hour reserved for pagination, active jobs, writes, and other token users; the compound regression bound covers four acknowledged fixes plus four acknowledged reviews with persisted plans in every repository at no more than 3,312 requests/hour. Existing configs above the repository limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the maximum number of new Codex runs the sequential worker may start in one tick), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `gh`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
51
- - **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding one private GitHub App identity (owner, id, slug, client id, private key) per GitHub account and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite other accounts; `creds status` lists every App and owner and warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the provider login commands and revoke the old App keys and bootstrap tokens. On an additional machine, do not create second Apps: generate an additional private key from each existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key per owner, not the setups.
52
+ - **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding one private GitHub App identity (owner, id, slug, client id, private key) per GitHub account and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite other accounts; `creds status` lists every App and owner and warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the provider login commands, revoke the old App keys on each App's settings page, and retire the old bootstrap tokens in the Cloudflare dashboard. The dashboard is the only route for those tokens: `login cloudflare` walks you through creating a second one under the reserved `standards-broker` name, the old one stays live under that same name, and `revoke` refuses every token holding it. On an additional machine, do not create second Apps: generate an additional private key from each existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key per owner, not the setups.
52
53
  - **`GH_TOKEN` / `GITHUB_TOKEN`** (optional generally; poller automation requires this or a logged-in `gh`) — GitHub API token for the `github` command and the GitHub portion of `check`; poller runs pass environment tokens through to Codex, while a local poller can share the service identity's authenticated `gh` state. When neither is available, other commands can still read public repositories anonymously, but poller writes and `github --apply` fail. GitHub's REST API reveals repo merge settings only to write-capable viewers, and `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens. REST serves ruleset `bypass_actors` only to a user-scoped token holding repository Administration read (a fine-grained PAT with that permission, or the local `gh` CLI under an admin account), and never to a GitHub App installation token at any permission level, so `check` retries the hidden lists over GraphQL, which withholds the actor identities but answers an exact bypass-actor count (established for a GitHub App installation token and for a non-admin classic-scope viewer, not for every identity class): a declared-empty list verifies exactly, a count that disagrees with the declared length is drift, and a matching non-zero count leaves *which* actors bypass unconfirmed, so the gate fails closed on that ruleset. An installation-token run therefore leaves a declared non-empty list failing until the check is re-run locally with admin `gh` auth; widening the App's permissions is not the fix. Private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow verifies with the workflow token, granted read-only "Contents" and "Issues"; Issues is what declared-label reads need, listing rulesets rides on the implicit "Metadata" read, and merge settings come over GraphQL. A private-repository probe compared that grant set against a broker App installation token across every read the check performs and found no difference, so CI provisions no settings credential of its own.
53
54
 
54
55
  See the standards repository README for the ownership model and adoption workflow.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "src/creds-cloudflare-api.ts",
33
33
  "src/creds-cloudflare-condition.ts",
34
34
  "src/creds-cloudflare-expiry.ts",
35
+ "src/creds-cloudflare-id.ts",
35
36
  "src/creds-cloudflare-pagination.ts",
36
37
  "src/creds-cloudflare-zone.ts",
37
38
  "src/creds-commands.ts",
@@ -46,11 +47,14 @@
46
47
  "src/creds-plan.ts",
47
48
  "src/creds-plan-groups.ts",
48
49
  "src/creds-plan-renew.ts",
50
+ "src/creds-plan-report.ts",
49
51
  "src/creds-plan-run.ts",
50
52
  "src/creds-plan-revoke.ts",
51
53
  "src/creds-plan-types.ts",
54
+ "src/creds-plan-unmanaged.ts",
52
55
  "src/creds-prompt.ts",
53
56
  "src/creds-r2.ts",
57
+ "src/creds-revoke.ts",
54
58
  "src/creds-sops.ts",
55
59
  "src/creds-sops-editor.ts",
56
60
  "src/creds-sops-structure.ts",
@@ -68,11 +72,15 @@
68
72
  "src/dependabot-directory-overlap.ts",
69
73
  "src/dependabot-inspect.ts",
70
74
  "src/dependabot-update.ts",
75
+ "src/dev-env-compose.ts",
71
76
  "src/dev-env-document.ts",
72
77
  "src/dev-env-destination-duplicates.ts",
78
+ "src/dev-env-destination-gitignore.ts",
73
79
  "src/dev-env-destination.ts",
74
80
  "src/dev-env-dotenv-value.ts",
75
81
  "src/dev-env-dotenv.ts",
82
+ "src/dev-env-reconciliation.ts",
83
+ "src/dev-env-secrets.ts",
76
84
  "src/dev-env-transaction-recovery.ts",
77
85
  "src/dev-env-transaction.ts",
78
86
  "src/dev-env.ts",
package/src/cli.ts CHANGED
@@ -965,7 +965,7 @@ Commands:
965
965
  doctor Validate extension seams only
966
966
  structure Validate monorepo structure rules only
967
967
  dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
968
- dev-env Write each workspace's generated .env.local from the SOPS-encrypted secrets/dev.yaml
968
+ dev-env Compose each workspace's generated .env.local from config/dev.yaml, the SOPS-encrypted secrets/dev.yaml, and gitignored config/dev.local.yaml overrides
969
969
  github Compare (--check) or converge (--apply) live GitHub settings
970
970
  creds Mint, rotate, and revoke brokered credentials (see \`standards creds help\`)
971
971
  poller Run one fix-poller tick over the configured repositories (host automation)
package/src/creds-args.ts CHANGED
@@ -20,8 +20,10 @@ export type CredsFlags = {
20
20
  zone: string | undefined;
21
21
  jurisdiction: R2Jurisdiction;
22
22
  s3: boolean;
23
+ force: boolean;
23
24
  org: string | undefined;
24
25
  name: string | undefined;
26
+ tokenId: string | undefined;
25
27
  readonly words: Array<string>;
26
28
  };
27
29
 
@@ -55,8 +57,10 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
55
57
  zone: undefined,
56
58
  jurisdiction: DEFAULT_R2_JURISDICTION,
57
59
  s3: false,
60
+ force: false,
58
61
  org: undefined,
59
62
  name: undefined,
63
+ tokenId: undefined,
60
64
  words: [],
61
65
  };
62
66
  const setters: Readonly<Record<string, (value: string) => void>> = {
@@ -93,12 +97,17 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
93
97
  '--name': (value) => {
94
98
  flags.name = value;
95
99
  },
100
+ '--token-id': (value) => {
101
+ flags.tokenId = value;
102
+ },
96
103
  };
97
104
  for (let index = 0; index < argv.length; index += 1) {
98
105
  const arg = argv[index] ?? '';
99
106
  const setter = setters[arg];
100
107
  if (arg === '--s3') {
101
108
  flags.s3 = true;
109
+ } else if (arg === '--force') {
110
+ flags.force = true;
102
111
  } else if (setter !== undefined) {
103
112
  setter(flagValue(argv, index));
104
113
  index += 1;
@@ -1,4 +1,4 @@
1
- const ACCOUNT_ID_PATTERN = /^[0-9a-f]{32}$/u;
1
+ import { isCloudflareId } from './creds-cloudflare-id';
2
2
 
3
3
  export const isCloudflareAccountId = (value: unknown): value is string =>
4
- typeof value === 'string' && ACCOUNT_ID_PATTERN.test(value);
4
+ typeof value === 'string' && isCloudflareId(value);
@@ -0,0 +1,10 @@
1
+ // Cloudflare identifies accounts, zones, and API tokens by the same
2
+ // 32-character lowercase hexadecimal shape, and this module owns that shape for
3
+ // all three. Checking it before a request turns a typo into a precise message
4
+ // instead of a provider 404, which reads the same as a resource that exists but
5
+ // is out of reach.
6
+
7
+ const CLOUDFLARE_ID = /^[0-9a-f]{32}$/u;
8
+
9
+ export const isCloudflareId = (value: string): boolean =>
10
+ CLOUDFLARE_ID.test(value);
@@ -4,9 +4,9 @@
4
4
  // would fail — or, worse, come back empty and read as a missing zone. A zone ID
5
5
  // is not a secret and is shown on the zone's dashboard overview.
6
6
 
7
- export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
7
+ import { isCloudflareId } from './creds-cloudflare-id';
8
8
 
9
- const ZONE_ID_PATTERN = /^[0-9a-f]{32}$/u;
9
+ export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
10
10
 
11
11
  export const zoneResource = (zoneId: string): string =>
12
12
  `${ZONE_SCOPE}.${zoneId}`;
@@ -30,7 +30,7 @@ export const parseZoneArgument = (value: string): ParsedZoneArgument => {
30
30
  };
31
31
  }
32
32
  const entries = [first, ...rest] as const;
33
- const invalid = entries.filter((zone) => !ZONE_ID_PATTERN.test(zone));
33
+ const invalid = entries.filter((zone) => !isCloudflareId(zone));
34
34
  return invalid.length === 0
35
35
  ? { ok: true, zoneIds: entries }
36
36
  : {
@@ -7,6 +7,7 @@ import { runCredsLoginCloudflare } from './creds-login-cloudflare';
7
7
  import { runCredsLoginGithub } from './creds-login-github';
8
8
  import { BROKER_IDENTITY_NAME } from './creds-naming';
9
9
  import { runCredsPlan } from './creds-plan-run';
10
+ import { runCredsRevoke } from './creds-revoke';
10
11
  import {
11
12
  inspectBrokerFileMode,
12
13
  readBrokerStore,
@@ -20,8 +21,9 @@ Commands:
20
21
  login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
21
22
  add cloudflare Mint a scoped, expiring account token and write it into a SOPS target
22
23
  add github Select the repository owner's App, verify its installation, and write it into SOPS
23
- plan Show revocations and rotations reconciling SOPS keys against brokered tokens
24
- apply Execute the plan: revoke orphaned tokens, roll expiring ones into SOPS
24
+ plan Reconcile SOPS keys against brokered tokens: show revocations, rotations, and the live account tokens nothing reconciles here
25
+ apply Execute the plan: revoke brokered tokens whose SOPS key is gone, roll expiring ones into SOPS
26
+ revoke Delete one Cloudflare token named by --token-id; bootstrap credentials and brokered tokens are refused
25
27
  permissions List Cloudflare permission group names for --permissions
26
28
  status Show the broker store location and configured providers
27
29
 
@@ -35,6 +37,8 @@ Options:
35
37
  --zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
36
38
  --jurisdiction <name> R2 jurisdiction: default or eu (default: default)
37
39
  --s3 Store the derived R2 S3 credential pair (<key>.access_key_id, <key>.secret_access_key) instead of the raw token
40
+ --token-id <id> Cloudflare token to revoke (32-character hexadecimal ID)
41
+ --force Let revoke delete a token brokered to a repository that is not this checkout's. It verifies nothing about that repository, so use it only when nothing reconciles the token any more: that repository was renamed, transferred, or deleted, so no checkout resolves to its name and no \`standards creds apply\` will ever revoke the token. It never permits revoking a broker bootstrap credential, a token brokered to this repository, a token whose brokered repository differs from this checkout's only in capitalisation, or any brokered token when this checkout's origin remote resolves to no GitHub repository, because ownership is then the very thing that could not be checked
38
42
  --org <org> Create the GitHub App under an organization
39
43
  --name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME}, suffixed by --org)
40
44
 
@@ -117,6 +121,13 @@ export const runCredsCommand = (
117
121
  'add github': () => runCredsAddGithub(flags.dir, flags),
118
122
  plan: () => runCredsPlan(flags.dir, false),
119
123
  apply: () => runCredsPlan(flags.dir, true),
124
+ revoke: () =>
125
+ runCredsRevoke({
126
+ account: flags.account,
127
+ tokenId: flags.tokenId,
128
+ dir: flags.dir,
129
+ force: flags.force,
130
+ }),
120
131
  permissions: () => runCredsPermissions(flags.account),
121
132
  status: () => runCredsStatus(),
122
133
  };
@@ -80,3 +80,16 @@ export const parseTokenName = (
80
80
  }
81
81
  return ref;
82
82
  };
83
+
84
+ // The repository a minted name belongs to is carried by the name itself, so a
85
+ // command holding no repository — `creds revoke` is account-scoped — can still
86
+ // tell a token reconciliation owns from one nothing manages.
87
+ export const parseAnyTokenName = (name: string): BrokeredTokenRef | null => {
88
+ if (!isInMintedNamespace(name)) {
89
+ return null;
90
+ }
91
+ const [owner, repo] = name.slice(`${NAME_PREFIX}/`.length).split('/');
92
+ return owner === undefined || repo === undefined
93
+ ? null
94
+ : parseTokenName(name, `${owner}/${repo}`);
95
+ };
@@ -0,0 +1,87 @@
1
+ // The operator-facing rendering of a computed plan. Every block is labelled and
2
+ // printed only when it has entries, because the three lists say different
3
+ // things: what reconciliation will do, what it will never touch, and what no
4
+ // repository reconciles at all. The summary counts them apart for the same
5
+ // reason.
6
+
7
+ import { BROKER_IDENTITY_NAME } from './creds-naming';
8
+ import type {
9
+ BrokeredElsewhereToken,
10
+ CredsPlan,
11
+ PlannedAction,
12
+ UnmanagedToken,
13
+ } from './creds-plan-types';
14
+
15
+ // Each listed token is offered with the exact command that retires it rather
16
+ // than with identifiers to reassemble: `plan` already holds the account, and
17
+ // `revoke` demands it whenever the broker holds more than one.
18
+ const revokeCommand = (token: UnmanagedToken, extra: string): string =>
19
+ `\`standards creds revoke --account ${token.accountId} --token-id ${token.tokenId}${extra}\``;
20
+
21
+ // One unmanaged shape has no revoke command to offer: the reserved bootstrap
22
+ // name. This machine's own bootstrap is excluded from the listing by verified
23
+ // ID, so a row under that name is another machine's or a superseded one, and
24
+ // `revoke` refuses it by name — printing a command that always fails would
25
+ // send the operator down a dead end. The remedy is the dashboard, which is
26
+ // also the only place the machine it belongs to can be confirmed.
27
+ const unmanagedRemedy = (token: UnmanagedToken): string =>
28
+ token.name === BROKER_IDENTITY_NAME
29
+ ? "the reserved name for a machine's broker bootstrap credential, which `revoke` refuses; retire it in the Cloudflare dashboard, where you can confirm which machine it belongs to"
30
+ : `retire it with ${revokeCommand(token, '')}`;
31
+
32
+ const reportActions = (
33
+ actions: ReadonlyArray<PlannedAction>,
34
+ apply: boolean,
35
+ ): void => {
36
+ if (actions.length === 0) {
37
+ return;
38
+ }
39
+ console.log(`standards creds: ${apply ? '' : 'planned '}actions`);
40
+ for (const action of actions) {
41
+ console.log(
42
+ ` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
43
+ );
44
+ }
45
+ };
46
+
47
+ const reportUnmanaged = (tokens: ReadonlyArray<UnmanagedToken>): void => {
48
+ if (tokens.length === 0) {
49
+ return;
50
+ }
51
+ console.log(
52
+ 'standards creds: unmanaged tokens — reconciliation mints nothing of this shape, and plan and apply never mutate them',
53
+ );
54
+ for (const token of tokens) {
55
+ console.log(
56
+ ` ${token.name} (${token.status}) — ${unmanagedRemedy(token)}`,
57
+ );
58
+ }
59
+ };
60
+
61
+ const reportBrokeredElsewhere = (
62
+ tokens: ReadonlyArray<BrokeredElsewhereToken>,
63
+ ): void => {
64
+ if (tokens.length === 0) {
65
+ return;
66
+ }
67
+ console.log(
68
+ 'standards creds: tokens brokered to another repository — never mutated here, and reconciled there only if a checkout still mints that exact name',
69
+ );
70
+ for (const token of tokens) {
71
+ console.log(
72
+ ` ${token.name} (${token.status}) — brokered to ${token.repo}; if no checkout mints this name any more — ${token.repo} renamed, transferred, or deleted — then nothing reconciles the token, and only then: ${revokeCommand(token, ' --force')}`,
73
+ );
74
+ }
75
+ };
76
+
77
+ export const reportCredsPlan = (plan: CredsPlan, apply: boolean): void => {
78
+ reportActions(plan.actions, apply);
79
+ reportUnmanaged(plan.unmanaged);
80
+ reportBrokeredElsewhere(plan.brokeredElsewhere);
81
+ for (const finding of plan.findings) {
82
+ console.error(`standards creds: ${finding}`);
83
+ }
84
+ console.log(
85
+ `standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy, ${plan.unmanaged.length} unmanaged token(s), ${plan.brokeredElsewhere.length} token(s) brokered to another repository`,
86
+ );
87
+ };
@@ -2,6 +2,7 @@ import { listSecretsTargets } from './creds-dest';
2
2
  import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
3
3
  import { computeCredsPlan } from './creds-plan';
4
4
  import { renewPlannedToken } from './creds-plan-renew';
5
+ import { reportCredsPlan } from './creds-plan-report';
5
6
  import { revokePlannedToken } from './creds-plan-revoke';
6
7
  import type { AccountToken, PlannedAction } from './creds-plan-types';
7
8
  import { readEncryptedKeys } from './creds-sops';
@@ -109,17 +110,7 @@ export const runCredsPlan = async (
109
110
  tokens: state.tokens,
110
111
  now: new Date(),
111
112
  });
112
- for (const action of plan.actions) {
113
- console.log(
114
- ` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
115
- );
116
- }
117
- for (const finding of plan.findings) {
118
- console.error(`standards creds: ${finding}`);
119
- }
120
- console.log(
121
- `standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy`,
122
- );
113
+ reportCredsPlan(plan, apply);
123
114
  if (plan.findings.length > 0) {
124
115
  return fail('reconciliation aborted until every finding is resolved');
125
116
  }
@@ -7,6 +7,21 @@ export type AccountToken = {
7
7
  readonly token: CloudflareToken;
8
8
  };
9
9
 
10
+ export type UnmanagedToken = {
11
+ readonly accountId: string;
12
+ readonly tokenId: string;
13
+ readonly name: string;
14
+ readonly status: string;
15
+ };
16
+
17
+ // A token the broker minted under another repository's name. That repository
18
+ // normally reconciles it, so it is not unmanaged — the distinction matters
19
+ // because it stops being true when that repository is renamed, transferred, or
20
+ // deleted, and this account is then the only place the token is still visible.
21
+ export type BrokeredElsewhereToken = UnmanagedToken & {
22
+ readonly repo: string;
23
+ };
24
+
10
25
  export type PlannedAction =
11
26
  | {
12
27
  readonly kind: 'revoke';
@@ -33,4 +48,8 @@ export type CredsPlan = {
33
48
  readonly actions: ReadonlyArray<PlannedAction>;
34
49
  readonly findings: ReadonlyArray<string>;
35
50
  readonly healthy: number;
51
+ // Tokens in the account the broker did not mint. Reported, never mutated.
52
+ readonly unmanaged: ReadonlyArray<UnmanagedToken>;
53
+ // Tokens the broker minted for another repository. Reported, never mutated.
54
+ readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
36
55
  };
@@ -0,0 +1,102 @@
1
+ // Reconciliation matches tokens to a repository by name and used to discard
2
+ // every other token in the account silently. That silence is where a forgotten
3
+ // credential hides: replacing a hand-made token means minting a brokered one
4
+ // and deleting the original, and nothing noticed when the second half was
5
+ // skipped. Classification makes the leftovers visible without giving the broker
6
+ // any power over them — a token named here is never mutated automatically.
7
+
8
+ import type { CloudflareToken } from './creds-cloudflare-api';
9
+ import {
10
+ type BrokeredTokenRef,
11
+ isInMintedNamespace,
12
+ parseAnyTokenName,
13
+ parseTokenName,
14
+ repoTokenPrefix,
15
+ } from './creds-naming';
16
+ import type {
17
+ AccountToken,
18
+ BrokeredElsewhereToken,
19
+ UnmanagedToken,
20
+ } from './creds-plan-types';
21
+
22
+ type Classification =
23
+ | { readonly kind: 'managed'; readonly ref: BrokeredTokenRef }
24
+ | { readonly kind: 'malformed' }
25
+ | { readonly kind: 'unmanaged' }
26
+ | { readonly kind: 'brokered-elsewhere'; readonly repo: string }
27
+ | { readonly kind: 'ignored' };
28
+
29
+ const classifyAccountToken = (
30
+ token: CloudflareToken,
31
+ repo: string,
32
+ ): Classification => {
33
+ const ref = parseTokenName(token.name, repo);
34
+ if (ref !== null) {
35
+ return { kind: 'managed', ref };
36
+ }
37
+ // An expired token grants nothing, and listing years of them would bury the
38
+ // live ones. Brokered tokens keep their own expiry handling in creds-plan.
39
+ if (token.status === 'expired') {
40
+ return { kind: 'ignored' };
41
+ }
42
+ if (token.name.startsWith(repoTokenPrefix(repo))) {
43
+ return { kind: 'malformed' };
44
+ }
45
+ // A brokered name carries the repository that owns it, which normally
46
+ // reconciles it. That premise fails exactly once: rename, transfer, or delete
47
+ // that repository and no checkout resolves to the old name, so nothing renews
48
+ // the token, nothing revokes it, and no repository would ever mention it.
49
+ // Every account sharer reporting it is the price of the orphan being seen at
50
+ // all. It stays out of the unmanaged bucket, which asserts something else —
51
+ // that the broker mints nothing of this shape anywhere.
52
+ const elsewhere = parseAnyTokenName(token.name);
53
+ if (elsewhere !== null) {
54
+ return { kind: 'brokered-elsewhere', repo: elsewhere.repo };
55
+ }
56
+ return isInMintedNamespace(token.name)
57
+ ? { kind: 'ignored' }
58
+ : { kind: 'unmanaged' };
59
+ };
60
+
61
+ export type ManagedTokenRef = AccountToken & {
62
+ readonly ref: BrokeredTokenRef;
63
+ };
64
+
65
+ export type TokenPartition = {
66
+ readonly managed: ReadonlyArray<ManagedTokenRef>;
67
+ readonly unmanaged: ReadonlyArray<UnmanagedToken>;
68
+ readonly brokeredElsewhere: ReadonlyArray<BrokeredElsewhereToken>;
69
+ readonly findings: ReadonlyArray<string>;
70
+ };
71
+
72
+ const reported = (entry: AccountToken): UnmanagedToken => ({
73
+ accountId: entry.accountId,
74
+ tokenId: entry.token.id,
75
+ name: entry.token.name,
76
+ status: entry.token.status,
77
+ });
78
+
79
+ export const partitionAccountTokens = (
80
+ tokens: ReadonlyArray<AccountToken>,
81
+ repo: string,
82
+ ): TokenPartition => {
83
+ const managed: Array<ManagedTokenRef> = [];
84
+ const unmanaged: Array<UnmanagedToken> = [];
85
+ const brokeredElsewhere: Array<BrokeredElsewhereToken> = [];
86
+ const findings: Array<string> = [];
87
+ for (const entry of tokens) {
88
+ const classification = classifyAccountToken(entry.token, repo);
89
+ if (classification.kind === 'managed') {
90
+ managed.push({ ...entry, ref: classification.ref });
91
+ } else if (classification.kind === 'malformed') {
92
+ findings.push(
93
+ `${entry.token.name} claims this repository's brokered namespace but is not a name this broker mints; rename it in the dashboard or retire it with \`standards creds revoke --account ${entry.accountId} --token-id ${entry.token.id}\``,
94
+ );
95
+ } else if (classification.kind === 'unmanaged') {
96
+ unmanaged.push(reported(entry));
97
+ } else if (classification.kind === 'brokered-elsewhere') {
98
+ brokeredElsewhere.push({ ...reported(entry), repo: classification.repo });
99
+ }
100
+ }
101
+ return { managed, unmanaged, brokeredElsewhere, findings };
102
+ };
package/src/creds-plan.ts CHANGED
@@ -3,23 +3,28 @@
3
3
  // naming scheme). A brokered token whose secret key vanished from SOPS is
4
4
  // revoked; one nearing expiry is replaced while copying its live policy and
5
5
  // lifetime. Secret keys without a brokered token are simply unmanaged — most
6
- // secrets are — and are never touched. Execution lives in creds-plan-run.ts.
6
+ // secrets are — and are never touched. Tokens the broker did not mint, and
7
+ // tokens it minted for another repository, are reported
8
+ // (creds-plan-unmanaged.ts) but never mutated. Execution lives in
9
+ // creds-plan-run.ts.
7
10
 
8
11
  import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
9
- import { parseTokenName } from './creds-naming';
10
12
  import { groupByIntersectingFootprint } from './creds-plan-groups';
11
13
  import type {
12
14
  AccountToken,
13
15
  CredsPlan,
14
16
  PlannedAction,
15
17
  } from './creds-plan-types';
18
+ import {
19
+ type ManagedTokenRef,
20
+ partitionAccountTokens,
21
+ } from './creds-plan-unmanaged';
16
22
  import { destinationFormatOf, inferredDestinationFootprint } from './creds-r2';
17
23
 
18
24
  const DEFAULT_RENEW_WITHIN_DAYS = 30;
19
25
  const DAY_MS = 86_400_000;
20
26
 
21
- type ManagedToken = AccountToken & {
22
- readonly ref: { readonly target: string; readonly key: string };
27
+ type ManagedToken = ManagedTokenRef & {
23
28
  readonly footprint: ReadonlyArray<string>;
24
29
  };
25
30
 
@@ -145,23 +150,16 @@ export const computeCredsPlan = (input: {
145
150
  const renewWithin =
146
151
  (input.renewWithinDays ?? DEFAULT_RENEW_WITHIN_DAYS) * DAY_MS;
147
152
  const actions: Array<PlannedAction> = [];
148
- const findings: Array<string> = [];
153
+ const partition = partitionAccountTokens(input.tokens, input.repo);
154
+ const findings: Array<string> = [...partition.findings];
149
155
  let healthy = 0;
150
- const managed = input.tokens.flatMap((entry) => {
151
- const ref = parseTokenName(entry.token.name, input.repo);
152
- return ref === null
153
- ? []
154
- : [
155
- {
156
- ...entry,
157
- ref,
158
- footprint: inferredDestinationFootprint(
159
- input.keysByTarget.get(ref.target),
160
- ref.key,
161
- ),
162
- },
163
- ];
164
- });
156
+ const managed = partition.managed.map((entry) => ({
157
+ ...entry,
158
+ footprint: inferredDestinationFootprint(
159
+ input.keysByTarget.get(entry.ref.target),
160
+ entry.ref.key,
161
+ ),
162
+ }));
165
163
  const dispositions = groupByIntersectingFootprint(managed).map((group) =>
166
164
  dispositionForGroup(group, input, renewWithin),
167
165
  );
@@ -174,5 +172,11 @@ export const computeCredsPlan = (input: {
174
172
  healthy += 1;
175
173
  }
176
174
  }
177
- return { actions, findings, healthy };
175
+ return {
176
+ actions,
177
+ findings,
178
+ healthy,
179
+ unmanaged: partition.unmanaged,
180
+ brokeredElsewhere: partition.brokeredElsewhere,
181
+ };
178
182
  };