@cosmicdrift/kumiko-framework 0.322.0 → 0.323.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/api/auth-routes.ts +43 -1
- package/src/changes.json +20 -0
- package/src/crypto/pii-field-encryption.ts +16 -1
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
- package/src/engine/__tests__/factories-long-text.test.ts +5 -3
- package/src/engine/__tests__/factories-personal.test.ts +99 -13
- package/src/engine/boot-validator/pii-retention.ts +0 -8
- package/src/engine/factories.ts +75 -2
- package/src/pipeline/irreversible-operation-gate.ts +7 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.323.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>",
|
|
@@ -202,8 +202,8 @@
|
|
|
202
202
|
"./package.json": "./package.json"
|
|
203
203
|
},
|
|
204
204
|
"dependencies": {
|
|
205
|
-
"@cosmicdrift/kumiko-http": "0.
|
|
206
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
205
|
+
"@cosmicdrift/kumiko-http": "0.323.0",
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.323.0",
|
|
207
207
|
"bullmq": "^5.76.7",
|
|
208
208
|
"bun-types": "^1.3.13",
|
|
209
209
|
"hono": "^4.13.1",
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
"zod": "^4.4.3"
|
|
220
220
|
},
|
|
221
221
|
"devDependencies": {
|
|
222
|
-
"@cosmicdrift/kumiko-dispatcher-live": "0.
|
|
222
|
+
"@cosmicdrift/kumiko-dispatcher-live": "0.323.0",
|
|
223
223
|
"bun-types": "^1.3.13",
|
|
224
224
|
"pino-pretty": "^13.1.3"
|
|
225
225
|
},
|
package/src/api/auth-routes.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
|
|
|
2
2
|
import type { Context } from "hono";
|
|
3
3
|
import { Hono } from "hono";
|
|
4
4
|
import { deleteCookie, setCookie } from "hono/cookie";
|
|
5
|
+
import type { ContentfulStatusCode } from "hono/utils/http-status";
|
|
5
6
|
import type Redis from "ioredis";
|
|
6
7
|
import * as z from "zod";
|
|
7
8
|
import { buildSessionRoles } from "../engine/membership-roles";
|
|
@@ -13,7 +14,7 @@ import {
|
|
|
13
14
|
SYSTEM_TENANT_ID,
|
|
14
15
|
type TenantId,
|
|
15
16
|
} from "../engine/types";
|
|
16
|
-
import { NotFoundError } from "../errors";
|
|
17
|
+
import { NotFoundError, toKumikoError, toWriteErrorInfo } from "../errors";
|
|
17
18
|
import type { Dispatcher } from "../pipeline/dispatcher";
|
|
18
19
|
import { assertUnreachable } from "../utils";
|
|
19
20
|
import { parseRoles } from "../utils/serialization";
|
|
@@ -184,6 +185,10 @@ const InviteSignupCompleteBody = z.object({
|
|
|
184
185
|
password: z.string().min(8).max(200),
|
|
185
186
|
});
|
|
186
187
|
|
|
188
|
+
const InviteInfoBody = z.object({
|
|
189
|
+
token: z.string().min(1),
|
|
190
|
+
});
|
|
191
|
+
|
|
187
192
|
// Shape guard for "handler not registered" — the only legitimate reason to
|
|
188
193
|
// fall back to a single-tenant reply on /auth/tenants or /auth/switch-tenant.
|
|
189
194
|
// Every other error (DB down, revoker throws, access denied, …) has to
|
|
@@ -503,6 +508,10 @@ export type InviteConfig = {
|
|
|
503
508
|
readonly acceptHandler: string;
|
|
504
509
|
readonly acceptWithLoginHandler: string;
|
|
505
510
|
readonly signupCompleteHandler: string;
|
|
511
|
+
// Optional: qualified query handler name for the anonymous invite-info
|
|
512
|
+
// lookup (email + hasAccount for a pending invite, token not consumed).
|
|
513
|
+
// When unset, POST /auth/invite-info is not mounted.
|
|
514
|
+
readonly infoHandler?: string;
|
|
506
515
|
};
|
|
507
516
|
|
|
508
517
|
// Magic-Link Self-Signup. Anders als reset/verify NICHT HMAC-signed —
|
|
@@ -1315,6 +1324,39 @@ export function createAuthRoutes(
|
|
|
1315
1324
|
...landingPath,
|
|
1316
1325
|
});
|
|
1317
1326
|
});
|
|
1327
|
+
|
|
1328
|
+
// Anonymous, read-only invite lookup for the acceptance page: pre-fill
|
|
1329
|
+
// the invited email and pick Branch 2 (existing account) vs Branch 3
|
|
1330
|
+
// (new account) before the user submits. Token is never consumed here.
|
|
1331
|
+
if (inv.infoHandler) {
|
|
1332
|
+
const infoHandler = inv.infoHandler;
|
|
1333
|
+
api.post(Routes.authInviteInfo, async (c) => {
|
|
1334
|
+
const raw = await c.req.json().catch(() => null);
|
|
1335
|
+
const parsed = InviteInfoBody.safeParse(raw);
|
|
1336
|
+
if (!parsed.success) {
|
|
1337
|
+
return c.json({ isSuccess: false, error: "invalid_body" }, 400);
|
|
1338
|
+
}
|
|
1339
|
+
try {
|
|
1340
|
+
// @cast-boundary engine-payload — generic dispatcher.query result
|
|
1341
|
+
const data = (await dispatcher.query(
|
|
1342
|
+
infoHandler,
|
|
1343
|
+
parsed.data,
|
|
1344
|
+
createAnonymousUser(SYSTEM_TENANT_ID),
|
|
1345
|
+
)) as { email: string; hasAccount: boolean };
|
|
1346
|
+
return c.json({ isSuccess: true, ...data });
|
|
1347
|
+
} catch (e) {
|
|
1348
|
+
const err = toKumikoError(e);
|
|
1349
|
+
// Same body shape as the write routes' `{ isSuccess: false, error:
|
|
1350
|
+
// result.error }` (WriteErrorInfo) — this is a query, not a write,
|
|
1351
|
+
// so there's no WriteResult to read `.error` off; toWriteErrorInfo
|
|
1352
|
+
// builds the equivalent shape directly from the caught error.
|
|
1353
|
+
return c.json(
|
|
1354
|
+
{ isSuccess: false, error: toWriteErrorInfo(err) },
|
|
1355
|
+
err.httpStatus as ContentfulStatusCode, // @cast-boundary engine-payload
|
|
1356
|
+
);
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1359
|
+
}
|
|
1318
1360
|
}
|
|
1319
1361
|
|
|
1320
1362
|
// POST /auth/logout — revokes the current session. Requires a valid JWT so
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,24 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.323.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "auth-routes: optional invite.infoHandler mounts POST /auth/invite-info",
|
|
6
|
+
"detail": "InviteConfig gained an optional infoHandler (qualified query-handler name). When set, createAuthRoutes mounts POST /auth/invite-info, dispatching the query anonymously and returning {isSuccess:true, email, hasAccount} or the query's serialized error.",
|
|
7
|
+
"migration": "No action needed: infoHandler is optional and existing invite configs without it are unaffected — the route simply isn't mounted."
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"version": "0.323.0",
|
|
11
|
+
"type": "fix",
|
|
12
|
+
"title": "kms.eraseKey is now gated by the agent-risk floor for irreversible operations",
|
|
13
|
+
"migration": "`configurePiiSubjectKms(adapter)` now stores a wrapper whose `eraseKey`\nruns `assertIrreversibleOperationAllowed(\"kms.eraseKey\")` before\ndelegating — the same agent-risk floor `executor.forget()`/`delete()`\nalready enforce. A directly-dispatched write handler that calls\n`configuredPiiSubjectKms()!.eraseKey(...)` must resolve `agent.risk:\n\"high\"`, or the call is denied with `access_denied` /\n`irreversible_operation_requires_high_risk`.\n\nCron-triggered jobs and pipelines started by a `risk: \"high\"` handler are\nunaffected — they run outside any entry handler or already resolve\n\"high\". `crypto-shredding:write:forget-subject`, the manual\n`run-forget-cleanup` operator trigger, and framework-internal cleanup\nhooks already satisfy this."
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"version": "0.323.0",
|
|
17
|
+
"type": "breaking",
|
|
18
|
+
"title": "createTextField/createLongTextField require an explicit personal-data stance",
|
|
19
|
+
"detail": "Both factories' `overrides` parameter is no longer optional and must\ninclude a `personal` stance:\n`{ personal: \"self\", find: \"exact\" | \"fuzzy\" | \"none\" | \"secret\" }`\n(longText: `find: \"none\" | \"secret\"`), `{ personal: \"tenant\", find: … }`,\n`{ personal: { of: \"<ownerField>\" }, find: … }`, `{ personal: \"ref\" }`, or\n`{ personal: false, reason: \"<why this is not personal data>\" }`. A\nmissing/malformed shape throws at the call site (`createTextField(...)` /\n`createLongTextField(...)` in the message, with every valid option\nlisted) instead of silently resolving to an unannotated field — this\nalso removes the #2918 boot-time deprecation warning, since the shape it\nwarned about can no longer be constructed.",
|
|
20
|
+
"migration": "Two call forms now throw: `createTextField()` / `createLongTextField()`\nwith no argument at all, and an options object with no `personal` key,\ne.g. `createTextField({ required: true })`. Add a stance to every call:\n\n createTextField({ personal: \"self\", find: \"exact\" })\n createTextField({ personal: \"tenant\", find: \"none\" })\n createTextField({ personal: { of: \"authorId\" }, find: \"none\" })\n createTextField({ personal: \"ref\" })\n createTextField({ personal: false, reason: \"is_business_data\" })\n\n`personal: false` additionally requires a non-empty `reason` string.\n`find` is mandatory whenever `personal` names a subject (`\"self\"` /\n`\"tenant\"` / `{ of }`) — not for `\"ref\"` or `false`. The #2918 boot-time\ndeprecation warning for this shape is gone; there's nothing left for it\nto warn about.\n\nTo find remaining call sites (the #2919 codemod mode below is the\nmigration tool): `kumiko-guards guards\n--guard=\"Text-Field Personal-Stance Guard\"` flags every\nstatically-decidable call missing a stance. `bun\nnode_modules/@cosmicdrift/kumiko-framework/src/scripts/codemod/pii-personal-migration.ts\n<targetDir> --report-stance` classifies unannotated text/longText fields\nby name heuristic (direct/user-owned/user-reference/near-miss/\nunclassified) to help pick the right stance — it does not add one\nautomatically, since a wrong guess would be worse than the throw. This\ncodemod is not wired into `kumiko upgrade --apply`; run it by hand. It\nalso only transforms fields still carrying the OLD flag-based API\n(`pii`/`userOwned`/`tenantOwned`/`subjectRef`/`allowPlaintext`) — a field\nwith no annotation at all was never in its mapping table and needs a\nstance added by hand either way."
|
|
21
|
+
},
|
|
2
22
|
{
|
|
3
23
|
"version": "0.322.0",
|
|
4
24
|
"type": "breaking",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { createCipheriv, createDecipheriv, randomBytes } from "node:crypto";
|
|
14
14
|
import type { EntityDefinition } from "../engine/types/fields";
|
|
15
15
|
import type { TenantId } from "../engine/types/identifiers";
|
|
16
|
+
import { assertIrreversibleOperationAllowed } from "../pipeline/irreversible-operation-gate";
|
|
16
17
|
import {
|
|
17
18
|
isLocalKeyKmsAdapter,
|
|
18
19
|
KeyAlreadyExistsError,
|
|
@@ -231,6 +232,20 @@ export async function decryptPiiFieldValues(
|
|
|
231
232
|
// (phase E); until then no production deployment can satisfy it.
|
|
232
233
|
let injectedKms: LocalKeyKmsAdapter | undefined;
|
|
233
234
|
|
|
235
|
+
// Explicit delegation: adapters are class instances with prototype methods (no spread/Object.create).
|
|
236
|
+
function gateIrreversibleKmsErase(adapter: LocalKeyKmsAdapter): LocalKeyKmsAdapter {
|
|
237
|
+
return {
|
|
238
|
+
capabilities: adapter.capabilities,
|
|
239
|
+
createKey: (subject, ctx) => adapter.createKey(subject, ctx),
|
|
240
|
+
getKey: (subject, ctx) => adapter.getKey(subject, ctx),
|
|
241
|
+
health: () => adapter.health(),
|
|
242
|
+
eraseKey: async (subject, ctx) => {
|
|
243
|
+
assertIrreversibleOperationAllowed("kms.eraseKey");
|
|
244
|
+
await adapter.eraseKey(subject, ctx);
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
|
|
234
249
|
export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
|
|
235
250
|
if (adapter !== undefined && !isLocalKeyKmsAdapter(adapter)) {
|
|
236
251
|
throw new Error(
|
|
@@ -238,7 +253,7 @@ export function configurePiiSubjectKms(adapter: KmsAdapter | undefined): void {
|
|
|
238
253
|
"(Vault transit) support lands with the BYOK adapter.",
|
|
239
254
|
);
|
|
240
255
|
}
|
|
241
|
-
injectedKms = adapter;
|
|
256
|
+
injectedKms = adapter && gateIrreversibleKmsErase(adapter);
|
|
242
257
|
}
|
|
243
258
|
|
|
244
259
|
export function configuredPiiSubjectKms(): LocalKeyKmsAdapter | undefined {
|
|
@@ -650,128 +650,6 @@ describe("validateBoot — PII annotations", () => {
|
|
|
650
650
|
);
|
|
651
651
|
expect(matchingWarn).toBeDefined();
|
|
652
652
|
});
|
|
653
|
-
|
|
654
|
-
// --- #2918: deprecation warning for text fields without any stance ---
|
|
655
|
-
|
|
656
|
-
const NO_STANCE = "declares no personal stance";
|
|
657
|
-
|
|
658
|
-
test("text field without a personal stance warns, naming feature, entity and field", () => {
|
|
659
|
-
const feature = defineFeature("test", (r) => {
|
|
660
|
-
r.entity(
|
|
661
|
-
"invoice",
|
|
662
|
-
createEntity({
|
|
663
|
-
fields: {
|
|
664
|
-
externalRef: { ...unannotatedText },
|
|
665
|
-
},
|
|
666
|
-
}),
|
|
667
|
-
);
|
|
668
|
-
});
|
|
669
|
-
validateBoot([feature]);
|
|
670
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
671
|
-
String(args[0]).includes(NO_STANCE),
|
|
672
|
-
);
|
|
673
|
-
expect(matchingWarn).toBeDefined();
|
|
674
|
-
const message = String((matchingWarn as unknown[])[0]);
|
|
675
|
-
expect(message).toContain("[Feature test]");
|
|
676
|
-
expect(message).toContain('Field "externalRef" on entity "invoice"');
|
|
677
|
-
expect(message).toContain('{ reason: "..." }');
|
|
678
|
-
});
|
|
679
|
-
|
|
680
|
-
test("longText field without a personal stance warns", () => {
|
|
681
|
-
const feature = defineFeature("test", (r) => {
|
|
682
|
-
r.entity(
|
|
683
|
-
"invoice",
|
|
684
|
-
createEntity({
|
|
685
|
-
fields: {
|
|
686
|
-
remarks: { ...unannotatedLongText },
|
|
687
|
-
},
|
|
688
|
-
}),
|
|
689
|
-
);
|
|
690
|
-
});
|
|
691
|
-
validateBoot([feature]);
|
|
692
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
693
|
-
String(args[0]).includes(NO_STANCE),
|
|
694
|
-
);
|
|
695
|
-
expect(matchingWarn).toBeDefined();
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
test("personal: false with a reason silences the stance warning", () => {
|
|
699
|
-
const feature = defineFeature("test", (r) => {
|
|
700
|
-
r.entity(
|
|
701
|
-
"invoice",
|
|
702
|
-
createEntity({
|
|
703
|
-
fields: {
|
|
704
|
-
externalRef: createTextField({
|
|
705
|
-
personal: false,
|
|
706
|
-
reason: "is_business_data",
|
|
707
|
-
}),
|
|
708
|
-
},
|
|
709
|
-
}),
|
|
710
|
-
);
|
|
711
|
-
});
|
|
712
|
-
validateBoot([feature]);
|
|
713
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
714
|
-
String(args[0]).includes(NO_STANCE),
|
|
715
|
-
);
|
|
716
|
-
expect(matchingWarn).toBeUndefined();
|
|
717
|
-
});
|
|
718
|
-
|
|
719
|
-
test("personal: self silences the stance warning", () => {
|
|
720
|
-
const feature = defineFeature("test", (r) => {
|
|
721
|
-
r.entity(
|
|
722
|
-
"invoice",
|
|
723
|
-
createEntity({
|
|
724
|
-
fields: {
|
|
725
|
-
externalRef: createTextField({
|
|
726
|
-
personal: "self",
|
|
727
|
-
find: "none",
|
|
728
|
-
}),
|
|
729
|
-
},
|
|
730
|
-
}),
|
|
731
|
-
);
|
|
732
|
-
});
|
|
733
|
-
validateBoot([feature]);
|
|
734
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
735
|
-
String(args[0]).includes(NO_STANCE),
|
|
736
|
-
);
|
|
737
|
-
expect(matchingWarn).toBeUndefined();
|
|
738
|
-
});
|
|
739
|
-
|
|
740
|
-
test("personal: ref silences the stance warning", () => {
|
|
741
|
-
const feature = defineFeature("test", (r) => {
|
|
742
|
-
r.entity(
|
|
743
|
-
"invoice",
|
|
744
|
-
createEntity({
|
|
745
|
-
fields: {
|
|
746
|
-
ownerRef: createTextField({ personal: "ref" }),
|
|
747
|
-
},
|
|
748
|
-
}),
|
|
749
|
-
);
|
|
750
|
-
});
|
|
751
|
-
validateBoot([feature]);
|
|
752
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
753
|
-
String(args[0]).includes(NO_STANCE),
|
|
754
|
-
);
|
|
755
|
-
expect(matchingWarn).toBeUndefined();
|
|
756
|
-
});
|
|
757
|
-
|
|
758
|
-
test("number field without a personal stance does not warn", () => {
|
|
759
|
-
const feature = defineFeature("test", (r) => {
|
|
760
|
-
r.entity(
|
|
761
|
-
"invoice",
|
|
762
|
-
createEntity({
|
|
763
|
-
fields: {
|
|
764
|
-
total: { type: "number", required: false },
|
|
765
|
-
},
|
|
766
|
-
}),
|
|
767
|
-
);
|
|
768
|
-
});
|
|
769
|
-
validateBoot([feature]);
|
|
770
|
-
const matchingWarn = warnSpy.mock.calls.find((args: unknown[]) =>
|
|
771
|
-
String(args[0]).includes(NO_STANCE),
|
|
772
|
-
);
|
|
773
|
-
expect(matchingWarn).toBeUndefined();
|
|
774
|
-
});
|
|
775
653
|
});
|
|
776
654
|
|
|
777
655
|
describe("validateBoot — retention", () => {
|
|
@@ -17,9 +17,11 @@ import type { LongTextFieldDef } from "../types";
|
|
|
17
17
|
// =============================================================================
|
|
18
18
|
|
|
19
19
|
describe("createLongTextField — runtime shape", () => {
|
|
20
|
-
test("
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
test("kumiko-framework#2921: no argument throws — a longText field must declare a personal stance", () => {
|
|
21
|
+
expect(() =>
|
|
22
|
+
// @ts-expect-error overrides (with a personal stance) is now required
|
|
23
|
+
createLongTextField(),
|
|
24
|
+
).toThrow(/must declare an explicit personal-data stance/);
|
|
23
25
|
});
|
|
24
26
|
|
|
25
27
|
test("required: true is preserved as literal in the return type", () => {
|
|
@@ -3,21 +3,84 @@
|
|
|
3
3
|
// ../factories, exercised through the public factory functions.
|
|
4
4
|
|
|
5
5
|
import { describe, expect, test } from "bun:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
createLongTextField,
|
|
8
|
+
createNumberField,
|
|
9
|
+
createTextField,
|
|
10
|
+
createTimestampField,
|
|
11
|
+
} from "../factories";
|
|
7
12
|
|
|
8
13
|
describe("createTextField — personal/find resolution", () => {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
required
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
expect(
|
|
14
|
+
// kumiko-framework#2921 — a text field can no longer be constructed
|
|
15
|
+
// without declaring whose data it holds, neither at the type level nor
|
|
16
|
+
// at runtime (fail-closed for untyped JS callers).
|
|
17
|
+
test("no argument at all throws", () => {
|
|
18
|
+
expect(() =>
|
|
19
|
+
// @ts-expect-error overrides (with a personal stance) is now required
|
|
20
|
+
createTextField(),
|
|
21
|
+
).toThrow(/must declare an explicit personal-data stance/);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
test("options without `personal` throws", () => {
|
|
25
|
+
expect(() =>
|
|
26
|
+
// @ts-expect-error `personal` is required on the options object
|
|
27
|
+
createTextField({ required: true }),
|
|
28
|
+
).toThrow(/createTextField\(\.\.\.\) must declare an explicit personal-data stance/);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("an untyped JS caller passing null throws the same guidance instead of a raw TypeError", () => {
|
|
32
|
+
// Simulated untyped-boundary call — a real JS consumer has no compiler
|
|
33
|
+
// to reject this, so the runtime gate must catch it. Cast only here.
|
|
34
|
+
const call = () => createTextField(null as unknown as Parameters<typeof createTextField>[0]);
|
|
35
|
+
expect(call).toThrow(/must declare an explicit personal-data stance/);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("an untyped JS caller passing personal: null throws the guidance message, not a raw TypeError", () => {
|
|
39
|
+
// Same untyped-boundary simulation, but `personal` itself is null
|
|
40
|
+
// instead of the whole options object — the object-branch subject
|
|
41
|
+
// check must not dereference `.of` on it.
|
|
42
|
+
const call = () =>
|
|
43
|
+
createTextField({ personal: null } as unknown as Parameters<typeof createTextField>[0]);
|
|
44
|
+
expect(call).toThrow(/must declare an explicit personal-data stance/);
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('personal: "self" without find throws', () => {
|
|
48
|
+
expect(() =>
|
|
49
|
+
// @ts-expect-error `find` is required alongside a named subject
|
|
50
|
+
createTextField({ personal: "self" }),
|
|
51
|
+
).toThrow(/must declare an explicit personal-data stance/);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("personal: false without a reason throws", () => {
|
|
55
|
+
expect(() =>
|
|
56
|
+
// @ts-expect-error `reason` is required alongside `personal: false`
|
|
57
|
+
createTextField({ personal: false }),
|
|
58
|
+
).toThrow(/must declare an explicit personal-data stance/);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("personal: false with a reason works", () => {
|
|
62
|
+
const f = createTextField({ personal: false, reason: "is_business_data" });
|
|
63
|
+
expect(f.allowPlaintext).toBe("is_business_data");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("an invalid personal value throws and lists every valid stance", () => {
|
|
67
|
+
const call = () =>
|
|
68
|
+
createTextField({
|
|
69
|
+
// @ts-expect-error "yes" is not a valid personal stance
|
|
70
|
+
personal: "yes",
|
|
71
|
+
});
|
|
72
|
+
expect(call).toThrow(/must declare an explicit personal-data stance/);
|
|
73
|
+
try {
|
|
74
|
+
call();
|
|
75
|
+
throw new Error("expected createTextField to throw");
|
|
76
|
+
} catch (error) {
|
|
77
|
+
const message = String(error);
|
|
78
|
+
expect(message).toContain('personal: "self"');
|
|
79
|
+
expect(message).toContain('personal: "tenant"');
|
|
80
|
+
expect(message).toContain('personal: { of: "<ownerField>" }');
|
|
81
|
+
expect(message).toContain('personal: "ref"');
|
|
82
|
+
expect(message).toContain("personal: false, reason:");
|
|
83
|
+
}
|
|
21
84
|
});
|
|
22
85
|
|
|
23
86
|
test('personal: "self", find: "exact" → pii + lookupable', () => {
|
|
@@ -108,6 +171,16 @@ describe("createLongTextField — restricted find (PersonalAnnotationsLongText)"
|
|
|
108
171
|
expect(f.lookupable).toBeUndefined();
|
|
109
172
|
expect(f).not.toHaveProperty("searchable");
|
|
110
173
|
});
|
|
174
|
+
|
|
175
|
+
test('find: "exact" is a createTextField-only value — createLongTextField rejects it', () => {
|
|
176
|
+
expect(() =>
|
|
177
|
+
createLongTextField({
|
|
178
|
+
personal: "self",
|
|
179
|
+
// @ts-expect-error "exact"/"fuzzy" don't exist on LongTextFindability
|
|
180
|
+
find: "exact",
|
|
181
|
+
}),
|
|
182
|
+
).toThrow(/must declare an explicit personal-data stance/);
|
|
183
|
+
});
|
|
111
184
|
});
|
|
112
185
|
|
|
113
186
|
describe("createTimestampField — personal without find (PersonalAnnotationsNoFind)", () => {
|
|
@@ -127,4 +200,17 @@ describe("createTimestampField — personal without find (PersonalAnnotationsNoF
|
|
|
127
200
|
const f = createTimestampField({ personal: false, reason: "server-generated, not user data" });
|
|
128
201
|
expect(f.allowPlaintext).toBe("server-generated, not user data");
|
|
129
202
|
});
|
|
203
|
+
|
|
204
|
+
// kumiko-framework#2921 only tightens text/longText — NoFind factories
|
|
205
|
+
// keep `personal` optional and stay callable without an argument.
|
|
206
|
+
test("createTimestampField() without any argument still works", () => {
|
|
207
|
+
const f = createTimestampField();
|
|
208
|
+
expect(f).toEqual({ type: "timestamp", required: false });
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
test("createNumberField() without any argument still works", () => {
|
|
212
|
+
const f = createNumberField();
|
|
213
|
+
expect(f.type).toBe("number");
|
|
214
|
+
expect(f).not.toHaveProperty("personal");
|
|
215
|
+
});
|
|
130
216
|
});
|
|
@@ -213,14 +213,6 @@ export function validatePiiAndRetention(feature: FeatureDefinition): void {
|
|
|
213
213
|
console.warn(
|
|
214
214
|
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" has a user-reference-typical name but no personal annotation — a foreign key into \`user\` carries Art.17 obligations even with no annotated content on the entity. Mark it { personal: "ref" } AND register r.useExtension(EXT_USER_DATA, "${entityName}", …) for Art.17 coverage — this warning is the only boot-time check for that, registering the hook is not enforced. Or { personal: { of: "${fieldName}" } } on the field it owns. If business data, set { personal: false, reason: "..." } to silence.`,
|
|
215
215
|
);
|
|
216
|
-
} else if (!annot.subjectRef && (field.type === "text" || field.type === "longText")) {
|
|
217
|
-
// Deprecation step towards #2810, where a text field without a
|
|
218
|
-
// stance becomes a boot error. Only text/longText: the other field
|
|
219
|
-
// types keep `personal` optional.
|
|
220
|
-
// biome-ignore lint/suspicious/noConsole: boot-time dev hint, no logger available yet
|
|
221
|
-
console.warn(
|
|
222
|
-
`[kumiko:boot] [Feature ${feature.name}] Field "${fieldName}" on entity "${entityName}" (type "${field.type}") declares no personal stance. Text fields will require one — this warning becomes a boot error in a future release (#2810). Declare { personal: "self" | "tenant" | "ref" | { of: "<ownerField>" } | false }; "false" additionally needs { reason: "..." } stating why the value is not personal data.`,
|
|
223
|
-
);
|
|
224
216
|
}
|
|
225
217
|
}
|
|
226
218
|
}
|
package/src/engine/factories.ts
CHANGED
|
@@ -88,6 +88,77 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
|
88
88
|
} as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
type TextFieldStanceFactory = "createTextField" | "createLongTextField";
|
|
92
|
+
|
|
93
|
+
// `find` is mandatory once a subject is named (types require it — see
|
|
94
|
+
// PersonalAnnotations / PersonalAnnotationsLongText), but the valid values
|
|
95
|
+
// differ per factory: longText has no lookup/search machinery.
|
|
96
|
+
const TEXT_FIELD_STANCE_FIND_VALUES: Record<TextFieldStanceFactory, readonly Findability[]> = {
|
|
97
|
+
createTextField: ["exact", "fuzzy", "none", "secret"],
|
|
98
|
+
createLongTextField: ["none", "secret"],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function textFieldStanceHint(factoryName: TextFieldStanceFactory): string {
|
|
102
|
+
const find = TEXT_FIELD_STANCE_FIND_VALUES[factoryName].map((f) => `"${f}"`).join(" | ");
|
|
103
|
+
return (
|
|
104
|
+
`{ personal: "self", find: ${find} }, ` +
|
|
105
|
+
`{ personal: "tenant", find: ${find} }, ` +
|
|
106
|
+
`{ personal: { of: "<ownerField>" }, find: ${find} }, ` +
|
|
107
|
+
`{ personal: "ref" }, or ` +
|
|
108
|
+
`{ personal: false, reason: "<why this is not personal data>" }`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function missingTextFieldStanceError(factoryName: TextFieldStanceFactory): Error {
|
|
113
|
+
return new Error(
|
|
114
|
+
`${factoryName}(...) must declare an explicit personal-data stance — a text field must state whose data it holds and (unless it isn't personal data) how findable it needs to stay (kumiko-framework#2921). Pass one of: ${textFieldStanceHint(factoryName)}. Run \`kumiko-guards guards --guard="Text-Field Personal-Stance Guard"\` to find remaining call sites.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
119
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fail-closed stance gate (kumiko-framework#2921) for the two text-shaped
|
|
123
|
+
// factories — runs before expandPersonalAnnotations so an untyped JS caller
|
|
124
|
+
// (no compiler to stop it) can't slip an unannotated text field past the
|
|
125
|
+
// type layer either. `overrides` is `unknown` here on purpose: the typed
|
|
126
|
+
// signature above already rejects a missing/malformed shape at compile
|
|
127
|
+
// time, this re-checks the same shape at the runtime boundary.
|
|
128
|
+
function assertTextFieldStance(factoryName: TextFieldStanceFactory, overrides: unknown): void {
|
|
129
|
+
if (overrides === null || typeof overrides !== "object") {
|
|
130
|
+
throw missingTextFieldStanceError(factoryName);
|
|
131
|
+
}
|
|
132
|
+
// @cast-boundary runtime-guard — re-validated field by field below before
|
|
133
|
+
// any value is trusted.
|
|
134
|
+
const { personal, find, reason } = overrides as PersonalOverridesInput;
|
|
135
|
+
if (personal === undefined || personal === null) {
|
|
136
|
+
throw missingTextFieldStanceError(factoryName);
|
|
137
|
+
}
|
|
138
|
+
if (personal === "ref") {
|
|
139
|
+
// skip: a user reference carries no content of its own, so no `find` applies
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (personal === false) {
|
|
143
|
+
if (!isNonEmptyString(reason)) {
|
|
144
|
+
throw missingTextFieldStanceError(factoryName);
|
|
145
|
+
}
|
|
146
|
+
// skip: plaintext by declaration, `find` only applies to a named subject
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const hasValidSubject =
|
|
150
|
+
personal === "self" ||
|
|
151
|
+
personal === "tenant" ||
|
|
152
|
+
(typeof personal === "object" && isNonEmptyString(personal.of));
|
|
153
|
+
if (!hasValidSubject) {
|
|
154
|
+
throw missingTextFieldStanceError(factoryName);
|
|
155
|
+
}
|
|
156
|
+
const validFinds = TEXT_FIELD_STANCE_FIND_VALUES[factoryName];
|
|
157
|
+
if (typeof find !== "string" || !(validFinds as readonly string[]).includes(find)) {
|
|
158
|
+
throw missingTextFieldStanceError(factoryName);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
91
162
|
// Generic über `R extends true | false` (statt `boolean`) damit
|
|
92
163
|
// `createTextField({ required: true })` literal `required: true` im
|
|
93
164
|
// Return-Type behält. `boolean` würde widenen — EntityTable<E>'s
|
|
@@ -96,9 +167,10 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
|
96
167
|
// degradieren. Default `R = false` matcht den runtime-default. Pattern
|
|
97
168
|
// in jeder required-bearing factory unten.
|
|
98
169
|
export function createTextField<R extends true | false = false>(
|
|
99
|
-
overrides
|
|
170
|
+
overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
100
171
|
PersonalAnnotations & { required?: R },
|
|
101
172
|
): TextFieldDef & { required: R } {
|
|
173
|
+
assertTextFieldStance("createTextField", overrides);
|
|
102
174
|
return {
|
|
103
175
|
type: "text",
|
|
104
176
|
maxLength: 200,
|
|
@@ -131,9 +203,10 @@ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
|
|
|
131
203
|
}
|
|
132
204
|
|
|
133
205
|
export function createLongTextField<R extends true | false = false>(
|
|
134
|
-
overrides
|
|
206
|
+
overrides: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
135
207
|
PersonalAnnotationsLongText & { required?: R },
|
|
136
208
|
): LongTextFieldDef & { required: R } {
|
|
209
|
+
assertTextFieldStance("createLongTextField", overrides);
|
|
137
210
|
return {
|
|
138
211
|
type: "longText",
|
|
139
212
|
required: false,
|
|
@@ -11,9 +11,13 @@ export function isIrreversibleEntityVerb(
|
|
|
11
11
|
return verb === "forget" || !entity.softDelete;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
// without
|
|
14
|
+
// The gate reads the directly dispatched entry handler; a write dispatched inside a deferred
|
|
15
|
+
// context (afterCommit hook, job, consumer) gets its own entryHandler and is gated too. Direct
|
|
16
|
+
// executor/KMS calls without one stay ungated in exactly two cases: deleting derived data
|
|
17
|
+
// rebuildable from a reversible source (document-ingest-foundation's forget-extract-with-file-ref
|
|
18
|
+
// consumer, re-created on fileRef.restored), and pipelines started by a risk "high" handler or a
|
|
19
|
+
// cron scan (forget cleanup, retention, tenant destroy). A deferred context can't return
|
|
20
|
+
// irreversibleOperationRequiresHighRisk to the agent synchronously, so the gate sits at the primitives.
|
|
17
21
|
export function assertIrreversibleOperationAllowed(operation: string): void {
|
|
18
22
|
const entryHandler = requestContext.get()?.entryHandler;
|
|
19
23
|
// skip: no entry handler (see above) or the entry handler already resolves "high".
|