@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.
- package/package.json +4 -4
- package/src/__tests__/field-access.integration.test.ts +7 -3
- package/src/__tests__/ownership-where-write-path.integration.test.ts +1 -1
- package/src/__tests__/ownership.integration.test.ts +1 -1
- package/src/__tests__/pii-personal-migration-report-codemod.test.ts +160 -0
- package/src/api/__tests__/server-boot-guards.test.ts +42 -0
- package/src/api/__tests__/server-error-logging.test.ts +168 -17
- package/src/api/request-context.ts +31 -0
- package/src/api/request-id-middleware.ts +2 -1
- package/src/api/routes.ts +35 -3
- package/src/api/server.ts +30 -4
- package/src/changes.json +69 -0
- package/src/crypto/__tests__/blind-index.test.ts +1 -1
- package/src/crypto/__tests__/event-pii.test.ts +110 -9
- package/src/crypto/__tests__/pii-field-encryption.test.ts +2 -2
- package/src/crypto/__tests__/subject-resolver.test.ts +25 -4
- package/src/crypto/subject-resolver.ts +25 -8
- package/src/db/__tests__/blind-index.integration.test.ts +1 -1
- package/src/db/__tests__/eagerload.integration.test.ts +12 -2
- package/src/db/__tests__/entity-field-encryption.test.ts +2 -2
- package/src/db/__tests__/event-store-executor-context.pii-roundtrip.test.ts +1 -1
- package/src/db/__tests__/event-store-executor-money-rehydrate.integration.test.ts +7 -2
- package/src/db/__tests__/event-store-executor-write-verbs.integration.test.ts +3 -1
- package/src/db/__tests__/event-store-executor.integration.test.ts +15 -5
- package/src/db/__tests__/list-filter-field-access.integration.test.ts +6 -2
- package/src/db/queries/shadow-swap.ts +35 -0
- package/src/engine/__tests__/boot-validator-action-wiring.test.ts +32 -0
- package/src/engine/__tests__/boot-validator-boot-check.test.ts +1 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +148 -15
- package/src/engine/__tests__/boot-validator.test.ts +226 -0
- package/src/engine/__tests__/build-app-schema.test.ts +18 -0
- package/src/engine/__tests__/engine.test.ts +87 -0
- package/src/engine/__tests__/entity-presave-wiring.integration.test.ts +6 -2
- package/src/engine/__tests__/factories-long-text.test.ts +6 -1
- package/src/engine/__tests__/form-money-currency-types.test.ts +90 -0
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +12 -2
- package/src/engine/boot-validator/action-wiring.ts +2 -1
- package/src/engine/boot-validator/entity-handler.ts +44 -0
- package/src/engine/boot-validator/index.ts +7 -2
- package/src/engine/boot-validator/pii-retention.ts +8 -0
- package/src/engine/boot-validator/screens.ts +54 -12
- package/src/engine/create-app.ts +54 -0
- package/src/engine/feature-config-events-jobs.ts +19 -0
- package/src/engine/index.ts +2 -0
- package/src/engine/qualified-name.ts +9 -0
- package/src/engine/screen-helpers.ts +1 -0
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +32 -8
- package/src/event-store/__tests__/event-attribution.integration.test.ts +186 -0
- package/src/event-store/event-store.ts +23 -2
- package/src/i18n/required-surface-keys.ts +1 -0
- package/src/jobs/__tests__/job-last-success.integration.test.ts +135 -0
- package/src/jobs/index.ts +7 -1
- package/src/jobs/job-runner.ts +104 -6
- package/src/logging/utils.ts +14 -1
- package/src/observability/index.ts +1 -0
- package/src/observability/standard-metrics.ts +20 -0
- package/src/pipeline/__tests__/blind-index-rebuild-guard.integration.test.ts +96 -0
- package/src/pipeline/active-membership.ts +10 -4
- package/src/pipeline/append-event-core.ts +2 -11
- package/src/pipeline/dispatch-shared.ts +17 -5
- package/src/pipeline/event-dispatcher-delivery.ts +15 -3
- package/src/pipeline/projection-rebuild.ts +7 -0
- package/src/schema-cli.ts +21 -0
- package/src/scripts/codemod/pii-personal-migration.ts +242 -2
- package/src/stack/__tests__/ownership-boot-guard.integration.test.ts +1 -1
- package/src/testing/__tests__/e2e-generator.test.ts +50 -0
- package/src/testing/e2e-generator.ts +4 -3
- 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.
|
|
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.
|
|
202
|
-
"@cosmicdrift/kumiko-types": "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.
|
|
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({
|
|
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
|
-
|
|
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("
|
|
71
|
-
const
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
|
340
|
-
//
|
|
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
|
-
|
|
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 {
|
|
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
|
-
...(
|
|
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.
|