@davidvornholt/standards 0.14.0 → 0.15.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
@@ -16,6 +16,8 @@ standards github --apply
16
16
  standards creds login github
17
17
  standards creds login cloudflare
18
18
  standards creds add cloudflare --dest ci:ci.cloudflare_workers_token --permissions "Workers Scripts Write"
19
+ standards creds add cloudflare --dest ci:ci.r2_read --bucket assets --s3 --permissions "Workers R2 Storage Bucket Item Read"
20
+ standards creds add cloudflare --dest ci:ci.eu_r2_read --bucket eu-assets --jurisdiction eu --s3 --permissions "Workers R2 Storage Bucket Item Read"
19
21
  standards creds add github --dest ci:ci.broker_app
20
22
  standards creds plan
21
23
  standards creds apply
@@ -33,9 +35,9 @@ standards poller --print-units --config <path>
33
35
 
34
36
  `structure` enforces the canonical monorepo structure contract: workspace scripts (`check-types`, `lint`, `lint:fix`, `test`), root gate scripts, filtered Turbo convenience aliases, internal `0.0.0`/`workspace:*` versioning, package `exports`, shared tsconfig inheritance, and browser a11y wiring. Only a workspace containing an explicit `*.a11y.ts` suite must provide a `test:a11y` script and direct `@axe-core/playwright` and `@playwright/test` dependencies. `check` includes `structure`, so these rules gate every consumer PR.
35
37
 
36
- `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. 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. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. The run works in a throwaway git worktree, hands results back through a structured outcome file, and the poller verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
38
+ `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. 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. The poller removes its direct GitHub token variables before launching Codex, but the approved run shares the host service identity and can read its other ambient credentials and logged-in tool state; that accepted visibility must not be treated as credential isolation. 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, hands results back through a structured outcome file, and then verifies effects (commits exist, no protected paths touched) before pushing, opening the draft PR, or posting the review — the required CI checks on the resulting PR remain the quality gate. Host mutation is declarative-only: `--print-units` emits a correctly escaped systemd service and timer for the polling host's infrastructure repository to adapt and deploy. That repository owns the service identity, writable HOME, PATH, GitHub token environment, lingering, and deployment. The service's tick budget (`TimeoutStartSec`) is derived from `maxJobsPerTick` × `runTimeoutMinutes` plus overhead, so re-render and redeploy the units after changing either value.
37
39
 
38
- `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 policy, 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-scoped groups such as DNS Write are rejected because the current command supplies an account resource, not a zone resource. `add github` writes the broker GitHub App's `app_id` and `private_key` under the destination key; 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. Bootstrap is once per machine: `login github` runs the App manifest flow (a localhost page posts the pre-filled manifest; one "Create GitHub App" click; credentials return through the conversion endpoint — nothing is copied by hand) and opens the installation page, where the App must be bound to selected repositories rather than all repositories; `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 `github.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.
40
+ `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 policy, 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-scoped groups such as DNS Write are rejected because the current command supplies an account resource, not a zone resource. `--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` writes the broker GitHub App's `app_id` and `private_key` under the destination key; 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: `login github` runs the App manifest flow (a localhost page posts the pre-filled manifest; one "Create GitHub App" click; credentials return through the conversion endpoint — nothing is copied by hand) and opens the installation page, where the App must be bound to selected repositories rather than all repositories; `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 `github.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.
39
41
 
40
42
  `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.
41
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@davidvornholt/standards",
3
- "version": "0.14.0",
3
+ "version": "0.15.0",
4
4
  "description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -21,8 +21,10 @@
21
21
  "README.md",
22
22
  "src/cli.ts",
23
23
  "src/creds-add.ts",
24
+ "src/creds-add-collision.ts",
24
25
  "src/creds-add-cloudflare-commit.ts",
25
26
  "src/creds-add-github.ts",
27
+ "src/creds-add-policy.ts",
26
28
  "src/creds-browser.ts",
27
29
  "src/creds-cloudflare-account.ts",
28
30
  "src/creds-cloudflare.ts",
@@ -30,15 +32,20 @@
30
32
  "src/creds-cloudflare-condition.ts",
31
33
  "src/creds-cloudflare-pagination.ts",
32
34
  "src/creds-commands.ts",
35
+ "src/creds-args.ts",
33
36
  "src/creds-dest.ts",
34
37
  "src/creds-login-cloudflare.ts",
35
38
  "src/creds-login-github.ts",
36
39
  "src/creds-login-github-manifest.ts",
37
40
  "src/creds-naming.ts",
38
41
  "src/creds-plan.ts",
42
+ "src/creds-plan-groups.ts",
43
+ "src/creds-plan-renew.ts",
39
44
  "src/creds-plan-run.ts",
40
45
  "src/creds-plan-revoke.ts",
46
+ "src/creds-plan-types.ts",
41
47
  "src/creds-prompt.ts",
48
+ "src/creds-r2.ts",
42
49
  "src/creds-sops.ts",
43
50
  "src/creds-sops-editor.ts",
44
51
  "src/creds-sops-structure.ts",
@@ -1,11 +1,14 @@
1
1
  import { deleteAccountToken } from './creds-cloudflare';
2
- import { type SopsWriteResult, verifySopsStoredValue } from './creds-sops';
2
+ import {
3
+ type SopsValueChange,
4
+ type SopsWriteResult,
5
+ verifySopsStoredValue,
6
+ } from './creds-sops';
3
7
 
4
8
  export const commitCreatedCloudflareToken = async (input: {
5
9
  readonly consumer: string;
6
10
  readonly rel: string;
7
- readonly key: string;
8
- readonly value: string;
11
+ readonly writes: ReadonlyArray<SopsValueChange>;
9
12
  readonly written: SopsWriteResult;
10
13
  readonly accountId: string;
11
14
  readonly bootstrapToken: string;
@@ -14,23 +17,33 @@ export const commitCreatedCloudflareToken = async (input: {
14
17
  }): Promise<
15
18
  { readonly ok: true } | { readonly ok: false; readonly problem: string }
16
19
  > => {
17
- const stored = verifySopsStoredValue(
18
- input.consumer,
19
- input.rel,
20
- input.key,
21
- input.value,
22
- );
23
- if (stored.ok && stored.matches) {
20
+ const stored = input.writes.map((write) => ({
21
+ path: write.path,
22
+ result: verifySopsStoredValue(
23
+ input.consumer,
24
+ input.rel,
25
+ write.path,
26
+ write.value,
27
+ ),
28
+ }));
29
+ if (stored.every(({ result }) => result.ok && result.matches)) {
24
30
  return { ok: true };
25
31
  }
26
- if (!stored.ok) {
32
+ const unverifiable = stored.flatMap(({ result }) =>
33
+ result.ok ? [] : [result.problem],
34
+ );
35
+ if (unverifiable.length > 0) {
36
+ const detail = unverifiable.join('; ');
27
37
  return {
28
38
  ok: false,
29
- problem: `token ${input.name} was created as ${input.accountId}/${input.tokenId}, but ${input.written.ok ? stored.problem : `${input.written.problem}; ${stored.problem}`}; the token remains active because its stored state is unverifiable`,
39
+ problem: `token ${input.name} was created as ${input.accountId}/${input.tokenId}, but ${input.written.ok ? detail : `${input.written.problem}; ${detail}`}; the token remains active because its stored state is unverifiable`,
30
40
  };
31
41
  }
42
+ const mismatched = stored.flatMap(({ path, result }) =>
43
+ result.ok && !result.matches ? [path] : [],
44
+ );
32
45
  const problem = input.written.ok
33
- ? 'the stored SOPS value does not match the replacement'
46
+ ? `the stored SOPS value at ${mismatched.join(', ')} does not match the replacement`
34
47
  : input.written.problem;
35
48
  const cleanup = await deleteAccountToken(
36
49
  input.accountId,
@@ -0,0 +1,46 @@
1
+ import { listAccountTokens } from './creds-cloudflare';
2
+ import type { ResolvedContext } from './creds-dest';
3
+ import { parseTokenName } from './creds-naming';
4
+ import {
5
+ type DestinationFormat,
6
+ destinationFootprint,
7
+ destinationFootprintsIntersect,
8
+ inferredDestinationFootprint,
9
+ } from './creds-r2';
10
+
11
+ export const findManagedDestinationCollision = async (
12
+ context: ResolvedContext,
13
+ format: DestinationFormat,
14
+ keys: ReadonlySet<string>,
15
+ ): Promise<string | null> => {
16
+ const listings = await Promise.all(
17
+ context.store.cloudflare.map(async (configured) => ({
18
+ accountId: configured.accountId,
19
+ listed: await listAccountTokens(configured.accountId, configured.token),
20
+ })),
21
+ );
22
+ const failed = listings.find(({ listed }) => !listed.ok);
23
+ if (failed?.listed.ok === false) {
24
+ return `account ${failed.accountId}: ${failed.listed.problem}; cannot prove the destination is unambiguous`;
25
+ }
26
+ const wanted = destinationFootprint(format, context.dest.key);
27
+ const collisions = listings.flatMap(({ accountId, listed }) =>
28
+ listed.ok
29
+ ? listed.value.flatMap((token) => {
30
+ const ref = parseTokenName(token.name, context.repo);
31
+ return ref !== null &&
32
+ ref.target === context.dest.target &&
33
+ (ref.key === context.dest.key ||
34
+ destinationFootprintsIntersect(
35
+ wanted,
36
+ inferredDestinationFootprint(keys, ref.key),
37
+ ))
38
+ ? [`${accountId}/${token.id} (${ref.key})`]
39
+ : [];
40
+ })
41
+ : [],
42
+ );
43
+ return collisions.length === 0
44
+ ? null
45
+ : `managed Cloudflare token already exists at ${collisions.join(', ')} and conflicts with ${context.dest.target}:${context.dest.key} by exact identity or destination footprint; one SOPS destination may be managed by only one account`;
46
+ };
@@ -0,0 +1,118 @@
1
+ // Permission-group and token-policy resolution for `standards creds add
2
+ // cloudflare`. The scope gates are cheap preconditions mirroring provider
3
+ // ground truth: a policy resource only accepts permission groups whose
4
+ // reported scopes include it, so incompatible selections fail here with the
5
+ // group names instead of failing at token creation.
6
+
7
+ import { listPermissionGroups } from './creds-cloudflare';
8
+ import type { PermissionGroup, TokenPolicy } from './creds-cloudflare-api';
9
+ import {
10
+ DEFAULT_R2_JURISDICTION,
11
+ isR2BucketName,
12
+ R2_BUCKET_SCOPE,
13
+ type R2Jurisdiction,
14
+ r2BucketResource,
15
+ } from './creds-r2';
16
+ import type { CloudflareBrokerAccount } from './creds-store';
17
+
18
+ const ACCOUNT_SCOPE = 'com.cloudflare.api.account';
19
+
20
+ export const unsupportedResourceScopes = (
21
+ groups: ReadonlyArray<PermissionGroup>,
22
+ scope: string,
23
+ ): ReadonlyArray<string> =>
24
+ groups
25
+ .filter((group) => !group.scopes.includes(scope))
26
+ .map((group) => group.name);
27
+
28
+ const scopeProblem = (
29
+ bucket: string | undefined,
30
+ selected: ReadonlyArray<PermissionGroup>,
31
+ ): string | null => {
32
+ const scope = bucket === undefined ? ACCOUNT_SCOPE : R2_BUCKET_SCOPE;
33
+ const unsupported = unsupportedResourceScopes(selected, scope);
34
+ if (unsupported.length === 0) {
35
+ return null;
36
+ }
37
+ return bucket === undefined
38
+ ? `permission group(s) ${unsupported.join(', ')} cannot target an account resource; choose account-scoped groups (zone-scoped groups require an explicit zone resource, which this command does not yet support, and R2 bucket-item groups require --bucket)`
39
+ : `permission group(s) ${unsupported.join(', ')} cannot target an R2 bucket resource; --bucket accepts only bucket-scoped groups such as Workers R2 Storage Bucket Item Read/Write`;
40
+ };
41
+
42
+ export type ResolvedTokenPolicy =
43
+ | {
44
+ readonly ok: true;
45
+ readonly policy: TokenPolicy;
46
+ readonly wanted: ReadonlyArray<string>;
47
+ }
48
+ | { readonly ok: false; readonly problem: string };
49
+
50
+ export const resolveTokenPolicy = async (
51
+ account: CloudflareBrokerAccount,
52
+ options: {
53
+ readonly permissions: string | undefined;
54
+ readonly bucket: string | undefined;
55
+ readonly jurisdiction?: R2Jurisdiction;
56
+ },
57
+ ): Promise<ResolvedTokenPolicy> => {
58
+ if (options.permissions === undefined || options.permissions.length === 0) {
59
+ return {
60
+ ok: false,
61
+ problem:
62
+ '--permissions "<Group Name>[,<Group Name>...]" is required; list names with `standards creds permissions`',
63
+ };
64
+ }
65
+ if (options.bucket !== undefined && !isR2BucketName(options.bucket)) {
66
+ return {
67
+ ok: false,
68
+ problem: `invalid R2 bucket name: ${options.bucket} (3-63 lowercase letters, digits, and hyphens)`,
69
+ };
70
+ }
71
+ const groups = await listPermissionGroups(account.accountId, account.token);
72
+ if (!groups.ok) {
73
+ return { ok: false, problem: groups.problem };
74
+ }
75
+ const wanted = options.permissions
76
+ .split(',')
77
+ .map((groupName) => groupName.trim());
78
+ const resolved = wanted.map((name) => ({
79
+ name,
80
+ group: groups.value.find(
81
+ (group) => group.name.toLowerCase() === name.toLowerCase(),
82
+ ),
83
+ }));
84
+ const unknown = resolved.flatMap(({ name, group }) =>
85
+ group === undefined ? [name] : [],
86
+ );
87
+ if (unknown.length > 0) {
88
+ return {
89
+ ok: false,
90
+ problem: `unknown permission group(s): ${unknown.join(', ')}; list names with \`standards creds permissions\``,
91
+ };
92
+ }
93
+ const selected = resolved.flatMap(({ group }) =>
94
+ group === undefined ? [] : [group],
95
+ );
96
+ const problem = scopeProblem(options.bucket, selected);
97
+ if (problem !== null) {
98
+ return { ok: false, problem };
99
+ }
100
+ const resource =
101
+ options.bucket === undefined
102
+ ? `${ACCOUNT_SCOPE}.${account.accountId}`
103
+ : r2BucketResource(
104
+ account.accountId,
105
+ options.bucket,
106
+ options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
107
+ );
108
+ return {
109
+ ok: true,
110
+ wanted,
111
+ policy: {
112
+ effect: 'allow',
113
+ resources: { [resource]: '*' },
114
+ // biome-ignore lint/style/useNamingConvention: Cloudflare's policy wire field is snake_case.
115
+ permission_groups: selected.map(({ id }) => ({ id })),
116
+ },
117
+ };
118
+ };
package/src/creds-add.ts CHANGED
@@ -1,58 +1,60 @@
1
1
  import { commitCreatedCloudflareToken } from './creds-add-cloudflare-commit';
2
- import {
3
- createAccountToken,
4
- listAccountTokens,
5
- listPermissionGroups,
6
- } from './creds-cloudflare';
7
- import type { PermissionGroup } from './creds-cloudflare-api';
2
+ import { findManagedDestinationCollision } from './creds-add-collision';
3
+ import { resolveTokenPolicy } from './creds-add-policy';
4
+ import { createAccountToken } from './creds-cloudflare';
8
5
  import { resolveContext, selectAccount } from './creds-dest';
9
6
  import { tokenNameOf } from './creds-naming';
10
- import { inspectSopsScalarDestination, setSopsValue } from './creds-sops';
7
+ import {
8
+ DEFAULT_R2_JURISDICTION,
9
+ type DestinationFormat,
10
+ destinationWrites,
11
+ type R2Jurisdiction,
12
+ s3Endpoint,
13
+ s3PairPaths,
14
+ } from './creds-r2';
15
+ import {
16
+ inspectSopsScalarDestination,
17
+ readEncryptedKeys,
18
+ setSopsValues,
19
+ } from './creds-sops';
11
20
 
12
21
  const DEFAULT_TTL_DAYS = 90;
13
22
  const DAY_MS = 86_400_000;
14
- const ACCOUNT_SCOPE = 'com.cloudflare.api.account';
15
23
 
16
- export const unsupportedAccountScopes = (
17
- groups: ReadonlyArray<PermissionGroup>,
18
- ): ReadonlyArray<string> =>
19
- groups
20
- .filter((group) => !group.scopes.includes(ACCOUNT_SCOPE))
21
- .map((group) => group.name);
22
-
23
- const resolveWantedGroups = (
24
- names: ReadonlyArray<string>,
25
- available: ReadonlyArray<PermissionGroup>,
26
- ): {
27
- readonly selected: ReadonlyArray<PermissionGroup>;
28
- readonly unknown: ReadonlyArray<string>;
29
- } => {
30
- const resolved = names.map((name) => ({
31
- name,
32
- group: available.find(
33
- (group) => group.name.toLowerCase() === name.toLowerCase(),
34
- ),
35
- }));
36
- return {
37
- selected: resolved.flatMap(({ group }) =>
38
- group === undefined ? [] : [group],
39
- ),
40
- unknown: resolved.flatMap(({ name, group }) =>
41
- group === undefined ? [name] : [],
42
- ),
43
- };
24
+ const printSuccess = (input: {
25
+ readonly name: string;
26
+ readonly permissions: ReadonlyArray<string>;
27
+ readonly expiresOn: string;
28
+ readonly destination: string;
29
+ readonly format: DestinationFormat;
30
+ readonly accountId: string;
31
+ readonly jurisdiction: R2Jurisdiction;
32
+ }): void => {
33
+ console.log(`standards creds: minted Cloudflare token ${input.name}`);
34
+ console.log(` permissions: ${input.permissions.join(', ')}`);
35
+ console.log(
36
+ ` expires: ${input.expiresOn} (rotate via \`standards creds apply\`)`,
37
+ );
38
+ console.log(
39
+ ` ${input.format === 's3' ? 'derived S3 credential pair' : 'value'} written to ${input.destination}`,
40
+ );
41
+ if (input.format === 's3') {
42
+ console.log(
43
+ ` S3 endpoint: ${s3Endpoint(input.accountId, input.jurisdiction)}`,
44
+ );
45
+ }
44
46
  };
45
47
 
46
- const printSuccess = (
47
- name: string,
48
- permissions: ReadonlyArray<string>,
49
- expiresOn: string,
50
- destination: string,
51
- ): void => {
52
- console.log(`standards creds: minted Cloudflare token ${name}`);
53
- console.log(` permissions: ${permissions.join(', ')}`);
54
- console.log(` expires: ${expiresOn} (rotate via \`standards creds apply\`)`);
55
- console.log(` value written to ${destination}`);
48
+ const inspectDestinations = async (
49
+ consumer: string,
50
+ rel: string,
51
+ paths: ReadonlyArray<string>,
52
+ ): Promise<string | null> => {
53
+ const inspected = await Promise.all(
54
+ paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
55
+ );
56
+ const blocked = inspected.find((result) => !result.ok);
57
+ return blocked !== undefined && !blocked.ok ? blocked.problem : null;
56
58
  };
57
59
 
58
60
  export const runCredsAddCloudflare = async (
@@ -62,76 +64,56 @@ export const runCredsAddCloudflare = async (
62
64
  readonly permissions: string | undefined;
63
65
  readonly account: string | undefined;
64
66
  readonly ttlDays: number | undefined;
67
+ readonly bucket: string | undefined;
68
+ readonly jurisdiction?: R2Jurisdiction;
69
+ readonly s3: boolean;
65
70
  },
66
71
  ): Promise<boolean> => {
67
- const context = await resolveContext(consumer, options.dest);
68
- if (context === null) {
69
- return false;
70
- }
71
- if (options.permissions === undefined || options.permissions.length === 0) {
72
+ if (options.s3 && options.bucket === undefined) {
72
73
  console.error(
73
- 'standards creds: --permissions "<Group Name>[,<Group Name>...]" is required; list names with `standards creds permissions`',
74
+ 'standards creds: --s3 requires --bucket so the credential is backed by a bucket-scoped R2 policy',
74
75
  );
75
76
  return false;
76
77
  }
78
+ const context = await resolveContext(consumer, options.dest);
79
+ if (context === null) {
80
+ return false;
81
+ }
77
82
  const account = selectAccount(context.store, options.account);
78
83
  if (account === null) {
79
84
  return false;
80
85
  }
81
- const destination = await inspectSopsScalarDestination(
86
+ const format: DestinationFormat = options.s3 ? 's3' : 'bearer';
87
+ const paths =
88
+ format === 's3' ? s3PairPaths(context.dest.key) : [context.dest.key];
89
+ const destinationProblem = await inspectDestinations(
82
90
  consumer,
83
91
  context.rel,
84
- context.dest.key,
92
+ paths,
85
93
  );
86
- if (!destination.ok) {
87
- console.error(`standards creds: ${destination.problem}`);
88
- return false;
89
- }
90
- const groups = await listPermissionGroups(account.accountId, account.token);
91
- if (!groups.ok) {
92
- console.error(`standards creds: ${groups.problem}`);
94
+ if (destinationProblem !== null) {
95
+ console.error(`standards creds: ${destinationProblem}`);
93
96
  return false;
94
97
  }
95
- const wanted = options.permissions
96
- .split(',')
97
- .map((groupName) => groupName.trim());
98
- const { selected, unknown } = resolveWantedGroups(wanted, groups.value);
99
- if (unknown.length > 0) {
100
- console.error(
101
- `standards creds: unknown permission group(s): ${unknown.join(', ')}; list names with \`standards creds permissions\``,
102
- );
98
+ const encryptedKeys = await readEncryptedKeys(consumer, context.rel);
99
+ if (!encryptedKeys.ok) {
100
+ console.error(`standards creds: ${encryptedKeys.problem}`);
103
101
  return false;
104
102
  }
105
- const unsupported = unsupportedAccountScopes(selected);
106
- if (unsupported.length > 0) {
107
- console.error(
108
- `standards creds: permission group(s) ${unsupported.join(', ')} cannot target an account resource; choose account-scoped groups (zone-scoped groups require an explicit zone resource, which this command does not yet support)`,
109
- );
103
+ const keys = new Set(encryptedKeys.keys);
104
+ const resolved = await resolveTokenPolicy(account, options);
105
+ if (!resolved.ok) {
106
+ console.error(`standards creds: ${resolved.problem}`);
110
107
  return false;
111
108
  }
112
109
  const name = tokenNameOf({ ...context.dest, repo: context.repo });
113
- const listings = await Promise.all(
114
- context.store.cloudflare.map(async (configured) => ({
115
- accountId: configured.accountId,
116
- listed: await listAccountTokens(configured.accountId, configured.token),
117
- })),
110
+ const collisionProblem = await findManagedDestinationCollision(
111
+ context,
112
+ format,
113
+ keys,
118
114
  );
119
- const failedListing = listings.find(({ listed }) => !listed.ok);
120
- if (failedListing?.listed.ok === false) {
121
- console.error(
122
- `standards creds: account ${failedListing.accountId}: ${failedListing.listed.problem}; cannot prove the destination is unambiguous`,
123
- );
124
- return false;
125
- }
126
- const collisions = listings.flatMap(({ accountId, listed }) =>
127
- listed.ok && listed.value.some((token) => token.name === name)
128
- ? [accountId]
129
- : [],
130
- );
131
- if (collisions.length > 0) {
132
- console.error(
133
- `standards creds: token ${name} already exists in Cloudflare account(s) ${collisions.join(', ')}; one SOPS destination may be managed by only one account`,
134
- );
115
+ if (collisionProblem !== null) {
116
+ console.error(`standards creds: ${collisionProblem}`);
135
117
  return false;
136
118
  }
137
119
  const ttlDays = options.ttlDays ?? DEFAULT_TTL_DAYS;
@@ -140,29 +122,23 @@ export const runCredsAddCloudflare = async (
140
122
  name,
141
123
  expiresOn,
142
124
  condition: null,
143
- policies: [
144
- {
145
- effect: 'allow',
146
- resources: { [`com.cloudflare.api.account.${account.accountId}`]: '*' },
147
- permission_groups: selected.map(({ id }) => ({ id })),
148
- },
149
- ],
125
+ policies: [resolved.policy],
150
126
  });
151
127
  if (!created.ok) {
152
128
  console.error(`standards creds: ${created.problem}`);
153
129
  return false;
154
130
  }
155
- const written = setSopsValue(
156
- consumer,
157
- context.rel,
131
+ const writes = destinationWrites(
132
+ format,
158
133
  context.dest.key,
134
+ created.value.id,
159
135
  created.value.value,
160
136
  );
137
+ const written = setSopsValues(consumer, context.rel, writes);
161
138
  const committed = await commitCreatedCloudflareToken({
162
139
  consumer,
163
140
  rel: context.rel,
164
- key: context.dest.key,
165
- value: created.value.value,
141
+ writes,
166
142
  written,
167
143
  accountId: account.accountId,
168
144
  bootstrapToken: account.token,
@@ -173,11 +149,14 @@ export const runCredsAddCloudflare = async (
173
149
  console.error(`standards creds: ${committed.problem}`);
174
150
  return false;
175
151
  }
176
- printSuccess(
152
+ printSuccess({
177
153
  name,
178
- wanted,
154
+ permissions: resolved.wanted,
179
155
  expiresOn,
180
- `${context.rel} at ${context.dest.key}`,
181
- );
156
+ destination: `${context.rel} at ${context.dest.key}`,
157
+ format,
158
+ accountId: account.accountId,
159
+ jurisdiction: options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
160
+ });
182
161
  return true;
183
162
  };