@cosmicdrift/kumiko-framework 0.249.0 → 0.251.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 +3 -3
- package/src/__tests__/full-stack.integration.test.ts +3 -1
- package/src/crypto/__tests__/event-pii.test.ts +25 -3
- package/src/crypto/__tests__/kms-wiring.test.ts +15 -2
- package/src/crypto/__tests__/pg-kms-adapter.integration.test.ts +18 -0
- package/src/crypto/event-pii.ts +7 -1
- package/src/crypto/index.ts +1 -0
- package/src/crypto/kms-wiring.ts +23 -10
- package/src/db/__tests__/event-store-executor-reference-sort.integration.test.ts +258 -0
- package/src/db/__tests__/table-builder-meta-lockstep.test.ts +33 -0
- package/src/db/entity-table-meta.ts +9 -2
- package/src/db/event-store-executor-read.ts +124 -7
- package/src/db/table-builder.ts +10 -0
- package/src/engine/__tests__/engine.test.ts +132 -2
- package/src/engine/__tests__/event-migration-declarative.test.ts +2 -0
- package/src/engine/__tests__/extend-entity-projection.test.ts +15 -5
- package/src/engine/__tests__/pipeline-handler.integration.test.ts +1 -1
- package/src/engine/__tests__/screen.test.ts +184 -0
- package/src/engine/boot-validator/index.ts +5 -1
- package/src/engine/boot-validator/projection-list-screens.ts +42 -0
- package/src/engine/boot-validator/screens.ts +45 -29
- package/src/engine/entity-handlers.ts +4 -0
- package/src/engine/feature-ast/__tests__/canonical-form.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/parse-happy-path.test.ts +1 -1
- package/src/engine/feature-ast/__tests__/parse.test.ts +27 -1
- package/src/engine/feature-ast/__tests__/patch.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/patcher.test.ts +1 -0
- package/src/engine/feature-ast/__tests__/render-roundtrip.test.ts +56 -3
- package/src/engine/feature-ast/extractors/events.ts +24 -0
- package/src/engine/feature-ast/patcher.ts +6 -1
- package/src/engine/feature-ast/patterns.ts +6 -0
- package/src/engine/feature-ast/render.ts +3 -4
- package/src/engine/feature-config-events-jobs.ts +33 -9
- package/src/engine/pattern-library/__tests__/library.test.ts +1 -0
- package/src/engine/registry-facade.ts +10 -1
- package/src/engine/registry-state.ts +8 -0
- package/src/engine/registry-validate.ts +54 -3
- package/src/engine/types/index.ts +1 -0
- package/src/entrypoint/__tests__/entrypoint-attach-dispatcher.integration.test.ts +4 -1
- package/src/entrypoint/__tests__/split-deploy.integration.test.ts +10 -3
- package/src/event-store/__tests__/upcaster.integration.test.ts +8 -1
- package/src/jobs/__tests__/job-write-as.integration.test.ts +206 -0
- package/src/jobs/job-runner.ts +12 -4
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/causation-chain.integration.test.ts +3 -1
- package/src/pipeline/__tests__/domain-events-projections.integration.test.ts +3 -1
- package/src/pipeline/__tests__/event-define-event-strict.integration.test.ts +6 -2
- package/src/pipeline/__tests__/fetch-for-writing.integration.test.ts +6 -2
- package/src/pipeline/__tests__/job-trigger-consumer.integration.test.ts +3 -1
- package/src/pipeline/__tests__/load-aggregate-query.integration.test.ts +1 -0
- package/src/pipeline/__tests__/msp-multi-hop.integration.test.ts +5 -3
- package/src/pipeline/__tests__/msp-rebuild.integration.test.ts +3 -0
- package/src/pipeline/__tests__/multi-stream-projection.integration.test.ts +2 -0
- package/src/pipeline/__tests__/rebuild-poison-quarantine.integration.test.ts +3 -1
- package/src/pipeline/__tests__/search-consumer-named-events.integration.test.ts +260 -0
- package/src/pipeline/__tests__/try-append-event.integration.test.ts +3 -1
- package/src/pipeline/system-hooks.ts +94 -25
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import { configureEventPiiCatalog } from "../crypto/event-pii";
|
|
2
2
|
import { resolveName } from "./handler-helpers";
|
|
3
|
-
import type {
|
|
3
|
+
import type {
|
|
4
|
+
RegistryState,
|
|
5
|
+
SearchableReferenceField,
|
|
6
|
+
SortableReferenceField,
|
|
7
|
+
} from "./registry-state";
|
|
4
8
|
import { buildImplicitProjection, hasFieldAccessRules, qualify } from "./registry-state";
|
|
5
9
|
import {
|
|
6
10
|
buildSoftDeleteCleanupJob,
|
|
@@ -192,12 +196,43 @@ function buildSearchableReferenceField(
|
|
|
192
196
|
};
|
|
193
197
|
}
|
|
194
198
|
|
|
195
|
-
//
|
|
199
|
+
// fw#2741: ORDER BY runs against the referenced row's label column, so the
|
|
200
|
+
// same explicit-labelField requirement as searchable applies — ordering by the
|
|
201
|
+
// "id" default would be a UUID order that looks deliberate to the user.
|
|
202
|
+
function buildSortableReferenceField(
|
|
203
|
+
entityName: string,
|
|
204
|
+
fieldName: string,
|
|
205
|
+
field: ReferenceFieldDef,
|
|
206
|
+
): SortableReferenceField {
|
|
207
|
+
if (field.labelField === undefined || field.labelField === "id") {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`[Entity ${entityName}] field "${fieldName}": sortable reference fields require an ` +
|
|
210
|
+
`explicit, non-"id" labelField. Ordering by "id" is a UUID order — arbitrary to the ` +
|
|
211
|
+
`user while looking deliberate. Set labelField to a human-readable field on the ` +
|
|
212
|
+
`referenced entity.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
if (field.multiple === true) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
`[Entity ${entityName}] field "${fieldName}": sortable is not supported on multiple ` +
|
|
218
|
+
`(array) reference fields — there is no single label to order by; remove "multiple" ` +
|
|
219
|
+
`or "sortable".`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return {
|
|
223
|
+
fieldName,
|
|
224
|
+
targetEntityName: parseReferenceTargetEntityName(field.entity),
|
|
225
|
+
labelField: field.labelField,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Precompute: searchable/sortable fields, searchable + sortable reference fields.
|
|
196
230
|
export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
197
231
|
for (const [name, entity] of state.entityMap) {
|
|
198
232
|
const searchable: string[] = [];
|
|
199
233
|
const sortable: string[] = [];
|
|
200
234
|
const searchableReferences: SearchableReferenceField[] = [];
|
|
235
|
+
const sortableReferences: SortableReferenceField[] = [];
|
|
201
236
|
for (const [fieldName, field] of Object.entries(entity.fields)) {
|
|
202
237
|
if (field.type === "text" && field.searchable === true) searchable.push(fieldName);
|
|
203
238
|
if (field.type === "text" && field.sortable === true) sortable.push(fieldName);
|
|
@@ -209,10 +244,14 @@ export function buildSearchableSortableCaches(state: RegistryState): void {
|
|
|
209
244
|
if (field.type === "reference" && field.searchable === true) {
|
|
210
245
|
searchableReferences.push(buildSearchableReferenceField(name, fieldName, field));
|
|
211
246
|
}
|
|
247
|
+
if (field.type === "reference" && field.sortable === true) {
|
|
248
|
+
sortableReferences.push(buildSortableReferenceField(name, fieldName, field));
|
|
249
|
+
}
|
|
212
250
|
}
|
|
213
251
|
state.searchableFieldsCache.set(name, searchable);
|
|
214
252
|
state.sortableFieldsCache.set(name, sortable);
|
|
215
253
|
state.searchableReferencesCache.set(name, searchableReferences);
|
|
254
|
+
state.sortableReferencesCache.set(name, sortableReferences);
|
|
216
255
|
}
|
|
217
256
|
}
|
|
218
257
|
|
|
@@ -664,9 +703,21 @@ export function publishEventPiiCatalog(state: RegistryState): void {
|
|
|
664
703
|
// into kumiko_events — encrypts catalogued payload fields regardless of
|
|
665
704
|
// which path produced the event (ctx.appendEvent, MSP-apply, low-level
|
|
666
705
|
// append in delivery/jobs loggers).
|
|
706
|
+
//
|
|
707
|
+
// Defense-in-depth (fw#2558): defineEvent is the only producer of
|
|
708
|
+
// EventDef.piiFields and already fails closed without a stance, but this
|
|
709
|
+
// boot gate re-checks every registered event — a future producer that
|
|
710
|
+
// forgets to run through defineEvent would otherwise leave a hole where
|
|
711
|
+
// append() silently ships plaintext instead of refusing to boot.
|
|
667
712
|
const eventPiiCatalog = new Map<string, EventPiiFields>();
|
|
668
713
|
for (const [qualified, def] of state.eventMap) {
|
|
669
|
-
if (def.piiFields
|
|
714
|
+
if (def.piiFields === undefined) {
|
|
715
|
+
throw new Error(
|
|
716
|
+
`Registered event "${qualified}" has no PII stance. Every r.defineEvent(...) call must pass { piiFields: { ... } } or { piiFields: "none" }.`,
|
|
717
|
+
);
|
|
718
|
+
}
|
|
719
|
+
if (def.piiFields === "none") continue;
|
|
720
|
+
eventPiiCatalog.set(qualified, def.piiFields);
|
|
670
721
|
}
|
|
671
722
|
configureEventPiiCatalog(eventPiiCatalog);
|
|
672
723
|
}
|
|
@@ -25,7 +25,10 @@ const writeProbeResults: Array<{ isSuccess: boolean }> = [];
|
|
|
25
25
|
const writeProbeFailures: string[] = [];
|
|
26
26
|
|
|
27
27
|
const writeProbeFeature = defineFeature("writeProbe", (r) => {
|
|
28
|
-
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
28
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
29
|
+
piiFields: "none",
|
|
30
|
+
version: 1,
|
|
31
|
+
});
|
|
29
32
|
r.writeHandler(
|
|
30
33
|
"note",
|
|
31
34
|
z.object({ note: z.string() }),
|
|
@@ -23,7 +23,10 @@ import { createTestRedis, type TestRedis, TestUsers } from "../../stack";
|
|
|
23
23
|
import { createAllInOneEntrypoint, createApiEntrypoint, createWorkerEntrypoint } from "../index";
|
|
24
24
|
|
|
25
25
|
const splitFeature = defineFeature("split", (r) => {
|
|
26
|
-
const tick = r.defineEvent("tick", z.object({ note: z.string() }), {
|
|
26
|
+
const tick = r.defineEvent("tick", z.object({ note: z.string() }), {
|
|
27
|
+
piiFields: "none",
|
|
28
|
+
version: 1,
|
|
29
|
+
});
|
|
27
30
|
r.multiStreamProjection({
|
|
28
31
|
name: "spy",
|
|
29
32
|
apply: {
|
|
@@ -38,7 +41,10 @@ const splitFeature = defineFeature("split", (r) => {
|
|
|
38
41
|
const consumedNotes: string[] = [];
|
|
39
42
|
|
|
40
43
|
const workerWriteFeature = defineFeature("workerWrite", (r) => {
|
|
41
|
-
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
44
|
+
const noted = r.defineEvent("noted", z.object({ note: z.string() }), {
|
|
45
|
+
piiFields: "none",
|
|
46
|
+
version: 1,
|
|
47
|
+
});
|
|
42
48
|
r.writeHandler(
|
|
43
49
|
"note",
|
|
44
50
|
z.object({ note: z.string() }),
|
|
@@ -76,6 +82,7 @@ const jobSawFilesRef: string[] = [];
|
|
|
76
82
|
|
|
77
83
|
const fileJobFeature = defineFeature("fileJob", (r) => {
|
|
78
84
|
const requested = r.defineEvent("bytes-requested", z.object({ storageKey: z.string() }), {
|
|
85
|
+
piiFields: "none",
|
|
79
86
|
version: 1,
|
|
80
87
|
});
|
|
81
88
|
// Stands in for file-foundation, which this package cannot import.
|
|
@@ -310,7 +317,7 @@ describe("entrypoint factories", () => {
|
|
|
310
317
|
// process's lane are skipped during buildServer()".
|
|
311
318
|
|
|
312
319
|
const laneFeature = defineFeature("lane", (r) => {
|
|
313
|
-
const ping = r.defineEvent("ping", z.object({}), { version: 1 });
|
|
320
|
+
const ping = r.defineEvent("ping", z.object({}), { piiFields: "none", version: 1 });
|
|
314
321
|
// Three MSPs: one pinned to api, one to worker (explicit), one to both.
|
|
315
322
|
// A fourth would be a default-undefined-runIn MSP which resolves to
|
|
316
323
|
// "worker" — covered implicitly by the worker test below.
|
|
@@ -57,6 +57,7 @@ const orderFeature = defineFeature("upcastshop", (r) => {
|
|
|
57
57
|
"priced",
|
|
58
58
|
z.object({ totalCents: z.number().int(), currency: z.string() }),
|
|
59
59
|
{
|
|
60
|
+
piiFields: "none",
|
|
60
61
|
version: 3,
|
|
61
62
|
migrations: [
|
|
62
63
|
// v1 → v2: renamed totalEuros → total (kept as string for this step)
|
|
@@ -321,6 +322,7 @@ describe("upcaster: async (Marten AsyncOnlyEventUpcaster — DB-Lookups)", () =>
|
|
|
321
322
|
"placed",
|
|
322
323
|
z.object({ customerId: z.string(), segment: z.string() }),
|
|
323
324
|
{
|
|
325
|
+
piiFields: "none",
|
|
324
326
|
version: 2,
|
|
325
327
|
migrations: [
|
|
326
328
|
{
|
|
@@ -407,6 +409,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
407
409
|
r.entity("hole-order", orderEntity);
|
|
408
410
|
// Only 1→2 registered — the 2→3 gap must be rejected.
|
|
409
411
|
r.defineEvent("bad", z.object({ v3: z.string() }), {
|
|
412
|
+
piiFields: "none",
|
|
410
413
|
version: 3,
|
|
411
414
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
|
|
412
415
|
});
|
|
@@ -424,6 +427,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
424
427
|
const future = defineFeature("future", (r) => {
|
|
425
428
|
r.entity("future-order", orderEntity);
|
|
426
429
|
r.defineEvent("early", z.object({ x: z.number() }), {
|
|
430
|
+
piiFields: "none",
|
|
427
431
|
version: 1,
|
|
428
432
|
migrations: [{ fromVersion: 1, toVersion: 2, transform: (p) => p }],
|
|
429
433
|
});
|
|
@@ -435,6 +439,7 @@ describe("upcaster: boot-time validation", () => {
|
|
|
435
439
|
const gaps = defineFeature("gaps", (r) => {
|
|
436
440
|
r.entity("gap-order", orderEntity);
|
|
437
441
|
r.defineEvent("jumpy", z.object({ v: z.number() }), {
|
|
442
|
+
piiFields: "none",
|
|
438
443
|
version: 4,
|
|
439
444
|
migrations: [
|
|
440
445
|
{ fromVersion: 1, toVersion: 2, transform: (p) => p },
|
|
@@ -452,6 +457,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
452
457
|
defineFeature("bigstep", (r) => {
|
|
453
458
|
r.entity("bigstep-order", orderEntity);
|
|
454
459
|
r.defineEvent("biz", z.object({ x: z.number() }), {
|
|
460
|
+
piiFields: "none",
|
|
455
461
|
version: 3,
|
|
456
462
|
migrations: [{ fromVersion: 1, toVersion: 3, transform: (p) => p }],
|
|
457
463
|
});
|
|
@@ -464,6 +470,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
464
470
|
defineFeature("dupestep", (r) => {
|
|
465
471
|
r.entity("dup-order", orderEntity);
|
|
466
472
|
r.defineEvent("dup", z.object({ x: z.number() }), {
|
|
473
|
+
piiFields: "none",
|
|
467
474
|
version: 2,
|
|
468
475
|
migrations: [
|
|
469
476
|
{ fromVersion: 1, toVersion: 2, transform: (p) => p },
|
|
@@ -478,7 +485,7 @@ describe("upcaster: registrar input validation", () => {
|
|
|
478
485
|
expect(() =>
|
|
479
486
|
defineFeature("badver", (r) => {
|
|
480
487
|
r.entity("badver-order", orderEntity);
|
|
481
|
-
r.defineEvent("neg", z.object({ x: z.number() }), { version: 0 });
|
|
488
|
+
r.defineEvent("neg", z.object({ x: z.number() }), { piiFields: "none", version: 0 });
|
|
482
489
|
}),
|
|
483
490
|
).toThrow(/positive integer/);
|
|
484
491
|
});
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// framework#2585 — JobContext.writeAs.
|
|
2
|
+
//
|
|
3
|
+
// `hasAccess` has no system bypass, so before writeAs existed a job could
|
|
4
|
+
// only write as its own systemUser (roles: ["system"]) and every write
|
|
5
|
+
// handler gated on concrete roles was unreachable from a job: the dispatch
|
|
6
|
+
// came back access_denied and the only signal was a failed job. These tests
|
|
7
|
+
// pin both halves — ctx.write against a role-gated handler is still denied,
|
|
8
|
+
// and ctx.writeAs(actor) reaches the same handler and attributes the event
|
|
9
|
+
// to the actor rather than to SYSTEM.
|
|
10
|
+
|
|
11
|
+
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { ROLES } from "../../auth/roles";
|
|
14
|
+
import { type BunTestDb, createTestDb } from "../../bun-db/__tests__/bun-test-db";
|
|
15
|
+
import { asRawClient } from "../../db/query";
|
|
16
|
+
import { createRegistry, defineFeature, type SessionUser, type WriteResult } from "../../engine";
|
|
17
|
+
import { createWorkerEntrypoint } from "../../entrypoint";
|
|
18
|
+
import { createArchivedStreamsTable, createEventsTable } from "../../event-store";
|
|
19
|
+
import { createEventConsumerStateTable } from "../../pipeline";
|
|
20
|
+
import { createTestRedis, type TestRedis } from "../../stack";
|
|
21
|
+
import { waitFor } from "../../testing";
|
|
22
|
+
import { createJobRunner } from "../job-runner";
|
|
23
|
+
|
|
24
|
+
const ADMIN_USER_ID = "11111111-1111-4111-8111-111111111111";
|
|
25
|
+
|
|
26
|
+
type ProbeOutcome = { readonly isSuccess: boolean; readonly errorCode: string | undefined };
|
|
27
|
+
|
|
28
|
+
const writeOutcomes: ProbeOutcome[] = [];
|
|
29
|
+
const writeAsOutcomes: ProbeOutcome[] = [];
|
|
30
|
+
const writeAsFailures: string[] = [];
|
|
31
|
+
|
|
32
|
+
function recordOutcome(sink: ProbeOutcome[], result: WriteResult): void {
|
|
33
|
+
sink.push({
|
|
34
|
+
isSuccess: result.isSuccess,
|
|
35
|
+
errorCode: result.isSuccess ? undefined : result.error.code,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const writeAsProbeFeature = defineFeature("writeAsProbe", (r) => {
|
|
40
|
+
const noted = r.defineEvent("adminNoted", z.object({ note: z.string() }), {
|
|
41
|
+
piiFields: "none",
|
|
42
|
+
version: 1,
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
r.writeHandler(
|
|
46
|
+
"adminNote",
|
|
47
|
+
z.object({ note: z.string() }),
|
|
48
|
+
async (event, ctx) => {
|
|
49
|
+
await ctx.unsafeAppendEvent({
|
|
50
|
+
aggregateId: crypto.randomUUID(),
|
|
51
|
+
aggregateType: "write-as-probe-note",
|
|
52
|
+
type: noted.name,
|
|
53
|
+
payload: { note: event.payload.note },
|
|
54
|
+
});
|
|
55
|
+
return { isSuccess: true as const, data: { note: event.payload.note } };
|
|
56
|
+
},
|
|
57
|
+
{ access: { roles: [ROLES.TenantAdmin] } },
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
r.job("writeAsSystem", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
61
|
+
const result = await ctx.write("write-as-probe:write:admin-note", {
|
|
62
|
+
note: payload["note"] as string, // @cast-boundary dynamic-key
|
|
63
|
+
});
|
|
64
|
+
recordOutcome(writeOutcomes, result);
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
r.job("writeAsActor", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
|
|
68
|
+
const actor: SessionUser = {
|
|
69
|
+
id: payload["actorId"] as string, // @cast-boundary dynamic-key
|
|
70
|
+
tenantId: ctx.systemUser.tenantId,
|
|
71
|
+
roles: [ROLES.TenantAdmin],
|
|
72
|
+
};
|
|
73
|
+
try {
|
|
74
|
+
const result = await ctx.writeAs(actor, "write-as-probe:write:admin-note", {
|
|
75
|
+
note: payload["note"] as string, // @cast-boundary dynamic-key
|
|
76
|
+
});
|
|
77
|
+
recordOutcome(writeAsOutcomes, result);
|
|
78
|
+
} catch (error) {
|
|
79
|
+
writeAsFailures.push(error instanceof Error ? error.message : String(error));
|
|
80
|
+
throw error;
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
const JWT = "job-write-as-test-secret-must-be-32-chars!";
|
|
86
|
+
|
|
87
|
+
let testDb: BunTestDb;
|
|
88
|
+
let testRedis: TestRedis;
|
|
89
|
+
|
|
90
|
+
beforeAll(async () => {
|
|
91
|
+
[testDb, testRedis] = await Promise.all([createTestDb(), createTestRedis()]);
|
|
92
|
+
await createEventsTable(testDb.db);
|
|
93
|
+
await createArchivedStreamsTable(testDb.db);
|
|
94
|
+
await createEventConsumerStateTable(testDb.db);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
afterAll(async () => {
|
|
98
|
+
await Promise.all([testDb.cleanup(), testRedis.cleanup()]);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
function redisUrl(): string {
|
|
102
|
+
return `redis://${testRedis.redis.options.host}:${testRedis.redis.options.port}/${testRedis.redis.options.db}`;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function uniquePrefix(label: string): string {
|
|
106
|
+
return `${label}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
async function notesFor(note: string): Promise<Array<{ created_by: string }>> {
|
|
110
|
+
const rows = await asRawClient(testDb.db).unsafe<{ created_by: string }>(
|
|
111
|
+
`SELECT created_by FROM kumiko_events
|
|
112
|
+
WHERE type = 'write-as-probe:event:admin-noted' AND payload->>'note' = $1`,
|
|
113
|
+
[note],
|
|
114
|
+
);
|
|
115
|
+
return [...rows];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("JobContext.writeAs reaches a role-gated write handler (framework#2585)", () => {
|
|
119
|
+
test("ctx.write is denied — the job's systemUser carries no TenantAdmin role", async () => {
|
|
120
|
+
writeOutcomes.length = 0;
|
|
121
|
+
const worker = createWorkerEntrypoint({
|
|
122
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
123
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
124
|
+
jwtSecret: JWT,
|
|
125
|
+
redisUrl: redisUrl(),
|
|
126
|
+
queueNamePrefix: uniquePrefix("job-write-as-denied"),
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await worker.start();
|
|
130
|
+
try {
|
|
131
|
+
await worker.jobRunner.dispatch("write-as-probe:job:write-as-system", {
|
|
132
|
+
note: "system attempt",
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(writeOutcomes.length).toBe(1);
|
|
137
|
+
});
|
|
138
|
+
expect(writeOutcomes[0]?.isSuccess).toBe(false);
|
|
139
|
+
expect(writeOutcomes[0]?.errorCode).toBe("access_denied");
|
|
140
|
+
expect(await notesFor("system attempt")).toHaveLength(0);
|
|
141
|
+
} finally {
|
|
142
|
+
await worker.stop();
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("ctx.writeAs(actor) commits and attributes the event to the actor", async () => {
|
|
147
|
+
writeAsOutcomes.length = 0;
|
|
148
|
+
const worker = createWorkerEntrypoint({
|
|
149
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
150
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
151
|
+
jwtSecret: JWT,
|
|
152
|
+
redisUrl: redisUrl(),
|
|
153
|
+
queueNamePrefix: uniquePrefix("job-write-as-allowed"),
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
await worker.start();
|
|
157
|
+
try {
|
|
158
|
+
await worker.jobRunner.dispatch("write-as-probe:job:write-as-actor", {
|
|
159
|
+
note: "actor attempt",
|
|
160
|
+
actorId: ADMIN_USER_ID,
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
await waitFor(() => {
|
|
164
|
+
expect(writeAsOutcomes.length).toBe(1);
|
|
165
|
+
});
|
|
166
|
+
expect(writeAsOutcomes[0]?.isSuccess).toBe(true);
|
|
167
|
+
|
|
168
|
+
const rows = await notesFor("actor attempt");
|
|
169
|
+
expect(rows).toHaveLength(1);
|
|
170
|
+
expect(rows[0]?.created_by).toBe(ADMIN_USER_ID);
|
|
171
|
+
} finally {
|
|
172
|
+
await worker.stop();
|
|
173
|
+
}
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
describe("JobContext.writeAs before attachDispatcher()", () => {
|
|
178
|
+
test("throws the same not-attached stub ctx.write does", async () => {
|
|
179
|
+
writeAsFailures.length = 0;
|
|
180
|
+
const runner = createJobRunner({
|
|
181
|
+
registry: createRegistry([writeAsProbeFeature]),
|
|
182
|
+
context: { db: testDb.db, redis: testRedis.redis },
|
|
183
|
+
redisUrl: redisUrl(),
|
|
184
|
+
consumerLane: "worker",
|
|
185
|
+
queueNamePrefix: uniquePrefix("job-write-as-bare"),
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
await runner.start();
|
|
189
|
+
try {
|
|
190
|
+
await runner.dispatch("write-as-probe:job:write-as-actor", {
|
|
191
|
+
note: "should never land",
|
|
192
|
+
actorId: ADMIN_USER_ID,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
await waitFor(() => {
|
|
196
|
+
expect(writeAsFailures.length).toBe(1);
|
|
197
|
+
});
|
|
198
|
+
expect(writeAsFailures[0]).toContain(
|
|
199
|
+
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
200
|
+
);
|
|
201
|
+
expect(await notesFor("should never land")).toHaveLength(0);
|
|
202
|
+
} finally {
|
|
203
|
+
await runner.stop();
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
package/src/jobs/job-runner.ts
CHANGED
|
@@ -144,9 +144,9 @@ export type JobRunner = {
|
|
|
144
144
|
payload: Record<string, unknown>,
|
|
145
145
|
user?: SessionUser,
|
|
146
146
|
): Promise<void>;
|
|
147
|
-
// Wires JobContext.write/queryAs to the real dispatcher — called
|
|
148
|
-
// boot, after the dispatcher exists (job-runner construction happens
|
|
149
|
-
// before it). Before this runs, JobContext.write/queryAs throw.
|
|
147
|
+
// Wires JobContext.write/writeAs/queryAs to the real dispatcher — called
|
|
148
|
+
// once at boot, after the dispatcher exists (job-runner construction happens
|
|
149
|
+
// before it). Before this runs, JobContext.write/writeAs/queryAs throw.
|
|
150
150
|
attachDispatcher(ref: DispatchWriteRef): void;
|
|
151
151
|
};
|
|
152
152
|
|
|
@@ -362,7 +362,7 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
362
362
|
// at job-execution time (after start()), so it is always defined by then.
|
|
363
363
|
let selfRunner: JobRunner | undefined;
|
|
364
364
|
// Set by attachDispatcher() once the boot-level dispatcher exists.
|
|
365
|
-
// JobContext.write/queryAs throw until this is set — see JobContext doc.
|
|
365
|
+
// JobContext.write/writeAs/queryAs throw until this is set — see JobContext doc.
|
|
366
366
|
let dispatchWriteRef: DispatchWriteRef | undefined;
|
|
367
367
|
|
|
368
368
|
// Counts active + waiting jobs with this name for this tenant across
|
|
@@ -581,6 +581,14 @@ export function createJobRunner(options: JobRunnerOptions): JobRunner {
|
|
|
581
581
|
}
|
|
582
582
|
return dispatchWriteRef.write(jobSystemUser, qn, payload);
|
|
583
583
|
},
|
|
584
|
+
writeAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
585
|
+
if (!dispatchWriteRef) {
|
|
586
|
+
throw new Error(
|
|
587
|
+
"JobContext.writeAs called before dispatcher attached — call attachDispatcher() first",
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
return dispatchWriteRef.write(user, qn, payload);
|
|
591
|
+
},
|
|
584
592
|
queryAs: (user: SessionUser, qn: string, payload: unknown) => {
|
|
585
593
|
if (!dispatchWriteRef) {
|
|
586
594
|
throw new Error(
|
|
@@ -32,7 +32,9 @@ const itemTable = buildEntityTable("arch-item", itemEntity);
|
|
|
32
32
|
const archFeature = defineFeature("archtest", (r) => {
|
|
33
33
|
r.entity("arch-item", itemEntity);
|
|
34
34
|
|
|
35
|
-
const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() })
|
|
35
|
+
const labelChanged = r.defineEvent("label-changed", z.object({ label: z.string() }), {
|
|
36
|
+
piiFields: "none",
|
|
37
|
+
});
|
|
36
38
|
|
|
37
39
|
const executor = createEventStoreExecutor(itemTable, itemEntity, {
|
|
38
40
|
entityName: "arch-item",
|
|
@@ -54,7 +54,9 @@ const applyObservations: ReqCtxSnapshot[] = [];
|
|
|
54
54
|
const causationFeature = defineFeature("causation", (r) => {
|
|
55
55
|
r.entity("causation-order", orderEntity);
|
|
56
56
|
|
|
57
|
-
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() })
|
|
57
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), {
|
|
58
|
+
piiFields: "none",
|
|
59
|
+
});
|
|
58
60
|
|
|
59
61
|
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
60
62
|
entityName: "causation-order",
|
|
@@ -58,7 +58,9 @@ const shippingFeature = defineFeature("shipping", (r) => {
|
|
|
58
58
|
r.entity("domain-shipment", shipmentEntity);
|
|
59
59
|
|
|
60
60
|
// Domain event. Qualified name is "shipping:event:billed".
|
|
61
|
-
const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() })
|
|
61
|
+
const shipmentBilled = r.defineEvent("billed", z.object({ cost: z.number() }), {
|
|
62
|
+
piiFields: "none",
|
|
63
|
+
});
|
|
62
64
|
|
|
63
65
|
r.projection({
|
|
64
66
|
name: "shipment-billing",
|
|
@@ -34,14 +34,18 @@ let foreignEventName = "";
|
|
|
34
34
|
// emit this from one of its handlers — Sprint-E cross-feature-ownership
|
|
35
35
|
// guard must reject it at the append site.
|
|
36
36
|
const neighborFeature = defineFeature("neighbor", (r) => {
|
|
37
|
-
const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() })
|
|
37
|
+
const foreign = r.defineEvent("neighbor.signal", z.object({ userId: z.uuid() }), {
|
|
38
|
+
piiFields: "none",
|
|
39
|
+
});
|
|
38
40
|
foreignEventName = foreign.name;
|
|
39
41
|
});
|
|
40
42
|
|
|
41
43
|
const emitterFeature = defineFeature("emitter", (r) => {
|
|
42
44
|
r.entity("widget", sharedWidgetEntity);
|
|
43
45
|
|
|
44
|
-
const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() })
|
|
46
|
+
const welcome = r.defineEvent("user.welcomed", z.object({ userId: z.uuid(), email: z.email() }), {
|
|
47
|
+
piiFields: "none",
|
|
48
|
+
});
|
|
45
49
|
welcomeEventName = welcome.name;
|
|
46
50
|
|
|
47
51
|
r.writeHandler(
|
|
@@ -31,8 +31,12 @@ const cartTable = buildEntityTable("f4wCart", cartEntity);
|
|
|
31
31
|
const cartFeature = defineFeature("f4w", (r) => {
|
|
32
32
|
r.entity("f4wCart", cartEntity);
|
|
33
33
|
|
|
34
|
-
const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() })
|
|
35
|
-
|
|
34
|
+
const itemAdded = r.defineEvent("itemAdded", z.object({ sku: z.string(), qty: z.number() }), {
|
|
35
|
+
piiFields: "none",
|
|
36
|
+
});
|
|
37
|
+
const checkedOut = r.defineEvent("checkedOut", z.object({ totalCents: z.number() }), {
|
|
38
|
+
piiFields: "none",
|
|
39
|
+
});
|
|
36
40
|
|
|
37
41
|
const cartExecutor = createEventStoreExecutor(cartTable, cartEntity, {
|
|
38
42
|
entityName: "f4wCart",
|
|
@@ -30,7 +30,9 @@ const FILE_REF_CREATED = entityEventName("fileRef", "created");
|
|
|
30
30
|
const processedItems: Array<{ readonly fileRefId: string }> = [];
|
|
31
31
|
|
|
32
32
|
const jobTriggerFixtureFeature = defineFeature("job-trigger-fixture", (r) => {
|
|
33
|
-
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) })
|
|
33
|
+
r.defineEvent("item-requested", z.object({ fileRefId: z.string().min(1) }), {
|
|
34
|
+
piiFields: "none",
|
|
35
|
+
});
|
|
34
36
|
|
|
35
37
|
// Mirrors document-ingest-foundation's request-ingest MSP exactly: reacts
|
|
36
38
|
// to fileRef.created, appends a NEW event via unsafeAppendEvent — no
|
|
@@ -42,9 +42,11 @@ const confirmLoadCounts: number[] = [];
|
|
|
42
42
|
const mmhFeature = defineFeature("mmh", (r) => {
|
|
43
43
|
r.entity("mmh-order", orderEntity);
|
|
44
44
|
|
|
45
|
-
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }));
|
|
46
|
-
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() })
|
|
47
|
-
|
|
45
|
+
const placed = r.defineEvent("placed", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
46
|
+
const confirmed = r.defineEvent("confirmed", z.object({ orderId: z.uuid() }), {
|
|
47
|
+
piiFields: "none",
|
|
48
|
+
});
|
|
49
|
+
const shipped = r.defineEvent("shipped", z.object({ orderId: z.uuid() }), { piiFields: "none" });
|
|
48
50
|
|
|
49
51
|
const orderExecutor = createEventStoreExecutor(orderTable, orderEntity, {
|
|
50
52
|
entityName: "mmh-order",
|
|
@@ -72,14 +72,17 @@ const feature = defineFeature("mspreb", (r) => {
|
|
|
72
72
|
const invoiceBilled = r.defineEvent(
|
|
73
73
|
"invoice-billed",
|
|
74
74
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
75
|
+
{ piiFields: "none" },
|
|
75
76
|
);
|
|
76
77
|
const paymentReceived = r.defineEvent(
|
|
77
78
|
"payment-received",
|
|
78
79
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
80
|
+
{ piiFields: "none" },
|
|
79
81
|
);
|
|
80
82
|
const escalationTriggered = r.defineEvent(
|
|
81
83
|
"escalation-triggered",
|
|
82
84
|
z.object({ customer: z.uuid() }),
|
|
85
|
+
{ piiFields: "none" },
|
|
83
86
|
);
|
|
84
87
|
|
|
85
88
|
// 1) Main rebuildable MSP — table materialized from two event types.
|
|
@@ -56,10 +56,12 @@ const mspFeature = defineFeature("msptest", (r) => {
|
|
|
56
56
|
const shipmentBilled = r.defineEvent(
|
|
57
57
|
"shipment-billed",
|
|
58
58
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
59
|
+
{ piiFields: "none" },
|
|
59
60
|
);
|
|
60
61
|
const refundIssued = r.defineEvent(
|
|
61
62
|
"refund-issued",
|
|
62
63
|
z.object({ customer: z.uuid(), cents: z.number().int() }),
|
|
64
|
+
{ piiFields: "none" },
|
|
63
65
|
);
|
|
64
66
|
|
|
65
67
|
r.multiStreamProjection({
|
|
@@ -110,7 +110,9 @@ const MSP_EVENT_SHORT = "poison-noted";
|
|
|
110
110
|
const feature = defineFeature("poisontest", (r) => {
|
|
111
111
|
r.entity("poison-item", itemEntity);
|
|
112
112
|
r.projection(poisonProjection);
|
|
113
|
-
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() })
|
|
113
|
+
const noted = r.defineEvent(MSP_EVENT_SHORT, z.object({ groupId: z.uuid(), name: z.string() }), {
|
|
114
|
+
piiFields: "none",
|
|
115
|
+
});
|
|
114
116
|
r.multiStreamProjection({
|
|
115
117
|
name: "poison-msp-counter",
|
|
116
118
|
table: mspCounterTable,
|