@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
|
@@ -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,37 @@
|
|
|
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
|
+
// The gate reads the directly dispatched entry handler; a write dispatched inside a deferred
|
|
15
|
+
// context (afterCommit hook, job, consumer) gets its own entryHandler and is gated too. Direct
|
|
16
|
+
// executor/KMS calls without one stay ungated in exactly two cases: deleting derived data
|
|
17
|
+
// rebuildable from a reversible source (document-ingest-foundation's forget-extract-with-file-ref
|
|
18
|
+
// consumer, re-created on fileRef.restored), and pipelines started by a risk "high" handler or a
|
|
19
|
+
// cron scan (forget cleanup, retention, tenant destroy). A deferred context can't return
|
|
20
|
+
// irreversibleOperationRequiresHighRisk to the agent synchronously, so the gate sits at the primitives.
|
|
21
|
+
export function assertIrreversibleOperationAllowed(operation: string): void {
|
|
22
|
+
const entryHandler = requestContext.get()?.entryHandler;
|
|
23
|
+
// skip: no entry handler (see above) or the entry handler already resolves "high".
|
|
24
|
+
if (!entryHandler || entryHandler.risk === "high") return;
|
|
25
|
+
throw new AccessDeniedError({
|
|
26
|
+
message:
|
|
27
|
+
`Handler "${entryHandler.qn}" performs an irreversible ${operation} but resolves agent risk ` +
|
|
28
|
+
`"${entryHandler.risk}" — declare agent: { risk: "high" } on "${entryHandler.qn}". The ` +
|
|
29
|
+
"directly called handler must carry it; delegating via ctx.write/writeAs does not.",
|
|
30
|
+
details: {
|
|
31
|
+
reason: FrameworkReasons.irreversibleOperationRequiresHighRisk,
|
|
32
|
+
handler: entryHandler.qn,
|
|
33
|
+
operation,
|
|
34
|
+
resolvedRisk: entryHandler.risk,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
@@ -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
|
+
}
|