okengine 0.5.0 → 0.6.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 (94) hide show
  1. package/package.json +2 -1
  2. package/site/content/docs/elements/ai.mdx +82 -1
  3. package/site/content/docs/elements/channel.mdx +6 -1
  4. package/site/content/docs/elements/flow.mdx +20 -17
  5. package/site/content/docs/get-started/why.mdx +10 -10
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/headers.mdx +10 -10
  8. package/site/content/docs/plugins/magic-link.mdx +27 -21
  9. package/site/content/docs/plugins/passkey.mdx +36 -24
  10. package/site/content/docs/plugins/two-factor.mdx +2 -1
  11. package/site/content/docs/reference/configuration.mdx +7 -0
  12. package/site/content/docs/reference/environment-variables.mdx +10 -5
  13. package/site/content/docs/reference/errors.mdx +14 -0
  14. package/site/content/docs/reference/fx.mdx +68 -16
  15. package/site/content/docs/reference/i18n.mdx +313 -0
  16. package/site/content/docs/reference/index.mdx +6 -1
  17. package/site/content/docs/reference/meta.json +1 -0
  18. package/site/content/docs/reference/plugins.mdx +1 -0
  19. package/src/auth/auth.test.ts +3 -0
  20. package/src/auth/bindings.ts +1 -1
  21. package/src/auth/constant-time.ts +22 -0
  22. package/src/auth/index.ts +2 -0
  23. package/src/auth/method-context.ts +12 -2
  24. package/src/cli/competitor-mention-removal.test.ts +3 -3
  25. package/src/compiler/aot.test.ts +16 -13
  26. package/src/compiler/effects-infer.ts +46 -0
  27. package/src/console/server/ai.test.ts +34 -5
  28. package/src/docker/compose.ts +9 -0
  29. package/src/docker/docker.test.ts +39 -0
  30. package/src/docker/index.ts +11 -1
  31. package/src/docker/recipes/index.ts +3 -1
  32. package/src/docker/recipes/ollama.ts +43 -0
  33. package/src/docker/stack-id.ts +2 -0
  34. package/src/drivers/ai-mock.ts +60 -0
  35. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  36. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  37. package/src/drivers/ai-ollama.ts +327 -0
  38. package/src/drivers/ai-openai-compatible.ts +211 -21
  39. package/src/drivers/ai-providers.test.ts +179 -2
  40. package/src/drivers/ai-stream.test.ts +195 -0
  41. package/src/drivers/ai-types.ts +42 -1
  42. package/src/drivers/channel-smtp.ts +8 -2
  43. package/src/drivers/index.ts +21 -1
  44. package/src/drivers/ollama.ts +14 -0
  45. package/src/elements/ai/rate.test.ts +53 -0
  46. package/src/elements/ai/rate.ts +66 -0
  47. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  48. package/src/elements/ai/runtime.ts +330 -100
  49. package/src/elements/ai/tools.test.ts +99 -0
  50. package/src/elements/ai.test.ts +26 -2
  51. package/src/elements/ai.ts +10 -1
  52. package/src/i18n/catalogs/ar.ts +67 -0
  53. package/src/i18n/catalogs/en.ts +68 -0
  54. package/src/i18n/failure-message.test.ts +56 -0
  55. package/src/i18n/failure-message.ts +93 -0
  56. package/src/i18n/format.ts +67 -0
  57. package/src/i18n/index.ts +57 -0
  58. package/src/i18n/locale-context.ts +48 -0
  59. package/src/i18n/messages.test.ts +173 -0
  60. package/src/i18n/messages.ts +169 -0
  61. package/src/i18n/types.ts +90 -0
  62. package/src/index.ts +26 -0
  63. package/src/kernel/app.ts +92 -2
  64. package/src/kernel/boot-bind/ai.test.ts +60 -0
  65. package/src/kernel/boot-bind/ai.ts +125 -2
  66. package/src/kernel/boot.test.ts +4 -3
  67. package/src/kernel/boot.ts +1 -1
  68. package/src/kernel/errors.ts +56 -5
  69. package/src/kernel/fx.test.ts +27 -0
  70. package/src/kernel/fx.ts +74 -18
  71. package/src/kernel/pipeline.test.ts +4 -0
  72. package/src/kernel/pipeline.ts +1 -1
  73. package/src/kernel/plugin.ts +16 -0
  74. package/src/kernel/registry.ts +15 -0
  75. package/src/plugins/auth/shared.ts +5 -1
  76. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  77. package/src/plugins/auth-methods.security.test.ts +764 -0
  78. package/src/plugins/compression.ts +1 -1
  79. package/src/plugins/config-source.test.ts +11 -11
  80. package/src/plugins/config-source.ts +2 -2
  81. package/src/plugins/cors.ts +1 -1
  82. package/src/plugins/email-otp.ts +54 -1
  83. package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
  84. package/src/plugins/headers.ts +240 -41
  85. package/src/plugins/index.ts +27 -5
  86. package/src/plugins/magic-link.ts +63 -3
  87. package/src/plugins/passkey-webauthn.ts +217 -0
  88. package/src/plugins/passkey.ts +99 -33
  89. package/src/plugins/response-headers.ts +54 -0
  90. package/src/plugins/two-factor.ts +6 -2
  91. package/src/plugins/username-policy.test.ts +302 -0
  92. package/src/plugins/username.ts +290 -9
  93. package/src/release/measure.ts +8 -1
  94. package/src/plugins/security-headers.ts +0 -255
@@ -0,0 +1,173 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { clearMessageFormatCache, formatMessage } from "./format.ts";
3
+ import {
4
+ clearMessageCatalogs,
5
+ defineLocale,
6
+ defineMessages,
7
+ flattenMessages,
8
+ getMessageCatalogs,
9
+ matchConfiguredLocale,
10
+ translate,
11
+ } from "./messages.ts";
12
+ import type { FlattenKeys, MessagesFor } from "./types.ts";
13
+
14
+ describe("flattenMessages", () => {
15
+ test("flattens nested trees with dot keys", () => {
16
+ expect(
17
+ flattenMessages({
18
+ errors: { notFound: "Missing" },
19
+ hello: "Hi",
20
+ }),
21
+ ).toEqual({
22
+ "errors.notFound": "Missing",
23
+ hello: "Hi",
24
+ });
25
+ });
26
+ });
27
+
28
+ describe("defineLocale / getMessageCatalogs", () => {
29
+ test("registers overlays that win over built-ins", () => {
30
+ clearMessageCatalogs();
31
+ defineLocale("en", { greeting: "Hello, {name}" });
32
+ defineLocale("ar", { greeting: "مرحباً، {name}" });
33
+ const catalogs = getMessageCatalogs();
34
+ expect(catalogs.en?.greeting).toBe("Hello, {name}");
35
+ expect(catalogs.ar?.greeting).toBe("مرحباً، {name}");
36
+ // Built-ins remain available under the same locale.
37
+ expect(catalogs.en?.["errors.Unauthorized"]).toBe("Authentication required.");
38
+ defineLocale("en", { greeting: "Hi, {name}" });
39
+ expect(getMessageCatalogs().en?.greeting).toBe("Hi, {name}");
40
+ clearMessageCatalogs();
41
+ });
42
+ });
43
+
44
+ describe("formatMessage — ICU", () => {
45
+ test("interpolates simple placeholders", () => {
46
+ clearMessageFormatCache();
47
+ expect(formatMessage("Hello, {name}", "en", { name: "Ada" })).toBe("Hello, Ada");
48
+ });
49
+
50
+ test("cardinal plurals", () => {
51
+ clearMessageFormatCache();
52
+ const msg = "{count, plural, =0 {no items} one {# item} other {# items}}";
53
+ expect(formatMessage(msg, "en", { count: 0 })).toBe("no items");
54
+ expect(formatMessage(msg, "en", { count: 1 })).toBe("1 item");
55
+ expect(formatMessage(msg, "en", { count: 5 })).toBe("5 items");
56
+ });
57
+
58
+ test("ordinal plurals via selectordinal", () => {
59
+ clearMessageFormatCache();
60
+ const msg = "You finished {place, selectordinal, one {#st} two {#nd} few {#rd} other {#th}}!";
61
+ expect(formatMessage(msg, "en", { place: 1 })).toBe("You finished 1st!");
62
+ expect(formatMessage(msg, "en", { place: 2 })).toBe("You finished 2nd!");
63
+ expect(formatMessage(msg, "en", { place: 3 })).toBe("You finished 3rd!");
64
+ expect(formatMessage(msg, "en", { place: 11 })).toBe("You finished 11th!");
65
+ });
66
+
67
+ test("enum select", () => {
68
+ clearMessageFormatCache();
69
+ const msg = "{status, select, online {Online} offline {Offline} other {Unknown}}";
70
+ expect(formatMessage(msg, "en", { status: "online" })).toBe("Online");
71
+ expect(formatMessage(msg, "en", { status: "offline" })).toBe("Offline");
72
+ expect(formatMessage(msg, "en", { status: "away" })).toBe("Unknown");
73
+ });
74
+
75
+ test("rich text tags via function values", () => {
76
+ clearMessageFormatCache();
77
+ const msg = "Read <docs>the docs</docs>.";
78
+ expect(
79
+ formatMessage(msg, "en", {
80
+ docs: (chunks) => `[${chunks.join("")}]`,
81
+ }),
82
+ ).toBe("Read [the docs].");
83
+ });
84
+
85
+ test("Arabic plural sample", () => {
86
+ clearMessageFormatCache();
87
+ const msg =
88
+ "{count, plural, zero {لا عناصر} one {عنصر واحد} two {عنصران} few {# عناصر} many {# عنصراً} other {# عنصر}}";
89
+ expect(formatMessage(msg, "ar", { count: 0 })).toBe("لا عناصر");
90
+ expect(formatMessage(msg, "ar", { count: 1 })).toBe("عنصر واحد");
91
+ expect(formatMessage(msg, "ar", { count: 2 })).toBe("عنصران");
92
+ });
93
+ });
94
+
95
+ describe("translate", () => {
96
+ const catalogs = {
97
+ en: {
98
+ "errors.notFound": "Not found",
99
+ greeting: "Hello, {name}",
100
+ items: "{count, plural, one {# item} other {# items}}",
101
+ },
102
+ ar: {
103
+ greeting: "مرحباً، {name}",
104
+ },
105
+ };
106
+
107
+ test("uses locale, then default, then key", () => {
108
+ expect(
109
+ translate({
110
+ locale: "ar",
111
+ defaultLocale: "en",
112
+ catalogs,
113
+ key: "greeting",
114
+ values: { name: "Ada" },
115
+ }),
116
+ ).toBe("مرحباً، Ada");
117
+ expect(
118
+ translate({
119
+ locale: "ar",
120
+ defaultLocale: "en",
121
+ catalogs,
122
+ key: "errors.notFound",
123
+ }),
124
+ ).toBe("Not found");
125
+ expect(
126
+ translate({
127
+ locale: "en",
128
+ defaultLocale: "en",
129
+ catalogs,
130
+ key: "items",
131
+ values: { count: 3 },
132
+ }),
133
+ ).toBe("3 items");
134
+ expect(
135
+ translate({
136
+ locale: "ar",
137
+ defaultLocale: "en",
138
+ catalogs,
139
+ key: "missing",
140
+ values: { x: 1 },
141
+ }),
142
+ ).toBe('missing:{"x":1}');
143
+ });
144
+ });
145
+
146
+ describe("matchConfiguredLocale", () => {
147
+ test("matches exact, base tag, then default", () => {
148
+ const locales = ["en", "ar"] as const;
149
+ expect(matchConfiguredLocale("ar", locales, "en")).toBe("ar");
150
+ expect(matchConfiguredLocale("ar-SA", locales, "en")).toBe("ar");
151
+ expect(matchConfiguredLocale("fr", locales, "en")).toBe("en");
152
+ expect(matchConfiguredLocale(undefined, locales, "en")).toBe("en");
153
+ });
154
+ });
155
+
156
+ describe("type helpers (compile-time shapes)", () => {
157
+ test("defineMessages preserves tree; MessagesFor aligns locales", () => {
158
+ const en = defineMessages({
159
+ errors: { notFound: "Not found" },
160
+ greeting: "Hello, {name}",
161
+ });
162
+ const ar = {
163
+ errors: { notFound: "غير موجود" },
164
+ greeting: "مرحباً، {name}",
165
+ } satisfies MessagesFor<typeof en>;
166
+
167
+ type Keys = FlattenKeys<typeof en>;
168
+ const key: Keys = "errors.notFound";
169
+ expect(en.errors.notFound).toBe("Not found");
170
+ expect(ar.greeting).toContain("{name}");
171
+ expect(key).toBe("errors.notFound");
172
+ });
173
+ });
@@ -0,0 +1,169 @@
1
+ /**
2
+ * App message catalogs for {@link Fx.t}.
3
+ *
4
+ * Locales register via {@link defineLocale}; boot wires catalogs into `fx`
5
+ * with the active request locale and the `i18n.default` fallback from
6
+ * `oke.config.ts`. Messages use ICU MessageFormat syntax.
7
+ *
8
+ * Built-in EN/AR catalogs (framework OKE codes + typed failures) are always
9
+ * present; app keys override built-ins for the same locale/key.
10
+ */
11
+
12
+ import { builtinAr } from "./catalogs/ar.ts";
13
+ import { builtinEn } from "./catalogs/en.ts";
14
+ import { formatMessage } from "./format.ts";
15
+ import type { MessageTree, MessageValues } from "./types.ts";
16
+
17
+ export type { MessageTree, MessageValues } from "./types.ts";
18
+
19
+ /** Flat key → message map after {@link flattenMessages}. */
20
+ export type MessageCatalog = Readonly<Record<string, string>>;
21
+
22
+ /** All registered locale catalogs (locale → flat map). */
23
+ export type MessageCatalogs = Readonly<Record<string, MessageCatalog>>;
24
+
25
+ /**
26
+ * Flatten a nested message tree into dot-separated keys.
27
+ *
28
+ * @param tree - Nested or flat messages
29
+ * @param prefix - Key prefix for recursion
30
+ */
31
+ export function flattenMessages(tree: MessageTree, prefix = ""): Record<string, string> {
32
+ const out: Record<string, string> = {};
33
+ for (const [key, value] of Object.entries(tree)) {
34
+ const path = prefix ? `${prefix}.${key}` : key;
35
+ if (typeof value === "string") {
36
+ out[path] = value;
37
+ } else {
38
+ Object.assign(out, flattenMessages(value, path));
39
+ }
40
+ }
41
+ return out;
42
+ }
43
+
44
+ /** App-registered overlays (win over built-ins per key). */
45
+ const registry = new Map<string, Record<string, string>>();
46
+
47
+ /** Built-in EN/AR catalogs (framework + typed failures). */
48
+ const builtins: Readonly<Record<string, MessageCatalog>> = {
49
+ en: flattenMessages(builtinEn as unknown as MessageTree),
50
+ ar: flattenMessages(builtinAr as unknown as MessageTree),
51
+ };
52
+
53
+ /**
54
+ * Identity helper that preserves a `const` message tree for typing.
55
+ *
56
+ * @param messages - Canonical catalog (usually English)
57
+ */
58
+ export function defineMessages<const T extends MessageTree>(messages: T): T {
59
+ return messages;
60
+ }
61
+
62
+ /**
63
+ * Register (or replace) messages for a locale.
64
+ *
65
+ * Side-effect import from `src/locales/<locale>.ts` before boot.
66
+ * Prefer `as const` / {@link defineMessages} on the default locale, then
67
+ * `satisfies MessagesFor<typeof en>` on translations so keys stay aligned.
68
+ *
69
+ * @param locale - BCP 47 locale tag (e.g. `"en"`, `"ar"`)
70
+ * @param messages - Nested or flat ICU message tree
71
+ */
72
+ export function defineLocale<const T extends MessageTree>(locale: string, messages: T): T {
73
+ const tag = locale.trim();
74
+ if (!tag) throw new Error("defineLocale: locale must be non-empty");
75
+ registry.set(tag, flattenMessages(messages));
76
+ return messages;
77
+ }
78
+
79
+ /**
80
+ * Snapshot of every catalog — built-ins merged under app overlays.
81
+ */
82
+ export function getMessageCatalogs(): MessageCatalogs {
83
+ const locales = new Set([...Object.keys(builtins), ...registry.keys()]);
84
+ const out: Record<string, MessageCatalog> = {};
85
+ for (const locale of locales) {
86
+ out[locale] = { ...(builtins[locale] ?? {}), ...(registry.get(locale) ?? {}) };
87
+ }
88
+ return out;
89
+ }
90
+
91
+ /**
92
+ * Clear app-registered catalogs (tests only). Built-ins remain.
93
+ */
94
+ export function clearMessageCatalogs(): void {
95
+ registry.clear();
96
+ }
97
+
98
+ /**
99
+ * @deprecated Use ICU `{name}` via {@link formatMessage} / `fx.t`. Kept for
100
+ * channel-style `{{name}}` callers during migration; not used by `fx.t`.
101
+ *
102
+ * @param template - Message template
103
+ * @param params - Replacement values
104
+ */
105
+ export function interpolateMessage(
106
+ template: string,
107
+ params?: Readonly<Record<string, unknown>>,
108
+ ): string {
109
+ if (!params) return template;
110
+ return template.replace(/\{\{(\w+)\}\}/g, (_, name: string) => {
111
+ const value = params[name];
112
+ return value === undefined || value === null ? `{{${name}}}` : String(value);
113
+ });
114
+ }
115
+
116
+ /** Options for {@link translate}. */
117
+ export interface TranslateOptions {
118
+ readonly locale: string;
119
+ readonly defaultLocale: string;
120
+ readonly catalogs: MessageCatalogs;
121
+ readonly key: string;
122
+ readonly values?: MessageValues;
123
+ /** @deprecated Prefer {@link TranslateOptions.values}. */
124
+ readonly params?: MessageValues;
125
+ }
126
+
127
+ /**
128
+ * Resolve a message key through locale → default → key fallback, then format
129
+ * with ICU MessageFormat.
130
+ *
131
+ * @param options - Locale, catalogs, key, values
132
+ */
133
+ export function translate(options: TranslateOptions): string {
134
+ const { locale, defaultLocale, catalogs, key } = options;
135
+ const values = options.values ?? options.params;
136
+ const primary = catalogs[locale]?.[key];
137
+ if (primary !== undefined) return formatMessage(primary, locale, values);
138
+ if (locale !== defaultLocale) {
139
+ const fallback = catalogs[defaultLocale]?.[key];
140
+ if (fallback !== undefined) return formatMessage(fallback, defaultLocale, values);
141
+ }
142
+ if (values === undefined) return key;
143
+ return `${key}:${JSON.stringify(values)}`;
144
+ }
145
+
146
+ /**
147
+ * Pick the best catalog locale from a candidate against configured locales.
148
+ *
149
+ * Exact match, then language subtag (`ar-SA` → `ar`), then default.
150
+ *
151
+ * @param candidate - Requested locale (may be undefined)
152
+ * @param locales - Configured locales
153
+ * @param defaultLocale - Fallback locale
154
+ */
155
+ export function matchConfiguredLocale(
156
+ candidate: string | undefined,
157
+ locales: readonly string[],
158
+ defaultLocale: string,
159
+ ): string {
160
+ if (!candidate?.trim()) return defaultLocale;
161
+ const tag = candidate.trim();
162
+ if (locales.includes(tag)) return tag;
163
+ const base = tag.toLowerCase().split("-")[0] ?? tag;
164
+ const byBase = locales.find(
165
+ (l) => l.toLowerCase() === base || l.toLowerCase().startsWith(`${base}-`),
166
+ );
167
+ if (byBase) return byBase;
168
+ return defaultLocale;
169
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Type-level helpers for message catalogs — keys, shapes, Register slot.
3
+ */
4
+
5
+ /** Nested or flat message tree (leaf values are ICU message strings). */
6
+ export type MessageTree = {
7
+ readonly [key: string]: string | MessageTree;
8
+ };
9
+
10
+ /**
11
+ * Flatten a message tree into dot-separated key unions.
12
+ *
13
+ * @typeParam T - Message tree
14
+ * @typeParam Prefix - Accumulated path prefix
15
+ */
16
+ export type FlattenKeys<T, Prefix extends string = ""> = {
17
+ [K in keyof T & string]: T[K] extends string
18
+ ? Prefix extends ""
19
+ ? K
20
+ : `${Prefix}.${K}`
21
+ : T[K] extends MessageTree
22
+ ? FlattenKeys<T[K], Prefix extends "" ? K : `${Prefix}.${K}`>
23
+ : never;
24
+ }[keyof T & string];
25
+
26
+ /**
27
+ * Same key structure as {@link Base}; every leaf is an ICU message string.
28
+ * Use with `satisfies` so Arabic (etc.) cannot drift from English keys.
29
+ *
30
+ * @typeParam Base - Canonical (usually English) tree
31
+ */
32
+ export type MessagesFor<Base extends MessageTree> = {
33
+ [K in keyof Base]: Base[K] extends string
34
+ ? string
35
+ : Base[K] extends MessageTree
36
+ ? MessagesFor<Base[K]>
37
+ : never;
38
+ };
39
+
40
+ /**
41
+ * Module-augmentation slot for typed `fx.t` keys.
42
+ *
43
+ * @example
44
+ * ```ts
45
+ * const en = { greeting: "Hello, {name}" } as const;
46
+ * defineLocale("en", en);
47
+ *
48
+ * declare module "okengine" {
49
+ * interface Register {
50
+ * messages: typeof en;
51
+ * }
52
+ * }
53
+ * ```
54
+ */
55
+ export interface Register {
56
+ // intentionally empty — augmented by the app
57
+ }
58
+
59
+ /** Messages from {@link Register}, when the app augments them. */
60
+ type MessagesFromRegister = Register extends { readonly messages: infer M }
61
+ ? M extends MessageTree
62
+ ? M
63
+ : never
64
+ : never;
65
+
66
+ /**
67
+ * Autocomplete / compile-time keys for {@link Fx.t}.
68
+ * Falls back to `string` until the app augments {@link Register}.
69
+ */
70
+ export type AppMessageKey = [MessagesFromRegister] extends [never]
71
+ ? string
72
+ : FlattenKeys<MessagesFromRegister>;
73
+
74
+ /**
75
+ * Values passed to ICU messages — primitives plus rich-text tag functions.
76
+ *
77
+ * Tag functions receive the formatted chunks inside `<tag>…</tag>` and return
78
+ * a string (or stringable) replacement.
79
+ */
80
+ export type MessageValue =
81
+ | string
82
+ | number
83
+ | boolean
84
+ | Date
85
+ | null
86
+ | undefined
87
+ | ((chunks: readonly string[]) => string);
88
+
89
+ /** Named values for one `fx.t` / {@link formatMessage} call. */
90
+ export type MessageValues = Readonly<Record<string, MessageValue>>;
package/src/index.ts CHANGED
@@ -176,6 +176,32 @@ export {
176
176
  type ContextInference,
177
177
  } from "./compiler/index.ts";
178
178
 
179
+ export {
180
+ defineLocale,
181
+ defineMessages,
182
+ flattenMessages,
183
+ formatMessage,
184
+ getMessageCatalogs,
185
+ interpolateMessage,
186
+ isRtlLocale,
187
+ matchConfiguredLocale,
188
+ parseAcceptLanguage,
189
+ resolveFailureMessage,
190
+ resolveLocale,
191
+ runWithLocale,
192
+ translate,
193
+ type AppMessageKey,
194
+ type FlattenKeys,
195
+ type LocaleContext,
196
+ type MessageCatalog,
197
+ type MessageCatalogs,
198
+ type MessageTree,
199
+ type MessageValue,
200
+ type MessageValues,
201
+ type MessagesFor,
202
+ type Register,
203
+ } from "./i18n/index.ts";
204
+
179
205
  export {
180
206
  createBunRuntime,
181
207
  createWebStandardRuntime,
package/src/kernel/app.ts CHANGED
@@ -35,6 +35,10 @@ import {
35
35
  type GateOptions,
36
36
  type ResolvedGateConfig,
37
37
  } from "../elements/gate/config.ts";
38
+ import type { TemplateCatalog } from "../elements/channel/runtime.ts";
39
+ import { parseAcceptLanguage } from "../elements/channel/locale.ts";
40
+ import { runWithLocale } from "../i18n/locale-context.ts";
41
+ import { getMessageCatalogs, matchConfiguredLocale } from "../i18n/messages.ts";
38
42
  import { runDurable } from "../elements/clock/durable.ts";
39
43
  import { isFlow, type AnyFlowDef } from "./flow.ts";
40
44
  import { fxRetry } from "./concurrency.ts";
@@ -567,6 +571,9 @@ export function oke(options: OkeOptions): OkeApp {
567
571
  secret: authBinding.secret,
568
572
  sessions: authBinding.sessions,
569
573
  now: authBinding.now,
574
+ passwordPolicy: gateConfig.auth?.passwordPolicy,
575
+ password: gateConfig.auth?.password,
576
+ breachCheck: gateConfig.auth?.breachCheck,
570
577
  });
571
578
  } else {
572
579
  setActiveGateAuthContext(undefined);
@@ -663,11 +670,14 @@ export function oke(options: OkeOptions): OkeApp {
663
670
  const pluginSignals = pluginRegistry.signalContributions();
664
671
  const pluginClocks = pluginRegistry.clockContributions();
665
672
  const pluginChannelTemplates = pluginRegistry.channelTemplateContributions();
673
+ const pluginChannelCatalogs = pluginRegistry.channelCatalogContributions();
666
674
  if (pluginSecrets.length > 0) available.add("vault");
667
675
  if (pluginGates.length > 0) available.add("gate");
668
676
  if (pluginSignals.length > 0) available.add("signal");
669
677
  if (pluginClocks.length > 0) available.add("clock");
670
- if (pluginChannelTemplates.length > 0) available.add("channel");
678
+ if (pluginChannelTemplates.length > 0 || pluginChannelCatalogs.length > 0) {
679
+ available.add("channel");
680
+ }
671
681
  // gate.auth (auto-absorbed auth plugin) satisfies `.needs("auth")`.
672
682
  if (gateConfig.auth) available.add("auth");
673
683
  assertPluginNeeds(caps, { pluginNames, available });
@@ -676,6 +686,7 @@ export function oke(options: OkeOptions): OkeApp {
676
686
  const baseSignals = overrides?.signals ?? options.signals ?? [];
677
687
  const baseClocks = overrides?.clocks ?? options.clocks ?? [];
678
688
  const baseChannel = overrides?.channel ?? options.channel;
689
+ const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
679
690
 
680
691
  const merged: BootOptions = {
681
692
  env: bootEnv,
@@ -692,6 +703,11 @@ export function oke(options: OkeOptions): OkeApp {
692
703
  channel: {
693
704
  ...(baseChannel ?? {}),
694
705
  templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],
706
+ ...(mergedCatalog ? { catalog: mergedCatalog } : {}),
707
+ defaultLocale:
708
+ baseChannel?.defaultLocale ??
709
+ (overrides?.config ?? options.config)?.i18n?.default ??
710
+ "en",
695
711
  },
696
712
  ai: overrides?.ai ?? options.ai,
697
713
  runs: overrides?.runs ?? options.runs,
@@ -754,10 +770,52 @@ export function oke(options: OkeOptions): OkeApp {
754
770
  readonly principal?: ResolvedPrincipal;
755
771
  /** Frozen origin identity for {@link Fx.principal} across `fx.call`. */
756
772
  readonly originPrincipal?: FxPrincipal;
773
+ /** Explicit locale override for {@link Fx.t} / channel sends. */
774
+ readonly locale?: string;
757
775
  },
758
776
  ): Promise<ExecuteResult> {
759
777
  registerFlow(flowDef);
760
778
 
779
+ const i18nConfig = options.config?.i18n;
780
+ const configuredLocales = i18nConfig?.locales ?? ["en", "ar"];
781
+ const defaultLocale = i18nConfig?.default ?? "en";
782
+ const acceptLanguage = extras?.request?.headers.get("accept-language") ?? undefined;
783
+ const resolvedLocale = matchConfiguredLocale(
784
+ extras?.locale ?? parseAcceptLanguage(acceptLanguage),
785
+ configuredLocales,
786
+ defaultLocale,
787
+ );
788
+
789
+ return runWithLocale({ locale: resolvedLocale, defaultLocale }, () =>
790
+ executeInLocale({
791
+ flowDef,
792
+ input,
793
+ trigger,
794
+ extras,
795
+ resolvedLocale,
796
+ defaultLocale,
797
+ }),
798
+ );
799
+ }
800
+
801
+ async function executeInLocale(args: {
802
+ readonly flowDef: AnyFlowDef;
803
+ readonly input: unknown;
804
+ readonly trigger: Trigger;
805
+ readonly extras?: {
806
+ readonly request?: Request;
807
+ readonly params?: Record<string, string>;
808
+ readonly validated?: boolean;
809
+ readonly auth?: ResolvedPrincipal | FxAuth;
810
+ readonly operator?: FxOperator;
811
+ readonly principal?: ResolvedPrincipal;
812
+ readonly originPrincipal?: FxPrincipal;
813
+ readonly locale?: string;
814
+ };
815
+ readonly resolvedLocale: string;
816
+ readonly defaultLocale: string;
817
+ }): Promise<ExecuteResult> {
818
+ const { flowDef, input, trigger, extras, resolvedLocale, defaultLocale } = args;
761
819
  const booted = await ensureBoot();
762
820
 
763
821
  const unitBag = flowDef.unit !== undefined ? unitHooks.get(flowDef.unit) : undefined;
@@ -860,6 +918,12 @@ export function oke(options: OkeOptions): OkeApp {
860
918
  effects: flowDef.effects,
861
919
  runTelemetry: telemetry,
862
920
  now,
921
+ i18n: {
922
+ locale: resolvedLocale,
923
+ defaultLocale,
924
+ catalogs: getMessageCatalogs(),
925
+ ...options.fx?.i18n,
926
+ },
863
927
  ...(principals ? { auth: principals.auth as FxAuth, operator: principals.operator } : {}),
864
928
  ...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
865
929
  ...(capability ? { capability } : {}),
@@ -882,7 +946,10 @@ export function oke(options: OkeOptions): OkeApp {
882
946
  target,
883
947
  callInput,
884
948
  { kind: "internal" } satisfies InternalTrigger,
885
- { originPrincipal: freezePrincipal(fx.principal) },
949
+ {
950
+ originPrincipal: freezePrincipal(fx.principal),
951
+ locale: resolvedLocale,
952
+ },
886
953
  );
887
954
  if (inner.failure) return inner.failure;
888
955
  return inner.output;
@@ -1300,5 +1367,28 @@ function archiveFromInput(
1300
1367
  return Object.keys(out).length > 0 ? out : undefined;
1301
1368
  }
1302
1369
 
1370
+ /**
1371
+ * Deep-merge channel template catalogs (later parts win per locale).
1372
+ *
1373
+ * @param parts - Catalog fragments (undefined skipped)
1374
+ */
1375
+ function mergeTemplateCatalogs(
1376
+ ...parts: readonly (TemplateCatalog | undefined)[]
1377
+ ): TemplateCatalog | undefined {
1378
+ const out: Record<
1379
+ string,
1380
+ Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
1381
+ > = {};
1382
+ let any = false;
1383
+ for (const part of parts) {
1384
+ if (!part) continue;
1385
+ any = true;
1386
+ for (const [template, locales] of Object.entries(part)) {
1387
+ out[template] = { ...(out[template] ?? {}), ...locales };
1388
+ }
1389
+ }
1390
+ return any ? out : undefined;
1391
+ }
1392
+
1303
1393
  /** @internal expose smart router type for tests */
1304
1394
  export type { HttpTrigger, SmartRouter };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * AI boot binder — shared driver switch + fail-loud resolution.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mockAiDriver } from "../../drivers/ai-mock.ts";
7
+ import { ollamaAiDriver } from "../../drivers/ai-ollama.ts";
8
+ import { aiDriverFor, aiUrlFor, bindAi, resolveAiDriverId } from "./ai.ts";
9
+
10
+ describe("ai boot binder", () => {
11
+ test("aiDriverFor maps protocol ids; unknown / reserved throw", () => {
12
+ expect(aiDriverFor("mock").id).toBe("mock");
13
+ expect(aiDriverFor("ollama")).toBe(ollamaAiDriver);
14
+ expect(() => aiDriverFor("bedrock")).toThrow(/not implemented/);
15
+ expect(() => aiDriverFor("nope")).toThrow(/unknown AI driver/);
16
+ });
17
+
18
+ test("resolveAiDriverId defaults to mock; honours config + OKE_AI_DRIVER in docker", () => {
19
+ expect(resolveAiDriverId({}, "local")).toBe("mock");
20
+ expect(resolveAiDriverId({ config: { drivers: { ai: { local: "ollama" } } } }, "local")).toBe(
21
+ "ollama",
22
+ );
23
+
24
+ const prev = process.env.OKE_AI_DRIVER;
25
+ process.env.OKE_AI_DRIVER = "ollama";
26
+ try {
27
+ expect(resolveAiDriverId({}, "local", true)).toBe("ollama");
28
+ } finally {
29
+ if (prev === undefined) delete process.env.OKE_AI_DRIVER;
30
+ else process.env.OKE_AI_DRIVER = prev;
31
+ }
32
+ });
33
+
34
+ test("aiUrlFor fails loud in docker without OKE_AI_URL", () => {
35
+ const prevUrl = process.env.OKE_AI_URL;
36
+ const prevHost = process.env.OLLAMA_HOST;
37
+ delete process.env.OKE_AI_URL;
38
+ delete process.env.OLLAMA_HOST;
39
+ try {
40
+ expect(() => aiUrlFor(true)).toThrow(/OKE_AI_URL/);
41
+ expect(aiUrlFor(false)).toBe("http://127.0.0.1:11434");
42
+ } finally {
43
+ if (prevUrl !== undefined) process.env.OKE_AI_URL = prevUrl;
44
+ if (prevHost !== undefined) process.env.OLLAMA_HOST = prevHost;
45
+ }
46
+ });
47
+
48
+ test("bindAi uses injected defaultDriver over config (tests stay on mock)", () => {
49
+ const runtime = bindAi(
50
+ {
51
+ config: { drivers: { ai: { local: "ollama" } } },
52
+ ai: { defaultDriver: mockAiDriver },
53
+ },
54
+ undefined,
55
+ () => 0,
56
+ "local",
57
+ );
58
+ expect(runtime.autoCacheDisabled).toBe(true);
59
+ });
60
+ });