@cosmicdrift/kumiko-framework 0.37.0 → 0.39.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.
Files changed (57) hide show
  1. package/package.json +2 -2
  2. package/src/api/__tests__/auth-routes-cookie.test.ts +79 -0
  3. package/src/api/auth-routes.ts +36 -8
  4. package/src/api/routes.ts +2 -7
  5. package/src/api/server.ts +1 -1
  6. package/src/bun-db/connection.ts +1 -0
  7. package/src/bun-db/index.ts +0 -1
  8. package/src/db/__tests__/schema-migration.integration.test.ts +1 -1
  9. package/src/db/__tests__/table-builder-meta-lockstep.test.ts +42 -1
  10. package/src/db/__tests__/tenant-db-where-merge.test.ts +34 -0
  11. package/src/db/collect-table-metas.ts +2 -2
  12. package/src/db/connection.ts +1 -0
  13. package/src/db/dialect.ts +16 -3
  14. package/src/db/event-store-executor.ts +29 -0
  15. package/src/db/index.ts +1 -0
  16. package/src/db/query.ts +1 -0
  17. package/src/db/tenant-db.ts +14 -4
  18. package/src/engine/__tests__/build-app-schema.test.ts +31 -3
  19. package/src/engine/__tests__/engine.test.ts +3 -3
  20. package/src/engine/__tests__/hook-phases.test.ts +5 -5
  21. package/src/engine/__tests__/lifecycle-hooks.test.ts +8 -8
  22. package/src/engine/__tests__/post-query-hook.test.ts +3 -3
  23. package/src/engine/__tests__/validation-hooks.test.ts +2 -2
  24. package/src/engine/boot-validator/entity-handler.ts +14 -11
  25. package/src/engine/boot-validator/ownership.ts +1 -1
  26. package/src/engine/boot-validator/pii-retention.ts +1 -1
  27. package/src/engine/boot-validator/screens-nav.ts +9 -6
  28. package/src/engine/build-app-schema.ts +42 -12
  29. package/src/engine/create-app.ts +1 -1
  30. package/src/engine/define-feature.ts +5 -1
  31. package/src/engine/feature-ast/extractors/round4.ts +1 -0
  32. package/src/engine/index.ts +2 -0
  33. package/src/engine/registry.ts +4 -3
  34. package/src/engine/steps/unsafe-projection-upsert.ts +4 -15
  35. package/src/engine/types/feature.ts +7 -3
  36. package/src/engine/types/hooks.ts +15 -11
  37. package/src/engine/types/screen.ts +2 -7
  38. package/src/engine/validate-projection-allowlist.ts +1 -1
  39. package/src/engine/validation.ts +1 -1
  40. package/src/errors/index.ts +1 -1
  41. package/src/errors/to-kumiko-error.ts +8 -0
  42. package/src/event-store/archive.ts +1 -0
  43. package/src/event-store/event-store.ts +1 -16
  44. package/src/event-store/index.ts +1 -0
  45. package/src/event-store/row-to-stored-event.ts +34 -0
  46. package/src/logging/__tests__/fallback-logger.test.ts +5 -5
  47. package/src/logging/utils.ts +1 -1
  48. package/src/migrations/projection-table-index.ts +3 -15
  49. package/src/pipeline/__tests__/archive-stream.integration.test.ts +75 -0
  50. package/src/pipeline/dispatcher-utils.ts +2 -12
  51. package/src/pipeline/event-consumer-state.ts +1 -0
  52. package/src/pipeline/event-dispatcher.ts +21 -42
  53. package/src/pipeline/msp-rebuild.ts +8 -19
  54. package/src/pipeline/projection-rebuild.ts +2 -13
  55. package/src/pipeline/system-hooks.ts +17 -4
  56. package/src/random/words.ts +4 -3
  57. package/src/stack/test-stack.ts +1 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.37.0",
3
+ "version": "0.39.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -181,7 +181,7 @@
181
181
  "zod": "^4.4.3"
182
182
  },
183
183
  "devDependencies": {
184
- "@cosmicdrift/kumiko-dispatcher-live": "0.35.0",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.38.0",
185
185
  "@types/uuid": "^11.0.0",
186
186
  "bun-types": "^1.3.13",
187
187
  "pino-pretty": "^13.1.3"
@@ -177,3 +177,82 @@ describe("auth-routes cookie behaviour on /auth/switch-tenant", () => {
177
177
  expect(newAuth?.value).not.toBe(validToken); // new jwt
178
178
  });
179
179
  });
180
+
181
+ // cookieDomain — Bug-Bash-2 Wave I (Auth auf Marketing-Host): Login auf
182
+ // dem Apex muss eine Session setzen die admin.<domain> mitliest. Ohne
183
+ // Option bleibt das Cookie host-only (kein Domain-Attribut).
184
+ describe("auth-routes cookieDomain", () => {
185
+ async function login(app: Hono): Promise<Response> {
186
+ return app.request("/api/auth/login", {
187
+ method: "POST",
188
+ headers: { "Content-Type": "application/json" },
189
+ body: JSON.stringify({ email: "a@b.c", password: "pw" }),
190
+ });
191
+ }
192
+
193
+ test("ohne cookieDomain: kein Domain-Attribut auf den Cookies", async () => {
194
+ const { app } = await buildApp();
195
+ const res = await login(app);
196
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).not.toMatch(/Domain=/i);
197
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).not.toMatch(/Domain=/i);
198
+ });
199
+
200
+ test("login: cookieDomain setzt Domain auf BEIDEN Cookies", async () => {
201
+ const { app } = await buildApp({ cookieDomain: "example.eu" });
202
+ const res = await login(app);
203
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
204
+ expect(getSetCookieRaw(res, CSRF_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
205
+ });
206
+
207
+ test("switch-tenant: rotierte Cookies tragen das Domain-Attribut", async () => {
208
+ const otherTenant = TestUsers.otherTenant;
209
+ const dispatcher = createStubDispatcher({
210
+ async query(type: string, _payload: unknown, _user: SessionUser): Promise<unknown> {
211
+ if (type === "tenant:query:memberships") {
212
+ return [
213
+ {
214
+ userId: TestUsers.user.id,
215
+ tenantId: otherTenant.tenantId,
216
+ roles: otherTenant.roles,
217
+ },
218
+ ];
219
+ }
220
+ return [];
221
+ },
222
+ });
223
+ const { app, validToken } = await buildApp({ cookieDomain: "example.eu" }, dispatcher);
224
+ const res = await app.request("/api/auth/switch-tenant", {
225
+ method: "POST",
226
+ headers: {
227
+ "Content-Type": "application/json",
228
+ Authorization: `Bearer ${validToken}`,
229
+ },
230
+ body: JSON.stringify({ tenantId: otherTenant.tenantId }),
231
+ });
232
+ expect(res.status).toBe(200);
233
+ expect(getSetCookieRaw(res, AUTH_COOKIE_NAME)).toMatch(/Domain=example\.eu/i);
234
+ });
235
+
236
+ test("logout löscht beide Varianten: mit Domain UND host-only", async () => {
237
+ const { app, validToken } = await buildApp({ cookieDomain: "example.eu" });
238
+ const res = await app.request("/api/auth/logout", {
239
+ method: "POST",
240
+ headers: { Authorization: `Bearer ${validToken}` },
241
+ });
242
+ expect(res.status).toBe(200);
243
+ const raw = res.headers.getSetCookie();
244
+ const authDeletes = raw.filter(
245
+ (c) => c.startsWith(`${AUTH_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
246
+ );
247
+ const csrfDeletes = raw.filter(
248
+ (c) => c.startsWith(`${CSRF_COOKIE_NAME}=`) && /Max-Age=0/i.test(c),
249
+ );
250
+ // Host-only-Bestand (vor cookieDomain gesetzt) + aktuelle Domain-
251
+ // Variante — beide müssen invalidiert werden, sonst wirkt der Logout
252
+ // auf dem alten Cookie nicht.
253
+ expect(authDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
254
+ expect(authDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
255
+ expect(csrfDeletes.some((c) => /Domain=example\.eu/i.test(c))).toBe(true);
256
+ expect(csrfDeletes.some((c) => !/Domain=/i.test(c))).toBe(true);
257
+ });
258
+ });
@@ -40,7 +40,12 @@ function cookieSecure(): boolean {
40
40
  // state that would trip the csrf-middleware on every retry.
41
41
  function setAuthCookies(
42
42
  c: Context,
43
- opts: { token: string; csrfToken: string; sameSite: "lax" | "strict" },
43
+ opts: {
44
+ token: string;
45
+ csrfToken: string;
46
+ sameSite: "lax" | "strict";
47
+ domain?: string | undefined;
48
+ },
44
49
  ): void {
45
50
  const sameSite = opts.sameSite === "strict" ? "Strict" : "Lax";
46
51
  const common = {
@@ -48,6 +53,7 @@ function setAuthCookies(
48
53
  sameSite,
49
54
  path: "/",
50
55
  maxAge: JWT_TTL_SECONDS,
56
+ ...(opts.domain !== undefined && { domain: opts.domain }),
51
57
  } as const;
52
58
 
53
59
  setCookie(c, AUTH_COOKIE_NAME, opts.token, { ...common, httpOnly: true });
@@ -56,9 +62,17 @@ function setAuthCookies(
56
62
  setCookie(c, CSRF_COOKIE_NAME, opts.csrfToken, { ...common, httpOnly: false });
57
63
  }
58
64
 
59
- function clearAuthCookies(c: Context): void {
65
+ function clearAuthCookies(c: Context, domain?: string): void {
66
+ // Beide Varianten löschen: mit Domain (aktuelle Cookies) UND host-only
67
+ // (Bestand aus der Zeit vor cookieDomain) — sonst bleibt nach einem
68
+ // Deploy mit neu gesetztem cookieDomain der alte Cookie liegen und der
69
+ // Logout wirkt nur scheinbar.
60
70
  deleteCookie(c, AUTH_COOKIE_NAME, { path: "/" });
61
71
  deleteCookie(c, CSRF_COOKIE_NAME, { path: "/" });
72
+ if (domain !== undefined) {
73
+ deleteCookie(c, AUTH_COOKIE_NAME, { path: "/", domain });
74
+ deleteCookie(c, CSRF_COOKIE_NAME, { path: "/", domain });
75
+ }
62
76
  }
63
77
 
64
78
  // Body schema for POST /auth/login. Enforced BEFORE rate-limit so that a
@@ -237,6 +251,14 @@ export type AuthRoutesConfig = {
237
251
  // The framework always pairs the cookie with a Double-Submit CSRF token
238
252
  // (see csrf-middleware), so "lax" is defense-in-depth, not defense-alone.
239
253
  cookieSameSite?: "lax" | "strict";
254
+ // Domain attribute for both auth cookies. Unset (default) = host-only
255
+ // cookie, scoped to the exact host that served the response. Set it to
256
+ // the registrable parent domain (e.g. "example.eu") when login and app
257
+ // live on DIFFERENT subdomains (login on apex, app on admin.) — the
258
+ // browser then sends the session to every subdomain. Careful: that
259
+ // includes ALL subdomains (tenant pages, previews); widen the scope
260
+ // only when the cross-subdomain session is actually required.
261
+ cookieDomain?: string;
240
262
  };
241
263
 
242
264
  export type PasswordResetConfig = {
@@ -402,6 +424,7 @@ export function createAuthRoutes(
402
424
  // "lax" keeps email deep-links (invite, magic-link, notification click)
403
425
  // working. High-security apps can opt into "strict" — see AuthRoutesConfig.
404
426
  const cookieSameSite = config.cookieSameSite ?? "lax";
427
+ const cookieDomain = config.cookieDomain;
405
428
 
406
429
  // POST /auth/login — public endpoint (bypasses auth middleware via PUBLIC_API_PATHS).
407
430
  // The configured login handler authenticates and returns a SessionUser;
@@ -481,7 +504,7 @@ export function createAuthRoutes(
481
504
  // ignores Set-Cookie keeps working without any server-side knowledge
482
505
  // of which transport this client will use next.
483
506
  const csrfToken = generateToken();
484
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
507
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
485
508
 
486
509
  return c.json({
487
510
  isSuccess: true,
@@ -588,7 +611,7 @@ export function createAuthRoutes(
588
611
 
589
612
  const token = await jwt.sign(sessionForJwt);
590
613
  const csrfToken = generateToken();
591
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
614
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
592
615
 
593
616
  return c.json({
594
617
  isSuccess: true,
@@ -671,7 +694,7 @@ export function createAuthRoutes(
671
694
  }
672
695
  const token = await jwt.sign(sessionForJwt);
673
696
  const csrfToken = generateToken();
674
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
697
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
675
698
  return c.json({
676
699
  isSuccess: true,
677
700
  token,
@@ -711,7 +734,7 @@ export function createAuthRoutes(
711
734
  }
712
735
  const token = await jwt.sign(sessionForJwt);
713
736
  const csrfToken = generateToken();
714
- setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite });
737
+ setAuthCookies(c, { token, csrfToken, sameSite: cookieSameSite, domain: cookieDomain });
715
738
  return c.json({
716
739
  isSuccess: true,
717
740
  token,
@@ -737,7 +760,7 @@ export function createAuthRoutes(
737
760
  }
738
761
  // Clear cookies on the cookie-transport path. Idempotent — clearing a
739
762
  // missing cookie is a no-op, so bearer-only clients aren't affected.
740
- clearAuthCookies(c);
763
+ clearAuthCookies(c, cookieDomain);
741
764
  return c.json({ isSuccess: true });
742
765
  });
743
766
 
@@ -876,7 +899,12 @@ export function createAuthRoutes(
876
899
  // the new token in the body below — their Set-Cookie is a no-op
877
900
  // because the browser never sent cookies.
878
901
  const csrfToken = generateToken();
879
- setAuthCookies(c, { token: newToken, csrfToken, sameSite: cookieSameSite });
902
+ setAuthCookies(c, {
903
+ token: newToken,
904
+ csrfToken,
905
+ sameSite: cookieSameSite,
906
+ domain: cookieDomain,
907
+ });
880
908
 
881
909
  return c.json({ token: newToken, tenantId: targetTenantId, roles: mergedRoles });
882
910
  });
package/src/api/routes.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import { type Context, Hono } from "hono";
2
2
  import type { ContentfulStatusCode } from "hono/utils/http-status";
3
3
  import {
4
- InternalError,
5
- isKumikoError,
6
4
  type KumikoError,
7
5
  reraiseAsKumikoError,
8
6
  serializeError,
7
+ toKumikoError,
9
8
  ValidationError,
10
9
  } from "../errors";
11
10
  import type { Dispatcher } from "../pipeline/dispatcher";
@@ -115,11 +114,7 @@ function jsonResponse(c: Context, body: unknown, status: ContentfulStatusCode =
115
114
  return c.body(stringifyJson(body), status, { "Content-Type": "application/json" });
116
115
  }
117
116
 
118
- function toKumiko(e: unknown): KumikoError {
119
- if (isKumikoError(e)) return e;
120
- if (e instanceof Error) return new InternalError({ cause: e });
121
- return new InternalError({ message: String(e) });
122
- }
117
+ const toKumiko = toKumikoError;
123
118
 
124
119
  // For /write + /batch: keep the isSuccess flag so clients can flip on a single
125
120
  // boolean (mirrors the success shape). The actual error body is the
package/src/api/server.ts CHANGED
@@ -635,7 +635,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
635
635
  // the yes/no answer for the boot check.
636
636
  function registryDeclaresFileFields(registry: Registry): boolean {
637
637
  for (const feature of registry.features.values()) {
638
- for (const entity of Object.values(feature.entities)) {
638
+ for (const entity of Object.values(feature.entities ?? {})) {
639
639
  for (const field of Object.values(entity.fields)) {
640
640
  if (isFileField(field)) return true;
641
641
  }
@@ -66,6 +66,7 @@ export function createBunDbConnection(
66
66
  };
67
67
  }
68
68
 
69
+ // guard:dup-ok — ENV-Reader für BunDB (gibt { db, listenClient } zurück), nicht verwandt mit redisClientOptionsFromEnv
69
70
  export function bunDbConnectionOptionsFromEnv(
70
71
  env: Readonly<Record<string, string | undefined>> = process.env,
71
72
  ): BunDbConnectionOptions {
@@ -11,7 +11,6 @@ export type {
11
11
  export { bunDbConnectionOptionsFromEnv, createBunDbConnection } from "./connection";
12
12
  export type { SelectOptions, TableInfo, WhereObject, WhereOperator, WhereValue } from "./query";
13
13
  export {
14
- asEntityTableMeta,
15
14
  asRawClient,
16
15
  countWhere,
17
16
  type DeleteManyBatchedOptions,
@@ -40,7 +40,7 @@ afterAll(async () => {
40
40
  async function applySchema(features: readonly FeatureDefinition[]): Promise<void> {
41
41
  const tables: Record<string, unknown> = {};
42
42
  for (const feature of features) {
43
- for (const [entityName, entity] of Object.entries(feature.entities)) {
43
+ for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
44
44
  tables[entityName] = buildEntityTable(entityName, entity);
45
45
  }
46
46
  }
@@ -1,8 +1,9 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { asEntityTableMeta } from "../../bun-db/query";
3
2
  import { createEntity } from "../../engine/factories";
3
+ import { sql } from "../dialect";
4
4
  import type { ColumnMeta, IndexMeta } from "../entity-table-meta";
5
5
  import { buildEntityTableMeta } from "../entity-table-meta";
6
+ import { asEntityTableMeta } from "../query";
6
7
  import { buildEntityTable } from "../table-builder";
7
8
 
8
9
  // Lock-step-Guard: buildEntityTable (Runtime-/Test-Stack-Pfad, Meta am
@@ -60,3 +61,43 @@ describe("buildEntityTable ↔ buildEntityTableMeta lock-step", () => {
60
61
  expect(cols.get("active")?.defaultSql).toBe("true");
61
62
  });
62
63
  });
64
+
65
+ // Zweite Probe: softDelete + explizite Indexes (unique, partial, multi-col).
66
+ // Diese Pfade generieren zusätzliche Spalten (deleted_at/_by) bzw. Index-
67
+ // Metas — Drift hier blieb von der defaults-Probe oben unentdeckt.
68
+ const entityWithSoftDeleteAndIndexes = createEntity({
69
+ table: "read_lockstep_probe_sd",
70
+ fields: {
71
+ title: { type: "text", required: true },
72
+ ownerId: { type: "text", required: true },
73
+ status: { type: "select", options: ["open", "done"], required: true, default: "open" },
74
+ },
75
+ softDelete: true,
76
+ indexes: [
77
+ { columns: ["ownerId"] },
78
+ { columns: ["ownerId", "status"], unique: true },
79
+ { columns: ["title"], unique: true, where: sql`status = 'open'`, name: "open_title_unique" },
80
+ ],
81
+ });
82
+
83
+ describe("lock-step — softDelete + explizite Indexes", () => {
84
+ const fromBuilder = asEntityTableMeta(
85
+ buildEntityTable("lockstepProbeSd", entityWithSoftDeleteAndIndexes),
86
+ );
87
+ const fromMeta = buildEntityTableMeta("lockstepProbeSd", entityWithSoftDeleteAndIndexes);
88
+
89
+ test("identical columns inkl. softDelete-Spalten", () => {
90
+ expect(byName<ColumnMeta>(fromBuilder?.columns ?? [])).toEqual(
91
+ byName<ColumnMeta>(fromMeta.columns),
92
+ );
93
+ const names = (fromBuilder?.columns ?? []).map((c) => c.name);
94
+ expect(names).toContain("deleted_at");
95
+ });
96
+
97
+ test("identical indexes inkl. unique/partial/multi-col", () => {
98
+ expect(byName<IndexMeta>(fromBuilder?.indexes ?? [])).toEqual(
99
+ byName<IndexMeta>(fromMeta.indexes),
100
+ );
101
+ expect((fromBuilder?.indexes ?? []).length).toBeGreaterThanOrEqual(3);
102
+ });
103
+ });
@@ -79,3 +79,37 @@ describe("tenant-db WHERE merge — caller cannot override tenant scope", () =>
79
79
  expect(captured[0]?.values).toContain(own);
80
80
  });
81
81
  });
82
+
83
+ describe("tenant-db WHERE merge — narrowing within the enforced scope", () => {
84
+ const SYSTEM = "00000000-0000-4000-8000-000000000000";
85
+
86
+ test("where.tenantId = own narrows to own only (excludes SYSTEM reference rows)", async () => {
87
+ const captured: Captured[] = [];
88
+ const tdb = createTenantDb(recordingDb(captured), own);
89
+
90
+ await tdb.selectMany(table, { tenantId: own });
91
+
92
+ expect(captured[0]?.values).toContain(own);
93
+ expect(captured[0]?.values).not.toContain(SYSTEM);
94
+ });
95
+
96
+ test("where.tenantId = [own, SYSTEM] keeps both", async () => {
97
+ const captured: Captured[] = [];
98
+ const tdb = createTenantDb(recordingDb(captured), own);
99
+
100
+ await tdb.selectMany(table, { tenantId: [own, SYSTEM] });
101
+
102
+ expect(captured[0]?.values).toContain(own);
103
+ expect(captured[0]?.values).toContain(SYSTEM);
104
+ });
105
+
106
+ test("mixed [own, foreign] drops the foreign id, keeps own", async () => {
107
+ const captured: Captured[] = [];
108
+ const tdb = createTenantDb(recordingDb(captured), own);
109
+
110
+ await tdb.selectMany(table, { tenantId: [own, foreign] });
111
+
112
+ expect(captured[0]?.values).toContain(own);
113
+ expect(captured[0]?.values).not.toContain(foreign);
114
+ });
115
+ });
@@ -6,10 +6,10 @@
6
6
  // read_subscriptions) nie in Migrations landeten und der erste Prod-Write
7
7
  // crashte (#255).
8
8
 
9
- import { asEntityTableMeta } from "../bun-db/query";
10
9
  import type { FeatureDefinition } from "../engine/types";
11
10
  import { buildEntityTableMeta, type EntityTableMeta } from "./entity-table-meta";
12
11
  import { enumerateFeatureTableSources } from "./feature-table-sources";
12
+ import { asEntityTableMeta } from "./query";
13
13
 
14
14
  function canonicalColumnsKey(meta: EntityTableMeta): string {
15
15
  // Spalten-Identität unabhängig von Deklarations-Reihenfolge und
@@ -34,7 +34,7 @@ export function collectTableMetas(
34
34
  // Pass 1: kanonische Schema-Quellen, identisch zum bisherigen Template-
35
35
  // Verhalten (gleiche Reihenfolge, gleiche buildEntityTableMeta-Optionen).
36
36
  for (const feature of features) {
37
- for (const [name, ent] of Object.entries(feature.entities)) {
37
+ for (const [name, ent] of Object.entries(feature.entities ?? {})) {
38
38
  const meta = buildEntityTableMeta(name, ent, { relations: feature.relations[name] });
39
39
  metas.push(meta);
40
40
  byName.set(meta.tableName, { meta, origin: `entity "${name}" (${feature.name})` });
@@ -17,6 +17,7 @@ export type PgClient = ReturnType<typeof postgres>;
17
17
  export type PgListenClient = ReturnType<typeof postgres>;
18
18
 
19
19
  // Legacy: postgres-js only. Neue Aufrufer: createConnection() aus api.ts.
20
+ // guard:dup-ok — andere Layer als createPgConnection (gibt DbConnection zurück, nicht postgres-Instanz)
20
21
  export function createDbConnection(
21
22
  url: string,
22
23
  options: import("./api").DbConnectionOptions = {},
package/src/db/dialect.ts CHANGED
@@ -272,9 +272,9 @@ export function instant(
272
272
  }
273
273
 
274
274
  // moneyAmount kept as a customType-style API but produces a bigint column.
275
- // bigintJsMode "bigint" — money cents must round-trip as JS bigint (lock-step
276
- // with entity-table-meta's money rendering; without it bun-db reads the
277
- // column as number and loses precision past 2^53).
275
+ // bigintJsMode "bigint" — entity-table-meta renders money as bigint, and the
276
+ // table-builder↔meta lockstep guard fails on a number-mode column. (Precision
277
+ // past 2^53 is the underlying motivation, not the immediate breakage.)
278
278
  export const moneyAmount = (name: string): ColumnBuilder<number> =>
279
279
  buildColumn(name, "bigint", { bigintJsMode: "bigint" }) as ColumnBuilder<number>;
280
280
 
@@ -482,6 +482,19 @@ export function table<TCols extends ColumnMap>(
482
482
  return out;
483
483
  }
484
484
 
485
+ /** Reads the `kumiko:schema:Name` symbol from a table object.
486
+ * Throws with `context` in the message so callers don't have to duplicate the guard. */
487
+ export function extractTableName(table: unknown, context = "extractTableName"): string {
488
+ if (typeof table !== "object" || table === null) {
489
+ throw new Error(`${context}: table is not an object`);
490
+ }
491
+ const name = (table as Record<symbol, unknown>)[KUMIKO_NAME_SYMBOL];
492
+ if (typeof name !== "string") {
493
+ throw new Error(`${context}: table missing kumiko:schema:Name symbol`);
494
+ }
495
+ return name;
496
+ }
497
+
485
498
  // Helper used by `instantToDriver` callers in legacy code — kept identical
486
499
  // to the previous behaviour. The native dialect handles parse/serialize
487
500
  // implicitly via the Bun driver; this function is a defensive coerce at
@@ -16,6 +16,7 @@ import type {
16
16
  FieldDefinition,
17
17
  SaveContext,
18
18
  SessionUser,
19
+ TenantId,
19
20
  WriteResult,
20
21
  } from "../engine/types";
21
22
  import { SYSTEM_TENANT_ID } from "../engine/types/identifiers";
@@ -29,10 +30,12 @@ import {
29
30
  writeFailure,
30
31
  } from "../errors";
31
32
  import {
33
+ ArchivedStreamError,
32
34
  append,
33
35
  type EventMetadata,
34
36
  VersionConflictError as EventStoreVersionConflict,
35
37
  getStreamVersion,
38
+ isStreamArchived,
36
39
  } from "../event-store";
37
40
  import type { EntityCache } from "../pipeline/entity-cache";
38
41
  import type { SearchAdapter } from "../search/types";
@@ -291,6 +294,26 @@ export function createEventStoreExecutor(
291
294
  return rehydrateCompoundTypes(row as DbRow, entity);
292
295
  }
293
296
 
297
+ // Archive guard for the CRUD write paths. Archived streams are read-only —
298
+ // ctx.appendEvent (append-event-core) already enforces this, but the
299
+ // executor appends directly via append() and getStreamVersion() ignores
300
+ // the archive flag, so without this check a PATCH/DELETE on an archived
301
+ // entity would silently land an event and break the read-only contract
302
+ // (loadAggregate returns [] for the same stream). Throws ArchivedStreamError
303
+ // to mirror the appendEvent path exactly — same 500 + rolled-back tx.
304
+ // Creates skip this: a fresh UUID can't be archived, and a deterministic-id
305
+ // re-create onto an archived stream collides on the unique index →
306
+ // version_conflict, which already blocks the write.
307
+ async function assertStreamWritable(
308
+ db: TenantDb,
309
+ id: EntityId,
310
+ tenantId: TenantId,
311
+ ): Promise<void> {
312
+ if (await isStreamArchived(db.raw, tenantId, String(id))) {
313
+ throw new ArchivedStreamError(tenantId, String(id));
314
+ }
315
+ }
316
+
294
317
  // SELECT a row by id with the ownership clause applied at the DB layer.
295
318
  // Detail() uses this both on cold path and as a cache-revalidation probe.
296
319
  async function loadWithOwnership(
@@ -521,6 +544,8 @@ export function createEventStoreExecutor(
521
544
  );
522
545
  }
523
546
 
547
+ await assertStreamWritable(db, payload.id, user.tenantId);
548
+
524
549
  // Stream-version is authoritative, not row.version. `ctx.appendEvent`
525
550
  // can bump the stream between CRUD writes (domain event on the same
526
551
  // aggregate); a stale row.version here would make the next CRUD write
@@ -655,6 +680,8 @@ export function createEventStoreExecutor(
655
680
  );
656
681
  }
657
682
 
683
+ await assertStreamWritable(db, payload.id, user.tenantId);
684
+
658
685
  // Stream-version authoritative (see update() for rationale).
659
686
  const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
660
687
 
@@ -730,6 +757,8 @@ export function createEventStoreExecutor(
730
757
  );
731
758
  }
732
759
 
760
+ await assertStreamWritable(db, payload.id, user.tenantId);
761
+
733
762
  // Stream-version authoritative (see update() for rationale).
734
763
  const currentVersion = await getStreamVersion(db.raw, String(payload.id), user.tenantId);
735
764
  // Restore carries the soft-deleted snapshot as `previous` — mirror of
package/src/db/index.ts CHANGED
@@ -11,6 +11,7 @@ export {
11
11
  bigint,
12
12
  bigserial,
13
13
  boolean,
14
+ extractTableName,
14
15
  index,
15
16
  instant,
16
17
  instantToDriver,
package/src/db/query.ts CHANGED
@@ -2,6 +2,7 @@
2
2
  // Alle Consumer importieren von hier, nicht direkt aus bun-db/.
3
3
  export {
4
4
  type AnyDb,
5
+ asEntityTableMeta,
5
6
  asRawClient,
6
7
  coerceRow,
7
8
  countWhere,
@@ -133,12 +133,22 @@ export function createTenantDb(
133
133
 
134
134
  // Reads see own-tenant rows + reference data (tenantId === SYSTEM_TENANT_ID).
135
135
  // Writes never touch reference rows — those are system-mode only.
136
- // The tenant filter is spread LAST so a caller-supplied `where.tenantId`
137
- // cannot override the enforced scope overriding it would be a
138
- // tenant-isolation bypass.
136
+ // A caller-supplied `where.tenantId` may only NARROW the enforced scope
137
+ // (e.g. exclude SYSTEM reference rows at the DB instead of post-filtering
138
+ // after a limit). Values outside the scope are dropped; if nothing valid
139
+ // remains, the full enforced scope applies — widening is never possible.
139
140
  function readWhere(table: Table, where?: WhereObject): WhereObject | undefined {
140
141
  if (!hasTenantColumn(table) || mode === "system") return where;
141
- const tenantFilter: WhereObject = { tenantId: [tenantId, SYSTEM_TENANT_ID] };
142
+ const allowed = [tenantId, SYSTEM_TENANT_ID];
143
+ const requested = where?.["tenantId"];
144
+ if (requested !== undefined) {
145
+ const requestedList = Array.isArray(requested) ? requested : [requested];
146
+ const narrowed = requestedList.filter(
147
+ (t): t is string => typeof t === "string" && allowed.includes(t),
148
+ );
149
+ return { ...where, tenantId: narrowed.length > 0 ? narrowed : allowed };
150
+ }
151
+ const tenantFilter: WhereObject = { tenantId: allowed };
142
152
  return where ? { ...where, ...tenantFilter } : tenantFilter;
143
153
  }
144
154
 
@@ -8,7 +8,7 @@
8
8
  // im Browser-Bundle.
9
9
 
10
10
  import { describe, expect, test } from "bun:test";
11
- import { buildAppSchema } from "../build-app-schema";
11
+ import { buildAppSchema, findNonJsonSafePath } from "../build-app-schema";
12
12
  import { defineFeature } from "../define-feature";
13
13
  import { createRegistry } from "../registry";
14
14
  import type { EntityDefinition } from "../types/fields";
@@ -207,8 +207,9 @@ describe("buildAppSchema", () => {
207
207
  const app = buildAppSchema(createRegistry([f]));
208
208
  const roundTripped = JSON.parse(JSON.stringify(app));
209
209
 
210
- // Vollständige deep-equality kein Silent-Drop durch JSON.stringify
211
- expect(roundTripped).toEqual(app);
210
+ // toStrictEqual: toEqual ignoriert undefined-Props und würde einen
211
+ // Silent-Drop durch JSON.stringify genau NICHT fangen.
212
+ expect(roundTripped).toStrictEqual(app);
212
213
 
213
214
  // Explizit: FormatSpec-Felder landen unverändert an
214
215
  const screen = roundTripped.features[0]?.screens[0];
@@ -234,3 +235,30 @@ describe("buildAppSchema", () => {
234
235
  expect(actions?.find((a) => a.id === "always")?.visible).toBe(true);
235
236
  });
236
237
  });
238
+
239
+ describe("findNonJsonSafePath", () => {
240
+ test("findet eine Funktion ausserhalb von PlatformComponent-Slots mit Pfad", () => {
241
+ const schema = { features: [{ label: () => "nope" }] };
242
+ expect(findNonJsonSafePath(schema, "schema")).toBe("schema.features[0].label");
243
+ });
244
+
245
+ test("PlatformComponent-Slots ({ react, native }) sind opak — Komponenten-Funktionen erlaubt", () => {
246
+ const schema = {
247
+ features: [{ screens: [{ id: "s1", component: { react: () => null } }] }],
248
+ };
249
+ expect(findNonJsonSafePath(schema, "schema")).toBeNull();
250
+ });
251
+
252
+ test("faengt undefined, bigint und Klassen-Instanzen", () => {
253
+ expect(findNonJsonSafePath({ a: undefined }, "schema")).toBe("schema.a");
254
+ expect(findNonJsonSafePath({ a: 1n }, "schema")).toBe("schema.a");
255
+ expect(findNonJsonSafePath({ a: new Map() }, "schema")).toBe("schema.a");
256
+ expect(findNonJsonSafePath({ a: Number.NaN }, "schema")).toBe("schema.a");
257
+ });
258
+
259
+ test("normales JSON-Schema passiert ohne Befund", () => {
260
+ expect(
261
+ findNonJsonSafePath({ features: [{ name: "x", count: 3, on: true, opt: null }] }, "schema"),
262
+ ).toBeNull();
263
+ });
264
+ });
@@ -65,9 +65,9 @@ describe("defineFeature", () => {
65
65
  );
66
66
  });
67
67
 
68
- expect(feature.entities["user"]).toBeDefined();
69
- expect(feature.entities["user"]?.table).toBe("Users");
70
- expect(feature.entities["user"]?.fields["email"]?.type).toBe("text");
68
+ expect(feature.entities?.["user"]).toBeDefined();
69
+ expect(feature.entities?.["user"]?.table).toBe("Users");
70
+ expect(feature.entities?.["user"]?.fields["email"]?.type).toBe("text");
71
71
  });
72
72
 
73
73
  test("collects write handlers with inferred types", () => {