@cosmicdrift/kumiko-framework 0.288.0 → 0.290.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 (75) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/field-access.integration.test.ts +7 -3
  3. package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
  4. package/src/__tests__/ownership.integration.test.ts +1 -1
  5. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  6. package/src/api/request-context.ts +28 -0
  7. package/src/api/server.ts +30 -4
  8. package/src/changes.json +75 -0
  9. package/src/compliance/__tests__/sub-processors.test.ts +10 -0
  10. package/src/compliance/sub-processors.ts +14 -1
  11. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  12. package/src/crypto/__tests__/kek-source.test.ts +298 -0
  13. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  14. package/src/crypto/__tests__/subject-resolver.test.ts +2 -2
  15. package/src/crypto/index.ts +3 -0
  16. package/src/crypto/kek-source.ts +189 -0
  17. package/src/crypto/kms-wiring.ts +20 -0
  18. package/src/db/__tests__/blind-index.integration.test.ts +1 -1
  19. package/src/db/__tests__/eagerload.integration.test.ts +12 -2
  20. package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
  21. package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
  22. package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
  23. package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +194 -1
  24. package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
  25. package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
  26. package/src/db/__tests__/tenant-db-declared-unsafe-raw.test.ts +60 -1
  27. package/src/db/event-store-executor-context.ts +79 -0
  28. package/src/db/event-store-executor-write.ts +42 -5
  29. package/src/db/index.ts +1 -0
  30. package/src/db/tenant-db.ts +8 -1
  31. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  32. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  33. package/src/engine/__tests__/boot-validator-i18n-keys.test.ts +56 -0
  34. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +26 -15
  35. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  36. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  37. package/src/engine/__tests__/required-surface-keys.test.ts +196 -0
  38. package/src/engine/boot-validator/__tests__/anonymous-rate-limit-required.test.ts +63 -0
  39. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  40. package/src/engine/boot-validator/action-wiring.ts +2 -1
  41. package/src/engine/boot-validator/entity-handler.ts +10 -4
  42. package/src/engine/boot-validator/screens.ts +4 -12
  43. package/src/engine/extensions/storage-provider.ts +17 -2
  44. package/src/engine/extensions/tenant-data.ts +9 -0
  45. package/src/engine/factories.ts +2 -0
  46. package/src/engine/index.ts +1 -0
  47. package/src/engine/qualified-name.ts +9 -0
  48. package/src/engine/screen-helpers.ts +17 -0
  49. package/src/errors/classes.ts +24 -0
  50. package/src/errors/index.ts +3 -0
  51. package/src/errors/member-resolution.ts +12 -0
  52. package/src/event-store/__tests__/backfill-pii.integration.test.ts +2 -2
  53. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  54. package/src/event-store/__tests__/provenance-append.integration.test.ts +33 -1
  55. package/src/event-store/event-store.ts +23 -2
  56. package/src/event-store/provenance-append.ts +9 -1
  57. package/src/files/__tests__/file-handle.test.ts +28 -1
  58. package/src/files/file-handle.ts +26 -7
  59. package/src/files/index.ts +1 -1
  60. package/src/i18n/required-surface-keys.ts +24 -12
  61. package/src/jobs/job-runner.ts +10 -2
  62. package/src/pipeline/__tests__/member-resolution-read-only.test.ts +79 -0
  63. package/src/pipeline/active-membership.ts +10 -4
  64. package/src/pipeline/append-event-core.ts +2 -11
  65. package/src/pipeline/dispatch-shared.ts +31 -18
  66. package/src/pipeline/dispatch-stream.ts +8 -3
  67. package/src/pipeline/dispatch-write.ts +3 -2
  68. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  69. package/src/pipeline/event-dispatcher.ts +18 -5
  70. package/src/pipeline/member-read-only-transaction.ts +2 -6
  71. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  72. package/src/stack/table-helpers.ts +6 -0
  73. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  74. package/src/testing/e2e-generator.ts +4 -3
  75. package/src/ui-types/index.ts +2 -0
@@ -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
+ }
@@ -42,7 +42,7 @@ const BIDX_KEY = Buffer.alloc(32, 5).toString("base64");
42
42
  const contactEntity = createEntity({
43
43
  fields: {
44
44
  email: createTextField({ required: true, personal: "self", find: "exact" }),
45
- displayName: createTextField(),
45
+ displayName: createTextField({ personal: false, reason: "test_fixture" }),
46
46
  },
47
47
  });
48
48
  const contactTable = buildEntityTable("contact", contactEntity);
@@ -82,7 +82,7 @@ const signalsFeature = defineFeature("signals", (r) => {
82
82
  // absent from an old event's payload, unlike contact.email (self-subject via row id).
83
83
  const noteEntity = createEntity({
84
84
  fields: {
85
- authorId: createTextField(),
85
+ authorId: createTextField({ personal: false, reason: "test_fixture" }),
86
86
  body: createTextField({ personal: { of: "authorId" }, find: "none" }),
87
87
  },
88
88
  });
@@ -0,0 +1,186 @@
1
+ // #3043 — event attribution. append() stamps metadata.feature +
2
+ // metadata.handler from the ambient execution scope, below every envelope
3
+ // builder, so no writer can forget it.
4
+ //
5
+ // Claims pinned here:
6
+ // 1. Dispatch path: an event a write-handler appends carries the handler's
7
+ // qualified name and its owning feature.
8
+ // 2. In-process path: the entity-executor's CRUD event carries the SAME
9
+ // attribution although nothing was passed through its signature.
10
+ // 3. MSP-apply: an event an apply writes is attributed to the consumer, not
11
+ // to the handler that started the chain.
12
+ // 4. No scope: a bare append() stamps the sentinel, never a guess.
13
+ // 5. appendRaw stays unstamped — historical rows keep their metadata verbatim
14
+ // and stay readable without the new fields.
15
+
16
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
17
+ import { UNATTRIBUTED_ORIGIN } from "@cosmicdrift/kumiko-types/event-store-types";
18
+ import { z } from "zod";
19
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
20
+ import { selectMany } from "../../db/query";
21
+ import { buildEntityTable } from "../../db/table-builder";
22
+ import { createEntity, createTextField, defineFeature } from "../../engine";
23
+ import {
24
+ resetEventStore,
25
+ setupTestStack,
26
+ type TestStack,
27
+ TestUsers,
28
+ unsafeCreateEntityTable,
29
+ } from "../../stack";
30
+ import { generateId as uuid } from "../../utils";
31
+ import { appendRaw } from "../admin-api";
32
+ import { append } from "../event-store";
33
+ import { eventsTable } from "../events-schema";
34
+
35
+ const orderEntity = createEntity({
36
+ table: "read_attribution_orders",
37
+ fields: {
38
+ item: createTextField({ personal: false, reason: "test_fixture", required: true }),
39
+ },
40
+ });
41
+
42
+ const orderTable = buildEntityTable("attr-order", orderEntity);
43
+
44
+ const PLACED = "attribution:event:placed";
45
+ const CONFIRMED = "attribution:event:confirmed";
46
+ const PLACE_HANDLER = "attribution:write:order:place";
47
+ const CONFIRMER_MSP = "attribution:projection:confirmer";
48
+
49
+ const attributionFeature = defineFeature("attribution", (r) => {
50
+ r.entity("attr-order", orderEntity);
51
+
52
+ const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
53
+ const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
54
+ piiFields: "none",
55
+ });
56
+
57
+ const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
58
+ entityName: "attr-order",
59
+ });
60
+
61
+ r.writeHandler(
62
+ "order:place",
63
+ z.object({ item: z.string() }),
64
+ async (event, ctx) => {
65
+ const created = await orderExecutor.create({ item: event.payload.item }, event.user, ctx.db);
66
+ if (!created.isSuccess) return created;
67
+ await ctx.unsafeAppendEvent({
68
+ aggregateId: String(created.data.id),
69
+ aggregateType: "attr-order",
70
+ type: placed.name,
71
+ payload: { orderId: String(created.data.id) },
72
+ });
73
+ return created;
74
+ },
75
+ { access: { roles: ["Admin"] } },
76
+ );
77
+
78
+ r.multiStreamProjection({
79
+ name: "confirmer",
80
+ apply: {
81
+ [placed.name]: async (event, _tx, ctx) => {
82
+ if (!ctx) throw new Error("MSP-apply ctx missing — regression of C.2b wiring");
83
+ await ctx.unsafeAppendEvent({
84
+ aggregateId: event.aggregateId,
85
+ aggregateType: "attr-order",
86
+ type: confirmed.name,
87
+ payload: { orderId: event.aggregateId },
88
+ });
89
+ },
90
+ },
91
+ });
92
+ });
93
+
94
+ let stack: TestStack;
95
+ const admin = TestUsers.admin;
96
+
97
+ beforeAll(async () => {
98
+ stack = await setupTestStack({ features: [attributionFeature], systemHooks: [] });
99
+ await unsafeCreateEntityTable(stack.db, orderEntity, "attr-order");
100
+ });
101
+
102
+ afterAll(async () => {
103
+ await stack.cleanup();
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await resetEventStore(stack, ["read_attribution_orders"]);
108
+ });
109
+
110
+ type Origin = { feature?: string; handler?: string };
111
+
112
+ async function originOf(type: string): Promise<Origin> {
113
+ const rows = await selectMany(stack.db, eventsTable);
114
+ const row = rows.find((r: Record<string, unknown>) => r["type"] === type);
115
+ expect(row).toBeDefined();
116
+ return (row?.["metadata"] ?? {}) as Origin;
117
+ }
118
+
119
+ describe("#3043 — event attribution from the execution scope", () => {
120
+ test("dispatch path: ctx.appendEvent stamps the handler and its feature", async () => {
121
+ await stack.http.writeOk(PLACE_HANDLER, { item: "widget" }, admin);
122
+
123
+ expect(await originOf(PLACED)).toMatchObject({
124
+ feature: "attribution",
125
+ handler: PLACE_HANDLER,
126
+ });
127
+ });
128
+
129
+ test("in-process path: the entity-executor CRUD event carries the same attribution", async () => {
130
+ await stack.http.writeOk(PLACE_HANDLER, { item: "sprocket" }, admin);
131
+
132
+ // Nothing is threaded through createEntityExecutor → EventStoreExecutor.write —
133
+ // the write runs inside the handler's scope, so the stamp finds it anyway.
134
+ expect(await originOf("attr-order.created")).toMatchObject({
135
+ feature: "attribution",
136
+ handler: PLACE_HANDLER,
137
+ });
138
+ });
139
+
140
+ test("MSP-apply: the follow-up event is attributed to the consumer, not the trigger", async () => {
141
+ await stack.http.writeOk(PLACE_HANDLER, { item: "gasket" }, admin);
142
+ await stack.eventDispatcher?.runOnce();
143
+
144
+ expect(await originOf(CONFIRMED)).toMatchObject({
145
+ feature: "attribution",
146
+ handler: CONFIRMER_MSP,
147
+ });
148
+ });
149
+
150
+ test("no scope: a bare append() stamps the sentinel instead of guessing", async () => {
151
+ const aggregateId = uuid();
152
+ await append(stack.db, {
153
+ aggregateId,
154
+ aggregateType: "attr-order",
155
+ tenantId: admin.tenantId,
156
+ expectedVersion: 0,
157
+ type: PLACED,
158
+ payload: { orderId: aggregateId },
159
+ metadata: { userId: admin.id },
160
+ });
161
+
162
+ expect(await originOf(PLACED)).toMatchObject({
163
+ feature: UNATTRIBUTED_ORIGIN,
164
+ handler: UNATTRIBUTED_ORIGIN,
165
+ });
166
+ });
167
+
168
+ test("appendRaw keeps historical metadata verbatim and stays readable", async () => {
169
+ const aggregateId = uuid();
170
+ await appendRaw(stack.db, {
171
+ aggregateId,
172
+ aggregateType: "attr-order",
173
+ tenantId: admin.tenantId,
174
+ expectedVersion: 0,
175
+ type: CONFIRMED,
176
+ payload: { orderId: aggregateId },
177
+ metadata: { userId: admin.id },
178
+ createdAt: Temporal.Instant.from("2023-01-15T10:00:00Z"),
179
+ createdBy: admin.id,
180
+ });
181
+
182
+ const origin = await originOf(CONFIRMED);
183
+ expect(origin.feature).toBeUndefined();
184
+ expect(origin.handler).toBeUndefined();
185
+ });
186
+ });
@@ -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,4 +1,8 @@
1
- import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event-store-types";
1
+ import {
2
+ type EventMetadata,
3
+ type StoredEvent,
4
+ UNATTRIBUTED_ORIGIN,
5
+ } from "@cosmicdrift/kumiko-types/event-store-types";
2
6
  // Value-only import, aliased to avoid shadowing the ambient global
3
7
  // `Temporal` TYPE this file's other Temporal.Instant annotations resolve
4
8
  // against (StoredEvent.createdAt et al. — importing the bare name here
@@ -6,6 +10,7 @@ import type { EventMetadata, StoredEvent } from "@cosmicdrift/kumiko-types/event
6
10
  // as a runtime value on globalThis, so the un-aliased call below crashed
7
11
  // with "Temporal is not defined" outside boot paths that install it (#1480).
8
12
  import { Temporal as TemporalPolyfill } from "temporal-polyfill";
13
+ import { requestContext } from "../api/request-context";
9
14
  import { encryptEventPayloadPii } from "../crypto/event-pii";
10
15
  import type { DbRunner } from "../db";
11
16
  import { constraintOf, isUniqueViolation } from "../db/pg-error";
@@ -83,7 +88,7 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
83
88
  aggregateType: event.aggregateType,
84
89
  aggregateId: event.aggregateId,
85
90
  });
86
- const toStore = payload === event.payload ? event : { ...event, payload };
91
+ const toStore = stampOrigin(payload === event.payload ? event : { ...event, payload });
87
92
  const newVersion = toStore.expectedVersion + 1;
88
93
  const eventVersion = toStore.eventVersion ?? 1;
89
94
 
@@ -116,6 +121,22 @@ export async function append(db: DbRunner, event: EventToAppend): Promise<Stored
116
121
  }
117
122
  }
118
123
 
124
+ // #3043 — attribution is derived from the execution scope, never taken from
125
+ // the caller: a passed-in value can lie about who wrote the row, a derived
126
+ // one cannot. Hence overwrite rather than merge. appendRaw/appendRawBatch
127
+ // bypass this deliberately — they replay historical rows verbatim.
128
+ function stampOrigin(event: EventToAppend): EventToAppend {
129
+ const origin = requestContext.get();
130
+ return {
131
+ ...event,
132
+ metadata: {
133
+ ...event.metadata,
134
+ feature: origin?.feature ?? UNATTRIBUTED_ORIGIN,
135
+ handler: origin?.handler ?? UNATTRIBUTED_ORIGIN,
136
+ },
137
+ };
138
+ }
139
+
119
140
  type InsertReturn = { id: bigint; createdAt: Temporal.Instant };
120
141
 
121
142
  async function insertFirstEvent(
@@ -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 {
@@ -1,8 +1,10 @@
1
1
  import { isExplicitDotFormKey } from "../engine/i18n-key";
2
+ import type { FieldsOrGroupsSection } from "../engine/screen-helpers";
2
3
  import {
3
4
  isExtensionEditSection,
4
5
  isWriteFormEditSection,
5
6
  normalizeListColumn,
7
+ sectionFieldSpecs,
6
8
  } from "../engine/screen-helpers";
7
9
  import type {
8
10
  ActionFormScreenDefinition,
@@ -82,6 +84,16 @@ function editFieldName(f: string | { readonly field: string }): string {
82
84
  return typeof f === "string" ? f : f.field;
83
85
  }
84
86
 
87
+ // Group titles are translated exactly like the section title (computeEditViewModel).
88
+ function pushSectionTitles(
89
+ out: Set<string>,
90
+ section: { readonly title?: string } & FieldsOrGroupsSection,
91
+ treatAsKey = false,
92
+ ): void {
93
+ pushKey(out, section.title, treatAsKey);
94
+ for (const group of section.groups ?? []) pushKey(out, group.title, treatAsKey);
95
+ }
96
+
85
97
  function pushRowActionKeys(out: Set<string>, action: RowAction): void {
86
98
  pushKey(out, action.label);
87
99
  if (action.kind === "writeHandler" || action.kind === undefined) {
@@ -182,8 +194,8 @@ export function requiredKeysFromScreen(
182
194
  continue;
183
195
  }
184
196
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
185
- pushKey(out, section.title);
186
- for (const f of section.fields) {
197
+ pushSectionTitles(out, section);
198
+ for (const f of sectionFieldSpecs(section)) {
187
199
  const fieldName = editFieldName(f);
188
200
  const override = edit.fieldLabels?.[fieldName];
189
201
  if (override !== undefined) pushKey(out, override);
@@ -206,8 +218,8 @@ export function requiredKeysFromScreen(
206
218
  continue;
207
219
  }
208
220
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
209
- pushKey(out, section.title);
210
- for (const f of section.fields) {
221
+ pushSectionTitles(out, section);
222
+ for (const f of sectionFieldSpecs(section)) {
211
223
  const fieldName = editFieldName(f);
212
224
  const override = form.fieldLabels?.[fieldName];
213
225
  if (override !== undefined) pushKey(out, override);
@@ -230,8 +242,8 @@ export function requiredKeysFromScreen(
230
242
  continue;
231
243
  }
232
244
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
233
- pushKey(out, section.title);
234
- for (const f of section.fields) {
245
+ pushSectionTitles(out, section);
246
+ for (const f of sectionFieldSpecs(section)) {
235
247
  const fieldName = editFieldName(f);
236
248
  out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
237
249
  }
@@ -254,8 +266,8 @@ export function requiredKeysFromScreen(
254
266
  continue;
255
267
  }
256
268
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
257
- pushKey(out, section.title);
258
- for (const f of section.fields) {
269
+ pushSectionTitles(out, section);
270
+ for (const f of sectionFieldSpecs(section)) {
259
271
  const fieldName = editFieldName(f);
260
272
  out.add(fieldLabelKey(featureName, ACTION_FORM_ENTITY, fieldName));
261
273
  }
@@ -277,8 +289,8 @@ export function requiredKeysFromScreen(
277
289
  continue;
278
290
  }
279
291
  if (section.kind === "relatedList") continue; // rejected at boot, unreachable here
280
- pushKey(out, section.title, treatDotFormAsKey);
281
- for (const f of section.fields) {
292
+ pushSectionTitles(out, section, treatDotFormAsKey);
293
+ for (const f of sectionFieldSpecs(section)) {
282
294
  const fieldName = editFieldName(f);
283
295
  const override = config.fieldLabels?.[fieldName];
284
296
  if (override !== undefined) pushKey(out, override, treatDotFormAsKey);
@@ -322,8 +334,8 @@ export function requiredKeysFromScreen(
322
334
  }
323
335
  continue;
324
336
  }
325
- pushKey(out, section.title);
326
- for (const f of section.fields) {
337
+ pushSectionTitles(out, section);
338
+ for (const f of sectionFieldSpecs(section)) {
327
339
  const fieldName = editFieldName(f);
328
340
  const override = detail.fieldLabels?.[fieldName];
329
341
  if (override !== undefined) pushKey(out, override);
@@ -4,6 +4,7 @@ import { requestContext } from "../api/request-context";
4
4
  import type { DbConnection, DbRow } from "../db/connection";
5
5
  import { createTenantDb, createUncheckedSystemDb, type TenantDb } from "../db/tenant-db";
6
6
  import { createDerivativesContext } from "../derivatives/derivatives-context";
7
+ import { qnScope } from "../engine/qualified-name";
7
8
  import { createSystemUser } from "../engine/system-user";
8
9
  import {
9
10
  type AppContext,
@@ -665,8 +666,15 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
665
666
 
666
667
  const runInSpan = async (): Promise<void> => {
667
668
  try {
668
- await requestContext.run({ requestId: jobRequestId, correlationId: jobCorrelationId }, () =>
669
- jobDef.handler(payload, jobContext),
669
+ await requestContext.run(
670
+ {
671
+ requestId: jobRequestId,
672
+ correlationId: jobCorrelationId,
673
+ // #3043 — events a job writes carry the job as their origin.
674
+ handler: jobName,
675
+ feature: qnScope(jobName),
676
+ },
677
+ () => jobDef.handler(payload, jobContext),
670
678
  );
671
679
  const duration = Date.now() - startTime;
672
680
  await options.onJobComplete?.(jobName, jobId, duration, logs);