@cosmicdrift/kumiko-dev-server 0.158.2 → 0.160.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.158.2",
3
+ "version": "0.160.0",
4
4
  "description": "Dev-tooling for Kumiko apps: local dev-server bootstrap (runDevApp), scaffolding, codegen. Not shipped into production node_modules — see @cosmicdrift/kumiko-server-runtime for the prod boot path.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -54,9 +54,9 @@
54
54
  "kumiko-schema-check": "./bin/kumiko-schema-check.ts"
55
55
  },
56
56
  "dependencies": {
57
- "@cosmicdrift/kumiko-bundled-features": "0.158.2",
58
- "@cosmicdrift/kumiko-framework": "0.158.2",
59
- "@cosmicdrift/kumiko-server-runtime": "0.158.2",
57
+ "@cosmicdrift/kumiko-bundled-features": "0.160.0",
58
+ "@cosmicdrift/kumiko-framework": "0.160.0",
59
+ "@cosmicdrift/kumiko-server-runtime": "0.160.0",
60
60
  "ts-morph": "^28.0.0"
61
61
  },
62
62
  "publishConfig": {
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
3
+ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
2
4
  import { validateBoot } from "@cosmicdrift/kumiko-framework/engine";
3
5
  import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";
4
6
  import {
@@ -110,9 +112,15 @@ describe("composeStacks", () => {
110
112
  expect(names.filter((n) => n === "sessions")).toHaveLength(2);
111
113
  expect(() =>
112
114
  validateBoot(
113
- composeFeatures([...composeIdentityStack(), ...composeGdprStack({ sessions: true })], {
114
- includeBundled: true,
115
- }),
115
+ composeFeatures(
116
+ [
117
+ authFoundationFeature,
118
+ createPersonalAccessTokensFeature({ scopes: {} }),
119
+ ...composeIdentityStack(),
120
+ ...composeGdprStack({ sessions: true }),
121
+ ],
122
+ { includeBundled: true },
123
+ ),
116
124
  ),
117
125
  ).toThrow(/duplicate feature/i);
118
126
  });
@@ -123,6 +131,8 @@ describe("composeStacks boots for real", () => {
123
131
  test("studio-shaped combined stack passes validateBoot (not just name-list comparison)", () => {
124
132
  const features = composeFeatures(
125
133
  [
134
+ authFoundationFeature,
135
+ createPersonalAccessTokensFeature({ scopes: {} }),
126
136
  ...composeOpsStack({ rateLimiting: true }),
127
137
  ...composePagesStack(),
128
138
  ...composeMailStack({ transports: ["inmemory"] }),
@@ -0,0 +1,63 @@
1
+ // createKumikoServer error / graceful-degradation paths — boot rejects,
2
+ // stylesheet pipeline failures, missing CSS route.
3
+
4
+ import { afterEach, describe, expect, test } from "bun:test";
5
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
6
+ import { tmpdir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
9
+ import { createKumikoServer, type KumikoServerHandle } from "../create-kumiko-server";
10
+
11
+ const emptyFeature = defineFeature("dev-server-errors-probe", () => {});
12
+
13
+ let handle: KumikoServerHandle | undefined;
14
+
15
+ afterEach(async () => {
16
+ if (handle) {
17
+ await handle.stop();
18
+ handle = undefined;
19
+ }
20
+ });
21
+
22
+ describe("createKumikoServer — client bundle failure", () => {
23
+ test("broken clientEntry rejects at boot with client bundle failed", async () => {
24
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-bundle-fail-"));
25
+ const entry = join(tmpDir, "client.tsx");
26
+ writeFileSync(entry, "const x = {{{\n");
27
+ try {
28
+ await expect(
29
+ createKumikoServer({
30
+ features: [emptyFeature],
31
+ port: 0,
32
+ installSignalHandlers: false,
33
+ clientEntry: entry,
34
+ stylesheet: false,
35
+ }),
36
+ ).rejects.toThrow(/client bundle failed|Bundle failed/);
37
+ } finally {
38
+ rmSync(tmpDir, { recursive: true, force: true });
39
+ }
40
+ });
41
+
42
+ test("_buildBundle throw propagates at boot", async () => {
43
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-stub-fail-"));
44
+ const entry = join(tmpDir, "client.tsx");
45
+ writeFileSync(entry, "// noop\n");
46
+ try {
47
+ await expect(
48
+ createKumikoServer({
49
+ features: [emptyFeature],
50
+ port: 0,
51
+ installSignalHandlers: false,
52
+ clientEntry: entry,
53
+ stylesheet: false,
54
+ _buildBundle: async () => {
55
+ throw new Error("stub build blew up");
56
+ },
57
+ }),
58
+ ).rejects.toThrow(/stub build blew up/);
59
+ } finally {
60
+ rmSync(tmpDir, { recursive: true, force: true });
61
+ }
62
+ });
63
+ });
@@ -1,5 +1,5 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
2
+ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
3
3
  import { tmpdir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { asRawClient } from "@cosmicdrift/kumiko-framework/bun-db";
@@ -336,3 +336,123 @@ describe("createKumikoServer extraRoutes-deps", () => {
336
336
  expect(body.data?.roles).toContain("SystemAdmin");
337
337
  });
338
338
  });
339
+
340
+ describe("createKumikoServer — stylesheet tailwind failure (graceful)", () => {
341
+ test("missing stylesheet entry boots without CSS — GET /styles.css → 404", async () => {
342
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-css-fail-"));
343
+ const entry = join(tmpDir, "client.tsx");
344
+ writeFileSync(entry, "export const x = 1;\n");
345
+ try {
346
+ handle = await createKumikoServer({
347
+ features: [probeFeature],
348
+ port: 0,
349
+ installSignalHandlers: false,
350
+ clientEntry: entry,
351
+ stylesheet: join(tmpDir, "does-not-exist.css"),
352
+ _buildBundle: async () => ({ js: "// stub", map: "" }),
353
+ });
354
+ const cssRes = await handle.fetch(new Request("http://localhost/styles.css"));
355
+ expect(cssRes.status).toBe(404);
356
+ expect(await cssRes.text()).toBe("no stylesheet");
357
+ } finally {
358
+ rmSync(tmpDir, { recursive: true, force: true });
359
+ }
360
+ });
361
+ });
362
+
363
+ describe("createKumikoServer — real Bun.build (buildClient)", () => {
364
+ test("clientEntry without _buildBundle produces a JS bundle via Bun.build", async () => {
365
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-real-build-"));
366
+ const entry = join(tmpDir, "client.tsx");
367
+ // Minimal entry Bun.build can emit — no JSX, no imports.
368
+ writeFileSync(entry, "export const ping = 1;\n");
369
+ // Empty dirs matching a glob — exercises expandWatchPatterns without
370
+ // writing files (avoids process.exit(75) from bare .tsx events).
371
+ mkdirSync(join(tmpDir, "pkg-a"));
372
+ mkdirSync(join(tmpDir, "pkg-b"));
373
+ try {
374
+ handle = await createKumikoServer({
375
+ features: [probeFeature],
376
+ port: 0,
377
+ installSignalHandlers: false,
378
+ clientEntry: entry,
379
+ stylesheet: false,
380
+ watchDirs: [join(tmpDir, "pkg-*")],
381
+ });
382
+ const res = await handle.fetch(new Request("http://localhost/client.js"));
383
+ expect(res.status).toBe(200);
384
+ expect(res.headers.get("content-type")).toMatch(/application\/javascript/);
385
+ const body = await res.text();
386
+ expect(body.length).toBeGreaterThan(0);
387
+ expect(body).toMatch(/ping/);
388
+ } finally {
389
+ rmSync(tmpDir, { recursive: true, force: true });
390
+ }
391
+ });
392
+ });
393
+
394
+ describe("createKumikoServer — hot-reload broadcast", () => {
395
+ test("file change under web/ rebuilds and broadcasts SSE reload", async () => {
396
+ const tmpDir = mkdtempSync(join(tmpdir(), "kumiko-watch-"));
397
+ const entry = join(tmpDir, "client.tsx");
398
+ const webDir = join(tmpDir, "web");
399
+ mkdirSync(webDir);
400
+ writeFileSync(entry, "export const ping = 1;\n");
401
+
402
+ let builds = 0;
403
+ try {
404
+ handle = await createKumikoServer({
405
+ features: [probeFeature],
406
+ port: 0,
407
+ installSignalHandlers: false,
408
+ clientEntry: entry,
409
+ stylesheet: false,
410
+ // Only the entry dir is watched (no extra watchDirs) — a nested
411
+ // web/page.tsx event arrives as "web/page.tsx" → hot-reload.
412
+ // Watching web/ separately would fire bare "page.tsx" → restart
413
+ // → process.exit(75) and kill the test runner.
414
+ _buildBundle: async () => {
415
+ builds += 1;
416
+ return { js: `// build-${builds}`, map: "" };
417
+ },
418
+ });
419
+
420
+ const sseRes = await handle.fetch(new Request("http://localhost/_reload"));
421
+ expect(sseRes.status).toBe(200);
422
+ const reader = sseRes.body?.getReader();
423
+ expect(reader).toBeDefined();
424
+ if (!reader) return;
425
+
426
+ await reader.read(); // drain connected comment
427
+
428
+ const initialBuilds = builds;
429
+ writeFileSync(join(webDir, "page.tsx"), "export const x = 1;\n");
430
+
431
+ const deadline = Date.now() + 3000;
432
+ let sawReload = false;
433
+ while (Date.now() < deadline && !sawReload) {
434
+ const readPromise = reader.read();
435
+ const timeout = new Promise<{ done: true; value: undefined }>((resolve) =>
436
+ setTimeout(() => resolve({ done: true, value: undefined }), 200),
437
+ );
438
+ const { value, done } = await Promise.race([readPromise, timeout]);
439
+ if (done || value === undefined) continue;
440
+ const chunk = new TextDecoder().decode(value);
441
+ if (chunk.includes("event: reload")) sawReload = true;
442
+ }
443
+ await reader.cancel();
444
+
445
+ expect(builds).toBeGreaterThan(initialBuilds);
446
+ expect(sawReload).toBe(true);
447
+
448
+ const js = await handle.fetch(new Request("http://localhost/client.js"));
449
+ expect(await js.text()).toMatch(/build-/);
450
+
451
+ // Abort watchers before teardown rmSync can fire a restart event.
452
+ await handle.stop();
453
+ handle = undefined;
454
+ } finally {
455
+ rmSync(tmpDir, { recursive: true, force: true });
456
+ }
457
+ });
458
+ });
@@ -64,6 +64,22 @@ describe("resolveStylesheet", () => {
64
64
  }
65
65
  });
66
66
 
67
+ test("Bun.resolveSync failure → undefined (catch path, no throw)", () => {
68
+ const tmpDir = realpathSync(mkdtempSync(join(tmpdir(), "kumiko-resolve-catch-")));
69
+ const cwdBefore = process.cwd();
70
+ process.chdir(tmpDir);
71
+ try {
72
+ const out = resolveStylesheet({
73
+ features: [],
74
+ clientEntry: "./entry.tsx",
75
+ });
76
+ expect(out).toBeUndefined();
77
+ } finally {
78
+ process.chdir(cwdBefore);
79
+ rmSync(tmpDir, { recursive: true, force: true });
80
+ }
81
+ });
82
+
67
83
  test("undefined + clientEntry + src/styles.css existiert → returns App-Theme-Override", () => {
68
84
  // Auto-Detection greift VOR dem renderer-web-Fallback: Wenn die App
69
85
  // ein eigenes src/styles.css hat (App-Theme-Pattern), wird das
@@ -12,6 +12,7 @@ import {
12
12
  seedAdmin,
13
13
  seedUserWithPassword,
14
14
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
15
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
15
16
  import {
16
17
  AuthMfaHandlers,
17
18
  base32Decode,
@@ -94,6 +95,13 @@ async function markVerified(userId: string): Promise<void> {
94
95
  await updateRows(stack.db, userTable, { emailVerified: true }, { id: userId });
95
96
  }
96
97
 
98
+ // writeOk drives real HTTP through the auth middleware, so a hand-built
99
+ // actor needs a live sid — sessionChecker rejects sidless JWTs.
100
+ async function withSession(user: SessionUser): Promise<SessionUser> {
101
+ const sid = await callbacks.get().sessionCreator(user, { ip: "127.0.0.1", userAgent: "test" });
102
+ return { ...user, sid };
103
+ }
104
+
97
105
  beforeAll(async () => {
98
106
  configureEntityFieldEncryption(createTestEnvelopeCipher());
99
107
  const bound = sessionCallbacksFromLateBound(callbacks);
@@ -110,6 +118,7 @@ beforeAll(async () => {
110
118
 
111
119
  const features = composeFeatures(
112
120
  [
121
+ authFoundationFeature,
113
122
  ...identity,
114
123
  ...composeOpsStack({ delivery: true, audit: false, jobs: false }),
115
124
  ...composeRendererStack(),
@@ -147,8 +156,8 @@ beforeAll(async () => {
147
156
  }),
148
157
  authConfig: {
149
158
  ...bound.asAuthConfig(),
150
- // No sessionStrictMode: seed/writeOk uses TestUsers JWTs without sid.
151
- // Login still gets jti via sessionCreator (asserted below).
159
+ // sessionChecker rejects sidless JWTs hand-built actors used with
160
+ // writeOk go through withSession() to get a live sid first.
152
161
  membershipQuery: "tenant:query:memberships",
153
162
  loginHandler: AuthHandlers.login,
154
163
  mfaVerifyHandler: AuthMfaHandlers.verify,
@@ -293,7 +302,7 @@ describe("saas-identity-wire", () => {
293
302
  });
294
303
  await markVerified(adminId);
295
304
 
296
- const admin: SessionUser = { id: adminId, tenantId, roles: ["Admin"] };
305
+ const admin = await withSession({ id: adminId, tenantId, roles: ["Admin"] });
297
306
  const invitee = "carol-wire@example.com";
298
307
  await stack.http.writeOk(AuthHandlers.inviteCreate, { email: invitee, role: "User" }, admin);
299
308
  expect(emailTransport.sent.length).toBeGreaterThanOrEqual(1);
@@ -368,7 +377,7 @@ describe("saas-identity-wire", () => {
368
377
  ],
369
378
  });
370
379
  await markVerified(userId);
371
- const user: SessionUser = { id: userId, tenantId, roles: ["User"] };
380
+ const user = await withSession({ id: userId, tenantId, roles: ["User"] });
372
381
 
373
382
  const start = await stack.http.writeOk<{ setupToken: string; otpauthUri: string }>(
374
383
  AuthMfaHandlers.enableStart,
@@ -92,6 +92,7 @@ describe("scaffoldApp", () => {
92
92
 
93
93
  const kumikoBin = readFileSync(join(dest, "bin/kumiko.ts"), "utf-8");
94
94
  expect(kumikoBin).toContain("runSchemaCli");
95
+ expect(kumikoBin).toContain("runConsumerCli");
95
96
  expect(kumikoBin).toContain("includeBundled: HAS_AUTH");
96
97
  });
97
98
 
@@ -13,6 +13,8 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
13
13
  import { mkdtempSync, readFileSync, rmSync } from "node:fs";
14
14
  import { tmpdir } from "node:os";
15
15
  import { join } from "node:path";
16
+ import { authFoundationFeature } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
17
+ import { createPersonalAccessTokensFeature } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
16
18
  import { createSecretsFeature } from "@cosmicdrift/kumiko-bundled-features/secrets";
17
19
  import { createSessionsFeature } from "@cosmicdrift/kumiko-bundled-features/sessions";
18
20
  import { createRegistry, defineFeature, validateBoot } from "@cosmicdrift/kumiko-framework/engine";
@@ -77,18 +79,32 @@ describe("walkthrough — DX-3.1 snapshot", () => {
77
79
  // a dummy defineFeature here — the scaffold-side of "notesFeature"
78
80
  // (file-content) is pinned in test 2; this test pins the runtime-side
79
81
  // (composeFeatures auto-prepend behaviour the walkthrough claims).
82
+ //
83
+ // sessions now requires auth-foundation (#1370/#1371), which itself
84
+ // needs a tokenVerifier provider mounted (#1368) — PAT here, mirroring
85
+ // the scaffold picker's real-world default. Not part of the walkthrough's
86
+ // advertised 7-feature count (that count is about includeBundled's
87
+ // auto-mount, unrelated to this coupling), so both are added on top.
80
88
  const notesFeature = defineFeature("notes", () => {});
81
- const APP_FEATURES = [createSecretsFeature(), createSessionsFeature(), notesFeature];
89
+ const APP_FEATURES = [
90
+ createSecretsFeature(),
91
+ authFoundationFeature,
92
+ createPersonalAccessTokensFeature({ scopes: {} }),
93
+ createSessionsFeature(),
94
+ notesFeature,
95
+ ];
82
96
 
83
97
  const composed = composeFeatures(APP_FEATURES, { includeBundled: true });
84
- // 3 explicit + 4 auto-mounted bundled = 7 total features.
85
- expect(composed.length).toBe(7);
98
+ // 5 explicit + 4 auto-mounted bundled = 9 total features.
99
+ expect(composed.length).toBe(9);
86
100
 
87
101
  const composedNames = composed.map((f) => f.name).sort();
88
102
  expect(composedNames).toEqual([
89
103
  "auth-email-password",
104
+ "auth-foundation",
90
105
  "config",
91
106
  "notes",
107
+ "personal-access-tokens",
92
108
  "secrets",
93
109
  "sessions",
94
110
  "tenant",
@@ -97,9 +113,9 @@ describe("walkthrough — DX-3.1 snapshot", () => {
97
113
 
98
114
  // validateBoot must pass (no missing-requires, no schema-errors).
99
115
  expect(() => validateBoot(composed)).not.toThrow();
100
- // Registry must contain all 7 features.
116
+ // Registry must contain all 9 features.
101
117
  const registry = createRegistry(composed);
102
- expect(registry.features.size).toBe(7);
118
+ expect(registry.features.size).toBe(9);
103
119
  });
104
120
 
105
121
  test("bin/main.ts contains the auth.admin stub the walkthrough relies on", async () => {
@@ -18,6 +18,7 @@ import {
18
18
  type SeedAdminOptions,
19
19
  seedAdmin,
20
20
  } from "@cosmicdrift/kumiko-bundled-features/auth-email-password/seeding";
21
+ import { resolveTokenVerifier } from "@cosmicdrift/kumiko-bundled-features/auth-foundation";
21
22
  import {
22
23
  AUTH_MFA_FEATURE,
23
24
  AuthMfaHandlers,
@@ -28,10 +29,8 @@ import {
28
29
  createConfigResolver,
29
30
  } from "@cosmicdrift/kumiko-bundled-features/config";
30
31
  import {
31
- createPatResolver,
32
32
  PAT_FEATURE,
33
33
  patRateLimitFromFeature,
34
- patScopesFromFeature,
35
34
  } from "@cosmicdrift/kumiko-bundled-features/personal-access-tokens";
36
35
  import {
37
36
  bindAutoRevokeFromFeature,
@@ -44,7 +43,7 @@ import {
44
43
  resolveTenantLifecycleGate,
45
44
  TENANT_LIFECYCLE_FEATURE,
46
45
  } from "@cosmicdrift/kumiko-bundled-features/tenant-lifecycle";
47
- import type { PatResolver, SessionMetadata } from "@cosmicdrift/kumiko-framework/api";
46
+ import type { SessionMetadata, TokenVerifier } from "@cosmicdrift/kumiko-framework/api";
48
47
  import { createInMemoryLoginRateLimiter } from "@cosmicdrift/kumiko-framework/api";
49
48
  import {
50
49
  configureBlindIndexKey,
@@ -89,6 +88,7 @@ import { renderWelcomeBanner } from "./welcome-banner";
89
88
  // @cosmicdrift/kumiko-server-runtime (single source of truth) — hier nur
90
89
  // durchgereicht.
91
90
  export type {
91
+ AccountUnlockSetup,
92
92
  AuthMailOptions,
93
93
  EmailVerificationSetup,
94
94
  InviteSetup,
@@ -97,6 +97,7 @@ export type {
97
97
  } from "@cosmicdrift/kumiko-server-runtime/run-prod-app";
98
98
 
99
99
  import type {
100
+ AccountUnlockSetup,
100
101
  AuthMailOptions,
101
102
  EmailVerificationSetup,
102
103
  InviteSetup,
@@ -120,7 +121,7 @@ export type RunDevAppAuthOptions = {
120
121
  /** Opt-in: revocable server-side sessions. Caller MUSS
121
122
  * `createSessionsFeature()` zu `features` adden — runDevApp wired
122
123
  * hier nur die Auth-Callbacks (creator/revoker/checker) gegen
123
- * stack.db, plus sessionStrictMode=true.
124
+ * stack.db (sidless JWTs werden dann abgelehnt).
124
125
  *
125
126
  * Standardverhalten ohne diese Option: stateless JWTs ohne sid,
126
127
  * Logout ist client-side cookie-clear, Karten­haus existing-Apps
@@ -145,6 +146,8 @@ export type RunDevAppAuthOptions = {
145
146
  readonly signup?: SignupSetup;
146
147
  /** Tenant-Invite flow (Magic-Link). Symmetric. */
147
148
  readonly invite?: InviteSetup;
149
+ /** Account-unlock flow (#1266). Symmetric zu RunProdAppAuthOptions. */
150
+ readonly accountUnlock?: AccountUnlockSetup;
148
151
  /** Domain attribute for both auth cookies (see
149
152
  * AuthRoutesConfig.cookieDomain). Symmetric zu RunProdAppAuthOptions. */
150
153
  readonly cookieDomain?: string;
@@ -406,21 +409,21 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
406
409
  }
407
410
  return sessionCallbacks;
408
411
  };
409
- // PAT opt-in: same late-bound holder pattern — the resolver needs the real
410
- // db (only concrete after setupTestStack). Wired when the feature is mounted;
411
- // scopes come from the feature's exports (single source with its handlers).
412
- let patResolver: PatResolver | undefined;
412
+ // Token-verifier opt-in: same late-bound holder pattern — resolveTokenVerifier
413
+ // needs the real db+registry (only concrete after setupTestStack). Wired
414
+ // whenever a provider feature (personal-access-tokens today) is mounted.
415
+ let tokenVerifier: TokenVerifier | undefined;
413
416
  let lifecycleDb: DbConnection | undefined;
414
417
  const patFeature = features.find((f) => f.name === PAT_FEATURE);
415
418
  const mfaFeature = features.find((f) => f.name === AUTH_MFA_FEATURE);
416
419
  const tenantLifecycleFeature = features.find((f) => f.name === TENANT_LIFECYCLE_FEATURE);
417
420
  const patAuthFragment = patFeature
418
421
  ? {
419
- patResolver: (rawToken: string) => {
420
- if (!patResolver) {
421
- throw new Error("[runDevApp] pat-resolver accessed before onAfterSetup");
422
+ tokenVerifier: (rawToken: string) => {
423
+ if (!tokenVerifier) {
424
+ throw new Error("[runDevApp] token-verifier accessed before onAfterSetup");
422
425
  }
423
- return patResolver(rawToken);
426
+ return tokenVerifier(rawToken);
424
427
  },
425
428
  patRateLimiter: (() => {
426
429
  const rl = patRateLimitFromFeature(patFeature);
@@ -449,11 +452,6 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
449
452
  sessionRevoker: (sid: string) => requireSessions().sessionRevoker(sid),
450
453
  sessionChecker: (sid: string, userId: string) =>
451
454
  requireSessions().sessionChecker(sid, userId),
452
- // strict-mode: jede neue Plattform-App startet ohne legacy-
453
- // JWTs ohne sid, daher safe als Default. Wer Sessions opt-in
454
- // wählt, will explizite Server-side Revocation — strict-mode
455
- // ist der einzige Modus der das tatsächlich erzwingt.
456
- sessionStrictMode: true,
457
455
  }
458
456
  : {};
459
457
 
@@ -509,6 +507,12 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
509
507
  confirmHandler: AuthHandlers.verifyEmail,
510
508
  },
511
509
  }),
510
+ ...(effectiveAuth.accountUnlock && {
511
+ accountUnlock: {
512
+ requestHandler: AuthHandlers.requestAccountUnlock,
513
+ confirmHandler: AuthHandlers.confirmAccountUnlock,
514
+ },
515
+ }),
512
516
  ...(effectiveAuth.signup && {
513
517
  signup: {
514
518
  requestHandler: AuthHandlers.signupRequest,
@@ -558,7 +562,8 @@ export async function runDevApp(options: RunDevAppOptions): Promise<KumikoServer
558
562
  }
559
563
  }
560
564
  if (patFeature) {
561
- patResolver = createPatResolver({ db: stack.db, scopes: patScopesFromFeature(patFeature) });
565
+ tokenVerifier = (rawToken) =>
566
+ resolveTokenVerifier({ db: stack.db, registry: stack.registry }, rawToken);
562
567
  }
563
568
  if (effectiveAuth) {
564
569
  await seedAdmin(stack.db, effectiveAuth.admin);
@@ -42,6 +42,8 @@ export type ScaffoldFeatureEntry = {
42
42
  readonly importPath: string;
43
43
  readonly exportName: string;
44
44
  readonly callExpression: string;
45
+ /** Runtime args for factory-style exports that need config the codegen text can't parse back out (e.g. `{ scopes: {} }`). Omit for zero-arg factories and object-style exports. */
46
+ readonly callArgs?: readonly unknown[];
45
47
  };
46
48
 
47
49
  export type ScaffoldAppOptions = {
@@ -765,7 +767,7 @@ deploys. Build context = app repo root; migrations ship in \`kumiko/migrations/\
765
767
  - \`kumiko/schema.ts\` — same feature set → \`ENTITY_METAS\` for \`kumiko schema\`.
766
768
  - \`bin/dev.ts\` — dev-server entry (\`bun dev\`).
767
769
  - \`bin/main.ts\` — production-bootstrap (\`bun run start\`).
768
- - \`bin/kumiko.ts\` — schema-CLI bundled into \`dist-server/kumiko.js\`.
770
+ - \`bin/kumiko.ts\` — schema + consumer-ops CLI bundled into \`dist-server/kumiko.js\`.
769
771
  - \`docker-compose.yml\` — local Postgres + Redis for \`bun dev\`.
770
772
 
771
773
  For full docs see https://docs.kumiko.rocks.
@@ -806,25 +808,32 @@ function renderBinKumiko(): string {
806
808
  return [
807
809
  "#!/usr/bin/env bun",
808
810
  "",
809
- "// Standalone kumiko schema-CLI for the production bundle. The deploy",
810
- "// migrate-step runs `bun /app/kumiko.js schema apply`; kumiko-build bundles",
811
- "// this file to dist-server/kumiko.js.",
811
+ "// Standalone kumiko CLI for the production bundle. The deploy migrate-step",
812
+ "// runs `bun /app/kumiko.js schema apply`; ops runs `bun /app/kumiko.js",
813
+ "// consumer status|restart <name>` to recover a dead event consumer without",
814
+ "// ad-hoc SQL. kumiko-build bundles this file to dist-server/kumiko.js.",
812
815
  "",
813
816
  'import { composeFeatures } from "@cosmicdrift/kumiko-server-runtime/compose-features";',
817
+ 'import { runConsumerCli } from "@cosmicdrift/kumiko-framework/consumer-cli";',
814
818
  'import { runSchemaCli } from "@cosmicdrift/kumiko-framework/schema-cli";',
815
819
  'import { APP_FEATURES, HAS_AUTH } from "../src/run-config";',
816
820
  "",
817
821
  "const [, , cmd, ...rest] = Bun.argv;",
818
- 'if (cmd !== "schema") {',
819
- " // biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
820
- ' console.error("\\n Unknown: kumiko " + (cmd ?? "") + " — only \'kumiko schema <sub>\' in the standalone bundle.\\n");',
821
- " process.exit(1);",
822
+ "// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
823
+ "const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };",
824
+ "",
825
+ 'if (cmd === "schema") {',
826
+ " const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
827
+ " process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features }));",
828
+ "}",
829
+ "",
830
+ 'if (cmd === "consumer") {',
831
+ " process.exit(await runConsumerCli(rest, out));",
822
832
  "}",
823
833
  "",
824
- "const features = composeFeatures([...APP_FEATURES], { includeBundled: HAS_AUTH });",
825
834
  "// biome-ignore lint/suspicious/noConsole: CLI output is the feature.",
826
- "const out = { log: (l: string) => console.log(l), err: (l: string) => console.error(l) };",
827
- "process.exit(await runSchemaCli(rest, process.env.INIT_CWD ?? process.cwd(), out, { features }));",
835
+ 'console.error("\\n Unknown: kumiko " + (cmd ?? "") + " only \'kumiko schema <sub>\' or \'kumiko consumer <sub>\' in the standalone bundle.\\n");',
836
+ "process.exit(1);",
828
837
  "",
829
838
  ].join("\n");
830
839
  }
@@ -843,11 +852,8 @@ async function instantiateScaffoldFeatures(
843
852
  `scaffoldApp: ${entry.importPath} missing export ${entry.exportName} for ${entry.callExpression}`,
844
853
  );
845
854
  }
846
- if (entry.callExpression.endsWith("()")) {
847
- if (typeof exp !== "function") {
848
- throw new Error(`scaffoldApp: ${entry.exportName} is not callable (${entry.importPath})`);
849
- }
850
- instances.push((exp as () => FeatureDefinition)());
855
+ if (typeof exp === "function") {
856
+ instances.push((exp as (...args: unknown[]) => FeatureDefinition)(...(entry.callArgs ?? [])));
851
857
  } else {
852
858
  instances.push(exp as FeatureDefinition);
853
859
  }