@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 +1 -1
- package/src/generate.ts +12 -1
- package/src/manifest.ts +5 -0
- package/src/plan.ts +148 -26
- package/src/resolve.ts +41 -0
- package/src/service.ts +21 -9
- package/src/urls.ts +79 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@suluk/platform",
|
|
3
|
-
"version": "0.
|
|
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
|
|
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
|
|
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
|
|
596
|
-
//
|
|
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
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
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"
|
|
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 ??
|
|
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
|
-
...
|
|
737
|
-
|
|
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
|
+
}
|