@davidvornholt/standards 0.13.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.
- package/README.md +10 -1
- package/package.json +27 -1
- package/src/cli.ts +11 -0
- package/src/creds-add-cloudflare-commit.ts +46 -0
- package/src/creds-add-github.ts +66 -0
- package/src/creds-add.ts +183 -0
- package/src/creds-browser.ts +28 -0
- package/src/creds-cloudflare-account.ts +4 -0
- package/src/creds-cloudflare-api.ts +200 -0
- package/src/creds-cloudflare-condition.ts +63 -0
- package/src/creds-cloudflare-pagination.ts +49 -0
- package/src/creds-cloudflare.ts +197 -0
- package/src/creds-commands.ts +200 -0
- package/src/creds-dest.ts +183 -0
- package/src/creds-login-cloudflare.ts +155 -0
- package/src/creds-login-github-manifest.ts +104 -0
- package/src/creds-login-github.ts +133 -0
- package/src/creds-naming.ts +82 -0
- package/src/creds-plan-revoke.ts +15 -0
- package/src/creds-plan-run.ts +198 -0
- package/src/creds-plan.ts +185 -0
- package/src/creds-prompt.ts +62 -0
- package/src/creds-sops-editor.ts +170 -0
- package/src/creds-sops-structure.ts +199 -0
- package/src/creds-sops.ts +198 -0
- package/src/creds-store-lock.ts +102 -0
- package/src/creds-store.ts +199 -0
- package/src/dev-env.ts +4 -22
- package/src/poller-codex.ts +23 -3
- package/src/poller-config.ts +11 -1
- package/src/poller-job-shared.ts +3 -1
- package/src/poller-units.ts +7 -2
- package/src/sops-exec.ts +47 -0
package/README.md
CHANGED
|
@@ -13,6 +13,12 @@ standards dependabot --write
|
|
|
13
13
|
standards dev-env
|
|
14
14
|
standards github --check
|
|
15
15
|
standards github --apply
|
|
16
|
+
standards creds login github
|
|
17
|
+
standards creds login cloudflare
|
|
18
|
+
standards creds add cloudflare --dest ci:ci.cloudflare_workers_token --permissions "Workers Scripts Write"
|
|
19
|
+
standards creds add github --dest ci:ci.broker_app
|
|
20
|
+
standards creds plan
|
|
21
|
+
standards creds apply
|
|
16
22
|
standards poller --config <path>
|
|
17
23
|
standards poller --print-units --config <path>
|
|
18
24
|
```
|
|
@@ -29,6 +35,8 @@ standards poller --print-units --config <path>
|
|
|
29
35
|
|
|
30
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.
|
|
31
37
|
|
|
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.
|
|
39
|
+
|
|
32
40
|
`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.
|
|
33
41
|
|
|
34
42
|
## Configuration
|
|
@@ -36,7 +44,8 @@ standards poller --print-units --config <path>
|
|
|
36
44
|
- **`.github/dependabot.local.yml`** (optional) — repo-owned Dependabot extension composed with the canonical `.github/dependabot.base.yml` into the generated `.github/dependabot.yml`. May define `updates` and top-level `registries`; a block matching a canonical normalized target may only add `ignore` and/or `registries` entries. Other matching-block policy is rejected deliberately. A missing or comments-only file means no additions. CLI 0.10.1 requires the selected content ref to contain the canonical base and rejects older refs before mutation.
|
|
37
45
|
- **`sync-standards.local.json`** (optional) — consumer-owned sync policy at the repo root, validated by `doctor`/`check` and every `init`/`sync` even when an explicit ref or local source makes its `"ref"` irrelevant. All fields optional; a missing file means the defaults. `"ref"` is a non-empty single-line string that pins a tag, branch, or full commit sha to sync from instead of `main` (an explicit `--ref` overrides it; a local-path `--from` source is used as-is and ignores only the validated pin). `"autoSync": false` is read by the standards-sync workflow, not the CLI, and skips the weekly scheduled run; deliberate syncs then run locally because the secret-bearing workflow is schedule-only. Version 0.7.0 removes the legacy `STANDARDS_AUTO_SYNC` and `STANDARDS_SYNC_REF` variable behavior; consumers must upgrade the package and lockfile before adopting a policy file.
|
|
38
46
|
- **`.github/settings.local.json` `"rulesetEnforcement": "unavailable-on-plan"`** (optional) — declares that the repository's GitHub plan cannot enforce rulesets (private repositories on GitHub Free, both personal accounts and organizations). `github --check` and `--apply` then skip rulesets instead of comparing them, because a comparison cannot be trusted there: personal accounts answer ruleset reads with HTTP 403, and free-plan organizations report declared rulesets as active while silently not enforcing them. Plan-gated repository settings that only function alongside branch protection (`allow_auto_merge`) are skipped for the same reason — GitHub accepts a PATCH for them with HTTP 200 and silently keeps the old value. The remaining repository merge settings are still checked and converged, and both commands print an unprotected-branch notice on every run. The only accepted value is `"unavailable-on-plan"` (enforcement is the default; omit the key on paid plans), and combining the opt-out with additional local rulesets is rejected. After upgrading the plan, remove the declaration and run `bun standards github --apply` to restore enforcement.
|
|
39
|
-
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (unique `owner/repo` strings
|
|
47
|
+
- **Poller config file** (required for `poller`, passed via `--config`) — host-level JSON, deliberately not repository state. Required: `"repos"` (1–12 unique `owner/repo` strings), `"model"` and `"reasoningEffort"` (passed to `codex exec`; the model choice is deliberate and has no default). The 12-repository maximum keeps the one-minute idle tick's baseline GitHub reads within a 5,000-request/hour API budget while retaining headroom for pagination, active jobs, writes, and other token users. Existing configs above the limit must be reduced or split across pollers with independent GitHub API budgets before upgrading. Optional: `"maxJobsPerTick"` (default 1 — the concurrency and rate-limit guard; subscription-authenticated Codex runs share the interactive rate window), `"staleClaimHours"` (default 6; must exceed `runTimeoutMinutes` so a sweep can never release the claim of a job that is still running), `"runTimeoutMinutes"` (default 240 — the timeout catches wedged agents, not honest multi-hour review runs), `"cacheDir"` (default `~/.cache/standards-poller`; holds bare mirrors and job worktrees; relative paths resolve against the config file), `"extraCodexArgs"` (extra `codex exec` arguments, e.g. enabling the collaboration feature for subagent fan-out). Unknown keys are rejected. The poller needs `git`, `codex` (authenticated — its rotating auth state must live in a writable location), and a GitHub token per the bullet below; the token needs issues, pull-requests, and contents write on the watched repos, and nothing else.
|
|
48
|
+
- **Broker store** (created by `creds login`) — machine-global root credentials at `$XDG_CONFIG_HOME/standards/broker.yaml` (default `~/.config/standards/broker.yaml`; override with `STANDARDS_BROKER_FILE`), holding the GitHub App identity (id, slug, client id, private key) and one bootstrap token per Cloudflare account. The file is plaintext with `0600` permissions — deliberately the same local-account trust boundary as the plaintext personal age identity at `~/.config/sops/age/keys.txt`, which unlocks the same secrets; SOPS-encrypting the store to that adjacent identity would not improve custody. Writes use an owner-only temporary file and atomic replacement, and simultaneous provider logins merge rather than overwrite each other; `creds status` warns when the final mode is broader. The store lives outside every repository, is never synced or backed up, and losing it is cheap: re-run the two login commands and revoke the old App key and bootstrap token. On an additional machine, do not create a second App: generate an additional private key from the existing App's settings page (GitHub Apps support several active keys; key generation has no API) and add it to that machine's store, so retiring a machine revokes one key, not the setup.
|
|
40
49
|
- **`GH_TOKEN` / `GITHUB_TOKEN`** (optional) — GitHub API token for the `github` command and the GitHub portion of `check`. When neither is set, the token from the local `gh` CLI is used; with no token at all, reads still work on public repositories. `--apply` needs an admin token. GitHub's REST API reveals repo merge settings only to write-capable viewers — `check` retries REST-hidden keys over GraphQL, which serves them to read-only tokens — ruleset `bypass_actors` requires repository Administration read, and private-repository labels require Issues read (or Pull requests read); a token that still cannot see declared state fails the check rather than passing unverified. In CI, the canonical workflow decrypts `ci.github_settings_read_token` from the SOPS-encrypted `secrets/ci.yaml`: a repository-scoped fine-grained PAT with read-only "Administration" and "Issues" access.
|
|
41
50
|
|
|
42
51
|
See the standards repository README for the ownership model and adoption workflow.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@davidvornholt/standards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Bootstrap, synchronize, and validate davidvornholt/standards consumers",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -20,6 +20,30 @@
|
|
|
20
20
|
"LICENSE",
|
|
21
21
|
"README.md",
|
|
22
22
|
"src/cli.ts",
|
|
23
|
+
"src/creds-add.ts",
|
|
24
|
+
"src/creds-add-cloudflare-commit.ts",
|
|
25
|
+
"src/creds-add-github.ts",
|
|
26
|
+
"src/creds-browser.ts",
|
|
27
|
+
"src/creds-cloudflare-account.ts",
|
|
28
|
+
"src/creds-cloudflare.ts",
|
|
29
|
+
"src/creds-cloudflare-api.ts",
|
|
30
|
+
"src/creds-cloudflare-condition.ts",
|
|
31
|
+
"src/creds-cloudflare-pagination.ts",
|
|
32
|
+
"src/creds-commands.ts",
|
|
33
|
+
"src/creds-dest.ts",
|
|
34
|
+
"src/creds-login-cloudflare.ts",
|
|
35
|
+
"src/creds-login-github.ts",
|
|
36
|
+
"src/creds-login-github-manifest.ts",
|
|
37
|
+
"src/creds-naming.ts",
|
|
38
|
+
"src/creds-plan.ts",
|
|
39
|
+
"src/creds-plan-run.ts",
|
|
40
|
+
"src/creds-plan-revoke.ts",
|
|
41
|
+
"src/creds-prompt.ts",
|
|
42
|
+
"src/creds-sops.ts",
|
|
43
|
+
"src/creds-sops-editor.ts",
|
|
44
|
+
"src/creds-sops-structure.ts",
|
|
45
|
+
"src/creds-store.ts",
|
|
46
|
+
"src/creds-store-lock.ts",
|
|
23
47
|
"src/dependabot-compose-input.ts",
|
|
24
48
|
"src/dependabot-compose.ts",
|
|
25
49
|
"src/dependabot-inspect.ts",
|
|
@@ -77,6 +101,7 @@
|
|
|
77
101
|
"src/poller-trust.ts",
|
|
78
102
|
"src/poller-units.ts",
|
|
79
103
|
"src/poller-workspace.ts",
|
|
104
|
+
"src/sops-exec.ts",
|
|
80
105
|
"src/structure-check.ts",
|
|
81
106
|
"src/structure-profile.ts",
|
|
82
107
|
"src/structure-script.ts",
|
|
@@ -93,6 +118,7 @@
|
|
|
93
118
|
"devDependencies": {
|
|
94
119
|
"@davidvornholt/typescript-config": "0.0.0",
|
|
95
120
|
"@types/bun": "^1.3.14",
|
|
121
|
+
"sigstore": "5.0.0",
|
|
96
122
|
"typescript": "7.0.2"
|
|
97
123
|
},
|
|
98
124
|
"engines": {
|
package/src/cli.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { cp, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
|
17
17
|
import { tmpdir } from 'node:os';
|
|
18
18
|
import { dirname, isAbsolute, join, posix, resolve } from 'node:path';
|
|
19
19
|
import process from 'node:process';
|
|
20
|
+
import { runCredsCommand } from './creds-commands';
|
|
20
21
|
import {
|
|
21
22
|
composeDependabot,
|
|
22
23
|
DEPENDABOT_BASE_FILE,
|
|
@@ -740,6 +741,7 @@ Commands:
|
|
|
740
741
|
dependabot Verify (--check) or regenerate (--write) the composed .github/dependabot.yml
|
|
741
742
|
dev-env Write each workspace's generated .env.local from the SOPS-encrypted secrets/dev.yaml
|
|
742
743
|
github Compare (--check) or converge (--apply) live GitHub settings
|
|
744
|
+
creds Mint, rotate, and revoke brokered credentials (see \`standards creds help\`)
|
|
743
745
|
poller Run one fix-poller tick over the configured repositories (host automation)
|
|
744
746
|
help Show this help
|
|
745
747
|
|
|
@@ -1142,6 +1144,15 @@ const runGateCommand = (
|
|
|
1142
1144
|
};
|
|
1143
1145
|
|
|
1144
1146
|
const main = async (): Promise<void> => {
|
|
1147
|
+
// The creds family owns its flag vocabulary; route it before the strict
|
|
1148
|
+
// global parser rejects those flags.
|
|
1149
|
+
const rawArgs = process.argv.slice(2);
|
|
1150
|
+
if (rawArgs[0] === 'creds') {
|
|
1151
|
+
if (!(await runCredsCommand(rawArgs.slice(1)))) {
|
|
1152
|
+
process.exitCode = 1;
|
|
1153
|
+
}
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1145
1156
|
const {
|
|
1146
1157
|
command,
|
|
1147
1158
|
consumer,
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { deleteAccountToken } from './creds-cloudflare';
|
|
2
|
+
import { type SopsWriteResult, verifySopsStoredValue } from './creds-sops';
|
|
3
|
+
|
|
4
|
+
export const commitCreatedCloudflareToken = async (input: {
|
|
5
|
+
readonly consumer: string;
|
|
6
|
+
readonly rel: string;
|
|
7
|
+
readonly key: string;
|
|
8
|
+
readonly value: string;
|
|
9
|
+
readonly written: SopsWriteResult;
|
|
10
|
+
readonly accountId: string;
|
|
11
|
+
readonly bootstrapToken: string;
|
|
12
|
+
readonly tokenId: string;
|
|
13
|
+
readonly name: string;
|
|
14
|
+
}): Promise<
|
|
15
|
+
{ readonly ok: true } | { readonly ok: false; readonly problem: string }
|
|
16
|
+
> => {
|
|
17
|
+
const stored = verifySopsStoredValue(
|
|
18
|
+
input.consumer,
|
|
19
|
+
input.rel,
|
|
20
|
+
input.key,
|
|
21
|
+
input.value,
|
|
22
|
+
);
|
|
23
|
+
if (stored.ok && stored.matches) {
|
|
24
|
+
return { ok: true };
|
|
25
|
+
}
|
|
26
|
+
if (!stored.ok) {
|
|
27
|
+
return {
|
|
28
|
+
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`,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const problem = input.written.ok
|
|
33
|
+
? 'the stored SOPS value does not match the replacement'
|
|
34
|
+
: input.written.problem;
|
|
35
|
+
const cleanup = await deleteAccountToken(
|
|
36
|
+
input.accountId,
|
|
37
|
+
input.bootstrapToken,
|
|
38
|
+
input.tokenId,
|
|
39
|
+
);
|
|
40
|
+
return {
|
|
41
|
+
ok: false,
|
|
42
|
+
problem: cleanup.ok
|
|
43
|
+
? `token ${input.name} was created, but ${problem}; deleted replacement token ${input.tokenId}`
|
|
44
|
+
: `token ${input.name} was created, but ${problem}; cleanup of token ${input.tokenId} also failed: ${cleanup.problem}`,
|
|
45
|
+
};
|
|
46
|
+
};
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// `standards creds add github`: place the broker GitHub App's credentials
|
|
2
|
+
// into a SOPS target. Durable GitHub tokens cannot be minted via API (PAT
|
|
3
|
+
// and OAuth-app creation are UI-only), so the durable secret is the App
|
|
4
|
+
// identity itself; workflows mint short-lived installation tokens from it at
|
|
5
|
+
// runtime, scoped per repository and permission.
|
|
6
|
+
|
|
7
|
+
import { resolveContext } from './creds-dest';
|
|
8
|
+
import {
|
|
9
|
+
inspectSopsScalarDestination,
|
|
10
|
+
setSopsValues,
|
|
11
|
+
verifySopsScalarLeaf,
|
|
12
|
+
} from './creds-sops';
|
|
13
|
+
|
|
14
|
+
export const runCredsAddGithub = async (
|
|
15
|
+
consumer: string,
|
|
16
|
+
options: { readonly dest: string | undefined },
|
|
17
|
+
): Promise<boolean> => {
|
|
18
|
+
const context = await resolveContext(consumer, options.dest);
|
|
19
|
+
if (context === null) {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
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
|
+
const appIdPath = `${context.dest.key}.app_id`;
|
|
30
|
+
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
|
+
),
|
|
35
|
+
);
|
|
36
|
+
const blocked = preflight.find((result) => !result.ok);
|
|
37
|
+
if (blocked !== undefined && !blocked.ok) {
|
|
38
|
+
console.error(`standards creds: ${blocked.problem}`);
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
const written = setSopsValues(consumer, context.rel, [
|
|
42
|
+
{ path: appIdPath, value: String(appId) },
|
|
43
|
+
{ path: privateKeyPath, value: privateKey },
|
|
44
|
+
]);
|
|
45
|
+
if (!written.ok) {
|
|
46
|
+
console.error(`standards creds: ${written.problem}`);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
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}`);
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
console.log(
|
|
60
|
+
`standards creds: wrote App ${slug} credentials to ${context.rel} at ${context.dest.key}.{app_id,private_key}`,
|
|
61
|
+
);
|
|
62
|
+
console.log(
|
|
63
|
+
' workflows mint short-lived installation tokens from these at runtime (actions/create-github-app-token), scoped per repository and permission',
|
|
64
|
+
);
|
|
65
|
+
return true;
|
|
66
|
+
};
|
package/src/creds-add.ts
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
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';
|
|
8
|
+
import { resolveContext, selectAccount } from './creds-dest';
|
|
9
|
+
import { tokenNameOf } from './creds-naming';
|
|
10
|
+
import { inspectSopsScalarDestination, setSopsValue } from './creds-sops';
|
|
11
|
+
|
|
12
|
+
const DEFAULT_TTL_DAYS = 90;
|
|
13
|
+
const DAY_MS = 86_400_000;
|
|
14
|
+
const ACCOUNT_SCOPE = 'com.cloudflare.api.account';
|
|
15
|
+
|
|
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
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
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}`);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
export const runCredsAddCloudflare = async (
|
|
59
|
+
consumer: string,
|
|
60
|
+
options: {
|
|
61
|
+
readonly dest: string | undefined;
|
|
62
|
+
readonly permissions: string | undefined;
|
|
63
|
+
readonly account: string | undefined;
|
|
64
|
+
readonly ttlDays: number | undefined;
|
|
65
|
+
},
|
|
66
|
+
): 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
|
+
console.error(
|
|
73
|
+
'standards creds: --permissions "<Group Name>[,<Group Name>...]" is required; list names with `standards creds permissions`',
|
|
74
|
+
);
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
const account = selectAccount(context.store, options.account);
|
|
78
|
+
if (account === null) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
const destination = await inspectSopsScalarDestination(
|
|
82
|
+
consumer,
|
|
83
|
+
context.rel,
|
|
84
|
+
context.dest.key,
|
|
85
|
+
);
|
|
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}`);
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
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
|
+
);
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
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
|
+
);
|
|
110
|
+
return false;
|
|
111
|
+
}
|
|
112
|
+
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
|
+
})),
|
|
118
|
+
);
|
|
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
|
+
);
|
|
135
|
+
return false;
|
|
136
|
+
}
|
|
137
|
+
const ttlDays = options.ttlDays ?? DEFAULT_TTL_DAYS;
|
|
138
|
+
const expiresOn = new Date(Date.now() + ttlDays * DAY_MS).toISOString();
|
|
139
|
+
const created = await createAccountToken(account.accountId, account.token, {
|
|
140
|
+
name,
|
|
141
|
+
expiresOn,
|
|
142
|
+
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
|
+
],
|
|
150
|
+
});
|
|
151
|
+
if (!created.ok) {
|
|
152
|
+
console.error(`standards creds: ${created.problem}`);
|
|
153
|
+
return false;
|
|
154
|
+
}
|
|
155
|
+
const written = setSopsValue(
|
|
156
|
+
consumer,
|
|
157
|
+
context.rel,
|
|
158
|
+
context.dest.key,
|
|
159
|
+
created.value.value,
|
|
160
|
+
);
|
|
161
|
+
const committed = await commitCreatedCloudflareToken({
|
|
162
|
+
consumer,
|
|
163
|
+
rel: context.rel,
|
|
164
|
+
key: context.dest.key,
|
|
165
|
+
value: created.value.value,
|
|
166
|
+
written,
|
|
167
|
+
accountId: account.accountId,
|
|
168
|
+
bootstrapToken: account.token,
|
|
169
|
+
tokenId: created.value.id,
|
|
170
|
+
name,
|
|
171
|
+
});
|
|
172
|
+
if (!committed.ok) {
|
|
173
|
+
console.error(`standards creds: ${committed.problem}`);
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
printSuccess(
|
|
177
|
+
name,
|
|
178
|
+
wanted,
|
|
179
|
+
expiresOn,
|
|
180
|
+
`${context.rel} at ${context.dest.key}`,
|
|
181
|
+
);
|
|
182
|
+
return true;
|
|
183
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Best-effort browser opener for login flows. The URL is always printed too,
|
|
2
|
+
// so a failure to spawn an opener is never an error.
|
|
3
|
+
|
|
4
|
+
import { spawn } from 'node:child_process';
|
|
5
|
+
import process from 'node:process';
|
|
6
|
+
|
|
7
|
+
const openerFor = (platform: string): 'open' | 'start' | 'xdg-open' => {
|
|
8
|
+
if (platform === 'darwin') {
|
|
9
|
+
return 'open';
|
|
10
|
+
}
|
|
11
|
+
if (platform === 'win32') {
|
|
12
|
+
return 'start';
|
|
13
|
+
}
|
|
14
|
+
return 'xdg-open';
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const openInBrowser = (url: string): void => {
|
|
18
|
+
try {
|
|
19
|
+
const child = spawn(openerFor(process.platform), [url], {
|
|
20
|
+
stdio: 'ignore',
|
|
21
|
+
detached: true,
|
|
22
|
+
});
|
|
23
|
+
child.on('error', () => undefined);
|
|
24
|
+
child.unref();
|
|
25
|
+
} catch {
|
|
26
|
+
// Printed URL remains the fallback.
|
|
27
|
+
}
|
|
28
|
+
};
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Cloudflare v4 API plumbing shared by the account-token operations in
|
|
2
|
+
// creds-cloudflare.ts: envelope parsing, error folding, and shared types.
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
type DecodedTokenCondition,
|
|
6
|
+
decodeTokenCondition,
|
|
7
|
+
} from './creds-cloudflare-condition';
|
|
8
|
+
import { isRecord } from './github-settings-parse';
|
|
9
|
+
|
|
10
|
+
const API_ROOT = 'https://api.cloudflare.com/client/v4';
|
|
11
|
+
|
|
12
|
+
export type CfResult<T> =
|
|
13
|
+
| { readonly ok: true; readonly value: T }
|
|
14
|
+
| { readonly ok: false; readonly problem: string };
|
|
15
|
+
|
|
16
|
+
export type CloudflareToken = {
|
|
17
|
+
readonly id: string;
|
|
18
|
+
readonly name: string;
|
|
19
|
+
readonly status: string;
|
|
20
|
+
readonly expiresOn: string | null;
|
|
21
|
+
readonly issuedOn: string | null;
|
|
22
|
+
readonly policies: ReadonlyArray<TokenPolicy> | null;
|
|
23
|
+
readonly condition: DecodedTokenCondition;
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type PermissionGroup = {
|
|
27
|
+
readonly id: string;
|
|
28
|
+
readonly name: string;
|
|
29
|
+
readonly scopes: ReadonlyArray<string>;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type TokenPolicy = {
|
|
33
|
+
readonly effect: 'allow' | 'deny';
|
|
34
|
+
readonly resources: Readonly<
|
|
35
|
+
Record<string, string | Readonly<Record<string, string>>>
|
|
36
|
+
>;
|
|
37
|
+
readonly permission_groups: ReadonlyArray<{ readonly id: string }>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type ResultInfo = {
|
|
41
|
+
readonly page: number;
|
|
42
|
+
readonly perPage: number;
|
|
43
|
+
readonly count: number;
|
|
44
|
+
readonly totalCount: number;
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type Envelope = {
|
|
48
|
+
readonly success: boolean;
|
|
49
|
+
readonly errors: ReadonlyArray<{ readonly message: string }>;
|
|
50
|
+
readonly result: unknown;
|
|
51
|
+
readonly resultInfo: ResultInfo | null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const cfRequest = async (
|
|
55
|
+
token: string,
|
|
56
|
+
method: string,
|
|
57
|
+
path: string,
|
|
58
|
+
body?: unknown,
|
|
59
|
+
): Promise<CfResult<Envelope>> => {
|
|
60
|
+
const response = await fetch(`${API_ROOT}${path}`, {
|
|
61
|
+
method,
|
|
62
|
+
headers: {
|
|
63
|
+
authorization: `Bearer ${token}`,
|
|
64
|
+
...(body === undefined ? {} : { 'content-type': 'application/json' }),
|
|
65
|
+
},
|
|
66
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
67
|
+
});
|
|
68
|
+
let parsed: unknown = null;
|
|
69
|
+
try {
|
|
70
|
+
parsed = (await response.json()) as unknown;
|
|
71
|
+
} catch {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
problem: `${method} ${path}: HTTP ${response.status} with a non-JSON body`,
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (!isRecord(parsed)) {
|
|
78
|
+
return { ok: false, problem: `${method} ${path}: unexpected response` };
|
|
79
|
+
}
|
|
80
|
+
const errors = Array.isArray(parsed.errors)
|
|
81
|
+
? parsed.errors.filter(isRecord).map((error) => ({
|
|
82
|
+
message:
|
|
83
|
+
typeof error.message === 'string' ? error.message : 'unknown error',
|
|
84
|
+
}))
|
|
85
|
+
: [];
|
|
86
|
+
const info = isRecord(parsed.result_info) ? parsed.result_info : {};
|
|
87
|
+
const resultInfo =
|
|
88
|
+
typeof info.page === 'number' &&
|
|
89
|
+
Number.isInteger(info.page) &&
|
|
90
|
+
info.page >= 1 &&
|
|
91
|
+
typeof info.per_page === 'number' &&
|
|
92
|
+
Number.isInteger(info.per_page) &&
|
|
93
|
+
info.per_page >= 1 &&
|
|
94
|
+
typeof info.count === 'number' &&
|
|
95
|
+
Number.isInteger(info.count) &&
|
|
96
|
+
info.count >= 0 &&
|
|
97
|
+
typeof info.total_count === 'number' &&
|
|
98
|
+
Number.isInteger(info.total_count) &&
|
|
99
|
+
info.total_count >= 0
|
|
100
|
+
? {
|
|
101
|
+
page: info.page,
|
|
102
|
+
perPage: info.per_page,
|
|
103
|
+
count: info.count,
|
|
104
|
+
totalCount: info.total_count,
|
|
105
|
+
}
|
|
106
|
+
: null;
|
|
107
|
+
const envelope: Envelope = {
|
|
108
|
+
success: parsed.success === true,
|
|
109
|
+
errors,
|
|
110
|
+
result: parsed.result,
|
|
111
|
+
resultInfo,
|
|
112
|
+
};
|
|
113
|
+
if (!envelope.success) {
|
|
114
|
+
const detail =
|
|
115
|
+
errors.length > 0
|
|
116
|
+
? errors.map((error) => error.message).join('; ')
|
|
117
|
+
: `HTTP ${response.status}`;
|
|
118
|
+
return { ok: false, problem: `${method} ${path}: ${detail}` };
|
|
119
|
+
}
|
|
120
|
+
return { ok: true, value: envelope };
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const stringRecordOf = (
|
|
124
|
+
raw: unknown,
|
|
125
|
+
): Readonly<Record<string, string>> | null => {
|
|
126
|
+
if (!isRecord(raw)) {
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
return Object.values(raw).every((value) => typeof value === 'string')
|
|
130
|
+
? (raw as Readonly<Record<string, string>>)
|
|
131
|
+
: null;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
const resourcesOf = (raw: unknown): TokenPolicy['resources'] | null => {
|
|
135
|
+
if (!isRecord(raw)) {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
const resources: Record<string, string | Readonly<Record<string, string>>> =
|
|
139
|
+
{};
|
|
140
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
141
|
+
if (typeof value === 'string') {
|
|
142
|
+
resources[key] = value;
|
|
143
|
+
} else {
|
|
144
|
+
const nested = stringRecordOf(value);
|
|
145
|
+
if (nested === null) {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
resources[key] = nested;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return resources;
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
const policyOf = (raw: unknown): TokenPolicy | null => {
|
|
155
|
+
if (
|
|
156
|
+
!(
|
|
157
|
+
isRecord(raw) &&
|
|
158
|
+
(raw.effect === 'allow' || raw.effect === 'deny') &&
|
|
159
|
+
Array.isArray(raw.permission_groups)
|
|
160
|
+
)
|
|
161
|
+
) {
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
const resources = resourcesOf(raw.resources);
|
|
165
|
+
const permissionGroups = raw.permission_groups
|
|
166
|
+
.filter(isRecord)
|
|
167
|
+
.filter((group) => typeof group.id === 'string')
|
|
168
|
+
.map((group) => ({ id: group.id as string }));
|
|
169
|
+
return resources !== null &&
|
|
170
|
+
permissionGroups.length === raw.permission_groups.length
|
|
171
|
+
? {
|
|
172
|
+
effect: raw.effect,
|
|
173
|
+
resources,
|
|
174
|
+
permission_groups: permissionGroups,
|
|
175
|
+
}
|
|
176
|
+
: null;
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const policiesOf = (raw: unknown): ReadonlyArray<TokenPolicy> | null => {
|
|
180
|
+
if (!Array.isArray(raw)) {
|
|
181
|
+
return null;
|
|
182
|
+
}
|
|
183
|
+
const policies = raw.map(policyOf);
|
|
184
|
+
return policies.every((policy) => policy !== null)
|
|
185
|
+
? (policies as ReadonlyArray<TokenPolicy>)
|
|
186
|
+
: null;
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
export const tokenOf = (raw: unknown): CloudflareToken | null =>
|
|
190
|
+
isRecord(raw) && typeof raw.id === 'string' && typeof raw.name === 'string'
|
|
191
|
+
? {
|
|
192
|
+
id: raw.id,
|
|
193
|
+
name: raw.name,
|
|
194
|
+
status: typeof raw.status === 'string' ? raw.status : 'unknown',
|
|
195
|
+
expiresOn: typeof raw.expires_on === 'string' ? raw.expires_on : null,
|
|
196
|
+
issuedOn: typeof raw.issued_on === 'string' ? raw.issued_on : null,
|
|
197
|
+
policies: policiesOf(raw.policies),
|
|
198
|
+
condition: decodeTokenCondition(raw.condition),
|
|
199
|
+
}
|
|
200
|
+
: null;
|