@cosmicdrift/kumiko-framework 0.166.0 → 0.167.1
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 -6
- package/src/api/__tests__/jwt.test.ts +12 -0
- package/src/api/__tests__/pii-leak-guard.integration.test.ts +2 -5
- package/src/api/auth-middleware.ts +1 -0
- package/src/api/jwt.ts +7 -0
- package/src/crypto/index.ts +0 -3
- package/src/crypto/kms-adapter.ts +26 -1
- package/src/db/__tests__/blind-index.integration.test.ts +4 -2
- package/src/db/__tests__/implicit-projection-equivalence.integration.test.ts +4 -2
- package/src/db/index.ts +0 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +20 -0
- package/src/engine/boot-validator/pii-retention.ts +7 -1
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +4 -2
- package/src/event-store/errors.ts +58 -2
- package/src/pipeline/dispatch-query.ts +1 -1
- package/src/pipeline/dispatch-shared.ts +15 -9
- package/src/pipeline/dispatch-stream.ts +1 -1
- package/src/pipeline/dispatch-write.ts +1 -1
- package/src/search/__tests__/search-pii-derived-index.integration.test.ts +1 -1
- package/src/secrets/__tests__/leak-guard.test.ts +12 -0
- package/src/testing/index.ts +13 -4
- package/src/time/tz-context.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.167.1",
|
|
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>",
|
|
@@ -182,9 +182,10 @@
|
|
|
182
182
|
"./package.json": "./package.json"
|
|
183
183
|
},
|
|
184
184
|
"dependencies": {
|
|
185
|
+
"@cosmicdrift/kumiko-types": "0.167.1",
|
|
185
186
|
"bullmq": "^5.76.7",
|
|
186
187
|
"bun-types": "^1.3.13",
|
|
187
|
-
"hono": "^4.12.
|
|
188
|
+
"hono": "^4.12.27",
|
|
188
189
|
"i18next": "^26.1.0",
|
|
189
190
|
"ioredis": "^5.10.1",
|
|
190
191
|
"jose": "^6.2.3",
|
|
@@ -196,11 +197,8 @@
|
|
|
196
197
|
"uuid": "^14.0.0",
|
|
197
198
|
"zod": "^4.4.3"
|
|
198
199
|
},
|
|
199
|
-
"peerDependencies": {
|
|
200
|
-
"@cosmicdrift/kumiko-types": "^0.166.0"
|
|
201
|
-
},
|
|
202
200
|
"devDependencies": {
|
|
203
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
201
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.167.1",
|
|
204
202
|
"bun-types": "^1.3.13",
|
|
205
203
|
"pino-pretty": "^13.1.3"
|
|
206
204
|
},
|
|
@@ -82,6 +82,18 @@ describe("createJwtHelper.verify — payload validation (KF-2)", () => {
|
|
|
82
82
|
.sign(new TextEncoder().encode(SECRET));
|
|
83
83
|
await expect(jwt.verify(token)).rejects.toThrow(/sub/);
|
|
84
84
|
});
|
|
85
|
+
|
|
86
|
+
// fw#1636 — SessionUser.timezone must survive the sign/verify roundtrip
|
|
87
|
+
// (jwt.sign silently drops any field it doesn't explicitly copy).
|
|
88
|
+
it("round-trips the timezone claim when set", async () => {
|
|
89
|
+
const payload = await jwt.verify(await jwt.sign({ ...user, timezone: "Asia/Tokyo" }));
|
|
90
|
+
expect(payload.timezone).toBe("Asia/Tokyo");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("omits the timezone claim when unset", async () => {
|
|
94
|
+
const payload = await jwt.verify(await jwt.sign(user));
|
|
95
|
+
expect(payload.timezone).toBeUndefined();
|
|
96
|
+
});
|
|
85
97
|
});
|
|
86
98
|
|
|
87
99
|
describe("createJwtHelper — keyring form", () => {
|
|
@@ -3,12 +3,9 @@
|
|
|
3
3
|
// rot), Prod → redact + Error-Log, ohne KMS → kein Scan (pass-through).
|
|
4
4
|
|
|
5
5
|
import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
|
|
6
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
6
7
|
import { z } from "zod";
|
|
7
|
-
import {
|
|
8
|
-
configurePiiSubjectKms,
|
|
9
|
-
InMemoryKmsAdapter,
|
|
10
|
-
resetPiiSubjectKmsForTests,
|
|
11
|
-
} from "../../crypto";
|
|
8
|
+
import { configurePiiSubjectKms, InMemoryKmsAdapter } from "../../crypto";
|
|
12
9
|
import { defineFeature } from "../../engine/define-feature";
|
|
13
10
|
import { defineQueryHandler } from "../../engine/define-handler";
|
|
14
11
|
import { setupTestStack, type TestStack, TestUsers } from "../../stack";
|
|
@@ -307,6 +307,7 @@ export function authMiddleware(jwt: JwtHelper, options: AuthMiddlewareOptions =
|
|
|
307
307
|
id: payload.sub,
|
|
308
308
|
tenantId: payload.tenantId,
|
|
309
309
|
roles: payload.roles,
|
|
310
|
+
...(payload.timezone ? { timezone: payload.timezone } : {}),
|
|
310
311
|
...(payload.claims ? { claims: payload.claims } : {}),
|
|
311
312
|
...(payload.jti ? { sid: payload.jti } : {}),
|
|
312
313
|
};
|
package/src/api/jwt.ts
CHANGED
|
@@ -10,6 +10,9 @@ export type JwtPayload = {
|
|
|
10
10
|
sub: string;
|
|
11
11
|
tenantId: TenantId;
|
|
12
12
|
roles: string[];
|
|
13
|
+
// IANA zone from user.timezone, set at login — see SessionUser.timezone
|
|
14
|
+
// (fw#1636). Absent → ctx.tz.user falls back to ctx.tz.tenant.
|
|
15
|
+
timezone?: string;
|
|
13
16
|
// Optional — present when a feature has registered auth claims via the
|
|
14
17
|
// `r.authClaims()` hook system. Absent for stateless-JWT deployments
|
|
15
18
|
// without auth-claims wiring.
|
|
@@ -106,6 +109,7 @@ export function createJwtHelper(
|
|
|
106
109
|
tenantId: user.tenantId,
|
|
107
110
|
roles: [...user.roles],
|
|
108
111
|
};
|
|
112
|
+
if (user.timezone) body.timezone = user.timezone;
|
|
109
113
|
if (user.claims) body.claims = { ...user.claims };
|
|
110
114
|
|
|
111
115
|
const header: jose.JWTHeaderParameters = keyring.signKid
|
|
@@ -155,6 +159,9 @@ export function createJwtHelper(
|
|
|
155
159
|
tenantId,
|
|
156
160
|
roles,
|
|
157
161
|
};
|
|
162
|
+
if (typeof payload["timezone"] === "string") {
|
|
163
|
+
result.timezone = payload["timezone"];
|
|
164
|
+
}
|
|
158
165
|
const claims = payload["claims"];
|
|
159
166
|
if (claims && typeof claims === "object") {
|
|
160
167
|
result.claims = claims as DbRow;
|
package/src/crypto/index.ts
CHANGED
|
@@ -6,14 +6,12 @@ export {
|
|
|
6
6
|
configureBlindIndexKey,
|
|
7
7
|
configuredBlindIndexKey,
|
|
8
8
|
decodeBlindIndexKey,
|
|
9
|
-
resetBlindIndexKeyForTests,
|
|
10
9
|
} from "./blind-index";
|
|
11
10
|
export {
|
|
12
11
|
configuredEventPiiCatalog,
|
|
13
12
|
configureEventPiiCatalog,
|
|
14
13
|
type EventPiiCatalog,
|
|
15
14
|
encryptEventPayloadPii,
|
|
16
|
-
resetEventPiiCatalogForTests,
|
|
17
15
|
} from "./event-pii";
|
|
18
16
|
export { InMemoryKmsAdapter } from "./in-memory-kms-adapter";
|
|
19
17
|
export {
|
|
@@ -64,7 +62,6 @@ export {
|
|
|
64
62
|
isPiiCiphertext,
|
|
65
63
|
PII_CIPHERTEXT_PREFIX,
|
|
66
64
|
PII_ERASED_SENTINEL,
|
|
67
|
-
resetPiiSubjectKmsForTests,
|
|
68
65
|
} from "./pii-field-encryption";
|
|
69
66
|
export {
|
|
70
67
|
createRequestKmsCache,
|
|
@@ -1,2 +1,27 @@
|
|
|
1
|
-
|
|
1
|
+
import { type SubjectId, subjectIdToKey } from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
2
|
+
|
|
2
3
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
4
|
+
|
|
5
|
+
// The KMS error classes live here and not in kumiko-types (#1629): callers
|
|
6
|
+
// branch on them with `instanceof`, which needs a single copy of the class.
|
|
7
|
+
|
|
8
|
+
export class KeyErasedError extends Error {
|
|
9
|
+
constructor(public readonly subject: SubjectId) {
|
|
10
|
+
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|
|
11
|
+
this.name = "KeyErasedError";
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class KeyNotFoundError extends Error {
|
|
16
|
+
constructor(public readonly subject: SubjectId) {
|
|
17
|
+
super(`Subject key not found: ${subjectIdToKey(subject)}`);
|
|
18
|
+
this.name = "KeyNotFoundError";
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class KeyAlreadyExistsError extends Error {
|
|
23
|
+
constructor(public readonly subject: SubjectId) {
|
|
24
|
+
super(`Subject key already exists: ${subjectIdToKey(subject)}`);
|
|
25
|
+
this.name = "KeyAlreadyExistsError";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
@@ -4,6 +4,10 @@
|
|
|
4
4
|
// dem Ciphertext (erased → NULL), und der Forget-Sweep nullt sofort.
|
|
5
5
|
|
|
6
6
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
7
|
+
import {
|
|
8
|
+
resetBlindIndexKeyForTests,
|
|
9
|
+
resetPiiSubjectKmsForTests,
|
|
10
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
7
11
|
import {
|
|
8
12
|
computeBlindIndex,
|
|
9
13
|
configureBlindIndexKey,
|
|
@@ -11,8 +15,6 @@ import {
|
|
|
11
15
|
decodeBlindIndexKey,
|
|
12
16
|
InMemoryKmsAdapter,
|
|
13
17
|
isPiiCiphertext,
|
|
14
|
-
resetBlindIndexKeyForTests,
|
|
15
|
-
resetPiiSubjectKmsForTests,
|
|
16
18
|
subjectIdToKey,
|
|
17
19
|
} from "../../crypto";
|
|
18
20
|
import { defineFeature } from "../../engine/define-feature";
|
|
@@ -227,6 +227,10 @@ describe("implicit-projection / Live==Rebuild equivalence", () => {
|
|
|
227
227
|
// damit auch für sensitive Spalten + Blind-Index. Einzige legitime Divergenz
|
|
228
228
|
// bleibt Crypto-Shredding: DEK erased → bidx NULL, Wert unlesbar.
|
|
229
229
|
|
|
230
|
+
import {
|
|
231
|
+
resetBlindIndexKeyForTests,
|
|
232
|
+
resetPiiSubjectKmsForTests,
|
|
233
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
230
234
|
import {
|
|
231
235
|
computeBlindIndex,
|
|
232
236
|
configureBlindIndexKey,
|
|
@@ -235,8 +239,6 @@ import {
|
|
|
235
239
|
decryptPiiFieldValues,
|
|
236
240
|
InMemoryKmsAdapter,
|
|
237
241
|
isPiiCiphertext,
|
|
238
|
-
resetBlindIndexKeyForTests,
|
|
239
|
-
resetPiiSubjectKmsForTests,
|
|
240
242
|
} from "../../crypto";
|
|
241
243
|
import { asRawClient, selectMany } from "../../db/query";
|
|
242
244
|
|
package/src/db/index.ts
CHANGED
|
@@ -558,6 +558,7 @@ describe("validateBoot — retention", () => {
|
|
|
558
558
|
createEntity({
|
|
559
559
|
fields: {
|
|
560
560
|
invoiceNumber: createTextField({ allowPlaintext: "is-business-data" }),
|
|
561
|
+
customerName: createTextField({ pii: true }),
|
|
561
562
|
},
|
|
562
563
|
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
563
564
|
}),
|
|
@@ -570,6 +571,25 @@ describe("validateBoot — retention", () => {
|
|
|
570
571
|
expect(matchingWarn).toBeDefined();
|
|
571
572
|
});
|
|
572
573
|
|
|
574
|
+
test("blockDelete without any subject-annotated field stays silent (#1622)", () => {
|
|
575
|
+
const feature = defineFeature("test", (r) => {
|
|
576
|
+
r.entity(
|
|
577
|
+
"lease",
|
|
578
|
+
createEntity({
|
|
579
|
+
fields: {
|
|
580
|
+
reference: createTextField({ allowPlaintext: "is-business-data" }),
|
|
581
|
+
},
|
|
582
|
+
retention: { keepFor: "10y", strategy: "blockDelete" },
|
|
583
|
+
}),
|
|
584
|
+
);
|
|
585
|
+
});
|
|
586
|
+
validateBoot([feature]);
|
|
587
|
+
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
588
|
+
String(args[0]).includes('strategy="blockDelete" but no field has an anonymize-function'),
|
|
589
|
+
);
|
|
590
|
+
expect(matchingWarn).toBeUndefined();
|
|
591
|
+
});
|
|
592
|
+
|
|
573
593
|
test('retention.keepFor with invalid format "30days" warns', () => {
|
|
574
594
|
const feature = defineFeature("test", (r) => {
|
|
575
595
|
r.entity(
|
|
@@ -234,11 +234,17 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
234
234
|
}
|
|
235
235
|
|
|
236
236
|
if (retention.strategy === "blockDelete") {
|
|
237
|
+
// blockDelete on an entity with no subject field is the correct
|
|
238
|
+
// "never auto-delete" choice; User-Forget never reaches those rows (#1622).
|
|
239
|
+
const hasSubjectField = Object.values(fieldsByName).some((f) => {
|
|
240
|
+
const a = f as PiiAnnotations; // @cast-boundary schema-walk
|
|
241
|
+
return Boolean(a.pii || a.userOwned || a.tenantOwned);
|
|
242
|
+
});
|
|
237
243
|
const hasAnonymize = Object.values(fieldsByName).some((f) => {
|
|
238
244
|
const a = f as PiiAnnotations; // @cast-boundary schema-walk
|
|
239
245
|
return Boolean(a.anonymize);
|
|
240
246
|
});
|
|
241
|
-
if (!hasAnonymize) {
|
|
247
|
+
if (hasSubjectField && !hasAnonymize) {
|
|
242
248
|
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
243
249
|
console.warn(
|
|
244
250
|
`[kumiko:boot] [Feature ${feature.name}] Entity "${entityName}" retention.strategy="blockDelete" but no field has an anonymize-function. User-Forget cannot anonymize — Forget will return error. Add { anonymize: () => null } or () => "[ANONYMIZED]" to PII fields.`,
|
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// blind-index column, so equality lookups keep working.
|
|
8
8
|
|
|
9
9
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
10
|
+
import {
|
|
11
|
+
resetBlindIndexKeyForTests,
|
|
12
|
+
resetPiiSubjectKmsForTests,
|
|
13
|
+
} from "@cosmicdrift/kumiko-framework/testing";
|
|
10
14
|
import { z } from "zod";
|
|
11
15
|
import {
|
|
12
16
|
configureBlindIndexKey,
|
|
@@ -14,8 +18,6 @@ import {
|
|
|
14
18
|
InMemoryKmsAdapter,
|
|
15
19
|
isPiiCiphertext,
|
|
16
20
|
PII_ERASED_SENTINEL,
|
|
17
|
-
resetBlindIndexKeyForTests,
|
|
18
|
-
resetPiiSubjectKmsForTests,
|
|
19
21
|
} from "../../crypto";
|
|
20
22
|
import { applyEntityEvent } from "../../db/apply-entity-event";
|
|
21
23
|
import { backfillEventPiiEncryption } from "../../db/queries/backfill-pii";
|
|
@@ -1,2 +1,58 @@
|
|
|
1
|
-
//
|
|
2
|
-
|
|
1
|
+
// Failure modes of the event-store's append() path. Surfaced as typed
|
|
2
|
+
// errors so the executor layer can map them to the framework's
|
|
3
|
+
// WriteResult error contract (version_conflict).
|
|
4
|
+
//
|
|
5
|
+
// These live here and not in kumiko-types (#1629): `instanceof` needs a
|
|
6
|
+
// single copy of the class, which forced kumiko-types to be a peerDependency
|
|
7
|
+
// and made every minor changeset resolve to a major bump.
|
|
8
|
+
|
|
9
|
+
export class VersionConflictError extends Error {
|
|
10
|
+
public readonly aggregateId: string;
|
|
11
|
+
public readonly expectedVersion: number;
|
|
12
|
+
constructor(aggregateId: string, expectedVersion: number) {
|
|
13
|
+
super(
|
|
14
|
+
`Version conflict on aggregate ${aggregateId}: expected predecessor version ${expectedVersion}`,
|
|
15
|
+
);
|
|
16
|
+
this.name = "VersionConflictError";
|
|
17
|
+
this.aggregateId = aggregateId;
|
|
18
|
+
this.expectedVersion = expectedVersion;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Thrown when append() collides on the partial unique index over
|
|
23
|
+
// metadata.idempotencyKey (tenant-scoped). Distinct from VersionConflictError:
|
|
24
|
+
// a version conflict means two writers raced the same predecessor; this
|
|
25
|
+
// means the same idempotency key was used twice, which is a caller-side
|
|
26
|
+
// retry that must have already appended once. Callers that set
|
|
27
|
+
// idempotencyKey should treat this as "already applied" rather than retry.
|
|
28
|
+
export class IdempotentAppendConflictError extends Error {
|
|
29
|
+
public readonly tenantId: string;
|
|
30
|
+
public readonly idempotencyKey: string;
|
|
31
|
+
constructor(tenantId: string, idempotencyKey: string) {
|
|
32
|
+
super(
|
|
33
|
+
`Idempotency conflict on tenant ${tenantId}: an event with idempotencyKey "${idempotencyKey}" was already appended.`,
|
|
34
|
+
);
|
|
35
|
+
this.name = "IdempotentAppendConflictError";
|
|
36
|
+
this.tenantId = tenantId;
|
|
37
|
+
this.idempotencyKey = idempotencyKey;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Thrown when ctx.appendEvent targets an archived stream. Archived aggregates
|
|
42
|
+
// are read-only — restoreStream() makes them writable again. The archive
|
|
43
|
+
// state is not carried on the events themselves; it lives on the sparse
|
|
44
|
+
// kumiko_archived_streams table. Handlers that need to branch on archive
|
|
45
|
+
// state should call ctx.isStreamArchived(id) first.
|
|
46
|
+
export class ArchivedStreamError extends Error {
|
|
47
|
+
public readonly tenantId: string;
|
|
48
|
+
public readonly aggregateId: string;
|
|
49
|
+
constructor(tenantId: string, aggregateId: string) {
|
|
50
|
+
super(
|
|
51
|
+
`Aggregate ${aggregateId} on tenant ${tenantId} is archived — appendEvent is blocked. ` +
|
|
52
|
+
`Call restoreStream() to re-open the stream before writing.`,
|
|
53
|
+
);
|
|
54
|
+
this.name = "ArchivedStreamError";
|
|
55
|
+
this.tenantId = tenantId;
|
|
56
|
+
this.aggregateId = aggregateId;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
@@ -79,7 +79,7 @@ async function executeQueryInner(
|
|
|
79
79
|
typeof parsed.data === "object" &&
|
|
80
80
|
parsed.data !== null &&
|
|
81
81
|
(parsed.data as Record<string, unknown>)["includeDeleted"] === true; // @cast-boundary validated-payload
|
|
82
|
-
const handlerContext = buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
|
|
82
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, tx, undefined, includeDeleted);
|
|
83
83
|
let result = await handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
84
84
|
|
|
85
85
|
// postQuery-Hooks: fire BEFORE field-access-filter so hooks see raw data
|
|
@@ -146,14 +146,14 @@ async function appendDomainEvent(
|
|
|
146
146
|
);
|
|
147
147
|
}
|
|
148
148
|
|
|
149
|
-
export function buildHandlerContext(
|
|
149
|
+
export async function buildHandlerContext(
|
|
150
150
|
ctx: DispatchContext,
|
|
151
151
|
type: string,
|
|
152
152
|
user: SessionUser,
|
|
153
153
|
tx?: DbTx,
|
|
154
154
|
afterCommitHooks?: AfterCommitHook[],
|
|
155
155
|
includeDeleted?: boolean,
|
|
156
|
-
): HandlerContext {
|
|
156
|
+
): Promise<HandlerContext> {
|
|
157
157
|
const { registry, appContext: context, effectiveFeatures, jobRunner } = ctx;
|
|
158
158
|
const isSystem = registry.isHandlerSystemScoped(type);
|
|
159
159
|
// The outer dispatcher receives a DbConnection from the server/stack;
|
|
@@ -515,13 +515,19 @@ export function buildHandlerContext(
|
|
|
515
515
|
// HandlerContext. The spread-then-assign order matters: anything in
|
|
516
516
|
// `context` can be overridden, but we want the authoritative registry
|
|
517
517
|
// from the dispatcher's own closure to win.
|
|
518
|
-
// ctx.tz
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
//
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
)
|
|
518
|
+
// ctx.tz is always present. tenant reads tenant:config:timezone through
|
|
519
|
+
// the already-built `config` accessor (raw qualified-key — no import of
|
|
520
|
+
// the tenant feature, framework/pipeline stays bundled-features-free);
|
|
521
|
+
// falls back to "UTC" when config is missing or the key is unset. user
|
|
522
|
+
// comes from SessionUser.timezone (set at login), else falls back to
|
|
523
|
+
// tenant (createTzContext's own default). An app-injected GeoTzProvider
|
|
524
|
+
// (context.geoTzProvider) feeds ctx.tz.fromCoordinates / fromAddress.
|
|
525
|
+
const tenantTz = config !== undefined ? await config("tenant:config:timezone") : undefined;
|
|
526
|
+
const tz = createTzContext({
|
|
527
|
+
...(context.geoTzProvider !== undefined ? { geoTz: context.geoTzProvider } : {}),
|
|
528
|
+
tenant: typeof tenantTz === "string" ? tenantTz : "UTC",
|
|
529
|
+
...(user.timezone !== undefined && { user: user.timezone }),
|
|
530
|
+
});
|
|
525
531
|
|
|
526
532
|
return {
|
|
527
533
|
...context,
|
|
@@ -73,7 +73,7 @@ async function* executeStreamInner(
|
|
|
73
73
|
// await; close is fire-and-forget instead (#1563).
|
|
74
74
|
let abandonedForInvalidation = false;
|
|
75
75
|
try {
|
|
76
|
-
const handlerContext = buildHandlerContext(ctx, type, user);
|
|
76
|
+
const handlerContext = await buildHandlerContext(ctx, type, user);
|
|
77
77
|
const chunks = handler.handler({ type, payload: parsed.data, user }, handlerContext);
|
|
78
78
|
iterator = chunks[Symbol.asyncIterator]();
|
|
79
79
|
|
|
@@ -348,7 +348,7 @@ async function executeWriteInner(
|
|
|
348
348
|
}
|
|
349
349
|
}
|
|
350
350
|
|
|
351
|
-
const handlerContext = buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
|
|
351
|
+
const handlerContext = await buildHandlerContext(ctx, type, user, tx, afterCommitHooks);
|
|
352
352
|
|
|
353
353
|
// Auto transition guard: if entity has transitions and handler doesn't skip it
|
|
354
354
|
if (entityName && !handler.unsafeSkipTransitionGuard) {
|
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
// plaintext in derived search index, purged on subject erase.
|
|
3
3
|
|
|
4
4
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
|
|
5
|
+
import { resetPiiSubjectKmsForTests } from "@cosmicdrift/kumiko-framework/testing";
|
|
5
6
|
import {
|
|
6
7
|
configurePiiSubjectKms,
|
|
7
8
|
InMemoryKmsAdapter,
|
|
8
9
|
isPiiCiphertext,
|
|
9
|
-
resetPiiSubjectKmsForTests,
|
|
10
10
|
subjectIdToKey,
|
|
11
11
|
} from "../../crypto";
|
|
12
12
|
import { asRawClient, buildEntityTable, createEventStoreExecutor, createTenantDb } from "../../db";
|
|
@@ -89,4 +89,16 @@ describe("assertNoSecretLeak — walks the response tree for branded values", ()
|
|
|
89
89
|
expect(() => assertNoSecretLeak(undefined)).not.toThrow();
|
|
90
90
|
expect(() => assertNoSecretLeak(null)).not.toThrow();
|
|
91
91
|
});
|
|
92
|
+
|
|
93
|
+
// Dual-package hazard: a second resolved copy of @cosmicdrift/kumiko-types
|
|
94
|
+
// brands with ITS OWN symbol. Constructing the brand from the global registry
|
|
95
|
+
// here stands in for that copy — with a plain Symbol() the guard walks past
|
|
96
|
+
// this value and serializes the plaintext (#1438-adjacent).
|
|
97
|
+
test("catches a Secret branded by another copy of the package", () => {
|
|
98
|
+
const foreign = {
|
|
99
|
+
[Symbol.for("kumiko.secret")]: true as const,
|
|
100
|
+
reveal: () => "plaintext-from-another-copy",
|
|
101
|
+
};
|
|
102
|
+
expect(() => assertNoSecretLeak({ payload: foreign })).toThrow(/leaked.*payload/);
|
|
103
|
+
});
|
|
92
104
|
});
|
package/src/testing/index.ts
CHANGED
|
@@ -1,7 +1,16 @@
|
|
|
1
|
-
// Test
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
|
|
1
|
+
// Test assertions and domain test fixtures. Production code (dev-server, bin/)
|
|
2
|
+
// must import nothing from this subpath — the stack builders live in
|
|
3
|
+
// `@cosmicdrift/kumiko-framework/stack`.
|
|
4
|
+
|
|
5
|
+
// The four cache/injection resets stay in their own modules (they close over
|
|
6
|
+
// module-private state) and are only re-exported here — they are out of /crypto
|
|
7
|
+
// and /db as of #1631. A production call to resetPiiSubjectKmsForTests() silently
|
|
8
|
+
// switches the PII layer off, and subject-annotated fields are written in
|
|
9
|
+
// plaintext from then on: no error, no log.
|
|
10
|
+
export { resetBlindIndexKeyForTests } from "../crypto/blind-index";
|
|
11
|
+
export { resetEventPiiCatalogForTests } from "../crypto/event-pii";
|
|
12
|
+
export { resetPiiSubjectKmsForTests } from "../crypto/pii-field-encryption";
|
|
13
|
+
export { resetEntityFieldEncryptionCacheForTests } from "../db/entity-field-encryption";
|
|
5
14
|
|
|
6
15
|
export { rolesOf } from "./access-assertions";
|
|
7
16
|
export { expectError, expectSuccess } from "./assertions";
|
package/src/time/tz-context.ts
CHANGED
|
@@ -10,9 +10,10 @@
|
|
|
10
10
|
// Feature code should no longer call `new Date()` — the lint rule for that
|
|
11
11
|
// lands in a later iteration, once all existing usages are migrated.
|
|
12
12
|
//
|
|
13
|
-
// `tenant` + `user` are the TZ defaults for the current request
|
|
14
|
-
//
|
|
15
|
-
//
|
|
13
|
+
// `tenant` + `user` are the TZ defaults for the current request — resolved
|
|
14
|
+
// by buildHandlerContext (tenant:config:timezone, then SessionUser.timezone,
|
|
15
|
+
// falling back to "UTC" at each step) and passed in via TzContextOptions.
|
|
16
|
+
// This factory itself stays a pure default-filler.
|
|
16
17
|
//
|
|
17
18
|
// The pure type contracts (TzContext, TzContextOptions, LocatedTimestampJson)
|
|
18
19
|
// live in @cosmicdrift/kumiko-types/tz-context — only the factories are here.
|