@cosmicdrift/kumiko-framework 0.288.0 → 0.289.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 (38) hide show
  1. package/package.json +4 -4
  2. package/src/changes.json +57 -0
  3. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  4. package/src/compliance/sub-processors.ts +14 -1
  5. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  6. package/src/crypto/index.ts +3 -0
  7. package/src/crypto/kek-source.ts +189 -0
  8. package/src/crypto/kms-wiring.ts +20 -0
  9. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +191 -0
  10. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  11. package/src/db/event-store-executor-context.ts +79 -0
  12. package/src/db/event-store-executor-write.ts +42 -5
  13. package/src/db/index.ts +1 -0
  14. package/src/db/tenant-db.ts +8 -1
  15. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  16. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  17. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  18. package/src/engine/boot-validator/entity-handler.ts +10 -4
  19. package/src/engine/extensions/storage-provider.ts +17 -2
  20. package/src/engine/extensions/tenant-data.ts +9 -0
  21. package/src/engine/factories.ts +2 -0
  22. package/src/engine/screen-helpers.ts +17 -0
  23. package/src/errors/classes.ts +24 -0
  24. package/src/errors/index.ts +3 -0
  25. package/src/errors/member-resolution.ts +12 -0
  26. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  27. package/src/event-store/provenance-append.ts +9 -1
  28. package/src/files/__tests__/file-handle.test.ts +28 -1
  29. package/src/files/file-handle.ts +26 -7
  30. package/src/files/index.ts +1 -1
  31. package/src/i18n/required-surface-keys.ts +24 -12
  32. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  33. package/src/pipeline/dispatch-shared.ts +14 -13
  34. package/src/pipeline/dispatch-stream.ts +8 -3
  35. package/src/pipeline/dispatch-write.ts +3 -2
  36. package/src/pipeline/event-dispatcher.ts +18 -5
  37. package/src/pipeline/member-read-only-transaction.ts +2 -6
  38. package/src/stack/table-helpers.ts +6 -0
@@ -3,10 +3,12 @@ import {
3
3
  ACTION_FORM_ENTITY,
4
4
  CONFIG_EDIT_ENTITY,
5
5
  fieldLabelKey,
6
+ PROJECTION_DETAIL_ENTITY,
6
7
  requiredKeysFromNav,
7
8
  requiredKeysFromScreen,
8
9
  requiredKeysFromWorkspace,
9
10
  screenTitleKey,
11
+ WRITE_FORM_SECTION_ENTITY,
10
12
  } from "../../i18n/required-surface-keys";
11
13
  import { i18nKey } from "../i18n-key";
12
14
  import type {
@@ -279,3 +281,197 @@ describe("dot-form label opt-in (fw#2313)", () => {
279
281
  expect(requiredKeysFromNav(nav)).toContain("fw2313.optin.nav.beta");
280
282
  });
281
283
  });
284
+
285
+ describe("requiredKeysFromScreen reads section.groups (fw#2986)", () => {
286
+ test("entityEdit: group fields and group titles, override honored", () => {
287
+ const screen: EntityEditScreenDefinition = {
288
+ id: "component-edit",
289
+ type: "entityEdit",
290
+ entity: "component",
291
+ fieldLabels: { name: "publicstatus:override.name" },
292
+ layout: {
293
+ sections: [
294
+ {
295
+ title: "publicstatus:section.basics",
296
+ fields: [],
297
+ groups: [
298
+ { title: "publicstatus:group.identity", fields: ["name"] },
299
+ { title: "publicstatus:group.state", fields: [{ field: "status" }] },
300
+ ],
301
+ },
302
+ ],
303
+ },
304
+ };
305
+ const keys = requiredKeysFromScreen("publicstatus", screen);
306
+ expect(keys).toContain("publicstatus:section.basics");
307
+ expect(keys).toContain("publicstatus:group.identity");
308
+ expect(keys).toContain("publicstatus:group.state");
309
+ expect(keys).toContain("publicstatus:override.name");
310
+ expect(keys).not.toContain(fieldLabelKey("publicstatus", "component", "name"));
311
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
312
+ });
313
+
314
+ test("entityEdit: a section with fields AND groups yields the union of both", () => {
315
+ const screen: EntityEditScreenDefinition = {
316
+ id: "component-edit",
317
+ type: "entityEdit",
318
+ entity: "component",
319
+ layout: {
320
+ sections: [
321
+ {
322
+ fields: ["name"],
323
+ groups: [{ title: "publicstatus:group.state", fields: ["status"] }],
324
+ },
325
+ ],
326
+ },
327
+ };
328
+ const keys = requiredKeysFromScreen("publicstatus", screen);
329
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "name"));
330
+ expect(keys).toContain(fieldLabelKey("publicstatus", "component", "status"));
331
+ });
332
+
333
+ test("actionForm: group fields and group titles", () => {
334
+ const keys = requiredKeysFromScreen("publicstatus", {
335
+ id: "incident-open-form",
336
+ type: "actionForm",
337
+ handler: "publicstatus:write:incident:open",
338
+ fields: { title: { type: "text" }, note: { type: "text" } },
339
+ fieldLabels: { title: "publicstatus:override.title" },
340
+ layout: {
341
+ sections: [
342
+ {
343
+ fields: [],
344
+ groups: [{ title: "publicstatus:group.body", fields: ["title", "note"] }],
345
+ },
346
+ ],
347
+ },
348
+ });
349
+ expect(keys).toContain("publicstatus:group.body");
350
+ expect(keys).toContain("publicstatus:override.title");
351
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "note"));
352
+ });
353
+
354
+ test("secretMint: group fields and group titles in mint and confirm layout", () => {
355
+ const keys = requiredKeysFromScreen("publicstatus", {
356
+ id: "token-mint",
357
+ type: "secretMint",
358
+ handler: "publicstatus:write:token:mint",
359
+ fields: { label: { type: "text" } },
360
+ layout: {
361
+ sections: [
362
+ {
363
+ fields: [],
364
+ groups: [{ title: "publicstatus:group.mint", fields: ["label"] }],
365
+ },
366
+ ],
367
+ },
368
+ reveal: { fields: [{ field: "token", label: "publicstatus:reveal.token" }] },
369
+ confirm: {
370
+ handler: "publicstatus:write:token:confirm",
371
+ fields: { ack: { type: "boolean" } },
372
+ layout: {
373
+ sections: [
374
+ {
375
+ fields: [],
376
+ groups: [{ title: "publicstatus:group.confirm", fields: ["ack"] }],
377
+ },
378
+ ],
379
+ },
380
+ },
381
+ });
382
+ expect(keys).toContain("publicstatus:group.mint");
383
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "label"));
384
+ expect(keys).toContain("publicstatus:group.confirm");
385
+ expect(keys).toContain(fieldLabelKey("publicstatus", ACTION_FORM_ENTITY, "ack"));
386
+ });
387
+
388
+ test("configEdit: group fields and group titles, override honored", () => {
389
+ const screen: ConfigEditScreenDefinition = {
390
+ id: "settings-retention",
391
+ type: "configEdit",
392
+ scope: "tenant",
393
+ configKeys: {
394
+ days: "publicstatus:config:retentionDays",
395
+ mode: "publicstatus:config:mode",
396
+ },
397
+ fieldLabels: { days: "publicstatus:override.retentionDays" },
398
+ fields: { days: { type: "number" }, mode: { type: "text" } },
399
+ layout: {
400
+ sections: [
401
+ {
402
+ fields: [],
403
+ groups: [{ title: "publicstatus:group.retention", fields: ["days", "mode"] }],
404
+ },
405
+ ],
406
+ },
407
+ };
408
+ const keys = requiredKeysFromScreen("publicstatus", screen);
409
+ expect(keys).toContain("publicstatus:group.retention");
410
+ expect(keys).toContain("publicstatus:override.retentionDays");
411
+ expect(keys).toContain(fieldLabelKey("publicstatus", CONFIG_EDIT_ENTITY, "mode"));
412
+ });
413
+
414
+ test("configEdit: dot-form group titles need treatDotFormAsKey, like section titles", () => {
415
+ const screen: ConfigEditScreenDefinition = {
416
+ id: "settings-retention",
417
+ type: "configEdit",
418
+ scope: "tenant",
419
+ configKeys: { days: "publicstatus:config:retentionDays" },
420
+ fields: { days: { type: "number" } },
421
+ layout: {
422
+ sections: [
423
+ {
424
+ fields: [],
425
+ groups: [{ title: "publicstatus.group.retention", fields: ["days"] }],
426
+ },
427
+ ],
428
+ },
429
+ };
430
+ expect(requiredKeysFromScreen("publicstatus", screen)).not.toContain(
431
+ "publicstatus.group.retention",
432
+ );
433
+ expect(requiredKeysFromScreen("publicstatus", screen, { treatDotFormAsKey: true })).toContain(
434
+ "publicstatus.group.retention",
435
+ );
436
+ });
437
+
438
+ test("projectionDetail: group fields and group titles, override honored", () => {
439
+ const keys = requiredKeysFromScreen("publicstatus", {
440
+ id: "incident-detail",
441
+ type: "projectionDetail",
442
+ query: "publicstatus:query:incident:detail",
443
+ fieldLabels: { severity: "publicstatus:override.severity" },
444
+ layout: {
445
+ sections: [
446
+ {
447
+ fields: [],
448
+ groups: [{ title: "publicstatus:group.facts", fields: ["severity", "openedAt"] }],
449
+ },
450
+ ],
451
+ },
452
+ });
453
+ expect(keys).toContain("publicstatus:group.facts");
454
+ expect(keys).toContain("publicstatus:override.severity");
455
+ expect(keys).toContain(fieldLabelKey("publicstatus", PROJECTION_DETAIL_ENTITY, "openedAt"));
456
+ });
457
+
458
+ test("projectionDetail: writeForm sections keep the WRITE_FORM_SECTION_ENTITY namespace", () => {
459
+ const keys = requiredKeysFromScreen("publicstatus", {
460
+ id: "incident-detail",
461
+ type: "projectionDetail",
462
+ query: "publicstatus:query:incident:detail",
463
+ layout: {
464
+ sections: [
465
+ {
466
+ kind: "writeForm",
467
+ title: "publicstatus:section.comment",
468
+ handler: "publicstatus:write:incident:comment",
469
+ fieldDefs: { body: { type: "text" } },
470
+ fields: ["body"],
471
+ },
472
+ ],
473
+ },
474
+ });
475
+ expect(keys).toContain(fieldLabelKey("publicstatus", WRITE_FORM_SECTION_ENTITY, "body"));
476
+ });
477
+ });
@@ -0,0 +1,63 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { z } from "zod";
3
+ import { defineFeature } from "../../define-feature";
4
+ import { validateHandlerAccess } from "../entity-handler";
5
+
6
+ describe("validateHandlerAccess — anonymous handlers require a rateLimit", () => {
7
+ test("an anonymous handler with no rateLimit throws, naming the handler", () => {
8
+ const feature = defineFeature("rl-anon", (r) => {
9
+ r.writeHandler(
10
+ "note:create",
11
+ z.object({ title: z.string() }),
12
+ async () => ({
13
+ isSuccess: true as const,
14
+ data: {},
15
+ }),
16
+ {
17
+ access: { roles: ["anonymous", "Admin"] },
18
+ },
19
+ );
20
+ });
21
+
22
+ expect(() => validateHandlerAccess(feature)).toThrow(/rl-anon:write:note:create/);
23
+ expect(() => validateHandlerAccess(feature)).toThrow(/declares no rateLimit/);
24
+ });
25
+
26
+ test("an anonymous handler with rateLimit: { disabled: true, reason } boots fine", () => {
27
+ const feature = defineFeature("rl-anon", (r) => {
28
+ r.writeHandler(
29
+ "note:create",
30
+ z.object({ title: z.string() }),
31
+ async () => ({
32
+ isSuccess: true as const,
33
+ data: {},
34
+ }),
35
+ {
36
+ access: { roles: ["anonymous", "Admin"] },
37
+ rateLimit: { disabled: true, reason: "internal replay path" },
38
+ },
39
+ );
40
+ });
41
+
42
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
43
+ });
44
+
45
+ test("an anonymous handler with rateLimit: { per: 'ip', ... } boots fine", () => {
46
+ const feature = defineFeature("rl-anon", (r) => {
47
+ r.writeHandler(
48
+ "note:create",
49
+ z.object({ title: z.string() }),
50
+ async () => ({
51
+ isSuccess: true as const,
52
+ data: {},
53
+ }),
54
+ {
55
+ access: { roles: ["anonymous", "Admin"] },
56
+ rateLimit: { per: "ip", limit: 30, windowSeconds: 60 },
57
+ },
58
+ );
59
+ });
60
+
61
+ expect(() => validateHandlerAccess(feature)).not.toThrow();
62
+ });
63
+ });
@@ -177,15 +177,21 @@ export function validateAnonymousRateLimit(
177
177
  access: NonNullable<FeatureDefinition["writeHandlers"][string]["access"]>,
178
178
  rateLimit: FeatureDefinition["writeHandlers"][string]["rateLimit"],
179
179
  ): void {
180
- // skip: handler doesn't opt into rate-limit, no user-bucket risk
181
- if (!rateLimit) return;
182
- // skip: disabled declarations carry no `.per` to bucket on
183
- if (isRateLimitDisabled(rateLimit)) return;
184
180
  // skip: openToAll handlers don't allow anonymous (hasAccess rejects), so
185
181
  // the user-bucket footgun doesn't apply
186
182
  if (!("roles" in access)) return;
187
183
  // skip: handler doesn't list anonymous, regular role-rate-limit is fine
188
184
  if (!access.roles.includes("anonymous")) return;
185
+ // skip: disabled declarations carry no `.per` to bucket on
186
+ if (isRateLimitDisabled(rateLimit)) return;
187
+ if (!rateLimit) {
188
+ throw new Error(
189
+ `${kind} handler "${featureName}:${kind}:${handlerName}" allows anonymous callers but declares no ` +
190
+ `rateLimit — an anonymous, internet-facing endpoint needs an upper bound. Set rateLimit: ` +
191
+ `{ per: "ip", limit: ..., windowSeconds: ... }, or the documented exception ` +
192
+ `rateLimit: { disabled: true, reason: "..." }.`,
193
+ );
194
+ }
189
195
  // skip: rate-limit is already keyed on something safe (ip / tenant)
190
196
  if (!USER_BUCKETED_RATE_LIMIT_PER.has(rateLimit.per)) return;
191
197
  throw new Error(
@@ -3,17 +3,32 @@
3
3
  // Mirror of tenant-data.ts, but the destroyTenant hook takes (tenantId, ctx)
4
4
  // rather than just (ctx) — runExtensionDestroyHooks (tenant-lifecycle/stages.ts)
5
5
  // passes tenantId as its own positional arg for every EXT_*_RESOURCE-style
6
- // extension point, not just this one. ctx here only guarantees tenantId plus
6
+ // extension point, not just this one. ctx here guarantees tenantId, db, plus
7
7
  // the optional fileProviderResolver/log; the richer stage-runner ctx
8
8
  // (tenant-lifecycle's DestructionStageCtx) is a structural superset, so a hook
9
9
  // typed against this minimal ctx is safely assignable wherever that richer ctx
10
10
  // is passed.
11
-
11
+ //
12
+ // `db` is a raw DbRunner, NOT a tenant-scoped TenantDb — unlike
13
+ // TenantDataHookCtx.db (EXT_TENANT_DATA), runExtensionDestroyHooks hands every
14
+ // EXT_*_RESOURCE-style hook (this one included) the stage runner's own
15
+ // DestructionStageCtx.db straight through, with no tenant filter and no
16
+ // escapeHatch/unsafeRaw gate — there is nothing to declare, because nothing
17
+ // ever wraps it. That's deliberate for this stage family: wiping a whole
18
+ // search index, an S3 prefix, or infra resources is inherently a cross-tenant,
19
+ // bulk operation, not a row-scoped one. A hook that needs a plain read across
20
+ // tenants (kumiko-framework#3035: does a foreign tenant's file_refs row still
21
+ // reference a key under THIS tenant's storage prefix) uses `db` directly via
22
+ // executeRawQueryRead — there is no `ctx.systemDb` here (that belongs to
23
+ // HandlerContext / r.systemScope() write-handler dispatch, a different code
24
+ // path this stage runner never goes through).
12
25
  import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
26
+ import type { DbRunner } from "../../db/connection";
13
27
  import type { TenantId } from "../types";
14
28
 
15
29
  export interface StorageProviderHookCtx {
16
30
  readonly tenantId: TenantId;
31
+ readonly db: DbRunner;
17
32
  readonly fileProviderResolver?: FileProviderResolver;
18
33
  readonly log?: (message: string) => void;
19
34
  }
@@ -3,6 +3,7 @@
3
3
  // Mirror of engine/extensions/user-data.ts at tenant granularity.
4
4
  // tenant-lifecycle orchestrates destroy via registry.getExtensionUsages(EXT_TENANT_DATA).
5
5
 
6
+ import type { FileProviderResolver } from "@cosmicdrift/kumiko-types/file-provider-resolver-types";
6
7
  import type { TenantDb } from "../../db/tenant-db";
7
8
  import type { Registry, TenantId } from "../types";
8
9
 
@@ -12,6 +13,14 @@ export interface TenantDataHookCtx {
12
13
  readonly db: TenantDb;
13
14
  readonly registry: Registry;
14
15
  readonly tenantId: TenantId;
16
+ // Threaded through from DestructionStageCtx (tenant-lifecycle/stages.ts),
17
+ // which resolves it unconditionally for every stage. "app-data" is the
18
+ // only stage where a fileRef row still exists to read a storageKey off —
19
+ // the "files" stage's prefix sweep runs after this stage already purged
20
+ // the rows (kumiko-framework#3035). Undefined when no file-provider is
21
+ // wired; a hook that needs it must treat that as "nothing to clean up".
22
+ readonly fileProviderResolver?: FileProviderResolver;
23
+ readonly log?: (message: string) => void;
15
24
  }
16
25
 
17
26
  export type TenantDataDestroyHook = (ctx: TenantDataHookCtx) => Promise<void>;
@@ -472,6 +472,8 @@ export function createEntity<F, const T extends EntityTenancy = "tenant">(def: {
472
472
  readonly retention?: RetentionDef;
473
473
  readonly derivedFields?: EntityDefinition["derivedFields"];
474
474
  readonly tenancy?: T;
475
+ /** See EntityDefinition.transferable. */
476
+ readonly transferable?: boolean;
475
477
  }): F extends FieldsMap ? EntityDefinition<F, T> : never {
476
478
  return {
477
479
  softDelete: false,
@@ -98,6 +98,23 @@ export function isWriteFormEditSection(section: EditSectionSpec): section is Edi
98
98
  return section.kind === "writeForm";
99
99
  }
100
100
 
101
+ /** Structural shape of every section that can carry `fields` and/or `groups` —
102
+ * matches EditWriteFormSection (no `groups`) too, so callers need no narrowing. */
103
+ export type FieldsOrGroupsSection = {
104
+ readonly fields: readonly EditFieldSpec[];
105
+ readonly groups?: readonly {
106
+ readonly title: string;
107
+ readonly fields: readonly EditFieldSpec[];
108
+ }[];
109
+ };
110
+
111
+ // Union of both sources, unlike the boot-validator's either-or flattening: that
112
+ // one runs after the fields-XOR-groups check, collectors run without it and must
113
+ // not drop a source (fw#2986).
114
+ export function sectionFieldSpecs(section: FieldsOrGroupsSection): readonly EditFieldSpec[] {
115
+ return [...section.fields, ...(section.groups?.flatMap((group) => group.fields) ?? [])];
116
+ }
117
+
101
118
  // Type guard — narrows FieldRenderer to FormatSpec. Useful for renderer
102
119
  // authors who branch on the three FieldRenderer variants without manual
103
120
  // "format" in renderer checks.
@@ -138,6 +138,30 @@ export class VersionConflictError extends ConflictError {
138
138
  }
139
139
  }
140
140
 
141
+ // `update()`'s optional `expect:` precondition (kumiko-framework#3024) —
142
+ // a freshly-read row no longer matches the caller's declared field values.
143
+ // Distinct from VersionConflictError: that one guards the stream version,
144
+ // this one guards an arbitrary business field a lifecycle transition
145
+ // declared ("only from status Active"). Same 409 family: the client can
146
+ // resolve it by re-checking the entity's current state.
147
+ export type PreconditionFailedDetails = {
148
+ readonly entityId: number | string;
149
+ readonly field: string;
150
+ };
151
+
152
+ export class PreconditionFailedError extends ConflictError {
153
+ override readonly code: string = "precondition_failed";
154
+
155
+ constructor(details: PreconditionFailedDetails, opts?: Pick<ErrorOpts, "i18nKey" | "cause">) {
156
+ super({
157
+ message: `precondition failed for entity ${details.entityId}: field "${details.field}" no longer matches the expected value`,
158
+ i18nKey: opts?.i18nKey ?? "errors.preconditionFailed",
159
+ details,
160
+ ...(opts?.cause && { cause: opts.cause }),
161
+ });
162
+ }
163
+ }
164
+
141
165
  // The caller retried a write with an idempotencyKey it had already used —
142
166
  // the event-store's partial unique index on metadata.idempotencyKey caught
143
167
  // the duplicate. Distinct from unique_violation/version_conflict: this is
@@ -2,6 +2,7 @@ export type {
2
2
  FeatureDisabledDetails,
3
3
  FieldIssue,
4
4
  NotFoundDetails,
5
+ PreconditionFailedDetails,
5
6
  RateLimitDetails,
6
7
  UnconfiguredDetails,
7
8
  UniqueViolationDetails,
@@ -16,6 +17,7 @@ export {
16
17
  IdempotentReplayError,
17
18
  InternalError,
18
19
  NotFoundError,
20
+ PreconditionFailedError,
19
21
  RateLimitError,
20
22
  UnconfiguredError,
21
23
  UniqueViolationError,
@@ -25,6 +27,7 @@ export {
25
27
  } from "./classes";
26
28
  export type { ErrorCtorInput, ErrorOpts } from "./kumiko-error";
27
29
  export { isKumikoError, KumikoError } from "./kumiko-error";
30
+ export { memberResolutionReadOnlyDenied } from "./member-resolution";
28
31
  export type { AgentReason, FrameworkReason } from "./reasons";
29
32
  export { AgentReasons, FrameworkReasons } from "./reasons";
30
33
  export type { ErrorLogEntry, ErrorResponseBody } from "./serialize";
@@ -0,0 +1,12 @@
1
+ import { AccessDeniedError } from "./classes";
2
+ import { FrameworkReasons } from "./reasons";
3
+
4
+ // Lives in errors/ (not pipeline/) so db/tenant-db.ts can throw it without importing pipeline.
5
+ export function memberResolutionReadOnlyDenied(cause?: unknown): AccessDeniedError {
6
+ return new AccessDeniedError({
7
+ message:
8
+ "a resolved member principal (ctx.queryAsMember) cannot write or reach raw SQL — read-only",
9
+ details: { reason: FrameworkReasons.memberResolutionReadOnly },
10
+ ...(cause instanceof Error && { cause }),
11
+ });
12
+ }
@@ -2,7 +2,7 @@ import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:tes
2
2
  import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
3
3
  import { asRawClient, transaction } from "../../db/query";
4
4
  import { createTenantDb, type TenantDb } from "../../db/tenant-db";
5
- import { InternalError } from "../../errors";
5
+ import { AccessDeniedError, InternalError } from "../../errors";
6
6
  import { ensureTemporalPolyfill } from "../../time/polyfill";
7
7
  import { generateId as uuid } from "../../utils";
8
8
  import { createEventsTable, loadAggregate, VersionConflictError } from "../index";
@@ -12,6 +12,14 @@ let testDb: BunTestDb;
12
12
  let tdb: TenantDb;
13
13
 
14
14
  const tenantA = uuid();
15
+ const tenantB = uuid();
16
+
17
+ async function countAllEvents(): Promise<number> {
18
+ const rows = await asRawClient(testDb.db).unsafe<{ n: string }>(
19
+ "SELECT count(*)::text AS n FROM kumiko_events",
20
+ );
21
+ return Number(rows[0]?.n);
22
+ }
15
23
 
16
24
  function provenanceEvent(overrides: Partial<ProvenanceEventInput>): ProvenanceEventInput {
17
25
  return {
@@ -106,4 +114,28 @@ describe("appendProvenanceEvent", () => {
106
114
  const events = await loadAggregate(testDb.db, event.aggregateId, tenantA);
107
115
  expect(events).toHaveLength(0);
108
116
  });
117
+
118
+ test("a foreign event.tenantId is rejected and writes no row at all", async () => {
119
+ const event = provenanceEvent({ tenantId: tenantB });
120
+ const before = await countAllEvents();
121
+
122
+ await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(AccessDeniedError);
123
+ await expect(appendProvenanceEvent(tdb, event)).rejects.toThrow(
124
+ new RegExp(`"${tenantB}".+"${tenantA}"`),
125
+ );
126
+
127
+ expect(await countAllEvents()).toBe(before);
128
+ expect(await loadAggregate(testDb.db, event.aggregateId, tenantB)).toHaveLength(0);
129
+ });
130
+
131
+ test('a mode: "system" TenantDb is rejected for a foreign event.tenantId too', async () => {
132
+ const systemTdb = createTenantDb(testDb.db, tenantA, "system");
133
+ const event = provenanceEvent({ tenantId: tenantB });
134
+ const before = await countAllEvents();
135
+
136
+ await expect(appendProvenanceEvent(systemTdb, event)).rejects.toThrow(AccessDeniedError);
137
+
138
+ expect(await countAllEvents()).toBe(before);
139
+ expect(await loadAggregate(testDb.db, event.aggregateId, tenantB)).toHaveLength(0);
140
+ });
109
141
  });
@@ -1,7 +1,7 @@
1
1
  import { runInSavepointIfSupported } from "../db/query";
2
2
  import { type TenantDb, unsafeRawForDeclaredStep, withUnsafeRawGrant } from "../db/tenant-db";
3
3
  import type { TenantId } from "../engine/types";
4
- import { InternalError } from "../errors";
4
+ import { AccessDeniedError, InternalError } from "../errors";
5
5
  import { SYSTEM_EVENT_PREFIX } from "../pipeline/append-event-core";
6
6
  import { append, type EventMetadata, getStreamVersion } from "./event-store";
7
7
 
@@ -38,6 +38,14 @@ export async function appendProvenanceEvent(
38
38
  }
39
39
  const granted = withUnsafeRawGrant(db, { reason: PROVENANCE_APPEND_REASON });
40
40
  const runner = unsafeRawForDeclaredStep(granted, PROVENANCE_APPEND_REASON);
41
+ // Unconditional, including mode "system": crossTenantRebinders keeps the
42
+ // original tenantId and only flips the mode, so a system-scoped db must not
43
+ // append for a foreign tenant either.
44
+ if (event.tenantId !== db.tenantId) {
45
+ throw new AccessDeniedError({
46
+ message: `appendProvenanceEvent: event tenant "${event.tenantId}" does not match the TenantDb's tenant "${db.tenantId}".`,
47
+ });
48
+ }
41
49
  // Bun.SQL/postgres.js abort the whole surrounding begin() on a statement
42
50
  // error (25P02) even when the caller's catch swallows it — the savepoint
43
51
  // confines that to this scope. Pool connections have no ambient tx to poison.
@@ -1,5 +1,10 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { createFileContext, createFileHandle, deriveKey } from "../file-handle";
2
+ import {
3
+ createFileContext,
4
+ createFileHandle,
5
+ deriveKey,
6
+ storageKeyStemPrefix,
7
+ } from "../file-handle";
3
8
  import { createInMemoryFileProvider } from "../in-memory-provider";
4
9
 
5
10
  describe("deriveKey", () => {
@@ -24,6 +29,28 @@ describe("deriveKey", () => {
24
29
  });
25
30
  });
26
31
 
32
+ describe("storageKeyStemPrefix", () => {
33
+ test("matches the original key and every derived variant", () => {
34
+ const prefix = storageKeyStemPrefix("tenant/vehicle/1/photo/abc.jpg");
35
+ expect("tenant/vehicle/1/photo/abc.jpg".startsWith(prefix)).toBe(true);
36
+ expect(deriveKey("tenant/vehicle/1/photo/abc.jpg", "medium").startsWith(prefix)).toBe(true);
37
+ expect(deriveKey("tenant/vehicle/1/photo/abc.jpg", "thumb").startsWith(prefix)).toBe(true);
38
+ });
39
+
40
+ test("does not match an unrelated key with the same directory", () => {
41
+ const prefix = storageKeyStemPrefix("tenant/vehicle/1/photo/abc.jpg");
42
+ expect("tenant/vehicle/1/photo/abcdef.jpg".startsWith(prefix)).toBe(false);
43
+ });
44
+
45
+ test("appends a dot for a key without an extension", () => {
46
+ expect(storageKeyStemPrefix("tenant/bar")).toBe("tenant/bar.");
47
+ });
48
+
49
+ test("only splits on the last segment — earlier dots stay", () => {
50
+ expect(storageKeyStemPrefix("archive.v2/foo.jpg")).toBe("archive.v2/foo.");
51
+ });
52
+ });
53
+
27
54
  describe("FileHandle", () => {
28
55
  test("read/write round-trip through the provider", async () => {
29
56
  const provider = createInMemoryFileProvider();
@@ -49,16 +49,35 @@ export function createFileContext(resolve: () => Promise<FileStorageProvider>):
49
49
  };
50
50
  }
51
51
 
52
+ // Splits a key into the part before the last dot of its last path segment
53
+ // (the "stem") and the extension including the dot. No dot in the last
54
+ // segment: the whole key is the stem, extension is empty.
55
+ function splitKeyExtension(key: string): { readonly stem: string; readonly ext: string } {
56
+ const lastSlash = key.lastIndexOf("/");
57
+ const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
58
+ const lastDot = lastSegment.lastIndexOf(".");
59
+ if (lastDot === -1) return { stem: key, ext: "" };
60
+ const stem = key.slice(0, key.length - lastSegment.length + lastDot);
61
+ const ext = lastSegment.slice(lastDot);
62
+ return { stem, ext };
63
+ }
64
+
52
65
  // Inserts a suffix before the file extension. Keys without an extension get
53
66
  // the suffix appended with a dot: `foo/bar` + `"small"` → `foo/bar.small`.
54
67
  // Keys with a dot earlier in the path (e.g. `archive.v2/foo.jpg`) correctly
55
68
  // split on the LAST segment only.
56
69
  export function deriveKey(key: string, suffix: string): string {
57
- const lastSlash = key.lastIndexOf("/");
58
- const lastSegment = lastSlash === -1 ? key : key.slice(lastSlash + 1);
59
- const lastDot = lastSegment.lastIndexOf(".");
60
- if (lastDot === -1) return `${key}.${suffix}`;
61
- const prefix = key.slice(0, key.length - lastSegment.length + lastDot);
62
- const ext = lastSegment.slice(lastDot);
63
- return `${prefix}.${suffix}${ext}`;
70
+ const { stem, ext } = splitKeyExtension(key);
71
+ return ext === "" ? `${key}.${suffix}` : `${stem}.${suffix}${ext}`;
72
+ }
73
+
74
+ // The prefix that covers a key AND every `derive()`d variant of it —
75
+ // `foo/bar.jpg` → `foo/bar.`, matching `foo/bar.jpg` and `foo/bar.medium.jpg`
76
+ // alike, since derive() only ever inserts a suffix between the stem and the
77
+ // extension. Used by the tenant-handover destroy sweep (kumiko-framework#3035)
78
+ // to reach a moved fileRef's original plus every already-rendered derivative,
79
+ // none of which have their own `file_refs` row.
80
+ export function storageKeyStemPrefix(key: string): string {
81
+ const { stem, ext } = splitKeyExtension(key);
82
+ return ext === "" ? `${key}.` : `${stem}.`;
64
83
  }
@@ -2,7 +2,7 @@ export { createFilesFeature } from "./feature";
2
2
  export type { FileContext, FileHandle } from "./file-handle";
3
3
  // `createFileHandle` is an implementation detail — construct handles via
4
4
  // `createFileContext(provider).ref(key)`, which is the AppContext surface.
5
- export { createFileContext, deriveKey } from "./file-handle";
5
+ export { createFileContext, deriveKey, storageKeyStemPrefix } from "./file-handle";
6
6
  export { fileRefEntity } from "./file-ref-entity";
7
7
  export { fileRefsTable } from "./file-ref-table";
8
8
  export type {