@davidvornholt/standards 0.18.0 → 0.20.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.
Files changed (38) hide show
  1. package/README.md +5 -4
  2. package/package.json +12 -1
  3. package/src/cli.ts +283 -73
  4. package/src/creds-add-cloudflare-commit.ts +2 -5
  5. package/src/creds-add-github.ts +82 -30
  6. package/src/creds-add-policy.ts +106 -26
  7. package/src/creds-add-preflight.ts +68 -0
  8. package/src/creds-add.ts +29 -20
  9. package/src/creds-args.ts +5 -0
  10. package/src/creds-cloudflare-zone.ts +40 -0
  11. package/src/creds-commands.ts +21 -9
  12. package/src/creds-github-app-api.ts +132 -0
  13. package/src/creds-github-apps.ts +107 -0
  14. package/src/creds-login-github-manifest.ts +38 -15
  15. package/src/creds-login-github.ts +78 -24
  16. package/src/creds-plan-renew.ts +2 -5
  17. package/src/creds-sops-value.ts +89 -0
  18. package/src/creds-sops.ts +0 -53
  19. package/src/creds-store-github.ts +121 -0
  20. package/src/creds-store-lock-holder.ts +98 -0
  21. package/src/creds-store-lock-inspection.ts +125 -0
  22. package/src/creds-store-lock-lease.ts +32 -0
  23. package/src/creds-store-lock.ts +27 -51
  24. package/src/creds-store.ts +14 -43
  25. package/src/github-command-shared.ts +8 -1
  26. package/src/github-live-drift.ts +3 -3
  27. package/src/managed-files.ts +116 -14
  28. package/src/managed-path-ownership.ts +85 -0
  29. package/src/poller-closing-reference.ts +179 -0
  30. package/src/poller-fix-outcome.ts +9 -0
  31. package/src/poller-fix-publication.ts +5 -12
  32. package/src/poller-fix-run.ts +2 -3
  33. package/src/poller-fix-validation.ts +7 -8
  34. package/src/poller-outcome.ts +4 -1
  35. package/src/poller-protected-paths.ts +1 -25
  36. package/src/poller-protocol.ts +0 -42
  37. package/src/poller-review-publication.ts +2 -8
  38. package/src/poller-review-validation.ts +5 -12
@@ -4,12 +4,18 @@
4
4
  // identity itself; workflows mint short-lived installation tokens from it at
5
5
  // runtime, scoped per repository and permission.
6
6
 
7
+ import { inspectDestinations } from './creds-add-preflight';
7
8
  import { resolveContext } from './creds-dest';
9
+ import { verifyGithubAppInstallation } from './creds-github-app-api';
8
10
  import {
9
- inspectSopsScalarDestination,
10
- setSopsValues,
11
- verifySopsScalarLeaf,
12
- } from './creds-sops';
11
+ loadOwnedGithubStore,
12
+ sameGithubApp,
13
+ selectGithubAppForRepo,
14
+ } from './creds-github-apps';
15
+ import { setSopsValues } from './creds-sops';
16
+ import { verifySopsStoredValue } from './creds-sops-value';
17
+ import { readBrokerStore, resolveBrokerPath } from './creds-store';
18
+ import { withBrokerLock } from './creds-store-lock';
13
19
 
14
20
  export const runCredsAddGithub = async (
15
21
  consumer: string,
@@ -19,41 +25,87 @@ export const runCredsAddGithub = async (
19
25
  if (context === null) {
20
26
  return false;
21
27
  }
22
- if (context.store.github === null) {
23
- console.error(
24
- 'standards creds: no broker GitHub App configured; run `standards creds login github`',
25
- );
26
- return false;
27
- }
28
- const { appId, privateKey, slug } = context.store.github;
29
28
  const appIdPath = `${context.dest.key}.app_id`;
30
29
  const privateKeyPath = `${context.dest.key}.private_key`;
31
- const preflight = await Promise.all(
32
- [appIdPath, privateKeyPath].map((path) =>
33
- inspectSopsScalarDestination(consumer, context.rel, path),
34
- ),
30
+ const blocked = await inspectDestinations(consumer, context.rel, [
31
+ appIdPath,
32
+ privateKeyPath,
33
+ ]);
34
+ if (blocked !== null) {
35
+ console.error(`standards creds: ${blocked}`);
36
+ return false;
37
+ }
38
+ const storePath = resolveBrokerPath();
39
+ const loaded = await loadOwnedGithubStore(storePath);
40
+ if (!loaded.ok) {
41
+ console.error(`standards creds: ${loaded.problem}`);
42
+ return false;
43
+ }
44
+ const selected = selectGithubAppForRepo(loaded.value.github, context.repo);
45
+ if (!selected.ok) {
46
+ console.error(`standards creds: ${selected.problem}`);
47
+ return false;
48
+ }
49
+ const installation = await verifyGithubAppInstallation(
50
+ selected.value,
51
+ context.repo,
35
52
  );
36
- const blocked = preflight.find((result) => !result.ok);
37
- if (blocked !== undefined && !blocked.ok) {
38
- console.error(`standards creds: ${blocked.problem}`);
53
+ if (!installation.ok) {
54
+ console.error(`standards creds: ${installation.problem}`);
55
+ return false;
56
+ }
57
+ const { appId, privateKey, slug } = selected.value;
58
+ let writeResult:
59
+ | { readonly ok: true }
60
+ | { readonly ok: false; readonly problem: string };
61
+ try {
62
+ writeResult = await withBrokerLock(storePath, async () => {
63
+ const current = selectGithubAppForRepo(
64
+ (await readBrokerStore(storePath)).github,
65
+ context.repo,
66
+ );
67
+ if (!current.ok) {
68
+ return current;
69
+ }
70
+ if (!sameGithubApp(current.value, selected.value)) {
71
+ return {
72
+ ok: false,
73
+ problem: `the broker GitHub App for ${selected.value.owner} changed while its installation was being verified; retry so the replacement can be verified`,
74
+ };
75
+ }
76
+ return setSopsValues(consumer, context.rel, [
77
+ { path: appIdPath, value: String(appId) },
78
+ { path: privateKeyPath, value: privateKey },
79
+ ]);
80
+ });
81
+ } catch (error) {
82
+ const message = error instanceof Error ? error.message : String(error);
83
+ console.error(`standards creds: ${message}`);
84
+ return false;
85
+ }
86
+ if (!writeResult.ok) {
87
+ console.error(`standards creds: ${writeResult.problem}`);
39
88
  return false;
40
89
  }
41
- const written = setSopsValues(consumer, context.rel, [
90
+ const verified = [
42
91
  { path: appIdPath, value: String(appId) },
43
92
  { path: privateKeyPath, value: privateKey },
44
- ]);
45
- if (!written.ok) {
46
- console.error(`standards creds: ${written.problem}`);
93
+ ].map(({ path, value }) => ({
94
+ path,
95
+ result: verifySopsStoredValue(consumer, context.rel, path, value),
96
+ }));
97
+ const unverifiable = verified.find(({ result }) => !result.ok);
98
+ if (unverifiable !== undefined && !unverifiable.result.ok) {
99
+ console.error(`standards creds: ${unverifiable.result.problem}`);
47
100
  return false;
48
101
  }
49
- const verified = await Promise.all(
50
- [appIdPath, privateKeyPath].map((path) =>
51
- verifySopsScalarLeaf(consumer, context.rel, path),
52
- ),
53
- );
54
- const failedVerification = verified.find((result) => !result.ok);
55
- if (failedVerification !== undefined && !failedVerification.ok) {
56
- console.error(`standards creds: ${failedVerification.problem}`);
102
+ const mismatched = verified
103
+ .filter(({ result }) => result.ok && !result.matches)
104
+ .map(({ path }) => path);
105
+ if (mismatched.length > 0) {
106
+ console.error(
107
+ `standards creds: the stored SOPS value at ${mismatched.join(', ')} does not match the selected GitHub App`,
108
+ );
57
109
  return false;
58
110
  }
59
111
  console.log(
@@ -6,9 +6,8 @@
6
6
 
7
7
  import { listPermissionGroups } from './creds-cloudflare';
8
8
  import type { PermissionGroup, TokenPolicy } from './creds-cloudflare-api';
9
+ import { ZONE_SCOPE, zoneResource } from './creds-cloudflare-zone';
9
10
  import {
10
- DEFAULT_R2_JURISDICTION,
11
- isR2BucketName,
12
11
  R2_BUCKET_SCOPE,
13
12
  type R2Jurisdiction,
14
13
  r2BucketResource,
@@ -17,6 +16,16 @@ import type { CloudflareBrokerAccount } from './creds-store';
17
16
 
18
17
  const ACCOUNT_SCOPE = 'com.cloudflare.api.account';
19
18
 
19
+ const tokenPolicy = (
20
+ resources: TokenPolicy['resources'],
21
+ groups: ReadonlyArray<PermissionGroup>,
22
+ ): TokenPolicy => ({
23
+ effect: 'allow',
24
+ resources,
25
+ // biome-ignore lint/style/useNamingConvention: Cloudflare's policy wire field is snake_case.
26
+ permission_groups: groups.map(({ id }) => ({ id })),
27
+ });
28
+
20
29
  export const unsupportedResourceScopes = (
21
30
  groups: ReadonlyArray<PermissionGroup>,
22
31
  scope: string,
@@ -26,23 +35,99 @@ export const unsupportedResourceScopes = (
26
35
  .map((group) => group.name);
27
36
 
28
37
  const scopeProblem = (
29
- bucket: string | undefined,
38
+ resource: { readonly kind: 'account' | 'bucket' },
30
39
  selected: ReadonlyArray<PermissionGroup>,
31
40
  ): string | null => {
32
- const scope = bucket === undefined ? ACCOUNT_SCOPE : R2_BUCKET_SCOPE;
41
+ const scope = resource.kind === 'account' ? ACCOUNT_SCOPE : R2_BUCKET_SCOPE;
33
42
  const unsupported = unsupportedResourceScopes(selected, scope);
34
43
  if (unsupported.length === 0) {
35
44
  return null;
36
45
  }
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)`
46
+ return resource.kind === 'account'
47
+ ? `permission group(s) ${unsupported.join(', ')} cannot target an account resource; choose account-scoped groups, name the zones with --zone for zone-scoped groups, or pass --bucket for R2 bucket-item groups`
39
48
  : `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
49
  };
41
50
 
51
+ // With zones named, a token spans two resources, so the selection is split by
52
+ // what each group can actually target rather than checked against one scope.
53
+ // A group matching neither resource is the operator's mistake and is named.
54
+ // A group Cloudflare reports as both zone- and account-scoped goes to the zone
55
+ // policy: `--zone` asks for zone reach, and the narrower reading is the safe
56
+ // one to guess wrong.
57
+ const zonePolicies = (
58
+ accountId: string,
59
+ zoneIds: ReadonlyArray<string>,
60
+ selected: ReadonlyArray<PermissionGroup>,
61
+ ):
62
+ | { readonly policies: ReadonlyArray<TokenPolicy> }
63
+ | { readonly problem: string } => {
64
+ const zoneGroups = selected.filter((group) =>
65
+ group.scopes.includes(ZONE_SCOPE),
66
+ );
67
+ const accountGroups = selected.filter(
68
+ (group) =>
69
+ !group.scopes.includes(ZONE_SCOPE) &&
70
+ group.scopes.includes(ACCOUNT_SCOPE),
71
+ );
72
+ const unsupported = selected
73
+ .filter(
74
+ (group) =>
75
+ !(
76
+ group.scopes.includes(ZONE_SCOPE) ||
77
+ group.scopes.includes(ACCOUNT_SCOPE)
78
+ ),
79
+ )
80
+ .map((group) => group.name);
81
+ if (unsupported.length > 0) {
82
+ return {
83
+ problem: `permission group(s) ${unsupported.join(', ')} target neither a zone nor an account resource; R2 bucket-item groups require --bucket, which cannot be combined with --zone`,
84
+ };
85
+ }
86
+ if (zoneGroups.length === 0) {
87
+ return {
88
+ problem:
89
+ '--zone was given but no selected permission group is zone-scoped; drop --zone, or add a zone-scoped group such as DNS Write',
90
+ };
91
+ }
92
+ const zoneResources = Object.fromEntries(
93
+ zoneIds.map((zoneId) => [zoneResource(zoneId), '*']),
94
+ );
95
+ return {
96
+ policies: [
97
+ ...(accountGroups.length === 0
98
+ ? []
99
+ : [
100
+ tokenPolicy(
101
+ { [`${ACCOUNT_SCOPE}.${accountId}`]: '*' },
102
+ accountGroups,
103
+ ),
104
+ ]),
105
+ tokenPolicy(zoneResources, zoneGroups),
106
+ ],
107
+ };
108
+ };
109
+
110
+ // One token reaches exactly one kind of resource, so the caller states which
111
+ // rather than passing flags whose illegal combinations would have to be
112
+ // re-rejected here.
113
+ export type PolicyResource =
114
+ | { readonly kind: 'account' }
115
+ | {
116
+ readonly kind: 'bucket';
117
+ readonly bucket: string;
118
+ readonly jurisdiction: R2Jurisdiction;
119
+ }
120
+ | {
121
+ readonly kind: 'zones';
122
+ // Non-empty by type: a zone policy with no resource would be accepted by
123
+ // the wire shape and reach nothing.
124
+ readonly zoneIds: readonly [string, ...ReadonlyArray<string>];
125
+ };
126
+
42
127
  export type ResolvedTokenPolicy =
43
128
  | {
44
129
  readonly ok: true;
45
- readonly policy: TokenPolicy;
130
+ readonly policies: ReadonlyArray<TokenPolicy>;
46
131
  readonly wanted: ReadonlyArray<string>;
47
132
  }
48
133
  | { readonly ok: false; readonly problem: string };
@@ -51,10 +136,10 @@ export const resolveTokenPolicy = async (
51
136
  account: CloudflareBrokerAccount,
52
137
  options: {
53
138
  readonly permissions: string | undefined;
54
- readonly bucket: string | undefined;
55
- readonly jurisdiction?: R2Jurisdiction;
139
+ readonly resource: PolicyResource;
56
140
  },
57
141
  ): Promise<ResolvedTokenPolicy> => {
142
+ const { resource } = options;
58
143
  if (options.permissions === undefined || options.permissions.length === 0) {
59
144
  return {
60
145
  ok: false,
@@ -62,12 +147,6 @@ export const resolveTokenPolicy = async (
62
147
  '--permissions "<Group Name>[,<Group Name>...]" is required; list names with `standards creds permissions`',
63
148
  };
64
149
  }
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
150
  const groups = await listPermissionGroups(account.accountId, account.token);
72
151
  if (!groups.ok) {
73
152
  return { ok: false, problem: groups.problem };
@@ -93,26 +172,27 @@ export const resolveTokenPolicy = async (
93
172
  const selected = resolved.flatMap(({ group }) =>
94
173
  group === undefined ? [] : [group],
95
174
  );
96
- const problem = scopeProblem(options.bucket, selected);
175
+ if (resource.kind === 'zones') {
176
+ const zoned = zonePolicies(account.accountId, resource.zoneIds, selected);
177
+ return 'problem' in zoned
178
+ ? { ok: false, problem: zoned.problem }
179
+ : { ok: true, wanted, policies: zoned.policies };
180
+ }
181
+ const problem = scopeProblem(resource, selected);
97
182
  if (problem !== null) {
98
183
  return { ok: false, problem };
99
184
  }
100
- const resource =
101
- options.bucket === undefined
185
+ const target =
186
+ resource.kind === 'account'
102
187
  ? `${ACCOUNT_SCOPE}.${account.accountId}`
103
188
  : r2BucketResource(
104
189
  account.accountId,
105
- options.bucket,
106
- options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
190
+ resource.bucket,
191
+ resource.jurisdiction,
107
192
  );
108
193
  return {
109
194
  ok: true,
110
195
  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
- },
196
+ policies: [tokenPolicy({ [target]: '*' }, selected)],
117
197
  };
118
198
  };
@@ -0,0 +1,68 @@
1
+ // Everything `creds add cloudflare` can settle before it mints anything: which
2
+ // resource the token will reach, and whether each destination is a writable
3
+ // scalar leaf. Minting first and discovering these afterwards would leave a
4
+ // live credential nobody asked for.
5
+
6
+ import type { PolicyResource } from './creds-add-policy';
7
+ import { parseZoneArgument } from './creds-cloudflare-zone';
8
+ import {
9
+ DEFAULT_R2_JURISDICTION,
10
+ isR2BucketName,
11
+ type R2Jurisdiction,
12
+ } from './creds-r2';
13
+ import { inspectSopsScalarDestination } from './creds-sops';
14
+
15
+ export const inspectDestinations = async (
16
+ consumer: string,
17
+ rel: string,
18
+ paths: ReadonlyArray<string>,
19
+ ): Promise<string | null> => {
20
+ const inspected = await Promise.all(
21
+ paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
22
+ );
23
+ const blocked = inspected.find((result) => !result.ok);
24
+ return blocked !== undefined && !blocked.ok ? blocked.problem : null;
25
+ };
26
+
27
+ export type ResolvedResourceFlags =
28
+ | { readonly ok: true; readonly resource: PolicyResource }
29
+ | { readonly ok: false; readonly problem: string };
30
+
31
+ // A token reaches one kind of resource, so the flags naming it collapse to that
32
+ // one choice here — before any provider or SOPS work, because no rejection
33
+ // below can be salvaged afterwards.
34
+ export const resolveResourceFlags = (options: {
35
+ readonly bucket: string | undefined;
36
+ readonly zone: string | undefined;
37
+ readonly jurisdiction?: R2Jurisdiction;
38
+ }): ResolvedResourceFlags => {
39
+ if (options.bucket !== undefined && options.zone !== undefined) {
40
+ return {
41
+ ok: false,
42
+ problem:
43
+ '--bucket and --zone cannot be combined; an R2 bucket credential and a zone credential are separate tokens',
44
+ };
45
+ }
46
+ if (options.bucket !== undefined) {
47
+ return isR2BucketName(options.bucket)
48
+ ? {
49
+ ok: true,
50
+ resource: {
51
+ kind: 'bucket',
52
+ bucket: options.bucket,
53
+ jurisdiction: options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
54
+ },
55
+ }
56
+ : {
57
+ ok: false,
58
+ problem: `invalid R2 bucket name: ${options.bucket} (3-63 lowercase letters, digits, and hyphens)`,
59
+ };
60
+ }
61
+ if (options.zone === undefined) {
62
+ return { ok: true, resource: { kind: 'account' } };
63
+ }
64
+ const zones = parseZoneArgument(options.zone);
65
+ return zones.ok
66
+ ? { ok: true, resource: { kind: 'zones', zoneIds: zones.zoneIds } }
67
+ : { ok: false, problem: zones.problem };
68
+ };
package/src/creds-add.ts CHANGED
@@ -1,7 +1,12 @@
1
1
  import { commitCreatedCloudflareToken } from './creds-add-cloudflare-commit';
2
2
  import { findManagedDestinationCollision } from './creds-add-collision';
3
3
  import { resolveTokenPolicy } from './creds-add-policy';
4
+ import {
5
+ inspectDestinations,
6
+ resolveResourceFlags,
7
+ } from './creds-add-preflight';
4
8
  import { createAccountToken } from './creds-cloudflare';
9
+ import type { TokenPolicy } from './creds-cloudflare-api';
5
10
  import { cloudflareExpiresOn } from './creds-cloudflare-expiry';
6
11
  import { resolveContext, selectAccount } from './creds-dest';
7
12
  import { tokenNameOf } from './creds-naming';
@@ -13,11 +18,7 @@ import {
13
18
  s3Endpoint,
14
19
  s3PairPaths,
15
20
  } from './creds-r2';
16
- import {
17
- inspectSopsScalarDestination,
18
- readEncryptedKeys,
19
- setSopsValues,
20
- } from './creds-sops';
21
+ import { readEncryptedKeys, setSopsValues } from './creds-sops';
21
22
 
22
23
  const DEFAULT_TTL_DAYS = 90;
23
24
  const DAY_MS = 86_400_000;
@@ -30,9 +31,16 @@ const printSuccess = (input: {
30
31
  readonly format: DestinationFormat;
31
32
  readonly accountId: string;
32
33
  readonly jurisdiction: R2Jurisdiction;
34
+ readonly policies: ReadonlyArray<TokenPolicy>;
33
35
  }): void => {
34
36
  console.log(`standards creds: minted Cloudflare token ${input.name}`);
35
37
  console.log(` permissions: ${input.permissions.join(', ')}`);
38
+ // Read off the minted policies rather than the flags that asked for them:
39
+ // a selection can add an account policy the flags do not mention, and the
40
+ // reach of a token is the one thing worth stating from ground truth.
41
+ console.log(
42
+ ` resources: ${input.policies.flatMap((policy) => Object.keys(policy.resources)).join(', ')}`,
43
+ );
36
44
  console.log(
37
45
  ` expires: ${input.expiresOn} (rotate via \`standards creds apply\`)`,
38
46
  );
@@ -46,18 +54,6 @@ const printSuccess = (input: {
46
54
  }
47
55
  };
48
56
 
49
- const inspectDestinations = async (
50
- consumer: string,
51
- rel: string,
52
- paths: ReadonlyArray<string>,
53
- ): Promise<string | null> => {
54
- const inspected = await Promise.all(
55
- paths.map((path) => inspectSopsScalarDestination(consumer, rel, path)),
56
- );
57
- const blocked = inspected.find((result) => !result.ok);
58
- return blocked !== undefined && !blocked.ok ? blocked.problem : null;
59
- };
60
-
61
57
  export const runCredsAddCloudflare = async (
62
58
  consumer: string,
63
59
  options: {
@@ -66,6 +62,7 @@ export const runCredsAddCloudflare = async (
66
62
  readonly account: string | undefined;
67
63
  readonly ttlDays: number | undefined;
68
64
  readonly bucket: string | undefined;
65
+ readonly zone: string | undefined;
69
66
  readonly jurisdiction?: R2Jurisdiction;
70
67
  readonly s3: boolean;
71
68
  },
@@ -76,6 +73,11 @@ export const runCredsAddCloudflare = async (
76
73
  );
77
74
  return false;
78
75
  }
76
+ const flags = resolveResourceFlags(options);
77
+ if (!flags.ok) {
78
+ console.error(`standards creds: ${flags.problem}`);
79
+ return false;
80
+ }
79
81
  const context = await resolveContext(consumer, options.dest);
80
82
  if (context === null) {
81
83
  return false;
@@ -102,7 +104,10 @@ export const runCredsAddCloudflare = async (
102
104
  return false;
103
105
  }
104
106
  const keys = new Set(encryptedKeys.keys);
105
- const resolved = await resolveTokenPolicy(account, options);
107
+ const resolved = await resolveTokenPolicy(account, {
108
+ permissions: options.permissions,
109
+ resource: flags.resource,
110
+ });
106
111
  if (!resolved.ok) {
107
112
  console.error(`standards creds: ${resolved.problem}`);
108
113
  return false;
@@ -123,7 +128,7 @@ export const runCredsAddCloudflare = async (
123
128
  name,
124
129
  expiresOn,
125
130
  condition: null,
126
- policies: [resolved.policy],
131
+ policies: resolved.policies,
127
132
  });
128
133
  if (!created.ok) {
129
134
  console.error(`standards creds: ${created.problem}`);
@@ -157,7 +162,11 @@ export const runCredsAddCloudflare = async (
157
162
  destination: `${context.rel} at ${context.dest.key}`,
158
163
  format,
159
164
  accountId: account.accountId,
160
- jurisdiction: options.jurisdiction ?? DEFAULT_R2_JURISDICTION,
165
+ jurisdiction:
166
+ flags.resource.kind === 'bucket'
167
+ ? flags.resource.jurisdiction
168
+ : DEFAULT_R2_JURISDICTION,
169
+ policies: resolved.policies,
161
170
  });
162
171
  return true;
163
172
  };
package/src/creds-args.ts CHANGED
@@ -17,6 +17,7 @@ export type CredsFlags = {
17
17
  account: string | undefined;
18
18
  ttlDays: number | undefined;
19
19
  bucket: string | undefined;
20
+ zone: string | undefined;
20
21
  jurisdiction: R2Jurisdiction;
21
22
  s3: boolean;
22
23
  org: string | undefined;
@@ -51,6 +52,7 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
51
52
  account: undefined,
52
53
  ttlDays: undefined,
53
54
  bucket: undefined,
55
+ zone: undefined,
54
56
  jurisdiction: DEFAULT_R2_JURISDICTION,
55
57
  s3: false,
56
58
  org: undefined,
@@ -76,6 +78,9 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
76
78
  '--bucket': (value) => {
77
79
  flags.bucket = value;
78
80
  },
81
+ '--zone': (value) => {
82
+ flags.zone = value;
83
+ },
79
84
  '--jurisdiction': (value) => {
80
85
  if (!isR2Jurisdiction(value)) {
81
86
  throw new Error('--jurisdiction must be default or eu');
@@ -0,0 +1,40 @@
1
+ // Zone resources for `standards creds add cloudflare`. Zones are named by ID,
2
+ // never by domain name: resolving a name would need Zone Read on the bootstrap
3
+ // token, which holds Account API Tokens / Edit and nothing else, so the lookup
4
+ // would fail — or, worse, come back empty and read as a missing zone. A zone ID
5
+ // is not a secret and is shown on the zone's dashboard overview.
6
+
7
+ export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
8
+
9
+ const ZONE_ID_PATTERN = /^[0-9a-f]{32}$/u;
10
+
11
+ export const zoneResource = (zoneId: string): string =>
12
+ `${ZONE_SCOPE}.${zoneId}`;
13
+
14
+ export type ParsedZoneArgument =
15
+ | {
16
+ readonly ok: true;
17
+ readonly zoneIds: readonly [string, ...ReadonlyArray<string>];
18
+ }
19
+ | { readonly ok: false; readonly problem: string };
20
+
21
+ export const parseZoneArgument = (value: string): ParsedZoneArgument => {
22
+ const [first, ...rest] = value
23
+ .split(',')
24
+ .map((zone) => zone.trim())
25
+ .filter((zone) => zone.length > 0);
26
+ if (first === undefined) {
27
+ return {
28
+ ok: false,
29
+ problem: '--zone requires at least one zone ID',
30
+ };
31
+ }
32
+ const entries = [first, ...rest] as const;
33
+ const invalid = entries.filter((zone) => !ZONE_ID_PATTERN.test(zone));
34
+ return invalid.length === 0
35
+ ? { ok: true, zoneIds: entries }
36
+ : {
37
+ ok: false,
38
+ problem: `not a zone ID: ${invalid.join(', ')}; --zone takes the 32-character hexadecimal zone ID shown on the zone's dashboard overview, not its domain name`,
39
+ };
40
+ };
@@ -2,6 +2,7 @@ import { runCredsAddCloudflare } from './creds-add';
2
2
  import { runCredsAddGithub } from './creds-add-github';
3
3
  import { parseCredsArgs } from './creds-args';
4
4
  import { listPermissionGroups } from './creds-cloudflare';
5
+ import { loadOwnedGithubStore } from './creds-github-apps';
5
6
  import { runCredsLoginCloudflare } from './creds-login-cloudflare';
6
7
  import { runCredsLoginGithub } from './creds-login-github';
7
8
  import { BROKER_IDENTITY_NAME } from './creds-naming';
@@ -15,10 +16,10 @@ import {
15
16
  const CREDS_USAGE = `Usage: standards creds <command> [options]
16
17
 
17
18
  Commands:
18
- login github Create the broker GitHub App via the manifest flow (one click) and store its credentials
19
+ login github Create or replace one account's broker GitHub App via the manifest flow
19
20
  login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
20
21
  add cloudflare Mint a scoped, expiring account token and write it into a SOPS target
21
- add github Write the broker App's credentials into a SOPS target for runtime token minting
22
+ add github Select the repository owner's App, verify its installation, and write it into SOPS
22
23
  plan Show revocations and rotations reconciling SOPS keys against brokered tokens
23
24
  apply Execute the plan: revoke orphaned tokens, roll expiring ones into SOPS
24
25
  permissions List Cloudflare permission group names for --permissions
@@ -31,15 +32,21 @@ Options:
31
32
  --account <id> Cloudflare account when more than one is configured
32
33
  --ttl-days <n> Token lifetime in days (default: 90)
33
34
  --bucket <name> Scope a Cloudflare token to one R2 bucket
35
+ --zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
34
36
  --jurisdiction <name> R2 jurisdiction: default or eu (default: default)
35
37
  --s3 Store the derived R2 S3 credential pair (<key>.access_key_id, <key>.secret_access_key) instead of the raw token
36
38
  --org <org> Create the GitHub App under an organization
37
- --name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
39
+ --name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME}, suffixed by --org)
38
40
 
39
41
  Secret values are written directly into SOPS-encrypted targets and never printed.`;
40
42
  const runCredsStatus = async (): Promise<boolean> => {
41
43
  const path = resolveBrokerPath();
42
- const store = await readBrokerStore(path);
44
+ const loaded = await loadOwnedGithubStore(path);
45
+ if (!loaded.ok) {
46
+ console.error(`standards creds: ${loaded.problem}`);
47
+ return false;
48
+ }
49
+ const { value: store } = loaded;
43
50
  console.log(`broker store: ${path}`);
44
51
  const mode = inspectBrokerFileMode(path);
45
52
  if (!mode.exists) {
@@ -47,11 +54,16 @@ const runCredsStatus = async (): Promise<boolean> => {
47
54
  } else if (mode.problem !== null) {
48
55
  console.log(` WARNING: ${mode.problem}`);
49
56
  }
50
- console.log(
51
- store.github === null
52
- ? 'github: not configured (`standards creds login github`)'
53
- : `github: App ${store.github.slug} (id ${store.github.appId}) — ${store.github.htmlUrl}`,
54
- );
57
+ if (store.github.length === 0) {
58
+ console.log('github: not configured (`standards creds login github`)');
59
+ } else {
60
+ console.log('github:');
61
+ for (const app of store.github) {
62
+ console.log(
63
+ ` ${app.owner}: App ${app.slug} (id ${app.appId}) — ${app.htmlUrl}`,
64
+ );
65
+ }
66
+ }
55
67
  console.log(
56
68
  store.cloudflare.length === 0
57
69
  ? 'cloudflare: not configured (`standards creds login cloudflare`)'