@suluk/platform 0.5.2 → 0.6.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.5.2",
3
+ "version": "0.6.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/generate.ts CHANGED
@@ -85,12 +85,17 @@ export async function generatePlatform(input: PlatformManifest | Platform, opts:
85
85
  log("▸ writing provision.config.ts");
86
86
  await opts.write("provision.config.ts", plan.provisionConfig);
87
87
  written.push("src/index.ts", "provision.config.ts");
88
- // the bun MOCK-PROVIDER dev server — only when the manifest sets `local: true` (else undefined → not written).
88
+ // the bun MOCK-PROVIDER dev server + the state-purge helper — only when the manifest sets `local: true`.
89
89
  if (plan.devEntry) {
90
90
  log("▸ writing src/dev.ts");
91
91
  await opts.write("src/dev.ts", plan.devEntry);
92
92
  written.push("src/dev.ts");
93
93
  }
94
+ if (plan.purgeScript) {
95
+ log("▸ writing scripts/purge-state.ts");
96
+ await opts.write("scripts/purge-state.ts", plan.purgeScript);
97
+ written.push("scripts/purge-state.ts");
98
+ }
94
99
 
95
100
  log(`✓ generated ${name}: ${plan.services.length} services. Next: bun install && suluk-provision apply`);
96
101
  return { plan, added, written };
package/src/manifest.ts CHANGED
@@ -22,6 +22,11 @@ export interface PlatformManifest {
22
22
  opts?: Record<string, Record<string, unknown>>;
23
23
  /** NON-SECRET config values → generated into `wrangler.toml` `[vars]`. SECRETS never go here (they live in `.env`). */
24
24
  vars?: Record<string, string>;
25
+ /** C058 (INTERNAL, dev-only) — the LOCAL-runtime derived URL vars (BASE_URL/BETTER_AUTH_URL/TRUSTED_ORIGINS/EMAIL_FROM),
26
+ * computed by `deriveHosts` from `LOCAL_BASE_URL`. Spread into `src/dev.ts`'s env; NEVER emitted to `[vars]`. */
27
+ localVars?: Record<string, string>;
28
+ /** C058 (INTERNAL, dev-only) — the raw local host (e.g. `localhost:8787`), so `src/dev.ts` can re-splice the actual PORT. */
29
+ __localHost?: string;
25
30
  /** emit the MOCK-PROVIDER dev runtime: a `src/dev.ts` that runs the app under bun with a bun:sqlite DB + JSON-file KV +
26
31
  * mocked providers when their keys are absent (mock-until-keyed), and the `dev` script pointed at it. Default false →
27
32
  * the scaffold is byte-for-byte the C051 golden. */
package/src/plan.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  * character.
5
5
  */
6
6
  import { type PlatformManifest, type Platform, isPlatform } from "./manifest";
7
- import { liftSystemBrand } from "./resolve";
7
+ import { liftSystemBrand, deriveHosts } from "./resolve";
8
8
  import { resolveWiring, groupImports, type Wiring } from "./wire";
9
9
  import { CATALOG, CORE_SERVICES, orderServices, collectEnv, BASE_DEPS, DEV_DEPS, resolveVersion, type EnvVar, type Service } from "./catalog";
10
10
 
@@ -52,12 +52,23 @@ export interface PlatformPlan {
52
52
  /** the generated `src/dev.ts` — the bun MOCK-PROVIDER dev server (bun:sqlite DB + JSON KV + mocked providers when keys
53
53
  * absent). Present ONLY when the manifest sets `local: true`; undefined otherwise (so the golden path is unchanged). */
54
54
  devEntry?: string;
55
+ /** the generated `scripts/purge-state.ts` — clears dev/live state (recommended on a mock↔real swap or a provision
56
+ * migration). Present ONLY when `local: true`. */
57
+ purgeScript?: string;
55
58
  }
56
59
 
57
60
  export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
58
61
  // C053: a `{ system, brand }` platform lowers to the legacy manifest first, then the UNCHANGED lowering runs — so the
59
62
  // legacy path is byte-for-byte identical and the new surface is sugar over it.
60
- const manifest = isPlatform(input) ? liftSystemBrand(input) : input;
63
+ const normalized = isPlatform(input) ? liftSystemBrand(input) : input;
64
+ // C058: the single-source URL derivation, on a PRIVATE copy so planPlatform never mutates the caller's manifest (the
65
+ // legacy full-URL manifest is a no-op — byte-identical). vars/opts are cloned; localVars/__localHost land on `manifest`.
66
+ const manifest: PlatformManifest = {
67
+ ...normalized,
68
+ ...(normalized.vars ? { vars: { ...normalized.vars } } : {}),
69
+ ...(normalized.opts ? { opts: structuredClone(normalized.opts) } : {}),
70
+ };
71
+ deriveHosts(manifest);
61
72
  // the EFFECTIVE catalog = core services + any inline (community) Service objects a `{system,brand}` platform carries. It
62
73
  // threads through EVERY emitter (mounts, provision, deps, env, wiring), so a community service contributes end-to-end. For
63
74
  // the legacy path and an all-core `{system,brand}` it === CORE_SERVICES → the Phase-0 golden lock still holds byte-for-byte.
@@ -91,7 +102,7 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
91
102
  provisionScript: buildProvisionScript(env),
92
103
  mintTokens: buildMintTokens(env),
93
104
  envScaffold: buildEnvScaffold(env),
94
- ...(local ? { devEntry: buildDevEntry(services) } : {}),
105
+ ...(local ? { devEntry: buildDevEntry(services, manifest.localVars, manifest.__localHost), purgeScript: buildPurgeScript(services) } : {}),
95
106
  };
96
107
  }
97
108
 
@@ -524,6 +535,8 @@ process.exit(0);
524
535
  export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES, local = false): string {
525
536
  const deps = new Set<string>(BASE_DEPS);
526
537
  for (const s of services) for (const d of catalog[s]?.deps ?? []) deps.add(d);
538
+ // local mode's src/dev.ts imports @suluk/cloudflare (/local + /live facades + CloudflareClient) at runtime (dedup-safe).
539
+ if (local) deps.add("@suluk/cloudflare");
527
540
  const dependencies: Record<string, string> = {};
528
541
  for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
529
542
  const pkg = {
@@ -536,7 +549,7 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
536
549
  // local mode: `dev` runs the bun mock-provider server (no keys needed → no env-check predev); `dev:cf` keeps wrangler.
537
550
  ...(local ? {} : { predev: "bun run scripts/env-check.ts" }), // (non-local) runs automatically before `dev`
538
551
  dev: local ? "bun run --hot src/dev.ts" : "wrangler dev",
539
- ...(local ? { "dev:cf": "wrangler dev" } : {}),
552
+ ...(local ? { "dev:cf": "wrangler dev", purge: "bun run scripts/purge-state.ts" } : {}),
540
553
  deploy: "wrangler deploy",
541
554
  "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
542
555
  "link-key": "bun run scripts/link-key.ts", // register the private key in ~/.suluk/settings.json (the central store)
@@ -592,9 +605,9 @@ function buildTsconfig(local = false): string {
592
605
  {
593
606
  compilerOptions: { module: "ESNext", target: "ESNext", moduleResolution: "bundler", types: ["node", "@cloudflare/workers-types"], skipLibCheck: true, strict: true, noEmit: true },
594
607
  include: ["src", "provision.config.ts", "platform.config.ts"],
595
- // src/dev.ts is a bun-only runtime file (bun:sqlite + Bun globals), NOT Worker code — exclude it from the Worker
596
- // typecheck (it pulls @suluk/cloudflare/local, which the workers-types config can't type); it's boot-tested instead.
597
- exclude: ["src/**/*.test.ts", ...(local ? ["src/dev.ts"] : [])], // the bun:test journeys harness runs under `bun test`, not the Worker build
608
+ // src/dev.ts + scripts/purge-state.ts are bun-only (bun:sqlite + Bun globals + @suluk/cloudflare/local), NOT Worker
609
+ // code exclude them from the Worker typecheck (the workers-types config can't type them); they're boot-tested instead.
610
+ exclude: ["src/**/*.test.ts", ...(local ? ["src/dev.ts", "scripts/purge-state.ts"] : [])], // the bun:test journeys harness runs under `bun test`, not the Worker build
598
611
  },
599
612
  null,
600
613
  2,
@@ -694,52 +707,86 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
694
707
  * no wrangler. Mock-until-keyed: it decrypts the committed `.env` if the app has been provisioned (real HTTP providers), else
695
708
  * every provider falls to its module's mock. The deployed Worker (`src/index.ts`) imports NONE of this — bun:sqlite stays out.
696
709
  */
697
- function buildDevEntry(services: string[]): string {
710
+ function buildDevEntry(services: string[], localVars?: Record<string, string>, localHost?: string): string {
698
711
  const usesKv = services.includes("rate-credit");
699
712
  const usesEmail = services.includes("email");
713
+ // C058: the local-runtime URL vars (derived from LOCAL_BASE_URL) + the default PORT (from the local host, so BASE_URL's
714
+ // port matches what we serve on). `rebase` re-points any localhost URL at the actual PORT if the operator overrides it.
715
+ const localPort = (localHost ?? "").match(/:(\d+)$/)?.[1] ?? "8787";
716
+ const localVarsSetup = localVars && Object.keys(localVars).length
717
+ ? `\n// C058 — the LOCAL-runtime URL vars (BASE_URL/BETTER_AUTH_URL/TRUSTED_ORIGINS derived from LOCAL_BASE_URL), re-pointed at the actual PORT.\nconst LOCAL_VARS = ${JSON.stringify(localVars)};\nconst urls = Object.fromEntries(Object.entries(LOCAL_VARS).map(([k, v]) => [k, v.replace(/(localhost:)\\d+/g, \`$1\${PORT}\`)]));`
718
+ : "";
719
+ const localVarsBind = localVarsSetup ? "\n ...urls," : "";
700
720
  const usesBilling = services.includes("billing");
701
721
  const localImports = ["d1FromSqlite", ...(usesKv ? ["jsonFileKvStore"] : []), ...(usesEmail ? ["jsonFileMailbox"] : []), "applyLocalSchema"];
722
+ const liveImports = ["d1FromHttp", ...(usesKv ? ["httpKvStore"] : [])];
702
723
  const billingImport = usesBilling ? '\nimport { mockStripeFetch } from "@suluk/billing";' : "";
703
- const kvBind = usesKv ? "\n RATE_CREDIT_KV: jsonFileKvStore(KV_PATH)," : "";
704
724
  const mailboxBind = usesEmail ? "\n SULUK_MAILBOX_SINK: mailbox," : "";
705
- // mock-until-keyed: only inject the Stripe fake when there is no real key (a provisioned app hits real Stripe).
725
+ // mock-until-keyed: only inject the Stripe fake when there is no real key (a provisioned app hits real Stripe). ORTHOGONAL
726
+ // to the state layer — a mock Stripe works against LIVE D1/KV too.
706
727
  const stripeInject = usesBilling
707
728
  ? '\nif (!env.STRIPE_SECRET_KEY) { env.STRIPE_SECRET_KEY = "sk_mock_local"; env.STRIPE_FETCH = mockStripeFetch(); }'
708
729
  : "";
709
730
  const mailboxRoute = usesEmail
710
731
  ? '\n// a dev-only inbox view of the emails the mock provider captured (never mounted on the deployed Worker).\napp.get("/api/email/dev/mailbox", async (c) => c.json(await mailbox.list()));\n'
711
732
  : "";
712
- return `// AUTO-GENERATED by @suluk/platform the bun MOCK-PROVIDER dev server. Runs the wired app under bun with a
713
- // bun:sqlite DB + JSON-file KV, so \`bun run dev\` works with ZERO Cloudflare account and no wrangler. A provider goes REAL
714
- // the moment its key is present (mock-until-keyed): add real keys to .env.temp + \`bun run provision\` and this file uses
715
- // them. NOTE: src/index.ts (the deployed Worker) imports NONE of these mocks — bun:sqlite never enters the Worker bundle.
733
+ const kvIdParse = usesKv ? '\nconst kvId = wrangler.match(/\\[\\[kv_namespaces\\]\\][\\s\\S]*?\\bid\\s*=\\s*"([^"]+)"/)?.[1];' : "";
734
+ const liveAttachCond = usesKv ? "cfToken && cfAccount && d1Id && kvId" : "cfToken && cfAccount && d1Id";
735
+ const kvDecl = usesKv ? "\nlet RATE_CREDIT_KV: unknown;" : "";
736
+ const liveKvAssign = usesKv ? "\n RATE_CREDIT_KV = httpKvStore(new CloudflareClient({ apiToken: (S.CLOUDFLARE_KV_TOKEN ?? cfToken) as string, accountId: cfAccount! }), kvId!);" : "";
737
+ const mockKvAssign = usesKv ? "\n RATE_CREDIT_KV = jsonFileKvStore(KV_PATH);" : "";
738
+ const kvEnvBind = usesKv ? "\n RATE_CREDIT_KV," : "";
739
+ return `// AUTO-GENERATED by @suluk/platform — the bun dev server. Runs the wired app under bun so \`bun run dev\` works with
740
+ // ZERO Cloudflare account and no wrangler. SINGLE ENVIRONMENT, MOCK-UNTIL-KEYED, per-layer + per-provider:
741
+ // • STATE (D1+KV): once PROVISIONED (CF token + account + binding ids) it attaches to the SAME LIVE services as the Worker
742
+ // over the Cloudflare HTTP API; a fresh app uses a local bun:sqlite + JSON-file mock. Both-or-neither (never split state).
743
+ // • PROVIDERS (Google/Stripe/Resend): each real when ITS key is present, else its module's mock — INDEPENDENT of the state
744
+ // layer, so a mock login/payment/email works against LIVE D1/KV too. RECOMMEND \`bun run purge\` when you swap a mock for a
745
+ // real key (or vice-versa) or migrate the provision — the old state's shape may not match.
746
+ // NOTE: src/index.ts (the deployed Worker) imports NONE of this — bun:sqlite/mocks never enter the Worker bundle.
716
747
  import { app } from "./index";
717
748
  import { Database } from "bun:sqlite";
718
- import { ${localImports.join(", ")} } from "@suluk/cloudflare/local";${billingImport}
749
+ import { ${localImports.join(", ")} } from "@suluk/cloudflare/local";
750
+ import { ${liveImports.join(", ")} } from "@suluk/cloudflare/live";
751
+ import { CloudflareClient } from "@suluk/cloudflare";${billingImport}
719
752
  import { loadEnvFile } from "@suluk/env/node";
720
753
 
721
754
  const DB_PATH = process.env.SULUK_DB_PATH ?? ".suluk/dev.sqlite";${usesKv ? '\nconst KV_PATH = process.env.SULUK_KV_PATH ?? ".suluk/dev-kv.json";' : ""}${usesEmail ? '\nconst MAILBOX_PATH = process.env.SULUK_MAILBOX_PATH ?? ".suluk/dev-mailbox.json";' : ""}
722
- const PORT = Number(process.env.PORT ?? 8787);
723
-
724
- const sqlite = new Database(DB_PATH, { create: true });
725
- const tables = await applyLocalSchema(sqlite); // discover src/db/*.ts + create the tables from the drizzle schema
726
- console.log(\`[suluk dev] sqlite \${DB_PATH} — \${tables.length} tables\`);
755
+ const PORT = Number(process.env.PORT ?? ${localPort});${localVarsSetup}
727
756
  ${usesEmail ? "const mailbox = jsonFileMailbox(MAILBOX_PATH); // a local inbox the mock email provider saves to\n" : ""}
728
757
  // Real secrets (if this app has been provisioned): decrypt the committed .env with the local private key. Fresh app / no
729
758
  // key → {} → every provider mocks. Best-effort: a decryption failure never blocks the mock path.
730
759
  let secrets: Record<string, string> = {};
731
760
  try { secrets = await loadEnvFile(); } catch {}
761
+ const S = { ...process.env, ...secrets } as Record<string, string | undefined>; // resolved config (env + decrypted secrets)
762
+
763
+ // STATE layer: attach LIVE (same D1${usesKv ? "+KV" : ""} as the Worker, over the CF HTTP API) once the minted token + account + the
764
+ // provisioned binding id(s) are present; else the local mock. Both-or-neither.
765
+ const cfToken = S.CLOUDFLARE_D1_TOKEN ?? S.CLOUDFLARE_API_TOKEN;
766
+ const cfAccount = S.CLOUDFLARE_ACCOUNT_ID;
767
+ const wrangler = await Bun.file("wrangler.toml").text().catch(() => "");
768
+ const d1Id = wrangler.match(/database_id\\s*=\\s*"([^"]+)"/)?.[1];${kvIdParse}
769
+ const liveAttach = !!(${liveAttachCond});
770
+
771
+ let DB: unknown;${kvDecl}
772
+ if (liveAttach) {
773
+ const cf = new CloudflareClient({ apiToken: cfToken!, accountId: cfAccount! });
774
+ DB = d1FromHttp(cf, d1Id!);${liveKvAssign}
775
+ console.log(\`[suluk dev] LIVE-ATTACHED — same D1${usesKv ? "+KV" : ""} as the Worker (\${d1Id}); local changes hit production state.\`);
776
+ } else {
777
+ const sqlite = new Database(DB_PATH, { create: true });
778
+ const tables = await applyLocalSchema(sqlite); // discover src/db/*.ts + create the tables from the drizzle schema
779
+ DB = d1FromSqlite(sqlite);${mockKvAssign}
780
+ console.log(\`[suluk dev] mock state — sqlite \${DB_PATH} (\${tables.length} tables)\`);
781
+ }
732
782
 
733
- // The request env: process.env < decrypted secrets < the mock bindings. DB/KV are always local (a bun process can't bind a
734
- // remote D1/KV); the HTTP providers (Google/Stripe/Resend) use their real key when present, else their module's mock.
735
783
  const env: Record<string, unknown> = {
736
- ...process.env,
737
- ...secrets,
738
- DB: d1FromSqlite(sqlite),${kvBind}${mailboxBind}
784
+ ...S,${localVarsBind}
785
+ DB,${kvEnvBind}${mailboxBind}
739
786
  };
740
787
 
741
788
  const mocked = ["GOOGLE_CLIENT_ID", "STRIPE_SECRET_KEY", "RESEND_API_KEY"].filter((k) => !env[k]);
742
- if (mocked.length) console.log(\`[suluk dev] mocked (no key): \${mocked.join(", ")}\`);${stripeInject}
789
+ if (mocked.length) console.log(\`[suluk dev] mocked providers (no key): \${mocked.join(", ")}\${liveAttach ? " — against LIVE state; run \\\`bun run purge\\\` after swapping a mock for real keys" : ""}\`);${stripeInject}
743
790
  ${mailboxRoute}
744
791
  const ctx = { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext;
745
792
  Bun.serve({ port: PORT, idleTimeout: 120, fetch: (req) => app.fetch(req, env as Parameters<typeof app.fetch>[1], ctx) });
@@ -747,6 +794,58 @@ console.log(\`[suluk dev] → http://localhost:\${PORT} (mock-until-keyed; prov
747
794
  `;
748
795
  }
749
796
 
797
+ /**
798
+ * `scripts/purge-state.ts` (emitted only when `local: true`) — reset STATE. The single environment is mock-until-keyed, so
799
+ * when you swap a mock provider for real keys (or vice-versa), or migrate the provision (a new service ⇒ new tables / KV /
800
+ * R2), the OLD state's shape may not match — this clears it. Always purges the LOCAL mock state (`.suluk/*`, recreated on
801
+ * next `bun run dev`); with `--yes` it ALSO drops the LIVE D1 tables + clears the live KV (then re-run `bun run provision`).
802
+ */
803
+ function buildPurgeScript(services: string[]): string {
804
+ const usesKv = services.includes("rate-credit");
805
+ const kvIdParse = usesKv ? '\nconst kvId = wrangler.match(/\\[\\[kv_namespaces\\]\\][\\s\\S]*?\\bid\\s*=\\s*"([^"]+)"/)?.[1];' : "";
806
+ const kvPurge = usesKv
807
+ ? `\n if (kvId) {
808
+ const kvCf = new CloudflareClient({ apiToken: (S.CLOUDFLARE_KV_TOKEN ?? token) as string, accountId: account });
809
+ const keys = await kvList(kvCf, kvId);
810
+ for (const k of keys) await kvDelete(kvCf, kvId, k);
811
+ console.log(\` cleared \${keys.length} live KV keys\`);
812
+ }`
813
+ : "";
814
+ const localFiles = ['.suluk/dev.sqlite', '.suluk/dev.sqlite-wal', '.suluk/dev.sqlite-shm', ...(usesKv ? ['.suluk/dev-kv.json'] : []), ...(services.includes("email") ? ['.suluk/dev-mailbox.json'] : [])];
815
+ return `// AUTO-GENERATED by @suluk/platform — purge STATE. RECOMMENDED whenever you swap a mock provider for real keys (or
816
+ // vice-versa) or migrate the provision (a new service ⇒ new tables/KV/R2): the single environment is mock-until-keyed, so
817
+ // the old state's shape may not match the new one. Always clears LOCAL mock state; \`--yes\` also purges LIVE D1 + KV.
818
+ import { rmSync } from "node:fs";
819
+ import { discoverTableNames } from "@suluk/cloudflare/local";
820
+ import { loadEnvFile } from "@suluk/env/node";
821
+ import { CloudflareClient, queryD1${usesKv ? ", kvList, kvDelete" : ""} } from "@suluk/cloudflare";
822
+
823
+ const yes = process.argv.includes("--yes");
824
+
825
+ // 1) LOCAL mock state — dev-only files; recreated on the next \`bun run dev\`. Always safe.
826
+ for (const f of ${JSON.stringify(localFiles)}) { try { rmSync(f, { force: true }); } catch {} }
827
+ console.log("✓ purged local mock state (.suluk/*)");
828
+
829
+ // 2) LIVE state (if provisioned): DROP the app's D1 tables${usesKv ? " + clear the KV namespace" : ""}. DESTRUCTIVE — needs \`--yes\`.
830
+ let S: Record<string, string | undefined> = { ...process.env };
831
+ try { S = { ...process.env, ...(await loadEnvFile()) }; } catch {}
832
+ const token = S.CLOUDFLARE_D1_TOKEN ?? S.CLOUDFLARE_API_TOKEN;
833
+ const account = S.CLOUDFLARE_ACCOUNT_ID;
834
+ const wrangler = await Bun.file("wrangler.toml").text().catch(() => "");
835
+ const d1Id = wrangler.match(/database_id\\s*=\\s*"([^"]+)"/)?.[1];${kvIdParse}
836
+
837
+ if (token && account && d1Id) {
838
+ if (!yes) {
839
+ console.log(\`\\n⚠ LIVE state detected (D1 \${d1Id}${usesKv ? '\${kvId ? " + KV " + kvId : ""}' : ""}). Re-run \\\`bun run purge -- --yes\\\` to DROP all app tables${usesKv ? " + clear KV" : ""}, then \\\`bun run provision\\\` to recreate the schema.\`);
840
+ } else {
841
+ const cf = new CloudflareClient({ apiToken: token, accountId: account });
842
+ for (const t of await discoverTableNames()) { await queryD1(cf, d1Id, \`DROP TABLE IF EXISTS "\${t}"\`); console.log(\` dropped \${t}\`); }${kvPurge}
843
+ console.log("✓ purged LIVE state — run \`bun run provision\` to recreate the schema. (R2 objects, if any, purge via the CF dashboard.)");
844
+ }
845
+ }
846
+ `;
847
+ }
848
+
750
849
  function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
751
850
  const frags = services.map((s) => catalog[s].provision).filter((p): p is NonNullable<typeof p> => !!p);
752
851
  const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
package/src/resolve.ts CHANGED
@@ -13,6 +13,7 @@
13
13
  import type { PlatformManifest } from "./manifest";
14
14
  import type { Platform, SystemManifest, BrandManifest } from "./manifest";
15
15
  import { CORE_SERVICES } from "./service";
16
+ import { deriveUrls } from "./urls";
16
17
 
17
18
  const isPlainObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
18
19
  const isScalar = (v: unknown): v is string | number | boolean => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
@@ -72,6 +73,46 @@ export function resolveNodeOpts(system: SystemManifest, brand: BrandManifest): {
72
73
  /** env-shaped globalServiceOpts (system behaviour delivered as a runtime env var) — the rest of `vars` is brand identity. */
73
74
  const SYSTEM_VAR_NAMES = new Set(["TRUSTED_ORIGINS", "ENVIRONMENT"]);
74
75
 
76
+ /**
77
+ * C058 — the single-source URL derivation, applied to a normalized {@link PlatformManifest} (BOTH authoring surfaces
78
+ * converge here). If the manifest declares `LIVE_BASE_URL` (a bare host) AND has NOT hand-authored `BETTER_AUTH_URL`
79
+ * (back-compat/golden-lock gate), derive every URL var: the WORKER `[vars]` from the LIVE host, the bun-dev env
80
+ * (`manifest.localVars`) from the LOCAL host, and — when `opts.auth.mcpScopes` is present — the mcp OAuth trio from LIVE.
81
+ * The two bare hosts + `EMAIL_DOMAIN`/`EXTRA_TRUSTED_ORIGINS` are DELETED from `vars` (pure inputs, never `[vars]`).
82
+ * Mutates the manifest in place; a no-op when the gate is off (a legacy full-URL manifest regenerates byte-for-byte).
83
+ */
84
+ export function deriveHosts(manifest: PlatformManifest): void {
85
+ const vars = manifest.vars;
86
+ if (!vars) return;
87
+ const liveHost = vars.LIVE_BASE_URL;
88
+ if (!liveHost || vars.BETTER_AUTH_URL) return; // gate: opt-in via LIVE_BASE_URL + never override a hand-authored URL
89
+
90
+ const localHost = vars.LOCAL_BASE_URL ?? liveHost;
91
+ const scopes = (manifest.opts?.auth?.mcpScopes as string[] | undefined) ?? undefined;
92
+ const opts = { scopes, emailDomain: vars.EMAIL_DOMAIN, extraOrigins: (vars.EXTRA_TRUSTED_ORIGINS ?? "").split(",").map((s) => s.trim()).filter(Boolean) };
93
+
94
+ const live = deriveUrls(liveHost, liveHost, opts); // the deployed Worker: BASE_URL === the live URL
95
+ vars.BETTER_AUTH_URL = live.BETTER_AUTH_URL;
96
+ vars.BASE_URL = live.BASE_URL;
97
+ vars.TRUSTED_ORIGINS = live.TRUSTED_ORIGINS;
98
+ vars.EMAIL_FROM = live.EMAIL_FROM;
99
+ // mcp OAuth is opt-in via `mcpScopes` (an app without it stays non-MCP). Derive the URL trio; drop the raw scope input.
100
+ if (scopes?.length) {
101
+ manifest.opts = { ...(manifest.opts ?? {}), auth: { ...(manifest.opts?.auth ?? {}), mcp: live.mcp } };
102
+ delete (manifest.opts.auth as Record<string, unknown>).mcpScopes;
103
+ }
104
+
105
+ const local = deriveUrls(localHost, liveHost, opts); // the local bun-dev runtime: BASE_URL === the local URL
106
+ manifest.localVars = { BASE_URL: local.BASE_URL, BETTER_AUTH_URL: local.BETTER_AUTH_URL, TRUSTED_ORIGINS: local.TRUSTED_ORIGINS, EMAIL_FROM: local.EMAIL_FROM };
107
+ manifest.__localHost = localHost;
108
+
109
+ // the bare hosts + override knobs are derivation INPUTS — never emit them as `[vars]`.
110
+ delete vars.LIVE_BASE_URL;
111
+ delete vars.LOCAL_BASE_URL;
112
+ delete vars.EMAIL_DOMAIN;
113
+ delete vars.EXTRA_TRUSTED_ORIGINS;
114
+ }
115
+
75
116
  /**
76
117
  * The MIGRATE direction — a legacy {@link PlatformManifest} → the C053 `{ system, brand }` split (the inverse of
77
118
  * {@link liftSystemBrand}). `opts` → per-service serviceOpts; `vars` split into globalServiceOpts (system-shaped) vs
package/src/service.ts CHANGED
@@ -144,6 +144,10 @@ export interface McpOAuthOpts {
144
144
  }
145
145
  /** auth's serviceOpts: optionally activate the MCP OAuth server (Better Auth `mcp()` plugin). */
146
146
  export interface AuthServiceOpts {
147
+ /** C058: activate the MCP OAuth server by declaring its SCOPE SET — the loginPage/consentPage/resource URLs are DERIVED
148
+ * from `LIVE_BASE_URL` (no host boilerplate). This is the single-source authoring path. */
149
+ mcpScopes?: string[];
150
+ /** LEGACY: the full MCP OAuth URL block. Prefer `mcpScopes` (URLs derived). Kept for back-compat with hand-authored URLs. */
147
151
  mcp?: McpOAuthOpts;
148
152
  }
149
153
 
package/src/urls.ts ADDED
@@ -0,0 +1,79 @@
1
+ /**
2
+ * C058 — the single-source URL derivation (PURE, generator-internal, build-time ONLY). The manifest declares TWO bare
3
+ * hosts once — `LIVE_BASE_URL` (the production host) + `LOCAL_BASE_URL` (the local-runtime host) — WITHOUT protocol; every
4
+ * other URL is DERIVED here and BAKED by `resolve.ts` into the Worker `[vars]` (from the live host) and the bun-dev env
5
+ * (from the local host). Registry consumers stay dumb env-readers (they never import this), so the golden path is unchanged
6
+ * and there is no URL authored twice. Never runs at app runtime.
7
+ */
8
+
9
+ /** strip protocol + trailing slash(es) + any path, leaving `host[:port]`. */
10
+ const strip = (h: string): string => h.replace(/^https?:\/\//, "").replace(/\/+$/, "").split("/")[0];
11
+ /** the bare hostname (no port). */
12
+ const hostname = (h: string): string => strip(h).split(":")[0];
13
+ /** the port, or "" when none. */
14
+ const port = (h: string): string => { const m = strip(h).match(/:(\d+)$/); return m ? m[1] : ""; };
15
+
16
+ /** LOCAL is decided by HOSTNAME ONLY — never by port presence (so `example.com:8443` is NOT misclassified as local). */
17
+ export function isLocal(h: string): boolean {
18
+ const n = hostname(h);
19
+ return n === "localhost" || n === "127.0.0.1" || n === "0.0.0.0" || n === "::1" || n.endsWith(".local");
20
+ }
21
+
22
+ /** Protocol rule: a local hostname → http, a real domain → https. Default ports (:80 http, :443 https) are STRIPPED so a
23
+ * derived origin exact-matches the browser `Origin` header. */
24
+ export function withProtocol(h: string): string {
25
+ const bare = strip(h);
26
+ const proto = isLocal(h) ? "http" : "https";
27
+ const p = port(h);
28
+ const dropDefault = (proto === "https" && p === "443") || (proto === "http" && p === "80");
29
+ return `${proto}://${dropDefault ? hostname(h) : bare}`;
30
+ }
31
+
32
+ /** the apex-ish host: the bare hostname minus a leading `www.` (NOT an eTLD+1 resolver — `api.example.com` stays as-is). */
33
+ export function apex(h: string): string {
34
+ return hostname(h).replace(/^www\./, "");
35
+ }
36
+
37
+ /** true when the live host is a SUBDOMAIN (e.g. `api.example.com`) — a hint that `noreply@<host>` may not be a verified
38
+ * sending domain, so an `EMAIL_DOMAIN` override is advisable. */
39
+ export function isSubdomain(h: string): boolean {
40
+ return apex(h).split(".").length > 2;
41
+ }
42
+
43
+ export interface DerivedUrls {
44
+ BETTER_AUTH_URL: string;
45
+ BASE_URL: string;
46
+ TRUSTED_ORIGINS: string;
47
+ EMAIL_FROM: string;
48
+ /** the mcp OAuth trio + scopes — ALWAYS anchored to the LIVE host (a public authorization-server identity). */
49
+ mcp: { loginPage: string; consentPage: string; resource: string; scopes: string[] };
50
+ }
51
+
52
+ export interface DeriveOptions {
53
+ scopes?: string[];
54
+ /** override the email sending domain (else the live apex). */
55
+ emailDomain?: string;
56
+ /** extra CORS origins to append (e.g. a separate SPA host or `*.pages.dev` previews). */
57
+ extraOrigins?: string[];
58
+ }
59
+
60
+ /**
61
+ * Derive every URL var for ONE runtime. `runtimeHost` = the host `BASE_URL`/`BETTER_AUTH_URL`/`TRUSTED_ORIGINS` resolve to
62
+ * for THIS runtime (the live host on the Worker; the local host in bun-dev). `liveHost` = the production host, which ALWAYS
63
+ * drives `EMAIL_FROM` + the mcp OAuth identity (baked into shared code = physically one value).
64
+ */
65
+ export function deriveUrls(runtimeHost: string, liveHost: string, o: DeriveOptions = {}): DerivedUrls {
66
+ const base = withProtocol(runtimeHost);
67
+ const live = withProtocol(liveHost);
68
+ const liveApex = apex(liveHost);
69
+ // origin allowlist: this runtime's own origin + the live apex + live www (so a pre-redirect www hit isn't CORS-rejected)
70
+ // + any operator EXTRA_TRUSTED_ORIGINS. A Set dedupes (on the Worker, base === the live apex origin).
71
+ const origins = new Set<string>([base, withProtocol(liveApex), withProtocol(`www.${liveApex}`), ...(o.extraOrigins ?? [])]);
72
+ return {
73
+ BETTER_AUTH_URL: base,
74
+ BASE_URL: base,
75
+ TRUSTED_ORIGINS: [...origins].join(","),
76
+ EMAIL_FROM: `noreply@${o.emailDomain ?? liveApex}`,
77
+ mcp: { loginPage: `${live}/oauth/sign-in`, consentPage: `${live}/oauth/consent`, resource: `${live}/api/mcp`, scopes: o.scopes ?? [] },
78
+ };
79
+ }