@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 +1 -1
- package/src/catalog.ts +1 -1
- package/src/generate.ts +3 -0
- package/src/index.ts +1 -1
- package/src/plan.ts +193 -29
- package/src/service.ts +35 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/platform",
|
|
3
|
-
"version": "0.
|
|
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
|
-
*
|
|
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
|
-
*
|
|
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
|
|
89
|
-
const
|
|
90
|
-
const
|
|
91
|
-
const
|
|
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
|
-
"#
|
|
94
|
-
"#
|
|
95
|
-
"#
|
|
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
|
-
"#
|
|
100
|
-
...
|
|
107
|
+
"# PROVISIONING creds — supply in .env.temp (plaintext). The master is EPHEMERAL (deleted after minting; never committed):",
|
|
108
|
+
...provisioning.map(line),
|
|
101
109
|
"",
|
|
102
|
-
"#
|
|
103
|
-
...
|
|
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:
|
|
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
|
|
138
|
-
// Run at deploy: \`bun run sync-secrets
|
|
139
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
309
|
-
*
|
|
310
|
-
* in
|
|
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
|
-
|
|
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 →
|
|
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
|
-
|
|
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
|
|
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 (
|
|
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
|
|
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({
|
|
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"] });
|