@cosmicdrift/kumiko-framework 0.322.0 → 0.324.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 +32 -0
- package/src/crypto/pii-field-encryption.ts +16 -1
- package/src/db/event-store-executor-write.ts +15 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
- package/src/engine/__tests__/entity-handlers.test.ts +62 -1
- 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/entity-handlers.ts +40 -7
- package/src/engine/factories.ts +75 -2
- package/src/engine/instruction-fields.ts +11 -0
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +14 -0
- package/src/errors/reasons.ts +6 -0
- package/src/jobs/__tests__/job-backoff.integration.test.ts +7 -11
- package/src/jobs/job-runner.ts +27 -0
- package/src/migrations/__tests__/pending-rebuilds.integration.test.ts +1 -0
- package/src/pipeline/__tests__/instruction-field-gate.integration.test.ts +213 -0
- package/src/pipeline/index.ts +1 -0
- package/src/pipeline/irreversible-operation-gate.ts +54 -12
- package/src/stack/__tests__/drain-jobs.integration.test.ts +159 -0
- package/src/stack/drain-jobs.ts +129 -0
- package/src/stack/test-stack.ts +19 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-framework",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.324.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.324.0",
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.324.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.324.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,36 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.324.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Writes on readAsInstruction fields now require agent.risk \"high\" on the directly-dispatched handler",
|
|
6
|
+
"migration": "New field flag `readAsInstruction: true` on `text`, `longText`, and `jsonb`\nfields (`@cosmicdrift/kumiko-types`) marks a field whose value a later run\nreads as an instruction — a prompt, rule, or template. Any create/update\nwhose payload writes such a field must resolve `agent.risk: \"high\"` on the\ndirectly-dispatched entry handler, or the executor gate denies it with\n`access_denied` / `instruction_field_write_requires_high_risk` before any\nDB write. This is opt-in per field, but touches every write path once a\nfield carries the flag — consumer checklist:\n\n1. Mark fields that a later run reads as an instruction (prompts, rules,\n templates) with `readAsInstruction: true`.\n2. Update your own handlers that write such a field via\n `executor.create`/`executor.update`, or delegate into one via\n `ctx.write`/`writeAs`, to `agent: { risk: \"high\" }` — the directly\n called handler must carry it, delegation does not inherit it.\n3. Standard entity-convention create/update handlers on an entity with such\n a field now default to \"high\" automatically; declaring a lower\n `agent.risk` on one is a define-time error, same as the delete floor\n from #3345.\n4. An update whose payload doesn't include the flagged field stays allowed\n unchanged — the gate checks payload presence after `preSave`, not\n whether the value actually changed.\n5. Not gated: `delete`/`restore` (they don't write the field; the existing\n irreversible-delete floor still applies), and your own domain events\n applied via `ctx.appendEvent` whose projection writes a flagged field —\n route such writes through the executor primitives (`create`/`update`)\n to get the gate.\n6. On `create`, a flagged field with a `default` counts as written even if\n the payload omits it — `applyDefaults` runs before the gate. A custom\n mid-risk create handler whose payload leaves the field out is denied\n just the same, and a standard create with `excludeFields` on such a\n field still resolves to \"high\"."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.324.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "TestStack.drainJobs() waits deterministically for jobs and their follow-up consumers",
|
|
12
|
+
"detail": "Migration note: replace `waitFor` polling on a job's side effect with `await\nstack.drainJobs()`; it rejects with the job's name and error on final\nfailure. New `JobRunner.countPendingJobs()` — hand-written `JobRunner`\nstubs (test doubles) must add it."
|
|
13
|
+
},
|
|
14
|
+
{
|
|
15
|
+
"version": "0.323.0",
|
|
16
|
+
"type": "improvement",
|
|
17
|
+
"title": "auth-routes: optional invite.infoHandler mounts POST /auth/invite-info",
|
|
18
|
+
"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.",
|
|
19
|
+
"migration": "No action needed: infoHandler is optional and existing invite configs without it are unaffected — the route simply isn't mounted."
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"version": "0.323.0",
|
|
23
|
+
"type": "fix",
|
|
24
|
+
"title": "kms.eraseKey is now gated by the agent-risk floor for irreversible operations",
|
|
25
|
+
"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."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"version": "0.323.0",
|
|
29
|
+
"type": "breaking",
|
|
30
|
+
"title": "createTextField/createLongTextField require an explicit personal-data stance",
|
|
31
|
+
"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.",
|
|
32
|
+
"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."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.322.0",
|
|
4
36
|
"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 {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { checkWriteFieldOwnership } from "../engine/field-access";
|
|
2
|
+
import { instructionFieldNames } from "../engine/instruction-fields";
|
|
2
3
|
import { userCanCreateFieldRow, userCanWriteFieldRow } from "../engine/ownership";
|
|
3
4
|
import { SYSTEM_ROLE, SYSTEM_USER_ID } from "../engine/system-user";
|
|
4
5
|
import type { EntityId, SessionUser } from "../engine/types";
|
|
@@ -19,6 +20,7 @@ import {
|
|
|
19
20
|
getStreamVersion,
|
|
20
21
|
} from "../event-store";
|
|
21
22
|
import {
|
|
23
|
+
assertInstructionFieldWriteAllowed,
|
|
22
24
|
assertIrreversibleOperationAllowed,
|
|
23
25
|
isIrreversibleEntityVerb,
|
|
24
26
|
} from "../pipeline/irreversible-operation-gate";
|
|
@@ -122,6 +124,7 @@ export function createWriteVerbs(
|
|
|
122
124
|
assertStreamWritable,
|
|
123
125
|
loadExpectSnapshot,
|
|
124
126
|
} = ctx;
|
|
127
|
+
const entityInstructionFieldNames = instructionFieldNames(entity);
|
|
125
128
|
|
|
126
129
|
return {
|
|
127
130
|
async create(payload, user, db, options) {
|
|
@@ -152,6 +155,12 @@ export function createWriteVerbs(
|
|
|
152
155
|
|
|
153
156
|
// After preSave so derived fields count, before the event append so nothing persists.
|
|
154
157
|
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(data), entity);
|
|
158
|
+
assertInstructionFieldWriteAllowed(
|
|
159
|
+
entityName,
|
|
160
|
+
"create",
|
|
161
|
+
Object.keys(data),
|
|
162
|
+
entityInstructionFieldNames,
|
|
163
|
+
);
|
|
155
164
|
|
|
156
165
|
// H.2 — entity-level write-ownership on create. No oldRow exists, so
|
|
157
166
|
// only the new row is checked. No Straddle concern for creates.
|
|
@@ -344,6 +353,12 @@ export function createWriteVerbs(
|
|
|
344
353
|
|
|
345
354
|
// After preSave so derived fields count, before the event append so nothing persists.
|
|
346
355
|
assertPersonalDataWrite(db, tableNameOf(table), Object.keys(changes), entity);
|
|
356
|
+
assertInstructionFieldWriteAllowed(
|
|
357
|
+
entityName,
|
|
358
|
+
"update",
|
|
359
|
+
Object.keys(changes),
|
|
360
|
+
entityInstructionFieldNames,
|
|
361
|
+
);
|
|
347
362
|
|
|
348
363
|
// H.2 — entity-level write-ownership on update. Load old row (already
|
|
349
364
|
// done above), build post-change row via shallow merge. Straddle-safe
|
|
@@ -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", () => {
|
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
type EntityCrudRegistrar,
|
|
14
14
|
registerEntityCrud,
|
|
15
15
|
} from "../entity-handlers";
|
|
16
|
-
import { createEntity, createTextField } from "../factories";
|
|
16
|
+
import { createEntity, createLongTextField, createTextField } from "../factories";
|
|
17
17
|
// Barrel import, not "../entity-handlers": covers that entityListSchema is
|
|
18
18
|
// actually re-exported through engine/index.ts.
|
|
19
19
|
import { entityListSchema, resolveAgentExposure } from "../index";
|
|
@@ -37,6 +37,31 @@ const noteEntitySoftDelete = createEntity({
|
|
|
37
37
|
softDelete: true,
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
+
const promptEntity = createEntity({
|
|
41
|
+
table: "prompts",
|
|
42
|
+
fields: {
|
|
43
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
44
|
+
body: createLongTextField({
|
|
45
|
+
personal: false,
|
|
46
|
+
reason: "test_fixture",
|
|
47
|
+
readAsInstruction: true,
|
|
48
|
+
}),
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const promptEntityDefaultedBody = createEntity({
|
|
53
|
+
table: "prompts_defaulted",
|
|
54
|
+
fields: {
|
|
55
|
+
label: createTextField({ required: true, personal: false, reason: "test_fixture" }),
|
|
56
|
+
body: createLongTextField({
|
|
57
|
+
personal: false,
|
|
58
|
+
reason: "test_fixture",
|
|
59
|
+
readAsInstruction: true,
|
|
60
|
+
default: "system default prompt",
|
|
61
|
+
}),
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
|
|
40
65
|
const adminAccess = { access: { roles: ["Admin"] } } as const;
|
|
41
66
|
|
|
42
67
|
describe("defineEntityWriteHandler", () => {
|
|
@@ -138,6 +163,42 @@ describe("defineEntityWriteHandler", () => {
|
|
|
138
163
|
expect(def.agent).toBeUndefined();
|
|
139
164
|
expect(resolveAgentExposure(def, "write").risk).toBe("mid");
|
|
140
165
|
});
|
|
166
|
+
|
|
167
|
+
test("standard create on an entity with a readAsInstruction field defaults agent.risk to high", () => {
|
|
168
|
+
const def = defineEntityCreateHandler("prompt", promptEntity, adminAccess);
|
|
169
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("high");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test("standard update on an entity with a readAsInstruction field defaults agent.risk to high", () => {
|
|
173
|
+
const def = defineEntityUpdateHandler("prompt", promptEntity, adminAccess);
|
|
174
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("high");
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("declaring mid on a readAsInstruction-writing standard handler throws", () => {
|
|
178
|
+
expect(() =>
|
|
179
|
+
defineEntityUpdateHandler("prompt", promptEntity, {
|
|
180
|
+
access: { roles: ["Admin"] },
|
|
181
|
+
agent: { risk: "mid" },
|
|
182
|
+
}),
|
|
183
|
+
).toThrow(/agent\.risk must be "high"/);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test("update with excludeFields excluding all readAsInstruction fields is not forced to high", () => {
|
|
187
|
+
const def = defineEntityUpdateHandler("prompt", promptEntity, {
|
|
188
|
+
access: { roles: ["Admin"] },
|
|
189
|
+
excludeFields: ["body"],
|
|
190
|
+
});
|
|
191
|
+
expect(def.agent).toBeUndefined();
|
|
192
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("mid");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("create excluding a readAsInstruction field that has a default is still forced to high", () => {
|
|
196
|
+
const def = defineEntityCreateHandler("prompt", promptEntityDefaultedBody, {
|
|
197
|
+
access: { roles: ["Admin"] },
|
|
198
|
+
excludeFields: ["body"],
|
|
199
|
+
});
|
|
200
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("high");
|
|
201
|
+
});
|
|
141
202
|
});
|
|
142
203
|
|
|
143
204
|
describe("defineEntityQueryHandler", () => {
|
|
@@ -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
|
}
|