@davidvornholt/standards 0.12.0 → 0.14.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.
@@ -0,0 +1,155 @@
1
+ // Cloudflare broker bootstrap. Cloudflare's API can create account-owned
2
+ // tokens, but the very first token must come from the dashboard — this is the
3
+ // one paste per account, ever. The flow tells the user exactly what to click
4
+ // and which single permission to grant, takes the token hidden, verifies it
5
+ // against the API before storing, and from then on every real credential is a
6
+ // scoped, expiring account token minted by `standards creds add`.
7
+
8
+ import { openInBrowser } from './creds-browser';
9
+ import { listAccountTokens, verifyAccountToken } from './creds-cloudflare';
10
+ import { isCloudflareAccountId } from './creds-cloudflare-account';
11
+ import type { CfResult, CloudflareToken } from './creds-cloudflare-api';
12
+ import { BROKER_IDENTITY_NAME, isInMintedNamespace } from './creds-naming';
13
+ import { promptHidden, promptLine } from './creds-prompt';
14
+ import {
15
+ readBrokerStore,
16
+ resolveBrokerPath,
17
+ updateBrokerStore,
18
+ } from './creds-store';
19
+
20
+ type IdentifiedBootstrapToken = {
21
+ readonly id: string;
22
+ readonly name: string;
23
+ readonly tokens: ReadonlyArray<CloudflareToken>;
24
+ };
25
+
26
+ export const identifyCloudflareBootstrapAuthority = async (
27
+ accountId: string,
28
+ token: string,
29
+ ): Promise<CfResult<IdentifiedBootstrapToken>> => {
30
+ const verified = await verifyAccountToken(accountId, token);
31
+ if (!verified.ok) {
32
+ return {
33
+ ok: false,
34
+ problem: `activity verification failed — ${verified.problem}`,
35
+ };
36
+ }
37
+ if (verified.value.status !== 'active') {
38
+ return {
39
+ ok: false,
40
+ problem: `token status is "${verified.value.status}", not "active"`,
41
+ };
42
+ }
43
+ const listed = await listAccountTokens(accountId, token);
44
+ if (!listed.ok) {
45
+ return {
46
+ ok: false,
47
+ problem: `token cannot list account API tokens — ${listed.problem}; grant Account / Account API Tokens / Edit`,
48
+ };
49
+ }
50
+ const own = listed.value.find((entry) => entry.id === verified.value.id);
51
+ if (own === undefined) {
52
+ return {
53
+ ok: false,
54
+ problem: `complete token list did not contain verified token ID ${verified.value.id}`,
55
+ };
56
+ }
57
+ if (own.status !== 'active') {
58
+ return {
59
+ ok: false,
60
+ problem: `the token verified as active but the account token list reports status "${own.status}"; refusing the contradictory provider response`,
61
+ };
62
+ }
63
+ return {
64
+ ok: true,
65
+ value: { id: verified.value.id, name: own.name, tokens: listed.value },
66
+ };
67
+ };
68
+
69
+ export const verifyCloudflareBootstrapAuthority = async (
70
+ accountId: string,
71
+ token: string,
72
+ ): Promise<CfResult<{ readonly tokenName: string }>> => {
73
+ const identified = await identifyCloudflareBootstrapAuthority(
74
+ accountId,
75
+ token,
76
+ );
77
+ if (!identified.ok) {
78
+ return identified;
79
+ }
80
+ if (isInMintedNamespace(identified.value.name)) {
81
+ return {
82
+ ok: false,
83
+ problem: `the token is named "${identified.value.name}", inside the broker's minted-token namespace — bootstrap credentials must remain distinguishable from minted credentials; rename it ${BROKER_IDENTITY_NAME} in the dashboard and re-run login`,
84
+ };
85
+ }
86
+ return { ok: true, value: { tokenName: identified.value.name } };
87
+ };
88
+
89
+ export const runCredsLoginCloudflare = async (options: {
90
+ readonly account: string | undefined;
91
+ }): Promise<boolean> => {
92
+ const accountId =
93
+ options.account ??
94
+ (await promptLine(
95
+ 'Cloudflare account ID (dash.cloudflare.com, account home, "Account ID" in the sidebar): ',
96
+ ));
97
+ if (!isCloudflareAccountId(accountId)) {
98
+ console.error(
99
+ 'standards creds: a Cloudflare account ID is 32 lowercase hexadecimal characters',
100
+ );
101
+ return false;
102
+ }
103
+ const storePath = resolveBrokerPath();
104
+ const store = await readBrokerStore(storePath);
105
+ if (store.cloudflare.some((entry) => entry.accountId === accountId)) {
106
+ console.error(
107
+ `standards creds: account ${accountId} is already configured; remove its entry from ${storePath} to replace the bootstrap token`,
108
+ );
109
+ return false;
110
+ }
111
+ const tokensUrl = `https://dash.cloudflare.com/${accountId}/api-tokens`;
112
+ console.log('Create the bootstrap token (one time for this account):');
113
+ console.log(` 1. Open ${tokensUrl}`);
114
+ console.log(' 2. Select Create Token');
115
+ console.log(' 3. Find Create additional tokens and select Use template');
116
+ console.log(` 4. Name it ${BROKER_IDENTITY_NAME}`);
117
+ console.log(
118
+ ' 5. Keep exactly one permission: Account / Account API Tokens / Edit',
119
+ );
120
+ console.log(' 6. Continue to summary, create the token, and copy the value');
121
+ openInBrowser(tokensUrl);
122
+ const token = await promptHidden('Paste the token (input is hidden): ');
123
+ if (token.length === 0) {
124
+ console.error('standards creds: no token entered');
125
+ return false;
126
+ }
127
+ const verified = await verifyCloudflareBootstrapAuthority(accountId, token);
128
+ if (!verified.ok) {
129
+ console.error(
130
+ `standards creds: token verification failed — ${verified.problem}`,
131
+ );
132
+ return false;
133
+ }
134
+ const { tokenName } = verified.value;
135
+ if (tokenName !== BROKER_IDENTITY_NAME) {
136
+ console.error(
137
+ `standards creds: warning — the token is named "${tokenName}", not "${BROKER_IDENTITY_NAME}"; rename it in the dashboard so it stays identifiable`,
138
+ );
139
+ }
140
+ await updateBrokerStore(storePath, (current) => {
141
+ if (current.cloudflare.some((entry) => entry.accountId === accountId)) {
142
+ throw new Error(
143
+ `Cloudflare account ${accountId} was configured while login was in progress; the newly entered token was not stored`,
144
+ );
145
+ }
146
+ return {
147
+ ...current,
148
+ cloudflare: [...current.cloudflare, { accountId, token }],
149
+ };
150
+ });
151
+ console.log(
152
+ `standards creds: Cloudflare account ${accountId} configured; bootstrap token stored in ${storePath}`,
153
+ );
154
+ return true;
155
+ };
@@ -0,0 +1,104 @@
1
+ // Pure manifest-flow pieces for the GitHub broker login: the App manifest,
2
+ // the auto-submitting form, the one-time state token, and the conversion
3
+ // exchange. The localhost listener and the login orchestration live in
4
+ // creds-login-github.ts.
5
+
6
+ import { randomBytes, timingSafeEqual } from 'node:crypto';
7
+ import type { GithubBrokerApp } from './creds-store';
8
+ import { apiError, HTTP_CREATED, request } from './github-api';
9
+ import { isRecord } from './github-settings-parse';
10
+
11
+ export const MANIFEST_STATE_BYTES = 32;
12
+
13
+ // Root-credential ceiling: everything the broker may ever delegate. Actual
14
+ // grants are narrowed per installation token at mint time.
15
+ const DEFAULT_PERMISSIONS = Object.fromEntries(
16
+ 'administration actions contents issues pull_requests secrets workflows'
17
+ .split(' ')
18
+ .map((permission) => [permission, 'write']),
19
+ );
20
+
21
+ export const buildAppManifest = (
22
+ name: string,
23
+ redirectUrl: string,
24
+ ): Readonly<Record<string, unknown>> => ({
25
+ name,
26
+ url: 'https://github.com/davidvornholt/standards',
27
+ hook_attributes: { url: 'https://example.invalid/unused', active: false },
28
+ redirect_url: redirectUrl,
29
+ public: false,
30
+ default_permissions: DEFAULT_PERMISSIONS,
31
+ default_events: [],
32
+ });
33
+
34
+ const escapeAttribute = (value: string): string =>
35
+ value
36
+ .replaceAll('&', '&amp;')
37
+ .replaceAll('"', '&quot;')
38
+ .replaceAll('<', '&lt;')
39
+ .replaceAll('>', '&gt;');
40
+
41
+ export const createManifestState = () => {
42
+ const expected = randomBytes(MANIFEST_STATE_BYTES);
43
+ let available = true;
44
+ return {
45
+ value: expected.toString('hex'),
46
+ accept: (candidate: string | null) => {
47
+ const received =
48
+ candidate === null ? Buffer.alloc(0) : Buffer.from(candidate, 'hex');
49
+ const matches =
50
+ available &&
51
+ received.length === expected.length &&
52
+ timingSafeEqual(received, expected);
53
+ available = matches ? false : available;
54
+ return matches;
55
+ },
56
+ };
57
+ };
58
+
59
+ export const manifestFormHtml = (
60
+ action: string,
61
+ manifest: string,
62
+ state: string,
63
+ ): string =>
64
+ `<!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>`;
65
+
66
+ export const parseConversion = (body: unknown): GithubBrokerApp | null =>
67
+ isRecord(body) &&
68
+ typeof body.id === 'number' &&
69
+ typeof body.slug === 'string' &&
70
+ typeof body.html_url === 'string' &&
71
+ typeof body.client_id === 'string' &&
72
+ typeof body.pem === 'string'
73
+ ? {
74
+ appId: body.id,
75
+ slug: body.slug,
76
+ htmlUrl: body.html_url,
77
+ clientId: body.client_id,
78
+ privateKey: body.pem,
79
+ }
80
+ : null;
81
+
82
+ export type ManifestConversion =
83
+ | { readonly ok: true; readonly app: GithubBrokerApp }
84
+ | { readonly ok: false; readonly problem: string };
85
+
86
+ // POST /app-manifests/{code}/conversions — note the plural resource name:
87
+ // the singular path hits no route and GitHub answers with the same bare 404
88
+ // an expired code would get. The wire-contract test pins this request line.
89
+ export const convertManifestCode = async (
90
+ code: string,
91
+ ): Promise<ManifestConversion> => {
92
+ const conversion = await request(
93
+ null,
94
+ 'POST',
95
+ `/app-manifests/${code}/conversions`,
96
+ );
97
+ if (conversion.status !== HTTP_CREATED) {
98
+ return { ok: false, problem: apiError('manifest conversion', conversion) };
99
+ }
100
+ const app = parseConversion(conversion.body);
101
+ return app === null
102
+ ? { ok: false, problem: 'unexpected manifest conversion response shape' }
103
+ : { ok: true, app };
104
+ };
@@ -0,0 +1,133 @@
1
+ import { randomBytes } from 'node:crypto';
2
+ import { openInBrowser } from './creds-browser';
3
+ import {
4
+ buildAppManifest,
5
+ convertManifestCode,
6
+ createManifestState,
7
+ MANIFEST_STATE_BYTES,
8
+ manifestFormHtml,
9
+ } from './creds-login-github-manifest';
10
+ import { BROKER_IDENTITY_NAME } from './creds-naming';
11
+ import {
12
+ readBrokerStore,
13
+ resolveBrokerPath,
14
+ updateBrokerStore,
15
+ } from './creds-store';
16
+
17
+ const LOGIN_TIMEOUT_MS = 600_000;
18
+ const HTTP_BAD_REQUEST = 400;
19
+ const HTTP_NOT_FOUND = 404;
20
+
21
+ export const startManifestLoginListener = (
22
+ formHtml: (port: number, state: string) => string,
23
+ timeoutMs = LOGIN_TIMEOUT_MS,
24
+ ) => {
25
+ const state = createManifestState();
26
+ const startPath = `/start/${randomBytes(MANIFEST_STATE_BYTES).toString('hex')}`;
27
+ let startAvailable = true;
28
+ const deferred = Promise.withResolvers<string>();
29
+ // biome-ignore lint/correctness/noUndeclaredVariables: Bun is the CLI's required runtime.
30
+ const server = Bun.serve({
31
+ hostname: '127.0.0.1',
32
+ port: 0,
33
+ fetch: (incoming, activeServer): Response => {
34
+ const url = new URL(incoming.url);
35
+ if (incoming.method === 'GET' && url.pathname === '/callback') {
36
+ const callbackCode = url.searchParams.get('code');
37
+ if (
38
+ callbackCode !== null &&
39
+ state.accept(url.searchParams.get('state'))
40
+ ) {
41
+ deferred.resolve(callbackCode);
42
+ return new Response('GitHub App created. Return to the terminal.');
43
+ }
44
+ return new Response(null, { status: HTTP_BAD_REQUEST });
45
+ }
46
+ if (
47
+ incoming.method === 'GET' &&
48
+ startAvailable &&
49
+ url.pathname === startPath
50
+ ) {
51
+ startAvailable = false;
52
+ return new Response(formHtml(Number(activeServer.port), state.value), {
53
+ headers: { 'cache-control': 'no-store', 'content-type': 'text/html' },
54
+ });
55
+ }
56
+ return new Response('Not found.', { status: HTTP_NOT_FOUND });
57
+ },
58
+ });
59
+ let timeout: ReturnType<typeof setTimeout>;
60
+ const close = (): void => {
61
+ clearTimeout(timeout);
62
+ server.stop();
63
+ };
64
+ timeout = setTimeout(() => {
65
+ close();
66
+ deferred.reject(
67
+ new Error('timed out waiting for the GitHub App creation redirect'),
68
+ );
69
+ }, timeoutMs);
70
+ return {
71
+ startUrl: `http://127.0.0.1:${server.port}${startPath}`,
72
+ code: deferred.promise,
73
+ close,
74
+ };
75
+ };
76
+ export const waitForCode = (
77
+ formHtml: (port: number, state: string) => string,
78
+ opener: typeof openInBrowser = openInBrowser,
79
+ ): Promise<string> => {
80
+ const listener = startManifestLoginListener(formHtml);
81
+ console.log(`Open ${listener.startUrl} to create the GitHub App.`);
82
+ opener(listener.startUrl);
83
+ return listener.code.finally(listener.close);
84
+ };
85
+ export const githubInstallMessage = (installUrl: string): string =>
86
+ `Install it only on the selected repositories that need broker credentials: ${installUrl}`;
87
+ export const runCredsLoginGithub = async (options: {
88
+ readonly name: string | undefined;
89
+ readonly org: string | undefined;
90
+ }): Promise<boolean> => {
91
+ const storePath = resolveBrokerPath();
92
+ const store = await readBrokerStore(storePath);
93
+ if (store.github !== null) {
94
+ console.error(
95
+ `standards creds: a broker GitHub App is already configured (${store.github.htmlUrl}); remove the "github" section from ${storePath} to re-run login`,
96
+ );
97
+ return false;
98
+ }
99
+ const action =
100
+ options.org === undefined
101
+ ? 'https://github.com/settings/apps/new'
102
+ : `https://github.com/organizations/${options.org}/settings/apps/new`;
103
+ const name = options.name ?? BROKER_IDENTITY_NAME;
104
+ const code = await waitForCode((port, state) =>
105
+ manifestFormHtml(
106
+ action,
107
+ JSON.stringify(
108
+ buildAppManifest(name, `http://127.0.0.1:${port}/callback`),
109
+ ),
110
+ state,
111
+ ),
112
+ );
113
+ const conversion = await convertManifestCode(code);
114
+ if (!conversion.ok) {
115
+ console.error(`standards creds: ${conversion.problem}`);
116
+ return false;
117
+ }
118
+ const { app } = conversion;
119
+ await updateBrokerStore(storePath, (current) => {
120
+ if (current.github !== null) {
121
+ throw new Error(
122
+ `a broker GitHub App was configured while login was in progress (${current.github.htmlUrl}); the newly created App ${app.htmlUrl} was not stored and should be deleted`,
123
+ );
124
+ }
125
+ return { ...current, github: app };
126
+ });
127
+ console.log(`standards creds: created App ${app.slug} (${app.htmlUrl})`);
128
+ console.log(` credentials stored in ${storePath}`);
129
+ const installUrl = `${app.htmlUrl}/installations/new`;
130
+ console.log(githubInstallMessage(installUrl));
131
+ openInBrowser(installUrl);
132
+ return true;
133
+ };
@@ -0,0 +1,82 @@
1
+ // Deterministic provider-side token names are the reconciliation key between
2
+ // a repository's SOPS key structure and the tokens the broker minted for it:
3
+ // `standards/<owner>/<repo>/<sops-target>/<dotted.key>`. No separate ledger
4
+ // exists — the SOPS file (plaintext key structure) and the provider token
5
+ // list, joined by this scheme, are the two sources of truth.
6
+
7
+ const NAME_PREFIX = 'standards';
8
+
9
+ // One broker identity credential per provider — the GitHub App and each
10
+ // account's Cloudflare bootstrap token — shares this name. It contains no
11
+ // slash, so it can never fall inside the minted namespace below.
12
+ export const BROKER_IDENTITY_NAME = 'standards-broker';
13
+
14
+ // The whole `standards/` prefix is reserved for minted tokens, deliberately
15
+ // broader than the names reconciliation parses today: login is the only
16
+ // cheap place to enforce the boundary, and a name that does not parse under
17
+ // the current scheme could become parseable if the scheme ever widens.
18
+ export const isInMintedNamespace = (name: string): boolean =>
19
+ name.startsWith(`${NAME_PREFIX}/`);
20
+
21
+ export type BrokeredTokenRef = {
22
+ readonly repo: string;
23
+ readonly target: string;
24
+ readonly key: string;
25
+ };
26
+
27
+ const SAFE_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9._-]*$/u;
28
+ const SAFE_REPO = /^[A-Za-z0-9][A-Za-z0-9._-]*\/[A-Za-z0-9][A-Za-z0-9._-]*$/u;
29
+
30
+ export const assertTokenRef = ({
31
+ repo,
32
+ target,
33
+ key,
34
+ }: BrokeredTokenRef): void => {
35
+ if (!SAFE_REPO.test(repo)) {
36
+ throw new Error(`invalid repository for token naming: ${repo}`);
37
+ }
38
+ if (!SAFE_SEGMENT.test(target)) {
39
+ throw new Error(`invalid secrets target for token naming: ${target}`);
40
+ }
41
+ for (const segment of key.split('.')) {
42
+ if (!SAFE_SEGMENT.test(segment)) {
43
+ throw new Error(`invalid secret key for token naming: ${key}`);
44
+ }
45
+ }
46
+ };
47
+
48
+ export const tokenNameOf = (ref: BrokeredTokenRef): string => {
49
+ assertTokenRef(ref);
50
+ return `${NAME_PREFIX}/${ref.repo}/${ref.target}/${ref.key}`;
51
+ };
52
+
53
+ export const repoTokenPrefix = (repo: string): string =>
54
+ `${NAME_PREFIX}/${repo}/`;
55
+
56
+ // Only names this broker minted for this repository parse; every other token
57
+ // (hand-made, other repos, other tools) returns null and is never touched.
58
+ export const parseTokenName = (
59
+ name: string,
60
+ repo: string,
61
+ ): BrokeredTokenRef | null => {
62
+ const prefix = repoTokenPrefix(repo);
63
+ if (!name.startsWith(prefix)) {
64
+ return null;
65
+ }
66
+ const rest = name.slice(prefix.length);
67
+ const separator = rest.indexOf('/');
68
+ if (separator <= 0 || separator === rest.length - 1) {
69
+ return null;
70
+ }
71
+ const ref = {
72
+ repo,
73
+ target: rest.slice(0, separator),
74
+ key: rest.slice(separator + 1),
75
+ };
76
+ try {
77
+ assertTokenRef(ref);
78
+ } catch {
79
+ return null;
80
+ }
81
+ return ref;
82
+ };
@@ -0,0 +1,15 @@
1
+ import { deleteAccountToken } from './creds-cloudflare';
2
+ import type { PlannedAction } from './creds-plan';
3
+ import type { CloudflareBrokerAccount } from './creds-store';
4
+
5
+ export const revokePlannedToken = async (
6
+ account: CloudflareBrokerAccount,
7
+ action: Extract<PlannedAction, { readonly kind: 'revoke' }>,
8
+ ): Promise<string | null> => {
9
+ const deleted = await deleteAccountToken(
10
+ account.accountId,
11
+ account.token,
12
+ action.tokenId,
13
+ );
14
+ return deleted.ok ? null : `${action.name}: ${deleted.problem}`;
15
+ };
@@ -0,0 +1,198 @@
1
+ import {
2
+ createAccountToken as createToken,
3
+ deleteAccountToken as deleteToken,
4
+ } from './creds-cloudflare';
5
+ import { listSecretsTargets, resolveTargetRel } from './creds-dest';
6
+ import { identifyCloudflareBootstrapAuthority } from './creds-login-cloudflare';
7
+ import {
8
+ type AccountToken,
9
+ computeCredsPlan,
10
+ type PlannedAction,
11
+ } from './creds-plan';
12
+ import { revokePlannedToken } from './creds-plan-revoke';
13
+ import {
14
+ inspectSopsScalarDestination as inspectDestination,
15
+ readEncryptedKeys,
16
+ verifySopsStoredValue as verifyStoredValue,
17
+ setSopsValue as writeValue,
18
+ } from './creds-sops';
19
+ import {
20
+ type BrokerStore,
21
+ type CloudflareBrokerAccount,
22
+ readBrokerStore,
23
+ resolveBrokerPath,
24
+ } from './creds-store';
25
+ import { resolveGithubRepo } from './github-api';
26
+
27
+ const gatherRepoState = async (consumer: string, store: BrokerStore) => {
28
+ const keysByTarget = new Map<string, ReadonlySet<string>>();
29
+ const targetKeys = await Promise.all(
30
+ listSecretsTargets(consumer).map(async ({ target, rel }) => ({
31
+ target,
32
+ rel,
33
+ keys: await readEncryptedKeys(consumer, rel),
34
+ })),
35
+ );
36
+ const problems: Array<string> = [];
37
+ for (const { target, rel, keys } of targetKeys) {
38
+ if (keys.ok) {
39
+ keysByTarget.set(target, new Set(keys.keys));
40
+ } else {
41
+ problems.push(`${rel}: ${keys.problem}`);
42
+ }
43
+ }
44
+ const tokens: Array<AccountToken> = [];
45
+ const listings = await Promise.all(
46
+ store.cloudflare.map(async (account) => ({
47
+ account,
48
+ identified: await identifyCloudflareBootstrapAuthority(
49
+ account.accountId,
50
+ account.token,
51
+ ),
52
+ })),
53
+ );
54
+ for (const { account, identified } of listings) {
55
+ if (identified.ok) {
56
+ tokens.push(
57
+ ...identified.value.tokens
58
+ .filter((token) => token.id !== identified.value.id)
59
+ .map((token) => ({
60
+ accountId: account.accountId,
61
+ token,
62
+ })),
63
+ );
64
+ } else {
65
+ problems.push(`account ${account.accountId}: ${identified.problem}`);
66
+ }
67
+ }
68
+ return { keysByTarget, tokens, problems };
69
+ };
70
+
71
+ const cleanupReplacement = async (
72
+ account: CloudflareBrokerAccount,
73
+ replacementId: string,
74
+ oldId: string,
75
+ context: string,
76
+ ): Promise<string> => {
77
+ const { accountId, token } = account;
78
+ const cleanup = await deleteToken(accountId, token, replacementId);
79
+ return cleanup.ok
80
+ ? `${context}; deleted replacement ${replacementId} and preserved old token ${oldId}`
81
+ : `${context}; cleanup of replacement ${replacementId} also failed: ${cleanup.problem}; old token ${oldId} remains active`;
82
+ };
83
+
84
+ const applyAction = async (
85
+ consumer: string,
86
+ store: BrokerStore,
87
+ action: PlannedAction,
88
+ ): Promise<string | null> => {
89
+ const account = store.cloudflare.find(
90
+ (entry) => entry.accountId === action.accountId,
91
+ );
92
+ if (account === undefined) {
93
+ return `${action.name}: account ${action.accountId} is not in the broker store`;
94
+ }
95
+ const { accountId, token: bootstrapToken } = account;
96
+ if (action.kind === 'revoke') {
97
+ return revokePlannedToken(account, action);
98
+ }
99
+ const rel = resolveTargetRel(consumer, action.target);
100
+ if (rel === null) {
101
+ return `${action.name}: secrets target ${action.target} not found`;
102
+ }
103
+ const destination = await inspectDestination(consumer, rel, action.key);
104
+ if (!destination.ok) {
105
+ return `${action.name}: ${destination.problem}`;
106
+ }
107
+ if (destination.state !== 'scalar') {
108
+ return `${action.name}: secret ${action.target}:${action.key} disappeared before renewal`;
109
+ }
110
+ const replacement = await createToken(accountId, bootstrapToken, {
111
+ name: action.name,
112
+ policies: action.policies,
113
+ expiresOn: action.replacementExpiresOn,
114
+ condition: action.condition,
115
+ });
116
+ if (!replacement.ok) {
117
+ return `${action.name}: ${replacement.problem}`;
118
+ }
119
+ const { id: replacementId, value } = replacement.value;
120
+ const written = writeValue(consumer, rel, action.key, value);
121
+ const verified = verifyStoredValue(consumer, rel, action.key, value);
122
+ if (verified.ok && !verified.matches) {
123
+ const problem = written.ok
124
+ ? `${action.name}: ${rel} at ${action.key} now holds a value matching neither the old nor the replacement token; repair the stored value manually`
125
+ : `${action.name}: ${written.problem}`;
126
+ return cleanupReplacement(account, replacementId, action.tokenId, problem);
127
+ }
128
+ if (!verified.ok) {
129
+ return `${action.name}: ${written.ok ? verified.problem : `${written.problem}; ${verified.problem}`}; account ${accountId} replacement ${replacementId} and old token ${action.tokenId} remain active because the stored value is unverifiable`;
130
+ }
131
+ const deleted = await deleteToken(accountId, bootstrapToken, action.tokenId);
132
+ return deleted.ok
133
+ ? null
134
+ : `${action.name}: replacement ${replacementId} is stored, but old token ${action.tokenId} could not be revoked: ${deleted.problem}`;
135
+ };
136
+
137
+ const fail = (message: string): false => {
138
+ console.error(`standards creds: ${message}`);
139
+ return false;
140
+ };
141
+
142
+ export const runCredsPlan = async (
143
+ consumer: string,
144
+ apply: boolean,
145
+ ): Promise<boolean> => {
146
+ const store = await readBrokerStore(resolveBrokerPath());
147
+ if (store.cloudflare.length === 0) {
148
+ console.log(
149
+ 'standards creds: no Cloudflare accounts configured; nothing to reconcile (`standards creds login cloudflare`)',
150
+ );
151
+ return true;
152
+ }
153
+ const repo = resolveGithubRepo(consumer);
154
+ if (repo === null) {
155
+ return fail('cannot resolve the GitHub repository from the origin remote');
156
+ }
157
+ const state = await gatherRepoState(consumer, store);
158
+ for (const problem of state.problems) {
159
+ console.error(`standards creds: ${problem}`);
160
+ }
161
+ if (state.problems.length > 0) {
162
+ return fail(
163
+ 'reconciliation aborted because repository or provider state could not be read safely',
164
+ );
165
+ }
166
+ const plan = computeCredsPlan({
167
+ repo,
168
+ keysByTarget: state.keysByTarget,
169
+ tokens: state.tokens,
170
+ now: new Date(),
171
+ });
172
+ for (const action of plan.actions) {
173
+ console.log(
174
+ ` ${apply ? '' : 'would '}${action.kind} ${action.name} (${action.reason})`,
175
+ );
176
+ }
177
+ for (const finding of plan.findings) {
178
+ console.error(`standards creds: ${finding}`);
179
+ }
180
+ console.log(
181
+ `standards creds: ${plan.actions.length} action(s), ${plan.findings.length} finding(s), ${plan.healthy} brokered token(s) healthy`,
182
+ );
183
+ if (plan.findings.length > 0) {
184
+ return fail('reconciliation aborted until every finding is resolved');
185
+ }
186
+ if (!apply || plan.actions.length === 0) {
187
+ return true;
188
+ }
189
+ const failures = (
190
+ await Promise.all(
191
+ plan.actions.map((action) => applyAction(consumer, store, action)),
192
+ )
193
+ ).filter((failure): failure is string => failure !== null);
194
+ for (const failure of failures) {
195
+ console.error(`standards creds: ${failure}`);
196
+ }
197
+ return failures.length === 0;
198
+ };