@cosmicdrift/kumiko-framework 0.320.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.
Files changed (54) hide show
  1. package/package.json +4 -4
  2. package/src/__tests__/schema-cli.integration.test.ts +11 -0
  3. package/src/__tests__/upgrade-cli.test.ts +50 -3
  4. package/src/api/__tests__/client-ip.test.ts +25 -0
  5. package/src/api/__tests__/extra-routes.integration.test.ts +42 -0
  6. package/src/api/client-ip.ts +27 -0
  7. package/src/api/extra-route.ts +3 -0
  8. package/src/api/index.ts +7 -1
  9. package/src/api/request-context.ts +23 -1
  10. package/src/api/server.ts +3 -1
  11. package/src/changes.json +60 -0
  12. package/src/db/event-store-executor-write.ts +8 -0
  13. package/src/db/queries/event-store-transfer.ts +73 -0
  14. package/src/engine/__tests__/entity-handlers.test.ts +28 -0
  15. package/src/engine/__tests__/steps-webhook-send.test.ts +2 -2
  16. package/src/engine/entity-handlers.ts +20 -1
  17. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/alias-reexport.ts +1 -0
  18. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-a.ts +1 -0
  19. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/cycle-b.ts +1 -0
  20. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/entry.ts +3 -0
  21. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/local-rebind.ts +3 -0
  22. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/origin.ts +1 -0
  23. package/src/engine/feature-ast/__tests__/fixtures/resolve-import/star-barrel.ts +1 -0
  24. package/src/engine/feature-ast/__tests__/parse.test.ts +4 -4
  25. package/src/engine/feature-ast/__tests__/resolve-import.test.ts +138 -0
  26. package/src/engine/feature-ast/extractors/index.ts +4 -0
  27. package/src/engine/feature-ast/extractors/resolve-import.ts +187 -0
  28. package/src/engine/feature-ast/extractors/shared.ts +26 -19
  29. package/src/engine/feature-ast/parse.ts +13 -20
  30. package/src/engine/index.ts +3 -0
  31. package/src/engine/registry-state.ts +2 -1
  32. package/src/engine/steps/webhook-send.ts +4 -2
  33. package/src/engine/types/index.ts +2 -0
  34. package/src/errors/i18n/de.yaml +14 -0
  35. package/src/errors/i18n/en.yaml +13 -0
  36. package/src/errors/reasons.ts +5 -0
  37. package/src/event-store/__tests__/transfer.integration.test.ts +236 -0
  38. package/src/event-store/index.ts +6 -0
  39. package/src/event-store/transfer.ts +84 -0
  40. package/src/i18n/__tests__/i18n.test.ts +35 -0
  41. package/src/i18n/__tests__/mail-registry.test.ts +19 -0
  42. package/src/i18n/index.ts +11 -4
  43. package/src/i18n/mail-registry.ts +13 -7
  44. package/src/pipeline/__tests__/archive-stream.integration.test.ts +3 -1
  45. package/src/pipeline/__tests__/irreversible-operation-gate.integration.test.ts +249 -0
  46. package/src/pipeline/dispatch-shared.ts +33 -4
  47. package/src/pipeline/index.ts +4 -0
  48. package/src/pipeline/irreversible-operation-gate.ts +33 -0
  49. package/src/pipeline/multi-stream-apply-context.ts +4 -0
  50. package/src/schema-cli.ts +5 -1
  51. package/src/ui-types/__tests__/plural.test.ts +108 -0
  52. package/src/ui-types/index.ts +6 -0
  53. package/src/ui-types/plural.ts +69 -0
  54. package/src/upgrade-cli.ts +58 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.320.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.320.0",
206
- "@cosmicdrift/kumiko-types": "0.320.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.320.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
  },
@@ -54,6 +54,17 @@ describe("runSchemaCli — no-DB paths", () => {
54
54
  expect(cap.err).toHaveLength(0);
55
55
  });
56
56
 
57
+ test.each(["--help", "-h", "help"])(
58
+ "%s prints the same usage as no subcommand and exits 0",
59
+ async (helpArg) => {
60
+ const cap = captureOut();
61
+ const code = await runSchemaCli([helpArg], appCwd, cap.out);
62
+ expect(code).toBe(0);
63
+ expect(cap.log.join("\n")).toContain("Subcommands:");
64
+ expect(cap.err).toHaveLength(0);
65
+ },
66
+ );
67
+
57
68
  test("unknown subcommand exits 1 and reports it on stderr", async () => {
58
69
  const cap = captureOut();
59
70
  const code = await runSchemaCli(["cutover"], appCwd, cap.out);
@@ -14,8 +14,8 @@ import { tmpdir } from "node:os";
14
14
  import { join } from "node:path";
15
15
  import {
16
16
  findCodemodScriptsRoot,
17
- findCoreChangelogFile,
18
17
  findFeaturesDirs,
18
+ findPackageChangelogFiles,
19
19
  resolveCodemodScript,
20
20
  runUpgradeCli,
21
21
  type UpgradeCliOut,
@@ -92,6 +92,23 @@ async function runJson(cwd: string, from: string): Promise<{ pending: Array<{ ti
92
92
  return JSON.parse(spy.logs.join("\n"));
93
93
  }
94
94
 
95
+ describe("upgrade command — --help", () => {
96
+ test("--help prints usage and exits 0 without touching the filesystem", async () => {
97
+ const spy = makeSpyOutput();
98
+ const exit = await runUpgradeCli(["--help"], "/nonexistent-cwd", spy.out);
99
+ expect(exit).toBe(0);
100
+ expect(spy.logs.join("\n")).toContain("kumiko-upgrade");
101
+ expect(spy.errs).toEqual([]);
102
+ });
103
+
104
+ test("-h behaves the same as --help", async () => {
105
+ const spy = makeSpyOutput();
106
+ const exit = await runUpgradeCli(["-h"], "/nonexistent-cwd", spy.out);
107
+ expect(exit).toBe(0);
108
+ expect(spy.logs.join("\n")).toContain("--apply");
109
+ });
110
+ });
111
+
95
112
  describe("upgrade command — framework core changelog", () => {
96
113
  test("collects core changes.json from the framework repo layout", async () => {
97
114
  const cwd = tmp({
@@ -198,6 +215,37 @@ describe("upgrade command — framework core changelog", () => {
198
215
  });
199
216
  });
200
217
 
218
+ describe("upgrade command — every package's changelog, not just framework core", () => {
219
+ const SERVER_RUNTIME_ENTRY = JSON.stringify([
220
+ { version: "0.168.0", type: "breaking", title: "server-runtime breaking change" },
221
+ ]);
222
+
223
+ test("a hoisted non-framework, non-bundled-features package's changes.json shows up in pending", async () => {
224
+ const cwd = tmp({
225
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
226
+ "apps/web/package.json": "{}",
227
+ });
228
+
229
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
230
+
231
+ expect(result.pending.map((e) => e.title)).toEqual(["server-runtime breaking change"]);
232
+ });
233
+
234
+ test("bundled-features is never double-counted alongside findFeaturesDirs's own collection", async () => {
235
+ const cwd = tmp({
236
+ "node_modules/@cosmicdrift/kumiko-server-runtime/src/changes.json": SERVER_RUNTIME_ENTRY,
237
+ "node_modules/@cosmicdrift/kumiko-bundled-features/src/user/changes.json": FEATURE_ENTRY,
238
+ "apps/web/package.json": "{}",
239
+ });
240
+
241
+ const result = await runJson(`${cwd}/apps/web`, "0.165.0");
242
+
243
+ expect(result.pending.map((e) => e.title).sort()).toEqual(
244
+ ["feature fix", "server-runtime breaking change"].sort(),
245
+ );
246
+ });
247
+ });
248
+
201
249
  describe("upgrade command — enterprise package layout", () => {
202
250
  // Layout is detected by presence of changes.json, not an "ai-" name
203
251
  // prefix — the old heuristic silently dropped every enterprise package
@@ -641,9 +689,8 @@ describe("changes.json codemod fields resolve to real published scripts", () =>
641
689
  }
642
690
 
643
691
  test("every codemod field is a scripts/codemod/*.ts path that resolves to an existing published script", () => {
644
- const coreChangelog = findCoreChangelogFile(REAL_REPO_ROOT);
645
692
  const changesJsonFiles = [
646
- ...(coreChangelog ? [coreChangelog] : []),
693
+ ...findPackageChangelogFiles(REAL_REPO_ROOT),
647
694
  ...findFeaturesDirs(REAL_REPO_ROOT).flatMap((dir) => findChangesJsonFiles(dir)),
648
695
  ];
649
696
  expect(changesJsonFiles.length).toBeGreaterThan(0);
@@ -9,6 +9,7 @@ import {
9
9
  assertValidTrustedProxyHops,
10
10
  type ClientIpHeaderSource,
11
11
  createClientIpResolver,
12
+ parseTrustedProxyHopsEnv,
12
13
  } from "../client-ip";
13
14
 
14
15
  function sourceOf(headers: Record<string, string>, socketAddress?: string): ClientIpHeaderSource {
@@ -137,3 +138,27 @@ describe("createClientIpResolver", () => {
137
138
  expect(() => createClientIpResolver(-1, "test")).toThrow(/non-negative integer/);
138
139
  });
139
140
  });
141
+
142
+ describe("parseTrustedProxyHopsEnv", () => {
143
+ test("undefined -> undefined (no env set)", () => {
144
+ expect(parseTrustedProxyHopsEnv(undefined, "test")).toBeUndefined();
145
+ });
146
+
147
+ test("valid digits-only string -> parsed integer", () => {
148
+ expect(parseTrustedProxyHopsEnv("1", "test")).toBe(1);
149
+ expect(parseTrustedProxyHopsEnv("0", "test")).toBe(0);
150
+ expect(parseTrustedProxyHopsEnv("42", "test")).toBe(42);
151
+ });
152
+
153
+ test("hex/exponent/negative notation throws instead of silently coercing", () => {
154
+ expect(() => parseTrustedProxyHopsEnv("0x10", "test")).toThrow(/non-negative integer/);
155
+ expect(() => parseTrustedProxyHopsEnv("1e3", "test")).toThrow(/non-negative integer/);
156
+ expect(() => parseTrustedProxyHopsEnv("-1", "test")).toThrow(/non-negative integer/);
157
+ });
158
+
159
+ test("error message carries the context prefix and the raw value", () => {
160
+ expect(() => parseTrustedProxyHopsEnv("x", "runProdApp")).toThrow(
161
+ 'runProdApp: KUMIKO_TRUSTED_PROXY_HOPS must be a non-negative integer, got "x".',
162
+ );
163
+ });
164
+ });
@@ -585,6 +585,48 @@ describe("extraRoutes: entry:signature", () => {
585
585
  });
586
586
  });
587
587
 
588
+ describe("extraRoutes: entry:anonymous deps.clientIp", () => {
589
+ const clientIpRoute: AnonymousExtraRoute = {
590
+ method: "GET",
591
+ path: "/public/client-ip-probe",
592
+ entry: "anonymous",
593
+ handler: async (c, deps) => c.json({ clientIp: deps.clientIp }),
594
+ };
595
+
596
+ test("default trustedProxyHops (0): a spoofed X-Forwarded-For is ignored, socket address wins", async () => {
597
+ const stack = await setupTestStack({
598
+ features: [probeFeature],
599
+ extraRoutes: [clientIpRoute],
600
+ });
601
+ try {
602
+ const res = await stack.app.request(
603
+ "/public/client-ip-probe",
604
+ { headers: { "x-forwarded-for": "203.0.113.10" } },
605
+ "198.51.100.9",
606
+ );
607
+ expect(await res.json()).toEqual({ clientIp: "198.51.100.9" });
608
+ } finally {
609
+ await stack.cleanup();
610
+ }
611
+ });
612
+
613
+ test("trustedProxyHops: 1 — the rightmost X-Forwarded-For entry is trusted", async () => {
614
+ const stack = await setupTestStack({
615
+ features: [probeFeature],
616
+ extraRoutes: [clientIpRoute],
617
+ trustedProxyHops: 1,
618
+ });
619
+ try {
620
+ const res = await stack.app.request("/public/client-ip-probe", {
621
+ headers: { "x-forwarded-for": "203.0.113.10" },
622
+ });
623
+ expect(await res.json()).toEqual({ clientIp: "203.0.113.10" });
624
+ } finally {
625
+ await stack.cleanup();
626
+ }
627
+ });
628
+ });
629
+
588
630
  // Type-level contract for ExtraRouteDefinition (kumiko-framework#3050): the
589
631
  // bodies below are never invoked — tsc checks them, bun:test does not run
590
632
  // them. Each `@ts-expect-error` turns into an "unused directive" compile
@@ -44,6 +44,33 @@ export function assertValidTrustedProxyHops(value: number | undefined, context:
44
44
  }
45
45
  }
46
46
 
47
+ export const TRUSTED_PROXY_HOPS_ENV = "KUMIKO_TRUSTED_PROXY_HOPS";
48
+
49
+ // Fail loud on a garbage env value rather than silently coercing to NaN:
50
+ // a resolver built from NaN treats it like "always short chain" and returns
51
+ // "unknown" for every request, which collapses an IP-keyed rate limiter
52
+ // into one shared bucket for the whole deployment — a self-inflicted DoS,
53
+ // worse than staying on the default. Digits-only (not parseInt) —
54
+ // parseInt("0x10")/("1e3")/("2x") would silently coerce instead of failing.
55
+ export function parseTrustedProxyHopsEnv(
56
+ raw: string | undefined,
57
+ context: string,
58
+ ): number | undefined {
59
+ if (raw === undefined) return undefined;
60
+ if (!/^\d+$/.test(raw)) {
61
+ throw new Error(
62
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
63
+ );
64
+ }
65
+ const parsed = Number.parseInt(raw, 10);
66
+ if (!Number.isInteger(parsed) || parsed < 0) {
67
+ throw new Error(
68
+ `${context}: ${TRUSTED_PROXY_HOPS_ENV} must be a non-negative integer, got "${raw}".`,
69
+ );
70
+ }
71
+ return parsed;
72
+ }
73
+
47
74
  // Instance-scoped, NOT a module singleton: the warn-once flag must reset
48
75
  // per server boot (and per test), otherwise the first test to trigger it
49
76
  // consumes the warning for the whole process and later tests can't observe
@@ -32,6 +32,9 @@ export type AnonymousExtraRouteDeps = {
32
32
  * user, same request-resolved tenant (never overridable), only under
33
33
  * "/api/" (the only path that populates a session user). */
34
34
  readonly write: (type: string, payload: unknown) => Promise<WriteResult>;
35
+ /** Resolved by the server's single trustedProxyHops-aware resolver —
36
+ * never read X-Forwarded-For yourself. */
37
+ readonly clientIp: string;
35
38
  };
36
39
 
37
40
  export type AnonymousExtraRoute = {
package/src/api/index.ts CHANGED
@@ -34,6 +34,8 @@ export {
34
34
  clientIpSourceFromHonoContext,
35
35
  createClientIpResolver,
36
36
  extractSocketAddress,
37
+ parseTrustedProxyHopsEnv,
38
+ TRUSTED_PROXY_HOPS_ENV,
37
39
  UNKNOWN_CLIENT_IP,
38
40
  } from "./client-ip";
39
41
  export type {
@@ -73,7 +75,11 @@ export type {
73
75
  export { isSafeLandingPath } from "./post-auth-landing";
74
76
  export type { RedisSseBroker, RedisSseBrokerOptions } from "./redis-sse-broker";
75
77
  export { createDefaultSseBroker, createRedisSseBroker, isRedisSseBroker } from "./redis-sse-broker";
76
- export { type RequestContextData, requestContext } from "./request-context";
78
+ export {
79
+ type RequestContextData,
80
+ requestContext,
81
+ runAsDirectCallEntry,
82
+ } from "./request-context";
77
83
  export {
78
84
  buildRequestContextDataFromRequest,
79
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: { readonly feature?: string; readonly handler?: string },
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/api/server.ts CHANGED
@@ -617,6 +617,7 @@ export function buildServer(options: ServerOptions): KumikoServer {
617
617
  userId: event.metadata.userId,
618
618
  ...(mspOwner && { callerFeature: mspOwner }),
619
619
  ...(mspFiles && { files: mspFiles }),
620
+ ...(contextWithObservability.secrets && { secrets: contextWithObservability.secrets }),
620
621
  ...(mspFiles && {
621
622
  derivatives: createDerivativesContext({
622
623
  files: mspFiles,
@@ -1260,7 +1261,7 @@ type ExtraRouteHonoHandlerDeps = {
1260
1261
  readonly secrets: import("../secrets").SecretsContext | undefined;
1261
1262
  readonly dispatchSystemWrite: (args: SystemDispatchArgs) => Promise<WriteResult>;
1262
1263
  readonly dispatchSystemQuery: (args: SystemDispatchArgs) => Promise<unknown>;
1263
- readonly clientIpResolver?: ClientIpResolver;
1264
+ readonly clientIpResolver: ClientIpResolver;
1264
1265
  };
1265
1266
 
1266
1267
  function buildExtraRouteHonoHandler(
@@ -1276,6 +1277,7 @@ function buildExtraRouteHonoHandler(
1276
1277
  registry: shared.registry,
1277
1278
  systemQuery: makeSystemQuery(c, shared.dispatcher, shared.clientIpResolver),
1278
1279
  write: makeAnonymousWrite(c, shared.dispatcher),
1280
+ clientIp: shared.clientIpResolver.resolve(clientIpSourceFromHonoContext(c)),
1279
1281
  });
1280
1282
  case ExtraRouteEntries.user:
1281
1283
  return async (c) => {
package/src/changes.json CHANGED
@@ -1,4 +1,64 @@
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
+ },
14
+ {
15
+ "version": "0.321.0",
16
+ "type": "improvement",
17
+ "title": "kumiko-schema gained --help/-h/help",
18
+ "detail": "Prints usage listing the available subcommands and exits 0.",
19
+ "migration": "No action needed."
20
+ },
21
+ {
22
+ "version": "0.321.0",
23
+ "type": "improvement",
24
+ "title": "parseTrustedProxyHopsEnv/TRUSTED_PROXY_HOPS_ENV factored out of runProdApp for runDevApp and consumer tooling to share",
25
+ "detail": "`packages/framework/src/api/client-ip.ts` gained\n`parseTrustedProxyHopsEnv(raw, context)` and the\n`TRUSTED_PROXY_HOPS_ENV = \"KUMIKO_TRUSTED_PROXY_HOPS\"` constant (both\nexported via the `api` barrel), moved out of `runProdApp`'s inline\nparsing. Same validation and error message as before.",
26
+ "migration": "No action needed; runProdApp's behavior and error message are unchanged."
27
+ },
28
+ {
29
+ "version": "0.321.0",
30
+ "type": "improvement",
31
+ "title": "AnonymousExtraRouteDeps gained clientIp, resolved by the server's trustedProxyHops-aware resolver",
32
+ "detail": "`AnonymousExtraRouteDeps.clientIp: string`, populated the same way as the\nhttpRoute path via `shared.clientIpResolver.resolve\n(clientIpSourceFromHonoContext(c))`. `ExtraRouteHonoHandlerDeps\n.clientIpResolver` is now required (its only caller always supplied it).",
33
+ "migration": "Anonymous extra routes that need the caller's IP should read\n`deps.clientIp` instead of reading `X-Forwarded-For` (or any other\nheader) themselves — the latter is attacker-controlled unless you also\nknow the exact trusted-hop count. Tests that build an\n`AnonymousExtraRouteDeps` literal by hand must add a `clientIp` string."
34
+ },
35
+ {
36
+ "version": "0.321.0",
37
+ "type": "improvement",
38
+ "title": "feature-ast resolves imported constants and registrar wrappers without building a TypeScript program",
39
+ "detail": "Imported `const` initializers (named imports, aliases, `export { X as Y }`,\nre-export chains, `export *`) and imported registrar-wrapper functions are\nresolved through `ts.resolveModuleName` plus a syntactic export walk.\nSame-file lookups walk enclosing block scopes instead of the file top level. Parse\noutput is unchanged; parsing a feature file drops from hundreds of\nmilliseconds to a few milliseconds."
40
+ },
41
+ {
42
+ "version": "0.321.0",
43
+ "type": "breaking",
44
+ "title": "webhook.send step auth secretRef renamed to secret; MultiStreamApplyContext gained an optional secrets field",
45
+ "detail": "`r.step.webhook.send`'s `auth` union renamed `secretRef` → `secret` (see\nthe step-dispatcher entry for the full rationale). `MultiStreamApplyContext`\n(and `createMultiStreamApplyContext`'s deps) gained an optional\n`secrets?: SecretsContext` field, mirroring `files`/`derivatives` — present\nwhen the app booted with the secrets feature, letting a saga/process-\nmanager MSP apply read a tenant secret with its own audit context. The\nserver's MSP consumer wiring now threads the boot-time `AppContext.secrets`\nthrough automatically; `rebuildMultiStreamProjection`'s rebuild context\ndeliberately does not carry `secrets` (rebuild must stay side-effect-free).",
46
+ "migration": "Rename `auth.secretRef` → `auth.secret` at every `r.step.webhook.send`\ncall site. No action needed for `MultiStreamApplyContext` consumers that\ndon't read `ctx.secrets` — the field is optional and additive."
47
+ },
48
+ {
49
+ "version": "0.321.0",
50
+ "type": "improvement",
51
+ "title": "New event-store primitive transferAggregateStreams for tenant-to-tenant aggregate moves",
52
+ "detail": "`transferAggregateStreams(db, { sourceTenantId, destinationTenantId,\naggregateType, aggregateIds, transferredBy })` (event-store/transfer.ts)\nmoves an aggregate's events, drops its snapshot, and carries its archive\nmarker from one tenant to another, then appends one\n`kumiko:system:aggregate.transferred` system event per moved aggregate\n(own fresh stream, destination tenant, payload carries aggregateType/\naggregateId/sourceTenantId/destinationTenantId). An id with no events\nunder the given tenant/aggregateType is left untouched and gets no event.\nBacked by db/queries/event-store-transfer.ts.",
53
+ "migration": "No action for existing consumers — this is a new, additive primitive."
54
+ },
55
+ {
56
+ "version": "0.321.0",
57
+ "type": "fix",
58
+ "title": "upgrade-cli now collects every package's changes.json, not just framework's",
59
+ "detail": "`findCoreChangelogFile(cwd): string | null` is replaced by\n`findPackageChangelogFiles(cwd): string[]`. In the framework repo itself\nit collects every `packages/<dir>/src/changes.json` except\n`packages/bundled-features` (bundled features are already collected per\nfeature dir). In a consumer repo it walks up to 10 levels looking for\n`node_modules/@cosmicdrift/*/src/changes.json`, skipping\n`kumiko-bundled-features`, deduping by package name (nearest\n`node_modules` wins) and by realpath (symlinked workspace copies).",
60
+ "migration": "No action needed — `kumiko upgrade` now surfaces more relevant breaking\nchanges than before, it never hides ones it previously showed."
61
+ },
2
62
  {
3
63
  "version": "0.320.0",
4
64
  "type": "breaking",
@@ -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));
@@ -0,0 +1,73 @@
1
+ // Raw SQL backing event-store/transfer.ts — the framework's own tenant-transfer
2
+ // primitive for streams. Kept in db/queries alongside the rest of the
3
+ // event-store's raw statements (see event-store.ts, event-store-admin.ts,
4
+ // backfill-pii.ts): the one place in the repo allowed to write tenant_id
5
+ // directly onto kumiko_events / kumiko_snapshots / kumiko_archived_streams.
6
+
7
+ import type { AnyDb } from "../query";
8
+ import { asRawClient } from "../query";
9
+
10
+ export type TransferEventRowsParams = {
11
+ readonly sourceTenantId: string;
12
+ readonly destinationTenantId: string;
13
+ readonly aggregateType: string;
14
+ readonly aggregateIds: readonly string[];
15
+ };
16
+
17
+ // Postgres' RETURNING clause has no DISTINCT keyword — dedupe the
18
+ // per-event-row hits in-process instead of relying on invalid SQL.
19
+ export async function transferEventRows(
20
+ db: AnyDb,
21
+ params: TransferEventRowsParams,
22
+ ): Promise<readonly string[]> {
23
+ const rows = (await asRawClient(db).unsafe(
24
+ `UPDATE "kumiko_events" SET "tenant_id" = $1 ` +
25
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4) ` +
26
+ `RETURNING "aggregate_id" AS id`,
27
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
28
+ )) as ReadonlyArray<{ id: string }>;
29
+ return [...new Set(rows.map((row) => row.id))];
30
+ }
31
+
32
+ export type DeleteSnapshotsForAggregatesParams = {
33
+ readonly tenantId: string;
34
+ readonly aggregateIds: readonly string[];
35
+ };
36
+
37
+ // Snapshots are a pure read-performance cache (event-store/snapshot.ts) —
38
+ // dropped rather than rewritten, forcing a full replay from the just-moved
39
+ // events on next read instead of carrying the snapshot's own generation
40
+ // bookkeeping across the tenant boundary.
41
+ export async function deleteSnapshotsForAggregates(
42
+ db: AnyDb,
43
+ params: DeleteSnapshotsForAggregatesParams,
44
+ ): Promise<void> {
45
+ await asRawClient(db).unsafe(
46
+ `DELETE FROM "kumiko_snapshots" WHERE "tenant_id" = $1 AND "aggregate_id" = ANY($2)`,
47
+ [params.tenantId, params.aggregateIds],
48
+ );
49
+ }
50
+
51
+ export type TransferArchivedStreamRowsParams = {
52
+ readonly sourceTenantId: string;
53
+ readonly destinationTenantId: string;
54
+ readonly aggregateType: string;
55
+ readonly aggregateIds: readonly string[];
56
+ };
57
+
58
+ // An archived stream's marker must move with it — leaving it behind would
59
+ // make the stream writable again the instant it lands in the destination
60
+ // tenant (archive.ts's isStreamArchived check is itself tenant-scoped).
61
+ // `aggregate_type` is part of the match, same as transferEventRows — an
62
+ // archived marker for a DIFFERENT aggregate type that happens to share the
63
+ // moved aggregate's id must stay in the source tenant.
64
+ export async function transferArchivedStreamRows(
65
+ db: AnyDb,
66
+ params: TransferArchivedStreamRowsParams,
67
+ ): Promise<void> {
68
+ await asRawClient(db).unsafe(
69
+ `UPDATE "kumiko_archived_streams" SET "tenant_id" = $1 ` +
70
+ `WHERE "tenant_id" = $2 AND "aggregate_type" = $3 AND "aggregate_id" = ANY($4)`,
71
+ [params.destinationTenantId, params.sourceTenantId, params.aggregateType, params.aggregateIds],
72
+ );
73
+ }
@@ -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", () => {
@@ -39,7 +39,7 @@ describe("buildWebhookSendStep", () => {
39
39
  method: "PUT",
40
40
  headers: { "X-Custom": "val" },
41
41
  body: { event: "test" },
42
- auth: { kind: "bearer", secretRef: "MY_SECRET" },
42
+ auth: { kind: "bearer", secret: "MY_SECRET" },
43
43
  retry: { times: 5, backoff: "linear" },
44
44
  mode: "deferred",
45
45
  });
@@ -125,7 +125,7 @@ describe("webhook.send run", () => {
125
125
 
126
126
  it("passes auth config through when provided", async () => {
127
127
  const stepDef = getStep("webhook.send");
128
- const auth = { kind: "bearer" as const, secretRef: "WEBHOOK_TOKEN" };
128
+ const auth = { kind: "bearer" as const, secret: "WEBHOOK_TOKEN" };
129
129
 
130
130
  await stepDef!.run({ url: "https://hooks.example/secured", mode: "deferred", auth }, mockCtx);
131
131
 
@@ -16,6 +16,7 @@ import { PAGED_QUERY_HANDLER_BRAND } from "./define-handler";
16
16
  import { buildInsertSchema, buildUpdateSchema } from "./schema-builder";
17
17
  import type {
18
18
  AccessRule,
19
+ AgentHandlerHints,
19
20
  DeriveContext,
20
21
  EntityDefinition,
21
22
  EscapeHatchDeclaration,
@@ -213,6 +214,23 @@ function isInTotalsMatchPair(entity: EntityDefinition, fieldName: string): boole
213
214
  );
214
215
  }
215
216
 
217
+ // delete on an entity without softDelete hard-purges the row — the agent
218
+ // risk gate only protects it if this handler resolves to "high".
219
+ function resolveEntityWriteAgentHints(
220
+ name: string,
221
+ verb: (typeof WRITE_VERBS)[number],
222
+ entity: EntityDefinition,
223
+ agent: AgentHandlerHints | undefined,
224
+ ): AgentHandlerHints | undefined {
225
+ if (verb !== "delete" || entity.softDelete) return agent;
226
+ if (agent?.risk !== undefined && agent.risk !== "high") {
227
+ throw new Error(
228
+ `"${name}": delete on an entity without softDelete is irreversible — agent.risk must be "high" (got "${agent.risk}").`,
229
+ );
230
+ }
231
+ return { ...agent, risk: "high" };
232
+ }
233
+
216
234
  function assertExcludableFields(
217
235
  name: string,
218
236
  verb: (typeof WRITE_VERBS)[number],
@@ -253,6 +271,7 @@ export function defineEntityWriteHandler(
253
271
  `"${name}": restore is only valid for entities declared with softDelete: true.`,
254
272
  );
255
273
  }
274
+ const agentHints = resolveEntityWriteAgentHints(name, verb, entity, options.agent);
256
275
  const excludedFields = options.excludeFields ?? [];
257
276
  assertExcludableFields(name, verb, entity, excludedFields);
258
277
 
@@ -378,7 +397,7 @@ export function defineEntityWriteHandler(
378
397
  handler,
379
398
  access: options.access,
380
399
  ...(options.description !== undefined && { description: options.description }),
381
- ...(options.agent !== undefined && { agent: options.agent }),
400
+ ...(agentHints !== undefined && { agent: agentHints }),
382
401
  };
383
402
  }
384
403
 
@@ -0,0 +1 @@
1
+ export { ORIGIN_VALUE as ALIASED } from "./origin";
@@ -0,0 +1 @@
1
+ export * from "./cycle-b";
@@ -0,0 +1 @@
1
+ export * from "./cycle-a";
@@ -0,0 +1,3 @@
1
+ import { ALIASED as RENAMED } from "./star-barrel";
2
+
3
+ export const marker: string = RENAMED;