@suluk/platform 0.3.2 → 0.4.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@suluk/platform",
3
- "version": "0.3.2",
3
+ "version": "0.4.0",
4
4
  "description": "The platform generator (C051): write one `definePlatform` manifest → it plans the shadcn-registry adds, generates the wired Hono entry, and merges each module's provision fragment into a single provision.config. The manifest compiles to a shadcn-add list + a C047 provision.config; the generator runs the adds + `@suluk/provision`. Turns the Suluk backend registry into a one-command platform. CANDIDATE tooling.",
5
5
  "publishConfig": {
6
6
  "access": "public"
package/src/catalog.ts CHANGED
@@ -29,7 +29,7 @@ export const CATALOG: Record<string, CatalogEntry> = Object.fromEntries(Object.e
29
29
  export const BASE_DEPS = ["@suluk/platform", "@suluk/provision", "@suluk/core", "@suluk/env", "effect", "hono", "drizzle-orm"];
30
30
 
31
31
  /** Pinned ranges for the NON-@suluk ecosystem deps — the single place they're kept current for every generated app.
32
- * @suluk/* are NOT here: they resolve to "latest" so a package fix flows to the app via `bun update` (the C052 payoff). */
32
+ * `@suluk/*` are NOT here: they resolve to "latest" so a package fix flows to the app via `bun update` (the C052 payoff). */
33
33
  export const ECOSYSTEM_VERSIONS: Record<string, string> = {
34
34
  "better-auth": "^1.0.0",
35
35
  "@better-auth/api-key": "^1.0.0",
package/src/generate.ts CHANGED
@@ -59,6 +59,9 @@ export async function generatePlatform(input: PlatformManifest | Platform, opts:
59
59
  ["src/env.ts", plan.envTs, true], // the @suluk/env declare-once (derived from the manifest's secrets)
60
60
  ["scripts/sync-secrets.ts", plan.syncSecrets, true], // the deploy-time secret push (derived)
61
61
  ["scripts/link-key.ts", plan.linkKey, true], // register the private key into ~/.suluk/settings.json (the central store)
62
+ ["scripts/provision.ts", plan.provisionScript, true], // the credential lifecycle (source .env.temp/.env → provision → seal)
63
+ ["scripts/mint-tokens.ts", plan.mintTokens, true], // mint scoped least-privilege CF tokens from the master
64
+ [".env.temp", plan.envTemp, false], // the PLAINTEXT provisioning bootstrap — SCAFFOLD IF ABSENT (gitignored; consumed by provision)
62
65
  [".env", plan.envScaffold, false], // the COMMITTED encrypted-secrets file — SCAFFOLD IF ABSENT (never clobber secrets)
63
66
  ] as const) {
64
67
  if (always || (await read(file)) == null) {
package/src/index.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @suluk/platform — the platform generator (C051). Write one `definePlatform` manifest; the generator plans the
2
+ * `@suluk/platform` — the platform generator (C051). Write one `definePlatform` manifest; the generator plans the
3
3
  * shadcn-registry adds, generates the wired Hono entry, and merges each module's provision fragment into a single
4
4
  * provision.config. The higher-level surface over C047's provision.config + the C050 registry: `services: ["auth",
5
5
  * "credits", "billing"]` → a whole backend. The generated `provision.config.ts` imports `mergeProvision` from here.
package/src/plan.ts CHANGED
@@ -39,6 +39,13 @@ export interface PlatformPlan {
39
39
  /** the generated `scripts/link-key.ts` — register the private key into the centralized `~/.suluk/settings.json` (the store
40
40
  * `@suluk/env` reads by default for local dev/deploy/CI), the toolfactory model. */
41
41
  linkKey: string;
42
+ /** the generated `.env.temp` SCAFFOLD — the PLAINTEXT bootstrap for `bun run provision` (gitignored; consumed + deleted). */
43
+ envTemp: string;
44
+ /** the generated `scripts/provision.ts` — the credential lifecycle: source `.env.temp`/`.env` → provision → mint scoped
45
+ * tokens → encrypt keepers → DELETE the ephemeral master token → stage the encrypted `.env`. */
46
+ provisionScript: string;
47
+ /** the generated `scripts/mint-tokens.ts` — mint scoped least-privilege CF tokens from the master, encrypted into `.env`. */
48
+ mintTokens: string;
42
49
  /** the generated `.env` SCAFFOLD (committed) — a header + the setup steps, NO values. `generate` writes it only if absent
43
50
  * (never clobbering the operator's encrypted secrets). Secret VALUES are added encrypted via `suluk-env set`. */
44
51
  envScaffold: string;
@@ -75,6 +82,9 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
75
82
  envTs: buildEnvTs(env),
76
83
  syncSecrets: buildSyncSecrets(),
77
84
  linkKey: buildLinkKey(),
85
+ envTemp: buildEnvTemp(env),
86
+ provisionScript: buildProvisionScript(env),
87
+ mintTokens: buildMintTokens(env),
78
88
  envScaffold: buildEnvScaffold(env),
79
89
  };
80
90
  }
@@ -85,22 +95,23 @@ const secretsOf = (env: EnvVar[]): EnvVar[] => env.filter((e) => e.secret);
85
95
  /** The SECRET env keys → `.env.example` (required uncommented `KEY=`, optional commented `# KEY=`), each with its hint.
86
96
  * Non-secret config is NOT here — it's in the manifest `vars` → wrangler `[vars]`. Safe to commit (no values). */
87
97
  function buildEnvExample(env: EnvVar[]): string {
88
- const secrets = env.filter((e) => e.secret);
89
- const line = (e: EnvVar, commented: boolean) => `${commented ? "# " : ""}${e.name}=${e.hint ? ` # ${e.hint}` : ""}`;
90
- const required = secrets.filter((e) => e.required);
91
- const optional = secrets.filter((e) => !e.required);
98
+ const line = (e: EnvVar) => `${e.name}=${e.hint ? ` # ${e.hint}` : ""}`;
99
+ const provisioning = provisioningOf(env).filter((e) => !e.minted); // the master + account id (the raw inputs)
100
+ const minted = env.filter((e) => e.minted); // the scoped tokens the mint step creates
101
+ const runtime = runtimeSecretsOf(env);
92
102
  return [
93
- "# .env keys checklist (generated). The `.env` is COMMITTED with these values ENCRYPTED (@suluk/env, ML-KEM-768):",
94
- "# bunx suluk-env keygen # once keypair (SULUK_PUBLIC_KEY .env; private .env.keys)",
95
- "# bun run link-key # register the private key in ~/.suluk/settings.json (@suluk/env reads it by default)",
96
- "# bunx suluk-env set KEY=value # per secret — encrypts it into .env",
97
- "# Non-secret config lives in platform.config.ts `vars` (→ wrangler.toml [vars]), NOT here.",
103
+ "# Secret keys checklist (generated). SETUP: fill `.env.temp` (plaintext) `bun run provision`. It creates the keypair,",
104
+ "# provisions infra, mints the scoped tokens, ENCRYPTS the keepers into the COMMITTED `.env` (@suluk/env ML-KEM-768), and",
105
+ "# DELETES the ephemeral CF master token. Non-secret config lives in platform.config.ts `vars` ( wrangler.toml [vars]).",
98
106
  "",
99
- "# Requiredthe app won't start without these:",
100
- ...required.map((e) => line(e, false)),
107
+ "# PROVISIONING creds supply in .env.temp (plaintext). The master is EPHEMERAL (deleted after minting; never committed):",
108
+ ...provisioning.map(line),
101
109
  "",
102
- "# Optional:",
103
- ...optional.map((e) => line(e, true)),
110
+ "# Scoped least-privilege tokens — MINTED by `bun run provision`/`mint-tokens` (you don't supply these); kept encrypted:",
111
+ ...minted.map((e) => `# ${line(e)}`),
112
+ "",
113
+ "# RUNTIME secrets — supply in .env.temp; encrypted into .env + shipped to the Worker:",
114
+ ...runtime.map((e) => (e.required ? line(e) : `# ${line(e)}`)),
104
115
  "",
105
116
  ].join("\n");
106
117
  }
@@ -112,8 +123,9 @@ function buildEnvExample(env: EnvVar[]): string {
112
123
  */
113
124
  function buildEnvTs(env: EnvVar[]): string {
114
125
  const secrets = secretsOf(env);
126
+ const surface = (e: EnvVar): string => (e.provisioning || e.minted || e.surface === "local" ? '["local"]' : '["cloudflare"]');
115
127
  const spec = secrets
116
- .map((e) => ` ${e.name}: { secret: true, ${e.required ? "required: true, " : ""}surfaces: ["cloudflare"]${e.hint ? `, description: ${JSON.stringify(e.hint)}` : ""} },`)
128
+ .map((e) => ` ${e.name}: { secret: true, ${e.required ? "required: true, " : ""}surfaces: ${surface(e)}${e.hint ? `, description: ${JSON.stringify(e.hint)}` : ""} },`)
117
129
  .join("\n");
118
130
  return [
119
131
  "// AUTO-GENERATED by @suluk/platform — the @suluk/env declare-once for this app's SECRETS. Values live ENCRYPTED in the",
@@ -134,15 +146,29 @@ function buildEnvTs(env: EnvVar[]): string {
134
146
  */
135
147
  function buildSyncSecrets(): string {
136
148
  return `#!/usr/bin/env bun
137
- // AUTO-GENERATED by @suluk/platform. Push the committed, @suluk/env-encrypted secrets to the Worker as \`wrangler secret\`s.
138
- // Run at deploy: \`bun run sync-secrets\`. Needs the private key (.env.keys / ~/.suluk / SULUK_PRIVATE_KEY) + a CF-authed wrangler.
139
- import { loadEnvFile } from "@suluk/env/node";
149
+ // AUTO-GENERATED by @suluk/platform. Push the DECRYPTION key to the Worker (so src/index.ts's loadEnv decrypts the committed
150
+ // .env at runtime), and optionally each runtime secret directly. Run at deploy: \`bun run sync-secrets\` (needs a deployed
151
+ // Worker + a CF-authed wrangler; the private key comes from ~/.suluk/settings.json).
152
+ import { loadEnvFile, readPrivateKey } from "@suluk/env/node";
140
153
  import { env } from "../src/env";
141
154
 
155
+ const put = async (name: string, value: string) => {
156
+ const p = Bun.spawn(["bunx", "wrangler", "secret", "put", name], { stdin: "pipe", stdout: "inherit", stderr: "inherit" });
157
+ p.stdin.write(value); await p.stdin.end();
158
+ if ((await p.exited) !== 0) { console.error(\`✗ failed to put \${name}\`); process.exit(1); }
159
+ console.log(\`✓ \${name}\`);
160
+ };
161
+
162
+ // 1. the DECRYPTION key — the Worker's loadEnv decrypts the committed .env with it (the primary runtime path).
163
+ const priv = readPrivateKey();
164
+ if (!priv) { console.error("✗ no private key (~/.suluk/settings.json / SULUK_PRIVATE_KEY) — run \`bun run link-key\`"); process.exit(1); }
165
+ await put("SULUK_PRIVATE_KEY", priv);
166
+
167
+ // 2. (optional, belt-and-suspenders) push each cloudflare-surfaced runtime secret directly too.
142
168
  const values = await loadEnvFile(); // decrypt every value in .env into a { KEY: value } record
143
169
  const names = env.forSurface("cloudflare").filter((k) => values[k] !== undefined && values[k] !== "");
144
170
  if (!names.length) {
145
- console.log("no cloudflare-surfaced secrets are set in .env yet — run \`bunx suluk-env set KEY=value\` first.");
171
+ console.log("✓ synced SULUK_PRIVATE_KEY (no cloudflare-surfaced runtime secrets set yet).");
146
172
  process.exit(0);
147
173
  }
148
174
  for (const name of names) {
@@ -193,6 +219,139 @@ console.log(\`✓ linked \${name} → \${settingsPath}. You can now \\\`rm .env.
193
219
  `;
194
220
  }
195
221
 
222
+ /** the app's PROVISIONING creds (surface "local" — used to stand up + deploy, never shipped to the Worker). */
223
+ const provisioningOf = (env: EnvVar[]): EnvVar[] => env.filter((e) => e.provisioning || e.minted || e.surface === "local");
224
+ /** the ephemeral provisioning creds (the CF master token) — DELETED after provisioning, never committed. */
225
+ const ephemeralOf = (env: EnvVar[]): EnvVar[] => env.filter((e) => e.provisioning);
226
+ /** the RUNTIME secrets (surface "cloudflare") — encrypted in the committed .env + reach the Worker. */
227
+ const runtimeSecretsOf = (env: EnvVar[]): EnvVar[] => secretsOf(env).filter((e) => !e.provisioning && !e.minted && e.surface !== "local");
228
+
229
+ /**
230
+ * `.env.temp` — the PLAINTEXT bootstrap (gitignored). The operator drops the raw provisioning creds (+ the runtime secrets)
231
+ * here; `bun run provision` CONSUMES it (stages the values into `.env`, encrypts the keepers, DELETES the ephemeral master
232
+ * token, then deletes `.env.temp`). `generate` writes this scaffold only if absent. NEVER committed.
233
+ */
234
+ function buildEnvTemp(env: EnvVar[]): string {
235
+ const line = (e: EnvVar) => `${e.name}= # ${e.hint ?? ""}`;
236
+ return [
237
+ "# .env.temp — PLAINTEXT bootstrap for `bun run provision`. Gitignored; consumed + DELETED after provisioning.",
238
+ "# Fill in the raw values, then run `bun run provision` (it encrypts the keepers into .env + deletes this file + the",
239
+ "# ephemeral CF master token). The DECRYPTION key is generated for you (→ ~/.suluk/settings.json + the Worker).",
240
+ "",
241
+ "# Provisioning creds (used to create infra + mint scoped tokens; the master is DELETED, never committed):",
242
+ ...provisioningOf(env).filter((e) => !e.minted).map(line),
243
+ "",
244
+ "# Runtime secrets (encrypted into .env + committed; shipped to the Worker):",
245
+ ...runtimeSecretsOf(env).map(line),
246
+ "",
247
+ ].join("\n");
248
+ }
249
+
250
+ /**
251
+ * `scripts/mint-tokens.ts` — mint the scoped least-privilege CF tokens from the master (toolfactory's model), each encrypted
252
+ * straight into `.env` via `suluk-env set`. Idempotent (skips a token already set). The value is never printed.
253
+ */
254
+ function buildMintTokens(env: EnvVar[]): string {
255
+ const minted = env.filter((e) => e.minted);
256
+ // map each minted token → the CF permission-group name(s) its hint implies (the operator can refine in the CF dashboard).
257
+ const groups: Record<string, string[]> = {
258
+ CLOUDFLARE_D1_TOKEN: ["D1 Write"],
259
+ CLOUDFLARE_WORKERS_TOKEN: ["Workers Scripts Write"],
260
+ CLOUDFLARE_KV_TOKEN: ["Workers KV Storage Write"],
261
+ };
262
+ const specs = minted.map((e) => ` { name: ${JSON.stringify(e.name)}, groups: ${JSON.stringify(groups[e.name] ?? ["Workers Scripts Write"])} },`).join("\n");
263
+ return `#!/usr/bin/env bun
264
+ // AUTO-GENERATED by @suluk/platform. Mint scoped least-privilege CF tokens FROM the master (CLOUDFLARE_API_TOKEN), each
265
+ // stored ENCRYPTED in .env via \`suluk-env set\`. Routine deploy/migrate then use these, not the master. Idempotent.
266
+ import { setVar, loadEnvFile, rawEnvRecord } from "@suluk/env/node";
267
+
268
+ await loadEnvFile({ override: true });
269
+ const token = process.env.CLOUDFLARE_API_TOKEN, acct = process.env.CLOUDFLARE_ACCOUNT_ID;
270
+ if (!token || !acct) { console.error("✗ CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID required (put them in .env.temp, run \`bun run provision\`)"); process.exit(1); }
271
+
272
+ const SCOPED = [
273
+ ${specs}
274
+ ];
275
+ // resolve the CF permission-group ids once (POST /accounts/{id}/tokens needs ids, not names).
276
+ const pgRes = await fetch("https://api.cloudflare.com/client/v4/user/tokens/permission_groups", { headers: { Authorization: \`Bearer \${token}\` } });
277
+ const pg = (await pgRes.json()) as { result?: Array<{ id: string; name: string }> };
278
+ const idOf = (name: string) => pg.result?.find((g) => g.name === name)?.id;
279
+
280
+ const have = rawEnvRecord();
281
+ for (const s of SCOPED) {
282
+ if (have[s.name]) { console.log(\`– skip \${s.name} (already set)\`); continue; }
283
+ const permission_groups = s.groups.map((n) => ({ id: idOf(n) })).filter((g) => g.id);
284
+ const res = await fetch(\`https://api.cloudflare.com/client/v4/accounts/\${acct}/tokens\`, {
285
+ method: "POST",
286
+ headers: { Authorization: \`Bearer \${token}\`, "Content-Type": "application/json" },
287
+ body: JSON.stringify({ name: \`\${s.name.toLowerCase()}\`, policies: [{ effect: "allow", resources: { [\`com.cloudflare.api.account.\${acct}\`]: "*" }, permission_groups }] }),
288
+ });
289
+ const out = (await res.json()) as { success?: boolean; result?: { value?: string }; errors?: unknown };
290
+ if (!out.success || !out.result?.value) { console.error(\`✗ mint \${s.name}: \${JSON.stringify(out.errors)}\`); process.exit(1); }
291
+ await setVar(s.name, out.result.value); // encrypted into .env; never printed
292
+ console.log(\`✓ minted \${s.name}\`);
293
+ }
294
+ console.log("✓ scoped tokens ready (encrypted in .env).");
295
+ `;
296
+ }
297
+
298
+ /**
299
+ * `scripts/provision.ts` — the credential lifecycle (the encrypted-commit model): source the raw creds from `.env.temp`
300
+ * (plaintext, first run — CONSUMED + deleted) OR the already-encrypted `.env`; ensure a keypair centralized in
301
+ * `~/.suluk/settings.json`; stand up the infra; mint scoped tokens; ENCRYPT the keepers into `.env`; DELETE the ephemeral
302
+ * master token (never committed); stage the encrypted `.env`. `bun run deploy` then ships the Worker + the decryption key.
303
+ */
304
+ function buildProvisionScript(env: EnvVar[]): string {
305
+ const ephemeral = ephemeralOf(env).map((e) => e.name);
306
+ return `#!/usr/bin/env bun
307
+ // AUTO-GENERATED by @suluk/platform — stand up the infra + SEAL the secrets (@suluk/env encrypted-commit model). Run once
308
+ // after filling .env.temp (or with an existing encrypted .env). Idempotent.
309
+ import { existsSync, rmSync, readFileSync, writeFileSync } from "node:fs";
310
+ import { loadEnvFile, setVar } from "@suluk/env/node";
311
+
312
+ const EPHEMERAL = ${JSON.stringify(ephemeral)}; // the CF master token(s): used to provision + mint, then DELETED (never committed)
313
+ const sh = async (cmd: string, args: string[]) => { const p = Bun.spawn([cmd, ...args], { stdout: "inherit", stderr: "inherit" }); if ((await p.exited) !== 0) { console.error(\`✗ \${cmd} \${args.join(" ")}\`); process.exit(1); } };
314
+
315
+ // 1. keypair → the central ~/.suluk/settings.json (the private key never stays in the repo).
316
+ await Bun.spawn(["bunx", "suluk-env", "keygen"]).exited; // idempotent (nonzero if a key already exists)
317
+ await sh("bun", ["run", "scripts/link-key.ts"]);
318
+ rmSync(".env.keys", { force: true });
319
+
320
+ // 2. source the raw creds: .env.temp (plaintext, first run) → stage into .env, then it's consumed; else the encrypted .env.
321
+ if (existsSync(".env.temp")) {
322
+ for (const l of readFileSync(".env.temp", "utf8").split("\\n")) {
323
+ const m = l.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.+)$/);
324
+ if (m && m[2].trim()) await setVar(m[1], m[2].trim().replace(/^["']|["']$/g, ""), { plain: true }); // stage plaintext (encrypted at step 5)
325
+ }
326
+ }
327
+ await loadEnvFile({ override: true }); // decrypt everything into process.env
328
+ if (!process.env.CLOUDFLARE_API_TOKEN || !process.env.CLOUDFLARE_ACCOUNT_ID) { console.error("✗ CLOUDFLARE_API_TOKEN / CLOUDFLARE_ACCOUNT_ID missing — put them in .env.temp"); process.exit(1); }
329
+
330
+ // 3. provision the infra (D1/KV — the C047 provision.config), 4. mint the scoped least-privilege tokens from the master.
331
+ await sh("bunx", ["suluk-provision", "apply"]);
332
+ await sh("bun", ["run", "scripts/mint-tokens.ts"]);
333
+
334
+ // 5. ENCRYPT every keeper value in .env in place (runtime secrets + account id + minted tokens).
335
+ await sh("bunx", ["suluk-env", "encrypt"]);
336
+
337
+ // 6. DELETE the ephemeral master token from .env (never committed) — routine ops use the minted scoped tokens.
338
+ if (EPHEMERAL.length) {
339
+ const kept = readFileSync(".env", "utf8").split("\\n").filter((l) => { const m = l.match(/^\\s*([A-Z0-9_]+)\\s*=/); return !(m && EPHEMERAL.includes(m[1])); });
340
+ writeFileSync(".env", kept.join("\\n"));
341
+ }
342
+ // 7. consume the plaintext bootstrap + stage the encrypted .env.
343
+ rmSync(".env.temp", { force: true });
344
+ await sh("git", ["add", "-f", ".env"]);
345
+ console.log("✓ provisioned + sealed: infra up, secrets encrypted in .env; the CF master token was removed from .env.");
346
+ console.log("");
347
+ console.log("⚠ REVOKE the master CF token in the dashboard now (https://dash.cloudflare.com/profile/api-tokens) — it minted the");
348
+ console.log(" scoped least-privilege tokens and is no longer needed. Routine deploy/migrate use the minted tokens. To create new");
349
+ console.log(" services / mint new tokens / teardown later, generate a FRESH master token, put it in .env.temp, re-run \`bun run provision\`.");
350
+ console.log("");
351
+ console.log("Next: \`bun run deploy\` (ships the Worker + pushes SULUK_PRIVATE_KEY so it decrypts the committed .env at runtime).");
352
+ `;
353
+ }
354
+
196
355
  /**
197
356
  * The `.env` SCAFFOLD — a header + setup steps, NO values (safe to commit). `generate` writes it ONLY IF ABSENT so it never
198
357
  * clobbers the operator's encrypted secrets. Its presence also lets `src/index.ts`'s `import "../.env"` resolve on a fresh app.
@@ -302,17 +461,18 @@ export function mergeGitignore(generated: string, existing: string | null): stri
302
461
  /** The encrypted-env preflight (run via `predev` / `bun run check`): is there a keypair, and is every REQUIRED secret set
303
462
  * (encrypted) in the committed `.env`? A plaintext secret sitting in `.env` is flagged (encrypt it before you commit). */
304
463
  function buildEnvCheckScript(env: EnvVar[]): string {
305
- const required = env.filter((e) => e.secret && e.required).map((e) => e.name);
464
+ // the required secrets that should be SET + ENCRYPTED in the committed .env after provisioning — the ephemeral master is
465
+ // EXCLUDED (it's deleted after provisioning, so its absence is correct).
466
+ const required = env.filter((e) => e.secret && e.required && !e.provisioning).map((e) => e.name);
306
467
  return `#!/usr/bin/env bun
307
468
  /**
308
- * AUTO-GENERATED by @suluk/platform — the ENCRYPTED-env preflight (wired as \`predev\` + \`bun run check\`). Secrets live in the
309
- * committed .env, ENCRYPTED with @suluk/env. This checks: a keypair exists, the REQUIRED secrets are set, and none is sitting
310
- * in plaintext (which must never be committed). Non-secret config is in platform.config.ts \`vars\` → wrangler.toml [vars].
469
+ * AUTO-GENERATED by @suluk/platform — the env preflight (wired as \`predev\` + \`bun run check\`). SETUP: fill \`.env.temp\`
470
+ * (plaintext) then \`bun run provision\`. This verifies the END STATE: a keypair exists, the required secrets are set +
471
+ * ENCRYPTED in the committed .env, and none is sitting in plaintext. Non-secret config is in platform.config.ts \`vars\`.
311
472
  */
312
473
  import { existsSync, readFileSync } from "node:fs";
313
474
 
314
475
  const REQUIRED = ${JSON.stringify(required)};
315
- const ENV = ".env";
316
476
 
317
477
  const parse = (p: string): Record<string, string> => {
318
478
  const out: Record<string, string> = {};
@@ -322,15 +482,17 @@ const parse = (p: string): Record<string, string> => {
322
482
  }
323
483
  return out;
324
484
  };
325
-
326
- const have = existsSync(ENV) ? parse(ENV) : {};
327
- const isEncrypted = (v: string) => v.startsWith("encrypted:");
328
485
  const fail = (msg: string) => { console.error("✗ " + msg); process.exit(1); };
329
486
 
330
- if (!have.SULUK_PUBLIC_KEY) fail("no @suluk/env keypair run \`bunx suluk-env keygen\` (creates SULUK_PUBLIC_KEY in .env + .env.keys).");
487
+ // a plaintext bootstrap is waiting to be consumed provision it (encrypts the keepers, deletes the master).
488
+ if (existsSync(".env.temp")) fail(".env.temp is present (plaintext) — run \`bun run provision\` to consume it (seals secrets into .env, deletes the master token).");
489
+
490
+ const have = existsSync(".env") ? parse(".env") : {};
491
+ const isEncrypted = (v: string) => v.startsWith("encrypted:");
492
+ if (!have.SULUK_PUBLIC_KEY) fail("no keypair yet — fill .env.temp with your creds/secrets and run \`bun run provision\` (creates the keypair, provisions, seals secrets).");
331
493
 
332
494
  const missing = REQUIRED.filter((k) => !have[k] && !process.env[k]);
333
- if (missing.length) fail("missing required secret(s): " + missing.join(", ") + "\\n → set each: bunx suluk-env set KEY=value");
495
+ if (missing.length) fail("missing required secret(s) in .env: " + missing.join(", ") + "\\n → add them to .env.temp + \`bun run provision\`, or \`bunx suluk-env set KEY=value\`");
334
496
 
335
497
  const plaintext = Object.keys(have).filter((k) => k !== "SULUK_PUBLIC_KEY" && have[k] && !isEncrypted(have[k]));
336
498
  if (plaintext.length) fail("PLAINTEXT secret(s) in .env (never commit these): " + plaintext.join(", ") + "\\n → encrypt: bunx suluk-env encrypt");
@@ -360,7 +522,9 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
360
522
  "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
361
523
  "link-key": "bun run scripts/link-key.ts", // register the private key in ~/.suluk/settings.json (the central store)
362
524
  "env:set": "suluk-env set", // encrypt + add a secret: `bun run env:set BETTER_AUTH_SECRET=...`
363
- "sync-secrets": "bun run scripts/sync-secrets.ts", // decrypt cloudflare-surfaced secrets `wrangler secret put` each
525
+ provision: "bun run scripts/provision.ts", // stand up infra + mint scoped tokens + seal secrets (consumes .env.temp)
526
+ "mint-tokens": "bun run scripts/mint-tokens.ts", // (re)mint the scoped least-privilege CF tokens from the master
527
+ "sync-secrets": "bun run scripts/sync-secrets.ts", // push SULUK_PRIVATE_KEY + runtime secrets to the Worker
364
528
  typecheck: "tsc --noEmit -p .",
365
529
  test: "bun test",
366
530
  },
package/src/service.ts CHANGED
@@ -16,15 +16,30 @@ export type Mount =
16
16
  | { kind: "route"; path: string; symbol: string; from: string } // e.g. `app.route("/api/credits", creditsRoutes())`
17
17
  | { kind: "dev" }; // dev/CI tooling (journeys, audit) — files only, no runtime mount, no provision fragment
18
18
 
19
- /** An env var a module needs at runtime — drives the generated `.env.example` + the env-check preflight. (Unchanged.) */
19
+ /** An env var a module (or the app's provisioning) needs — drives the generated `env.ts`, `.env.example`, `.env.temp`, the
20
+ * env-check preflight, and the provision/sync-secrets scripts. */
20
21
  export interface EnvVar {
21
22
  name: string;
22
23
  /** the app WON'T work without it (the "minimum keys") — the env-check requires a non-empty value before it's happy. */
23
24
  required?: boolean;
24
- /** a credential (never commit) shown commented in `.env.example` + flagged in the temp file. */
25
+ /** a credential (encrypted at rest in the committed `.env`, or — if `provisioning` staged plaintext in `.env.temp`). */
25
26
  secret?: boolean;
26
- /** a one-line hint shown as a comment in `.env.example`. */
27
+ /** a one-line hint shown as a comment. */
27
28
  hint?: string;
29
+ /**
30
+ * Where the value is USED. `"cloudflare"` = a Worker RUNTIME secret (pushed by `sync-secrets` / decrypted by `loadEnv`);
31
+ * `"local"` = used only by provisioning/deploy on this machine, NEVER shipped to the Worker. Defaults: a `secret` → the
32
+ * Worker runtime (`"cloudflare"`); a `provisioning`/`minted` cred → `"local"`.
33
+ */
34
+ surface?: "local" | "cloudflare";
35
+ /**
36
+ * An EPHEMERAL provisioning credential (e.g. the Cloudflare API master token): supplied PLAINTEXT in `.env.temp`, used to
37
+ * provision infra + mint scoped tokens, then DELETED after provisioning — never committed (not even encrypted). Implies
38
+ * `surface: "local"`.
39
+ */
40
+ provisioning?: boolean;
41
+ /** a scoped least-privilege token MINTED during provisioning (from the master), then kept ENCRYPTED in `.env`. `surface: "local"`. */
42
+ minted?: boolean;
28
43
  }
29
44
 
30
45
  /** The old catalog record — now a DERIVED VIEW of a {@link Service} (see {@link toCatalogEntry}); kept so `planPlatform`
@@ -153,7 +168,22 @@ export function toCatalogEntry(s: Service): CatalogEntry {
153
168
  // Each core service is exported as a NAMED, precisely-typed const so a `defineSystem` author can import it and get typed
154
169
  // serviceOpts keyed by id. Ported field-for-field from the C051 CATALOG (byte-identity via the Phase-0 golden lock).
155
170
 
156
- export const appService = defineService({ id: "app", mount: { kind: "base" }, env: [{ name: "TRUSTED_ORIGINS", hint: "comma-separated browser origins allowed on /api/* (CORS)" }] });
171
+ export const appService = defineService({
172
+ id: "app",
173
+ mount: { kind: "base" },
174
+ env: [
175
+ { name: "TRUSTED_ORIGINS", hint: "comma-separated browser origins allowed on /api/* (CORS)" },
176
+ // ── Cloudflare provisioning creds (surface "local" — used to stand up + deploy the infra, NEVER shipped to the Worker) ──
177
+ // The MASTER token is EPHEMERAL: supply it plaintext in .env.temp, it mints the scoped tokens below + provisions, then
178
+ // it's DELETED (never committed). Routine deploy/migrate then use the minted least-privilege tokens.
179
+ { name: "CLOUDFLARE_API_TOKEN", required: true, secret: true, provisioning: true, hint: "CF account-scoped master token (Workers Scripts + D1 + KV Edit) — mints the scoped tokens + provisions, then DELETED (never in git)" },
180
+ { name: "CLOUDFLARE_ACCOUNT_ID", required: true, secret: true, surface: "local", hint: "CF account id — a KEEPER (routine scoped-token ops need it), kept encrypted in .env" },
181
+ // Scoped least-privilege tokens minted from the master during provisioning; kept ENCRYPTED in .env for routine ops.
182
+ { name: "CLOUDFLARE_D1_TOKEN", secret: true, minted: true, hint: "scoped: D1 Write (migrations)" },
183
+ { name: "CLOUDFLARE_WORKERS_TOKEN", secret: true, minted: true, hint: "scoped: Workers Scripts Write (deploy + secret put)" },
184
+ { name: "CLOUDFLARE_KV_TOKEN", secret: true, minted: true, hint: "scoped: KV Write (rate-limit / rate-credit namespaces)" },
185
+ ],
186
+ });
157
187
 
158
188
  export const authService = defineService({
159
189
  id: "auth",
@@ -250,7 +280,7 @@ export const rateLimitService = defineService({ id: "rate-limit", mount: { kind:
250
280
  export const rateCreditService = defineService({ id: "rate-credit", mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }); // credit-backed free-tier bucket (KV binding)
251
281
  export const i18nService = defineService({ id: "i18n", mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] });
252
282
  export const referenceService = defineService({ id: "reference", mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }); // derived — no provision
253
- export const adminService = defineService({ id: "admin", mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"] }); // reads existing tables — no provision
283
+ export const adminService = defineService({ id: "admin", mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"], env: [{ name: "SUPERADMIN_EMAILS", secret: true, hint: "comma/space-separated admin emails → the admin scope (secret-surfaced so they stay out of git plaintext)" }] }); // reads existing tables — no provision
254
284
  export const logsService = defineService({ id: "logs", mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } });
255
285
  export const journeysService = defineService({ id: "journeys", mount: { kind: "dev" }, deps: ["@suluk/journeys"] });
256
286
  export const auditService = defineService({ id: "audit", mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] });