@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
|
@@ -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
|
-
...(
|
|
400
|
+
...(agentHints !== undefined && { agent: agentHints }),
|
|
382
401
|
};
|
|
383
402
|
}
|
|
384
403
|
|
package/src/engine/factories.ts
CHANGED
|
@@ -88,6 +88,77 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
|
88
88
|
} as Omit<T, "personal" | "find" | "reason"> & ResolvedPiiFlags;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
+
type TextFieldStanceFactory = "createTextField" | "createLongTextField";
|
|
92
|
+
|
|
93
|
+
// `find` is mandatory once a subject is named (types require it — see
|
|
94
|
+
// PersonalAnnotations / PersonalAnnotationsLongText), but the valid values
|
|
95
|
+
// differ per factory: longText has no lookup/search machinery.
|
|
96
|
+
const TEXT_FIELD_STANCE_FIND_VALUES: Record<TextFieldStanceFactory, readonly Findability[]> = {
|
|
97
|
+
createTextField: ["exact", "fuzzy", "none", "secret"],
|
|
98
|
+
createLongTextField: ["none", "secret"],
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function textFieldStanceHint(factoryName: TextFieldStanceFactory): string {
|
|
102
|
+
const find = TEXT_FIELD_STANCE_FIND_VALUES[factoryName].map((f) => `"${f}"`).join(" | ");
|
|
103
|
+
return (
|
|
104
|
+
`{ personal: "self", find: ${find} }, ` +
|
|
105
|
+
`{ personal: "tenant", find: ${find} }, ` +
|
|
106
|
+
`{ personal: { of: "<ownerField>" }, find: ${find} }, ` +
|
|
107
|
+
`{ personal: "ref" }, or ` +
|
|
108
|
+
`{ personal: false, reason: "<why this is not personal data>" }`
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function missingTextFieldStanceError(factoryName: TextFieldStanceFactory): Error {
|
|
113
|
+
return new Error(
|
|
114
|
+
`${factoryName}(...) must declare an explicit personal-data stance — a text field must state whose data it holds and (unless it isn't personal data) how findable it needs to stay (kumiko-framework#2921). Pass one of: ${textFieldStanceHint(factoryName)}. Run \`kumiko-guards guards --guard="Text-Field Personal-Stance Guard"\` to find remaining call sites.`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
119
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Fail-closed stance gate (kumiko-framework#2921) for the two text-shaped
|
|
123
|
+
// factories — runs before expandPersonalAnnotations so an untyped JS caller
|
|
124
|
+
// (no compiler to stop it) can't slip an unannotated text field past the
|
|
125
|
+
// type layer either. `overrides` is `unknown` here on purpose: the typed
|
|
126
|
+
// signature above already rejects a missing/malformed shape at compile
|
|
127
|
+
// time, this re-checks the same shape at the runtime boundary.
|
|
128
|
+
function assertTextFieldStance(factoryName: TextFieldStanceFactory, overrides: unknown): void {
|
|
129
|
+
if (overrides === null || typeof overrides !== "object") {
|
|
130
|
+
throw missingTextFieldStanceError(factoryName);
|
|
131
|
+
}
|
|
132
|
+
// @cast-boundary runtime-guard — re-validated field by field below before
|
|
133
|
+
// any value is trusted.
|
|
134
|
+
const { personal, find, reason } = overrides as PersonalOverridesInput;
|
|
135
|
+
if (personal === undefined || personal === null) {
|
|
136
|
+
throw missingTextFieldStanceError(factoryName);
|
|
137
|
+
}
|
|
138
|
+
if (personal === "ref") {
|
|
139
|
+
// skip: a user reference carries no content of its own, so no `find` applies
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (personal === false) {
|
|
143
|
+
if (!isNonEmptyString(reason)) {
|
|
144
|
+
throw missingTextFieldStanceError(factoryName);
|
|
145
|
+
}
|
|
146
|
+
// skip: plaintext by declaration, `find` only applies to a named subject
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const hasValidSubject =
|
|
150
|
+
personal === "self" ||
|
|
151
|
+
personal === "tenant" ||
|
|
152
|
+
(typeof personal === "object" && isNonEmptyString(personal.of));
|
|
153
|
+
if (!hasValidSubject) {
|
|
154
|
+
throw missingTextFieldStanceError(factoryName);
|
|
155
|
+
}
|
|
156
|
+
const validFinds = TEXT_FIELD_STANCE_FIND_VALUES[factoryName];
|
|
157
|
+
if (typeof find !== "string" || !(validFinds as readonly string[]).includes(find)) {
|
|
158
|
+
throw missingTextFieldStanceError(factoryName);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
91
162
|
// Generic über `R extends true | false` (statt `boolean`) damit
|
|
92
163
|
// `createTextField({ required: true })` literal `required: true` im
|
|
93
164
|
// Return-Type behält. `boolean` würde widenen — EntityTable<E>'s
|
|
@@ -96,9 +167,10 @@ function expandPersonalAnnotations<T extends PersonalOverridesInput>(
|
|
|
96
167
|
// degradieren. Default `R = false` matcht den runtime-default. Pattern
|
|
97
168
|
// in jeder required-bearing factory unten.
|
|
98
169
|
export function createTextField<R extends true | false = false>(
|
|
99
|
-
overrides
|
|
170
|
+
overrides: Partial<Omit<TextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
100
171
|
PersonalAnnotations & { required?: R },
|
|
101
172
|
): TextFieldDef & { required: R } {
|
|
173
|
+
assertTextFieldStance("createTextField", overrides);
|
|
102
174
|
return {
|
|
103
175
|
type: "text",
|
|
104
176
|
maxLength: 200,
|
|
@@ -131,9 +203,10 @@ export function createDerivedField(spec: DerivedFieldDef): DerivedFieldDef {
|
|
|
131
203
|
}
|
|
132
204
|
|
|
133
205
|
export function createLongTextField<R extends true | false = false>(
|
|
134
|
-
overrides
|
|
206
|
+
overrides: Partial<Omit<LongTextFieldDef, "type" | "required" | keyof ResolvedPiiFlags>> &
|
|
135
207
|
PersonalAnnotationsLongText & { required?: R },
|
|
136
208
|
): LongTextFieldDef & { required: R } {
|
|
209
|
+
assertTextFieldStance("createLongTextField", overrides);
|
|
137
210
|
return {
|
|
138
211
|
type: "longText",
|
|
139
212
|
required: false,
|
package/src/engine/index.ts
CHANGED
|
@@ -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,
|
|
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 {
|
package/src/errors/i18n/de.yaml
CHANGED
|
@@ -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.
|
package/src/errors/i18n/en.yaml
CHANGED
|
@@ -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.
|
package/src/errors/reasons.ts
CHANGED
|
@@ -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
|
|
37
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
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
|
+
});
|