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.
- package/package.json +2 -1
- package/site/content/docs/elements/ai.mdx +82 -1
- package/site/content/docs/elements/channel.mdx +6 -1
- package/site/content/docs/elements/flow.mdx +20 -17
- package/site/content/docs/get-started/why.mdx +10 -10
- package/site/content/docs/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/headers.mdx +10 -10
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- package/site/content/docs/plugins/passkey.mdx +36 -24
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/configuration.mdx +7 -0
- package/site/content/docs/reference/environment-variables.mdx +10 -5
- package/site/content/docs/reference/errors.mdx +14 -0
- package/site/content/docs/reference/fx.mdx +68 -16
- package/site/content/docs/reference/i18n.mdx +313 -0
- package/site/content/docs/reference/index.mdx +6 -1
- package/site/content/docs/reference/meta.json +1 -0
- package/site/content/docs/reference/plugins.mdx +1 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/auth/bindings.ts +1 -1
- package/src/auth/constant-time.ts +22 -0
- package/src/auth/index.ts +2 -0
- package/src/auth/method-context.ts +12 -2
- package/src/cli/competitor-mention-removal.test.ts +3 -3
- package/src/compiler/aot.test.ts +16 -13
- package/src/compiler/effects-infer.ts +46 -0
- package/src/console/server/ai.test.ts +34 -5
- package/src/docker/compose.ts +9 -0
- package/src/docker/docker.test.ts +39 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -1
- package/src/docker/recipes/ollama.ts +43 -0
- package/src/docker/stack-id.ts +2 -0
- package/src/drivers/ai-mock.ts +60 -0
- package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
- package/src/drivers/ai-ollama.integration.test.ts +197 -0
- package/src/drivers/ai-ollama.ts +327 -0
- package/src/drivers/ai-openai-compatible.ts +211 -21
- package/src/drivers/ai-providers.test.ts +179 -2
- package/src/drivers/ai-stream.test.ts +195 -0
- package/src/drivers/ai-types.ts +42 -1
- package/src/drivers/channel-smtp.ts +8 -2
- package/src/drivers/index.ts +21 -1
- package/src/drivers/ollama.ts +14 -0
- package/src/elements/ai/rate.test.ts +53 -0
- package/src/elements/ai/rate.ts +66 -0
- package/src/elements/ai/redacted-prompt.test.ts +90 -0
- package/src/elements/ai/runtime.ts +330 -100
- package/src/elements/ai/tools.test.ts +99 -0
- package/src/elements/ai.test.ts +26 -2
- package/src/elements/ai.ts +10 -1
- package/src/i18n/catalogs/ar.ts +67 -0
- package/src/i18n/catalogs/en.ts +68 -0
- package/src/i18n/failure-message.test.ts +56 -0
- package/src/i18n/failure-message.ts +93 -0
- package/src/i18n/format.ts +67 -0
- package/src/i18n/index.ts +57 -0
- package/src/i18n/locale-context.ts +48 -0
- package/src/i18n/messages.test.ts +173 -0
- package/src/i18n/messages.ts +169 -0
- package/src/i18n/types.ts +90 -0
- package/src/index.ts +26 -0
- package/src/kernel/app.ts +92 -2
- package/src/kernel/boot-bind/ai.test.ts +60 -0
- package/src/kernel/boot-bind/ai.ts +125 -2
- package/src/kernel/boot.test.ts +4 -3
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/errors.ts +56 -5
- package/src/kernel/fx.test.ts +27 -0
- package/src/kernel/fx.ts +74 -18
- package/src/kernel/pipeline.test.ts +4 -0
- package/src/kernel/pipeline.ts +1 -1
- package/src/kernel/plugin.ts +16 -0
- package/src/kernel/registry.ts +15 -0
- package/src/plugins/auth/shared.ts +5 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
- package/src/plugins/auth-methods.security.test.ts +764 -0
- package/src/plugins/compression.ts +1 -1
- package/src/plugins/config-source.test.ts +11 -11
- package/src/plugins/config-source.ts +2 -2
- package/src/plugins/cors.ts +1 -1
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/{security-headers.test.ts → headers.test.ts} +18 -18
- package/src/plugins/headers.ts +240 -41
- package/src/plugins/index.ts +27 -5
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/passkey-webauthn.ts +217 -0
- package/src/plugins/passkey.ts +99 -33
- package/src/plugins/response-headers.ts +54 -0
- package/src/plugins/two-factor.ts +6 -2
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- package/src/release/measure.ts +8 -1
- 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
|
|
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
|
-
{
|
|
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
|
+
});
|