@cosmicdrift/kumiko-framework 0.289.0 → 0.291.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 (68) 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/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
  6. package/src/api/__tests__/server-boot-guards.test.ts +42 -0
  7. package/src/api/__tests__/server-error-logging.test.ts +168 -17
  8. package/src/api/request-context.ts +31 -0
  9. package/src/api/request-id-middleware.ts +2 -1
  10. package/src/api/routes.ts +35 -3
  11. package/src/api/server.ts +30 -4
  12. package/src/changes.json +69 -0
  13. package/src/crypto/__tests__/blind-index.test.ts +1 -1
  14. package/src/crypto/__tests__/event-pii.test.ts +110 -9
  15. package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
  16. package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
  17. package/src/crypto/subject-resolver.ts +25 -8
  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 +3 -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/queries/shadow-swap.ts +35 -0
  27. package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
  28. package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
  29. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
  30. package/src/engine/__tests__/boot-validator.test.ts +226 -0
  31. package/src/engine/__tests__/build-app-schema.test.ts +18 -0
  32. package/src/engine/__tests__/engine.test.ts +87 -0
  33. package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
  34. package/src/engine/__tests__/factories-long-text.test.ts +6 -1
  35. package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
  36. package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
  37. package/src/engine/boot-validator/action-wiring.ts +2 -1
  38. package/src/engine/boot-validator/entity-handler.ts +44 -0
  39. package/src/engine/boot-validator/index.ts +7 -2
  40. package/src/engine/boot-validator/pii-retention.ts +8 -0
  41. package/src/engine/boot-validator/screens.ts +54 -12
  42. package/src/engine/create-app.ts +54 -0
  43. package/src/engine/feature-config-events-jobs.ts +19 -0
  44. package/src/engine/index.ts +2 -0
  45. package/src/engine/qualified-name.ts +9 -0
  46. package/src/engine/screen-helpers.ts +1 -0
  47. package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
  48. package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
  49. package/src/event-store/event-store.ts +23 -2
  50. package/src/i18n/required-surface-keys.ts +1 -0
  51. package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
  52. package/src/jobs/index.ts +7 -1
  53. package/src/jobs/job-runner.ts +104 -6
  54. package/src/logging/utils.ts +14 -1
  55. package/src/observability/index.ts +1 -0
  56. package/src/observability/standard-metrics.ts +20 -0
  57. package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
  58. package/src/pipeline/active-membership.ts +10 -4
  59. package/src/pipeline/append-event-core.ts +2 -11
  60. package/src/pipeline/dispatch-shared.ts +17 -5
  61. package/src/pipeline/event-dispatcher-delivery.ts +15 -3
  62. package/src/pipeline/projection-rebuild.ts +7 -0
  63. package/src/schema-cli.ts +21 -0
  64. package/src/scripts/codemod/pii-personal-migration.ts +242 -2
  65. package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
  66. package/src/testing/__tests__/e2e-generator.test.ts +50 -0
  67. package/src/testing/e2e-generator.ts +4 -3
  68. package/src/ui-types/index.ts +2 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.289.0",
3
+ "version": "0.291.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>",
@@ -198,8 +198,8 @@
198
198
  "./package.json": "./package.json"
199
199
  },
200
200
  "dependencies": {
201
- "@cosmicdrift/kumiko-http": "0.289.0",
202
- "@cosmicdrift/kumiko-types": "0.289.0",
201
+ "@cosmicdrift/kumiko-http": "0.291.0",
202
+ "@cosmicdrift/kumiko-types": "0.291.0",
203
203
  "bullmq": "^5.76.7",
204
204
  "bun-types": "^1.3.13",
205
205
  "hono": "^4.13.1",
@@ -215,7 +215,7 @@
215
215
  "zod": "^4.4.3"
216
216
  },
217
217
  "devDependencies": {
218
- "@cosmicdrift/kumiko-dispatcher-live": "0.289.0",
218
+ "@cosmicdrift/kumiko-dispatcher-live": "0.291.0",
219
219
  "bun-types": "^1.3.13",
220
220
  "pino-pretty": "^13.1.3"
221
221
  },
@@ -26,10 +26,14 @@ import {
26
26
  const employeeEntity = createEntity({
27
27
  table: "fa_employees",
28
28
  fields: {
29
- email: createTextField({ required: true }),
30
- firstName: createTextField(),
29
+ email: createTextField({ personal: false, reason: "test_fixture", required: true }),
30
+ firstName: createTextField({ personal: false, reason: "test_fixture" }),
31
31
  salary: createNumberField({ access: { read: ["Admin", "Accounting"], write: ["Admin"] } }),
32
- notes: createTextField({ access: { read: ["Admin"], write: ["Admin"] } }),
32
+ notes: createTextField({
33
+ personal: false,
34
+ reason: "test_fixture",
35
+ access: { read: ["Admin"], write: ["Admin"] },
36
+ }),
33
37
  },
34
38
  });
35
39
 
@@ -45,7 +45,7 @@ const memoEntity = createEntity({
45
45
  table: "fw2626_memos",
46
46
  softDelete: true,
47
47
  fields: {
48
- ownerId: createTextField({ required: true }),
48
+ ownerId: createTextField({ personal: false, reason: "test_fixture", required: true }),
49
49
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
50
50
  },
51
51
  access: {
@@ -37,7 +37,7 @@ const contractEntity = createEntity({
37
37
  softDelete: true,
38
38
  fields: {
39
39
  teamId: createTextField({ personal: false, reason: "test_fixture", required: true }),
40
- assigneeId: createTextField(),
40
+ assigneeId: createTextField({ personal: false, reason: "test_fixture" }),
41
41
  title: createTextField({ personal: false, reason: "test_fixture", required: true }),
42
42
  // propA: public on read + write
43
43
  propA: createTextField({ personal: false, reason: "test_fixture" }),
@@ -0,0 +1,160 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { reportStanceForSource } from "../scripts/codemod/pii-personal-migration";
3
+
4
+ function wrapField(fieldSrc: string): string {
5
+ return `
6
+ const entity = createEntity({
7
+ table: "entity_table",
8
+ fields: {
9
+ ${fieldSrc}
10
+ },
11
+ });
12
+ `;
13
+ }
14
+
15
+ describe("reportStanceForSource", () => {
16
+ it("classifies an exact PII_DIRECT_NAME_HINTS match", () => {
17
+ const [site] = reportStanceForSource(wrapField("email: createTextField({}),"), "t.ts");
18
+ expect(site?.stance).toBe("direct");
19
+ expect(site?.hint).toBe("email");
20
+ });
21
+
22
+ it("classifies an exact PII_USER_OWNED_NAME_HINTS match", () => {
23
+ const [site] = reportStanceForSource(wrapField("note: createTextField({}),"), "t.ts");
24
+ expect(site?.stance).toBe("user-owned");
25
+ expect(site?.hint).toBe("note");
26
+ });
27
+
28
+ it("classifies an exact PII_USER_REFERENCE_NAME_HINTS match", () => {
29
+ const [site] = reportStanceForSource(wrapField("authorId: createTextField({}),"), "t.ts");
30
+ expect(site?.stance).toBe("user-reference");
31
+ expect(site?.hint).toBe("authorid");
32
+ });
33
+
34
+ it("classifies a near-miss when a hint occurs at a segment boundary", () => {
35
+ const [site] = reportStanceForSource(
36
+ wrapField("advisorDisplayName: createTextField({}),"),
37
+ "t.ts",
38
+ );
39
+ expect(site?.stance).toBe("near-miss");
40
+ expect(site?.hint).toBe("displayname");
41
+ });
42
+
43
+ it("picks the longest hint on multiple boundary matches", () => {
44
+ const [site] = reportStanceForSource(
45
+ wrapField("customerEmailAddress: createTextField({}),"),
46
+ "t.ts",
47
+ );
48
+ expect(site?.stance).toBe("near-miss");
49
+ expect(site?.hint).toBe("address");
50
+ });
51
+
52
+ // No hint entry is a substring of "performedbyuserid" itself, but its
53
+ // segment-aligned suffix "userid" (>= 5 chars) is a substring of the
54
+ // full-name hints "createdbyuserid"/"updatedbyuserid"/"assigneeuserid" —
55
+ // the shortest of the three, "assigneeuserid", is reported.
56
+ it.each([
57
+ ["performedByUserId", "assigneeuserid"],
58
+ ["portalUserId", "assigneeuserid"],
59
+ ["ownerUserId", "assigneeuserid"],
60
+ ])("classifies %s as near-miss via its segment-aligned suffix", (field, expectedHint) => {
61
+ const [site] = reportStanceForSource(wrapField(`${field}: createTextField({}),`), "t.ts");
62
+ expect(site?.stance).toBe("near-miss");
63
+ expect(site?.hint).toBe(expectedHint);
64
+ });
65
+
66
+ it.each([
67
+ "contextId",
68
+ "stepKey",
69
+ "runId",
70
+ "scheduleId",
71
+ "costCategoryId",
72
+ "toolCallId",
73
+ "conversationId",
74
+ "handlerQn",
75
+ ])("classifies %s as unclassified — no hint containment or suffix match", (field) => {
76
+ const [site] = reportStanceForSource(wrapField(`${field}: createTextField({}),`), "t.ts");
77
+ expect(site?.stance).toBe("unclassified");
78
+ expect(site?.hint).toBeUndefined();
79
+ });
80
+
81
+ it("does not report a call already annotated with a personal stance", () => {
82
+ const sites = reportStanceForSource(
83
+ wrapField('email: createTextField({ personal: false, reason: "x" }),'),
84
+ "t.ts",
85
+ );
86
+ expect(sites).toHaveLength(0);
87
+ });
88
+
89
+ it("reports a call whose personal value is undefined", () => {
90
+ const sites = reportStanceForSource(
91
+ wrapField("email: createTextField({ personal: undefined }),"),
92
+ "t.ts",
93
+ );
94
+ expect(sites).toHaveLength(1);
95
+ });
96
+
97
+ it("does not report a call whose options literal contains a spread", () => {
98
+ const sites = reportStanceForSource(wrapField("email: createTextField({ ...base }),"), "t.ts");
99
+ expect(sites).toHaveLength(0);
100
+ });
101
+
102
+ it("does not report a call with a non-literal options argument", () => {
103
+ const sites = reportStanceForSource(wrapField("email: createTextField(options),"), "t.ts");
104
+ expect(sites).toHaveLength(0);
105
+ });
106
+
107
+ it("reports a bare createTextField() call with no arguments", () => {
108
+ const sites = reportStanceForSource(wrapField("stepKey: createTextField(),"), "t.ts");
109
+ expect(sites).toHaveLength(1);
110
+ expect(sites[0]?.stance).toBe("unclassified");
111
+ expect(sites[0]?.hint).toBeUndefined();
112
+ expect(sites[0]?.field).toBe("stepKey");
113
+ });
114
+
115
+ it("falls back to a synthetic field name and stays unclassified when there is no enclosing PropertyAssignment", () => {
116
+ const source = `
117
+ const x = [createTextField({})];
118
+ `;
119
+ const sites = reportStanceForSource(source, "t.ts");
120
+ expect(sites).toHaveLength(1);
121
+ expect(sites[0]?.field).toBe("createTextField(...)");
122
+ expect(sites[0]?.stance).toBe("unclassified");
123
+ expect(sites[0]?.hint).toBeUndefined();
124
+ });
125
+
126
+ it("resolves the entity from createEntity's table property", () => {
127
+ const [site] = reportStanceForSource(wrapField("email: createTextField({}),"), "t.ts");
128
+ expect(site?.entity).toBe("entity_table");
129
+ });
130
+
131
+ it("falls back to the enclosing variable name when createEntity has no table property", () => {
132
+ const source = `
133
+ const fields = createEntity({
134
+ fields: {
135
+ email: createTextField({}),
136
+ },
137
+ });
138
+ `;
139
+ const [site] = reportStanceForSource(source, "t.ts");
140
+ expect(site?.entity).toBe("fields");
141
+ });
142
+
143
+ it("resolves entity to null when the call is not inside a createEntity call", () => {
144
+ const source = `
145
+ const standalone = { email: createTextField({}) };
146
+ `;
147
+ const [site] = reportStanceForSource(source, "t.ts");
148
+ expect(site?.entity).toBeNull();
149
+ });
150
+
151
+ it("captures createLongTextField the same way as createTextField", () => {
152
+ const [site] = reportStanceForSource(
153
+ wrapField("description: createLongTextField({}),"),
154
+ "t.ts",
155
+ );
156
+ expect(site?.callee).toBe("createLongTextField");
157
+ expect(site?.stance).toBe("user-owned");
158
+ expect(site?.hint).toBe("description");
159
+ });
160
+ });
@@ -10,6 +10,7 @@ import {
10
10
  defineFeature,
11
11
  defineQueryHandler,
12
12
  EXT_PRINCIPAL_STATUS,
13
+ EXT_TENANT_LIFECYCLE_STATUS,
13
14
  } from "../../engine";
14
15
  import { createInMemorySearchAdapter } from "../../search";
15
16
  import { buildServer } from "../server";
@@ -263,3 +264,44 @@ describe("buildServer — auth membershipQuery requires a principalStatus provid
263
264
  ).not.toThrow();
264
265
  });
265
266
  });
267
+
268
+ describe("buildServer — tenant-lifecycle gate derivation", () => {
269
+ const lifecyclePlugin = { resolveStatus: async () => null };
270
+ const providerFeature = defineFeature("lifecycle-provider", (r) => {
271
+ r.extendsRegistrar(EXT_TENANT_LIFECYCLE_STATUS, {});
272
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider", lifecyclePlugin);
273
+ });
274
+ const secondProviderFeature = defineFeature("lifecycle-provider-2", (r) => {
275
+ r.useExtension(EXT_TENANT_LIFECYCLE_STATUS, "lifecycle-provider-2", lifecyclePlugin);
276
+ });
277
+
278
+ test("throws when a lifecycle provider is mounted but context.db is missing", () => {
279
+ expect(() =>
280
+ buildServer({
281
+ registry: createRegistry([providerFeature]),
282
+ context: {},
283
+ jwtSecret: JWT_SECRET,
284
+ }),
285
+ ).toThrow(/tenantLifecycleStatus provider is mounted .* but context\.db is missing/);
286
+ });
287
+
288
+ test("throws when two lifecycle providers are registered", () => {
289
+ expect(() =>
290
+ buildServer({
291
+ registry: createRegistry([providerFeature, secondProviderFeature]),
292
+ context: {},
293
+ jwtSecret: JWT_SECRET,
294
+ }),
295
+ ).toThrow(/multiple "tenantLifecycleStatus" providers registered/);
296
+ });
297
+
298
+ test("no provider mounted leaves the gate unwired", () => {
299
+ expect(() =>
300
+ buildServer({
301
+ registry: createRegistry([]),
302
+ context: {},
303
+ jwtSecret: JWT_SECRET,
304
+ }),
305
+ ).not.toThrow();
306
+ });
307
+ });
@@ -1,6 +1,7 @@
1
- import { describe, expect, spyOn, test } from "bun:test";
1
+ import { afterEach, beforeEach, describe, expect, spyOn, test } from "bun:test";
2
2
  import { z } from "zod";
3
3
  import { createRegistry, defineFeature } from "../../engine";
4
+ import { RateLimitError, UnprocessableError } from "../../errors";
4
5
  import { TestUsers } from "../../stack";
5
6
  import { ensureTemporalPolyfill } from "../../time";
6
7
  import { buildServer } from "../server";
@@ -13,6 +14,10 @@ await ensureTemporalPolyfill();
13
14
 
14
15
  const JWT_SECRET = "test-secret-at-least-32-chars-long!!";
15
16
 
17
+ const openToAll = {
18
+ access: { openToAll: { reason: "test handler callable by any signed-in test user" } },
19
+ } as const;
20
+
16
21
  const boomFeature = defineFeature("boom", (r) => {
17
22
  r.queryHandler(
18
23
  "explode",
@@ -20,7 +25,38 @@ const boomFeature = defineFeature("boom", (r) => {
20
25
  async () => {
21
26
  throw new Error("disk on fire");
22
27
  },
23
- { access: { openToAll: { reason: "test handler callable by any signed-in test user" } } },
28
+ openToAll,
29
+ );
30
+ r.queryHandler(
31
+ "decode",
32
+ z.object({}),
33
+ async () => {
34
+ throw new UnprocessableError("vin_not_decodable", {
35
+ details: { vin: "WDB0000000SECRET" },
36
+ });
37
+ },
38
+ openToAll,
39
+ );
40
+ r.queryHandler(
41
+ "throttled",
42
+ z.object({}),
43
+ async () => {
44
+ throw new RateLimitError({
45
+ bucket: "ip:203.0.113.7",
46
+ limit: 1,
47
+ windowSeconds: 60,
48
+ remaining: 0,
49
+ retryAfterSeconds: 60,
50
+ resetAt: "2026-01-01T00:00:00.000Z",
51
+ });
52
+ },
53
+ openToAll,
54
+ );
55
+ r.queryHandler(
56
+ "login",
57
+ z.object({ email: z.email(), password: z.string().min(8) }),
58
+ async () => ({ ok: true }),
59
+ openToAll,
24
60
  );
25
61
  });
26
62
 
@@ -45,6 +81,43 @@ function apiFaultLog(calls: unknown[][]): string | undefined {
45
81
  return hit ? JSON.stringify(hit) : undefined;
46
82
  }
47
83
 
84
+ function isRecord(value: unknown): value is Record<string, unknown> {
85
+ return typeof value === "object" && value !== null;
86
+ }
87
+
88
+ // The `[api] handler rejected` warn line logServerFault emits for 4xx.
89
+ function apiRejectionLog(calls: unknown[][]): Record<string, unknown> | undefined {
90
+ const hit = calls.find(
91
+ (args) => typeof args[0] === "string" && args[0].includes("[api] handler rejected"),
92
+ );
93
+ return isRecord(hit?.[1]) ? hit[1] : undefined;
94
+ }
95
+
96
+ async function queryWithCapturedWarnings(
97
+ type: string,
98
+ payload: unknown,
99
+ ): Promise<{ status: number; warnings: unknown[][]; errors: unknown[][] }> {
100
+ const warnings: unknown[][] = [];
101
+ const errors: unknown[][] = [];
102
+ const warnSpy = spyOn(console, "warn").mockImplementation((...args) => {
103
+ warnings.push(args);
104
+ });
105
+ const errorSpy = spyOn(console, "error").mockImplementation((...args) => {
106
+ errors.push(args);
107
+ });
108
+ try {
109
+ const res = await app.request("/api/query", {
110
+ method: "POST",
111
+ headers: await auth(),
112
+ body: JSON.stringify({ type, payload }),
113
+ });
114
+ return { status: res.status, warnings, errors };
115
+ } finally {
116
+ warnSpy.mockRestore();
117
+ errorSpy.mockRestore();
118
+ }
119
+ }
120
+
48
121
  describe("HTTP layer logs unexpected 5xx faults", () => {
49
122
  test("a throwing query 500s AND the cause stack reaches the log", async () => {
50
123
  const calls: unknown[][] = [];
@@ -67,21 +140,99 @@ describe("HTTP layer logs unexpected 5xx faults", () => {
67
140
  }
68
141
  });
69
142
 
70
- test("an expected 404 does NOT log a server fault (no 4xx noise)", async () => {
71
- const calls: unknown[][] = [];
72
- const spy = spyOn(console, "error").mockImplementation((...args) => {
73
- calls.push(args);
143
+ test("a 404 stays off the error level (it is a client outcome, not a server fault)", async () => {
144
+ const { status, errors } = await queryWithCapturedWarnings("nope:query:nothing", {});
145
+ expect(status).toBe(404);
146
+ expect(apiFaultLog(errors)).toBeUndefined();
147
+ });
148
+ });
149
+
150
+ describe("HTTP layer logs 4xx client faults on warn (#3077)", () => {
151
+ let previousLogLevel: string | undefined;
152
+
153
+ beforeEach(() => {
154
+ previousLogLevel = process.env["LOG_LEVEL"];
155
+ process.env["LOG_LEVEL"] = "info";
156
+ });
157
+
158
+ afterEach(() => {
159
+ if (previousLogLevel === undefined) delete process.env["LOG_LEVEL"];
160
+ else process.env["LOG_LEVEL"] = previousLogLevel;
161
+ });
162
+
163
+ test("a 422 leaves a log line with status, code and duration", async () => {
164
+ const { status, warnings } = await queryWithCapturedWarnings("boom:query:decode", {});
165
+ expect(status).toBe(422);
166
+ const logged = apiRejectionLog(warnings);
167
+ expect(logged).toBeDefined();
168
+ expect(logged?.["status"]).toBe(422);
169
+ expect(logged?.["code"]).toBe("unprocessable");
170
+ expect(logged?.["type"]).toBe("boom:query:decode");
171
+ expect(typeof logged?.["durationMs"]).toBe("number");
172
+ expect(logged?.["durationMs"]).toBeGreaterThanOrEqual(0);
173
+ });
174
+
175
+ test("a 429 leaves a log line", async () => {
176
+ const { status, warnings } = await queryWithCapturedWarnings("boom:query:throttled", {});
177
+ expect(status).toBe(429);
178
+ expect(apiRejectionLog(warnings)?.["status"]).toBe(429);
179
+ expect(apiRejectionLog(warnings)?.["code"]).toBe("rate_limited");
180
+ });
181
+
182
+ test("a 400 validation failure leaves a log line", async () => {
183
+ const { status, warnings } = await queryWithCapturedWarnings("boom:query:login", {
184
+ email: "nope",
185
+ password: "short",
74
186
  });
75
- try {
76
- const res = await app.request("/api/query", {
77
- method: "POST",
78
- headers: await auth(),
79
- body: JSON.stringify({ type: "nope:query:nothing", payload: {} }),
80
- });
81
- expect(res.status).toBe(404);
82
- expect(apiFaultLog(calls)).toBeUndefined();
83
- } finally {
84
- spy.mockRestore();
85
- }
187
+ expect(status).toBe(400);
188
+ expect(apiRejectionLog(warnings)?.["status"]).toBe(400);
189
+ expect(apiRejectionLog(warnings)?.["code"]).toBe("validation_error");
190
+ });
191
+
192
+ test("a 404 leaves a log line and truncates the client-supplied type", async () => {
193
+ const longType = `ghost:query:${"x".repeat(500)}`;
194
+ const { status, warnings } = await queryWithCapturedWarnings(longType, {});
195
+ expect(status).toBe(404);
196
+ const loggedType = apiRejectionLog(warnings)?.["type"];
197
+ expect(typeof loggedType).toBe("string");
198
+ expect(String(loggedType).length).toBeLessThanOrEqual(120);
199
+ });
200
+
201
+ test("the 4xx line carries no submitted values, no message, no details, no stack", async () => {
202
+ const { warnings } = await queryWithCapturedWarnings("boom:query:login", {
203
+ email: "victim-at-example.com",
204
+ password: "hunter2-super-secret",
205
+ apiKey: "sk-live-0000000000",
206
+ });
207
+ const logged = apiRejectionLog(warnings);
208
+ expect(logged).toBeDefined();
209
+ expect(Object.keys(logged ?? {}).sort()).toEqual([
210
+ "code",
211
+ "durationMs",
212
+ "requestId",
213
+ "status",
214
+ "type",
215
+ ]);
216
+ const serialized = JSON.stringify(logged);
217
+ expect(serialized).not.toContain("victim-at-example.com");
218
+ expect(serialized).not.toContain("hunter2-super-secret");
219
+ expect(serialized).not.toContain("sk-live-0000000000");
220
+ });
221
+
222
+ test("a 422 does NOT reach the error level (5xx contract unchanged)", async () => {
223
+ const { errors } = await queryWithCapturedWarnings("boom:query:decode", {});
224
+ expect(apiFaultLog(errors)).toBeUndefined();
225
+ });
226
+
227
+ test("LOG_LEVEL=error silences the 4xx lines but keeps 5xx", async () => {
228
+ process.env["LOG_LEVEL"] = "error";
229
+
230
+ const rejected = await queryWithCapturedWarnings("boom:query:decode", {});
231
+ expect(rejected.status).toBe(422);
232
+ expect(apiRejectionLog(rejected.warnings)).toBeUndefined();
233
+
234
+ const exploded = await queryWithCapturedWarnings("boom:query:explode", {});
235
+ expect(exploded.status).toBe(500);
236
+ expect(apiFaultLog(exploded.errors)).toBeDefined();
86
237
  });
87
238
  });
@@ -38,6 +38,15 @@ export type RequestContextData = {
38
38
  // request-locale.ts. Undefined when neither header carried a valid tag;
39
39
  // callers fall back further (dispatch-shared.ts's ctx.locale chain).
40
40
  readonly locale?: string;
41
+ // Attribution of the currently executing scope (#3043): the feature that
42
+ // owns it and the qualified name of the handler / MSP-consumer / job
43
+ // inside it. event-store.append() reads both and stamps them onto every
44
+ // event written under this scope.
45
+ readonly feature?: string;
46
+ readonly handler?: string;
47
+ // performance.now() at request entry, so a failing request can report how
48
+ // long it ran. Monotonic — a wall-clock step cannot make it negative.
49
+ readonly startedAt?: number;
41
50
  };
42
51
 
43
52
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -55,3 +64,25 @@ export const requestContext = {
55
64
  return generateId();
56
65
  },
57
66
  };
67
+
68
+ // Enter a scope that attributes every event written inside it. Keeps the
69
+ // surrounding request's ids so correlation survives, and mints fresh ones
70
+ // when there is no surrounding request (job-runner, event-dispatcher) —
71
+ // requestId/correlationId are mandatory, `get()` may be undefined.
72
+ export function runWithOrigin<T>(
73
+ origin: { readonly feature?: string; readonly handler?: string },
74
+ fn: () => T,
75
+ ): T {
76
+ const current = requestContext.get();
77
+ const requestId = current?.requestId ?? requestContext.generateId();
78
+ return requestContext.run(
79
+ {
80
+ ...current,
81
+ requestId,
82
+ correlationId: current?.correlationId ?? requestId,
83
+ feature: origin.feature,
84
+ handler: origin.handler,
85
+ },
86
+ fn,
87
+ );
88
+ }
@@ -62,6 +62,7 @@ export function buildRequestContextDataFromRequest(req: Request): RequestContext
62
62
  return {
63
63
  requestId,
64
64
  correlationId,
65
+ startedAt: performance.now(),
65
66
  ...(signal ? { signal } : {}),
66
67
  ...(ip && ip.length > 0 ? { ip } : {}),
67
68
  ...(userAgent !== undefined ? { userAgent } : {}),
@@ -81,7 +82,7 @@ export function buildRequestContextData(c: Context): RequestContextData {
81
82
  // instead of letting req.headers.get() throw on every request.
82
83
  if (!c.req.raw) {
83
84
  const requestId = requestContext.generateId();
84
- return { requestId, correlationId: requestId };
85
+ return { requestId, correlationId: requestId, startedAt: performance.now() };
85
86
  }
86
87
  return buildRequestContextDataFromRequest(c.req.raw);
87
88
  }
package/src/api/routes.ts CHANGED
@@ -333,15 +333,47 @@ function assertPatAllowed(user: SessionUser, type: string): void {
333
333
  }
334
334
  }
335
335
 
336
+ // Log levels that silence the 4xx tier. Checked here and not in the logger
337
+ // because this module uses the console fallback, which pino's level never
338
+ // reaches — LOG_LEVEL is the volume knob for client faults.
339
+ const FAULT_LOG_SILENCED_LEVELS = new Set(["error", "fatal", "silent"]);
340
+
341
+ // `type` is client-supplied on an unknown-handler 404, so cap it before it
342
+ // reaches the log — an unbounded field would let a caller flood the sink.
343
+ const MAX_LOGGED_TYPE_LENGTH = 120;
344
+
345
+ function clientFaultLoggingEnabled(): boolean {
346
+ return !FAULT_LOG_SILENCED_LEVELS.has(process.env["LOG_LEVEL"] ?? "");
347
+ }
348
+
349
+ // A failing request must leave a trace even when it ends in 4xx — a paid
350
+ // external call that 422s was invisible before (offlot#117). Status, error
351
+ // code and duration only: message/details/stack can carry submitted values.
352
+ function logClientFault(err: KumikoError, requestId: string | undefined, type?: string): void {
353
+ if (!clientFaultLoggingEnabled()) {
354
+ // skip: LOG_LEVEL silences the 4xx tier — the deployment opted out of client-fault volume
355
+ return;
356
+ }
357
+ const startedAt = requestContext.get()?.startedAt;
358
+ createFallbackLogger("api").warn("handler rejected", {
359
+ requestId,
360
+ type: type?.slice(0, MAX_LOGGED_TYPE_LENGTH),
361
+ status: err.httpStatus,
362
+ code: err.code,
363
+ ...(startedAt === undefined ? {} : { durationMs: Math.round(performance.now() - startedAt) }),
364
+ });
365
+ }
366
+
336
367
  // Unexpected server faults (5xx) carry their diagnostic stack only on the
337
368
  // in-process error — serializeError strips cause/details from the wire body.
338
369
  // Without this a wrapped throw (InternalError{cause}) returns a 500 with zero
339
- // log lines, leaving ops nothing to debug (the bug this guards). 4xx are
340
- // expected client outcomes and stay unlogged. `type` is the only handler
370
+ // log lines, leaving ops nothing to debug (the bug this guards). 4xx take the
371
+ // redacted `warn` line above instead. `type` is the only handler
341
372
  // discriminator — every request hits the same /api/{query,command} path.
342
373
  function logServerFault(err: KumikoError, requestId: string | undefined, type?: string): void {
343
374
  if (err.httpStatus < 500) {
344
- // skip: 4xx are expected client outcomes (not-found, validation, denied) — logging them is noise
375
+ logClientFault(err, requestId, type);
376
+ // skip: 4xx already logged on warn by logClientFault — the error level stays 5xx-only
345
377
  return;
346
378
  }
347
379
  const cause = err.cause;
package/src/api/server.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  registerStandardMetrics,
20
20
  wrapRedisClient,
21
21
  } from "../observability";
22
+ import { resolveTenantLifecyclePlugin } from "../pipeline/active-membership";
22
23
  import type { DispatcherOptions } from "../pipeline/dispatcher";
23
24
  import { createDispatcher, type Dispatcher } from "../pipeline/dispatcher";
24
25
  import { SHARED_INSTANCE_SENTINEL } from "../pipeline/event-consumer-state";
@@ -43,7 +44,12 @@ import {
43
44
  import type { SearchAdapter } from "../search/types";
44
45
  import { assertUnreachable, generateId } from "../utils";
45
46
  import { NO_ROUTE_MATCH_HEADER_NAME, PUBLIC_API_PATHS } from "./api-constants";
46
- import { type AnonymousAccessResolved, authMiddleware, getUser } from "./auth-middleware";
47
+ import {
48
+ type AnonymousAccessResolved,
49
+ authMiddleware,
50
+ getUser,
51
+ type TenantLifecycleStatusResolver,
52
+ } from "./auth-middleware";
47
53
  import { type AuthRoutesConfig, createAuthRoutes } from "./auth-routes";
48
54
  import { csrfMiddleware } from "./csrf-middleware";
49
55
  import { createJwtHelper, type JwtHelper, type JwtKeyring } from "./jwt";
@@ -670,12 +676,16 @@ export function buildServer(options: ServerOptions): KumikoServer {
670
676
  // a token (or, when anonymousAccess is wired, falls through as anonymous).
671
677
  // A session-checker is forwarded when the auth-config wires one, so the
672
678
  // middleware can reject revoked sids on every request.
679
+ // Mounting a tenantLifecycleStatus provider is what turns the 410 on, so no
680
+ // entrypoint can forget the wiring; `??` short-circuits, so an explicit
681
+ // auth.resolveTenantLifecycleStatus remains the override.
682
+ const tenantLifecycleResolver =
683
+ options.auth?.resolveTenantLifecycleStatus ??
684
+ deriveTenantLifecycleResolver(options.registry, baseDb);
673
685
  const jwtGuard = authMiddleware(jwt, {
674
686
  ...(options.auth?.sessionChecker ? { sessionChecker: options.auth.sessionChecker } : {}),
675
687
  ...(options.auth?.tokenVerifier ? { tokenVerifier: options.auth.tokenVerifier } : {}),
676
- ...(options.auth?.resolveTenantLifecycleStatus
677
- ? { resolveTenantLifecycleStatus: options.auth.resolveTenantLifecycleStatus }
678
- : {}),
688
+ ...(tenantLifecycleResolver ? { resolveTenantLifecycleStatus: tenantLifecycleResolver } : {}),
679
689
  ...(options.anonymousAccess ? { anonymousAccess: options.anonymousAccess } : {}),
680
690
  });
681
691
  app.use("/api/*", async (c, next) => {
@@ -890,6 +900,22 @@ export function buildServer(options: ServerOptions): KumikoServer {
890
900
  };
891
901
  }
892
902
 
903
+ function deriveTenantLifecycleResolver(
904
+ registry: Registry,
905
+ db: DbConnection | undefined,
906
+ ): TenantLifecycleStatusResolver | undefined {
907
+ const plugin = resolveTenantLifecyclePlugin(registry, "buildServer");
908
+ if (!plugin) return undefined;
909
+ if (!db) {
910
+ throw new Error(
911
+ "[kumiko] a tenantLifecycleStatus provider is mounted (tenant-lifecycle) but context.db is " +
912
+ "missing — the request-level teardown gate needs a DbConnection. Pass context.db, or wire " +
913
+ "auth.resolveTenantLifecycleStatus explicitly.",
914
+ );
915
+ }
916
+ return (tenantId) => plugin.resolveStatus(tenantId, { db });
917
+ }
918
+
893
919
  // Scans every feature's entities for a file/image/files/images field. Short-
894
920
  // circuits on the first hit — no need to build a full inventory, we only want
895
921
  // the yes/no answer for the boot check.