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,330 @@
1
+ /**
2
+ * Real Mailpit end-to-end: magic-link / email-otp request → SMTP → Mailpit API.
3
+ *
4
+ * Gated on a live Docker daemon — same real-skip pattern as pgvector /
5
+ * Meilisearch (`const live = … ? test : test.skip`). Never an empty pass.
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import { mkdtemp, rm } from "node:fs/promises";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
13
+ import { oke } from "../kernel/app.ts";
14
+ import { resetFlowSeq } from "../kernel/flow.ts";
15
+ import { resetBindings } from "../kernel/on.ts";
16
+ import { emailOtp } from "./email-otp.ts";
17
+ import { magicLink } from "./magic-link.ts";
18
+
19
+ const SECRET = "test-secret-at-least-16";
20
+ const MAILPIT_IMAGE = "axllent/mailpit:v1.22.3";
21
+
22
+ afterEach(() => {
23
+ resetBindings();
24
+ resetFlowSeq();
25
+ });
26
+
27
+ function dockerAvailable(): boolean {
28
+ try {
29
+ const proc = Bun.spawnSync(["docker", "info"], {
30
+ stdout: "pipe",
31
+ stderr: "pipe",
32
+ });
33
+ return proc.exitCode === 0;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ const DOCKER = dockerAvailable();
40
+ if (!DOCKER) {
41
+ console.log("skip: mailpit e2e (docker daemon not available)");
42
+ }
43
+ const live = DOCKER ? test : test.skip;
44
+
45
+ interface MailpitMessageSummary {
46
+ readonly ID: string;
47
+ readonly Subject: string;
48
+ readonly To: readonly { readonly Address: string }[];
49
+ readonly Snippet?: string;
50
+ }
51
+
52
+ interface MailpitMessage {
53
+ readonly ID: string;
54
+ readonly Subject: string;
55
+ readonly Text?: string;
56
+ readonly HTML?: string;
57
+ readonly To: readonly { readonly Address: string }[];
58
+ }
59
+
60
+ interface MailpitList {
61
+ readonly total: number;
62
+ readonly messages: readonly MailpitMessageSummary[];
63
+ }
64
+
65
+ async function waitForMailpit(uiUrl: string, timeoutMs = 30_000): Promise<void> {
66
+ const deadline = Date.now() + timeoutMs;
67
+ while (Date.now() < deadline) {
68
+ try {
69
+ const res = await fetch(`${uiUrl}/api/v1/info`);
70
+ if (res.ok) return;
71
+ } catch {
72
+ // still starting
73
+ }
74
+ await Bun.sleep(250);
75
+ }
76
+ throw new Error(`mailpit not ready at ${uiUrl}`);
77
+ }
78
+
79
+ async function clearMailpit(uiUrl: string): Promise<void> {
80
+ await fetch(`${uiUrl}/api/v1/messages`, { method: "DELETE" });
81
+ }
82
+
83
+ async function waitForMessage(
84
+ uiUrl: string,
85
+ to: string,
86
+ timeoutMs = 15_000,
87
+ ): Promise<MailpitMessage> {
88
+ const deadline = Date.now() + timeoutMs;
89
+ while (Date.now() < deadline) {
90
+ const listRes = await fetch(`${uiUrl}/api/v1/messages`);
91
+ expect(listRes.ok).toBe(true);
92
+ const list = (await listRes.json()) as MailpitList;
93
+ const hit = list.messages.find((m) => m.To.some((addr) => addr.Address === to));
94
+ if (hit) {
95
+ const msgRes = await fetch(`${uiUrl}/api/v1/message/${hit.ID}`);
96
+ expect(msgRes.ok).toBe(true);
97
+ return (await msgRes.json()) as MailpitMessage;
98
+ }
99
+ await Bun.sleep(200);
100
+ }
101
+ throw new Error(`no Mailpit message for ${to} within ${timeoutMs}ms`);
102
+ }
103
+
104
+ function jsonPost(path: string, body: unknown): Request {
105
+ return new Request(`http://localhost${path}`, {
106
+ method: "POST",
107
+ headers: { "content-type": "application/json" },
108
+ body: JSON.stringify(body),
109
+ });
110
+ }
111
+
112
+ describe("auth delivery — Mailpit integration", () => {
113
+ live(
114
+ "magic-link request lands a real email containing the token and link",
115
+ async () => {
116
+ const dir = await mkdtemp(join(tmpdir(), "oke-mailpit-ml-"));
117
+ const dockerDir = join(dir, "docker");
118
+ const project = `oke-mp-ml-${Date.now()}`;
119
+ const prevSmtp = process.env.SMTP_URL;
120
+ const prevOkeSmtp = process.env.OKE_CHANNEL_EMAIL_URL;
121
+
122
+ try {
123
+ // Offset host ports so a developer's already-running Mailpit on
124
+ // :1025/:8025 does not collide with the test stack.
125
+ const instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 6);
126
+ const derived = deriveInfrastructure({
127
+ images: { "channel.email": MAILPIT_IMAGE },
128
+ app: "mailpit-ml",
129
+ host: "127.0.0.1",
130
+ includeApp: false,
131
+ composeDir: "docker",
132
+ instanceId,
133
+ });
134
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
135
+
136
+ const composeFiles = ["compose.yml", "compose.channel.email.yml"];
137
+ const up = Bun.spawn(
138
+ [
139
+ "docker",
140
+ "compose",
141
+ "-p",
142
+ project,
143
+ ...composeFiles.flatMap((f) => ["-f", f]),
144
+ "up",
145
+ "-d",
146
+ ],
147
+ {
148
+ cwd: dockerDir,
149
+ stdout: "pipe",
150
+ stderr: "pipe",
151
+ env: { ...process.env, ...derived.stackEnv },
152
+ },
153
+ );
154
+ const [upErr, upCode] = await Promise.all([new Response(up.stderr).text(), up.exited]);
155
+ expect(upCode).toBe(0);
156
+ if (upCode !== 0) console.error(upErr);
157
+
158
+ const uiUrl = derived.stackEnv.MAILPIT_UI_URL!;
159
+ const smtpUrl = derived.stackEnv.SMTP_URL!;
160
+ await waitForMailpit(uiUrl);
161
+ await clearMailpit(uiUrl);
162
+
163
+ process.env.SMTP_URL = smtpUrl;
164
+ process.env.OKE_CHANNEL_EMAIL_URL = smtpUrl;
165
+
166
+ const email = `ml-${crypto.randomUUID().slice(0, 8)}@example.com`;
167
+ const baseUrl = "http://app.test:6530";
168
+ const app = oke({
169
+ name: `mailpit-ml-${crypto.randomUUID()}`,
170
+ env: "test",
171
+ registry: "ignore",
172
+ gate: { auth: { secret: SECRET } },
173
+ config: {
174
+ drivers: { channel: { email: { test: "smtp" } } },
175
+ },
176
+ }).plug(
177
+ magicLink({
178
+ exposeDevToken: true,
179
+ baseUrl,
180
+ }),
181
+ );
182
+ await app.boot({ env: "test" });
183
+
184
+ const res = await app.fetch(jsonPost("/auth/magic-link/request", { email }));
185
+ expect(res.status).toBe(200);
186
+ const body = (await res.json()) as { data: { ok: true; devToken?: string } };
187
+ expect(body.data.ok).toBe(true);
188
+ expect(body.data.devToken).toBeTruthy();
189
+ const token = body.data.devToken!;
190
+ const expectedLink = `${baseUrl}/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
191
+
192
+ const msg = await waitForMessage(uiUrl, email);
193
+ expect(msg.Subject).toBe("Your sign-in link");
194
+ const text = msg.Text ?? "";
195
+ const html = msg.HTML ?? "";
196
+ expect(text.includes(token) || html.includes(token)).toBe(true);
197
+ expect(text.includes(expectedLink) || html.includes(expectedLink)).toBe(true);
198
+
199
+ await app.stop();
200
+ } finally {
201
+ if (prevSmtp === undefined) delete process.env.SMTP_URL;
202
+ else process.env.SMTP_URL = prevSmtp;
203
+ if (prevOkeSmtp === undefined) delete process.env.OKE_CHANNEL_EMAIL_URL;
204
+ else process.env.OKE_CHANNEL_EMAIL_URL = prevOkeSmtp;
205
+
206
+ await Bun.spawn(
207
+ [
208
+ "docker",
209
+ "compose",
210
+ "-p",
211
+ project,
212
+ "-f",
213
+ "compose.yml",
214
+ "-f",
215
+ "compose.channel.email.yml",
216
+ "down",
217
+ "-v",
218
+ ],
219
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
220
+ ).exited.catch(() => {});
221
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
222
+ }
223
+ },
224
+ 120_000,
225
+ );
226
+
227
+ live(
228
+ "email-otp request lands a real email containing the OTP",
229
+ async () => {
230
+ const dir = await mkdtemp(join(tmpdir(), "oke-mailpit-otp-"));
231
+ const dockerDir = join(dir, "docker");
232
+ const project = `oke-mp-otp-${Date.now()}`;
233
+ const prevSmtp = process.env.SMTP_URL;
234
+ const prevOkeSmtp = process.env.OKE_CHANNEL_EMAIL_URL;
235
+
236
+ try {
237
+ const instanceId = crypto.randomUUID().replace(/-/g, "").slice(0, 6);
238
+ const derived = deriveInfrastructure({
239
+ images: { "channel.email": MAILPIT_IMAGE },
240
+ app: "mailpit-otp",
241
+ host: "127.0.0.1",
242
+ includeApp: false,
243
+ composeDir: "docker",
244
+ instanceId,
245
+ });
246
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
247
+
248
+ const composeFiles = ["compose.yml", "compose.channel.email.yml"];
249
+ const up = Bun.spawn(
250
+ [
251
+ "docker",
252
+ "compose",
253
+ "-p",
254
+ project,
255
+ ...composeFiles.flatMap((f) => ["-f", f]),
256
+ "up",
257
+ "-d",
258
+ ],
259
+ {
260
+ cwd: dockerDir,
261
+ stdout: "pipe",
262
+ stderr: "pipe",
263
+ env: { ...process.env, ...derived.stackEnv },
264
+ },
265
+ );
266
+ const [upErr, upCode] = await Promise.all([new Response(up.stderr).text(), up.exited]);
267
+ expect(upCode).toBe(0);
268
+ if (upCode !== 0) console.error(upErr);
269
+
270
+ const uiUrl = derived.stackEnv.MAILPIT_UI_URL!;
271
+ const smtpUrl = derived.stackEnv.SMTP_URL!;
272
+ await waitForMailpit(uiUrl);
273
+ await clearMailpit(uiUrl);
274
+
275
+ process.env.SMTP_URL = smtpUrl;
276
+ process.env.OKE_CHANNEL_EMAIL_URL = smtpUrl;
277
+
278
+ const email = `otp-${crypto.randomUUID().slice(0, 8)}@example.com`;
279
+ const app = oke({
280
+ name: `mailpit-otp-${crypto.randomUUID()}`,
281
+ env: "test",
282
+ registry: "ignore",
283
+ gate: { auth: { secret: SECRET } },
284
+ config: {
285
+ drivers: { channel: { email: { test: "smtp" } } },
286
+ },
287
+ }).plug(emailOtp({ exposeDevOtp: true }));
288
+ await app.boot({ env: "test" });
289
+
290
+ const res = await app.fetch(jsonPost("/auth/email-otp/request", { email }));
291
+ expect(res.status).toBe(200);
292
+ const body = (await res.json()) as { data: { ok: true; devOtp?: string } };
293
+ expect(body.data.ok).toBe(true);
294
+ expect(body.data.devOtp).toMatch(/^\d{6}$/);
295
+ const otp = body.data.devOtp!;
296
+
297
+ const msg = await waitForMessage(uiUrl, email);
298
+ expect(msg.Subject).toBe("Your sign-in code");
299
+ const text = msg.Text ?? "";
300
+ const html = msg.HTML ?? "";
301
+ expect(text.includes(otp) || html.includes(otp)).toBe(true);
302
+
303
+ await app.stop();
304
+ } finally {
305
+ if (prevSmtp === undefined) delete process.env.SMTP_URL;
306
+ else process.env.SMTP_URL = prevSmtp;
307
+ if (prevOkeSmtp === undefined) delete process.env.OKE_CHANNEL_EMAIL_URL;
308
+ else process.env.OKE_CHANNEL_EMAIL_URL = prevOkeSmtp;
309
+
310
+ await Bun.spawn(
311
+ [
312
+ "docker",
313
+ "compose",
314
+ "-p",
315
+ project,
316
+ "-f",
317
+ "compose.yml",
318
+ "-f",
319
+ "compose.channel.email.yml",
320
+ "down",
321
+ "-v",
322
+ ],
323
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
324
+ ).exited.catch(() => {});
325
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
326
+ }
327
+ },
328
+ 120_000,
329
+ );
330
+ });