@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.
Files changed (32) hide show
  1. package/package.json +4 -4
  2. package/src/api/auth-routes.ts +43 -1
  3. package/src/api/index.ts +5 -1
  4. package/src/api/request-context.ts +23 -1
  5. package/src/changes.json +32 -0
  6. package/src/crypto/pii-field-encryption.ts +16 -1
  7. package/src/db/event-store-executor-write.ts +8 -0
  8. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
  9. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  10. package/src/engine/__tests__/factories-long-text.test.ts +5 -3
  11. package/src/engine/__tests__/factories-personal.test.ts +99 -13
  12. package/src/engine/boot-validator/pii-retention.ts +0 -8
  13. package/src/engine/entity-handlers.ts +20 -1
  14. package/src/engine/factories.ts +75 -2
  15. package/src/engine/index.ts +3 -0
  16. package/src/engine/registry-state.ts +2 -1
  17. package/src/engine/types/index.ts +2 -0
  18. package/src/errors/i18n/de.yaml +14 -0
  19. package/src/errors/i18n/en.yaml +13 -0
  20. package/src/errors/reasons.ts +5 -0
  21. package/src/i18n/__tests__/i18n.test.ts +35 -0
  22. package/src/i18n/__tests__/mail-registry.test.ts +19 -0
  23. package/src/i18n/index.ts +11 -4
  24. package/src/i18n/mail-registry.ts +13 -7
  25. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  26. package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
  27. package/src/pipeline/dispatch-shared.ts +33 -4
  28. package/src/pipeline/index.ts +4 -0
  29. package/src/pipeline/irreversible-operation-gate.ts +37 -0
  30. package/src/ui-types/__tests__/plural.test.ts +108 -0
  31. package/src/ui-types/index.ts +6 -0
  32. package/src/ui-types/plural.ts +69 -0
@@ -16,6 +16,7 @@ import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
16
16
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
17
17
  import type {
18
18
  AccessRule,
19
+ AgentHandlerHints,
19
20
  DeriveContext,
20
21
  EntityDefinition,
21
22
  EscapeHatchDeclaration,
@@ -213,6 +214,23 @@ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boole
213
214
  );
214
215
  }
215
216
 
217
+ // delete on an entity without softDelete hard-purges the row — the agent
218
+ // risk gate only protects it if this handler resolves to "high".
219
+ function resolveEntityWriteAgentHints(
220
+ name: string,
221
+ verb: (typeof WRITE_VERBS)[number],
222
+ entity: EntityDefinition,
223
+ agent: AgentHandlerHints | undefined,
224
+ ): AgentHandlerHints | undefined {
225
+ if (verb !== "delete" || entity.softDelete) return agent;
226
+ if (agent?.risk !== undefined && agent.risk !== "high") {
227
+ throw new Error(
228
+ `"${name}": delete on an entity without softDelete is irreversible — agent.risk must be "high" (got "${agent.risk}").`,
229
+ );
230
+ }
231
+ return { ...agent, risk: "high" };
232
+ }
233
+
216
234
  function assertExcludableFields(
217
235
  name: string,
218
236
  verb: (typeof WRITE_VERBS)[number],
@@ -253,6 +271,7 @@ export function defineEntityWriteHandler(
253
271
  `"${name}": restore is only valid for entities declared with softDelete: true.`,
254
272
  );
255
273
  }
274
+ const agentHints = resolveEntityWriteAgentHints(name, verb, entity, options.agent);
256
275
  const excludedFields = options.excludeFields ?? [];
257
276
  assertExcludableFields(name, verb, entity, excludedFields);
258
277
 
@@ -378,7 +397,7 @@ export function defineEntityWriteHandler(
378
397
  handler,
379
398
  access: options.access,
380
399
  ...(options.description !== undefined && { description: options.description }),
381
- ...(options.agent !== undefined && { agent: options.agent }),
400
+ ...(agentHints !== undefined && { agent: agentHints }),
382
401
  };
383
402
  }
384
403
 
@@ -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?: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
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?: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
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,
@@ -479,6 +479,7 @@ export type {
479
479
  PersonalAnnotationsNoFind,
480
480
  PersonalSubject,
481
481
  PlatformComponent,
482
+ PluralForms,
482
483
  PostDeleteHookFn,
483
484
  PostSaveHookFn,
484
485
  PreDeleteHookFn,
@@ -528,8 +529,10 @@ export type {
528
529
  TenantId,
529
530
  TextFieldDef,
530
531
  ToolbarAction,
532
+ TranslationEntry,
531
533
  TranslationKeys,
532
534
  TranslationsDef,
535
+ TranslationValue,
533
536
  TreeAction,
534
537
  TreeActionDef,
535
538
  TreeActionsHandle,
@@ -38,6 +38,7 @@ import type {
38
38
  SecretKeyDefinition,
39
39
  StoreTableDef,
40
40
  StreamHandlerDef,
41
+ TranslationValue,
41
42
  TreeActionDef,
42
43
  WorkspaceDefinition,
43
44
  WriteHandlerDef,
@@ -212,7 +213,7 @@ export type RegistryState = {
212
213
  extensionSelectorMap: Map<string, string>;
213
214
  allReferenceData: ReferenceDataDef[];
214
215
  allConfigSeeds: ConfigSeedDef[];
215
- mergedTranslations: Record<string, Record<string, string>>;
216
+ mergedTranslations: Record<string, Record<string, TranslationValue>>;
216
217
  metricMap: Map<string, FeatureMetricDef & { readonly featureName: string }>;
217
218
  secretKeyMap: Map<string, SecretKeyDefinition>;
218
219
  projectionMap: Map<string, ProjectionDefinition>;
@@ -42,6 +42,7 @@ export type {
42
42
  NotificationDefinition,
43
43
  NotificationRecipientFn,
44
44
  NotificationTemplateFn,
45
+ PluralForms,
45
46
  ReferenceDataDef,
46
47
  RegistrarExtensionDef,
47
48
  RegistrarExtensionHooks,
@@ -50,6 +51,7 @@ export type {
50
51
  TranslationEntry,
51
52
  TranslationKeys,
52
53
  TranslationsDef,
54
+ TranslationValue,
53
55
  UiExtensionDef,
54
56
  } from "@cosmicdrift/kumiko-types/config";
55
57
  export type {
@@ -159,6 +159,20 @@ public_intake_required:
159
159
  Wurzel-Handler `access: { roles: [..., "anonymous"], personalData: "public-intake" }`
160
160
  deklarieren.
161
161
 
162
+ irreversible_operation_requires_high_risk:
163
+ endUser: |
164
+ Diese Aktion ist nicht erlaubt.
165
+ Wende dich an einen Administrator wenn du glaubst, dass das ein Fehler ist.
166
+ developer: |
167
+ `AccessDeniedError`: der direkt aufgerufene Einstiegs-Handler führt ein
168
+ hartes Delete oder `forget` aus (`details.operation`), aber sein
169
+ aufgelöstes Agent-Risiko (`details.resolvedRisk`) liegt unter `"high"`.
170
+ `details.handler` nennt den Einstiegs-Handler — eine Delegation über
171
+ `ctx.write`/`writeAs` aus einem Handler mit niedrigerem Risiko erbt das
172
+ deklarierte Risiko des inneren Handlers nicht.
173
+
174
+ `agent: { risk: "high" }` am genannten Handler deklarieren.
175
+
162
176
  agent.tool_not_allowed:
163
177
  endUser: |
164
178
  Dieses Tool ist im aktuellen Modus nicht verfügbar.
@@ -153,6 +153,19 @@ public_intake_required:
153
153
  `access: { roles: [..., "anonymous"], personalData: "public-intake" }` on
154
154
  the root handler if the anonymous intake is intended.
155
155
 
156
+ irreversible_operation_requires_high_risk:
157
+ endUser: |
158
+ This action isn't allowed.
159
+ Please contact an administrator if you believe this is a mistake.
160
+ developer: |
161
+ `AccessDeniedError`: the directly-dispatched entry handler performs a hard
162
+ delete or `forget` (`details.operation`) but its resolved agent risk
163
+ (`details.resolvedRisk`) is below `"high"`. `details.handler` names the
164
+ entry handler — delegating the call via `ctx.write`/`writeAs` from a
165
+ lower-risk handler does not inherit an inner handler's declared risk.
166
+
167
+ Declare `agent: { risk: "high" }` on the named handler.
168
+
156
169
  agent.tool_not_allowed:
157
170
  endUser: |
158
171
  This tool is not available in the current mode.
@@ -63,6 +63,11 @@ export const FrameworkReasons = {
63
63
  // AccessDeniedError: a write under an anonymous root touched a personal-data field
64
64
  // without the root handler declaring access.personalData: "public-intake".
65
65
  publicIntakeRequired: "public_intake_required",
66
+
67
+ // AccessDeniedError: the directly-dispatched entry handler performs a hard
68
+ // delete/forget but its agent.risk resolves below "high" — delegation via
69
+ // ctx.write/writeAs from a lower-risk handler does not inherit the gate.
70
+ irreversibleOperationRequiresHighRisk: "irreversible_operation_requires_high_risk",
66
71
  } as const;
67
72
 
68
73
  export type FrameworkReason = (typeof FrameworkReasons)[keyof typeof FrameworkReasons];
@@ -76,3 +76,38 @@ describe("createI18n", () => {
76
76
  expect(keys).toContain("adminUsers:field.email");
77
77
  });
78
78
  });
79
+
80
+ describe("createI18n — plural forms", () => {
81
+ const notificationsFeature = defineFeature("notifications", (r) => {
82
+ r.translations({
83
+ keys: {
84
+ "notifications.unread": {
85
+ de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
86
+ en: { one: "{count} unread message", other: "{count} unread messages" },
87
+ },
88
+ },
89
+ });
90
+ });
91
+
92
+ test("resolves the CLDR category for the given locale and count", () => {
93
+ const registry = createRegistry([notificationsFeature]);
94
+ const i18n = createI18n(registry, { defaultLocale: "en" });
95
+
96
+ expect(i18n.t("notifications:notifications.unread", "en", { count: 1 })).toBe(
97
+ "1 unread message",
98
+ );
99
+ expect(i18n.t("notifications:notifications.unread", "en", { count: 5 })).toBe(
100
+ "5 unread messages",
101
+ );
102
+ expect(i18n.t("notifications:notifications.unread", "de", { count: 1 })).toBe(
103
+ "1 ungelesene Nachricht",
104
+ );
105
+ });
106
+
107
+ test("falls back to `other` when no params are given", () => {
108
+ const registry = createRegistry([notificationsFeature]);
109
+ const i18n = createI18n(registry, { defaultLocale: "en" });
110
+
111
+ expect(i18n.t("notifications:notifications.unread", "en")).toBe("{count} unread messages");
112
+ });
113
+ });
@@ -33,3 +33,22 @@ test("resolveMailLocale prefers exact, then root, then en", () => {
33
33
  expect(resolveMailLocale("de-AT")).toBe("de");
34
34
  expect(resolveMailLocale("fr-CA")).toBe("en");
35
35
  });
36
+
37
+ describe("mailT — plural forms", () => {
38
+ registerMailTranslations("en", {
39
+ "test.unread": { one: "{count} unread message", other: "{count} unread messages" },
40
+ });
41
+ registerMailTranslations("de", {
42
+ "test.unread": { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
43
+ });
44
+
45
+ test("resolves the CLDR category for the count", () => {
46
+ expect(mailT("en", "test.unread", { count: 1 })).toBe("1 unread message");
47
+ expect(mailT("en", "test.unread", { count: 5 })).toBe("5 unread messages");
48
+ expect(mailT("de", "test.unread", { count: 1 })).toBe("1 ungelesene Nachricht");
49
+ });
50
+
51
+ test("uses the fallback locale's own plural rules, not the requested locale's", () => {
52
+ expect(mailT("fr", "test.unread", { count: 1 })).toBe("1 unread message");
53
+ });
54
+ });
package/src/i18n/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { Registry, TranslationKeys } from "../engine/types";
2
+ import { resolveTranslationValue } from "../ui-types/plural";
2
3
 
3
4
  export {
4
5
  hasMailTranslations,
@@ -20,7 +21,7 @@ export type I18nOptions = {
20
21
  };
21
22
 
22
23
  export type I18n = {
23
- t(key: string, locale?: string): string;
24
+ t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string;
24
25
  getAllKeys(): string[];
25
26
  };
26
27
 
@@ -29,12 +30,18 @@ export function createI18n(registry: Registry, options: I18nOptions): I18n {
29
30
  const { defaultLocale } = options;
30
31
 
31
32
  return {
32
- t(key: string, locale?: string): string {
33
+ t(key: string, locale?: string, params?: Readonly<Record<string, unknown>>): string {
33
34
  const entry = translations[key];
34
35
  if (!entry) return key;
35
36
 
36
- const resolvedLocale = locale ?? defaultLocale;
37
- return entry[resolvedLocale] ?? entry[defaultLocale] ?? key;
37
+ const requestedLocale = locale ?? defaultLocale;
38
+ // Plural category selection needs the locale the value actually came
39
+ // from, not the requested one — falling back to defaultLocale must
40
+ // apply defaultLocale's plural rules to that defaultLocale text.
41
+ const hitLocale = entry[requestedLocale] !== undefined ? requestedLocale : defaultLocale;
42
+ const value = entry[hitLocale];
43
+ if (value === undefined) return key;
44
+ return resolveTranslationValue(value, hitLocale, params);
38
45
  },
39
46
 
40
47
  getAllKeys(): string[] {
@@ -1,8 +1,10 @@
1
- const tables = new Map<string, Readonly<Record<string, string>>>();
1
+ import { resolveTranslationValue, type TranslationValue } from "../ui-types/plural";
2
+
3
+ const tables = new Map<string, Readonly<Record<string, TranslationValue>>>();
2
4
 
3
5
  export function registerMailTranslations(
4
6
  locale: string,
5
- bundle: Readonly<Record<string, string>>,
7
+ bundle: Readonly<Record<string, TranslationValue>>,
6
8
  ): void {
7
9
  const prev = tables.get(locale) ?? {};
8
10
  tables.set(locale, { ...prev, ...bundle });
@@ -16,13 +18,17 @@ export function hasMailTranslations(locale: string): boolean {
16
18
  export function mailT(
17
19
  locale: string,
18
20
  key: string,
19
- params?: Readonly<Record<string, string>>,
21
+ params?: Readonly<Record<string, string | number>>,
20
22
  ): string {
21
23
  const root = locale.split("-")[0] ?? locale;
22
- const raw =
23
- tables.get(locale)?.[key] ?? tables.get(root)?.[key] ?? tables.get("en")?.[key] ?? key;
24
- if (params === undefined) return raw;
25
- return raw.replace(/\{(\w+)\}/g, (_, name: string) => params[name] ?? `{${name}}`);
24
+ // Plural category selection needs the locale the value actually came
25
+ // from, not the requested one — a `pl` bundle falling back to `en`
26
+ // must apply English plural rules to that `en` text.
27
+ for (const localeToTry of [locale, root, "en"]) {
28
+ const value = tables.get(localeToTry)?.[key];
29
+ if (value !== undefined) return resolveTranslationValue(value, localeToTry, params);
30
+ }
31
+ return key;
26
32
  }
27
33
 
28
34
  /** Locale key mailT would actually use (exact → root → en). Use for appUrl
@@ -102,7 +102,9 @@ const archFeature = defineFeature("archtest", (r) => {
102
102
  "item:delete",
103
103
  z.object({ id: z.uuid() }),
104
104
  async (event, ctx) => executor.delete({ id: event.payload.id }, event.user, ctx.db),
105
- { access: { roles: ["Admin"] } },
105
+ // arch-item has no softDelete — a hard delete needs agent.risk: "high"
106
+ // to pass the irreversible-operation gate.
107
+ { access: { roles: ["Admin"] }, agent: { risk: "high" } },
106
108
  );
107
109
 
108
110
  r.queryHandler(
@@ -0,0 +1,249 @@
1
+ // Agent-risk floor for irreversible operations (delete on a non-softDelete
2
+ // entity, forget on any entity): the directly-dispatched entry handler must
3
+ // resolve agent.risk "high", or the executor gate refuses before any DB read.
4
+ // Real HTTP calls + setupTestStack — never createTestDispatcher.
5
+
6
+ import { afterAll, afterEach, beforeAll, describe, expect, test } from "bun:test";
7
+ import * as z from "zod";
8
+ import { createEventStoreExecutor } from "../../db/event-store-executor";
9
+ import { buildEntityTable } from "../../db/table-builder";
10
+ import { createEntity, createTextField, defineFeature } from "../../engine";
11
+ import type { Registry } from "../../engine/types";
12
+ import { resolveAgentExposure } from "../../engine/types";
13
+ import { resetEventStore, setupTestStack, type TestStack, TestUsers } from "../../stack";
14
+ import { waitFor } from "../../testing";
15
+
16
+ const admin = TestUsers.admin;
17
+ const NONEXISTENT_ID = "00000000-0000-4000-8000-00000000dead";
18
+
19
+ const hardEntity = createEntity({
20
+ table: "gate_hard_items",
21
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
22
+ });
23
+ const hardTable = buildEntityTable("hard", hardEntity);
24
+ const hardExecutor = createEventStoreExecutor(hardTable, hardEntity, { entityName: "hard" });
25
+
26
+ const softEntity = createEntity({
27
+ table: "gate_soft_items",
28
+ fields: { label: createTextField({ personal: false, reason: "test_fixture", required: true }) },
29
+ softDelete: true,
30
+ });
31
+ const softTable = buildEntityTable("soft", softEntity);
32
+ const softExecutor = createEventStoreExecutor(softTable, softEntity, { entityName: "soft" });
33
+
34
+ const jobForgetRuns: Array<{ readonly id: string }> = [];
35
+
36
+ const gateFeature = defineFeature("gatetest", (r) => {
37
+ r.entity("hard", hardEntity);
38
+ r.entity("soft", softEntity);
39
+
40
+ r.crud("hard", hardEntity, {
41
+ write: { access: { roles: ["Admin"] } },
42
+ read: { access: { roles: ["Admin"] } },
43
+ });
44
+ r.crud("soft", softEntity, {
45
+ write: { access: { roles: ["Admin"] } },
46
+ read: { access: { roles: ["Admin"] } },
47
+ });
48
+
49
+ r.writeHandler(
50
+ "forget-hard-mid",
51
+ z.object({ id: z.uuid() }),
52
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
53
+ { access: { roles: ["Admin"] } },
54
+ );
55
+
56
+ r.writeHandler(
57
+ "forget-hard-high",
58
+ z.object({ id: z.uuid() }),
59
+ async (event, ctx) => hardExecutor.forget({ id: event.payload.id }, event.user, ctx.db),
60
+ { access: { roles: ["Admin"] }, agent: { risk: "high" } },
61
+ );
62
+
63
+ r.writeHandler(
64
+ "delete-hard-mid",
65
+ z.object({ id: z.uuid() }),
66
+ async (event, ctx) => hardExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
67
+ { access: { roles: ["Admin"] } },
68
+ );
69
+
70
+ r.writeHandler(
71
+ "delete-soft-mid",
72
+ z.object({ id: z.uuid() }),
73
+ async (event, ctx) => softExecutor.delete({ id: event.payload.id }, event.user, ctx.db),
74
+ { access: { roles: ["Admin"] } },
75
+ );
76
+
77
+ r.writeHandler(
78
+ "delegate-write-to-forget-high",
79
+ z.object({ id: z.uuid() }),
80
+ async (event, ctx) => ctx.write("gatetest:write:forget-hard-high", { id: event.payload.id }),
81
+ { access: { roles: ["Admin"] } },
82
+ );
83
+
84
+ r.writeHandler(
85
+ "delegate-write-as-to-forget-high",
86
+ z.object({ id: z.uuid() }),
87
+ async (event, ctx) =>
88
+ ctx.writeAs(event.user, "gatetest:write:forget-hard-high", { id: event.payload.id }),
89
+ { access: { roles: ["Admin"] } },
90
+ );
91
+
92
+ r.job("forgetHardJob", { trigger: { manual: true }, retries: 0 }, async (payload, ctx) => {
93
+ const id = payload["id"] as string; // @cast-boundary dynamic-key
94
+ await hardExecutor.forget({ id }, ctx.systemUser, ctx.db);
95
+ jobForgetRuns.push({ id });
96
+ });
97
+ });
98
+
99
+ function errorReason(details: unknown): string | undefined {
100
+ if (!details || typeof details !== "object" || !("reason" in details)) return undefined;
101
+ return typeof details.reason === "string" ? details.reason : undefined;
102
+ }
103
+
104
+ let stack: TestStack;
105
+
106
+ beforeAll(async () => {
107
+ stack = await setupTestStack({ features: [gateFeature], jobs: { consumerLane: "worker" } });
108
+ });
109
+
110
+ afterAll(async () => {
111
+ await stack.cleanup();
112
+ });
113
+
114
+ afterEach(async () => {
115
+ await resetEventStore(stack, ["gate_hard_items", "gate_soft_items"]);
116
+ });
117
+
118
+ async function createHard(label: string): Promise<string> {
119
+ const { id } = await stack.http.writeOk<{ id: string }>(
120
+ "gatetest:write:hard:create",
121
+ { label },
122
+ admin,
123
+ );
124
+ return id;
125
+ }
126
+
127
+ async function createSoft(label: string): Promise<string> {
128
+ const { id } = await stack.http.writeOk<{ id: string }>(
129
+ "gatetest:write:soft:create",
130
+ { label },
131
+ admin,
132
+ );
133
+ return id;
134
+ }
135
+
136
+ // The entity-convention "detail" query returns `data: null` for a missing
137
+ // row instead of an HTTP error — assert via queryOk + null, not queryErr.
138
+ async function hardRowExists(id: string): Promise<boolean> {
139
+ const row = await stack.http.queryOk("gatetest:query:hard:detail", { id }, admin);
140
+ return row !== null;
141
+ }
142
+
143
+ describe("executor.forget — always irreversible", () => {
144
+ test("mid-risk handler is denied, row survives", async () => {
145
+ const id = await createHard("survives-mid-forget");
146
+ const err = await stack.http.writeErr("gatetest:write:forget-hard-mid", { id }, admin);
147
+ expect(err.httpStatus).toBe(403);
148
+ expect(err.code).toBe("access_denied");
149
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
150
+ expect(err.message).toContain("gatetest:write:forget-hard-mid");
151
+ expect(await hardRowExists(id)).toBe(true);
152
+ });
153
+
154
+ test("high-risk handler succeeds, row is gone", async () => {
155
+ const id = await createHard("gone-after-high-forget");
156
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
157
+ expect(await hardRowExists(id)).toBe(false);
158
+ });
159
+
160
+ test("nonexistent id at mid-risk still answers 403, not 404", async () => {
161
+ const err = await stack.http.writeErr(
162
+ "gatetest:write:forget-hard-mid",
163
+ { id: NONEXISTENT_ID },
164
+ admin,
165
+ );
166
+ expect(err.httpStatus).toBe(403);
167
+ expect(err.code).toBe("access_denied");
168
+ });
169
+ });
170
+
171
+ describe("executor.delete — irreversible only without softDelete", () => {
172
+ test("mid-risk handler on a hard-delete entity is denied", async () => {
173
+ const id = await createHard("survives-mid-delete");
174
+ const err = await stack.http.writeErr("gatetest:write:delete-hard-mid", { id }, admin);
175
+ expect(err.httpStatus).toBe(403);
176
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
177
+ });
178
+
179
+ test("mid-risk handler on a softDelete entity succeeds", async () => {
180
+ const id = await createSoft("soft-delete-ok");
181
+ await stack.http.writeOk("gatetest:write:delete-soft-mid", { id }, admin);
182
+ const row = await stack.http.queryOk("gatetest:query:soft:detail", { id }, admin);
183
+ expect(row).toBeNull();
184
+ });
185
+ });
186
+
187
+ describe("delegation via ctx.write/writeAs does not inherit the inner handler's risk", () => {
188
+ test("mid handler delegating via ctx.write to a high forget handler is denied", async () => {
189
+ const id = await createHard("delegate-write-denied");
190
+ const err = await stack.http.writeErr(
191
+ "gatetest:write:delegate-write-to-forget-high",
192
+ { id },
193
+ admin,
194
+ );
195
+ expect(err.httpStatus).toBe(403);
196
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
197
+ expect(await hardRowExists(id)).toBe(true);
198
+ });
199
+
200
+ test("mid handler delegating via ctx.writeAs to a high forget handler is denied", async () => {
201
+ const id = await createHard("delegate-write-as-denied");
202
+ const err = await stack.http.writeErr(
203
+ "gatetest:write:delegate-write-as-to-forget-high",
204
+ { id },
205
+ admin,
206
+ );
207
+ expect(err.httpStatus).toBe(403);
208
+ expect(errorReason(err.details)).toBe("irreversible_operation_requires_high_risk");
209
+ expect(await hardRowExists(id)).toBe(true);
210
+ });
211
+
212
+ test("calling the high handler directly still succeeds", async () => {
213
+ const id = await createHard("direct-high-ok");
214
+ await stack.http.writeOk("gatetest:write:forget-hard-high", { id }, admin);
215
+ expect(await hardRowExists(id)).toBe(false);
216
+ });
217
+ });
218
+
219
+ describe("standard entity-convention delete handler", () => {
220
+ test("resolves to high risk on a non-softDelete entity, mid on a softDelete one", () => {
221
+ const registry = stack.registry as Registry;
222
+ const hardDelete = registry.getWriteHandler("gatetest:write:hard:delete");
223
+ const softDelete = registry.getWriteHandler("gatetest:write:soft:delete");
224
+ if (!hardDelete || !softDelete) throw new Error("delete handlers missing from registry");
225
+ expect(resolveAgentExposure(hardDelete, "write").risk).toBe("high");
226
+ expect(resolveAgentExposure(softDelete, "write").risk).toBe("mid");
227
+ });
228
+
229
+ test("succeeds over HTTP on the non-softDelete entity", async () => {
230
+ const id = await createHard("standard-delete-ok");
231
+ await stack.http.writeOk("gatetest:write:hard:delete", { id }, admin);
232
+ expect(await hardRowExists(id)).toBe(false);
233
+ });
234
+ });
235
+
236
+ describe("jobs have no entry handler — the gate stays out of their way", () => {
237
+ test("a job calling executor.forget without a write-handler entry runs through", async () => {
238
+ jobForgetRuns.length = 0;
239
+ const id = await createHard("job-forget-ok");
240
+ await stack.jobRunner?.dispatch("gatetest:job:forget-hard-job", {
241
+ id,
242
+ tenantId: admin.tenantId,
243
+ });
244
+ await waitFor(() => {
245
+ expect(jobForgetRuns.some((run) => run.id === id)).toBe(true);
246
+ });
247
+ expect(await hardRowExists(id)).toBe(false);
248
+ });
249
+ });