@pithy-sh/cli 0.1.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/LICENSE +21 -0
- package/package.json +72 -0
- package/scripts/templateManifest.ts +49 -0
- package/scripts/tsconfig.json +26 -0
- package/scripts/vendorTemplate.ts +84 -0
- package/scripts/verifyPack.ts +88 -0
- package/src/audit/cliAudit.ts +406 -0
- package/src/bin.ts +111 -0
- package/src/capabilities/add.ts +288 -0
- package/src/capabilities/addBootstrap.ts +275 -0
- package/src/capabilities/catalog.ts +175 -0
- package/src/capabilities/compose.ts +39 -0
- package/src/capabilities/configConstants.ts +74 -0
- package/src/capabilities/configImports.ts +397 -0
- package/src/capabilities/eject.ts +331 -0
- package/src/capabilities/emailProvisioner.ts +346 -0
- package/src/capabilities/entitlementGap.ts +70 -0
- package/src/capabilities/entryExports.ts +162 -0
- package/src/capabilities/flow.ts +550 -0
- package/src/capabilities/hostRegistry.ts +368 -0
- package/src/capabilities/loadFailure.ts +208 -0
- package/src/capabilities/manifests.ts +238 -0
- package/src/capabilities/mediaProvisioner.ts +471 -0
- package/src/capabilities/mintSecrets.ts +306 -0
- package/src/capabilities/paymentsProvisioner.ts +207 -0
- package/src/capabilities/prerequisites.ts +168 -0
- package/src/capabilities/r2Bucket.ts +113 -0
- package/src/capabilities/reconcile.ts +1483 -0
- package/src/capabilities/remove.ts +597 -0
- package/src/capabilities/requiredOptions.ts +92 -0
- package/src/capabilities/rotateSecrets.ts +305 -0
- package/src/capabilities/secrets.ts +178 -0
- package/src/capabilities/secretsDispatcher.ts +29 -0
- package/src/capabilities/secretsProvisioner.ts +389 -0
- package/src/capabilities/storageProvisioner.ts +414 -0
- package/src/capabilities/supportProvisioner.ts +515 -0
- package/src/capabilities/testersLoader.ts +52 -0
- package/src/capabilities/testersProvisioner.ts +236 -0
- package/src/capabilities/turnstileProvisioner.ts +347 -0
- package/src/capabilities/vectorProvisioner.ts +260 -0
- package/src/ci/fileModes.ts +223 -0
- package/src/ci/sourceFiles.ts +200 -0
- package/src/ci/workflowDrivers.ts +524 -0
- package/src/cloudflare/accountAnswer.ts +110 -0
- package/src/cloudflare/config.ts +685 -0
- package/src/cloudflare/storeId.ts +129 -0
- package/src/commands/add.ts +372 -0
- package/src/commands/alias.ts +205 -0
- package/src/commands/dashboard.ts +651 -0
- package/src/commands/deploy.ts +150 -0
- package/src/commands/dev.ts +37 -0
- package/src/commands/doctor.ts +2059 -0
- package/src/commands/email.ts +425 -0
- package/src/commands/env.ts +155 -0
- package/src/commands/feature.ts +359 -0
- package/src/commands/init.ts +538 -0
- package/src/commands/media.ts +303 -0
- package/src/commands/migrate.ts +129 -0
- package/src/commands/payments.ts +336 -0
- package/src/commands/provision.ts +368 -0
- package/src/commands/remove.ts +151 -0
- package/src/commands/secrets.ts +652 -0
- package/src/commands/seed.ts +229 -0
- package/src/commands/storage.ts +309 -0
- package/src/commands/support.ts +331 -0
- package/src/commands/testers.ts +1020 -0
- package/src/commands/token.ts +364 -0
- package/src/commands/turnstile.ts +271 -0
- package/src/commands/ui.ts +222 -0
- package/src/commands/upgrade.ts +517 -0
- package/src/commands/vector.ts +390 -0
- package/src/commands/worker.ts +295 -0
- package/src/dashboard/api.ts +323 -0
- package/src/dashboard/connect.ts +758 -0
- package/src/dashboard/contract.ts +289 -0
- package/src/dashboard/grant.ts +124 -0
- package/src/dashboard/registry.ts +519 -0
- package/src/dashboard/resolveTarget.ts +119 -0
- package/src/dev/delivery.ts +174 -0
- package/src/dev/devLogin.ts +155 -0
- package/src/dev/devLoginTargets.ts +91 -0
- package/src/dev/env.ts +206 -0
- package/src/dev/hostWorkers.ts +290 -0
- package/src/dev/keys.ts +111 -0
- package/src/dev/logging.ts +87 -0
- package/src/dev/openUrl.ts +75 -0
- package/src/dev/orchestrator.ts +1014 -0
- package/src/dev/ports.ts +220 -0
- package/src/dev/readyWatch.ts +142 -0
- package/src/dev/state.ts +90 -0
- package/src/devSecrets/bootstrapVars.ts +265 -0
- package/src/devSecrets/devVars.ts +240 -0
- package/src/devSecrets/edit.ts +256 -0
- package/src/devSecrets/file.ts +277 -0
- package/src/devSecrets/generate.ts +428 -0
- package/src/devSecrets/location.ts +80 -0
- package/src/devSecrets/mode.ts +71 -0
- package/src/devSecrets/records.ts +30 -0
- package/src/devSecrets/report.ts +99 -0
- package/src/devSecrets/seed.ts +344 -0
- package/src/devSecrets/store.ts +262 -0
- package/src/devSecrets/targets.ts +204 -0
- package/src/dispatch.ts +147 -0
- package/src/docs/catalog.ts +246 -0
- package/src/docs/writeCatalog.ts +45 -0
- package/src/doctor/cloudflare.ts +287 -0
- package/src/doctor/devPreferences.ts +155 -0
- package/src/doctor/devSecrets.ts +464 -0
- package/src/doctor/devVars.ts +414 -0
- package/src/doctor/devVarsLocal.ts +138 -0
- package/src/doctor/environments.ts +155 -0
- package/src/doctor/health.ts +354 -0
- package/src/doctor/localDelivery.ts +91 -0
- package/src/doctor/portsRegistry.ts +252 -0
- package/src/doctor/projectName.ts +584 -0
- package/src/doctor/secretBindings.ts +166 -0
- package/src/doctor/settings.ts +274 -0
- package/src/doctor/settingsSources.ts +202 -0
- package/src/doctor/workerName.ts +174 -0
- package/src/doctor/wranglerVars.ts +33 -0
- package/src/feature/bindings.ts +93 -0
- package/src/feature/create.ts +179 -0
- package/src/feature/destroy.ts +160 -0
- package/src/feature/devConfig.ts +201 -0
- package/src/feature/identity.ts +100 -0
- package/src/feature/manifest.ts +132 -0
- package/src/feature/ports.ts +615 -0
- package/src/feature/provision.ts +362 -0
- package/src/feature/sync.ts +148 -0
- package/src/feature/worktree.ts +282 -0
- package/src/help/groups.ts +47 -0
- package/src/help/rootUsage.ts +135 -0
- package/src/main.ts +73 -0
- package/src/migrations/ledger.ts +129 -0
- package/src/migrations/registry.ts +47 -0
- package/src/migrations/run.ts +1066 -0
- package/src/notifier/check.ts +129 -0
- package/src/notifier/installer.ts +48 -0
- package/src/notifier/notify.ts +152 -0
- package/src/notifier/state.ts +248 -0
- package/src/notifier/version.ts +59 -0
- package/src/platform/editor.ts +333 -0
- package/src/platform/rc.ts +118 -0
- package/src/platform/shell.ts +83 -0
- package/src/project/appBindings.ts +184 -0
- package/src/project/appWorkflows.ts +266 -0
- package/src/project/applyDomains.ts +166 -0
- package/src/project/askDomains.ts +220 -0
- package/src/project/atomic.ts +466 -0
- package/src/project/bindingEntries.ts +425 -0
- package/src/project/config.ts +701 -0
- package/src/project/dashboard.ts +118 -0
- package/src/project/deploy.ts +364 -0
- package/src/project/devVars.ts +113 -0
- package/src/project/domainPrompt.ts +191 -0
- package/src/project/domains.ts +386 -0
- package/src/project/envInventory.ts +356 -0
- package/src/project/environment.ts +125 -0
- package/src/project/extensions.ts +69 -0
- package/src/project/jsonc.ts +289 -0
- package/src/project/packageManager.ts +238 -0
- package/src/project/readOptionalFile.ts +342 -0
- package/src/project/rollback.ts +145 -0
- package/src/project/scaffold.ts +1088 -0
- package/src/project/templateFiles.ts +53 -0
- package/src/project/verifyDeploy.ts +230 -0
- package/src/project/versionMetadata.ts +77 -0
- package/src/project/workerAddress.ts +176 -0
- package/src/project/workerCommand.ts +564 -0
- package/src/project/workerIdentity.ts +50 -0
- package/src/project/workerManifest.ts +135 -0
- package/src/project/workerScaffold.ts +289 -0
- package/src/project/workerScope.ts +394 -0
- package/src/project/workers.ts +86 -0
- package/src/project/workflows.ts +281 -0
- package/src/project/wrangler.ts +168 -0
- package/src/provision/confirm.ts +86 -0
- package/src/provision/environment.ts +407 -0
- package/src/provision/featureConfig.ts +98 -0
- package/src/provision/mode.ts +62 -0
- package/src/provision/pendingSecrets.ts +96 -0
- package/src/provision/resources.ts +126 -0
- package/src/provision/secretBindings.ts +149 -0
- package/src/provision/store.ts +33 -0
- package/src/provision/unprovisioned.ts +114 -0
- package/src/provision/wranglerEnv.ts +220 -0
- package/src/rootFlags.ts +48 -0
- package/src/seed/drivers.ts +423 -0
- package/src/seed/media.ts +187 -0
- package/src/seed/plan.ts +137 -0
- package/src/seed/prepare.ts +224 -0
- package/src/seed/registry.ts +25 -0
- package/src/seed/run.ts +793 -0
- package/src/seed/safety.ts +206 -0
- package/src/terminal/logger.ts +42 -0
- package/src/terminal/output.ts +64 -0
- package/src/terminal/style.ts +132 -0
- package/src/test-utils/doctorHarness.ts +190 -0
- package/src/test-utils/migrateHarness.ts +126 -0
- package/src/test-utils/seedHarness.ts +173 -0
- package/src/test-utils/tempRepo.ts +45 -0
- package/src/tokens/config.ts +16 -0
- package/src/tokens/engine.ts +345 -0
- package/src/tokens/mintedTokens.ts +233 -0
- package/src/tokens/sinks.ts +84 -0
- package/src/ui/flow.ts +451 -0
- package/src/ui/react.ts +112 -0
- package/src/ui/routeAllowlist.ts +208 -0
- package/src/ui/scaffold.ts +113 -0
- package/src/ui/screenStyles.ts +127 -0
- package/src/ui/stubs.ts +135 -0
- package/src/ui/templates.ts +52 -0
- package/src/ui/wire.ts +311 -0
- package/src/ui/workerUi.ts +172 -0
- package/templates/starter/.dev.secrets.example.jsonc +43 -0
- package/templates/starter/.dev.vars.example +30 -0
- package/templates/starter/apps/api/package.json +22 -0
- package/templates/starter/apps/api/pithy.config.ts +65 -0
- package/templates/starter/apps/api/pithy.worker.jsonc +11 -0
- package/templates/starter/apps/api/src/bindings.workers.test.ts +18 -0
- package/templates/starter/apps/api/src/cloudflare-test.d.ts +11 -0
- package/templates/starter/apps/api/src/index.ts +8 -0
- package/templates/starter/apps/api/tsconfig.json +26 -0
- package/templates/starter/apps/api/wrangler.jsonc +68 -0
- package/templates/starter/biome.template.jsonc +75 -0
- package/templates/starter/gitignore +37 -0
- package/templates/starter/package.json +28 -0
- package/templates/starter/pithy.config.ts +67 -0
- package/templates/starter/plugins/no-console.grit +25 -0
- package/templates/starter/plugins/no-process-io.grit +25 -0
- package/templates/starter/tsconfig.json +14 -0
- package/templates/starter/tsconfig.tools.json +30 -0
- package/templates/starter/vitest.config.ts +124 -0
- package/templates/starter/vitest.workers.config.ts +26 -0
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareNotConfiguredError } from "@pithy-sh/cloudflare/src/client/errors";
|
|
5
|
+
import type {
|
|
6
|
+
AccountTokenSummary,
|
|
7
|
+
MintedAccountToken,
|
|
8
|
+
TokenPermission,
|
|
9
|
+
} from "@pithy-sh/cloudflare/src/tokens/accountTokensManager";
|
|
10
|
+
import type { PermissionKey } from "@pithy-sh/cloudflare/src/tokens/permissions";
|
|
11
|
+
import {
|
|
12
|
+
type ProfileOverride,
|
|
13
|
+
profilePermissions,
|
|
14
|
+
resolveProfile,
|
|
15
|
+
type TokenProfile,
|
|
16
|
+
type TokenStore,
|
|
17
|
+
} from "@pithy-sh/cloudflare/src/tokens/profiles";
|
|
18
|
+
import { kebab } from "@pithy-sh/core/src/naming/resource";
|
|
19
|
+
import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
|
|
20
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
21
|
+
import { type SinkTarget, writeTokenToSink } from "./sinks";
|
|
22
|
+
|
|
23
|
+
/** The account-token control plane the engine drives — the subset of `CloudflareAccountTokensManager` it needs. */
|
|
24
|
+
export interface AccountTokenControl {
|
|
25
|
+
mintToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
|
|
26
|
+
rollToken(name: string, permissions: TokenPermission[]): Promise<MintedAccountToken>;
|
|
27
|
+
findTokenByName(name: string): Promise<AccountTokenSummary | null>;
|
|
28
|
+
listTokens(): Promise<AccountTokenSummary[]>;
|
|
29
|
+
deleteToken(id: string): Promise<void>;
|
|
30
|
+
deleteTokensByName(name: string): Promise<number>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The audit action codes for token lifecycle events — the `cloudflare/token_*` federated taxonomy. */
|
|
34
|
+
export const TokenAuditActions = {
|
|
35
|
+
minted: "cloudflare/token_minted",
|
|
36
|
+
rotated: "cloudflare/token_rotated",
|
|
37
|
+
revoked: "cloudflare/token_revoked",
|
|
38
|
+
} as const;
|
|
39
|
+
|
|
40
|
+
/** One token-lifecycle audit event. Never carries the token value — only its id and where it went. */
|
|
41
|
+
export interface TokenAuditEvent {
|
|
42
|
+
action: string;
|
|
43
|
+
outcome: "success" | "failure";
|
|
44
|
+
profile: string;
|
|
45
|
+
env: string;
|
|
46
|
+
tokenId?: string;
|
|
47
|
+
store?: TokenStore;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** The audit sink: records a token-lifecycle event. Absent → auditing is a no-op (audit not composed). */
|
|
51
|
+
export type TokenAudit = (event: TokenAuditEvent) => Promise<void>;
|
|
52
|
+
|
|
53
|
+
/** Everything the engine needs to mint, store, list, rotate, and revoke a project's scoped tokens. */
|
|
54
|
+
export interface TokenEngine {
|
|
55
|
+
/** The account tokens target. */
|
|
56
|
+
accountId: string;
|
|
57
|
+
/**
|
|
58
|
+
* The project name (root `pithy.config.ts` `name`, via `requireProjectName` — never guessed). The
|
|
59
|
+
* first segment of every token name and of every Secrets Store entry a mint writes. Required, because
|
|
60
|
+
* Cloudflare's account token list and Secrets Store are both flat: without it, two Pithy projects in
|
|
61
|
+
* one account mint tokens of the same name, and `revoke` — which deletes *every* token of a name —
|
|
62
|
+
* revokes the other project's credential along with its own.
|
|
63
|
+
*/
|
|
64
|
+
project: string;
|
|
65
|
+
/** The project root — for the audit sink's app-database lookup. Nothing minted is written into it (#182). */
|
|
66
|
+
projectDir: string;
|
|
67
|
+
/** Where the Pithy config directory is. Defaults to the real one; a seam so a test writes its own. */
|
|
68
|
+
paths?: StatePathOptions;
|
|
69
|
+
/** The CF account-token control plane (the bootstrap token authenticates it). */
|
|
70
|
+
tokens: AccountTokenControl;
|
|
71
|
+
/** The aggregated profile registry (`resolveTokenProfiles`). */
|
|
72
|
+
profiles: Record<string, TokenProfile>;
|
|
73
|
+
/** The declared backend of a secret name (from the composed secret registry), for the store destination. */
|
|
74
|
+
secretBackend?: (secretName: string) => string | undefined;
|
|
75
|
+
/** Writes to the CF Secrets Store, for the `secrets-store` destination. Absent → that destination errors. */
|
|
76
|
+
putSecret?: (name: string, value: string) => Promise<void>;
|
|
77
|
+
/** Records lifecycle events; a no-op when absent (audit not composed). */
|
|
78
|
+
audit?: TokenAudit;
|
|
79
|
+
/** Resolves an adopter's `pithy.config.ts` override for a profile. */
|
|
80
|
+
override?: (profile: string) => ProfileOverride | undefined;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Per-call overrides (CLI flags) that win over the profile default and the config override. */
|
|
84
|
+
export interface MintOptions {
|
|
85
|
+
/** Override the store (`--store`). */
|
|
86
|
+
store?: TokenStore;
|
|
87
|
+
/** Override the permission keys (`--permission`). */
|
|
88
|
+
permissions?: PermissionKey[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The stable CF token name for a (project, env, profile): `<project>-<env>-<profile>`. One identity per
|
|
93
|
+
* triple, rolled in place on re-mint.
|
|
94
|
+
*
|
|
95
|
+
* The account's API-token list is flat and account-wide, so this name is the only thing that separates
|
|
96
|
+
* one project's credentials from another's in the same account. The project segment goes first because
|
|
97
|
+
* that is the ownership boundary {@link tokenPrefix} filters on.
|
|
98
|
+
*/
|
|
99
|
+
export function tokenName(project: string, env: string, profile: string): string {
|
|
100
|
+
return resourceNames(project).env(env).apiToken(profile);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The prefix every one of a project's tokens for an environment shares — `<project>-<env>-`.
|
|
105
|
+
*
|
|
106
|
+
* This is the ownership filter for listing. It must be built from the same kebab-cased segments
|
|
107
|
+
* {@link tokenName} composes, and only the trailing `thing` segment of a resource name is ever
|
|
108
|
+
* truncated, so a prefix match here is exact: a token outside this project or this environment cannot
|
|
109
|
+
* pass it.
|
|
110
|
+
*/
|
|
111
|
+
export function tokenPrefix(project: string, env: string): string {
|
|
112
|
+
return `${kebab(project)}-${kebab(env)}-`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The CF Secrets Store entry name a profile's minted value is written to.
|
|
117
|
+
*
|
|
118
|
+
* Distinct from `profile.secret`, which is the registry join key and the `.dev.vars` **variable** name.
|
|
119
|
+
* Only the store entry is scoped: the store is one flat account-wide namespace where an unscoped name
|
|
120
|
+
* would let one project's mint overwrite another's live credential, whereas `.dev.vars` is a file in
|
|
121
|
+
* this checkout and renaming its keys would break every CI pipeline reading `CF_TOKEN_CI_SYSTEM`.
|
|
122
|
+
*
|
|
123
|
+
* A `global` profile puts the literal `global` in the environment slot, so every environment resolves
|
|
124
|
+
* the one entry provisioning wrote instead of minting a per-environment entry nothing binds.
|
|
125
|
+
*/
|
|
126
|
+
export function tokenStoreEntryName(project: string, env: string, profile: TokenProfile): string {
|
|
127
|
+
const names = resourceNames(project);
|
|
128
|
+
return (profile.secretScope === "global" ? names.global : names.env(env)).secretEntry(profile.secret);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The outcome of a mint/rotate. `value` is for the caller's in-process use — never surface it in output. */
|
|
132
|
+
export interface TokenResult {
|
|
133
|
+
profile: string;
|
|
134
|
+
env: string;
|
|
135
|
+
tokenId: string;
|
|
136
|
+
name: string;
|
|
137
|
+
/** The secret token value — for in-process use. NEVER include in CLI output or `--json`. */
|
|
138
|
+
value: string;
|
|
139
|
+
sink: SinkTarget;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Emit a lifecycle event through the audit sink; non-fatal — an audit failure never breaks the action. */
|
|
143
|
+
async function emit(engine: TokenEngine, event: TokenAuditEvent): Promise<void> {
|
|
144
|
+
if (!engine.audit) return;
|
|
145
|
+
try {
|
|
146
|
+
await engine.audit(event);
|
|
147
|
+
} catch {
|
|
148
|
+
// Non-fatal by contract: an audit write never breaks the token action it records.
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** Merge the config override and the per-call CLI flags into one override (CLI flags win). */
|
|
153
|
+
function mergeOverride(engine: TokenEngine, profile: string, options?: MintOptions): ProfileOverride | undefined {
|
|
154
|
+
const config = engine.override?.(profile);
|
|
155
|
+
const merged: ProfileOverride = { ...config };
|
|
156
|
+
if (options?.store) merged.store = options.store;
|
|
157
|
+
if (options?.permissions) merged.permissions = options.permissions;
|
|
158
|
+
return Object.keys(merged).length > 0 ? merged : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Resolve where a profile's minted value is written: the `--store`/profile override if set, otherwise
|
|
163
|
+
* the token's **declared secret backend** — a token can't live in the encrypted D1 store (Worker-only),
|
|
164
|
+
* so a store-backed token must be declared `cf-secrets-store`; an undeclared secret with no override
|
|
165
|
+
* fails with actionable guidance. This is the registry-defined storage: the secret's definition decides
|
|
166
|
+
* where the value goes, and `dev-vars`/`ephemeral` are the explicit overrides.
|
|
167
|
+
*/
|
|
168
|
+
function resolveDestination(engine: TokenEngine, profile: TokenProfile): TokenStore {
|
|
169
|
+
// An explicit store (a profile's `defaultStore` or a `--store` override) is the declaration itself.
|
|
170
|
+
if (profile.defaultStore) return profile.defaultStore;
|
|
171
|
+
// No store declared → the destination comes from the token's declared secret-registry backend.
|
|
172
|
+
const backend = engine.secretBackend?.(profile.secret);
|
|
173
|
+
if (backend === "cf-secrets-store") return "secrets-store";
|
|
174
|
+
if (backend === "d1") {
|
|
175
|
+
throw new CloudflareNotConfiguredError({
|
|
176
|
+
message: `Token "${profile.name}" can't be stored in the encrypted D1 secrets store — its value is read outside the Worker.`,
|
|
177
|
+
action: `Declare ${profile.secret} as cf-secrets-store, or mint with --store dev-vars.`,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
throw new CloudflareNotConfiguredError({
|
|
181
|
+
message: `No storage is declared for token "${profile.name}".`,
|
|
182
|
+
action: `Declare the secret ${profile.secret} (pithy secrets) as cf-secrets-store, or mint with --store dev-vars.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Mint the profile's token for an environment and return a usable value. **Rolls in place**: the token
|
|
188
|
+
* name is a stable `(profile, env)` identity, and each mint regenerates its value with the profile's
|
|
189
|
+
* *current* permissions — so adding a capability's `ciPermissions` (or an override) takes effect on the
|
|
190
|
+
* next mint, without hand-editing scopes. The fresh value is written to the resolved store and returned
|
|
191
|
+
* for in-process use. Audited on success/failure.
|
|
192
|
+
*
|
|
193
|
+
* It does not reuse a stored value: that would pin the token to its old scope, silently defeating the
|
|
194
|
+
* "add a capability → the CI token grows" contract. Roll keeps one identity, so re-minting never orphans
|
|
195
|
+
* a token; a Worker consumer reads the current value from its CFSS binding, and a `dev-vars` consumer
|
|
196
|
+
* re-reads the refreshed file.
|
|
197
|
+
*/
|
|
198
|
+
export async function mintProfileToken(
|
|
199
|
+
engine: TokenEngine,
|
|
200
|
+
profileName: string,
|
|
201
|
+
env: string,
|
|
202
|
+
options?: MintOptions,
|
|
203
|
+
): Promise<TokenResult> {
|
|
204
|
+
const profile = resolveProfile(engine.profiles, profileName, mergeOverride(engine, profileName, options));
|
|
205
|
+
const store = resolveDestination(engine, profile);
|
|
206
|
+
const name = tokenName(engine.project, env, profileName);
|
|
207
|
+
|
|
208
|
+
try {
|
|
209
|
+
const minted = await engine.tokens.rollToken(name, profilePermissions(profile, engine.accountId));
|
|
210
|
+
const sink = await writeTokenToSink(store, minted.value, {
|
|
211
|
+
project: engine.project,
|
|
212
|
+
env,
|
|
213
|
+
secretName: profile.secret,
|
|
214
|
+
storeEntryName: tokenStoreEntryName(engine.project, env, profile),
|
|
215
|
+
putSecret: engine.putSecret,
|
|
216
|
+
...(engine.paths !== undefined ? { paths: engine.paths } : {}),
|
|
217
|
+
});
|
|
218
|
+
await emit(engine, {
|
|
219
|
+
action: TokenAuditActions.minted,
|
|
220
|
+
outcome: "success",
|
|
221
|
+
profile: profileName,
|
|
222
|
+
env,
|
|
223
|
+
tokenId: minted.id,
|
|
224
|
+
store,
|
|
225
|
+
});
|
|
226
|
+
return { profile: profileName, env, tokenId: minted.id, name, value: minted.value, sink };
|
|
227
|
+
} catch (error) {
|
|
228
|
+
await emit(engine, { action: TokenAuditActions.minted, outcome: "failure", profile: profileName, env });
|
|
229
|
+
throw error;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** One row of `pithy token list`: a minted token's identity, never its value. */
|
|
234
|
+
export interface TokenListItem {
|
|
235
|
+
profile: string;
|
|
236
|
+
env: string;
|
|
237
|
+
name: string;
|
|
238
|
+
tokenId: string;
|
|
239
|
+
status?: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* List **this project's** minted tokens for an environment — identities only, never values.
|
|
244
|
+
*
|
|
245
|
+
* Two gates, and both matter. The `<project>-<env>-` prefix is the ownership filter: the account token
|
|
246
|
+
* list is every token in the account, including other Pithy projects' and the operator's own, and
|
|
247
|
+
* anything the CLI lists is something it offers to rotate and revoke.
|
|
248
|
+
*
|
|
249
|
+
* The profile is then recovered by **reverse lookup** from the known profile names, not by slicing the
|
|
250
|
+
* prefix off the name. A token name is composed through the naming facade, which truncates and hashes a
|
|
251
|
+
* trailing segment past its namespace's budget — so the profile segment on the wire is not always
|
|
252
|
+
* the profile name, and a slice would hand back a mangled string that `rotate`/`revoke` cannot resolve.
|
|
253
|
+
* Composing each known profile's name and matching exactly is the only recovery that survives that. A
|
|
254
|
+
* prefixed token no profile claims is therefore not listed: it is not a profile token this CLI can act on.
|
|
255
|
+
*/
|
|
256
|
+
export async function listProfileTokens(engine: TokenEngine, env: string): Promise<TokenListItem[]> {
|
|
257
|
+
const prefix = tokenPrefix(engine.project, env);
|
|
258
|
+
const byName = new Map<string, string>();
|
|
259
|
+
for (const profile of Object.keys(engine.profiles)) {
|
|
260
|
+
byName.set(tokenName(engine.project, env, profile), profile);
|
|
261
|
+
}
|
|
262
|
+
const all = await engine.tokens.listTokens();
|
|
263
|
+
return all.flatMap((token) => {
|
|
264
|
+
if (!token.name.startsWith(prefix)) return [];
|
|
265
|
+
const profile = byName.get(token.name);
|
|
266
|
+
if (!profile) return [];
|
|
267
|
+
return [{ profile, env, name: token.name, tokenId: token.id, status: token.status }];
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Options for `pithy token rotate`. */
|
|
272
|
+
export interface RotateOptions extends MintOptions {
|
|
273
|
+
/** Keep the previous token instead of deleting it — a grace window for consumers to pick up the new one. */
|
|
274
|
+
keepPrevious?: boolean;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Rotate the profile's token with the proven two-step (Cloudflare has no single-call rotate): mint a
|
|
279
|
+
* **new** token with the same name and policies, store its value, then delete the prior token(s) by id.
|
|
280
|
+
* `keepPrevious` leaves the old token in place — a grace window while a Worker consumer picks up the
|
|
281
|
+
* new value (redeploy) before it is revoked. Audited on success/failure.
|
|
282
|
+
*/
|
|
283
|
+
export async function rotateProfileToken(
|
|
284
|
+
engine: TokenEngine,
|
|
285
|
+
profileName: string,
|
|
286
|
+
env: string,
|
|
287
|
+
options?: RotateOptions,
|
|
288
|
+
): Promise<TokenResult> {
|
|
289
|
+
const profile = resolveProfile(engine.profiles, profileName, mergeOverride(engine, profileName, options));
|
|
290
|
+
const store = resolveDestination(engine, profile);
|
|
291
|
+
const name = tokenName(engine.project, env, profileName);
|
|
292
|
+
try {
|
|
293
|
+
// Snapshot the prior token id(s) before creating the replacement, so we delete exactly what predates it.
|
|
294
|
+
const priorIds = (await engine.tokens.listTokens()).filter((token) => token.name === name).map((token) => token.id);
|
|
295
|
+
const minted = await engine.tokens.mintToken(name, profilePermissions(profile, engine.accountId));
|
|
296
|
+
const sink = await writeTokenToSink(store, minted.value, {
|
|
297
|
+
project: engine.project,
|
|
298
|
+
env,
|
|
299
|
+
secretName: profile.secret,
|
|
300
|
+
storeEntryName: tokenStoreEntryName(engine.project, env, profile),
|
|
301
|
+
putSecret: engine.putSecret,
|
|
302
|
+
...(engine.paths !== undefined ? { paths: engine.paths } : {}),
|
|
303
|
+
});
|
|
304
|
+
if (!options?.keepPrevious) {
|
|
305
|
+
for (const id of priorIds) await engine.tokens.deleteToken(id);
|
|
306
|
+
}
|
|
307
|
+
await emit(engine, {
|
|
308
|
+
action: TokenAuditActions.rotated,
|
|
309
|
+
outcome: "success",
|
|
310
|
+
profile: profileName,
|
|
311
|
+
env,
|
|
312
|
+
tokenId: minted.id,
|
|
313
|
+
store,
|
|
314
|
+
});
|
|
315
|
+
return { profile: profileName, env, tokenId: minted.id, name, value: minted.value, sink };
|
|
316
|
+
} catch (error) {
|
|
317
|
+
await emit(engine, { action: TokenAuditActions.rotated, outcome: "failure", profile: profileName, env });
|
|
318
|
+
throw error;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** The outcome of a revoke — how many tokens of the profile's name were deleted. */
|
|
323
|
+
export interface RevokeResult {
|
|
324
|
+
profile: string;
|
|
325
|
+
env: string;
|
|
326
|
+
name: string;
|
|
327
|
+
revoked: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Revoke the profile's token(s) for an environment — deletes **every** account token of that name.
|
|
332
|
+
* Audited. The name is project-scoped, which is what keeps that sweep inside this project: an unscoped
|
|
333
|
+
* name would make one project's revoke delete every other project's token of the same profile.
|
|
334
|
+
*/
|
|
335
|
+
export async function revokeProfileToken(engine: TokenEngine, profileName: string, env: string): Promise<RevokeResult> {
|
|
336
|
+
const name = tokenName(engine.project, env, profileName);
|
|
337
|
+
try {
|
|
338
|
+
const revoked = await engine.tokens.deleteTokensByName(name);
|
|
339
|
+
await emit(engine, { action: TokenAuditActions.revoked, outcome: "success", profile: profileName, env });
|
|
340
|
+
return { profile: profileName, env, name, revoked };
|
|
341
|
+
} catch (error) {
|
|
342
|
+
await emit(engine, { action: TokenAuditActions.revoked, outcome: "failure", profile: profileName, env });
|
|
343
|
+
throw error;
|
|
344
|
+
}
|
|
345
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { ConflictError } from "@pithy-sh/core/src/error/pithyError";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import { devSecretsDir } from "../devSecrets/location";
|
|
8
|
+
import { ensureOwnerOnlyDirFor, tightenMode } from "../devSecrets/mode";
|
|
9
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
10
|
+
import { writeFileAtomic } from "../project/atomic";
|
|
11
|
+
import { type MergeBase, readMergeBase, readOptionalFile, requireRecord } from "../project/readOptionalFile";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Where `pithy token mint --store dev-vars` puts a minted token: `<config>/<project>/tokens.json`,
|
|
15
|
+
* keyed by environment, mode `0600`, in the `0700` project directory (#182).
|
|
16
|
+
*
|
|
17
|
+
* **It used to be `.dev.vars.<env>`, inside the checkout.** `devVarsFileName` returned `.dev.vars` for
|
|
18
|
+
* dev and `.dev.vars.<env>` for anything else, so `pithy token mint --env production --store dev-vars`
|
|
19
|
+
* wrote a **live production Cloudflare token into the project directory** — one file per environment,
|
|
20
|
+
* each holding that environment's credential. They were gitignored, which is not sufficient on its own:
|
|
21
|
+
* #145 was an `npm pack` leak, and `npm pack` does not consult `.gitignore` when `files` is set in
|
|
22
|
+
* `package.json`. That path also had its own permissions defect on record — a private copy of the upsert
|
|
23
|
+
* that lacked the shared one's `0600`, so minting for an environment with no file yet left a production
|
|
24
|
+
* credential at the umask default, `0664`.
|
|
25
|
+
*
|
|
26
|
+
* The rule this file exists to make structural: **no minted credential is written into the checkout, for
|
|
27
|
+
* any environment.** There is no filename here a project directory can hold.
|
|
28
|
+
*
|
|
29
|
+
* **Per project, not per account.** Unlike the bootstrap pair in `<config>/cloudflare.json`, these are
|
|
30
|
+
* minted *for this project's* environments — the store entry name they mirror is project-scoped for
|
|
31
|
+
* exactly that reason, since the Secrets Store is one flat account-wide namespace.
|
|
32
|
+
*
|
|
33
|
+
* **One file, keyed by environment, rather than a file per environment.** A family of files is the shape
|
|
34
|
+
* that produced the defect above: each new environment is a new path, created by whichever call site got
|
|
35
|
+
* there first, at whatever mode that call site remembered. One document has one creation site and one
|
|
36
|
+
* mode.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
/** The file's name inside the project's config directory. Undotted: nothing here is hidden from anything. */
|
|
40
|
+
export const MINTED_TOKENS_FILE_NAME = "tokens.json";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The minted-token document: environment → variable name → value.
|
|
44
|
+
*
|
|
45
|
+
* Validated because it is read off disk and hand-editable, and because a non-string value would be
|
|
46
|
+
* handed to a later CLI run as a credential. The variable name is the profile's `secret` verbatim — a
|
|
47
|
+
* local environment-variable name like `CF_TOKEN_CI_SYSTEM`, never project-scoped, because that is what
|
|
48
|
+
* a pipeline reads.
|
|
49
|
+
*/
|
|
50
|
+
export const MintedTokens = z
|
|
51
|
+
.record(
|
|
52
|
+
z.string().describe("The environment the token was minted for — `dev`, `staging`, `production`."),
|
|
53
|
+
z
|
|
54
|
+
.record(
|
|
55
|
+
z.string().describe("The token's variable key, exactly as the profile declares it."),
|
|
56
|
+
z.string().describe("The minted token value. Never logged, never printed, never in --json."),
|
|
57
|
+
)
|
|
58
|
+
.describe("Every token minted for that environment, by variable key."),
|
|
59
|
+
)
|
|
60
|
+
.describe("Minted Cloudflare tokens this machine holds for one project, by environment. Mode 0600.");
|
|
61
|
+
export type MintedTokens = z.output<typeof MintedTokens>;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* `<config>/<project>/tokens.json`, from a project name that has already been resolved.
|
|
65
|
+
*
|
|
66
|
+
* The **name**, never a directory. It is the same key `secrets.jsonc` and `dev.json` are filed under, so
|
|
67
|
+
* every worktree of one project reads one file — and the caller has already put the name through
|
|
68
|
+
* `requireProjectName`, which is the gate that stops a checkout's basename from becoming a second answer.
|
|
69
|
+
*/
|
|
70
|
+
export function mintedTokensPath(project: string, options: StatePathOptions = {}): string {
|
|
71
|
+
return join(devSecretsDir(project, options), MINTED_TOKENS_FILE_NAME);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Record one minted token under its environment, and answer the file it landed in.
|
|
76
|
+
*
|
|
77
|
+
* **Read-modify-write over a credential file, so every state but "there is no file" refuses.** Only
|
|
78
|
+
* `ENOENT` licenses starting from `{}`. A file that could not be read, one that is not JSON, one that
|
|
79
|
+
* parsed to something that is not a document, one that is a record of something else — writing this
|
|
80
|
+
* document over any of them deletes every other environment's live token with no copy anywhere, which is
|
|
81
|
+
* the exact shape of #142 and of the two `.dev.vars` losses before it. Each refusal is
|
|
82
|
+
* {@link mintedTokensBase}'s, because it is the one that can tell an absence from an answer, and the
|
|
83
|
+
* merge below takes a {@link MergeBase} so that no other read can be handed to it (#219).
|
|
84
|
+
*/
|
|
85
|
+
export async function writeMintedToken(
|
|
86
|
+
project: string,
|
|
87
|
+
env: string,
|
|
88
|
+
name: string,
|
|
89
|
+
value: string,
|
|
90
|
+
options: StatePathOptions = {},
|
|
91
|
+
): Promise<string> {
|
|
92
|
+
const base = await mintedTokensBase(mintedTokensPath(project, options));
|
|
93
|
+
const merged = withMintedToken(base, env, name, value);
|
|
94
|
+
await ensureOwnerOnlyDirFor(base.path);
|
|
95
|
+
// The path comes off the base rather than being re-derived, so what is written can only ever land back
|
|
96
|
+
// on the file the base was read from.
|
|
97
|
+
await writeFileAtomic(base.path, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
|
|
98
|
+
// Unconditionally and after the write, the same rule every other credential file here follows: a file
|
|
99
|
+
// an editor or a `cp` created at the umask holds a live API token at 0644 until something narrows it.
|
|
100
|
+
await tightenMode(base.path);
|
|
101
|
+
return base.path;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* The document to write: the base, with one token recorded under its environment.
|
|
106
|
+
*
|
|
107
|
+
* **It takes a {@link MergeBase} and that is the whole point.** Only {@link readMergeBase} mints one, so
|
|
108
|
+
* the lenient {@link readMintedTokens} below — which answers `{}` for a file it cannot make sense of,
|
|
109
|
+
* correctly, because it rewrites nothing — cannot reach this function. The accident that produced all
|
|
110
|
+
* five instances of this defect is a type error here rather than a replaced credential file on disk.
|
|
111
|
+
*/
|
|
112
|
+
function withMintedToken(base: MergeBase<MintedTokens>, env: string, name: string, value: string): MintedTokens {
|
|
113
|
+
const document = base.document;
|
|
114
|
+
return { ...document, [env]: { ...(document[env] ?? {}), [name]: value } };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The merge base for a write over `tokens.json`: the whole document, known-good, or a refusal.
|
|
119
|
+
*
|
|
120
|
+
* The strict half of the reader/writer split (#219). Absent is `{}`; unopenable, not JSON, not a record
|
|
121
|
+
* and not a document of minted tokens are four refusals, each in this file's own words — "Pithy won't
|
|
122
|
+
* rewrite a credential file it could not read" is the sentence, and the four spellings of "could not
|
|
123
|
+
* read" are the four ways a hand edit gets there.
|
|
124
|
+
*/
|
|
125
|
+
async function mintedTokensBase(path: string): Promise<MergeBase<MintedTokens>> {
|
|
126
|
+
return readMergeBase(path, MintedTokens, {
|
|
127
|
+
unreadable: ({ code, cause }) =>
|
|
128
|
+
new ConflictError(
|
|
129
|
+
{
|
|
130
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
131
|
+
action:
|
|
132
|
+
"Fix the file's permissions, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not read.",
|
|
133
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
134
|
+
},
|
|
135
|
+
{ cause },
|
|
136
|
+
),
|
|
137
|
+
unparseable: () =>
|
|
138
|
+
new ConflictError({
|
|
139
|
+
message: `Cannot update ${path}: it is there and is not JSON.`,
|
|
140
|
+
action:
|
|
141
|
+
"Fix the JSON, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not parse — every other environment's token is still in it.",
|
|
142
|
+
// Never the parser's own message: it quotes the line it choked on, and these lines are tokens.
|
|
143
|
+
detail: `${MINTED_TOKENS_FILE_NAME} at ${path} did not parse`,
|
|
144
|
+
}),
|
|
145
|
+
notARecord: ({ found }) =>
|
|
146
|
+
new ConflictError({
|
|
147
|
+
message: `Cannot update ${path}: it holds ${found}, not a document of minted tokens.`,
|
|
148
|
+
action:
|
|
149
|
+
"Restore it to a JSON object keyed by environment, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not make sense of.",
|
|
150
|
+
detail: `${MINTED_TOKENS_FILE_NAME} at ${path} parsed to ${found}`,
|
|
151
|
+
}),
|
|
152
|
+
invalid: ({ at }) =>
|
|
153
|
+
new ConflictError({
|
|
154
|
+
message: `Cannot update ${path}: it is not a document of minted tokens.`,
|
|
155
|
+
action:
|
|
156
|
+
"Restore it to a JSON object of environment → variable name → token, or move it aside, and run the command again.",
|
|
157
|
+
// The key path, so the line can be found. Never the value on it, which is a credential.
|
|
158
|
+
detail: `${MINTED_TOKENS_FILE_NAME} at ${path} failed its schema at ${at}`,
|
|
159
|
+
}),
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* **The reporting read**, for a caller that wants to know what has been minted and will rewrite nothing.
|
|
165
|
+
*
|
|
166
|
+
* **It has no production caller today, and that is a true statement about this file rather than dead
|
|
167
|
+
* code left behind.** The one caller it had routed its planning pass through {@link mintedTokensBase}
|
|
168
|
+
* in #222 — because a plan computed against a file it could not read is a plan that may tell somebody a
|
|
169
|
+
* value is safe to delete — and was itself removed in #406. Nothing else reads `tokens.json` at all: #224
|
|
170
|
+
* established that the `dev-vars` sink's consumer is a person, who opens the file and pastes the
|
|
171
|
+
* `ci-system` token into CI.
|
|
172
|
+
*
|
|
173
|
+
* **It stays because the split is the design, not because something might want it.** #219's whole
|
|
174
|
+
* argument is that a reporting read and a merge base are different things and a call site must not be
|
|
175
|
+
* able to reach for the wrong one by accident. Deleting the reporting half would leave the next caller
|
|
176
|
+
* that wants to *report* on this file with only the strict read — which refuses where reporting should
|
|
177
|
+
* shrug — and the obvious move then is to re-add a lenient read locally, which is exactly how all five
|
|
178
|
+
* instances of that defect happened. The command #224 proposes, a `pithy token show` that puts a
|
|
179
|
+
* minted value on a terminal deliberately, is what this is for.
|
|
180
|
+
*
|
|
181
|
+
* The write goes through {@link writeMintedToken}, which reads its own base through
|
|
182
|
+
* {@link mintedTokensBase} — so this answer is never anybody's merge base, and the type says so, since
|
|
183
|
+
* a `MintedTokens` is not a {@link MergeBase} of one (#219).
|
|
184
|
+
*
|
|
185
|
+
* **Which of the two this is, stated, because that was the ambiguity that cost four files.** This is the
|
|
186
|
+
* one that may answer `{}`, and it does so for a file that is not there and for one that nothing can be
|
|
187
|
+
* made of at all — it will not parse, or it parses and is not a document of minted tokens. A reader has
|
|
188
|
+
* nothing to destroy, and a caller reading a token back gets an honest "nothing here" rather than a
|
|
189
|
+
* command that will not run.
|
|
190
|
+
*
|
|
191
|
+
* **Two failures are refusals even here, and they are the file's rather than the caller's.** A file that
|
|
192
|
+
* is there and will not open, because "absent" is a claim about the filesystem that this read cannot
|
|
193
|
+
* make ({@link readOptionalFile}, #190). And a value that parsed to something that is not a record,
|
|
194
|
+
* because that is a claim about what is in the file — something is here, this is not what it is
|
|
195
|
+
* ({@link requireRecord}, #209).
|
|
196
|
+
*/
|
|
197
|
+
export async function readMintedTokens(path: string): Promise<MintedTokens> {
|
|
198
|
+
const source = await readOptionalFile(path, {
|
|
199
|
+
unreadable: ({ code, cause }) =>
|
|
200
|
+
new ConflictError(
|
|
201
|
+
{
|
|
202
|
+
message: `Cannot update ${path}: Pithy could not read what is already in it.`,
|
|
203
|
+
action:
|
|
204
|
+
"Fix the file's permissions, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not read.",
|
|
205
|
+
detail: `${code ?? "unknown error"} while reading ${path}`,
|
|
206
|
+
},
|
|
207
|
+
{ cause },
|
|
208
|
+
),
|
|
209
|
+
});
|
|
210
|
+
if (source === null) return {};
|
|
211
|
+
const value = safeJson(source);
|
|
212
|
+
if (value === undefined) return {};
|
|
213
|
+
const document = requireRecord(path, value, {
|
|
214
|
+
notARecord: ({ found }) =>
|
|
215
|
+
new ConflictError({
|
|
216
|
+
message: `Cannot update ${path}: it holds ${found}, not a document of minted tokens.`,
|
|
217
|
+
action:
|
|
218
|
+
"Restore it to a JSON object keyed by environment, or move it aside, and run the command again. Pithy won't rewrite a credential file it could not make sense of.",
|
|
219
|
+
detail: `${MINTED_TOKENS_FILE_NAME} at ${path} parsed to ${found}`,
|
|
220
|
+
}),
|
|
221
|
+
});
|
|
222
|
+
const parsed = MintedTokens.safeParse(document);
|
|
223
|
+
return parsed.success ? parsed.data : {};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** `JSON.parse` that answers `undefined` rather than throwing — this file is hand-edited. */
|
|
227
|
+
function safeJson(source: string): unknown {
|
|
228
|
+
try {
|
|
229
|
+
return JSON.parse(source);
|
|
230
|
+
} catch {
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// SPDX-FileCopyrightText: 2026 Pithy
|
|
2
|
+
// SPDX-License-Identifier: MIT
|
|
3
|
+
|
|
4
|
+
import { CloudflareNotConfiguredError } from "@pithy-sh/cloudflare/src/client/errors";
|
|
5
|
+
import type { TokenStore } from "@pithy-sh/cloudflare/src/tokens/profiles";
|
|
6
|
+
import type { StatePathOptions } from "../notifier/state";
|
|
7
|
+
import { writeMintedToken } from "./mintedTokens";
|
|
8
|
+
|
|
9
|
+
/** Where a minted token landed — the store and a human location string. Never carries the value. */
|
|
10
|
+
export interface SinkTarget {
|
|
11
|
+
sink: TokenStore;
|
|
12
|
+
/** A human location for output: a filename, "CF Secrets Store", or the ephemeral note. No secret value. */
|
|
13
|
+
location: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* What a sink write needs: the project dir + env to target, the two names a value can be stored under,
|
|
18
|
+
* and a Secrets Store writer.
|
|
19
|
+
*
|
|
20
|
+
* The two names are deliberately separate. They were one string, and that string was doing two jobs
|
|
21
|
+
* with opposite requirements: a **variable key** in a file this checkout owns, and an **entry name** in
|
|
22
|
+
* one flat namespace shared by every project in the Cloudflare account. Only the second needs a project
|
|
23
|
+
* scope — and scoping the first would rename `CF_TOKEN_CI_SYSTEM` and break every pipeline reading it.
|
|
24
|
+
*/
|
|
25
|
+
export interface SinkContext {
|
|
26
|
+
/**
|
|
27
|
+
* The project's name, from the root `pithy.config.ts` via `requireProjectName` — never a directory.
|
|
28
|
+
* It keys `<config>/<project>/`, which is where a minted token goes now that nothing is written into
|
|
29
|
+
* the checkout (#182).
|
|
30
|
+
*/
|
|
31
|
+
project: string;
|
|
32
|
+
env: string;
|
|
33
|
+
/**
|
|
34
|
+
* The `.dev.vars` variable key — a local environment-variable name, never project-scoped. This is
|
|
35
|
+
* what CI reads, so it stays exactly as the profile declares it.
|
|
36
|
+
*/
|
|
37
|
+
secretName: string;
|
|
38
|
+
/**
|
|
39
|
+
* The CF Secrets Store entry name — project-scoped (`tokenStoreEntryName`). The store is one flat
|
|
40
|
+
* account-wide namespace, so this name is the only partition between two projects' entries: an
|
|
41
|
+
* unscoped one would silently overwrite another project's live credential.
|
|
42
|
+
*/
|
|
43
|
+
storeEntryName: string;
|
|
44
|
+
/** Writes a value to the CF Secrets Store; required for the `secrets-store` sink. */
|
|
45
|
+
putSecret?: (name: string, value: string) => Promise<void>;
|
|
46
|
+
/** Where the Pithy config directory is. Defaults to the real one; a seam so a test writes its own. */
|
|
47
|
+
paths?: StatePathOptions;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Write a minted token value to its store and report where it landed — never the value. `ephemeral`
|
|
52
|
+
* persists nothing (the caller uses the value in-process); `dev-vars` records the token's **variable
|
|
53
|
+
* key** under its environment in `<config>/<project>/tokens.json`, for an operator to read out and paste
|
|
54
|
+
* into CI — **no command consumes that file**, and saying otherwise cost an issue (#224);
|
|
55
|
+
* `secrets-store` writes it to the CF Secrets Store under the project-scoped **entry name**, for a Worker
|
|
56
|
+
* to read via its binding.
|
|
57
|
+
*
|
|
58
|
+
* **The `dev-vars` sink writes nothing inside the project directory, for any environment (#182).** It
|
|
59
|
+
* used to write `.dev.vars` for dev and `.dev.vars.<env>` for everything else — so a production mint put
|
|
60
|
+
* a live production Cloudflare token in the checkout, gitignored but reachable by `npm pack`, which does
|
|
61
|
+
* not consult `.gitignore` when `files` is set (#145). The store name stays `dev-vars` because it is a
|
|
62
|
+
* public flag value and renaming it would break every documented invocation; what changed is where it
|
|
63
|
+
* puts the value. See {@link writeMintedToken}.
|
|
64
|
+
*/
|
|
65
|
+
export async function writeTokenToSink(store: TokenStore, value: string, context: SinkContext): Promise<SinkTarget> {
|
|
66
|
+
switch (store) {
|
|
67
|
+
case "ephemeral":
|
|
68
|
+
return { sink: store, location: "(ephemeral — not written)" };
|
|
69
|
+
case "dev-vars": {
|
|
70
|
+
const path = await writeMintedToken(context.project, context.env, context.secretName, value, context.paths ?? {});
|
|
71
|
+
return { sink: store, location: path };
|
|
72
|
+
}
|
|
73
|
+
case "secrets-store": {
|
|
74
|
+
if (!context.putSecret) {
|
|
75
|
+
throw new CloudflareNotConfiguredError({
|
|
76
|
+
message: "No CF Secrets Store is configured for the secrets-store store.",
|
|
77
|
+
action: "Run pithy add secrets to record SECRETS_STORE_ID, or mint with --store dev-vars.",
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
await context.putSecret(context.storeEntryName, value);
|
|
81
|
+
return { sink: store, location: "CF Secrets Store" };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|