@pithy-sh/secrets 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.
Files changed (64) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +15 -0
  3. package/package.json +52 -0
  4. package/pithy.manifest.json +48 -0
  5. package/src/admin/health.ts +94 -0
  6. package/src/admin/status.ts +462 -0
  7. package/src/audit/actions.ts +53 -0
  8. package/src/capability.ts +219 -0
  9. package/src/cli/audit.ts +33 -0
  10. package/src/cli/dispatch.ts +192 -0
  11. package/src/cli/partialWrite.ts +69 -0
  12. package/src/cli/rotationLedger.ts +98 -0
  13. package/src/cli/validate.ts +38 -0
  14. package/src/cli/writeTargets.ts +125 -0
  15. package/src/cloudflare-test.d.ts +16 -0
  16. package/src/crypto/envelope.ts +188 -0
  17. package/src/crypto/versionedValue.ts +82 -0
  18. package/src/data/secretRotations.ts +49 -0
  19. package/src/data/statusDb.ts +29 -0
  20. package/src/data/systemSecrets.ts +44 -0
  21. package/src/data/tables.ts +16 -0
  22. package/src/dev/devSecretsFile.ts +167 -0
  23. package/src/dev/loadDevSecrets.ts +128 -0
  24. package/src/dev/seedDevSecrets.ts +447 -0
  25. package/src/env/bindings.ts +84 -0
  26. package/src/error/errors.ts +155 -0
  27. package/src/http/guards.ts +107 -0
  28. package/src/http/responses.ts +225 -0
  29. package/src/http/rotate.ts +224 -0
  30. package/src/http/routes.ts +300 -0
  31. package/src/http/schemas.ts +53 -0
  32. package/src/http/view.ts +74 -0
  33. package/src/index.ts +50 -0
  34. package/src/keyspace.ts +70 -0
  35. package/src/keyspaceWrite.ts +135 -0
  36. package/src/management/writeSecret.ts +120 -0
  37. package/src/manager/configWriter.ts +19 -0
  38. package/src/manager/dispatcher.ts +142 -0
  39. package/src/manager/managerRegistry.ts +53 -0
  40. package/src/manager/retryPolicy.ts +44 -0
  41. package/src/manager/rotationWorkflow.ts +26 -0
  42. package/src/manager/secretsConfigWriter.ts +61 -0
  43. package/src/manager/worker.ts +119 -0
  44. package/src/manager/wrangler.jsonc +76 -0
  45. package/src/manager/writeWorkflow.ts +162 -0
  46. package/src/migrations/0001_init.ts +53 -0
  47. package/src/mintValue.ts +53 -0
  48. package/src/provision/provisionSecrets.ts +206 -0
  49. package/src/provision/resolveManagerConfig.ts +175 -0
  50. package/src/registry.ts +453 -0
  51. package/src/rotation/atRestKeyRotation.ts +146 -0
  52. package/src/rotation/keyRotation.ts +139 -0
  53. package/src/rotation/rotateValue.ts +412 -0
  54. package/src/rotation/rotationLedger.ts +167 -0
  55. package/src/rotation/valueRotator.ts +76 -0
  56. package/src/scope.ts +120 -0
  57. package/src/secretsStore.ts +765 -0
  58. package/src/sharedSecretsStore.ts +187 -0
  59. package/src/store/rotationTracker.ts +189 -0
  60. package/src/store/systemSecretsStore.ts +223 -0
  61. package/src/test-utils/devEncryptionKeys.ts +30 -0
  62. package/src/test-utils/secretFixtures.ts +178 -0
  63. package/src/valueBearing.ts +42 -0
  64. package/src/version.generated.ts +16 -0
@@ -0,0 +1,206 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
5
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
6
+ import type { EncryptionConfig } from "../crypto/envelope";
7
+ import { generateKeyB64 } from "../rotation/keyRotation";
8
+ import { type ManagedEnvironment, managedEnvironments } from "../scope";
9
+
10
+ /**
11
+ * The CF Secrets Store entry name holding an environment's master key — `<project>-<env>-secrets-encryption-keys`.
12
+ *
13
+ * A Cloudflare account has **one** Secrets Store, flat and unpartitionable, so the entry name is the
14
+ * only partition there is. Without the project segment, two Pithy projects in one account would both
15
+ * resolve to the same entry: the second `pithy secrets provision` would find the first's key already
16
+ * there, adopt it, and encrypt its rows under another project's key — and either project's teardown
17
+ * would then orphan both. The environment segment keeps staging and prod distinct within a project.
18
+ *
19
+ * Composed through core's naming facade as a **Secrets Store entry**, not through the generic composer.
20
+ * The kind is what carries the limit: a store entry has no documented Cloudflare cap, so it is held to
21
+ * Pithy's own ceiling rather than to R2's 63 — which is why this reads `…-secrets-encryption-keys`
22
+ * rather than the hashed `…-secrets-encryp-91c2e9` a 63-character budget once produced. The facade also
23
+ * validates the environment, so a stale spelling fails here rather than naming an entry nothing binds.
24
+ *
25
+ * The worker still binds this entry under the fixed `SECRETS_ENCRYPTION_KEYS` **binding** name; only the
26
+ * store entry is scoped (see the manager's resolved `wrangler.jsonc`).
27
+ */
28
+ export function masterKeySecretName(project: string, env: ManagedEnvironment): string {
29
+ return resourceNames(project).env(env).secretEntry("secrets-encryption-keys");
30
+ }
31
+
32
+ /**
33
+ * The profile/`.dev.vars` **secret name** the manager's CF API token is known by — the registry join
34
+ * key, not a Secrets Store entry name. Deliberately unscoped: it is a variable key, and scoping it
35
+ * would rename an environment variable rather than partition an account-wide namespace.
36
+ */
37
+ export const MANAGER_CF_API_TOKEN_SECRET = "SECRETS_MANAGER_CF_API_TOKEN";
38
+
39
+ /**
40
+ * The CF Secrets Store entry name holding the scoped CF API token, bound into each manager as
41
+ * `CLOUDFLARE_API_TOKEN` — `<project>-global-secrets-manager-cf-api-token`.
42
+ *
43
+ * The token is `global` (one value, written once canonically and bound the same way by every manager
44
+ * of this project), so the literal `global` fills the environment slot rather than being omitted — the
45
+ * naming rule has no exception to remember. The facade makes that a property rather than a string:
46
+ * `names.global` is the same interface an environment gets, so no call site can typo the scope into a
47
+ * near-miss of a real environment. Provisioning owns this store-entry-name → binding-var mapping out of
48
+ * band; the manager registry stays keyed by the binding var (see `manager/managerRegistry`).
49
+ */
50
+ export function managerCfApiTokenSecretName(project: string): string {
51
+ return resourceNames(project).global.secretEntry(MANAGER_CF_API_TOKEN_SECRET);
52
+ }
53
+
54
+ /**
55
+ * The Cloudflare account-token **name** under which provisioning mints the manager's runtime
56
+ * credential — `<project>-global-secrets-manager`. Distinct from
57
+ * {@link managerCfApiTokenSecretName}, the Secrets Store entry name that holds the token's value:
58
+ * this is the token's identity in the account's API-token list, the key idempotent re-mint and
59
+ * teardown match on.
60
+ *
61
+ * The account's token list is flat too, and teardown deletes **every** token of this name. An
62
+ * unscoped name would therefore make one project's `pithy secrets deprovision` revoke every other
63
+ * project's manager credential in the same account — every one of their rotations failing at once.
64
+ *
65
+ * Named as an **API token** through the facade, which is the only reason the two functions can differ
66
+ * in budget as well as in suffix: a token label is a free-text field Cloudflare puts no cap on.
67
+ */
68
+ export function managerCfApiTokenName(project: string): string {
69
+ return resourceNames(project).global.apiToken("secrets-manager");
70
+ }
71
+
72
+ /**
73
+ * Mint a fresh master key and build the initial encryption config to store as the env's
74
+ * `SECRETS_ENCRYPTION_KEYS` at provision time: version 1, one key, current. A real
75
+ * `ensureMasterKey` JSON-stringifies this and writes it to CF Secrets Store — but only when no key
76
+ * exists yet, since replacing it would orphan every stored secret.
77
+ */
78
+ export async function initialMasterKeyConfig(now: Date = new Date()): Promise<EncryptionConfig> {
79
+ return { currentVersion: "1", versions: { "1": await generateKeyB64() }, lastRotatedAt: now.toISOString() };
80
+ }
81
+
82
+ /**
83
+ * The provisioning orchestration for `pithy add secrets`. It stands up the durable, per-environment
84
+ * secrets infrastructure that the rest of the capability assumes: a dedicated D1, a minted master
85
+ * key, the migrated schema, and the deployed manager worker — once per managed environment.
86
+ *
87
+ * The live Cloudflare/wrangler operations are behind the {@link SecretsProvisioner} seam so the
88
+ * orchestration is unit-tested (the sequence, the per-env fan-out, idempotency contract) without
89
+ * touching Cloudflare; the real seam implementation is the live-CF glue, verified by the integration
90
+ * suite. Each `ensure*` step must be **idempotent** — re-running `pithy add secrets` is a no-op.
91
+ */
92
+ export interface SecretsProvisioner {
93
+ /**
94
+ * Verify the account prerequisites before any resource is created — most importantly that a
95
+ * `workers.dev` subdomain is registered, which Cloudflare requires to deploy the Workflow-hosting
96
+ * managers. Throws a clear error if a prerequisite is missing, so provisioning fails fast and clean
97
+ * (nothing half-created) rather than partway through a deploy.
98
+ */
99
+ preflight(): Promise<void>;
100
+ /**
101
+ * Ensure the manager's least-privilege CF API token (Secrets Store Read + Write) and write it into
102
+ * the Secrets Store as the manager's runtime credential — once, before any per-env work, since the
103
+ * token is `global`. Runs first so a bootstrap token that **cannot** mint account tokens fails the
104
+ * whole provision fast and clean, before any resource is created. Idempotent: reuse the stored token
105
+ * if present, otherwise roll the existing manager token's value in place (or mint one if none exists).
106
+ */
107
+ ensureManagerToken(): Promise<void>;
108
+ /** Create (or reuse) the per-env secrets D1; returns its id. Idempotent. */
109
+ ensureDatabase(env: ManagedEnvironment): Promise<{ databaseId: string }>;
110
+ /**
111
+ * Mint the initial master key and store it as `SECRETS_ENCRYPTION_KEYS` for this environment;
112
+ * returns the Secrets Store id. Idempotent — if the key already exists, it is left untouched (a
113
+ * fresh key would orphan every stored secret).
114
+ */
115
+ ensureMasterKey(env: ManagedEnvironment): Promise<{ storeId: string }>;
116
+ /** Run the `secrets_*` migrations against this environment's D1. Idempotent (already-applied are skipped). */
117
+ migrate(env: ManagedEnvironment, databaseId: string): Promise<void>;
118
+ /** Deploy the prebuilt manager worker for this environment, wired to the resolved resource ids. */
119
+ deployManager(env: ManagedEnvironment, resolved: { databaseId: string; storeId: string }): Promise<void>;
120
+ }
121
+
122
+ /** What provisioning produced, per environment — the resource ids the manager's `wrangler.jsonc` needs. */
123
+ export interface ProvisionResult {
124
+ perEnv: Array<{ env: ManagedEnvironment; databaseId: string; storeId: string }>;
125
+ }
126
+
127
+ /**
128
+ * Provision every managed environment in order: mint the global manager token, then per env create
129
+ * the D1, mint the master key, migrate, deploy the manager. The order matters — the manager token is
130
+ * minted first (one global credential, and a bootstrap token that cannot mint fails fast before
131
+ * anything is created), the database and key exist before migrations run, and the manager is deployed
132
+ * last, once its resources are in place. Idempotent end to end (each step is).
133
+ *
134
+ * `environments` is the project's declaration, and **the loop is over all of it**: an environment the
135
+ * project deploys to and this skips is one whose secrets have no master key — the exact silence #241
136
+ * found. One manager per declared environment is the price of declaring it (see `scope.ts`).
137
+ */
138
+ export async function provisionSecrets(
139
+ provisioner: SecretsProvisioner,
140
+ environments: DeclaredEnvironments | readonly string[],
141
+ ): Promise<ProvisionResult> {
142
+ await provisioner.preflight();
143
+ await provisioner.ensureManagerToken();
144
+ const perEnv: ProvisionResult["perEnv"] = [];
145
+ for (const env of managedEnvironments(environments)) {
146
+ const { databaseId } = await provisioner.ensureDatabase(env);
147
+ const { storeId } = await provisioner.ensureMasterKey(env);
148
+ await provisioner.migrate(env, databaseId);
149
+ await provisioner.deployManager(env, { databaseId, storeId });
150
+ perEnv.push({ env, databaseId, storeId });
151
+ }
152
+ return { perEnv };
153
+ }
154
+
155
+ /**
156
+ * The teardown seam — the inverse of {@link SecretsProvisioner}, removing each environment's secrets
157
+ * infrastructure. Behind the seam so the orchestration (order, the key-deletion guard, idempotency)
158
+ * is unit-tested without Cloudflare; the live implementation is verified by the integration suite.
159
+ * Every step is idempotent — a missing resource is a no-op, so re-running teardown is safe.
160
+ */
161
+ export interface SecretsDeprovisioner {
162
+ /** Delete the env's manager worker. Idempotent (a missing worker is a no-op). */
163
+ deleteManager(env: ManagedEnvironment): Promise<void>;
164
+ /**
165
+ * Delete the env's master key from the Secrets Store. Destructive — every stored secret becomes
166
+ * undecryptable — so the orchestration only calls it when explicitly asked. Idempotent.
167
+ */
168
+ deleteMasterKey(env: ManagedEnvironment): Promise<void>;
169
+ /** Delete the env's secrets D1. Idempotent (a missing database is a no-op). */
170
+ deleteDatabase(env: ManagedEnvironment): Promise<void>;
171
+ /**
172
+ * Remove the manager's CF API token entirely: delete the minted account token from Cloudflare
173
+ * **and** its `<project>-global-secrets-manager-cf-api-token` entry from the Secrets Store. Both
174
+ * names are project-scoped, so this never reaches another project's credential. It is `global` —
175
+ * one token shared by both managers — so it is removed once, after every manager is gone. Safe and
176
+ * ungated: the token is a re-mintable access credential, not a key, so removing it orphans no
177
+ * secrets. Idempotent (a missing token or entry is a no-op).
178
+ */
179
+ deleteManagerToken(): Promise<void>;
180
+ }
181
+
182
+ /** Teardown options. By default the master keys are **kept** — deleting them is irreversible. */
183
+ export interface DeprovisionOptions {
184
+ /** Also delete each environment's master key. Off by default; only a full destroy sets it. */
185
+ deleteKeys?: boolean;
186
+ }
187
+
188
+ /**
189
+ * Tear down every managed environment, reversing {@link provisionSecrets}: delete the manager worker
190
+ * first (it binds the other resources), then — only when `deleteKeys` is set — the master key, then
191
+ * the D1. The master key is preserved unless explicitly requested: losing it orphans every secret,
192
+ * and a re-provision can reuse the existing key. The shared manager CF API token is deleted once at
193
+ * the end, after every manager that binds it is gone. Idempotent end to end.
194
+ */
195
+ export async function deprovisionSecrets(
196
+ deprovisioner: SecretsDeprovisioner,
197
+ environments: DeclaredEnvironments | readonly string[],
198
+ options: DeprovisionOptions = {},
199
+ ): Promise<void> {
200
+ for (const env of managedEnvironments(environments)) {
201
+ await deprovisioner.deleteManager(env);
202
+ if (options.deleteKeys) await deprovisioner.deleteMasterKey(env);
203
+ await deprovisioner.deleteDatabase(env);
204
+ }
205
+ await deprovisioner.deleteManagerToken();
206
+ }
@@ -0,0 +1,175 @@
1
+ // SPDX-FileCopyrightText: 2026 Pithy
2
+ // SPDX-License-Identifier: MIT
3
+
4
+ import { InternalError } from "@pithy-sh/core/src/error/pithyError";
5
+ import type { DeclaredEnvironments } from "@pithy-sh/core/src/naming/environment";
6
+ import { resourceNames } from "@pithy-sh/core/src/naming/resourceNames";
7
+ import { SECRETS_CAPABILITY, secretsRotateWorkflowName, secretsWriteWorkflowName } from "../manager/dispatcher";
8
+ import { type ManagedEnvironment, managedEnvironments } from "../scope";
9
+ import { managerCfApiTokenSecretName, masterKeySecretName } from "./provisionSecrets";
10
+
11
+ /**
12
+ * The manager's `wrangler.jsonc` template shape — only the fields provisioning resolves. The
13
+ * committed template (`src/manager/wrangler.jsonc`) is the source of truth for the static fields
14
+ * (compatibility date, crons, class names); this resolver fills the per-environment placeholders.
15
+ */
16
+ export interface ManagerWranglerTemplate {
17
+ name: string;
18
+ main: string;
19
+ compatibility_date: string;
20
+ compatibility_flags: string[];
21
+ /** Off — the manager has no public URL (Workflow dispatch + cron only). Passed through unchanged. */
22
+ workers_dev: boolean;
23
+ d1_databases: Array<{ binding: string; database_name: string; database_id: string }>;
24
+ secrets_store_secrets: Array<{ binding: string; store_id: string; secret_name: string }>;
25
+ workflows: Array<{ binding: string; name: string; class_name: string }>;
26
+ triggers: { crons: string[] };
27
+ vars: Record<string, string>;
28
+ }
29
+
30
+ /** The resolved resource ids for one environment's manager deploy. */
31
+ export interface ManagerConfigParams {
32
+ env: ManagedEnvironment;
33
+ databaseId: string;
34
+ storeId: string;
35
+ accountId: string;
36
+ /**
37
+ * The project name (root `pithy.config.ts` `name`, via `requireProjectName` — never guessed). Every
38
+ * name in the resolved config leads with it: the Worker script, its D1, both Workflows, and both
39
+ * Secrets Store entries. It is also stamped into the worker as the `PROJECT` var, so the at-rest
40
+ * rotation writes its new key set back to the entry this worker actually binds.
41
+ */
42
+ project: string;
43
+ }
44
+
45
+ /**
46
+ * One project's manager for one environment — `<project>-<env>-secrets`. It is three things at once:
47
+ * the deployed Worker script name, the resolved config's basename, and the `database_name` of that
48
+ * environment's secrets D1 (the manager and its database are one unit, so they share one name).
49
+ *
50
+ * **This is the sharpest project-scoping case in the toolset.** A Worker script name is account-scoped
51
+ * and `wrangler deploy` upserts: unscoped, a second Pithy project's `pithy secrets provision` does not
52
+ * collide with the first — it silently *replaces* the first project's running secrets manager, pointing
53
+ * it at the second project's D1 and master key. Every subsequent write and rotation for the first
54
+ * project then lands in, or fails against, resources it does not own. The D1 name shares the same flat
55
+ * namespace and the same fate.
56
+ *
57
+ * Named as a **Worker script** through core's facade, so it is held to 63 — the workers.dev cap, the
58
+ * only one that survives an adopter enabling a subdomain — rather than to the Workflow's 64 the two
59
+ * numbers used to share. The database takes the same string deliberately: the manager and its D1 are
60
+ * one unit, and a D1 name is the looser of the two limits, so the tighter one governs both.
61
+ */
62
+ export function managerWorkerName(project: string, env: ManagedEnvironment): string {
63
+ return resourceNames(project).env(env).worker(SECRETS_CAPABILITY);
64
+ }
65
+
66
+ /**
67
+ * The deployed name behind one of the manager's Workflow bindings.
68
+ *
69
+ * Derived from the binding, never from the template's own `name`. The template says
70
+ * `pithy-secrets-write`, and no amount of suffixing recovers a project from that — so, exactly like
71
+ * every other capability's host resolver, the name is composed from `(project, capability, job, env)`
72
+ * and the template's literal is documentation. An unrecognized binding is an authoring bug: it would
73
+ * deploy a Workflow under a name nothing dispatches to, so it fails loudly here.
74
+ */
75
+ function managerWorkflowName(binding: string, project: string, env: ManagedEnvironment): string {
76
+ switch (binding) {
77
+ case "SECRETS_WRITE":
78
+ return secretsWriteWorkflowName(project, env);
79
+ case "AT_REST_ROTATION":
80
+ return secretsRotateWorkflowName(project, env);
81
+ default:
82
+ throw new InternalError({
83
+ message: "The secrets manager template declares a Workflow provisioning cannot name.",
84
+ action: "Give the binding a project-scoped name in resolveManagerConfig, or drop it from the template.",
85
+ detail: `unresolved workflows binding: ${binding}`,
86
+ });
87
+ }
88
+ }
89
+
90
+ /**
91
+ * Resolve the manager `wrangler.jsonc` template into one environment's standalone config — no
92
+ * `[env.*]` stanzas (CLAUDE.md: staging and prod are genuinely separate workers). The template
93
+ * carries `<filled-at-provision>` placeholders; this fills the project-scoped worker and Workflow
94
+ * names, the D1 id and name, the Secrets Store id and entry names, and the account id, leaving every
95
+ * static field untouched. Pure: the caller parses the template and writes the result.
96
+ *
97
+ * Every name it composes leads with the project, and every one goes through core's naming facade under
98
+ * its own kind — a Worker script for the host, a Workflow for each job, a Secrets Store entry for each
99
+ * bound secret — rather than suffixing the template's own literals, which carry no project to suffix.
100
+ * Picking the kind is what picks the limit; no call here passes a budget.
101
+ */
102
+ export function resolveManagerConfig(
103
+ template: ManagerWranglerTemplate,
104
+ params: ManagerConfigParams,
105
+ ): ManagerWranglerTemplate {
106
+ const { env, databaseId, storeId, accountId, project } = params;
107
+ const name = managerWorkerName(project, env);
108
+ const resolved: ManagerWranglerTemplate = structuredClone(template);
109
+
110
+ resolved.name = name;
111
+ resolved.d1_databases = resolved.d1_databases.map((db) => ({ ...db, database_name: name, database_id: databaseId }));
112
+ // Both secrets live in the one account-wide store, so every entry gets `storeId` — and **every**
113
+ // entry name is resolved here, never passed through. The template's literals are placeholders: an
114
+ // entry name provisioning did not write is an entry the worker cannot bind, so an unrecognized
115
+ // binding is an authoring bug and fails loudly rather than deploying a worker that dies on first read.
116
+ resolved.secrets_store_secrets = resolved.secrets_store_secrets.map((entry) => ({
117
+ ...entry,
118
+ store_id: storeId,
119
+ secret_name: managerStoreEntryName(entry.binding, project, env),
120
+ }));
121
+ // The write Workflow's name is the CLI's dispatch target (<project>-<env>-secrets-write). Both are
122
+ // composed from the binding, so two projects' managers are addressable separately in one account.
123
+ resolved.workflows = resolved.workflows.map((wf) => ({ ...wf, name: managerWorkflowName(wf.binding, project, env) }));
124
+ resolved.vars = {
125
+ ...resolved.vars,
126
+ CLOUDFLARE_ACCOUNT_ID: accountId,
127
+ SECRETS_STORE_ID: storeId,
128
+ ENVIRONMENT: env,
129
+ PROJECT: project,
130
+ };
131
+
132
+ return resolved;
133
+ }
134
+
135
+ /**
136
+ * The Secrets Store entry name behind one of the manager's store bindings. The master key is
137
+ * per-environment; the CF API token is `global`. Both are project-scoped, because the account has
138
+ * one flat Secrets Store and the name is the only partition in it.
139
+ */
140
+ function managerStoreEntryName(binding: string, project: string, env: ManagedEnvironment): string {
141
+ switch (binding) {
142
+ case "SECRETS_ENCRYPTION_KEYS":
143
+ return masterKeySecretName(project, env);
144
+ case "CLOUDFLARE_API_TOKEN":
145
+ return managerCfApiTokenSecretName(project);
146
+ default:
147
+ throw new InternalError({
148
+ message: "The secrets manager template declares a Secrets Store binding provisioning never writes.",
149
+ action: "Give the binding a project-scoped entry name in resolveManagerConfig, or drop it from the template.",
150
+ detail: `unresolved secrets_store_secrets binding: ${binding}`,
151
+ });
152
+ }
153
+ }
154
+
155
+ /**
156
+ * Resolve the manager config for every declared environment, given each env's provisioned ids.
157
+ *
158
+ * An environment with no entry in `perEnv` is skipped rather than resolved against `undefined` ids: the
159
+ * ids are what provisioning produced, so a gap means that environment was not provisioned, and writing a
160
+ * manager config with an empty `database_id` would deploy a manager bound to nothing.
161
+ */
162
+ export function resolveAllManagerConfigs(
163
+ template: ManagerWranglerTemplate,
164
+ account: { accountId: string; project: string },
165
+ perEnv: Record<ManagedEnvironment, { databaseId: string; storeId: string }>,
166
+ environments: DeclaredEnvironments | readonly string[],
167
+ ): Array<{ env: ManagedEnvironment; config: ManagerWranglerTemplate }> {
168
+ const resolved: Array<{ env: ManagedEnvironment; config: ManagerWranglerTemplate }> = [];
169
+ for (const env of managedEnvironments(environments)) {
170
+ const ids = perEnv[env];
171
+ if (!ids) continue;
172
+ resolved.push({ env, config: resolveManagerConfig(template, { env, ...account, ...ids }) });
173
+ }
174
+ return resolved;
175
+ }