@davidvornholt/standards 0.19.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.
Files changed (56) hide show
  1. package/README.md +7 -5
  2. package/package.json +19 -1
  3. package/src/cli.ts +2 -2
  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 +14 -0
  10. package/src/creds-cloudflare-account.ts +2 -2
  11. package/src/creds-cloudflare-id.ts +10 -0
  12. package/src/creds-cloudflare-zone.ts +40 -0
  13. package/src/creds-commands.ts +34 -11
  14. package/src/creds-github-app-api.ts +132 -0
  15. package/src/creds-github-apps.ts +107 -0
  16. package/src/creds-login-github-manifest.ts +38 -15
  17. package/src/creds-login-github.ts +78 -24
  18. package/src/creds-naming.ts +13 -0
  19. package/src/creds-plan-renew.ts +2 -5
  20. package/src/creds-plan-report.ts +87 -0
  21. package/src/creds-plan-run.ts +2 -11
  22. package/src/creds-plan-types.ts +19 -0
  23. package/src/creds-plan-unmanaged.ts +102 -0
  24. package/src/creds-plan.ts +25 -21
  25. package/src/creds-revoke.ts +162 -0
  26. package/src/creds-sops-value.ts +89 -0
  27. package/src/creds-sops.ts +0 -53
  28. package/src/creds-store-github.ts +121 -0
  29. package/src/creds-store-lock-holder.ts +105 -0
  30. package/src/creds-store-lock-inspection.ts +125 -0
  31. package/src/creds-store-lock-lease.ts +32 -0
  32. package/src/creds-store-lock.ts +28 -51
  33. package/src/creds-store.ts +14 -43
  34. package/src/dev-env-compose.ts +120 -0
  35. package/src/dev-env-destination-duplicates.ts +10 -10
  36. package/src/dev-env-destination-gitignore.ts +22 -0
  37. package/src/dev-env-destination.ts +52 -30
  38. package/src/dev-env-document.ts +19 -6
  39. package/src/dev-env-dotenv.ts +12 -3
  40. package/src/dev-env-reconciliation.ts +170 -0
  41. package/src/dev-env-secrets.ts +41 -0
  42. package/src/dev-env-transaction-recovery.ts +74 -11
  43. package/src/dev-env-transaction.ts +61 -15
  44. package/src/dev-env.ts +115 -63
  45. package/src/github-command-shared.ts +8 -1
  46. package/src/github-live-drift.ts +3 -3
  47. package/src/poller-closing-reference.ts +179 -0
  48. package/src/poller-fix-outcome.ts +9 -0
  49. package/src/poller-fix-publication.ts +5 -12
  50. package/src/poller-fix-run.ts +2 -3
  51. package/src/poller-fix-validation.ts +7 -8
  52. package/src/poller-outcome.ts +4 -1
  53. package/src/poller-protected-paths.ts +1 -25
  54. package/src/poller-protocol.ts +0 -42
  55. package/src/poller-review-publication.ts +2 -8
  56. package/src/poller-review-validation.ts +5 -12
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,10 +17,13 @@ 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;
23
+ force: boolean;
22
24
  org: string | undefined;
23
25
  name: string | undefined;
26
+ tokenId: string | undefined;
24
27
  readonly words: Array<string>;
25
28
  };
26
29
 
@@ -51,10 +54,13 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
51
54
  account: undefined,
52
55
  ttlDays: undefined,
53
56
  bucket: undefined,
57
+ zone: undefined,
54
58
  jurisdiction: DEFAULT_R2_JURISDICTION,
55
59
  s3: false,
60
+ force: false,
56
61
  org: undefined,
57
62
  name: undefined,
63
+ tokenId: undefined,
58
64
  words: [],
59
65
  };
60
66
  const setters: Readonly<Record<string, (value: string) => void>> = {
@@ -76,6 +82,9 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
76
82
  '--bucket': (value) => {
77
83
  flags.bucket = value;
78
84
  },
85
+ '--zone': (value) => {
86
+ flags.zone = value;
87
+ },
79
88
  '--jurisdiction': (value) => {
80
89
  if (!isR2Jurisdiction(value)) {
81
90
  throw new Error('--jurisdiction must be default or eu');
@@ -88,12 +97,17 @@ export const parseCredsArgs = (argv: ReadonlyArray<string>): CredsFlags => {
88
97
  '--name': (value) => {
89
98
  flags.name = value;
90
99
  },
100
+ '--token-id': (value) => {
101
+ flags.tokenId = value;
102
+ },
91
103
  };
92
104
  for (let index = 0; index < argv.length; index += 1) {
93
105
  const arg = argv[index] ?? '';
94
106
  const setter = setters[arg];
95
107
  if (arg === '--s3') {
96
108
  flags.s3 = true;
109
+ } else if (arg === '--force') {
110
+ flags.force = true;
97
111
  } else if (setter !== undefined) {
98
112
  setter(flagValue(argv, index));
99
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);
@@ -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
+ import { isCloudflareId } from './creds-cloudflare-id';
8
+
9
+ export const ZONE_SCOPE = 'com.cloudflare.api.account.zone';
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) => !isCloudflareId(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,10 +2,12 @@ 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';
8
9
  import { runCredsPlan } from './creds-plan-run';
10
+ import { runCredsRevoke } from './creds-revoke';
9
11
  import {
10
12
  inspectBrokerFileMode,
11
13
  readBrokerStore,
@@ -15,12 +17,13 @@ import {
15
17
  const CREDS_USAGE = `Usage: standards creds <command> [options]
16
18
 
17
19
  Commands:
18
- login github Create the broker GitHub App via the manifest flow (one click) and store its credentials
20
+ login github Create or replace one account's broker GitHub App via the manifest flow
19
21
  login cloudflare Store a Cloudflare account's bootstrap token (one guided paste per account)
20
22
  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
- plan Show revocations and rotations reconciling SOPS keys against brokered tokens
23
- apply Execute the plan: revoke orphaned tokens, roll expiring ones into SOPS
23
+ add github Select the repository owner's App, verify its installation, and write it 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
24
27
  permissions List Cloudflare permission group names for --permissions
25
28
  status Show the broker store location and configured providers
26
29
 
@@ -31,15 +34,23 @@ Options:
31
34
  --account <id> Cloudflare account when more than one is configured
32
35
  --ttl-days <n> Token lifetime in days (default: 90)
33
36
  --bucket <name> Scope a Cloudflare token to one R2 bucket
37
+ --zone <zone-id> Add a zone resource for zone-scoped groups (comma-separated IDs)
34
38
  --jurisdiction <name> R2 jurisdiction: default or eu (default: default)
35
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
36
42
  --org <org> Create the GitHub App under an organization
37
- --name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME})
43
+ --name <name> GitHub App name (default: ${BROKER_IDENTITY_NAME}, suffixed by --org)
38
44
 
39
45
  Secret values are written directly into SOPS-encrypted targets and never printed.`;
40
46
  const runCredsStatus = async (): Promise<boolean> => {
41
47
  const path = resolveBrokerPath();
42
- const store = await readBrokerStore(path);
48
+ const loaded = await loadOwnedGithubStore(path);
49
+ if (!loaded.ok) {
50
+ console.error(`standards creds: ${loaded.problem}`);
51
+ return false;
52
+ }
53
+ const { value: store } = loaded;
43
54
  console.log(`broker store: ${path}`);
44
55
  const mode = inspectBrokerFileMode(path);
45
56
  if (!mode.exists) {
@@ -47,11 +58,16 @@ const runCredsStatus = async (): Promise<boolean> => {
47
58
  } else if (mode.problem !== null) {
48
59
  console.log(` WARNING: ${mode.problem}`);
49
60
  }
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
- );
61
+ if (store.github.length === 0) {
62
+ console.log('github: not configured (`standards creds login github`)');
63
+ } else {
64
+ console.log('github:');
65
+ for (const app of store.github) {
66
+ console.log(
67
+ ` ${app.owner}: App ${app.slug} (id ${app.appId}) — ${app.htmlUrl}`,
68
+ );
69
+ }
70
+ }
55
71
  console.log(
56
72
  store.cloudflare.length === 0
57
73
  ? 'cloudflare: not configured (`standards creds login cloudflare`)'
@@ -105,6 +121,13 @@ export const runCredsCommand = (
105
121
  'add github': () => runCredsAddGithub(flags.dir, flags),
106
122
  plan: () => runCredsPlan(flags.dir, false),
107
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
+ }),
108
131
  permissions: () => runCredsPermissions(flags.account),
109
132
  status: () => runCredsStatus(),
110
133
  };
@@ -0,0 +1,132 @@
1
+ import { createSign } from 'node:crypto';
2
+ import type { GithubBrokerApp } from './creds-store';
3
+ import { type ApiResponse, apiError, HTTP_OK, request } from './github-api';
4
+ import { isNonEmptyString, isRecord } from './github-settings-parse';
5
+
6
+ const JWT_CLOCK_SKEW_SECONDS = 60;
7
+ const SECONDS_PER_MINUTE = 60;
8
+ const JWT_LIFETIME_MINUTES = 9;
9
+ const JWT_LIFETIME_SECONDS = JWT_LIFETIME_MINUTES * SECONDS_PER_MINUTE;
10
+ const MILLISECONDS_PER_SECOND = 1000;
11
+ const HTTP_NOT_FOUND = 404;
12
+
13
+ type GithubAppResult<T> =
14
+ | { readonly ok: true; readonly value: T }
15
+ | { readonly ok: false; readonly problem: string };
16
+
17
+ export const createGithubAppJwt = (
18
+ app: GithubBrokerApp,
19
+ now = Date.now(),
20
+ ): string => {
21
+ const seconds = Math.floor(now / MILLISECONDS_PER_SECOND);
22
+ const header = Buffer.from(
23
+ JSON.stringify({ alg: 'RS256', typ: 'JWT' }),
24
+ ).toString('base64url');
25
+ const payload = Buffer.from(
26
+ JSON.stringify({
27
+ iat: seconds - JWT_CLOCK_SKEW_SECONDS,
28
+ exp: seconds + JWT_LIFETIME_SECONDS,
29
+ iss: app.clientId,
30
+ }),
31
+ ).toString('base64url');
32
+ const unsigned = `${header}.${payload}`;
33
+ const signature = createSign('RSA-SHA256')
34
+ .update(unsigned)
35
+ .sign(app.privateKey, 'base64url');
36
+ return `${unsigned}.${signature}`;
37
+ };
38
+
39
+ const appRequest = async (
40
+ app: GithubBrokerApp,
41
+ path: string,
42
+ ): Promise<GithubAppResult<ApiResponse>> => {
43
+ try {
44
+ return {
45
+ ok: true,
46
+ value: await request(createGithubAppJwt(app), 'GET', path),
47
+ };
48
+ } catch (error) {
49
+ const message = error instanceof Error ? error.message : String(error);
50
+ return {
51
+ ok: false,
52
+ problem: `cannot authenticate as App ${app.slug}: ${message}`,
53
+ };
54
+ }
55
+ };
56
+
57
+ export const resolveGithubAppOwner = async (
58
+ app: GithubBrokerApp,
59
+ ): Promise<GithubAppResult<string>> => {
60
+ const result = await appRequest(app, '/app');
61
+ if (!result.ok) {
62
+ return result;
63
+ }
64
+ if (result.value.status !== HTTP_OK) {
65
+ return {
66
+ ok: false,
67
+ problem: apiError(`resolve owner for App ${app.slug}`, result.value),
68
+ };
69
+ }
70
+ const { body } = result.value;
71
+ if (
72
+ !isRecord(body) ||
73
+ body.id !== app.appId ||
74
+ !isRecord(body.owner) ||
75
+ !isNonEmptyString(body.owner.login)
76
+ ) {
77
+ return {
78
+ ok: false,
79
+ problem: `resolve owner for App ${app.slug}: unexpected response shape or App identity`,
80
+ };
81
+ }
82
+ return { ok: true, value: body.owner.login };
83
+ };
84
+
85
+ export const verifyGithubAppInstallation = async (
86
+ app: GithubBrokerApp,
87
+ repo: string,
88
+ ): Promise<GithubAppResult<true>> => {
89
+ const [owner, name, ...rest] = repo.split('/');
90
+ if (
91
+ owner === undefined ||
92
+ name === undefined ||
93
+ owner.length === 0 ||
94
+ name.length === 0 ||
95
+ rest.length > 0
96
+ ) {
97
+ return { ok: false, problem: `invalid GitHub repository: ${repo}` };
98
+ }
99
+ const result = await appRequest(
100
+ app,
101
+ `/repos/${encodeURIComponent(owner)}/${encodeURIComponent(name)}/installation`,
102
+ );
103
+ if (!result.ok) {
104
+ return result;
105
+ }
106
+ if (result.value.status === HTTP_NOT_FOUND) {
107
+ return {
108
+ ok: false,
109
+ problem: `App ${app.slug} for ${owner} is not installed on ${repo}; install it for that selected repository at ${app.htmlUrl}/installations/new`,
110
+ };
111
+ }
112
+ if (result.value.status !== HTTP_OK) {
113
+ return {
114
+ ok: false,
115
+ problem: apiError(`verify App installation on ${repo}`, result.value),
116
+ };
117
+ }
118
+ const { body } = result.value;
119
+ if (
120
+ !isRecord(body) ||
121
+ body.app_id !== app.appId ||
122
+ !isRecord(body.account) ||
123
+ !isNonEmptyString(body.account.login) ||
124
+ body.account.login.toLowerCase() !== owner.toLowerCase()
125
+ ) {
126
+ return {
127
+ ok: false,
128
+ problem: `verify App installation on ${repo}: unexpected App or account identity`,
129
+ };
130
+ }
131
+ return { ok: true, value: true };
132
+ };
@@ -0,0 +1,107 @@
1
+ import { resolveGithubAppOwner } from './creds-github-app-api';
2
+ import {
3
+ type BrokerStore,
4
+ type GithubBrokerApp,
5
+ readBrokerStore,
6
+ updateBrokerStore,
7
+ } from './creds-store';
8
+
9
+ type GithubAppsResult<T> =
10
+ | { readonly ok: true; readonly value: T }
11
+ | { readonly ok: false; readonly problem: string };
12
+
13
+ export const sameGithubApp = (
14
+ left: GithubBrokerApp,
15
+ right: GithubBrokerApp,
16
+ ): boolean =>
17
+ left.owner?.toLowerCase() === right.owner?.toLowerCase() &&
18
+ left.appId === right.appId &&
19
+ left.slug === right.slug &&
20
+ left.htmlUrl === right.htmlUrl &&
21
+ left.clientId === right.clientId &&
22
+ left.privateKey === right.privateKey;
23
+
24
+ export const loadOwnedGithubStore = async (
25
+ path: string,
26
+ lookupOwner: (
27
+ app: GithubBrokerApp,
28
+ ) => Promise<GithubAppsResult<string>> = resolveGithubAppOwner,
29
+ ): Promise<GithubAppsResult<BrokerStore>> => {
30
+ const initial = await readBrokerStore(path);
31
+ const legacy = initial.github.find((app) => app.owner === null);
32
+ if (legacy === undefined) {
33
+ return { ok: true, value: initial };
34
+ }
35
+ const owner = await lookupOwner(legacy);
36
+ if (!owner.ok) {
37
+ return {
38
+ ok: false,
39
+ problem: `cannot migrate the legacy singleton GitHub App in ${path}: ${owner.problem}`,
40
+ };
41
+ }
42
+ await updateBrokerStore(path, (current) => ({
43
+ ...current,
44
+ github: current.github.map((app) =>
45
+ app.owner === null && sameGithubApp(app, legacy)
46
+ ? { ...app, owner: owner.value }
47
+ : app,
48
+ ),
49
+ }));
50
+ const migrated = await readBrokerStore(path);
51
+ if (migrated.github.some((app) => app.owner === null)) {
52
+ return {
53
+ ok: false,
54
+ problem: `the GitHub App in ${path} changed while its legacy owner was being migrated; retry`,
55
+ };
56
+ }
57
+ return { ok: true, value: migrated };
58
+ };
59
+
60
+ export const selectGithubAppForRepo = (
61
+ apps: ReadonlyArray<GithubBrokerApp>,
62
+ repo: string,
63
+ ): GithubAppsResult<GithubBrokerApp> => {
64
+ const owner = repo.split('/')[0] ?? '';
65
+ const matches = apps.filter(
66
+ (app) => app.owner?.toLowerCase() === owner.toLowerCase(),
67
+ );
68
+ if (matches.length === 1 && matches[0] !== undefined) {
69
+ return { ok: true, value: matches[0] };
70
+ }
71
+ if (matches.length > 1) {
72
+ return {
73
+ ok: false,
74
+ problem: `multiple broker GitHub Apps are configured for repository owner ${owner}; keep exactly one entry for that owner`,
75
+ };
76
+ }
77
+ const loginHint =
78
+ apps.length === 0
79
+ ? 'no broker GitHub Apps are configured'
80
+ : `configured owners: ${apps.map((app) => app.owner).join(', ')}`;
81
+ return {
82
+ ok: false,
83
+ problem: `no broker GitHub App is configured for repository owner ${owner} (${loginHint}); run \`standards creds login github --org ${owner}\` for an organization, or run it without --org while signed in as ${owner}`,
84
+ };
85
+ };
86
+
87
+ export const upsertGithubApp = (
88
+ apps: ReadonlyArray<GithubBrokerApp>,
89
+ app: GithubBrokerApp,
90
+ ): {
91
+ readonly apps: ReadonlyArray<GithubBrokerApp>;
92
+ readonly replaced: GithubBrokerApp | null;
93
+ } => {
94
+ if (app.owner === null || apps.some((entry) => entry.owner === null)) {
95
+ throw new Error('legacy GitHub Apps must be migrated before login');
96
+ }
97
+ const index = apps.findIndex(
98
+ (entry) => entry.owner?.toLowerCase() === app.owner?.toLowerCase(),
99
+ );
100
+ if (index === -1) {
101
+ return { apps: [...apps, app], replaced: null };
102
+ }
103
+ return {
104
+ apps: apps.map((entry, entryIndex) => (entryIndex === index ? app : entry)),
105
+ replaced: apps[index] ?? null,
106
+ };
107
+ };
@@ -4,11 +4,35 @@
4
4
  // creds-login-github.ts.
5
5
 
6
6
  import { randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import { BROKER_IDENTITY_NAME } from './creds-naming';
7
8
  import type { GithubBrokerApp } from './creds-store';
9
+ import { isGithubBrokerApp } from './creds-store-github';
8
10
  import { apiError, HTTP_CREATED, request } from './github-api';
9
11
  import { isRecord } from './github-settings-parse';
10
12
 
11
13
  export const MANIFEST_STATE_BYTES = 32;
14
+ const GITHUB_APP_NAME_MAX_LENGTH = 34;
15
+
16
+ type GithubAppNameResult =
17
+ | { readonly ok: true; readonly value: string }
18
+ | { readonly ok: false; readonly problem: string };
19
+
20
+ export const resolveGithubAppName = (
21
+ requested: string | undefined,
22
+ org: string | undefined,
23
+ ): GithubAppNameResult => {
24
+ const name =
25
+ requested ??
26
+ (org === undefined
27
+ ? BROKER_IDENTITY_NAME
28
+ : `${BROKER_IDENTITY_NAME}-${org}`);
29
+ return name.length <= GITHUB_APP_NAME_MAX_LENGTH
30
+ ? { ok: true, value: name }
31
+ : {
32
+ ok: false,
33
+ problem: `GitHub App names may contain at most ${GITHUB_APP_NAME_MAX_LENGTH} characters; pass --name with a shorter globally unique name`,
34
+ };
35
+ };
12
36
 
13
37
  // Root-credential ceiling: everything the broker may ever delegate. The
14
38
  // `permission-*` inputs a workflow passes when minting are a request, not a
@@ -70,21 +94,20 @@ export const manifestFormHtml = (
70
94
  ): string =>
71
95
  `<!doctype html><html><body><form id="m" action="${escapeAttribute(`${action}?state=${encodeURIComponent(state)}`)}" method="post"><input type="hidden" name="manifest" value="${escapeAttribute(manifest)}"><noscript><button type="submit">Create GitHub App</button></noscript></form><script>document.getElementById("m").submit()</script></body></html>`;
72
96
 
73
- export const parseConversion = (body: unknown): GithubBrokerApp | null =>
74
- isRecord(body) &&
75
- typeof body.id === 'number' &&
76
- typeof body.slug === 'string' &&
77
- typeof body.html_url === 'string' &&
78
- typeof body.client_id === 'string' &&
79
- typeof body.pem === 'string'
80
- ? {
81
- appId: body.id,
82
- slug: body.slug,
83
- htmlUrl: body.html_url,
84
- clientId: body.client_id,
85
- privateKey: body.pem,
86
- }
87
- : null;
97
+ export const parseConversion = (body: unknown): GithubBrokerApp | null => {
98
+ if (!(isRecord(body) && isRecord(body.owner))) {
99
+ return null;
100
+ }
101
+ const app = {
102
+ owner: body.owner.login,
103
+ appId: body.id,
104
+ slug: body.slug,
105
+ htmlUrl: body.html_url,
106
+ clientId: body.client_id,
107
+ privateKey: body.pem,
108
+ };
109
+ return isGithubBrokerApp(app) && app.owner !== null ? app : null;
110
+ };
88
111
 
89
112
  export type ManifestConversion =
90
113
  | { readonly ok: true; readonly app: GithubBrokerApp }