@suluk/platform 0.1.9 → 0.2.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/src/resolve.ts ADDED
@@ -0,0 +1,111 @@
1
+ /**
2
+ * C053 Phase 2 — resolve the four opts quadrants of a `{ system, brand }` platform and LOWER it into the legacy
3
+ * `PlatformManifest` the C051 generator already consumes. This is the byte-identity strategy: the new authoring surface is
4
+ * sugar that normalizes DOWN to `{ services, opts, vars }`, then the UNCHANGED `planPlatform` renders it — so a system/brand
5
+ * manifest equivalent to the legacy one produces the SAME bytes (the Phase-0 golden lock proves it), and the legacy path is
6
+ * untouched.
7
+ *
8
+ * The 2×2 (NODE opts):
9
+ * - serviceOpts (per-service) + globalServiceOpts-keys-a-service `reads` → the ENTRY (a mount's opts object) → `opts`
10
+ * - brandOpts (per-service) + globalBrandOpts + env-shaped globalServiceOpts → wrangler `[vars]`/env → `vars`
11
+ * Composition (`wire`) is Phase 3 — ignored here.
12
+ */
13
+ import type { PlatformManifest } from "./manifest";
14
+ import type { Platform, SystemManifest, BrandManifest } from "./manifest";
15
+ import { CORE_SERVICES } from "./service";
16
+
17
+ const isPlainObject = (v: unknown): v is Record<string, unknown> => typeof v === "object" && v !== null && !Array.isArray(v);
18
+ const isScalar = (v: unknown): v is string | number | boolean => typeof v === "string" || typeof v === "number" || typeof v === "boolean";
19
+
20
+ /** Deep-merge b over a (b wins), preserving a's key insertion order then b's new keys — so the emitted JSON is stable. */
21
+ function deepMerge(a: Record<string, unknown>, b: Record<string, unknown>): Record<string, unknown> {
22
+ const out: Record<string, unknown> = { ...a };
23
+ for (const [k, v] of Object.entries(b)) {
24
+ const prev = out[k];
25
+ out[k] = isPlainObject(prev) && isPlainObject(v) ? deepMerge(prev, v) : v;
26
+ }
27
+ return out;
28
+ }
29
+
30
+ const pick = (o: Record<string, unknown>, keys: string[]): Record<string, unknown> => {
31
+ const out: Record<string, unknown> = {};
32
+ for (const k of keys) if (k in o) out[k] = o[k];
33
+ return out;
34
+ };
35
+
36
+ /** A ServiceRef → its runtime id (a string ref is the id; a Service object contributes `.id`). */
37
+ export function serviceId(ref: string | { id: string }): string {
38
+ return typeof ref === "string" ? ref : ref.id;
39
+ }
40
+
41
+ /**
42
+ * Resolve the node quadrants of `{ system, brand }` into the `{ services, opts, vars }` a legacy manifest carries:
43
+ * - `opts[id]` (→ entry): the globalServiceOpts keys the service `reads`, deep-merged UNDER its per-service serviceOpts.
44
+ * Empty results are omitted, so the map matches a hand-written legacy manifest (which only lists services that HAVE opts).
45
+ * - `vars` (→ [vars]): every scalar value across globalServiceOpts + globalBrandOpts + per-service brandOpts. `buildWrangler`
46
+ * only surfaces the ones that are declared service env vars, so extra keys are harmless.
47
+ */
48
+ export function resolveNodeOpts(system: SystemManifest, brand: BrandManifest): { services: string[]; opts: Record<string, Record<string, unknown>>; vars: Record<string, string> } {
49
+ const services = system.services.map(serviceId);
50
+ const gso = (system.globalServiceOpts ?? {}) as Record<string, unknown>;
51
+
52
+ const opts: Record<string, Record<string, unknown>> = {};
53
+ for (const id of services) {
54
+ const reads = CORE_SERVICES[id]?.reads?.globalService ?? [];
55
+ const fromGlobal = pick(gso, reads);
56
+ const perService = (system.serviceOpts as Record<string, Record<string, unknown>> | undefined)?.[id] ?? {};
57
+ const eff = deepMerge(fromGlobal, perService);
58
+ if (Object.keys(eff).length) opts[id] = eff;
59
+ }
60
+
61
+ const vars: Record<string, string> = {};
62
+ const foldScalars = (o: Record<string, unknown> | undefined) => {
63
+ for (const [k, v] of Object.entries(o ?? {})) if (isScalar(v)) vars[k] = String(v);
64
+ };
65
+ foldScalars(gso);
66
+ foldScalars(brand.globalBrandOpts as Record<string, unknown> | undefined);
67
+ for (const perBrand of Object.values(brand.brandOpts ?? {})) foldScalars(perBrand as Record<string, unknown>);
68
+
69
+ return { services, opts, vars };
70
+ }
71
+
72
+ /** env-shaped globalServiceOpts (system behaviour delivered as a runtime env var) — the rest of `vars` is brand identity. */
73
+ const SYSTEM_VAR_NAMES = new Set(["TRUSTED_ORIGINS", "ENVIRONMENT"]);
74
+
75
+ /**
76
+ * The MIGRATE direction — a legacy {@link PlatformManifest} → the C053 `{ system, brand }` split (the inverse of
77
+ * {@link liftSystemBrand}). `opts` → per-service serviceOpts; `vars` split into globalServiceOpts (system-shaped) vs
78
+ * globalBrandOpts (identity). Round-trips byte-for-byte: `liftSystemBrand(liftLegacy(m))` generates the same app as `m`.
79
+ */
80
+ export function liftLegacy(m: PlatformManifest): Platform {
81
+ const globalServiceOpts: Record<string, string> = {};
82
+ const globalBrandOpts: Record<string, string> = {};
83
+ for (const [k, v] of Object.entries(m.vars ?? {})) (SYSTEM_VAR_NAMES.has(k) ? globalServiceOpts : globalBrandOpts)[k] = v;
84
+ return {
85
+ system: {
86
+ registry: m.registry,
87
+ services: m.services,
88
+ ...(Object.keys(globalServiceOpts).length ? { globalServiceOpts } : {}),
89
+ ...(m.opts && Object.keys(m.opts).length ? { serviceOpts: m.opts } : {}),
90
+ },
91
+ brand: {
92
+ name: m.name,
93
+ ...(Object.keys(globalBrandOpts).length ? { globalBrandOpts } : {}),
94
+ },
95
+ };
96
+ }
97
+
98
+ /** Lower a `{ system, brand }` platform to the legacy {@link PlatformManifest} the C051 generator renders. */
99
+ export function liftSystemBrand(p: Platform): PlatformManifest {
100
+ const { services, opts, vars } = resolveNodeOpts(p.system, p.brand);
101
+ const registry = p.system.registry ?? p.system.registries?.core;
102
+ // backstop (definePlatform also guards): never lower to an empty registry → malformed "/service" adds.
103
+ if (!registry) throw new Error('platform: `system.registry` (or `system.registries.core`) is required (e.g. "MahmoodKhalil57/suluk")');
104
+ return {
105
+ name: p.brand.name,
106
+ registry,
107
+ services,
108
+ ...(Object.keys(opts).length ? { opts } : {}),
109
+ ...(Object.keys(vars).length ? { vars } : {}),
110
+ };
111
+ }
package/src/service.ts ADDED
@@ -0,0 +1,265 @@
1
+ /**
2
+ * The common Service interface (C053) — "define what each core service is" so community shadcn registries can extend the
3
+ * platform with the SAME shape. A service = how it MOUNTS + its PROVISION fragment + npm DEPS + runtime ENV (all present
4
+ * since C051), PLUS (C053) the typed opts surfaces and the COMPOSITION surface (ports it EXPOSES / capabilities it OFFERS).
5
+ *
6
+ * PHASE 1 (this file) lands the interface + the 19 core services expressed through it (`CORE_SERVICES`); `catalog.ts`
7
+ * DERIVES the old `CATALOG` view from it (`toCatalogEntry`) so `planPlatform` is byte-for-byte unchanged. The typed opts
8
+ * schemas (`serviceOpts`/`brandOpts`) and the composition ENGINE are declared here but not yet consumed — Phase 2 wires the
9
+ * opts, Phase 3 the composition. Every phase runs against the Phase-0 golden lock.
10
+ */
11
+
12
+ /** How a module contributes to the generated `src/index.ts`. (Unchanged from C051.) */
13
+ export type Mount =
14
+ | { kind: "base" } // the app skeleton — `createApp()`
15
+ | { kind: "middleware"; symbol: string; from: string } // e.g. `mountAuthRoutes(app)`
16
+ | { kind: "route"; path: string; symbol: string; from: string } // e.g. `app.route("/api/credits", creditsRoutes())`
17
+ | { kind: "dev" }; // dev/CI tooling (journeys, audit) — files only, no runtime mount, no provision fragment
18
+
19
+ /** An env var a module needs at runtime — drives the generated `.env.example` + the env-check preflight. (Unchanged.) */
20
+ export interface EnvVar {
21
+ name: string;
22
+ /** the app WON'T work without it (the "minimum keys") — the env-check requires a non-empty value before it's happy. */
23
+ required?: boolean;
24
+ /** a credential (never commit) — shown commented in `.env.example` + flagged in the temp file. */
25
+ secret?: boolean;
26
+ /** a one-line hint shown as a comment in `.env.example`. */
27
+ hint?: string;
28
+ }
29
+
30
+ /** The old catalog record — now a DERIVED VIEW of a {@link Service} (see {@link toCatalogEntry}); kept so `planPlatform`
31
+ * and the C051 helpers read the same shape they always did. */
32
+ export interface CatalogEntry {
33
+ mount: Mount;
34
+ provision?: { symbol: string; from: string };
35
+ deps?: string[];
36
+ env?: EnvVar[];
37
+ }
38
+
39
+ /**
40
+ * Standard-Schema v1 shape (zod v4 implements it). Declared LOCALLY so the Service interface can carry the typed-opts slots
41
+ * with NO runtime validator dependency in Phase 1; Phase 2 replaces this with `@standard-schema/spec` and populates
42
+ * `serviceOpts`/`brandOpts` with real zod schemas (zod as a peerDependency). `Out` carries the inferred value type.
43
+ */
44
+ export interface Schema<Out = unknown> {
45
+ readonly "~standard": {
46
+ readonly version: 1;
47
+ readonly vendor: string;
48
+ readonly validate: (value: unknown) => { value: Out } | { issues: readonly unknown[] } | Promise<unknown>;
49
+ };
50
+ }
51
+
52
+ /**
53
+ * A typed PORT a service EXPOSES: a named hook others fill. `hookOptKey` is the mount-opt field a bound edge renders INTO
54
+ * (e.g. auth's `onUserCreated`), so an edge never emits a separate post-route statement — it composes into the producer's
55
+ * own mount call. `render` wraps the consumer expressions for this hook's real signature. (Consumed in Phase 3.)
56
+ */
57
+ export interface Port<P = unknown> {
58
+ readonly kind: "port";
59
+ readonly param?: Schema<P>;
60
+ readonly hookOptKey: string;
61
+ readonly render: (consumerExprs: string[]) => string;
62
+ }
63
+
64
+ /**
65
+ * A typed CAPABILITY a service OFFERS to fill a port. `build` produces the consumer EXPRESSION rendered into the producer's
66
+ * hook closure — it may reference the closure's fixed params `userId` and `env` (the seam threads env), plus the symbols it
67
+ * declares in `imports` (all TRUSTED — from the service definition, never manifest free text). `with` is the wire's
68
+ * schema-validated params (JSON data only). (Consumed in Phase 3.)
69
+ */
70
+ export interface Capability<A = unknown> {
71
+ readonly kind: "capability";
72
+ readonly param?: Schema<A>;
73
+ readonly symbol: string; // exported name in the service's owned code (import-checked)
74
+ readonly from: string;
75
+ readonly imports?: { symbol: string; from: string }[]; // what the built expr references → unioned into the entry imports
76
+ readonly build: (ctx: { with: Record<string, unknown> }) => string;
77
+ }
78
+
79
+ /** What a service brings to the composition graph: the ports it exposes + the capabilities it offers. */
80
+ export interface CompositionSurface {
81
+ exposes?: Record<string, Port>;
82
+ offers?: Record<string, Capability>;
83
+ }
84
+
85
+ /**
86
+ * THE COMMON INTERFACE. `SO` = the service-opts value type, `BO` = the brand-opts value type (both Phase 2). A core service
87
+ * and a community service instantiate the exact same shape via {@link defineService}.
88
+ */
89
+ export interface Service<SO = {}, BO = {}> {
90
+ readonly id: string; // "auth" | "acme.analytics"
91
+ readonly registry?: string; // owning registry (multi-registry, Phase 4); default = the manifest's core alias
92
+ readonly mount: Mount;
93
+ readonly provision?: { symbol: string; from: string };
94
+ readonly deps?: string[];
95
+ readonly env?: EnvVar[];
96
+ readonly serviceOpts?: Schema<SO>; // how THIS service works → the ENTRY (mount 2nd arg) [Phase 2]
97
+ readonly brandOpts?: Schema<BO>; // THIS service's brand-facing → [vars]/env by default [Phase 2]
98
+ readonly reads?: { globalService?: string[]; globalBrand?: string[] }; // which globals it consumes [Phase 2]
99
+ readonly compose?: CompositionSurface; // ports it exposes + capabilities it offers [Phase 3]
100
+ }
101
+
102
+ /**
103
+ * Author a service. `const S` PRESERVES the literal `id` + the precise `serviceOpts`/`brandOpts` marker types, so the
104
+ * manifest (`defineSystem`) can key typed opts by service id off the imported service objects — no codegen. Validates the id.
105
+ */
106
+ export function defineService<const S extends Service<any, any>>(s: S): S {
107
+ if (!s.id) throw new Error("defineService: `id` is required");
108
+ return s;
109
+ }
110
+
111
+ /**
112
+ * A TYPED opts marker for a service's `serviceOpts`/`brandOpts`. Phase 2 uses it purely for TYPES — the manifest author
113
+ * gets autocomplete + type-checking on that service's opts. It carries the value type `T` in the `Schema<T>` slot; Phase 3
114
+ * swaps it for a runtime-validating zod schema of the SAME type (a drop-in — the field type is `Schema<T>` either way).
115
+ */
116
+ export function optsType<T>(): Schema<T> {
117
+ return { "~standard": { version: 1, vendor: "suluk", validate: (value) => ({ value: value as T }) } };
118
+ }
119
+
120
+ /** The MCP OAuth authorization-server config (auth's `serviceOpts.mcp`) — the frontend OAuth pages + resource + scope set. */
121
+ export interface McpOAuthOpts {
122
+ loginPage: string;
123
+ consentPage: string;
124
+ resource: string;
125
+ scopes: string[];
126
+ }
127
+ /** auth's serviceOpts: optionally activate the MCP OAuth server (Better Auth `mcp()` plugin). */
128
+ export interface AuthServiceOpts {
129
+ mcp?: McpOAuthOpts;
130
+ }
131
+
132
+ /**
133
+ * Core service id → its serviceOpts value type. Lets a STRING-referenced core service (e.g. `services: ["auth", …]`) get the
134
+ * SAME typed serviceOpts as the imported-object form (`services: [authService, …]`), instead of collapsing to `unknown`.
135
+ * Extend as core services gain typed opts.
136
+ */
137
+ export interface CoreServiceOptsMap {
138
+ auth: AuthServiceOpts;
139
+ }
140
+
141
+ /** Project a Service onto the legacy {@link CatalogEntry} shape the C051 generator reads. Field-for-field — so a derived
142
+ * CATALOG is behaviourally identical to the old hardcoded one (proven by the Phase-0 golden lock). */
143
+ export function toCatalogEntry(s: Service): CatalogEntry {
144
+ return { mount: s.mount, provision: s.provision, deps: s.deps, env: s.env };
145
+ }
146
+
147
+ /**
148
+ * The 19 CORE services, expressed through the common interface (the dogfood). Ported field-for-field from the C051 CATALOG;
149
+ * `auth` and `credits` additionally declare their composition surface (the `auth.onUserCreated` port + the
150
+ * `credits.grantOnSignup` capability) — inert until the Phase-3 engine consumes them, and the render/build templates are
151
+ * PROVISIONAL (Phase 3 pins them against the real auth seam signature, see ADR C053 open question #1).
152
+ */
153
+ // Each core service is exported as a NAMED, precisely-typed const so a `defineSystem` author can import it and get typed
154
+ // serviceOpts keyed by id. Ported field-for-field from the C051 CATALOG (byte-identity via the Phase-0 golden lock).
155
+
156
+ export const appService = defineService({ id: "app", mount: { kind: "base" }, env: [{ name: "TRUSTED_ORIGINS", hint: "comma-separated browser origins allowed on /api/* (CORS)" }] });
157
+
158
+ export const authService = defineService({
159
+ id: "auth",
160
+ mount: { kind: "middleware", symbol: "mountAuthRoutes", from: "./auth" },
161
+ provision: { symbol: "authProvision", from: "./src/provision/auth" },
162
+ deps: ["better-auth", "@better-auth/api-key", "@better-auth/passkey", "@suluk/better-auth"],
163
+ env: [
164
+ { name: "BETTER_AUTH_SECRET", required: true, secret: true, hint: "session-signing key — `openssl rand -base64 32`" },
165
+ { name: "BETTER_AUTH_URL", hint: "your deployed origin, e.g. https://api.example.com" },
166
+ { name: "GOOGLE_CLIENT_ID", secret: true, hint: "optional — enables Google sign-in" },
167
+ { name: "GOOGLE_CLIENT_SECRET", secret: true, hint: "optional — pairs with GOOGLE_CLIENT_ID" },
168
+ ],
169
+ serviceOpts: optsType<AuthServiceOpts>(), // typed: `serviceOpts.auth.mcp` autocompletes + type-checks
170
+ compose: {
171
+ exposes: {
172
+ // the signup hook. The seam is PINNED to (userId, env) — registry/auth/auth.ts widened to pass the Worker env into the
173
+ // databaseHook callback (env is already in buildAuth's closure), so a consumer expr can build its Effect layers.
174
+ onUserCreated: { kind: "port", hookOptKey: "onUserCreated", render: (exprs) => `async (userId, env) => { ${exprs.join("; ")}; }` },
175
+ },
176
+ },
177
+ });
178
+
179
+ export const contractService = defineService({ id: "contract", mount: { kind: "middleware", symbol: "mountContract", from: "./routes/contract" }, deps: ["@suluk/hono", "zod"] });
180
+ 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"] });
181
+
182
+ export const creditsService = defineService({
183
+ id: "credits",
184
+ mount: { kind: "route", path: "/api/credits", symbol: "creditsRoutes", from: "./routes/credits" },
185
+ provision: { symbol: "creditsProvision", from: "./src/provision/credits" },
186
+ deps: ["@suluk/credits"],
187
+ compose: {
188
+ offers: {
189
+ // grant N credits on signup, idempotent per user (the real Credits Effect service + grantOnce, over the request DB).
190
+ // References the closure's `userId` + `env`; provides CreditsLive + DbLive(env) and runs the program.
191
+ grantOnSignup: {
192
+ kind: "capability",
193
+ symbol: "Credits",
194
+ from: "./services/credits",
195
+ imports: [
196
+ { symbol: "Effect", from: "effect" },
197
+ { symbol: "Credits", from: "./services/credits" },
198
+ { symbol: "CreditsLive", from: "./services/credits" },
199
+ { symbol: "DbLive", from: "./app" },
200
+ ],
201
+ build: ({ with: w }) => {
202
+ // fail LOUDLY at generate time on a wrong-typed money param — never render an invalid literal into the app.
203
+ if (w.amount !== undefined && typeof w.amount !== "number") throw new Error(`credits.grantOnSignup: 'amount' must be a number (got ${typeof w.amount})`);
204
+ const amount = (w.amount as number | undefined) ?? 100;
205
+ return `await Effect.runPromise(Effect.flatMap(Credits, (s) => s.grant(userId, ${JSON.stringify(amount)}, "signup:" + userId, "signup grant")).pipe(Effect.provide(CreditsLive), Effect.provide(DbLive(env))))`;
206
+ },
207
+ },
208
+ },
209
+ },
210
+ });
211
+
212
+ 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"] });
213
+
214
+ export const billingService = defineService({
215
+ id: "billing",
216
+ mount: { kind: "route", path: "/api/billing", symbol: "billingRoutes", from: "./routes/billing" },
217
+ provision: { symbol: "billingProvision", from: "./src/provision/billing" },
218
+ deps: ["@suluk/billing", "@suluk/payments", "@suluk/credits"],
219
+ env: [
220
+ { name: "STRIPE_SECRET_KEY", required: true, secret: true, hint: "your Stripe secret key" },
221
+ { name: "STRIPE_PUBLISHABLE_KEY", hint: "returned by GET /api/billing/payment-config" },
222
+ ],
223
+ });
224
+
225
+ 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"] });
226
+ 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"] });
227
+
228
+ export const emailService = defineService({
229
+ id: "email",
230
+ mount: { kind: "route", path: "/api/email", symbol: "emailRoutes", from: "./routes/email" }, // stateless binding — no provision fragment (C052)
231
+ deps: ["@suluk/email"],
232
+ env: [
233
+ { name: "RESEND_API_KEY", secret: true, hint: "omit → the console provider (dev)" },
234
+ { name: "EMAIL_FROM", hint: "the from-address" },
235
+ { name: "BRAND_NAME", hint: "email template branding" },
236
+ { name: "BASE_URL", hint: "email link base" },
237
+ { name: "ENVIRONMENT", hint: '"production" → use Resend (else console)' },
238
+ ],
239
+ });
240
+
241
+ export const webhooksService = defineService({
242
+ id: "webhooks",
243
+ mount: { kind: "route", path: "/api/webhooks", symbol: "webhooksRoutes", from: "./routes/webhooks" },
244
+ provision: { symbol: "webhooksProvision", from: "./src/provision/webhooks" },
245
+ deps: ["@suluk/payments"],
246
+ env: [{ name: "STRIPE_WEBHOOK_SECRET", required: true, secret: true, hint: "verifies inbound Stripe events (POST /api/webhooks/stripe)" }],
247
+ });
248
+
249
+ export const rateLimitService = defineService({ id: "rate-limit", mount: { kind: "middleware", symbol: "mountRateLimit", from: "./services/rate-limit" }, deps: ["@suluk/hono"] });
250
+ export const rateCreditService = defineService({ id: "rate-credit", mount: { kind: "middleware", symbol: "mountRateCredit", from: "./services/rate-credit" } }); // credit-backed free-tier bucket (KV binding)
251
+ export const i18nService = defineService({ id: "i18n", mount: { kind: "middleware", symbol: "mountI18n", from: "./services/i18n" }, deps: ["@suluk/i18n"] });
252
+ export const referenceService = defineService({ id: "reference", mount: { kind: "route", path: "/api/reference", symbol: "referenceRoutes", from: "./routes/reference" }, deps: ["@suluk/reference"] }); // derived — no provision
253
+ export const adminService = defineService({ id: "admin", mount: { kind: "route", path: "/api/admin", symbol: "adminRoutes", from: "./routes/admin" }, deps: ["@suluk/credits"] }); // reads existing tables — no provision
254
+ export const logsService = defineService({ id: "logs", mount: { kind: "route", path: "/api/logs", symbol: "logsRoutes", from: "./routes/logs" }, provision: { symbol: "logsProvision", from: "./src/provision/logs" } });
255
+ export const journeysService = defineService({ id: "journeys", mount: { kind: "dev" }, deps: ["@suluk/journeys"] });
256
+ export const auditService = defineService({ id: "audit", mount: { kind: "dev" }, deps: ["@suluk/cockpit", "@suluk/harden"] });
257
+
258
+ /** The 19 core services, in the C051 catalog order. */
259
+ const CORE_SERVICE_LIST: Service[] = [
260
+ appService, authService, contractService, mcpService, creditsService, keysService, billingService, costService, erasureService, emailService,
261
+ webhooksService, rateLimitService, rateCreditService, i18nService, referenceService, adminService, logsService, journeysService, auditService,
262
+ ];
263
+
264
+ /** The core services keyed by id (key === `service.id`, guaranteed by construction). */
265
+ export const CORE_SERVICES: Record<string, Service> = Object.fromEntries(CORE_SERVICE_LIST.map((s) => [s.id, s]));
package/src/wire.ts ADDED
Binary file
package/test/plan.test.ts DELETED
@@ -1,247 +0,0 @@
1
- import { test, expect, describe } from "bun:test";
2
- import { definePlatform, planPlatform, mergeProvision, generatePlatform, buildPackageJson, mergePackageJson, mergeWranglerToml } from "../src/index";
3
- import type { InstanceSpec } from "@suluk/provision";
4
-
5
- /** C051 — the platform generator: manifest → plan (adds + wired entry + merged provision), the provision merge, and the
6
- * generate orchestration (with recorders). */
7
- const manifest = definePlatform({ name: "autotoolfactory", registry: "acme/reg", services: ["auth", "credits", "keys", "billing", "logs"] });
8
-
9
- describe("planPlatform — manifest → shadcn adds + entry + provision.config", () => {
10
- const plan = planPlatform(manifest);
11
-
12
- test("orders app + auth first, then the rest; adds are registry refs", () => {
13
- expect(plan.services).toEqual(["app", "auth", "credits", "keys", "billing", "logs"]);
14
- expect(plan.adds).toEqual(["acme/reg/app", "acme/reg/auth", "acme/reg/credits", "acme/reg/keys", "acme/reg/billing", "acme/reg/logs"]);
15
- });
16
-
17
- test("the generated entry wires the base + auth middleware + each route", () => {
18
- expect(plan.entry).toContain('import { createApp } from "./app";');
19
- expect(plan.entry).toContain("const app = createApp();");
20
- expect(plan.entry).toContain('import { mountAuthRoutes } from "./auth";');
21
- expect(plan.entry).toContain("mountAuthRoutes(app);");
22
- expect(plan.entry).toContain('import { creditsRoutes } from "./routes/credits";');
23
- expect(plan.entry).toContain('app.route("/api/credits", creditsRoutes());');
24
- expect(plan.entry).toContain('app.route("/api/billing", billingRoutes());');
25
- expect(plan.entry).toContain("export default app;");
26
- });
27
-
28
- test("the generated provision.config imports each fragment + merges them", () => {
29
- expect(plan.provisionConfig).toContain('import { defineProvision } from "@suluk/provision";');
30
- expect(plan.provisionConfig).toContain('import { mergeProvision } from "@suluk/platform";');
31
- expect(plan.provisionConfig).toContain('import { authProvision } from "./src/provision/auth";');
32
- expect(plan.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision, keysProvision, billingProvision, logsProvision])");
33
- });
34
-
35
- test("an unknown service throws", () => {
36
- expect(() => planPlatform({ name: "x", registry: "r", services: ["nope"] })).toThrow(/unknown service/);
37
- });
38
-
39
- test("per-service opts are passed to the mount (e.g. auth mcp OAuth config)", () => {
40
- const p = planPlatform(definePlatform({ name: "o", registry: "acme/reg", services: ["auth", "credits"], opts: { auth: { mcp: { resource: "https://api.x", scopes: ["credits:read"] } } } }));
41
- expect(p.entry).toContain('mountAuthRoutes(app, {"mcp":{"resource":"https://api.x","scopes":["credits:read"]}});');
42
- // a service with no opts still gets the bare call.
43
- expect(p.entry).toContain('app.route("/api/credits", creditsRoutes());');
44
- });
45
- });
46
-
47
- describe("cost (route + provision) + dev modules (journeys/audit — files only)", () => {
48
- const plan = planPlatform(definePlatform({ name: "full", registry: "acme/reg", services: ["auth", "credits", "cost", "logs", "journeys", "audit"] }));
49
-
50
- test("cost mounts a /cost route and contributes a provision fragment", () => {
51
- expect(plan.entry).toContain('import { costRoutes } from "./routes/cost";');
52
- expect(plan.entry).toContain('app.route("/api/cost", costRoutes());');
53
- expect(plan.provisionConfig).toContain('import { costProvision } from "./src/provision/cost";');
54
- expect(plan.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision, costProvision, logsProvision])");
55
- });
56
-
57
- test("erasure mounts an /erasure route and contributes a provision fragment", () => {
58
- const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "erasure"] }));
59
- expect(p.entry).toContain('import { erasureRoutes } from "./routes/erasure";');
60
- expect(p.entry).toContain('app.route("/api/erasure", erasureRoutes());');
61
- expect(p.provisionConfig).toContain('import { erasureProvision } from "./src/provision/erasure";');
62
- expect(p.provisionConfig).toContain("mergeProvision([authProvision, erasureProvision])");
63
- });
64
-
65
- test("email mounts an /email route but contributes NO provision fragment (stateless binding)", () => {
66
- const p = planPlatform(definePlatform({ name: "m", registry: "acme/reg", services: ["auth", "email", "credits"] }));
67
- expect(p.entry).toContain('import { emailRoutes } from "./routes/email";');
68
- expect(p.entry).toContain('app.route("/api/email", emailRoutes());');
69
- // email has no provision fragment, so the merge is just auth + credits.
70
- expect(p.provisionConfig).not.toContain("emailProvision");
71
- expect(p.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision])");
72
- });
73
-
74
- test("webhooks mounts a /webhooks route and contributes a provision fragment", () => {
75
- const p = planPlatform(definePlatform({ name: "w", registry: "acme/reg", services: ["auth", "webhooks"] }));
76
- expect(p.entry).toContain('import { webhooksRoutes } from "./routes/webhooks";');
77
- expect(p.entry).toContain('app.route("/api/webhooks", webhooksRoutes());');
78
- expect(p.provisionConfig).toContain("mergeProvision([authProvision, webhooksProvision])");
79
- });
80
-
81
- test("rate-limit + i18n are MIDDLEWARE mounts (app.use, no route, no provision) emitted BEFORE routes", () => {
82
- const p = planPlatform(definePlatform({ name: "mw", registry: "acme/reg", services: ["auth", "credits", "rate-limit", "i18n"] }));
83
- expect(p.entry).toContain("mountRateLimit(app);");
84
- expect(p.entry).toContain("mountI18n(app);");
85
- // no route, no provision for either.
86
- expect(p.entry).not.toContain('app.route("/rate-limit"');
87
- expect(p.provisionConfig).toContain("mergeProvision([authProvision, creditsProvision])");
88
- // two-pass ordering: every middleware mount precedes every route mount, so global middleware applies to all routes.
89
- const lastMw = Math.max(p.entry.indexOf("mountRateLimit(app);"), p.entry.indexOf("mountI18n(app);"), p.entry.indexOf("mountAuthRoutes(app);"));
90
- expect(lastMw).toBeLessThan(p.entry.indexOf('app.route("/api/credits"'));
91
- });
92
-
93
- test("contract is a MIDDLEWARE mount (scope gate + /api/openapi.json), emitted before routes; feature routes under /api/*", () => {
94
- const p = planPlatform(definePlatform({ name: "c", registry: "acme/reg", services: ["auth", "contract", "credits"] }));
95
- expect(p.entry).toContain('import { mountContract } from "./routes/contract";');
96
- expect(p.entry).toContain("mountContract(app);");
97
- expect(p.entry).toContain('app.route("/api/credits", creditsRoutes());'); // toolfactory-parity /api/* prefix
98
- // the gate (middleware) is emitted before any route.
99
- expect(p.entry.indexOf("mountContract(app);")).toBeLessThan(p.entry.indexOf('app.route("/api/credits"'));
100
- expect(p.provisionConfig).not.toContain("contractProvision");
101
- });
102
-
103
- test("reference + admin mount /api routes with NO provision (derived / reads existing tables)", () => {
104
- const p = planPlatform(definePlatform({ name: "ra", registry: "acme/reg", services: ["auth", "contract", "reference", "admin", "credits"] }));
105
- expect(p.entry).toContain('app.route("/api/reference", referenceRoutes());');
106
- expect(p.entry).toContain('app.route("/api/admin", adminRoutes());');
107
- expect(p.provisionConfig).not.toContain("referenceProvision");
108
- expect(p.provisionConfig).not.toContain("adminProvision");
109
- });
110
-
111
- test("mcp is a middleware mount (server + discovery + connections) with a provision fragment", () => {
112
- const p = planPlatform(definePlatform({ name: "m", registry: "acme/reg", services: ["auth", "contract", "mcp", "credits"] }));
113
- expect(p.entry).toContain('import { mountMcp } from "./routes/mcp";');
114
- expect(p.entry).toContain("mountMcp(app);");
115
- expect(p.entry).not.toContain('app.route("/api/mcp"'); // it's a mount, not a route
116
- expect(p.provisionConfig).toContain("mcpProvision");
117
- });
118
-
119
- test("rate-credit is a middleware mount (KV binding) with NO provision fragment", () => {
120
- const p = planPlatform(definePlatform({ name: "rc", registry: "acme/reg", services: ["auth", "rate-credit", "credits"] }));
121
- expect(p.entry).toContain("mountRateCredit(app);");
122
- expect(p.provisionConfig).not.toContain("rateCreditProvision");
123
- });
124
-
125
- test("dev modules add shadcn refs but NO entry mount and NO provision fragment", () => {
126
- expect(plan.adds).toContain("acme/reg/journeys");
127
- expect(plan.adds).toContain("acme/reg/audit");
128
- expect(plan.entry).not.toContain("journeys");
129
- expect(plan.entry).not.toContain("audit");
130
- expect(plan.provisionConfig).not.toContain("journeys");
131
- expect(plan.provisionConfig).not.toContain("audit");
132
- });
133
- });
134
-
135
- describe("mergeProvision — combine same-ref instances, union migrations in order", () => {
136
- test("two `db` fragments merge into one with both migrations (fragment order preserved)", () => {
137
- const auth: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0000_auth", sql: "A" }] }, bind: { database_id: "ID" }, protected: true }];
138
- const credits: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "app-db", params: { migrations: [{ name: "0001_credits", sql: "C" }] }, bind: { database_id: "ID" }, protected: true }];
139
- const merged = mergeProvision([auth, credits]);
140
- expect(merged.length).toBe(1);
141
- expect(merged[0].ref).toBe("db");
142
- expect((merged[0].params!.migrations as { name: string }[]).map((m) => m.name)).toEqual(["0000_auth", "0001_credits"]);
143
- expect(merged[0].protected).toBe(true);
144
- });
145
-
146
- test("distinct refs stay separate", () => {
147
- const a: InstanceSpec[] = [{ ref: "db", service: "cloudflare-d1", name: "db", params: {} }];
148
- const b: InstanceSpec[] = [{ ref: "kv", service: "cloudflare-kv", name: "cache", params: {} }];
149
- expect(mergeProvision([a, b]).map((i) => i.ref).sort()).toEqual(["db", "kv"]);
150
- });
151
- });
152
-
153
- describe("generatePlatform — the orchestration (with recorders)", () => {
154
- test("writes the scaffold config FIRST, then a shadcn add per service, then the glue", async () => {
155
- const ran: string[] = [];
156
- const wrote: string[] = [];
157
- const res = await generatePlatform(manifest, {
158
- run: async (cmd, args) => void ran.push(`${cmd} ${args.join(" ")}`),
159
- write: async (path) => void wrote.push(path),
160
- read: async () => null, // a fresh app — no existing config
161
- });
162
- expect(ran).toEqual(plannedAdds()); // exactly the planned adds, in order
163
- expect(ran.length).toBe(6); // app+auth+credits+keys+billing+logs
164
- // config is written BEFORE the shadcn adds; the glue after. env-example + env-check + wrangler + gitignore included.
165
- expect(wrote).toEqual(["package.json", "wrangler.toml", "tsconfig.json", "components.json", ".gitignore", ".env.example", "scripts/env-check.ts", "src/index.ts", "provision.config.ts"]);
166
- expect(res.added.length).toBe(6);
167
- });
168
-
169
- test("leaves an existing tsconfig/components.json/.gitignore untouched; always rewrites package.json/.env.example", async () => {
170
- const wrote: string[] = [];
171
- await generatePlatform(manifest, {
172
- run: async () => {},
173
- write: async (path) => void wrote.push(path),
174
- read: async (p) => (p === "package.json" ? '{"name":"x","dependencies":{"my-lib":"^1.0.0"}}' : "existing"),
175
- });
176
- expect(wrote).toContain("package.json"); // merged + rewritten
177
- expect(wrote).toContain(".env.example"); // template — always current
178
- expect(wrote).toContain("scripts/env-check.ts");
179
- expect(wrote).not.toContain("tsconfig.json"); // present → left as-is
180
- expect(wrote).not.toContain(".gitignore");
181
- });
182
- });
183
-
184
- describe("env — secrets in .env (temp lifecycle), non-secrets in the manifest vars", () => {
185
- test(".env.example lists ONLY required + optional secrets, never non-secret config", () => {
186
- const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing", "webhooks", "email"] }));
187
- expect(p.envExample).toContain("BETTER_AUTH_SECRET="); // required secret, uncommented
188
- expect(p.envExample).toContain("STRIPE_SECRET_KEY=");
189
- expect(p.envExample).toContain("STRIPE_WEBHOOK_SECRET=");
190
- expect(p.envExample).toContain("# RESEND_API_KEY="); // optional secret, commented
191
- expect(p.envExample).not.toContain("BASE_URL"); // non-secret → the manifest vars, NOT .env
192
- expect(p.envExample).not.toContain("TRUSTED_ORIGINS");
193
- });
194
-
195
- test("wrangler.toml [vars] come from the manifest vars; unset non-secrets are commented; D1 binding present", () => {
196
- const p = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "email", "rate-credit"], vars: { BASE_URL: "https://x.dev", ENVIRONMENT: "production" } }));
197
- expect(p.wranglerToml).toContain('BASE_URL = "https://x.dev"'); // set in the manifest
198
- expect(p.wranglerToml).toContain('ENVIRONMENT = "production"');
199
- expect(p.wranglerToml).toContain("# EMAIL_FROM ="); // unset → commented
200
- expect(p.wranglerToml).toContain('binding = "DB"');
201
- expect(p.wranglerToml).toContain('binding = "RATE_CREDIT_KV"'); // rate-credit selected
202
- expect(p.wranglerToml).not.toContain("BETTER_AUTH_SECRET"); // secrets never in [vars]
203
- });
204
-
205
- test("the env-check script bakes in the required secrets; merge preserves provisioned binding ids", () => {
206
- const p = planPlatform(definePlatform({ name: "e", registry: "acme/reg", services: ["auth", "billing"] }));
207
- expect(p.envCheck).toContain('["BETTER_AUTH_SECRET","STRIPE_SECRET_KEY"]');
208
- expect(p.envCheck).toContain(".env.temp");
209
- // wrangler merge keeps the operator's database_id across a regen
210
- const merged = mergeWranglerToml(p.wranglerToml, 'name="e"\n[[d1_databases]]\nbinding = "DB"\ndatabase_id = "abc-123"');
211
- expect(merged).toContain('database_id = "abc-123"');
212
- });
213
- });
214
-
215
- describe("package.json generation — the manifest is the only surface", () => {
216
- test("buildPackageJson unions base + service deps; @suluk/* → latest, ecosystem pinned", () => {
217
- const plan = planPlatform(definePlatform({ name: "myapp", registry: "acme/reg", services: ["auth", "credits", "billing"] }));
218
- const pkg = JSON.parse(plan.packageJson);
219
- expect(pkg.name).toBe("myapp");
220
- expect(pkg.dependencies["@suluk/credits"]).toBe("latest"); // fixes flow via bun update
221
- expect(pkg.dependencies["@suluk/billing"]).toBe("latest");
222
- expect(pkg.dependencies["hono"]).toBe("^4.0.0"); // ecosystem pinned
223
- expect(pkg.dependencies["better-auth"]).toBe("^1.0.0"); // auth's dep
224
- expect(pkg.devDependencies["typescript"]).toBeDefined();
225
- expect(pkg.scripts.generate).toBe("suluk-platform");
226
- });
227
-
228
- test("mergePackageJson keeps app-added deps + scripts, baseline wins for framework deps", () => {
229
- const baseline = buildPackageJson("myapp", ["auth", "credits"]);
230
- const existing = JSON.stringify({ name: "myapp", dependencies: { "@suluk/credits": "^0.1.0", "my-product-lib": "^2.0.0" }, scripts: { deploy: "wrangler deploy" } });
231
- const merged = JSON.parse(mergePackageJson(baseline, existing));
232
- expect(merged.dependencies["my-product-lib"]).toBe("^2.0.0"); // app extra preserved
233
- expect(merged.dependencies["@suluk/credits"]).toBe("latest"); // baseline wins → stays up to date
234
- expect(merged.scripts.deploy).toBe("wrangler deploy"); // app script preserved
235
- expect(merged.scripts.typecheck).toBe("tsc --noEmit -p ."); // framework script filled in
236
- });
237
-
238
- test("planPlatform emits tsconfig + components.json", () => {
239
- const plan = planPlatform(manifest);
240
- expect(JSON.parse(plan.tsconfig).exclude).toContain("src/**/*.test.ts");
241
- expect(JSON.parse(plan.componentsJson).aliases.utils).toBe("src/lib/utils");
242
- });
243
- });
244
-
245
- function plannedAdds() {
246
- return planPlatform(manifest).adds.map((a) => `bunx shadcn@latest add ${a} --yes`);
247
- }
package/tsconfig.json DELETED
@@ -1 +0,0 @@
1
- { "extends": "../../tsconfig.base.json", "compilerOptions": { "types": ["bun"] }, "include": ["src", "test", "bin"] }