@cosmicdrift/kumiko-framework 0.212.0 → 0.213.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cosmicdrift/kumiko-framework",
3
- "version": "0.212.0",
3
+ "version": "0.213.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>",
@@ -194,7 +194,7 @@
194
194
  "./package.json": "./package.json"
195
195
  },
196
196
  "dependencies": {
197
- "@cosmicdrift/kumiko-types": "0.212.0",
197
+ "@cosmicdrift/kumiko-types": "0.213.0",
198
198
  "bullmq": "^5.76.7",
199
199
  "bun-types": "^1.3.13",
200
200
  "hono": "^4.13.1",
@@ -210,7 +210,7 @@
210
210
  "zod": "^4.4.3"
211
211
  },
212
212
  "devDependencies": {
213
- "@cosmicdrift/kumiko-dispatcher-live": "0.212.0",
213
+ "@cosmicdrift/kumiko-dispatcher-live": "0.213.0",
214
214
  "bun-types": "^1.3.13",
215
215
  "pino-pretty": "^13.1.3"
216
216
  },
@@ -69,6 +69,51 @@ describe("runSchemaCli — no-DB paths", () => {
69
69
  expect(cap.err.join("\n")).not.toContain("kumiko-schema");
70
70
  });
71
71
 
72
+ test("generate --help prints usage, exits 0, writes no migration (framework#2191)", async () => {
73
+ writeSchemaFile(appCwd, "tbl_a");
74
+ const cap = captureOut();
75
+ const code = await runSchemaCli(["generate", "--help"], appCwd, cap.out);
76
+ expect(code).toBe(0);
77
+ expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
78
+ expect(cap.err).toHaveLength(0);
79
+ expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
80
+ });
81
+
82
+ test("generate -h prints usage, exits 0, writes no migration", async () => {
83
+ writeSchemaFile(appCwd, "tbl_a");
84
+ const cap = captureOut();
85
+ const code = await runSchemaCli(["generate", "-h"], appCwd, cap.out);
86
+ expect(code).toBe(0);
87
+ expect(cap.log.join("\n")).toContain("Usage: schema generate <name>");
88
+ expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
89
+ });
90
+
91
+ test("generate name starting with -- is rejected, exits 1, writes no migration", async () => {
92
+ writeSchemaFile(appCwd, "tbl_a");
93
+ const cap = captureOut();
94
+ const code = await runSchemaCli(["generate", "--evil"], appCwd, cap.out);
95
+ expect(code).toBe(1);
96
+ expect(cap.err.join("\n")).toContain('Invalid migration name "--evil"');
97
+ expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
98
+ });
99
+
100
+ test("generate name with path separators is rejected (path traversal)", async () => {
101
+ writeSchemaFile(appCwd, "tbl_a");
102
+ const cap = captureOut();
103
+ const code = await runSchemaCli(["generate", "../../evil"], appCwd, cap.out);
104
+ expect(code).toBe(1);
105
+ expect(cap.err.join("\n")).toContain("Invalid migration name");
106
+ expect(existsSync(join(appCwd, "kumiko/migrations"))).toBe(false);
107
+ });
108
+
109
+ test("generate with a hyphenated name still works", async () => {
110
+ writeSchemaFile(appCwd, "tbl_a");
111
+ const cap = captureOut();
112
+ const code = await runSchemaCli(["generate", "add-user-table"], appCwd, cap.out);
113
+ expect(code).toBe(0);
114
+ expect(existsSync(join(appCwd, "kumiko/migrations/0001_add-user-table.sql"))).toBe(true);
115
+ });
116
+
72
117
  test("generate with missing schema.ts exits 1", async () => {
73
118
  const cap = captureOut();
74
119
  const code = await runSchemaCli(["generate", "init"], appCwd, cap.out);
@@ -0,0 +1,96 @@
1
+ // Regression guard for the request-locale wiring: X-Locale header (or, when
2
+ // absent, Accept-Language) must reach ctx.locale on real HTTP calls — the
3
+ // AsyncLocalStorage plumbing in request-id-middleware.ts / dispatch-shared.ts
4
+ // can't be exercised via createTestDispatcher, which skips the HTTP layer
5
+ // entirely.
6
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7
+ import { defineFeature } from "@cosmicdrift/kumiko-framework/engine";
8
+ import {
9
+ createTestUser,
10
+ setupTestStack,
11
+ type TestStack,
12
+ } from "@cosmicdrift/kumiko-framework/stack";
13
+ import { z } from "zod";
14
+ import { LOCALE_HEADER_NAME } from "../api-constants";
15
+
16
+ const localeProbe = defineFeature("locale-probe", (r) => {
17
+ r.writeHandler(
18
+ "read-locale",
19
+ z.object({}),
20
+ async (_event, ctx) => ({ isSuccess: true, data: { locale: ctx.locale } }),
21
+ { access: { openToAll: true } },
22
+ );
23
+ });
24
+
25
+ async function readLocale(stack: TestStack, headers: Record<string, string>): Promise<string> {
26
+ const res = await stack.http.writeWithHeaders(
27
+ "locale-probe:write:read-locale",
28
+ {},
29
+ createTestUser({ id: 1 }),
30
+ headers,
31
+ );
32
+ const body = (await res.json()) as { data: { locale: string } };
33
+ return body.data.locale;
34
+ }
35
+
36
+ describe("ctx.locale resolution over real HTTP", () => {
37
+ let stack: TestStack;
38
+
39
+ beforeAll(async () => {
40
+ stack = await setupTestStack({ features: [localeProbe] });
41
+ });
42
+
43
+ afterAll(async () => {
44
+ await stack.cleanup();
45
+ });
46
+
47
+ test("X-Locale header wins", async () => {
48
+ const locale = await readLocale(stack, { [LOCALE_HEADER_NAME]: "de-AT" });
49
+ expect(locale).toBe("de-AT");
50
+ });
51
+
52
+ test("falls back to Accept-Language when X-Locale is absent", async () => {
53
+ const locale = await readLocale(stack, { "accept-language": "fr-FR,fr;q=0.9,en;q=0.8" });
54
+ expect(locale).toBe("fr-FR");
55
+ });
56
+
57
+ test("Accept-Language with only invalid tags falls back to the boot default", async () => {
58
+ const locale = await readLocale(stack, { "accept-language": "*, ;q=0.1" });
59
+ expect(locale).toBe("en");
60
+ });
61
+
62
+ test("a malformed/manipulated X-Locale header is rejected, not passed through", async () => {
63
+ const locale = await readLocale(stack, {
64
+ [LOCALE_HEADER_NAME]: "<script>alert(1)</script>",
65
+ });
66
+ expect(locale).toBe("en");
67
+ });
68
+
69
+ test("an invalid X-Locale header falls through to a valid Accept-Language instead of the default", async () => {
70
+ const locale = await readLocale(stack, {
71
+ [LOCALE_HEADER_NAME]: "not-a-real-locale-tag-way-too-long-to-be-valid",
72
+ "accept-language": "es-ES",
73
+ });
74
+ expect(locale).toBe("es-ES");
75
+ });
76
+ });
77
+
78
+ describe("ctx.locale falls back to the app's boot-configured defaultLocale", () => {
79
+ let stack: TestStack;
80
+
81
+ beforeAll(async () => {
82
+ stack = await setupTestStack({
83
+ features: [localeProbe],
84
+ extraContext: { defaultLocale: "ja" },
85
+ });
86
+ });
87
+
88
+ afterAll(async () => {
89
+ await stack.cleanup();
90
+ });
91
+
92
+ test("no header signal at all uses the app's defaultLocale, not the hardcoded default", async () => {
93
+ const locale = await readLocale(stack, {});
94
+ expect(locale).toBe("ja");
95
+ });
96
+ });
@@ -118,4 +118,11 @@ export const STATE_CHANGING_METHODS: ReadonlySet<string> = new Set([
118
118
  export const TENANT_HEADER_NAME = "X-Tenant";
119
119
  export const TENANT_COOKIE_NAME = "kumiko_tenant";
120
120
 
121
+ // Client-declared active UI locale (BCP-47). Read once per request in
122
+ // request-id-middleware.ts, before auth, so it reaches public routes
123
+ // (e.g. signup-request) too. Falls back to Accept-Language, then the app's
124
+ // boot-configured defaultLocale, when absent or malformed — see
125
+ // request-locale.ts and dispatch-shared.ts's ctx.locale resolution.
126
+ export const LOCALE_HEADER_NAME = "X-Locale";
127
+
121
128
  export type Route = (typeof Routes)[keyof typeof Routes];
@@ -33,6 +33,11 @@ export type RequestContextData = {
33
33
  // Raw User-Agent header — audit trails (download tokens, GDPR export
34
34
  // access) want it alongside `ip`. Undefined for non-HTTP entry points.
35
35
  readonly userAgent?: string;
36
+ // Client-declared active UI locale (BCP-47), resolved once from
37
+ // X-Locale / Accept-Language by requestIdMiddleware — see
38
+ // request-locale.ts. Undefined when neither header carried a valid tag;
39
+ // callers fall back further (dispatch-shared.ts's ctx.locale chain).
40
+ readonly locale?: string;
36
41
  };
37
42
 
38
43
  const storage = new AsyncLocalStorage<RequestContextData>();
@@ -1,4 +1,6 @@
1
1
  import type { Context, Next } from "hono";
2
+ import { resolveHeaderLocale } from "../i18n/request-locale";
3
+ import { LOCALE_HEADER_NAME } from "./api-constants";
2
4
  import { type RequestContextData, requestContext } from "./request-context";
3
5
 
4
6
  const REQUEST_ID_HEADER = "X-Request-ID";
@@ -45,6 +47,13 @@ export function buildRequestContextData(c: Context): RequestContextData {
45
47
  const xff = c.req.header("x-forwarded-for");
46
48
  const ip = xff?.split(",")[0]?.trim();
47
49
  const userAgent = c.req.header("user-agent");
50
+ // Runs before auth-middleware, so this reaches public routes too (e.g.
51
+ // signup-request) — that's the whole point: the active UI locale must
52
+ // survive to anonymous callers, not just authenticated ones.
53
+ const locale = resolveHeaderLocale({
54
+ headerLocale: c.req.header(LOCALE_HEADER_NAME),
55
+ acceptLanguage: c.req.header("accept-language"),
56
+ });
48
57
 
49
58
  return {
50
59
  requestId,
@@ -52,6 +61,7 @@ export function buildRequestContextData(c: Context): RequestContextData {
52
61
  ...(signal ? { signal } : {}),
53
62
  ...(ip && ip.length > 0 ? { ip } : {}),
54
63
  ...(userAgent !== undefined ? { userAgent } : {}),
64
+ ...(locale !== undefined ? { locale } : {}),
55
65
  };
56
66
  }
57
67
 
@@ -28,6 +28,7 @@ export {
28
28
  selectMany,
29
29
  transaction,
30
30
  type UpsertOnConflictOptions,
31
+ unsafeReadRetrying,
31
32
  updateMany,
32
33
  upsertByPk,
33
34
  upsertOnConflict,
@@ -685,7 +685,10 @@ function isClosedConnectionError(err: unknown): boolean {
685
685
  );
686
686
  }
687
687
 
688
- async function unsafeRead<TRow>(
688
+ // Exported so raw-SQL query modules outside bun-db (e.g. bundled-features'
689
+ // db/queries/*.ts) can opt into the same #1163 retry instead of calling
690
+ // asRawClient(db).unsafe(...) directly and losing it.
691
+ export async function unsafeReadRetrying<TRow>(
689
692
  db: AnyDb,
690
693
  sqlText: string,
691
694
  params: readonly unknown[],
@@ -736,7 +739,7 @@ export async function selectMany<TRow = any>(
736
739
  }
737
740
  sqlText += ` LIMIT ${options.limit}`;
738
741
  }
739
- const raw = (await unsafeRead(db, sqlText, values)) as readonly Record<string, unknown>[];
742
+ const raw = (await unsafeReadRetrying(db, sqlText, values)) as readonly Record<string, unknown>[];
740
743
  return coerceRows(raw, info) as readonly TRow[];
741
744
  }
742
745
 
@@ -970,7 +973,7 @@ export async function countWhere(
970
973
  sqlText += ` WHERE ${w.sqlText}`;
971
974
  values = w.values;
972
975
  }
973
- const rows = (await unsafeRead(db, sqlText, values)) as readonly { count: number }[];
976
+ const rows = (await unsafeReadRetrying(db, sqlText, values)) as readonly { count: number }[];
974
977
  return rows[0]?.count ?? 0;
975
978
  }
976
979
 
package/src/i18n/index.ts CHANGED
@@ -1,6 +1,12 @@
1
1
  import type { Registry, TranslationKeys } from "../engine/types";
2
2
 
3
3
  export { hasMailTranslations, mailT, registerMailTranslations } from "./mail-registry";
4
+ export {
5
+ DEFAULT_LOCALE,
6
+ isValidLocaleTag,
7
+ pickAcceptLanguage,
8
+ resolveHeaderLocale,
9
+ } from "./request-locale";
4
10
 
5
11
  export type I18nOptions = {
6
12
  defaultLocale: string;
@@ -0,0 +1,63 @@
1
+ // Request-scoped locale resolution — the language counterpart to ctx.tz
2
+ // (time/tz-context.ts). Unlike TzContext there's no closed catalog to
3
+ // validate against here: mail-registry.ts is a dynamic, per-package
4
+ // registry that locale packages (kumiko-locale-de, ...) populate at import
5
+ // time, so "known locale" isn't a fixed enum. Validation checks
6
+ // well-formedness instead — a header is user input either way.
7
+
8
+ export const DEFAULT_LOCALE = "en";
9
+
10
+ // Loose BCP-47: 2-3 letter primary subtag, then 1-8 more alphanumeric
11
+ // subtags separated by "-" (region/script/variant/extension). Rejects
12
+ // control characters, oversized values, and header-injection shapes
13
+ // without implementing a full RFC 5646 parser.
14
+ const LOCALE_TAG_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
15
+ const MAX_LOCALE_TAG_LENGTH = 35;
16
+
17
+ export function isValidLocaleTag(value: string): boolean {
18
+ return value.length <= MAX_LOCALE_TAG_LENGTH && LOCALE_TAG_RE.test(value);
19
+ }
20
+
21
+ type AcceptLanguageCandidate = { readonly tag: string; readonly q: number; readonly index: number };
22
+
23
+ /**
24
+ * Picks the best well-formed tag from an Accept-Language header (RFC 9110
25
+ * §12.5.4): parses "tag;q=x" pairs, sorts by q descending (header order
26
+ * breaks ties), and returns the first tag that passes isValidLocaleTag.
27
+ * Tags with q=0 (explicitly excluded) are dropped entirely.
28
+ */
29
+ export function pickAcceptLanguage(header: string | undefined): string | undefined {
30
+ if (header === undefined || header.length === 0) return undefined;
31
+
32
+ const candidates: AcceptLanguageCandidate[] = header
33
+ .split(",")
34
+ .map((part, index): AcceptLanguageCandidate | undefined => {
35
+ const [tagRaw, ...params] = part.trim().split(";");
36
+ const tag = tagRaw?.trim();
37
+ if (tag === undefined || tag.length === 0 || !isValidLocaleTag(tag)) return undefined;
38
+ const qParam = params.find((p) => p.trim().startsWith("q="));
39
+ const parsedQ = qParam !== undefined ? Number(qParam.trim().slice(2)) : 1;
40
+ const q = Number.isFinite(parsedQ) ? parsedQ : 0;
41
+ return { tag, q, index };
42
+ })
43
+ .filter((c): c is AcceptLanguageCandidate => c !== undefined && c.q > 0)
44
+ .sort((a, b) => b.q - a.q || a.index - b.index);
45
+
46
+ return candidates[0]?.tag;
47
+ }
48
+
49
+ /**
50
+ * Request-layer resolution: an explicit, validated X-Locale header wins;
51
+ * otherwise the best Accept-Language tag; otherwise undefined — no signal
52
+ * from this request, callers fall back further (the app's boot-configured
53
+ * defaultLocale, then DEFAULT_LOCALE — see dispatch-shared.ts).
54
+ */
55
+ export function resolveHeaderLocale(options: {
56
+ readonly headerLocale?: string;
57
+ readonly acceptLanguage?: string;
58
+ }): string | undefined {
59
+ if (options.headerLocale !== undefined && isValidLocaleTag(options.headerLocale)) {
60
+ return options.headerLocale;
61
+ }
62
+ return pickAcceptLanguage(options.acceptLanguage);
63
+ }
@@ -64,4 +64,35 @@ describe("distributed lock", () => {
64
64
  const token = await lock.acquire("test-lock-5");
65
65
  expect(token).not.toBeNull();
66
66
  });
67
+
68
+ test("renew extends the TTL for the owning token", async () => {
69
+ const lock = createDistributedLock(testRedis.redis);
70
+ const token = await lock.acquire("test-lock-6", { ttlSeconds: 1 });
71
+ if (!token) throw new Error("expected token");
72
+
73
+ const renewed = await lock.renew("test-lock-6", token, 5);
74
+ expect(renewed).toBe(true);
75
+
76
+ // Past the original 1s TTL, but renew pushed it out to 5s — still held.
77
+ await new Promise((r) => setTimeout(r, 1200));
78
+ expect(await lock.acquire("test-lock-6")).toBeNull();
79
+ });
80
+
81
+ test("renew with wrong token fails and does not extend the TTL", async () => {
82
+ const lock = createDistributedLock(testRedis.redis);
83
+ await lock.acquire("test-lock-7", { ttlSeconds: 1 });
84
+
85
+ const renewed = await lock.renew("test-lock-7", "wrong-token", 5);
86
+ expect(renewed).toBe(false);
87
+
88
+ // The original 1s TTL still applies — the wrong-token renew didn't touch it.
89
+ await new Promise((r) => setTimeout(r, 1100));
90
+ expect(await lock.acquire("test-lock-7")).not.toBeNull();
91
+ });
92
+
93
+ test("renew on an expired/absent key fails", async () => {
94
+ const lock = createDistributedLock(testRedis.redis);
95
+ const renewed = await lock.renew("test-lock-8-never-acquired", "some-token", 5);
96
+ expect(renewed).toBe(false);
97
+ });
67
98
  });
@@ -51,6 +51,7 @@ import {
51
51
  } from "../event-store/snapshot";
52
52
  import { upcastStoredEvent, upcastStoredEvents } from "../event-store/upcaster";
53
53
  import { createFileContext } from "../files/file-handle";
54
+ import { DEFAULT_LOCALE } from "../i18n/request-locale";
54
55
  import {
55
56
  createMetricsHandle,
56
57
  createNoopMetricsHandle,
@@ -620,6 +621,12 @@ export async function buildHandlerContext(
620
621
  ...(safeUserTz !== undefined && { user: safeUserTz }),
621
622
  });
622
623
 
624
+ // ctx.locale — request-layer signal (X-Locale header → Accept-Language,
625
+ // resolved once at the HTTP boundary by request-id-middleware.ts) wins;
626
+ // falls back to the app's boot-configured defaultLocale, then
627
+ // DEFAULT_LOCALE. Mirrors ctx.tz's Request → Boot-Default chain above.
628
+ const locale = reqCtx?.locale ?? context.defaultLocale ?? DEFAULT_LOCALE;
629
+
623
630
  return {
624
631
  ...context,
625
632
  registry,
@@ -647,6 +654,7 @@ export async function buildHandlerContext(
647
654
  metrics,
648
655
  metricsFor,
649
656
  tz,
657
+ locale,
650
658
  // Cancellation signal flows from the HTTP middleware via
651
659
  // requestContext. Conditional spread so non-HTTP entry-points
652
660
  // (jobs, dispatcher MSP-applies) don't get a phantom signal that
@@ -5,6 +5,11 @@ import { RedisKeys } from "./redis-keys";
5
5
  export type DistributedLock = {
6
6
  acquire(key: string, options?: { ttlSeconds?: number }): Promise<string | null>;
7
7
  release(key: string, token: string): Promise<boolean>;
8
+ /** Extends the TTL of a lock this caller still holds (token matches).
9
+ * Returns false when the token doesn't match — expired and re-claimed
10
+ * by someone else, or never held — the caller must treat that as
11
+ * "no longer the owner" and stop renewing, not retry. */
12
+ renew(key: string, token: string, ttlSeconds: number): Promise<boolean>;
8
13
  };
9
14
 
10
15
  export function createDistributedLock(
@@ -20,6 +25,16 @@ export function createDistributedLock(
20
25
  end
21
26
  `;
22
27
 
28
+ // Lua script for atomic check-and-extend — same ownership check as release,
29
+ // but resets the TTL instead of deleting the key.
30
+ const renewScript = `
31
+ if redis.call("get", KEYS[1]) == ARGV[1] then
32
+ return redis.call("expire", KEYS[1], ARGV[2])
33
+ else
34
+ return 0
35
+ end
36
+ `;
37
+
23
38
  return {
24
39
  async acquire(key, options = {}) {
25
40
  const ttl = options.ttlSeconds ?? 30;
@@ -33,5 +48,16 @@ export function createDistributedLock(
33
48
  const result = (await redis.eval(releaseScript, 1, `${prefix}${key}`, token)) as number; // @cast-boundary db-operator
34
49
  return result === 1;
35
50
  },
51
+
52
+ async renew(key, token, ttlSeconds) {
53
+ const result = (await redis.eval(
54
+ renewScript,
55
+ 1,
56
+ `${prefix}${key}`,
57
+ token,
58
+ String(ttlSeconds),
59
+ )) as number; // @cast-boundary db-operator
60
+ return result === 1;
61
+ },
36
62
  };
37
63
  }
package/src/schema-cli.ts CHANGED
@@ -143,10 +143,21 @@ export async function runSchemaCli(
143
143
  switch (sub) {
144
144
  case "generate": {
145
145
  const name = argv[1];
146
+ if (name === "--help" || name === "-h") {
147
+ out.log(" Usage: schema generate <name>");
148
+ return 0;
149
+ }
146
150
  if (!name) {
147
151
  out.err(" Usage: schema generate <name>");
148
152
  return 1;
149
153
  }
154
+ // name lands unescaped in `${seq}_${name}.sql` (generateMigration) — this
155
+ // allowlist blocks flag-like names and path traversal (`../../x`).
156
+ if (name.startsWith("-") || !/^[A-Za-z0-9_-]+$/.test(name)) {
157
+ out.err(` Invalid migration name "${name}" — use letters, digits, "-", "_" only.`);
158
+ out.err(" Usage: schema generate <name>");
159
+ return 1;
160
+ }
150
161
  if (!existsSync(schemaFile)) {
151
162
  out.err(` ${schemaFile} fehlt.`);
152
163
  out.err(" App-Convention: kumiko/schema.ts mit");
@@ -1,10 +1,10 @@
1
1
  // @runtime runtime
2
2
  //
3
- // bridgeStub liefert eine HandlerContext-Shape mit throw-on-use Bridge-Methods
4
- // (ctx.query/write/loadAggregate/...). Wird von Test-Code UND Production-
5
- // Services genutzt (delivery-service nutzt es um cross-feature notify-Calls
6
- // ohne echten Dispatcher zu fahren). Daher runtime-Klassifizierung trotz
7
- // Wohnsitz unter `testing/` — keine vitest-Imports, keine Test-Side-Effects.
3
+ // bridgeStub hands back a HandlerContext shape with throw-on-use bridge
4
+ // methods (ctx.query/write/loadAggregate/...). Used by both test code AND
5
+ // production services (delivery-service uses it to run cross-feature notify
6
+ // calls without a real dispatcher). Hence the runtime classification despite
7
+ // living under `testing/` — no vitest imports, no test side-effects.
8
8
  import type {
9
9
  AppendEventArgs,
10
10
  FetchForWritingArgs,
@@ -12,6 +12,7 @@ import type {
12
12
  SessionUser,
13
13
  WriteResult,
14
14
  } from "../engine/types";
15
+ import { DEFAULT_LOCALE } from "../i18n/request-locale";
15
16
  import { createNoopMetricsHandle, getFallbackTracer } from "../observability";
16
17
  import { createTzContext } from "../time";
17
18
 
@@ -61,13 +62,14 @@ export function bridgeStub(opts?: {
61
62
  | "metricsFor"
62
63
  | "tracer"
63
64
  | "tz"
65
+ | "locale"
64
66
  | "user"
65
67
  > {
66
- // ctx.user ist Convenience-Alias zu event.user (siehe HandlerContext-
67
- // Doku). Caller-Code erwartet das Feld; bridgeStub liefert es als
68
- // Stub mit den Anonymous-Default-Werten wenn kein User explizit
69
- // übergeben wird. Test-Code mit Identity-Bezug übergibt seinen
70
- // SessionUser hier und bekommt ihn am ctx zurück.
68
+ // ctx.user is a convenience alias for event.user (see HandlerContext
69
+ // docs). Caller code expects the field; bridgeStub hands back a stub with
70
+ // anonymous default values when no user is passed explicitly. Test code
71
+ // that cares about identity passes its own SessionUser here and gets it
72
+ // back on ctx.
71
73
  const stubUser: SessionUser = opts?.user ?? {
72
74
  id: "00000000-0000-0000-0000-000000000000",
73
75
  tenantId: "00000000-0000-0000-0000-000000000000" as SessionUser["tenantId"], // @cast-boundary engine-bridge
@@ -122,8 +124,11 @@ export function bridgeStub(opts?: {
122
124
  metrics: createNoopMetricsHandle(),
123
125
  metricsFor: () => createNoopMetricsHandle(),
124
126
  tracer: noopTracer,
125
- // Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
126
- // ohne dass es ein "Bridge"-Konzept ist. Default UTC.
127
+ // Real TzContext, not notAvailable — test code uses ctx.tz routinely,
128
+ // it isn't a "bridge" concept. Defaults to UTC.
127
129
  tz: createTzContext(),
130
+ // Same reasoning as tz above — ctx.locale is always-present, not a
131
+ // bridge method. Defaults to DEFAULT_LOCALE.
132
+ locale: DEFAULT_LOCALE,
128
133
  };
129
134
  }