@cosmicdrift/kumiko-framework 0.105.1 → 0.105.2

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-framework",
3
- "version": "0.105.1",
3
+ "version": "0.105.2",
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.105.1",
184
+ "@cosmicdrift/kumiko-dispatcher-live": "0.105.2",
185
185
  "bun-types": "^1.3.13",
186
186
  "pino-pretty": "^13.1.3"
187
187
  },
@@ -26,6 +26,17 @@ function forge(claims: Record<string, unknown>): Promise<string> {
26
26
  .sign(new TextEncoder().encode(SECRET));
27
27
  }
28
28
 
29
+ // Same as forge(), but never calls .setSubject() — the only way to produce a
30
+ // validly-signed token with no `sub` claim at all (forge() always sets one).
31
+ function forgeNoSub(claims: Record<string, unknown>): Promise<string> {
32
+ return new jose.SignJWT(claims)
33
+ .setProtectedHeader({ alg: "HS256" })
34
+ .setIssuer(ISSUER)
35
+ .setIssuedAt()
36
+ .setExpirationTime("1h")
37
+ .sign(new TextEncoder().encode(SECRET));
38
+ }
39
+
29
40
  describe("createJwtHelper.verify — payload validation (KF-2)", () => {
30
41
  const jwt = createJwtHelper(SECRET);
31
42
 
@@ -55,4 +66,20 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
55
66
  const token = await forge({ tenantId: TENANT, roles: ["ok", 7] });
56
67
  await expect(jwt.verify(token)).rejects.toThrow(/roles/);
57
68
  });
69
+
70
+ it("rejects a validly-signed token with no sub claim", async () => {
71
+ const token = await forgeNoSub({ tenantId: TENANT, roles: ["TenantAdmin"] });
72
+ await expect(jwt.verify(token)).rejects.toThrow(/sub/);
73
+ });
74
+
75
+ it("rejects a token whose sub claim is an empty string", async () => {
76
+ const token = await new jose.SignJWT({ tenantId: TENANT, roles: ["TenantAdmin"] })
77
+ .setProtectedHeader({ alg: "HS256" })
78
+ .setSubject("")
79
+ .setIssuer(ISSUER)
80
+ .setIssuedAt()
81
+ .setExpirationTime("1h")
82
+ .sign(new TextEncoder().encode(SECRET));
83
+ await expect(jwt.verify(token)).rejects.toThrow(/sub/);
84
+ });
58
85
  });
package/src/api/jwt.ts CHANGED
@@ -69,9 +69,12 @@ export function createJwtHelper(secret: string, issuer = "kumiko"): JwtHelper {
69
69
  }
70
70
  roles.push(role);
71
71
  }
72
+ if (typeof payload.sub !== "string" || payload.sub === "") {
73
+ throw new Error("JWT payload validation failed: sub claim is missing or malformed");
74
+ }
72
75
 
73
76
  const result: JwtPayload = {
74
- sub: String(payload.sub),
77
+ sub: payload.sub,
75
78
  tenantId,
76
79
  roles,
77
80
  };
@@ -1,11 +1,18 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  ACTION_FORM_ENTITY,
4
+ CONFIG_EDIT_ENTITY,
4
5
  fieldLabelKey,
6
+ requiredKeysFromNav,
5
7
  requiredKeysFromScreen,
8
+ requiredKeysFromWorkspace,
6
9
  screenTitleKey,
7
10
  } from "../../i18n/required-surface-keys";
8
- import type { EntityListScreenDefinition } from "../types";
11
+ import type {
12
+ ConfigEditScreenDefinition,
13
+ EntityEditScreenDefinition,
14
+ EntityListScreenDefinition,
15
+ } from "../types";
9
16
 
10
17
  describe("requiredKeysFromScreen", () => {
11
18
  test("entityList emits screen title + column field labels", () => {
@@ -35,4 +42,130 @@ describe("requiredKeysFromScreen", () => {
35
42
  });
36
43
  expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "title"));
37
44
  });
45
+
46
+ test("entityEdit emits submitLabel + section titles + field labels (override honored)", () => {
47
+ const screen: EntityEditScreenDefinition = {
48
+ id: "component-edit",
49
+ type: "entityEdit",
50
+ entity: "component",
51
+ submitLabel: "publicstatus:actions.saveComponent",
52
+ fieldLabels: { name: "publicstatus:override.name" },
53
+ layout: {
54
+ sections: [{ title: "publicstatus:section.basics", fields: ["name", "status"] }],
55
+ },
56
+ };
57
+ const keys = requiredKeysFromScreen("publicstatus", screen);
58
+ expect(keys).toContain("publicstatus:actions.saveComponent");
59
+ expect(keys).toContain("publicstatus:section.basics");
60
+ // override wins over the default entity:field convention
61
+ expect(keys).toContain("publicstatus:override.name");
62
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
63
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
64
+ });
65
+
66
+ test("entityEdit extension section pushes only the section title (no field labels)", () => {
67
+ const screen: EntityEditScreenDefinition = {
68
+ id: "component-edit",
69
+ type: "entityEdit",
70
+ entity: "component",
71
+ layout: {
72
+ sections: [
73
+ {
74
+ kind: "extension",
75
+ title: "publicstatus:section.customFields",
76
+ component: { react: {} },
77
+ },
78
+ ],
79
+ },
80
+ };
81
+ const keys = requiredKeysFromScreen("publicstatus", screen);
82
+ expect(keys).toEqual([screenTitleKey("component-edit"), "publicstatus:section.customFields"]);
83
+ });
84
+
85
+ test("configEdit uses the CONFIG_EDIT_ENTITY namespace, honors fieldLabels override", () => {
86
+ const screen: ConfigEditScreenDefinition = {
87
+ id: "settings-retention",
88
+ type: "configEdit",
89
+ scope: "tenant",
90
+ configKeys: { days: "publicstatus:config:retentionDays" },
91
+ fieldLabels: { days: "publicstatus:override.retentionDays" },
92
+ fields: { days: { type: "number" } },
93
+ layout: { sections: [{ fields: ["days"] }] },
94
+ };
95
+ const keys = requiredKeysFromScreen("publicstatus", screen);
96
+ expect(keys).toContain("publicstatus:override.retentionDays");
97
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
98
+ });
99
+
100
+ test("configEdit without a fieldLabels override falls back to the CONFIG_EDIT_ENTITY convention", () => {
101
+ const screen: ConfigEditScreenDefinition = {
102
+ id: "settings-retention",
103
+ type: "configEdit",
104
+ scope: "tenant",
105
+ configKeys: { days: "publicstatus:config:retentionDays" },
106
+ fields: { days: { type: "number" } },
107
+ layout: { sections: [{ fields: ["days"] }] },
108
+ };
109
+ const keys = requiredKeysFromScreen("publicstatus", screen);
110
+ expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "days"));
111
+ });
112
+
113
+ test("custom screen emits only the screen title — no field surface to validate", () => {
114
+ const keys = requiredKeysFromScreen("publicstatus", {
115
+ id: "dashboard",
116
+ type: "custom",
117
+ renderer: { react: {} },
118
+ });
119
+ expect(keys).toEqual([screenTitleKey("dashboard")]);
120
+ });
121
+
122
+ test("entityList rowActions/toolbarActions emit label + confirm + confirmLabel", () => {
123
+ const screen: EntityListScreenDefinition = {
124
+ id: "component-list",
125
+ type: "entityList",
126
+ entity: "component",
127
+ columns: ["name"],
128
+ rowActions: [
129
+ {
130
+ id: "delete",
131
+ label: "publicstatus:actions.delete",
132
+ handler: "publicstatus:write:component:delete",
133
+ confirm: "publicstatus:confirm.deleteComponent",
134
+ confirmLabel: "publicstatus:confirm.deleteComponentButton",
135
+ style: "danger",
136
+ },
137
+ ],
138
+ toolbarActions: [
139
+ {
140
+ kind: "writeHandler",
141
+ id: "sync-all",
142
+ label: "publicstatus:actions.syncAll",
143
+ handler: "publicstatus:write:component:syncAll",
144
+ confirm: "publicstatus:confirm.syncAll",
145
+ confirmLabel: "publicstatus:confirm.syncAllButton",
146
+ },
147
+ ],
148
+ };
149
+ const keys = requiredKeysFromScreen("publicstatus", screen);
150
+ expect(keys).toContain("publicstatus:actions.delete");
151
+ expect(keys).toContain("publicstatus:confirm.deleteComponent");
152
+ expect(keys).toContain("publicstatus:confirm.deleteComponentButton");
153
+ expect(keys).toContain("publicstatus:actions.syncAll");
154
+ expect(keys).toContain("publicstatus:confirm.syncAll");
155
+ expect(keys).toContain("publicstatus:confirm.syncAllButton");
156
+ });
157
+ });
158
+
159
+ describe("requiredKeysFromNav / requiredKeysFromWorkspace", () => {
160
+ test("nav label is a required key", () => {
161
+ expect(requiredKeysFromNav({ id: "catalog", label: "shop:nav.catalog" })).toEqual([
162
+ "shop:nav.catalog",
163
+ ]);
164
+ });
165
+
166
+ test("workspace label is a required key", () => {
167
+ expect(
168
+ requiredKeysFromWorkspace({ id: "disposition", label: "bmc:workspace.disposition" }),
169
+ ).toEqual(["bmc:workspace.disposition"]);
170
+ });
38
171
  });
@@ -90,6 +90,12 @@ export interface UserDataHookCtx {
90
90
  * touch tenant-scoped rows. Absent → treat as `"multi-user"` (no erasure).
91
91
  */
92
92
  readonly tenantModel?: TenantUserModel;
93
+ /**
94
+ * Original user email captured before the forget transaction anonymizes it.
95
+ * Set on delete hooks during `runForgetCleanup` so matchers (e.g. email
96
+ * subscriptions) work in every tenant pass. Absent on export hooks.
97
+ */
98
+ readonly userEmailBeforeDelete?: string | null;
93
99
  }
94
100
 
95
101
  /**
@@ -224,4 +224,12 @@ describe("file download roundtrip (GET /files/:id)", () => {
224
224
  await deleteFile(admin, id);
225
225
  expect((await download(admin, id)).status).toBe(404);
226
226
  });
227
+
228
+ test("a cross-tenant download is rejected (404, no existence oracle)", async () => {
229
+ const id = await upload(admin, "tenant-a-only.png", SMALL);
230
+ const res = await download(otherAdmin, id);
231
+ expect(res.status).toBe(404);
232
+ const body = (await res.json()) as { error?: string };
233
+ expect(body.error).toBe("not_found");
234
+ });
227
235
  });
@@ -150,8 +150,8 @@ export function createFileRoutes(options: FileRoutesOptions): Hono {
150
150
  // are orphaned in the provider; cleanup-jobs sweep those later. Losing a
151
151
  // row on append-failure is acceptable; corrupting a committed row with a
152
152
  // missing binary is not.
153
- const data = new Uint8Array(await file.arrayBuffer());
154
153
  const storageProvider = await options.resolveProvider(user.tenantId);
154
+ const data = new Uint8Array(await file.arrayBuffer());
155
155
  await storageProvider.write(storageKey, data, file.type);
156
156
 
157
157
  // Create via the standard entity executor: emits fileRef.created +
@@ -547,6 +547,49 @@ describe("write-handler shape guard", () => {
547
547
  });
548
548
  });
549
549
 
550
+ // --- Dispatch: geoTzProvider seam ---
551
+
552
+ describe("dispatcher context.geoTzProvider (680/1)", () => {
553
+ test("ctx.tz.fromCoordinates reaches the app-injected geoTzProvider through a real dispatch", async () => {
554
+ // All geo-tz.test.ts coverage calls createTzContext({ geoTz }) directly —
555
+ // none go through the dispatcher or read context.geoTzProvider. A typo in
556
+ // the property forwarding at dispatcher.ts would make the injection a
557
+ // silent no-op (provider configured, ctx.tz.fromCoordinates still throws).
558
+ const geoFeature = defineFeature("geo", (r) => {
559
+ r.queryHandler(
560
+ "resolve-zone",
561
+ z.object({ latitude: z.number(), longitude: z.number() }),
562
+ async (query, ctx) => ({
563
+ zone: await ctx.tz.fromCoordinates({
564
+ latitude: query.payload.latitude,
565
+ longitude: query.payload.longitude,
566
+ }),
567
+ }),
568
+ { access: { openToAll: true } },
569
+ );
570
+ });
571
+ const registry = createRegistry([geoFeature]);
572
+ const calls: Array<{ latitude: number; longitude: number }> = [];
573
+ const dispatcher = createDispatcher(registry, {
574
+ geoTzProvider: {
575
+ fromCoordinates: ({ latitude, longitude }) => {
576
+ calls.push({ latitude, longitude });
577
+ return "Europe/Berlin";
578
+ },
579
+ },
580
+ });
581
+
582
+ const result = await dispatcher.query(
583
+ "geo:query:resolve-zone",
584
+ { latitude: 52.52, longitude: 13.405 },
585
+ createTestUser(),
586
+ );
587
+
588
+ expect(result).toEqual({ zone: "Europe/Berlin" });
589
+ expect(calls).toEqual([{ latitude: 52.52, longitude: 13.405 }]);
590
+ });
591
+ });
592
+
550
593
  // --- Mock helpers ---
551
594
 
552
595
  function createMockIdempotencyGuard() {
@@ -750,6 +750,30 @@ describe("rebuildProjection — live-tail catch-up (#363 Phase 2)", () => {
750
750
  expect(await getCount(groupX)).toBe(1); // #443 fixed: fenced count re-check re-replayed the missed low-id event
751
751
  });
752
752
 
753
+ test("(656/1) a clean rebuild with no concurrency does not trigger the #443 recompute", async () => {
754
+ // The #443 shortfall-recompute rebuilds the shadow a 2nd time and re-replays
755
+ // the whole log — correct on the rare path it guards, but a silent perf
756
+ // regression if it fired on every rebuild. Its final values are identical
757
+ // either way (full re-replay is idempotent), so only a call-count seam can
758
+ // prove it stayed off the happy path.
759
+ const group = "00000000-0000-4000-8000-000000000203";
760
+ await appendCreatedEvent(group, "a");
761
+ await appendCreatedEvent(group, "b");
762
+
763
+ let shadowBuilds = 0;
764
+ const result = await rebuildProjection(qualifiedProjectionName, {
765
+ db: testDb.db,
766
+ registry,
767
+ __test_onBuildShadowTable: () => {
768
+ shadowBuilds++;
769
+ },
770
+ });
771
+
772
+ expect(shadowBuilds).toBe(1);
773
+ expect(result.eventsProcessed).toBe(2);
774
+ expect(await getCount(group)).toBe(2);
775
+ });
776
+
753
777
  test("the rebuild tx sees concurrently-committed rows (READ COMMITTED, not a frozen snapshot)", async () => {
754
778
  // The catch-up loop only works if each fresh SELECT in the rebuild tx sees
755
779
  // rows other connections committed since the previous batch. Under
@@ -151,6 +151,10 @@ type RebuildDeps = {
151
151
  // a slow callback here delays the cutover fence acquisition, widening the
152
152
  // window in which concurrent writers can still commit below the cursor.
153
153
  readonly __test_onBeforeFence?: () => void | Promise<void>;
154
+ // Test-only seam: fires each time the shadow table is (re)built. The #443
155
+ // recompute is idempotent on final values, so only a call-count seam like
156
+ // this can prove it didn't fire on a clean, non-concurrent rebuild.
157
+ readonly __test_onBuildShadowTable?: () => void;
154
158
  };
155
159
 
156
160
  export async function rebuildProjection(
@@ -216,6 +220,7 @@ export async function rebuildProjection(
216
220
  await db.begin(async (tx: DbTx) => {
217
221
  await markProjectionRebuilding(tx, projectionName);
218
222
  await buildShadowTable(tx, meta);
223
+ deps.__test_onBuildShadowTable?.();
219
224
 
220
225
  // A projection that subscribes to nothing has no events to replay and no
221
226
  // live writer touching its table — skip straight to swapping the empty
@@ -253,6 +258,7 @@ export async function rebuildProjection(
253
258
  const expectedEvents = await countSubscribedEvents(tx, sourcesList, subscribedList);
254
259
  if (expectedEvents > BigInt(eventsProcessed)) {
255
260
  await buildShadowTable(tx, meta);
261
+ deps.__test_onBuildShadowTable?.();
256
262
  lastProcessedEventId = 0n;
257
263
  eventsProcessed = 0;
258
264
  while ((await drainBatch(tx)) === REBUILD_BATCH_SIZE) {
@@ -18,7 +18,22 @@ describe("warnIfNonUtcServerTimeZone", () => {
18
18
  expect(messages[0]).toContain("UTC");
19
19
  });
20
20
 
21
- test("Default-Aufruf liest die Prozess-TZ ohne zu werfen", () => {
22
- expect(() => warnIfNonUtcServerTimeZone(undefined, () => {})).not.toThrow();
21
+ test("Default-Aufruf liest die ECHTE Prozess-TZ (ambient, nicht injiziert)", () => {
22
+ // Das ist der einzige Test der ambient-TZ liest statt sie zu injizieren —
23
+ // der Grund warum der tz-matrix-CI-Job (4 Legs: LA/Berlin/Tokyo/Apia)
24
+ // überhaupt einen Unterschied zwischen den Legs sehen kann. Ohne diese
25
+ // Assertion wäre "läuft in jeder Zone ohne zu werfen" in allen 4 Legs
26
+ // identisch grün — die beworbene Schutzwirkung ("ein `new Date(wallClock)`-
27
+ // Bug bricht HIER") würde nicht existieren.
28
+ const ambientTz = Intl.DateTimeFormat().resolvedOptions().timeZone;
29
+ const messages: string[] = [];
30
+ const warned = warnIfNonUtcServerTimeZone(undefined, (m) => messages.push(m));
31
+ if (ambientTz === "UTC") {
32
+ expect(warned).toBe(false);
33
+ expect(messages).toHaveLength(0);
34
+ } else {
35
+ expect(warned).toBe(true);
36
+ expect(messages[0]).toContain(ambientTz);
37
+ }
23
38
  });
24
39
  });