@suluk/platform 0.5.2 → 0.7.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.7.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,23 @@ 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 composed contract surface (one fragment per module) — only when the `contract` service is installed.
89
+ if (plan.contractOps) {
90
+ log("▸ writing src/contract.ops.ts");
91
+ await opts.write("src/contract.ops.ts", plan.contractOps);
92
+ written.push("src/contract.ops.ts");
93
+ }
94
+ // the bun MOCK-PROVIDER dev server + the state-purge helper — only when the manifest sets `local: true`.
89
95
  if (plan.devEntry) {
90
96
  log("▸ writing src/dev.ts");
91
97
  await opts.write("src/dev.ts", plan.devEntry);
92
98
  written.push("src/dev.ts");
93
99
  }
100
+ if (plan.purgeScript) {
101
+ log("▸ writing scripts/purge-state.ts");
102
+ await opts.write("scripts/purge-state.ts", plan.purgeScript);
103
+ written.push("scripts/purge-state.ts");
104
+ }
94
105
 
95
106
  log(`✓ generated ${name}: ${plan.services.length} services. Next: bun install && suluk-provision apply`);
96
107
  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
 
@@ -16,6 +16,9 @@ export interface PlatformPlan {
16
16
  entry: string;
17
17
  /** the generated `provision.config.ts` content. */
18
18
  provisionConfig: string;
19
+ /** the generated `src/contract.ops.ts` — the COMPOSED contract surface (one `RouteContract[]` fragment per module). Present
20
+ * ONLY when the `contract` service is installed; the base `src/contract.ts` consumes its `ALL_OPS`. */
21
+ contractOps?: string;
19
22
  /** the generated `package.json` content (the FRAMEWORK baseline — `generate` merges it with any existing so app-added
20
23
  * deps/scripts survive). @suluk/* on "latest" so fixes flow via `bun update`; ecosystem deps on pinned ranges. */
21
24
  packageJson: string;
@@ -52,12 +55,23 @@ export interface PlatformPlan {
52
55
  /** the generated `src/dev.ts` — the bun MOCK-PROVIDER dev server (bun:sqlite DB + JSON KV + mocked providers when keys
53
56
  * absent). Present ONLY when the manifest sets `local: true`; undefined otherwise (so the golden path is unchanged). */
54
57
  devEntry?: string;
58
+ /** the generated `scripts/purge-state.ts` — clears dev/live state (recommended on a mock↔real swap or a provision
59
+ * migration). Present ONLY when `local: true`. */
60
+ purgeScript?: string;
55
61
  }
56
62
 
57
63
  export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
58
64
  // C053: a `{ system, brand }` platform lowers to the legacy manifest first, then the UNCHANGED lowering runs — so the
59
65
  // legacy path is byte-for-byte identical and the new surface is sugar over it.
60
- const manifest = isPlatform(input) ? liftSystemBrand(input) : input;
66
+ const normalized = isPlatform(input) ? liftSystemBrand(input) : input;
67
+ // C058: the single-source URL derivation, on a PRIVATE copy so planPlatform never mutates the caller's manifest (the
68
+ // legacy full-URL manifest is a no-op — byte-identical). vars/opts are cloned; localVars/__localHost land on `manifest`.
69
+ const manifest: PlatformManifest = {
70
+ ...normalized,
71
+ ...(normalized.vars ? { vars: { ...normalized.vars } } : {}),
72
+ ...(normalized.opts ? { opts: structuredClone(normalized.opts) } : {}),
73
+ };
74
+ deriveHosts(manifest);
61
75
  // the EFFECTIVE catalog = core services + any inline (community) Service objects a `{system,brand}` platform carries. It
62
76
  // threads through EVERY emitter (mounts, provision, deps, env, wiring), so a community service contributes end-to-end. For
63
77
  // the legacy path and an all-core `{system,brand}` it === CORE_SERVICES → the Phase-0 golden lock still holds byte-for-byte.
@@ -77,6 +91,7 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
77
91
  adds: services.map((s) => `${catalog[s].registry ?? manifest.registry}/${s}`),
78
92
  entry: buildEntry(services, manifest.opts, wiring, catalog, local),
79
93
  provisionConfig: buildProvisionConfig(services, catalog),
94
+ ...(services.includes("contract") ? { contractOps: buildContractOps(services, catalog) } : {}),
80
95
  packageJson: buildPackageJson(manifest.name, services, catalog, local),
81
96
  tsconfig: buildTsconfig(local),
82
97
  componentsJson: buildComponentsJson(),
@@ -91,7 +106,7 @@ export function planPlatform(input: PlatformManifest | Platform): PlatformPlan {
91
106
  provisionScript: buildProvisionScript(env),
92
107
  mintTokens: buildMintTokens(env),
93
108
  envScaffold: buildEnvScaffold(env),
94
- ...(local ? { devEntry: buildDevEntry(services) } : {}),
109
+ ...(local ? { devEntry: buildDevEntry(services, manifest.localVars, manifest.__localHost), purgeScript: buildPurgeScript(services) } : {}),
95
110
  };
96
111
  }
97
112
 
@@ -524,6 +539,8 @@ process.exit(0);
524
539
  export function buildPackageJson(name: string, services: string[], catalog: Record<string, Service> = CORE_SERVICES, local = false): string {
525
540
  const deps = new Set<string>(BASE_DEPS);
526
541
  for (const s of services) for (const d of catalog[s]?.deps ?? []) deps.add(d);
542
+ // local mode's src/dev.ts imports @suluk/cloudflare (/local + /live facades + CloudflareClient) at runtime (dedup-safe).
543
+ if (local) deps.add("@suluk/cloudflare");
527
544
  const dependencies: Record<string, string> = {};
528
545
  for (const d of [...deps].sort()) dependencies[d] = resolveVersion(d);
529
546
  const pkg = {
@@ -536,7 +553,7 @@ export function buildPackageJson(name: string, services: string[], catalog: Reco
536
553
  // local mode: `dev` runs the bun mock-provider server (no keys needed → no env-check predev); `dev:cf` keeps wrangler.
537
554
  ...(local ? {} : { predev: "bun run scripts/env-check.ts" }), // (non-local) runs automatically before `dev`
538
555
  dev: local ? "bun run --hot src/dev.ts" : "wrangler dev",
539
- ...(local ? { "dev:cf": "wrangler dev" } : {}),
556
+ ...(local ? { "dev:cf": "wrangler dev", purge: "bun run scripts/purge-state.ts" } : {}),
540
557
  deploy: "wrangler deploy",
541
558
  "env:keygen": "suluk-env keygen", // create the @suluk/env keypair (SULUK_PUBLIC_KEY → .env; private → .env.keys)
542
559
  "link-key": "bun run scripts/link-key.ts", // register the private key in ~/.suluk/settings.json (the central store)
@@ -592,9 +609,9 @@ function buildTsconfig(local = false): string {
592
609
  {
593
610
  compilerOptions: { module: "ESNext", target: "ESNext", moduleResolution: "bundler", types: ["node", "@cloudflare/workers-types"], skipLibCheck: true, strict: true, noEmit: true },
594
611
  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
612
+ // src/dev.ts + scripts/purge-state.ts are bun-only (bun:sqlite + Bun globals + @suluk/cloudflare/local), NOT Worker
613
+ // code exclude them from the Worker typecheck (the workers-types config can't type them); they're boot-tested instead.
614
+ 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
615
  },
599
616
  null,
600
617
  2,
@@ -694,52 +711,86 @@ function buildEntry(services: string[], opts?: Record<string, Record<string, unk
694
711
  * no wrangler. Mock-until-keyed: it decrypts the committed `.env` if the app has been provisioned (real HTTP providers), else
695
712
  * every provider falls to its module's mock. The deployed Worker (`src/index.ts`) imports NONE of this — bun:sqlite stays out.
696
713
  */
697
- function buildDevEntry(services: string[]): string {
714
+ function buildDevEntry(services: string[], localVars?: Record<string, string>, localHost?: string): string {
698
715
  const usesKv = services.includes("rate-credit");
699
716
  const usesEmail = services.includes("email");
717
+ // C058: the local-runtime URL vars (derived from LOCAL_BASE_URL) + the default PORT (from the local host, so BASE_URL's
718
+ // port matches what we serve on). `rebase` re-points any localhost URL at the actual PORT if the operator overrides it.
719
+ const localPort = (localHost ?? "").match(/:(\d+)$/)?.[1] ?? "8787";
720
+ const localVarsSetup = localVars && Object.keys(localVars).length
721
+ ? `\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}\`)]));`
722
+ : "";
723
+ const localVarsBind = localVarsSetup ? "\n ...urls," : "";
700
724
  const usesBilling = services.includes("billing");
701
725
  const localImports = ["d1FromSqlite", ...(usesKv ? ["jsonFileKvStore"] : []), ...(usesEmail ? ["jsonFileMailbox"] : []), "applyLocalSchema"];
726
+ const liveImports = ["d1FromHttp", ...(usesKv ? ["httpKvStore"] : [])];
702
727
  const billingImport = usesBilling ? '\nimport { mockStripeFetch } from "@suluk/billing";' : "";
703
- const kvBind = usesKv ? "\n RATE_CREDIT_KV: jsonFileKvStore(KV_PATH)," : "";
704
728
  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).
729
+ // mock-until-keyed: only inject the Stripe fake when there is no real key (a provisioned app hits real Stripe). ORTHOGONAL
730
+ // to the state layer — a mock Stripe works against LIVE D1/KV too.
706
731
  const stripeInject = usesBilling
707
732
  ? '\nif (!env.STRIPE_SECRET_KEY) { env.STRIPE_SECRET_KEY = "sk_mock_local"; env.STRIPE_FETCH = mockStripeFetch(); }'
708
733
  : "";
709
734
  const mailboxRoute = usesEmail
710
735
  ? '\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
736
  : "";
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.
737
+ const kvIdParse = usesKv ? '\nconst kvId = wrangler.match(/\\[\\[kv_namespaces\\]\\][\\s\\S]*?\\bid\\s*=\\s*"([^"]+)"/)?.[1];' : "";
738
+ const liveAttachCond = usesKv ? "cfToken && cfAccount && d1Id && kvId" : "cfToken && cfAccount && d1Id";
739
+ const kvDecl = usesKv ? "\nlet RATE_CREDIT_KV: unknown;" : "";
740
+ const liveKvAssign = usesKv ? "\n RATE_CREDIT_KV = httpKvStore(new CloudflareClient({ apiToken: (S.CLOUDFLARE_KV_TOKEN ?? cfToken) as string, accountId: cfAccount! }), kvId!);" : "";
741
+ const mockKvAssign = usesKv ? "\n RATE_CREDIT_KV = jsonFileKvStore(KV_PATH);" : "";
742
+ const kvEnvBind = usesKv ? "\n RATE_CREDIT_KV," : "";
743
+ return `// AUTO-GENERATED by @suluk/platform — the bun dev server. Runs the wired app under bun so \`bun run dev\` works with
744
+ // ZERO Cloudflare account and no wrangler. SINGLE ENVIRONMENT, MOCK-UNTIL-KEYED, per-layer + per-provider:
745
+ // • STATE (D1+KV): once PROVISIONED (CF token + account + binding ids) it attaches to the SAME LIVE services as the Worker
746
+ // over the Cloudflare HTTP API; a fresh app uses a local bun:sqlite + JSON-file mock. Both-or-neither (never split state).
747
+ // • PROVIDERS (Google/Stripe/Resend): each real when ITS key is present, else its module's mock — INDEPENDENT of the state
748
+ // layer, so a mock login/payment/email works against LIVE D1/KV too. RECOMMEND \`bun run purge\` when you swap a mock for a
749
+ // real key (or vice-versa) or migrate the provision — the old state's shape may not match.
750
+ // NOTE: src/index.ts (the deployed Worker) imports NONE of this — bun:sqlite/mocks never enter the Worker bundle.
716
751
  import { app } from "./index";
717
752
  import { Database } from "bun:sqlite";
718
- import { ${localImports.join(", ")} } from "@suluk/cloudflare/local";${billingImport}
753
+ import { ${localImports.join(", ")} } from "@suluk/cloudflare/local";
754
+ import { ${liveImports.join(", ")} } from "@suluk/cloudflare/live";
755
+ import { CloudflareClient } from "@suluk/cloudflare";${billingImport}
719
756
  import { loadEnvFile } from "@suluk/env/node";
720
757
 
721
758
  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\`);
759
+ const PORT = Number(process.env.PORT ?? ${localPort});${localVarsSetup}
727
760
  ${usesEmail ? "const mailbox = jsonFileMailbox(MAILBOX_PATH); // a local inbox the mock email provider saves to\n" : ""}
728
761
  // Real secrets (if this app has been provisioned): decrypt the committed .env with the local private key. Fresh app / no
729
762
  // key → {} → every provider mocks. Best-effort: a decryption failure never blocks the mock path.
730
763
  let secrets: Record<string, string> = {};
731
764
  try { secrets = await loadEnvFile(); } catch {}
765
+ const S = { ...process.env, ...secrets } as Record<string, string | undefined>; // resolved config (env + decrypted secrets)
766
+
767
+ // STATE layer: attach LIVE (same D1${usesKv ? "+KV" : ""} as the Worker, over the CF HTTP API) once the minted token + account + the
768
+ // provisioned binding id(s) are present; else the local mock. Both-or-neither.
769
+ const cfToken = S.CLOUDFLARE_D1_TOKEN ?? S.CLOUDFLARE_API_TOKEN;
770
+ const cfAccount = S.CLOUDFLARE_ACCOUNT_ID;
771
+ const wrangler = await Bun.file("wrangler.toml").text().catch(() => "");
772
+ const d1Id = wrangler.match(/database_id\\s*=\\s*"([^"]+)"/)?.[1];${kvIdParse}
773
+ const liveAttach = !!(${liveAttachCond});
774
+
775
+ let DB: unknown;${kvDecl}
776
+ if (liveAttach) {
777
+ const cf = new CloudflareClient({ apiToken: cfToken!, accountId: cfAccount! });
778
+ DB = d1FromHttp(cf, d1Id!);${liveKvAssign}
779
+ console.log(\`[suluk dev] LIVE-ATTACHED — same D1${usesKv ? "+KV" : ""} as the Worker (\${d1Id}); local changes hit production state.\`);
780
+ } else {
781
+ const sqlite = new Database(DB_PATH, { create: true });
782
+ const tables = await applyLocalSchema(sqlite); // discover src/db/*.ts + create the tables from the drizzle schema
783
+ DB = d1FromSqlite(sqlite);${mockKvAssign}
784
+ console.log(\`[suluk dev] mock state — sqlite \${DB_PATH} (\${tables.length} tables)\`);
785
+ }
732
786
 
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
787
  const env: Record<string, unknown> = {
736
- ...process.env,
737
- ...secrets,
738
- DB: d1FromSqlite(sqlite),${kvBind}${mailboxBind}
788
+ ...S,${localVarsBind}
789
+ DB,${kvEnvBind}${mailboxBind}
739
790
  };
740
791
 
741
792
  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}
793
+ 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
794
  ${mailboxRoute}
744
795
  const ctx = { waitUntil() {}, passThroughOnException() {} } as unknown as ExecutionContext;
745
796
  Bun.serve({ port: PORT, idleTimeout: 120, fetch: (req) => app.fetch(req, env as Parameters<typeof app.fetch>[1], ctx) });
@@ -747,6 +798,77 @@ console.log(\`[suluk dev] → http://localhost:\${PORT} (mock-until-keyed; prov
747
798
  `;
748
799
  }
749
800
 
801
+ /**
802
+ * `scripts/purge-state.ts` (emitted only when `local: true`) — reset STATE. The single environment is mock-until-keyed, so
803
+ * when you swap a mock provider for real keys (or vice-versa), or migrate the provision (a new service ⇒ new tables / KV /
804
+ * R2), the OLD state's shape may not match — this clears it. Always purges the LOCAL mock state (`.suluk/*`, recreated on
805
+ * next `bun run dev`); with `--yes` it ALSO drops the LIVE D1 tables + clears the live KV (then re-run `bun run provision`).
806
+ */
807
+ function buildPurgeScript(services: string[]): string {
808
+ const usesKv = services.includes("rate-credit");
809
+ const kvIdParse = usesKv ? '\nconst kvId = wrangler.match(/\\[\\[kv_namespaces\\]\\][\\s\\S]*?\\bid\\s*=\\s*"([^"]+)"/)?.[1];' : "";
810
+ const kvPurge = usesKv
811
+ ? `\n if (kvId) {
812
+ const kvCf = new CloudflareClient({ apiToken: (S.CLOUDFLARE_KV_TOKEN ?? token) as string, accountId: account });
813
+ const keys = await kvList(kvCf, kvId);
814
+ for (const k of keys) await kvDelete(kvCf, kvId, k);
815
+ console.log(\` cleared \${keys.length} live KV keys\`);
816
+ }`
817
+ : "";
818
+ 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'] : [])];
819
+ return `// AUTO-GENERATED by @suluk/platform — purge STATE. RECOMMENDED whenever you swap a mock provider for real keys (or
820
+ // vice-versa) or migrate the provision (a new service ⇒ new tables/KV/R2): the single environment is mock-until-keyed, so
821
+ // the old state's shape may not match the new one. Always clears LOCAL mock state; \`--yes\` also purges LIVE D1 + KV.
822
+ import { rmSync } from "node:fs";
823
+ import { discoverTableNames } from "@suluk/cloudflare/local";
824
+ import { loadEnvFile } from "@suluk/env/node";
825
+ import { CloudflareClient, queryD1${usesKv ? ", kvList, kvDelete" : ""} } from "@suluk/cloudflare";
826
+
827
+ const yes = process.argv.includes("--yes");
828
+
829
+ // 1) LOCAL mock state — dev-only files; recreated on the next \`bun run dev\`. Always safe.
830
+ for (const f of ${JSON.stringify(localFiles)}) { try { rmSync(f, { force: true }); } catch {} }
831
+ console.log("✓ purged local mock state (.suluk/*)");
832
+
833
+ // 2) LIVE state (if provisioned): DROP the app's D1 tables${usesKv ? " + clear the KV namespace" : ""}. DESTRUCTIVE — needs \`--yes\`.
834
+ let S: Record<string, string | undefined> = { ...process.env };
835
+ try { S = { ...process.env, ...(await loadEnvFile()) }; } catch {}
836
+ const token = S.CLOUDFLARE_D1_TOKEN ?? S.CLOUDFLARE_API_TOKEN;
837
+ const account = S.CLOUDFLARE_ACCOUNT_ID;
838
+ const wrangler = await Bun.file("wrangler.toml").text().catch(() => "");
839
+ const d1Id = wrangler.match(/database_id\\s*=\\s*"([^"]+)"/)?.[1];${kvIdParse}
840
+
841
+ if (token && account && d1Id) {
842
+ if (!yes) {
843
+ 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.\`);
844
+ } else {
845
+ const cf = new CloudflareClient({ apiToken: token, accountId: account });
846
+ for (const t of await discoverTableNames()) { await queryD1(cf, d1Id, \`DROP TABLE IF EXISTS "\${t}"\`); console.log(\` dropped \${t}\`); }${kvPurge}
847
+ console.log("✓ purged LIVE state — run \`bun run provision\` to recreate the schema. (R2 objects, if any, purge via the CF dashboard.)");
848
+ }
849
+ }
850
+ `;
851
+ }
852
+
853
+ /**
854
+ * `src/contract.ops.ts` — the COMPOSED contract surface: one `RouteContract[]` fragment per installed module (each module
855
+ * OWNS its ops next to its routes), spread into `ALL_OPS`. Mirrors {@link buildProvisionConfig}. The base `src/contract.ts`
856
+ * consumes `ALL_OPS` (adding the system op + the derivations), so adding/changing a module's routes only touches THAT
857
+ * module's fragment — the central contract can never drift from the routes again.
858
+ */
859
+ function buildContractOps(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
860
+ const frags = services.map((s) => catalog[s].contract).filter((c): c is NonNullable<typeof c> => !!c);
861
+ const imports = frags.map((f) => `import { ${f.symbol} } from "${f.from}";`);
862
+ return [
863
+ "// AUTO-GENERATED by @suluk/platform — the composed contract surface (one fragment per installed module).",
864
+ 'import type { DocumentedRoute } from "@suluk/hono";',
865
+ ...imports,
866
+ "",
867
+ `export const ALL_OPS: readonly DocumentedRoute[] = [${frags.map((f) => `...${f.symbol}`).join(", ")}];`,
868
+ "",
869
+ ].join("\n");
870
+ }
871
+
750
872
  function buildProvisionConfig(services: string[], catalog: Record<string, Service> = CORE_SERVICES): string {
751
873
  const frags = services.map((s) => catalog[s].provision).filter((p): p is NonNullable<typeof p> => !!p);
752
874
  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
@@ -50,6 +50,8 @@ export interface EnvVar {
50
50
  export interface CatalogEntry {
51
51
  mount: Mount;
52
52
  provision?: { symbol: string; from: string };
53
+ /** the module's CONTRACT fragment — its `RouteContract[]` (ops), composed into `src/contract.ops.ts` (mirrors `provision`). */
54
+ contract?: { symbol: string; from: string };
53
55
  deps?: string[];
54
56
  env?: EnvVar[];
55
57
  }
@@ -109,6 +111,8 @@ export interface Service<SO = {}, BO = {}> {
109
111
  readonly registry?: string; // owning registry (multi-registry, Phase 4); default = the manifest's core alias
110
112
  readonly mount: Mount;
111
113
  readonly provision?: { symbol: string; from: string };
114
+ /** the module's CONTRACT fragment — its `RouteContract[]` (ops), composed into `src/contract.ops.ts` (mirrors `provision`). */
115
+ readonly contract?: { symbol: string; from: string };
112
116
  readonly deps?: string[];
113
117
  readonly env?: EnvVar[];
114
118
  readonly serviceOpts?: Schema<SO>; // how THIS service works → the ENTRY (mount 2nd arg) [Phase 2]
@@ -144,6 +148,10 @@ export interface McpOAuthOpts {
144
148
  }
145
149
  /** auth's serviceOpts: optionally activate the MCP OAuth server (Better Auth `mcp()` plugin). */
146
150
  export interface AuthServiceOpts {
151
+ /** C058: activate the MCP OAuth server by declaring its SCOPE SET — the loginPage/consentPage/resource URLs are DERIVED
152
+ * from `LIVE_BASE_URL` (no host boilerplate). This is the single-source authoring path. */
153
+ mcpScopes?: string[];
154
+ /** LEGACY: the full MCP OAuth URL block. Prefer `mcpScopes` (URLs derived). Kept for back-compat with hand-authored URLs. */
147
155
  mcp?: McpOAuthOpts;
148
156
  }
149
157
 
@@ -159,7 +167,7 @@ export interface CoreServiceOptsMap {
159
167
  /** Project a Service onto the legacy {@link CatalogEntry} shape the C051 generator reads. Field-for-field — so a derived
160
168
  * CATALOG is behaviourally identical to the old hardcoded one (proven by the Phase-0 golden lock). */
161
169
  export function toCatalogEntry(s: Service): CatalogEntry {
162
- return { mount: s.mount, provision: s.provision, deps: s.deps, env: s.env };
170
+ return { mount: s.mount, provision: s.provision, contract: s.contract, deps: s.deps, env: s.env };
163
171
  }
164
172
 
165
173
  /**
@@ -210,12 +218,13 @@ export const authService = defineService({
210
218
  });
211
219
 
212
220
  export const contractService = defineService({ id: "contract", mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] });
213
- export const mcpService = defineService({ id: "mcp", mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, deps: ["@suluk/mcp", "better-auth"] });
221
+ export const mcpService = defineService({ id: "mcp", mount: { kind: "middleware", symbol: "mountMcp", from: "./routes/mcp" }, provision: { symbol: "mcpProvision", from: "./src/provision/mcp" }, contract: { symbol: "mcpOps", from: "./contract/mcp" }, deps: ["@suluk/mcp", "better-auth"] });
214
222
 
215
223
  export const creditsService = defineService({
216
224
  id: "credits",
217
225
  mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" },
218
226
  provision: { symbol: "creditsProvision", from: "./src/provision/credits" },
227
+ contract: { symbol: "creditsOps", from: "./contract/credits" },
219
228
  deps: ["@suluk/credits"],
220
229
  compose: {
221
230
  offers: {
@@ -242,12 +251,13 @@ export const creditsService = defineService({
242
251
  },
243
252
  });
244
253
 
245
- export const keysService = defineService({ id: "keys", mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" }, deps: ["@suluk/keys"] });
254
+ export const keysService = defineService({ id: "keys", mount: { kind: "route", path: "/api/keys", symbol: "keysRoutes", from: "./routes/keys" }, provision: { symbol: "keysProvision", from: "./src/provision/keys" }, contract: { symbol: "keysOps", from: "./contract/keys" }, deps: ["@suluk/keys"] });
246
255
 
247
256
  export const billingService = defineService({
248
257
  id: "billing",
249
258
  mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" },
250
259
  provision: { symbol: "billingProvision", from: "./src/provision/billing" },
260
+ contract: { symbol: "billingOps", from: "./contract/billing" },
251
261
  deps: ["@suluk/billing", "@suluk/payments", "@suluk/credits"],
252
262
  env: [
253
263
  { name: "STRIPE_SECRET_KEY", required: true, secret: true, hint: "your Stripe secret key" },
@@ -255,12 +265,13 @@ export const billingService = defineService({
255
265
  ],
256
266
  });
257
267
 
258
- export const costService = defineService({ id: "cost", mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, deps: ["@suluk/cost"] });
259
- export const erasureService = defineService({ id: "erasure", mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" }, deps: ["@suluk/better-auth"] });
268
+ export const costService = defineService({ id: "cost", mount: { kind: "route", path: "/api/cost", symbol: "costRoutes", from: "./routes/cost" }, provision: { symbol: "costProvision", from: "./src/provision/cost" }, contract: { symbol: "costOps", from: "./contract/cost" }, deps: ["@suluk/cost"] });
269
+ export const erasureService = defineService({ id: "erasure", mount: { kind: "route", path: "/api/erasure", symbol: "erasureRoutes", from: "./routes/erasure" }, provision: { symbol: "erasureProvision", from: "./src/provision/erasure" }, contract: { symbol: "erasureOps", from: "./contract/erasure" }, deps: ["@suluk/better-auth"] });
260
270
 
261
271
  export const emailService = defineService({
262
272
  id: "email",
263
- mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, // stateless binding — no provision fragment (C052)
273
+ mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, // stateless binding — no provision fragment (C052) // stateless binding — no provision fragment (C052)
274
+ contract: { symbol: "emailOps", from: "./contract/email" },
264
275
  deps: ["@suluk/email"],
265
276
  env: [
266
277
  { name: "RESEND_API_KEY", secret: true, hint: "omit → the console provider (dev)" },
@@ -275,6 +286,7 @@ export const webhooksService = defineService({
275
286
  id: "webhooks",
276
287
  mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" },
277
288
  provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" },
289
+ contract: { symbol: "webhooksOps", from: "./contract/webhooks" },
278
290
  deps: ["@suluk/payments"],
279
291
  env: [{ name: "STRIPE_WEBHOOK_SECRET", required: true, secret: true, hint: "verifies inbound Stripe events (POST /api/webhooks/stripe)" }],
280
292
  });
@@ -282,9 +294,9 @@ export const webhooksService = defineService({
282
294
  export const rateLimitService = defineService({ id: "rate-limit", mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] });
283
295
  export const rateCreditService = defineService({ id: "rate-credit", mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }); // credit-backed free-tier bucket (KV binding)
284
296
  export const i18nService = defineService({ id: "i18n", mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] });
285
- export const referenceService = defineService({ id: "reference", mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }); // derived — no provision
286
- 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
287
- export const logsService = defineService({ id: "logs", mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } });
297
+ export const referenceService = defineService({ id: "reference", mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, contract: { symbol: "referenceOps", from: "./contract/reference" }, deps: ["@suluk/reference"] }); // derived — no provision
298
+ export const adminService = defineService({ id: "admin", mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, contract: { symbol: "adminOps", from: "./contract/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
299
+ export const logsService = defineService({ id: "logs", mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" }, contract: { symbol: "logsOps", from: "./contract/logs" } });
288
300
  export const journeysService = defineService({ id: "journeys", mount: { kind: "dev" }, deps: ["@suluk/journeys"] });
289
301
  export const auditService = defineService({ id: "audit", mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] });
290
302
 
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
+ }