@cosmicdrift/kumiko-framework 0.321.0 → 0.322.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/index.ts +5 -1
- package/src/api/request-context.ts +23 -1
- package/src/changes.json +12 -0
- package/src/db/event-store-executor-write.ts +8 -0
- package/src/engine/__tests__/entity-handlers.test.ts +28 -0
- package/src/engine/entity-handlers.ts +20 -1
- 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 +33 -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.322.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.322.0",
|
|
206
|
+
"@cosmicdrift/kumiko-types": "0.322.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.322.0",
|
|
223
223
|
"bun-types": "^1.3.13",
|
|
224
224
|
"pino-pretty": "^13.1.3"
|
|
225
225
|
},
|
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,16 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.322.0",
|
|
4
|
+
"type": "breaking",
|
|
5
|
+
"title": "Irreversible writes now require agent.risk \"high\" on the directly-dispatched handler",
|
|
6
|
+
"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."
|
|
7
|
+
},
|
|
8
|
+
{
|
|
9
|
+
"version": "0.322.0",
|
|
10
|
+
"type": "improvement",
|
|
11
|
+
"title": "Translation values may now be CLDR plural-form objects, resolved per locale with Intl.PluralRules",
|
|
12
|
+
"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)`."
|
|
13
|
+
},
|
|
2
14
|
{
|
|
3
15
|
"version": "0.321.0",
|
|
4
16
|
"type": "improvement",
|
|
@@ -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));
|
|
@@ -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", () => {
|
|
@@ -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/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
|
+
});
|
|
@@ -13,6 +13,7 @@ import { createDerivativesContext } from "../derivatives/derivatives-context";
|
|
|
13
13
|
import type { defineTransitions } from "../engine/state-machine";
|
|
14
14
|
import type { EffectiveFeaturesResolver } from "../engine/tier-resolver-extension";
|
|
15
15
|
import type {
|
|
16
|
+
AgentRisk,
|
|
16
17
|
AggregateStreamHandle,
|
|
17
18
|
AppContext,
|
|
18
19
|
AppendEventArgs,
|
|
@@ -28,7 +29,7 @@ import type {
|
|
|
28
29
|
SessionUser,
|
|
29
30
|
WriteResult,
|
|
30
31
|
} from "../engine/types";
|
|
31
|
-
import { isRateLimitDisabled } from "../engine/types";
|
|
32
|
+
import { isRateLimitDisabled, resolveAgentExposure } from "../engine/types";
|
|
32
33
|
import type { TenantId } from "../engine/types/identifiers";
|
|
33
34
|
import {
|
|
34
35
|
FeatureDisabledError,
|
|
@@ -877,6 +878,25 @@ export async function buildHandlerContext(
|
|
|
877
878
|
: handlerContext;
|
|
878
879
|
}
|
|
879
880
|
|
|
881
|
+
// Resolves the agent risk the irreversible-operation gate should attribute to
|
|
882
|
+
// `type` when it becomes the request's entry handler — same defaulting rule
|
|
883
|
+
// (resolveAgentExposure) the agent manifest itself uses, so the two never diverge.
|
|
884
|
+
function resolveEntryHandlerRisk(
|
|
885
|
+
registry: Registry,
|
|
886
|
+
type: string,
|
|
887
|
+
operation: "query" | "write" | "stream",
|
|
888
|
+
): AgentRisk {
|
|
889
|
+
if (operation === "write") {
|
|
890
|
+
return resolveAgentExposure(registry.getWriteHandler(type) ?? {}, "write").risk;
|
|
891
|
+
}
|
|
892
|
+
if (operation === "stream") {
|
|
893
|
+
// StreamHandlerDef carries no `agent` hint — streams can never declare
|
|
894
|
+
// "high", so nested irreversible writes stay conservatively denied.
|
|
895
|
+
return "mid";
|
|
896
|
+
}
|
|
897
|
+
return resolveAgentExposure(registry.getQueryHandler(type) ?? {}, "query").risk;
|
|
898
|
+
}
|
|
899
|
+
|
|
880
900
|
// Wrap handler execution in a dispatcher.handler span AND emit the standard
|
|
881
901
|
// dispatcher metrics (duration + error counter). Errors are re-thrown so
|
|
882
902
|
// control flow stays identical to the uninstrumented path.
|
|
@@ -916,7 +936,11 @@ export async function runHandlerInstrumented<T>(
|
|
|
916
936
|
// #3043 — everything the handler writes, including entity-executor
|
|
917
937
|
// writes below it, is attributed to this handler.
|
|
918
938
|
const result = await runWithOrigin(
|
|
919
|
-
{
|
|
939
|
+
{
|
|
940
|
+
handler: type,
|
|
941
|
+
feature: registry.getHandlerFeature(type),
|
|
942
|
+
entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, operation) },
|
|
943
|
+
},
|
|
920
944
|
inner,
|
|
921
945
|
);
|
|
922
946
|
if (operation === "write" && isFailedWriteResult(result)) {
|
|
@@ -978,8 +1002,13 @@ export async function* runStreamInstrumented<T>(
|
|
|
978
1002
|
// generator suspension, so wrapping inner() once would leave every event a
|
|
979
1003
|
// stream writes unattributed.
|
|
980
1004
|
const inScope = <R>(fn: () => R): R =>
|
|
981
|
-
runWithOrigin(
|
|
982
|
-
|
|
1005
|
+
runWithOrigin(
|
|
1006
|
+
{
|
|
1007
|
+
handler: type,
|
|
1008
|
+
feature: registry.getHandlerFeature(type),
|
|
1009
|
+
entryHandler: { qn: type, risk: resolveEntryHandlerRisk(registry, type, "stream") },
|
|
1010
|
+
},
|
|
1011
|
+
() => observabilityContext.run({ activeSpan: span }, fn),
|
|
983
1012
|
);
|
|
984
1013
|
try {
|
|
985
1014
|
let next = await inScope(() => it.next());
|
package/src/pipeline/index.ts
CHANGED
|
@@ -50,6 +50,10 @@ export type { PruneEventsOptions, PruneEventsResult } from "./event-retention";
|
|
|
50
50
|
export { ConsumerLagError, pruneEvents } from "./event-retention";
|
|
51
51
|
export type { IdempotencyGuard } from "./idempotency";
|
|
52
52
|
export { createIdempotencyGuard } from "./idempotency";
|
|
53
|
+
export {
|
|
54
|
+
assertIrreversibleOperationAllowed,
|
|
55
|
+
isIrreversibleEntityVerb,
|
|
56
|
+
} from "./irreversible-operation-gate";
|
|
53
57
|
export type { LifecycleHooks, SystemHookDef, SystemHooks } from "./lifecycle-pipeline";
|
|
54
58
|
export { createLifecycleHooks } from "./lifecycle-pipeline";
|
|
55
59
|
export type { MspRebuildDeps } from "./msp-rebuild";
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { requestContext } from "../api/request-context";
|
|
2
|
+
import type { EntityDefinition } from "../engine/types";
|
|
3
|
+
import { AccessDeniedError, FrameworkReasons } from "../errors";
|
|
4
|
+
|
|
5
|
+
// forget hard-purges regardless of softDelete; delete is only irreversible
|
|
6
|
+
// when the entity has no soft-delete recovery path.
|
|
7
|
+
export function isIrreversibleEntityVerb(
|
|
8
|
+
verb: "delete" | "forget",
|
|
9
|
+
entity: EntityDefinition,
|
|
10
|
+
): boolean {
|
|
11
|
+
return verb === "forget" || !entity.softDelete;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// No entryHandler means the call didn't originate from a directly-dispatched
|
|
15
|
+
// handler (job, MSP/consumer apply, seed, afterCommit hook, HTTP route
|
|
16
|
+
// without a handler) — those are outside agent reach and stay ungated.
|
|
17
|
+
export function assertIrreversibleOperationAllowed(operation: string): void {
|
|
18
|
+
const entryHandler = requestContext.get()?.entryHandler;
|
|
19
|
+
// skip: no entry handler (see above) or the entry handler already resolves "high".
|
|
20
|
+
if (!entryHandler || entryHandler.risk === "high") return;
|
|
21
|
+
throw new AccessDeniedError({
|
|
22
|
+
message:
|
|
23
|
+
`Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
|
|
24
|
+
`"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
|
|
25
|
+
"directly called handler must carry it; delegating via ctx.write/writeAs does not.",
|
|
26
|
+
details: {
|
|
27
|
+
reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
|
|
28
|
+
handler: entryHandler.qn,
|
|
29
|
+
operation,
|
|
30
|
+
resolvedRisk: entryHandler.risk,
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { resolveTranslationValue, translationValueOtherText } from "../plural";
|
|
3
|
+
|
|
4
|
+
const messageCount = {
|
|
5
|
+
de: { one: "{count} ungelesene Nachricht", other: "{count} ungelesene Nachrichten" },
|
|
6
|
+
en: { one: "{count} unread message", other: "{count} unread messages" },
|
|
7
|
+
pl: {
|
|
8
|
+
one: "{count} nieprzeczytana wiadomość",
|
|
9
|
+
few: "{count} nieprzeczytane wiadomości",
|
|
10
|
+
many: "{count} nieprzeczytanych wiadomości",
|
|
11
|
+
other: "{count} nieprzeczytanej wiadomości",
|
|
12
|
+
},
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
describe("resolveTranslationValue — plain strings", () => {
|
|
16
|
+
test("interpolates {name} placeholders", () => {
|
|
17
|
+
expect(resolveTranslationValue("Hello, {name}!", "en", { name: "Ada" })).toBe("Hello, Ada!");
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
test("leaves an unknown placeholder untouched", () => {
|
|
21
|
+
expect(resolveTranslationValue("Hello, {name}!", "en")).toBe("Hello, {name}!");
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("resolveTranslationValue — plural forms", () => {
|
|
26
|
+
test("en: singular vs plural", () => {
|
|
27
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 1 })).toBe("1 unread message");
|
|
28
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 0 })).toBe("0 unread messages");
|
|
29
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 2 })).toBe("2 unread messages");
|
|
30
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 5 })).toBe("5 unread messages");
|
|
31
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: 21 })).toBe(
|
|
32
|
+
"21 unread messages",
|
|
33
|
+
);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("de: singular vs plural", () => {
|
|
37
|
+
expect(resolveTranslationValue(messageCount.de, "de", { count: 1 })).toBe(
|
|
38
|
+
"1 ungelesene Nachricht",
|
|
39
|
+
);
|
|
40
|
+
expect(resolveTranslationValue(messageCount.de, "de", { count: 5 })).toBe(
|
|
41
|
+
"5 ungelesene Nachrichten",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("pl: one/few/many CLDR categories", () => {
|
|
46
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 1 })).toBe(
|
|
47
|
+
"1 nieprzeczytana wiadomość",
|
|
48
|
+
);
|
|
49
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 2 })).toBe(
|
|
50
|
+
"2 nieprzeczytane wiadomości",
|
|
51
|
+
);
|
|
52
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 5 })).toBe(
|
|
53
|
+
"5 nieprzeczytanych wiadomości",
|
|
54
|
+
);
|
|
55
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 21 })).toBe(
|
|
56
|
+
"21 nieprzeczytanych wiadomości",
|
|
57
|
+
);
|
|
58
|
+
expect(resolveTranslationValue(messageCount.pl, "pl", { count: 22 })).toBe(
|
|
59
|
+
"22 nieprzeczytane wiadomości",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a formal BCP-47 subtag still resolves plural categories (renderer's <locale>-x-formal tier)", () => {
|
|
64
|
+
expect(resolveTranslationValue(messageCount.pl, "pl-x-formal", { count: 2 })).toBe(
|
|
65
|
+
"2 nieprzeczytane wiadomości",
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("falls back to `other` when the selected category has no form", () => {
|
|
70
|
+
const partial = { one: "one item", other: "{count} items" };
|
|
71
|
+
// pl "5" selects "many", which `partial` doesn't define.
|
|
72
|
+
expect(resolveTranslationValue(partial, "pl", { count: 5 })).toBe("5 items");
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("falls back to `other` when count is missing", () => {
|
|
76
|
+
expect(resolveTranslationValue(messageCount.en, "en")).toBe("{count} unread messages");
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
test("falls back to `other` when count is not a finite number", () => {
|
|
80
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: "many" })).toBe(
|
|
81
|
+
"many unread messages",
|
|
82
|
+
);
|
|
83
|
+
// count is present (interpolated verbatim) but non-finite, so the plural
|
|
84
|
+
// category still falls back to `other`.
|
|
85
|
+
expect(resolveTranslationValue(messageCount.en, "en", { count: Number.NaN })).toBe(
|
|
86
|
+
"NaN unread messages",
|
|
87
|
+
);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test("an invalid locale tag falls back to `other` without throwing", () => {
|
|
91
|
+
expect(() =>
|
|
92
|
+
resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 }),
|
|
93
|
+
).not.toThrow();
|
|
94
|
+
expect(resolveTranslationValue(messageCount.en, "not-a-locale-!!", { count: 1 })).toBe(
|
|
95
|
+
"1 unread messages",
|
|
96
|
+
);
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe("translationValueOtherText", () => {
|
|
101
|
+
test("returns a plain string as-is", () => {
|
|
102
|
+
expect(translationValueOtherText("Welcome")).toBe("Welcome");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test("returns the `other` form for a plural entry", () => {
|
|
106
|
+
expect(translationValueOtherText(messageCount.en)).toBe("{count} unread messages");
|
|
107
|
+
});
|
|
108
|
+
});
|
package/src/ui-types/index.ts
CHANGED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
|
|
2
|
+
|
|
3
|
+
export type { PluralForms, TranslationValue } from "@cosmicdrift/kumiko-types/config";
|
|
4
|
+
|
|
5
|
+
// Hermes on older Expo/React Native builds doesn't ship Intl.PluralRules —
|
|
6
|
+
// fall back to `other` there instead of hand-rolling CLDR plural rules.
|
|
7
|
+
const hasPluralRulesSupport = typeof Intl !== "undefined" && typeof Intl.PluralRules === "function";
|
|
8
|
+
|
|
9
|
+
// `null` cache entries mark a locale tag Intl.PluralRules rejected, so a bad
|
|
10
|
+
// tag doesn't retry the constructor (and its exception) on every call.
|
|
11
|
+
const pluralRulesCache = new Map<string, Intl.PluralRules | null>();
|
|
12
|
+
|
|
13
|
+
function pluralRulesFor(locale: string): Intl.PluralRules | null {
|
|
14
|
+
if (!hasPluralRulesSupport) return null;
|
|
15
|
+
const cached = pluralRulesCache.get(locale);
|
|
16
|
+
if (cached !== undefined) return cached;
|
|
17
|
+
let rules: Intl.PluralRules | null;
|
|
18
|
+
try {
|
|
19
|
+
rules = new Intl.PluralRules(locale);
|
|
20
|
+
} catch {
|
|
21
|
+
rules = null;
|
|
22
|
+
}
|
|
23
|
+
pluralRulesCache.set(locale, rules);
|
|
24
|
+
return rules;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function isFiniteCount(value: unknown): value is number {
|
|
28
|
+
return typeof value === "number" && Number.isFinite(value);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function pluralFormText(forms: PluralForms, locale: string, count: unknown): string {
|
|
32
|
+
if (!isFiniteCount(count)) return forms.other;
|
|
33
|
+
const rules = pluralRulesFor(locale);
|
|
34
|
+
if (rules === null) return forms.other;
|
|
35
|
+
const category = rules.select(count);
|
|
36
|
+
return forms[category] ?? forms.other;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function interpolate(
|
|
40
|
+
template: string,
|
|
41
|
+
params: Readonly<Record<string, unknown>> | undefined,
|
|
42
|
+
): string {
|
|
43
|
+
if (params === undefined) return template;
|
|
44
|
+
return template.replace(/\{(\w+)\}/g, (_, name: string) => {
|
|
45
|
+
const value = params[name];
|
|
46
|
+
return value !== undefined ? String(value) : `{${name}}`;
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Text-only reading of a TranslationValue — the CLDR `other` form for a
|
|
51
|
+
* plural entry, or the string as-is. For call sites that render a single
|
|
52
|
+
* string (agent manifests, labels) without a locale/count context. */
|
|
53
|
+
export function translationValueOtherText(value: TranslationValue): string {
|
|
54
|
+
return typeof value === "string" ? value : value.other;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Resolve a TranslationValue to display text: a plural entry picks its CLDR
|
|
58
|
+
* category via `Intl.PluralRules` from `params.count`, falling back to
|
|
59
|
+
* `other` when `count` is missing, non-finite, or `locale` is invalid.
|
|
60
|
+
* `{name}` interpolation then applies to the resolved string — an unknown
|
|
61
|
+
* placeholder is left as-is. */
|
|
62
|
+
export function resolveTranslationValue(
|
|
63
|
+
value: TranslationValue,
|
|
64
|
+
locale: string,
|
|
65
|
+
params?: Readonly<Record<string, unknown>>,
|
|
66
|
+
): string {
|
|
67
|
+
const text = typeof value === "string" ? value : pluralFormText(value, locale, params?.["count"]);
|
|
68
|
+
return interpolate(text, params);
|
|
69
|
+
}
|