@cosmicdrift/kumiko-dev-server 0.146.4 → 0.147.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": "@cosmicdrift/kumiko-dev-server",
3
- "version": "0.146.4",
3
+ "version": "0.147.0",
4
4
  "description": "Development server bootstrap for Kumiko apps. Bundles the client, mints dev-JWTs, injects the resolved AppSchema, and seeds an admin. Not for production.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -58,8 +58,8 @@
58
58
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
59
59
  },
60
60
  "dependencies": {
61
- "@cosmicdrift/kumiko-bundled-features": "0.146.4",
62
- "@cosmicdrift/kumiko-framework": "0.146.4",
61
+ "@cosmicdrift/kumiko-bundled-features": "0.147.0",
62
+ "@cosmicdrift/kumiko-framework": "0.147.0",
63
63
  "ts-morph": "^28.0.0"
64
64
  },
65
65
  "publishConfig": {
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
+ import { composeFeatures } from "../compose-features";
2
4
  import {
3
5
  composeFileStack,
4
6
  composeGdprStack,
@@ -77,8 +79,25 @@ describe("composeStacks", () => {
77
79
  });
78
80
  });
79
81
 
80
- /** Parity: preset name-sets match blocks used in studio / money-horse / publicstatus run-config. */
81
- describe("composeStacks parity (feature names)", () => {
82
+ /** Snapshot, not a live parity check: intended block combinations, mirrored by hand from studio/money-horse/publicstatus run-configs. A drifted run-config stays green here. */
83
+ describe("composeStacks boots for real", () => {
84
+ test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
85
+ const features = composeFeatures(
86
+ [
87
+ ...composeOpsStack({ rateLimiting: true }),
88
+ ...composePagesStack(),
89
+ ...composeMailStack({ transports: ["inmemory"] }),
90
+ ...composeFileStack({ providers: ["inmemory"] }),
91
+ ...composeGdprStack({ order: "compliance-first", sessions: true }),
92
+ ...composeUserDataRightsStack(),
93
+ ],
94
+ { includeBundled: true },
95
+ );
96
+ expect(() => validateBoot(features)).not.toThrow();
97
+ });
98
+ });
99
+
100
+ describe("composeStacks intended block names (snapshot, not live parity)", () => {
82
101
  test("studio SaaS blocks are covered by presets", () => {
83
102
  const names = stackFeatureNames([
84
103
  ...composeOpsStack({ rateLimiting: true }),
@@ -135,7 +154,7 @@ describe("composeStacks parity (feature names)", () => {
135
154
  const names = stackFeatureNames([
136
155
  ...composePagesStack(),
137
156
  ...composeGdprStack({ tenantLifecycle: true }),
138
- ...composeOpsStack({ delivery: true, audit: true, jobs: true, sessions: false }),
157
+ ...composeOpsStack({ delivery: true, audit: true, jobs: true }),
139
158
  ...composeRendererStack(),
140
159
  ...composeFileStack({ providers: ["inmemory"] }),
141
160
  ...composeUserDataRightsStack(),
@@ -44,7 +44,7 @@ describe("buildFewShotCorpus — smoke", () => {
44
44
  });
45
45
  plantPackage(workdir, "samples/recipes/legacy-demo", {
46
46
  pkgName: "@cosmicdrift/kumiko-sample-legacy-demo",
47
- pkgDescription: "Identifier-ref-style legacy feature.",
47
+ pkgDescription: "Handler-object-reference-style legacy feature.",
48
48
  featureSource: buildLegacyFeature("legacyDemo"),
49
49
  });
50
50
 
@@ -293,19 +293,24 @@ defineFeature("${featureName}", (r) => {
293
293
  }
294
294
 
295
295
  function buildLegacyFeature(featureName: string): string {
296
- // Identifier-ref style — the parser refuses (entity definition is a
297
- // captured const), produces a ParseError, and the corpus marks the
298
- // entry as authoringStyle: "legacy".
296
+ // Handler-object-reference style — writeHandler receives a single
297
+ // identifier referencing a pre-built handler object rather than an
298
+ // inline string name + schema + handler. The parser has no extractor
299
+ // shape for that (it only reads a literal name as the first arg, or
300
+ // an inline object-literal form), so it produces a ParseError and the
301
+ // corpus marks the entry as authoringStyle: "legacy".
302
+ //
303
+ // Note: a captured-const *entity definition* (`r.entity("x", xEntity)`)
304
+ // used to be the legacy example here, but #998 taught the parser to
305
+ // resolve such references via a raw-ref sentinel — that idiom parses
306
+ // cleanly now and no longer exercises the legacy path.
299
307
  return `
300
- import { defineFeature, createEntity, createTextField } from "@cosmicdrift/kumiko-framework/engine";
308
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
301
309
 
302
- const itemEntity = createEntity({
303
- table: "items",
304
- fields: { title: createTextField({ required: true }) },
305
- });
310
+ const itemCreateHandler = { name: "item:create" };
306
311
 
307
312
  defineFeature("${featureName}", (r) => {
308
- r.entity("item", itemEntity);
313
+ r.writeHandler(itemCreateHandler);
309
314
  });
310
315
  `;
311
316
  }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { mergeExtraContext } from "../setup-test-stack-from-features";
3
+
4
+ // #882/4: the integration test only asserted getFeature("config") — which
5
+ // includeBundled already provides — never that presets:["config"] actually
6
+ // merges configResolver/textContent into extraContext. Unit-test the merge
7
+ // directly instead.
8
+
9
+ const fakeDeps = {
10
+ registry: {} as never,
11
+ db: { marker: "fake-db" } as never,
12
+ sseBroker: {} as never,
13
+ redis: {} as never,
14
+ };
15
+
16
+ describe("mergeExtraContext", () => {
17
+ test("no presets → returns base untouched (function passthrough)", () => {
18
+ const base = () => ({ foo: "bar" });
19
+ const merged = mergeExtraContext(base, []);
20
+ expect(merged).toBe(base);
21
+ });
22
+
23
+ test("no presets → returns base untouched (object passthrough)", () => {
24
+ const base = { foo: "bar" };
25
+ const merged = mergeExtraContext(base, []);
26
+ expect(merged).toBe(base);
27
+ });
28
+
29
+ test("config preset merges configResolver, keeping base-object fields", () => {
30
+ const merged = mergeExtraContext({ foo: "bar" }, ["config"]);
31
+ expect(typeof merged).toBe("function");
32
+ const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
33
+ expect(result["foo"]).toBe("bar");
34
+ expect(result["configResolver"]).toBeDefined();
35
+ });
36
+
37
+ test("config preset merges configResolver, keeping base-fn fields", () => {
38
+ const merged = mergeExtraContext(() => ({ fromFn: 1 }), ["config"]);
39
+ const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
40
+ expect(result["fromFn"]).toBe(1);
41
+ expect(result["configResolver"]).toBeDefined();
42
+ });
43
+
44
+ test("text-content preset merges textContent, built from deps.db", () => {
45
+ const merged = mergeExtraContext(undefined, ["text-content"]);
46
+ const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
47
+ expect(result["textContent"]).toBeDefined();
48
+ });
49
+
50
+ test("both presets merge both fields", () => {
51
+ const merged = mergeExtraContext(undefined, ["config", "text-content"]);
52
+ const result = (merged as (deps: typeof fakeDeps) => Record<string, unknown>)(fakeDeps);
53
+ expect(result["configResolver"]).toBeDefined();
54
+ expect(result["textContent"]).toBeDefined();
55
+ });
56
+ });
@@ -1,6 +1,11 @@
1
1
  import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2
2
  import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
+ // TS7's package.json exports only version.cjs at ".", so the classic
5
+ // createProgram/ScriptTarget Compiler API is unreachable via `import *
6
+ // as ts from "typescript"` anymore; ts-morph's vendored (older) compiler
7
+ // build is the only way to get that surface, so diagnostics here run
8
+ // against a different compiler than the repo's own tsc.
4
9
  import { ts } from "ts-morph";
5
10
  import { runCodegen } from "../run-codegen";
6
11
 
@@ -61,11 +61,14 @@ export type UserDataRightsStackOptions = {
61
61
  readonly includeDefaults?: boolean;
62
62
  };
63
63
 
64
+ // `sessions` lives ONLY on GdprStackOptions — composeOpsStack used to accept
65
+ // it too, and combining both presets with sessions:true double-pushed the
66
+ // "sessions" feature name into the registry, which createRegistry rejects
67
+ // with "duplicate feature" (a boot-time crash, not a warning).
64
68
  export type OpsStackOptions = {
65
69
  readonly delivery?: boolean;
66
70
  readonly audit?: boolean;
67
71
  readonly jobs?: boolean;
68
- readonly sessions?: boolean;
69
72
  readonly rateLimiting?: boolean;
70
73
  };
71
74
 
@@ -135,13 +138,11 @@ export function composeOpsStack(options: OpsStackOptions = {}): FeatureDefinitio
135
138
  const delivery = options.delivery ?? true;
136
139
  const audit = options.audit ?? true;
137
140
  const jobs = options.jobs ?? true;
138
- const sessions = options.sessions ?? false;
139
141
  const rateLimiting = options.rateLimiting ?? false;
140
142
  const out: FeatureDefinition[] = [];
141
143
  if (delivery) out.push(createDeliveryFeature());
142
144
  if (audit) out.push(createAuditFeature());
143
145
  if (jobs) out.push(createJobsFeature());
144
- if (sessions) out.push(createSessionsFeature());
145
146
  if (rateLimiting) out.push(createRateLimitingFeature());
146
147
  return out;
147
148
  }
@@ -670,6 +670,12 @@ export async function createKumikoServer(
670
670
  ...(options.effectiveFeatures !== undefined && {
671
671
  effectiveFeatures: options.effectiveFeatures,
672
672
  }),
673
+ // Wires ctx.jobRunner so write handlers' `ctx.jobRunner.dispatch(...)`
674
+ // works under the dev server (#983) — no-ops when no jobs are
675
+ // registered. `jobs: {}` (no `consumerLane`) builds an enqueuer-only
676
+ // runner — `startDevJobRunners` below stays the sole consumer/cron-
677
+ // scheduler for every lane, so `runOnBoot`/cron jobs don't double-fire.
678
+ jobs: {},
673
679
  });
674
680
  await createEventsTable(stack.db);
675
681
  await pushEntityProjectionTables(stack, stack.registry);
@@ -0,0 +1,234 @@
1
+ import { makeAuthPaths } from "@cosmicdrift/kumiko-bundled-features/auth-email-password";
2
+ import { createSmtpTransportFromEnv } from "@cosmicdrift/kumiko-bundled-features/channel-email";
3
+ import {
4
+ buildEnvConfigOverrides,
5
+ createConfigAccessorFactory,
6
+ createConfigResolver,
7
+ } from "@cosmicdrift/kumiko-bundled-features/config";
8
+ import {
9
+ collectChannels,
10
+ createDeliveryService,
11
+ DELIVERY_FEATURE,
12
+ } from "@cosmicdrift/kumiko-bundled-features/delivery";
13
+ import {
14
+ createSecretsContext,
15
+ SECRETS_FEATURE_NAME,
16
+ } from "@cosmicdrift/kumiko-bundled-features/secrets";
17
+ import {
18
+ bindAutoRevokeFromFeature,
19
+ createSessionCallbacks,
20
+ } from "@cosmicdrift/kumiko-bundled-features/sessions";
21
+ import { createTextContentApi } from "@cosmicdrift/kumiko-bundled-features/text-content";
22
+ import type { SseBroker } from "@cosmicdrift/kumiko-framework/api";
23
+ import type { KmsAdapter } from "@cosmicdrift/kumiko-framework/crypto";
24
+ import type { DbConnection } from "@cosmicdrift/kumiko-framework/db";
25
+ import type {
26
+ ConfigResolver,
27
+ FeatureDefinition,
28
+ NotifyFactory,
29
+ Registry,
30
+ } from "@cosmicdrift/kumiko-framework/engine";
31
+ import type { MasterKeyProvider } from "@cosmicdrift/kumiko-framework/secrets";
32
+ import { type BootCrypto, resolveBootCrypto } from "./boot/boot-crypto";
33
+ import type {
34
+ AuthMailOptions,
35
+ EmailVerificationSetup,
36
+ InviteSetup,
37
+ PasswordResetSetup,
38
+ SignupSetup,
39
+ } from "./run-prod-app";
40
+ import type { ProdSessionsConfig } from "./session-wiring";
41
+
42
+ // Boot-time context helpers for runProdApp: ctx-extra-context wiring
43
+ // (textContent/delivery/secrets/config-resolver), auth-mail convenience
44
+ // normalization, and prod session-auth wiring. Split out of run-prod-app.ts
45
+ // (#1005, Welle 2) — mechanical relocation, these are self-contained pure
46
+ // functions, no closure over runProdApp's local boot state.
47
+
48
+ // Shared with runDevApp (mergeConfigResolverDefault) for dev/prod parity.
49
+ export function addConfigAccessorFactory<T extends { readonly configResolver?: ConfigResolver }>(
50
+ resolved: T,
51
+ registry: Registry,
52
+ ): T {
53
+ if (!resolved.configResolver) return resolved;
54
+ return {
55
+ ...resolved,
56
+ _configAccessorFactory: createConfigAccessorFactory(registry, resolved.configResolver),
57
+ };
58
+ }
59
+
60
+ // Framework-Default-Provider für den AppContext — gleicher Mechanismus wie
61
+ // der tenantTierResolver-Autowire (findTierResolverUsage): deklarierter
62
+ // Bedarf (Feature gemountet) → Default aus db/env, App überschreibt nur die
63
+ // Ausnahme. textContent ist unbedingt (createTextContentApi wirft nie, baut
64
+ // nur einen db-gebundenen Accessor). secrets wird nur auto-verdrahtet wenn
65
+ // das secrets-Feature gemountet ist UND ein KEK tatsächlich verfügbar ist
66
+ // (masterKey-Override ODER env-KEK present) — sonst skip, damit der eager
67
+ // KEK-Detection + Provider/Cipher-Aufbau leben in boot/boot-crypto.ts
68
+ // (envHasMasterKek, resolveBootCrypto) — gemeinsam mit runDevApp.
69
+ // Prod-Misconfig (secrets gemountet, kein KEK) fängt schon secretsEnvSchema
70
+ // beim Boot; fehlt der env-Schema-Pfad, wirft requireSecretsContext beim
71
+ // ersten ctx.secrets-Zugriff mit Wiring-Hinweis. configResolver nur im
72
+ // Auth-Mode. Exportiert + pure für Unit-Tests; der merge mit App-Werten
73
+ // passiert beim Caller (App gewinnt).
74
+
75
+ // Prod/dev parity for ctx.notify: without this `_notifyFactory` is only wired
76
+ // in tests (createDeliveryTestContext), so ctx.notify is undefined at runtime
77
+ // and every notification silently skips. sseBroker optional (email/push don't
78
+ // need it, in-app SSE does); no jobRunner → queued channels send inline.
79
+ function buildDeliveryNotifyFactory(opts: {
80
+ readonly db: DbConnection;
81
+ readonly registry: Registry;
82
+ readonly sseBroker?: SseBroker;
83
+ }): NotifyFactory {
84
+ const deliveryService = createDeliveryService({
85
+ db: opts.db,
86
+ registry: opts.registry,
87
+ channels: collectChannels(opts.registry),
88
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
89
+ });
90
+ return (user, tenantId) => (notificationType, options) =>
91
+ deliveryService.notify(notificationType, options, user, tenantId);
92
+ }
93
+
94
+ export function buildBootExtraContext(opts: {
95
+ readonly db: DbConnection;
96
+ readonly features: readonly FeatureDefinition[];
97
+ readonly envSource: Record<string, string | undefined>;
98
+ readonly registry: Registry;
99
+ readonly hasAuth: boolean;
100
+ // Resolved once per boot via resolveBootCrypto — shared by secrets,
101
+ // config-resolver, config-set-handler and boot-seeds. Absent (tests
102
+ // that don't care about encryption) ⇒ resolved from envSource here.
103
+ readonly crypto?: BootCrypto;
104
+ readonly masterKey?: MasterKeyProvider;
105
+ readonly sseBroker?: SseBroker;
106
+ readonly kms?: KmsAdapter;
107
+ }): Record<string, unknown> {
108
+ const crypto = opts.crypto ?? resolveBootCrypto(opts.envSource, opts.masterKey);
109
+ const hasSecretsFeature = opts.features.some((f) => f.name === SECRETS_FEATURE_NAME);
110
+ const wireSecrets = hasSecretsFeature && crypto.masterKeyProvider !== undefined;
111
+ const hasDeliveryFeature = opts.features.some((f) => f.name === DELIVERY_FEATURE);
112
+ return {
113
+ textContent: createTextContentApi(opts.db),
114
+ ...(opts.kms && { kms: opts.kms }),
115
+ ...(hasDeliveryFeature && {
116
+ _notifyFactory: buildDeliveryNotifyFactory({
117
+ db: opts.db,
118
+ registry: opts.registry,
119
+ ...(opts.sseBroker && { sseBroker: opts.sseBroker }),
120
+ }),
121
+ }),
122
+ // Top-level provider so feature jobs (secrets rotate, config reencrypt)
123
+ // reach it via ctx — previously only test-stack wired it.
124
+ ...(crypto.masterKeyProvider && { masterKeyProvider: crypto.masterKeyProvider }),
125
+ // Encrypt/decrypt partner for `encrypted: true` config keys. Wired
126
+ // whenever a master key exists — NOT gated on the secrets feature,
127
+ // config encryption must work without mounting ctx.secrets.
128
+ ...(crypto.configCipher && { configEncryption: crypto.configCipher }),
129
+ ...(wireSecrets &&
130
+ crypto.masterKeyProvider && {
131
+ secrets: createSecretsContext({
132
+ db: opts.db,
133
+ masterKeyProvider: crypto.masterKeyProvider,
134
+ dekCache: crypto.dekCache,
135
+ }),
136
+ }),
137
+ ...(opts.hasAuth && {
138
+ configResolver: createConfigResolver({
139
+ appOverrides: buildEnvConfigOverrides(opts.registry, opts.envSource),
140
+ ...(crypto.configCipher && { cipher: crypto.configCipher }),
141
+ }),
142
+ }),
143
+ };
144
+ }
145
+
146
+ // auth.mail-Convenience → normalisiert in die expliziten passwordReset/
147
+ // emailVerification/signup/invite-Felder, BEVOR buildComposeAuthOptions
148
+ // (Feature-Side: hmacSecret/mode) und das auth-routes-Fragment sie lesen —
149
+ // so speist EIN mail-Block beide Pfade. App-explizite Flows gewinnen über
150
+ // den Default. Null-Transport-Guard: ohne SMTP_HOST-env bleibt alles
151
+ // unverdrahtet (sonst lieferten die reset/verify-Routes 500).
152
+ /** Die Auth-Felder die resolveAuthMail liest/normalisiert — beide
153
+ * App-Auth-Typen (prod + dev) erfüllen das strukturell. */
154
+ type AuthMailNormalizable = {
155
+ readonly mail?: AuthMailOptions;
156
+ readonly passwordReset?: PasswordResetSetup;
157
+ readonly emailVerification?: EmailVerificationSetup;
158
+ readonly signup?: SignupSetup;
159
+ readonly invite?: InviteSetup;
160
+ };
161
+
162
+ export function resolveAuthMail<T extends AuthMailNormalizable>(
163
+ auth: T,
164
+ hmacSecret: string,
165
+ envSource: Record<string, string | undefined>,
166
+ ): T {
167
+ if (!auth.mail) return auth;
168
+ // SMTP-presence gate: ohne SMTP_HOST-env wird KEIN Flow verdrahtet (Routes
169
+ // blieben sonst 500). Der eigentliche Mail-Versand läuft über delivery
170
+ // (channel-email), nicht über diesen Transport — er ist nur der Detektor
171
+ // "ist Mail konfiguriert?".
172
+ if (
173
+ !createSmtpTransportFromEnv(envSource, { fallbackFrom: auth.mail.from ?? "noreply@localhost" })
174
+ ) {
175
+ return auth;
176
+ }
177
+ const paths = makeAuthPaths(auth.mail.paths);
178
+ // appName/locale fließen in alle vier Flow-Options (alle mailen via delivery).
179
+ const mailPresentation = {
180
+ ...(auth.mail.appName !== undefined && { appName: auth.mail.appName }),
181
+ ...(auth.mail.locale !== undefined && { locale: auth.mail.locale }),
182
+ };
183
+ return {
184
+ ...auth,
185
+ passwordReset: auth.passwordReset ?? {
186
+ hmacSecret,
187
+ appUrl: `${auth.mail.baseUrl}${paths.resetPassword}`,
188
+ ...mailPresentation,
189
+ },
190
+ emailVerification: auth.emailVerification ?? {
191
+ hmacSecret,
192
+ appUrl: `${auth.mail.baseUrl}${paths.verifyEmail}`,
193
+ ...(auth.mail.emailVerificationMode !== undefined && {
194
+ mode: auth.mail.emailVerificationMode,
195
+ }),
196
+ ...mailPresentation,
197
+ },
198
+ signup: auth.signup ?? {
199
+ appUrl: `${auth.mail.baseUrl}${paths.signupComplete}`,
200
+ ...mailPresentation,
201
+ },
202
+ invite: auth.invite ?? {
203
+ appUrl: `${auth.mail.baseUrl}${paths.inviteAccept}`,
204
+ ...mailPresentation,
205
+ },
206
+ };
207
+ }
208
+
209
+ export function buildProdSessionAuth(
210
+ db: DbConnection,
211
+ opts: ProdSessionsConfig,
212
+ sessionsFeature: FeatureDefinition | undefined,
213
+ ): {
214
+ readonly sessionCreator: ReturnType<typeof createSessionCallbacks>["sessionCreator"];
215
+ readonly sessionRevoker: ReturnType<typeof createSessionCallbacks>["sessionRevoker"];
216
+ readonly sessionChecker: ReturnType<typeof createSessionCallbacks>["sessionChecker"];
217
+ readonly sessionStrictMode: true;
218
+ } {
219
+ const cbs = createSessionCallbacks({
220
+ db,
221
+ ...(opts.expiresInMs !== undefined && { expiresInMs: opts.expiresInMs }),
222
+ });
223
+ // Secure-by-default: password-change/-reset mass-revokes the user's live
224
+ // sessions without the app opting in via autoRevokeOnPasswordChange.
225
+ if (sessionsFeature) {
226
+ bindAutoRevokeFromFeature(sessionsFeature)?.(cbs.sessionMassRevoker);
227
+ }
228
+ return {
229
+ sessionCreator: cbs.sessionCreator,
230
+ sessionRevoker: cbs.sessionRevoker,
231
+ sessionChecker: cbs.sessionChecker,
232
+ sessionStrictMode: true,
233
+ };
234
+ }