@cosmicdrift/kumiko-framework 0.321.0 → 0.323.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/package.json +4 -4
  2. package/src/api/auth-routes.ts +43 -1
  3. package/src/api/index.ts +5 -1
  4. package/src/api/request-context.ts +23 -1
  5. package/src/changes.json +32 -0
  6. package/src/crypto/pii-field-encryption.ts +16 -1
  7. package/src/db/event-store-executor-write.ts +8 -0
  8. package/src/engine/__tests__/boot-validator-pii-retention.test.ts +0 -122
  9. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  10. package/src/engine/__tests__/factories-long-text.test.ts +5 -3
  11. package/src/engine/__tests__/factories-personal.test.ts +99 -13
  12. package/src/engine/boot-validator/pii-retention.ts +0 -8
  13. package/src/engine/entity-handlers.ts +20 -1
  14. package/src/engine/factories.ts +75 -2
  15. package/src/engine/index.ts +3 -0
  16. package/src/engine/registry-state.ts +2 -1
  17. package/src/engine/types/index.ts +2 -0
  18. package/src/errors/i18n/de.yaml +14 -0
  19. package/src/errors/i18n/en.yaml +13 -0
  20. package/src/errors/reasons.ts +5 -0
  21. package/src/i18n/__tests__/i18n.test.ts +35 -0
  22. package/src/i18n/__tests__/mail-registry.test.ts +19 -0
  23. package/src/i18n/index.ts +11 -4
  24. package/src/i18n/mail-registry.ts +13 -7
  25. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  26. package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
  27. package/src/pipeline/dispatch-shared.ts +33 -4
  28. package/src/pipeline/index.ts +4 -0
  29. package/src/pipeline/irreversible-operation-gate.ts +37 -0
  30. package/src/ui-types/__tests__/plural.test.ts +108 -0
  31. package/src/ui-types/index.ts +6 -0
  32. package/src/ui-types/plural.ts +69 -0
@@ -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
- { handler: type, feature: registry.getHandlerFeature(type) },
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({ handler: type, feature: registry.getHandlerFeature(type) }, () =>
982
- observabilityContext.run({ activeSpan: span }, fn),
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());
@@ -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
+ });
@@ -150,3 +150,9 @@ export {
150
150
  REFERENCE_LOOKUP_SOURCES,
151
151
  SYSTEM_REFERENCE_LABELS,
152
152
  } from "./list-row-meta";
153
+ export {
154
+ type PluralForms,
155
+ resolveTranslationValue,
156
+ type TranslationValue,
157
+ translationValueOtherText,
158
+ } from "./plural";
@@ -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
+ }