@cosmicdrift/kumiko-framework 0.321.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/api/index.ts +5 -1
- package/src/api/request-context.ts +23 -1
- package/src/changes.json +32 -0
- package/src/crypto/pii-field-encryption.ts +16 -1
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- 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 +20 -1
- package/src/engine/factories.ts +75 -2
- package/src/engine/index.ts +3 -0
- package/src/engine/registry-state.ts +2 -1
- package/src/engine/types/index.ts +2 -0
- package/src/errors/i18n/de.yaml +14 -0
- package/src/errors/i18n/en.yaml +13 -0
- package/src/errors/reasons.ts +5 -0
- package/src/i18n/__tests__/i18n.test.ts +35 -0
- package/src/i18n/__tests__/mail-registry.test.ts +19 -0
- package/src/i18n/index.ts +11 -4
- package/src/i18n/mail-registry.ts +13 -7
- package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
- package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
- package/src/pipeline/dispatch-shared.ts +33 -4
- package/src/pipeline/index.ts +4 -0
- package/src/pipeline/irreversible-operation-gate.ts +37 -0
- package/src/ui-types/__tests__/plural.test.ts +108 -0
- package/src/ui-types/index.ts +6 -0
- package/src/ui-types/plural.ts +69 -0
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/api/index.ts
CHANGED
|
@@ -75,7 +75,11 @@ export type {
|
|
|
75
75
|
export { isSafeLandingPath } from "./post-auth-landing";
|
|
76
76
|
export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
|
|
77
77
|
export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
|
|
78
|
-
export {
|
|
78
|
+
export {
|
|
79
|
+
type RequestContextData,
|
|
80
|
+
requestContext,
|
|
81
|
+
runAsDirectCallEntry,
|
|
82
|
+
} from "./request-context";
|
|
79
83
|
export {
|
|
80
84
|
buildRequestContextDataFromRequest,
|
|
81
85
|
requestIdMiddleware,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
import { isPersonalDataGated, type WriteOrigin } from "@cosmicdrift/kumiko-types/event-store-types";
|
|
3
|
+
import type { AgentRisk } from "@cosmicdrift/kumiko-types/handlers";
|
|
3
4
|
import { generateId } from "../utils";
|
|
4
5
|
|
|
5
6
|
// Request-scoped propagation. Populated by the HTTP middleware and by the
|
|
@@ -54,6 +55,10 @@ export type RequestContextData = {
|
|
|
54
55
|
// Only ever a gated origin. Read by job enqueue and event-store.append();
|
|
55
56
|
// dispatch roots never narrow from it, only jobs inherit explicitly.
|
|
56
57
|
readonly writeOrigin?: WriteOrigin;
|
|
58
|
+
// The handler directly invoked by the dispatcher (not a nested ctx.write/
|
|
59
|
+
// writeAs target) — nested dispatches inherit it unchanged, so the
|
|
60
|
+
// irreversible-operation gate always sees the outermost caller's risk.
|
|
61
|
+
readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
|
|
57
62
|
};
|
|
58
63
|
|
|
59
64
|
const storage = new AsyncLocalStorage<RequestContextData>();
|
|
@@ -77,7 +82,11 @@ export const requestContext = {
|
|
|
77
82
|
// when there is no surrounding request (job-runner, event-dispatcher) —
|
|
78
83
|
// requestId/correlationId are mandatory, `get()` may be undefined.
|
|
79
84
|
export function runWithOrigin<T>(
|
|
80
|
-
origin: {
|
|
85
|
+
origin: {
|
|
86
|
+
readonly feature?: string;
|
|
87
|
+
readonly handler?: string;
|
|
88
|
+
readonly entryHandler?: { readonly qn: string; readonly risk: AgentRisk };
|
|
89
|
+
},
|
|
81
90
|
fn: () => T,
|
|
82
91
|
): T {
|
|
83
92
|
const current = requestContext.get();
|
|
@@ -89,11 +98,24 @@ export function runWithOrigin<T>(
|
|
|
89
98
|
correlationId: current?.correlationId ?? requestId,
|
|
90
99
|
feature: origin.feature,
|
|
91
100
|
handler: origin.handler,
|
|
101
|
+
// The outermost dispatch wins — a nested ctx.write/writeAs must not
|
|
102
|
+
// overwrite the entry handler that the irreversible-operation gate reads.
|
|
103
|
+
entryHandler: current?.entryHandler ?? origin.entryHandler,
|
|
92
104
|
},
|
|
93
105
|
fn,
|
|
94
106
|
);
|
|
95
107
|
}
|
|
96
108
|
|
|
109
|
+
// Clears entryHandler so the next dispatch inside fn becomes the new entry —
|
|
110
|
+
// used by dispatchToolCall so the invoked tool handler, not the surrounding
|
|
111
|
+
// turn/approve handler, is what the irreversible-operation gate sees.
|
|
112
|
+
export function runAsDirectCallEntry<T>(fn: () => T): T {
|
|
113
|
+
const current = requestContext.get();
|
|
114
|
+
if (!current) return fn();
|
|
115
|
+
const { entryHandler: _entryHandler, ...rest } = current;
|
|
116
|
+
return requestContext.run(rest, fn);
|
|
117
|
+
}
|
|
118
|
+
|
|
97
119
|
// An ungated origin never mints a scope: seeds and boot writes keep their missing context.
|
|
98
120
|
export function runWithWriteOrigin<T>(origin: WriteOrigin, fn: () => T): T {
|
|
99
121
|
const current = requestContext.get();
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,36 @@
|
|
|
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
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"version": "0.322.0",
|
|
24
|
+
"type": "breaking",
|
|
25
|
+
"title": "Irreversible writes now require agent.risk \"high\" on the directly-dispatched handler",
|
|
26
|
+
"migration": "Any write handler reachable as the directly-dispatched entry point that\nperforms `executor.forget()` (any entity) or `executor.delete()` on an\nentity without `softDelete` must resolve `agent.risk: \"high\"`, or the\nexecutor gate now denies it with `access_denied` /\n`irreversible_operation_requires_high_risk` before any DB read.\n\nThree breaking changes ship together:\n1. Add `agent: { risk: \"high\" }` to your own write handler if it directly\n runs `forget` or a hard `delete` on a non-softDelete entity.\n2. Delegating via `ctx.write`/`ctx.writeAs` from a lower-risk handler into\n a high-risk forget/hard-delete handler no longer inherits the inner\n handler's risk — the outermost/entry handler's declared risk governs,\n so the delegating handler itself must be \"high\".\n3. Declaring `agent.risk` below \"high\" on a standard entity-convention\n delete handler for a non-softDelete entity is now a define-time/boot\n error instead of a silently-accepted setting.\n\nStandard entity-convention delete handlers on a non-softDelete entity now\ndefault to \"high\" automatically (no more implicit \"always allow\").\n\n`dispatchToolCall` treats the invoked tool itself as the entry handler,\nnot the surrounding turn/approve write handler that dispatched it — a\nmid-risk \"approve\" handler can safely invoke a high-risk tool directly,\nbut a mid-risk tool that itself delegates to a high-risk handler via\n`ctx.write`/`writeAs` is still denied per rule 2.\n\n`runAsDirectCallEntry` (framework `/api`) is the one sanctioned entry\nboundary: it clears the entry handler so the next dispatch counts as the\ndirectly called one. `dispatchToolCall` uses it because the human\nconfirmation happens in approve; do not use it to lift a handler past\nthe floor.\n\nRemaining gap: `afterCommit` hooks, jobs, and event consumers are not\ngated by this floor — they have no directly-dispatched entry handler to\nattribute risk to."
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
"version": "0.322.0",
|
|
30
|
+
"type": "improvement",
|
|
31
|
+
"title": "Translation values may now be CLDR plural-form objects, resolved per locale with Intl.PluralRules",
|
|
32
|
+
"migration": "A translation entry's value type widens from `string` to\n`string | PluralForms`, so an existing `TranslationEntry` locale value can\nnow also be an object with `one`/`few`/`many`/`other` (etc.) CLDR\ncategories, where `other` is required:\n\n```ts\n{ de: { one: \"{count} Status-Seite\", other: \"{count} Status-Seiten\" } }\n```\n\n`createI18n(...).t(key, locale, { count })` resolves the CLDR category for\nthe given locale via a per-locale-cached `Intl.PluralRules` and interpolates\n`{count}`; it falls back to `other` when `count` is missing/non-finite, the\nlocale tag is invalid, or `Intl.PluralRules` is unavailable (older Hermes).\n`mailT` and the renderer's `translateWithFallbacks` resolve plural values\nthe same way, through the shared `resolveTranslationValue` helper.\n\nExisting string-only translations keep working unchanged. Code that reads\nvalues back out of a bundle (`TranslationBundle`, `TranslationsByLocale`,\n`TranslationEntry`) and annotates them as `string` no longer typechecks:\nannotate as `TranslationValue` (from `@cosmicdrift/kumiko-framework/ui-types`)\nor flatten with `translationValueOtherText(value)`."
|
|
33
|
+
},
|
|
2
34
|
{
|
|
3
35
|
"version": "0.321.0",
|
|
4
36
|
"type": "improvement",
|
|
@@ -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 {
|
|
@@ -18,6 +18,10 @@ import {
|
|
|
18
18
|
VersionConflictError as EventStoreVersionConflict,
|
|
19
19
|
getStreamVersion,
|
|
20
20
|
} from "../event-store";
|
|
21
|
+
import {
|
|
22
|
+
assertIrreversibleOperationAllowed,
|
|
23
|
+
isIrreversibleEntityVerb,
|
|
24
|
+
} from "../pipeline/irreversible-operation-gate";
|
|
21
25
|
import { generateId } from "../utils";
|
|
22
26
|
import { applyEntityEvent } from "./apply-entity-event";
|
|
23
27
|
import { flattenCompoundTypes, rehydrateCompoundTypes } from "./compound-types";
|
|
@@ -586,6 +590,9 @@ export function createWriteVerbs(
|
|
|
586
590
|
},
|
|
587
591
|
|
|
588
592
|
async delete(payload, user, db) {
|
|
593
|
+
if (isIrreversibleEntityVerb("delete", entity)) {
|
|
594
|
+
assertIrreversibleOperationAllowed(`${entityName}.delete (entity has no softDelete)`);
|
|
595
|
+
}
|
|
589
596
|
const runner = tenantDbRunner(db);
|
|
590
597
|
const existing = await loadById(payload.id, db);
|
|
591
598
|
if (!existing) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -684,6 +691,7 @@ export function createWriteVerbs(
|
|
|
684
691
|
// the erasure replays on rebuild (created → forgotten → row gone). Loads
|
|
685
692
|
// without the isDeleted filter so trashed (soft-deleted) rows are erased too.
|
|
686
693
|
async forget(payload, user, db) {
|
|
694
|
+
assertIrreversibleOperationAllowed(`${entityName}.forget`);
|
|
687
695
|
const runner = tenantDbRunner(db);
|
|
688
696
|
const raw = await db.fetchOne<Record<string, unknown>>(table, { id: payload.id });
|
|
689
697
|
if (!raw) return writeFailure(new NotFoundError(entityName, payload.id));
|
|
@@ -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", () => {
|
|
@@ -110,6 +110,34 @@ describe("defineEntityWriteHandler", () => {
|
|
|
110
110
|
// @ts-expect-error access is required since fw#2855 — no implicit openToAll.
|
|
111
111
|
expect(() => defineEntityCreateHandler("note", noteEntity)).toThrow();
|
|
112
112
|
});
|
|
113
|
+
|
|
114
|
+
test("delete on an entity without softDelete defaults agent.risk to high", () => {
|
|
115
|
+
const def = defineEntityDeleteHandler("note", noteEntity, adminAccess);
|
|
116
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("high");
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
test("delete on an entity without softDelete preserves other agent hints while forcing risk high", () => {
|
|
120
|
+
const def = defineEntityDeleteHandler("note", noteEntity, {
|
|
121
|
+
access: { roles: ["Admin"] },
|
|
122
|
+
agent: { expose: false },
|
|
123
|
+
});
|
|
124
|
+
expect(def.agent).toEqual({ expose: false, risk: "high" });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("delete on an entity without softDelete throws when agent.risk is explicitly set below high", () => {
|
|
128
|
+
expect(() =>
|
|
129
|
+
defineEntityDeleteHandler("note", noteEntity, {
|
|
130
|
+
access: { roles: ["Admin"] },
|
|
131
|
+
agent: { risk: "mid" },
|
|
132
|
+
}),
|
|
133
|
+
).toThrow(/agent\.risk must be "high"/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
test("delete on a softDelete entity leaves agent hints untouched", () => {
|
|
137
|
+
const def = defineEntityDeleteHandler("note", noteEntitySoftDelete, adminAccess);
|
|
138
|
+
expect(def.agent).toBeUndefined();
|
|
139
|
+
expect(resolveAgentExposure(def, "write").risk).toBe("mid");
|
|
140
|
+
});
|
|
113
141
|
});
|
|
114
142
|
|
|
115
143
|
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
|
}
|