@cosmicdrift/kumiko-framework 0.255.0 → 0.255.2
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/crypto/__tests__/subject-id-schema.test.ts +60 -0
- package/src/crypto/index.ts +1 -0
- package/src/crypto/kms-adapter.ts +25 -1
- package/src/crypto/subject-resolver.ts +2 -2
- package/src/engine/boot-validator/__tests__/record-owned.test.ts +4 -5
- package/src/engine/boot-validator/record-owned.ts +3 -4
- package/src/event-store/__tests__/backfill-pii.integration.test.ts +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.255.
|
|
3
|
+
"version": "0.255.2",
|
|
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,7 +198,7 @@
|
|
|
198
198
|
"./package.json": "./package.json"
|
|
199
199
|
},
|
|
200
200
|
"dependencies": {
|
|
201
|
-
"@cosmicdrift/kumiko-types": "0.255.
|
|
201
|
+
"@cosmicdrift/kumiko-types": "0.255.2",
|
|
202
202
|
"bullmq": "^5.76.7",
|
|
203
203
|
"bun-types": "^1.3.13",
|
|
204
204
|
"hono": "^4.13.1",
|
|
@@ -214,7 +214,7 @@
|
|
|
214
214
|
"zod": "^4.4.3"
|
|
215
215
|
},
|
|
216
216
|
"devDependencies": {
|
|
217
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.255.
|
|
217
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.255.2",
|
|
218
218
|
"bun-types": "^1.3.13",
|
|
219
219
|
"pino-pretty": "^13.1.3"
|
|
220
220
|
},
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// subjectIdSchema is `satisfies z.ZodType<SubjectId>` at the definition site
|
|
2
|
+
// (kms-adapter.ts) — a future 4th SubjectId variant or a renamed field fails
|
|
3
|
+
// to compile there. This file pins the runtime side of that binding: exactly
|
|
4
|
+
// the three SubjectId shapes parse, everything else is refused.
|
|
5
|
+
|
|
6
|
+
import { describe, expect, test } from "bun:test";
|
|
7
|
+
import { subjectIdSchema } from "../kms-adapter";
|
|
8
|
+
|
|
9
|
+
const UUID_A = "6b2f4a0e-1c9d-4f3a-9d2e-00000000000a";
|
|
10
|
+
|
|
11
|
+
describe("subjectIdSchema", () => {
|
|
12
|
+
test("accepts a user subject", () => {
|
|
13
|
+
expect(subjectIdSchema.safeParse({ kind: "user", userId: UUID_A }).success).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
test("accepts a tenant subject", () => {
|
|
17
|
+
expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: UUID_A }).success).toBe(true);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("accepts a record subject", () => {
|
|
21
|
+
expect(
|
|
22
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: UUID_A }).success,
|
|
23
|
+
).toBe(true);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
test("mints tenantId as a branded TenantId, not a plain string type", () => {
|
|
27
|
+
const result = subjectIdSchema.parse({ kind: "tenant", tenantId: UUID_A });
|
|
28
|
+
expect(result).toEqual({ kind: "tenant", tenantId: UUID_A });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("rejects a non-UUID userId", () => {
|
|
32
|
+
expect(subjectIdSchema.safeParse({ kind: "user", userId: "not-a-uuid" }).success).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
test("rejects a non-UUID tenantId", () => {
|
|
36
|
+
expect(subjectIdSchema.safeParse({ kind: "tenant", tenantId: "not-a-uuid" }).success).toBe(
|
|
37
|
+
false,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("rejects a non-UUID record id", () => {
|
|
42
|
+
expect(
|
|
43
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "mail-account", id: "not-a-uuid" })
|
|
44
|
+
.success,
|
|
45
|
+
).toBe(false);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects a record entity that violates RECORD_ENTITY_PATTERN", () => {
|
|
49
|
+
expect(
|
|
50
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "_internal", id: UUID_A }).success,
|
|
51
|
+
).toBe(false);
|
|
52
|
+
expect(
|
|
53
|
+
subjectIdSchema.safeParse({ kind: "record", entity: "v2.digest", id: UUID_A }).success,
|
|
54
|
+
).toBe(false);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects an unknown subject kind", () => {
|
|
58
|
+
expect(subjectIdSchema.safeParse({ kind: "bogus", id: UUID_A }).success).toBe(false);
|
|
59
|
+
});
|
|
60
|
+
});
|
package/src/crypto/index.ts
CHANGED
|
@@ -1,10 +1,34 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
RECORD_ENTITY_PATTERN,
|
|
3
|
+
type SubjectId,
|
|
4
|
+
subjectIdToKey,
|
|
5
|
+
} from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
import type { TenantId } from "../engine/types/identifiers";
|
|
2
8
|
|
|
3
9
|
export * from "@cosmicdrift/kumiko-types/kms-adapter-types";
|
|
4
10
|
|
|
5
11
|
// The KMS error classes live here and not in kumiko-types (#1629): callers
|
|
6
12
|
// branch on them with `instanceof`, which needs a single copy of the class.
|
|
7
13
|
|
|
14
|
+
// `satisfies z.ZodType<SubjectId>` binds this to the TS type at compile time
|
|
15
|
+
// (fw#2801) — mint (subjectKeyForRecord) and shred (forget-subject) can no
|
|
16
|
+
// longer drift apart on which subjects are valid, the way #2809 happened.
|
|
17
|
+
export const subjectIdSchema = z.discriminatedUnion("kind", [
|
|
18
|
+
z.object({ kind: z.literal("user"), userId: z.uuid() }),
|
|
19
|
+
z.object({
|
|
20
|
+
kind: z.literal("tenant"),
|
|
21
|
+
// z.uuid() only proves string shape; TenantId is a nominal brand on top
|
|
22
|
+
// of that, minted here right after validation (@cast-boundary).
|
|
23
|
+
tenantId: z.uuid().transform((value) => value as TenantId),
|
|
24
|
+
}),
|
|
25
|
+
z.object({
|
|
26
|
+
kind: z.literal("record"),
|
|
27
|
+
entity: z.string().regex(RECORD_ENTITY_PATTERN),
|
|
28
|
+
id: z.uuid(),
|
|
29
|
+
}),
|
|
30
|
+
]) satisfies z.ZodType<SubjectId>;
|
|
31
|
+
|
|
8
32
|
export class KeyErasedError extends Error {
|
|
9
33
|
constructor(public readonly subject: SubjectId) {
|
|
10
34
|
super(`Subject key erased: ${subjectIdToKey(subject)}`);
|
|
@@ -171,8 +171,8 @@ export function resolveEventSubject(
|
|
|
171
171
|
);
|
|
172
172
|
}
|
|
173
173
|
// aggregate_type is a free-form text column (events-schema.ts) — reject
|
|
174
|
-
// here rather than mint a record key that subjectIdSchema (
|
|
175
|
-
//
|
|
174
|
+
// here rather than mint a record key that subjectIdSchema (kms-adapter.ts)
|
|
175
|
+
// would later refuse to shred.
|
|
176
176
|
if (!RECORD_ENTITY_PATTERN.test(envelope.aggregateType)) {
|
|
177
177
|
throw new SubjectResolutionError(
|
|
178
178
|
fieldName,
|
|
@@ -1,9 +1,8 @@
|
|
|
1
1
|
// fw#2809 — a recordOwned field is encrypted under `record:<entity>:<id>`
|
|
2
|
-
// (kms-adapter-types.ts) and forgetSubject's subjectIdSchema
|
|
3
|
-
// id as a UUID
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
// before it ships.
|
|
2
|
+
// (kms-adapter-types.ts) and forgetSubject's subjectIdSchema (crypto/kms-adapter.ts)
|
|
3
|
+
// validates that id as a UUID. An idType: "serial" entity would encrypt such
|
|
4
|
+
// a field but could never satisfy a forget-subject request for it — the boot
|
|
5
|
+
// guard catches that combination before it ships.
|
|
7
6
|
|
|
8
7
|
import { describe, expect, test } from "bun:test";
|
|
9
8
|
import { defineFeature } from "../../define-feature";
|
|
@@ -2,10 +2,9 @@ import type { FeatureDefinition } from "../types";
|
|
|
2
2
|
import type { ResolvedPiiFlags } from "../types/fields";
|
|
3
3
|
|
|
4
4
|
// A recordOwned field is encrypted under `record:<entity>:<id>` (kms-adapter-types.ts)
|
|
5
|
-
// and forgetSubject's subjectIdSchema requires that id
|
|
6
|
-
//
|
|
7
|
-
//
|
|
8
|
-
// forget-subject request for it.
|
|
5
|
+
// and forgetSubject's subjectIdSchema (crypto/kms-adapter.ts) requires that id
|
|
6
|
+
// to be a UUID — an entity with idType: "serial" would encrypt the field but
|
|
7
|
+
// could never satisfy a forget-subject request for it.
|
|
9
8
|
export function validateRecordOwnedSubjects(feature: FeatureDefinition): void {
|
|
10
9
|
for (const [entityName, entity] of Object.entries(feature.entities ?? {})) {
|
|
11
10
|
if (entity.idType !== "serial") continue;
|
|
@@ -64,7 +64,7 @@ const mailerFeature = defineFeature("mailer", (r) => {
|
|
|
64
64
|
).name;
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
// fw#2801
|
|
67
|
+
// fw#2801: custom events declaring a tenant/self subject — no entity,
|
|
68
68
|
// pure catalog events — proving the catalog branch resolves through the
|
|
69
69
|
// same resolveEventSubject the live append path uses.
|
|
70
70
|
let TENANT_NOTE_EVENT_TYPE: string;
|