okengine 0.5.1 → 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 (76) 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/plugins/email-otp.mdx +25 -19
  6. package/site/content/docs/plugins/magic-link.mdx +27 -21
  7. package/site/content/docs/reference/configuration.mdx +7 -0
  8. package/site/content/docs/reference/environment-variables.mdx +10 -5
  9. package/site/content/docs/reference/errors.mdx +14 -0
  10. package/site/content/docs/reference/fx.mdx +68 -16
  11. package/site/content/docs/reference/i18n.mdx +313 -0
  12. package/site/content/docs/reference/index.mdx +6 -1
  13. package/site/content/docs/reference/meta.json +1 -0
  14. package/site/content/docs/reference/plugins.mdx +1 -0
  15. package/src/auth/auth.test.ts +3 -0
  16. package/src/auth/bindings.ts +1 -1
  17. package/src/auth/method-context.ts +12 -2
  18. package/src/compiler/aot.test.ts +16 -13
  19. package/src/compiler/effects-infer.ts +46 -0
  20. package/src/console/server/ai.test.ts +34 -5
  21. package/src/docker/compose.ts +9 -0
  22. package/src/docker/docker.test.ts +39 -0
  23. package/src/docker/index.ts +11 -1
  24. package/src/docker/recipes/index.ts +3 -1
  25. package/src/docker/recipes/ollama.ts +43 -0
  26. package/src/docker/stack-id.ts +2 -0
  27. package/src/drivers/ai-mock.ts +60 -0
  28. package/src/drivers/ai-ollama-tools.integration.test.ts +107 -0
  29. package/src/drivers/ai-ollama.integration.test.ts +197 -0
  30. package/src/drivers/ai-ollama.ts +327 -0
  31. package/src/drivers/ai-openai-compatible.ts +211 -21
  32. package/src/drivers/ai-providers.test.ts +179 -2
  33. package/src/drivers/ai-stream.test.ts +195 -0
  34. package/src/drivers/ai-types.ts +42 -1
  35. package/src/drivers/channel-smtp.ts +8 -2
  36. package/src/drivers/index.ts +21 -1
  37. package/src/drivers/ollama.ts +14 -0
  38. package/src/elements/ai/rate.test.ts +53 -0
  39. package/src/elements/ai/rate.ts +66 -0
  40. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  41. package/src/elements/ai/runtime.ts +330 -100
  42. package/src/elements/ai/tools.test.ts +99 -0
  43. package/src/elements/ai.test.ts +26 -2
  44. package/src/elements/ai.ts +10 -1
  45. package/src/i18n/catalogs/ar.ts +67 -0
  46. package/src/i18n/catalogs/en.ts +68 -0
  47. package/src/i18n/failure-message.test.ts +56 -0
  48. package/src/i18n/failure-message.ts +93 -0
  49. package/src/i18n/format.ts +67 -0
  50. package/src/i18n/index.ts +57 -0
  51. package/src/i18n/locale-context.ts +48 -0
  52. package/src/i18n/messages.test.ts +173 -0
  53. package/src/i18n/messages.ts +169 -0
  54. package/src/i18n/types.ts +90 -0
  55. package/src/index.ts +26 -0
  56. package/src/kernel/app.ts +92 -2
  57. package/src/kernel/boot-bind/ai.test.ts +60 -0
  58. package/src/kernel/boot-bind/ai.ts +125 -2
  59. package/src/kernel/boot.test.ts +4 -3
  60. package/src/kernel/boot.ts +1 -1
  61. package/src/kernel/errors.ts +56 -5
  62. package/src/kernel/fx.test.ts +27 -0
  63. package/src/kernel/fx.ts +74 -18
  64. package/src/kernel/pipeline.test.ts +4 -0
  65. package/src/kernel/pipeline.ts +1 -1
  66. package/src/kernel/plugin.ts +16 -0
  67. package/src/kernel/registry.ts +15 -0
  68. package/src/plugins/auth/shared.ts +5 -1
  69. package/src/plugins/auth-delivery.mailpit.integration.test.ts +330 -0
  70. package/src/plugins/auth-methods.security.test.ts +12 -10
  71. package/src/plugins/email-otp.ts +54 -1
  72. package/src/plugins/index.ts +16 -2
  73. package/src/plugins/magic-link.ts +63 -3
  74. package/src/plugins/username-policy.test.ts +302 -0
  75. package/src/plugins/username.ts +290 -9
  76. package/src/release/measure.ts +8 -1
@@ -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
+ });
@@ -2,8 +2,8 @@
2
2
  * Exploit-proof security audit for the seven Gate auth method plugins.
3
3
  *
4
4
  * Real HTTP against a booted app — rate limits, gate posture, single-use tokens,
5
- * anonymous non-escalation, channel delivery gap, TOTP constant-time compare,
6
- * and WebAuthn signature + origin verification.
5
+ * anonymous non-escalation, Channel delivery (email wired / phone deferred),
6
+ * TOTP constant-time compare, and WebAuthn signature + origin verification.
7
7
  */
8
8
 
9
9
  import { afterEach, describe, expect, test } from "bun:test";
@@ -420,8 +420,8 @@ describe("auth methods — anonymous non-escalation", () => {
420
420
  });
421
421
  });
422
422
 
423
- describe("auth methods — channel delivery gap", () => {
424
- test("magic / email-otp / phone generate codes but do not expose them without exposeDev*", async () => {
423
+ describe("auth methods — channel delivery", () => {
424
+ test("magic / email-otp send via fx.send; phone remains unwired; exposeDev* stays off by default", async () => {
425
425
  resetBindings();
426
426
  resetFlowSeq();
427
427
  const app = oke({
@@ -450,15 +450,17 @@ describe("auth methods — channel delivery gap", () => {
450
450
  expect(phoneBody.data.ok).toBe(true);
451
451
  expect(phoneBody.data.devOtp).toBeUndefined();
452
452
 
453
- // No Channel import / send path in these plugins — codes exist only in the
454
- // verification store (unreachable without exposeDev* or a future Channel wire).
455
453
  const magicSrc = await Bun.file(new URL("./magic-link.ts", import.meta.url)).text();
456
454
  const emailSrc = await Bun.file(new URL("./email-otp.ts", import.meta.url)).text();
457
455
  const phoneSrc = await Bun.file(new URL("./phone-number.ts", import.meta.url)).text();
458
- for (const src of [magicSrc, emailSrc, phoneSrc]) {
459
- expect(src).not.toMatch(/fx\.channel|channel\./);
460
- expect(src).not.toMatch(/from ["'].*channel/);
461
- }
456
+ expect(magicSrc).toMatch(/fx\.send/);
457
+ expect(magicSrc).toMatch(/channel\./);
458
+ expect(emailSrc).toMatch(/fx\.send/);
459
+ expect(emailSrc).toMatch(/channel\./);
460
+ // SMS delivery stays deferred — phone must not import Channel or fx.send.
461
+ expect(phoneSrc).not.toMatch(/fx\.send/);
462
+ expect(phoneSrc).not.toMatch(/channel\./);
463
+ expect(phoneSrc).not.toMatch(/from ["'].*channel/);
462
464
 
463
465
  await app.stop();
464
466
  });
@@ -1,5 +1,9 @@
1
1
  /**
2
2
  * Email OTP Gate auth method plugin.
3
+ *
4
+ * Delivers the one-time code via Channel (`fx.send` + `auth-email-otp`
5
+ * template). {@link EmailOtpOptions.exposeDevOtp} remains available for
6
+ * local DX without Mailpit / SMTP.
3
7
  */
4
8
 
5
9
  import {
@@ -17,6 +21,7 @@ import {
17
21
  putVerification,
18
22
  type VerificationStore,
19
23
  } from "../auth/verification.ts";
24
+ import { channel } from "../elements/channel.ts";
20
25
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
21
26
  import {
22
27
  AuthFailed,
@@ -32,6 +37,33 @@ import {
32
37
 
33
38
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
34
39
  const MAX_ATTEMPTS = 5;
40
+ const DEFAULT_FROM = "OKE <no-reply@oke.local>";
41
+
42
+ /** Channel template for email OTP delivery. */
43
+ export const emailOtpTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-email-otp", {
44
+ description: "Email OTP sign-in code",
45
+ schema: z.object({
46
+ email: z.string(),
47
+ otp: z.string(),
48
+ }),
49
+ locales: ["en", "ar"],
50
+ });
51
+
52
+ /** Default EN/AR bodies for {@link emailOtpTemplate}. */
53
+ export const emailOtpCatalog = {
54
+ "auth-email-otp": {
55
+ en: {
56
+ subject: "Your sign-in code",
57
+ text: "Your one-time sign-in code is: {{otp}}\n",
58
+ html: "<p>Your one-time sign-in code is:</p><p><strong>{{otp}}</strong></p>",
59
+ },
60
+ ar: {
61
+ subject: "رمز تسجيل الدخول",
62
+ text: "رمز تسجيل الدخول لمرة واحدة هو: {{otp}}\n",
63
+ html: '<p dir="rtl">رمز تسجيل الدخول لمرة واحدة هو:</p><p dir="rtl"><strong>{{otp}}</strong></p>',
64
+ },
65
+ },
66
+ } as const;
35
67
 
36
68
  /** Options for {@link emailOtp}. */
37
69
  export interface EmailOtpOptions extends AuthMethodOptions {
@@ -41,6 +73,8 @@ export interface EmailOtpOptions extends AuthMethodOptions {
41
73
  readonly verifications?: VerificationStore;
42
74
  /** Return raw OTP in the request response (test / local). */
43
75
  readonly exposeDevOtp?: boolean;
76
+ /** Override the template `from` address. */
77
+ readonly from?: string;
44
78
  }
45
79
 
46
80
  /**
@@ -53,6 +87,17 @@ export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
53
87
  const identities = opts.identities ?? createIdentityStore();
54
88
  const verifications = opts.verifications ?? createVerificationStore();
55
89
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
90
+ const tmpl =
91
+ opts.from !== undefined
92
+ ? channel.email({ from: opts.from }).template("auth-email-otp", {
93
+ description: "Email OTP sign-in code",
94
+ schema: z.object({
95
+ email: z.string(),
96
+ otp: z.string(),
97
+ }),
98
+ locales: ["en", "ar"],
99
+ })
100
+ : emailOtpTemplate;
56
101
 
57
102
  const request = flow({
58
103
  name: "auth.requestEmailOtp",
@@ -64,7 +109,8 @@ export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
64
109
  devOtp: z.string().optional(),
65
110
  }),
66
111
  errors: { AuthFailed, AuthRateLimited },
67
- do: async (input) => {
112
+ effects: { sends: ["auth-email-otp"] },
113
+ do: async (input, fx) => {
68
114
  const email = normalizeEmail(input.email);
69
115
  if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
70
116
  const otp = generateOtp(6);
@@ -84,6 +130,10 @@ export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
84
130
  consumedAt: null,
85
131
  attempts: 0,
86
132
  });
133
+ await fx.send(tmpl, {
134
+ to: email,
135
+ data: { email, otp },
136
+ });
87
137
  return {
88
138
  ok: true as const,
89
139
  ...(opts.exposeDevOtp ? { devOtp: otp } : {}),
@@ -134,6 +184,9 @@ export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
134
184
 
135
185
  return plugin("emailOtp", { version: "0.0.1", config: { method: "email-otp" } })
136
186
  .needs("auth")
187
+ .needs("channel")
188
+ .channelTemplate(tmpl)
189
+ .channelCatalog(emailOtpCatalog)
137
190
  .binding(bindPublicAuth("/email-otp/request", request, "otp"))
138
191
  .binding(bindPublicAuth("/email-otp/verify", verify, "otp"));
139
192
  }
@@ -16,9 +16,14 @@ export {
16
16
  } from "./config-source.ts";
17
17
  export { cors, type CorsOptions } from "./cors.ts";
18
18
  export { csrf, type CsrfOptions } from "./csrf.ts";
19
- export { emailOtp, type EmailOtpOptions } from "./email-otp.ts";
19
+ export { emailOtp, emailOtpCatalog, emailOtpTemplate, type EmailOtpOptions } from "./email-otp.ts";
20
20
  export { ipAllowlist, type IpAllowlistOptions } from "./ip-allowlist.ts";
21
- export { magicLink, type MagicLinkOptions } from "./magic-link.ts";
21
+ export {
22
+ magicLink,
23
+ magicLinkCatalog,
24
+ magicLinkTemplate,
25
+ type MagicLinkOptions,
26
+ } from "./magic-link.ts";
22
27
  export { maintenanceMode, type MaintenanceModeOptions } from "./maintenance-mode.ts";
23
28
  export {
24
29
  createPasskeyStore,
@@ -57,9 +62,18 @@ export {
57
62
  type TwoFactorStore,
58
63
  } from "./two-factor.ts";
59
64
  export {
65
+ assertUsernamePolicy,
60
66
  createUsernameStore,
67
+ DEFAULT_RESERVED_USERNAMES,
68
+ DEFAULT_USERNAME_ALLOWED_CHARS,
69
+ DEFAULT_USERNAME_MAX_LENGTH,
70
+ DEFAULT_USERNAME_MIN_LENGTH,
71
+ resolveUsernamePolicy,
61
72
  username,
73
+ UsernamePolicyError,
74
+ type ResolvedUsernamePolicy,
62
75
  type UsernamePluginOptions,
76
+ type UsernamePolicyOptions,
63
77
  type UsernameRow,
64
78
  type UsernameStore,
65
79
  } from "./username.ts";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Magic-link Gate auth method plugin.
3
3
  *
4
- * Delivery via Channel is optional in v1 when {@link MagicLinkOptions.exposeDevToken}
5
- * is set, the request response includes `devToken` for tests / local DX.
4
+ * Delivers the one-time token via Channel (`fx.send` + `auth-magic-link`
5
+ * template). {@link MagicLinkOptions.exposeDevToken} remains available for
6
+ * local DX without Mailpit / SMTP.
6
7
  */
7
8
 
8
9
  import {
@@ -18,6 +19,7 @@ import {
18
19
  putVerification,
19
20
  type VerificationStore,
20
21
  } from "../auth/verification.ts";
22
+ import { channel } from "../elements/channel.ts";
21
23
  import { plugin, type PluginDef } from "../kernel/plugin.ts";
22
24
  import {
23
25
  AuthFailed,
@@ -32,6 +34,35 @@ import {
32
34
  } from "./auth/shared.ts";
33
35
 
34
36
  const DEFAULT_TTL_MS = 10 * 60 * 1000;
37
+ const DEFAULT_FROM = "OKE <no-reply@oke.local>";
38
+ const DEFAULT_BASE_URL = "http://127.0.0.1:6530";
39
+
40
+ /** Channel template for magic-link delivery. */
41
+ export const magicLinkTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-magic-link", {
42
+ description: "Magic-link sign-in email",
43
+ schema: z.object({
44
+ email: z.string(),
45
+ token: z.string(),
46
+ link: z.string(),
47
+ }),
48
+ locales: ["en", "ar"],
49
+ });
50
+
51
+ /** Default EN/AR bodies for {@link magicLinkTemplate}. */
52
+ export const magicLinkCatalog = {
53
+ "auth-magic-link": {
54
+ en: {
55
+ subject: "Your sign-in link",
56
+ text: "Sign in with this link:\n{{link}}\n\nOr enter this token:\n{{token}}\n",
57
+ html: '<p>Sign in with this link:</p><p><a href="{{link}}">{{link}}</a></p><p>Or enter this token:</p><p><code>{{token}}</code></p>',
58
+ },
59
+ ar: {
60
+ subject: "رابط تسجيل الدخول",
61
+ text: "سجّل الدخول عبر هذا الرابط:\n{{link}}\n\nأو أدخل هذا الرمز:\n{{token}}\n",
62
+ html: '<p dir="rtl">سجّل الدخول عبر هذا الرابط:</p><p dir="rtl"><a href="{{link}}">{{link}}</a></p><p dir="rtl">أو أدخل هذا الرمز:</p><p dir="rtl"><code>{{token}}</code></p>',
63
+ },
64
+ },
65
+ } as const;
35
66
 
36
67
  /** Options for {@link magicLink}. */
37
68
  export interface MagicLinkOptions extends AuthMethodOptions {
@@ -43,6 +74,13 @@ export interface MagicLinkOptions extends AuthMethodOptions {
43
74
  readonly verifications?: VerificationStore;
44
75
  /** Return raw token in the request response (test / local). */
45
76
  readonly exposeDevToken?: boolean;
77
+ /**
78
+ * App origin used to build the magic link (default `OKE_APP_URL` or
79
+ * `http://127.0.0.1:6530`).
80
+ */
81
+ readonly baseUrl?: string;
82
+ /** Override the template `from` address. */
83
+ readonly from?: string;
46
84
  }
47
85
 
48
86
  /**
@@ -55,6 +93,19 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
55
93
  const identities = opts.identities ?? createIdentityStore();
56
94
  const verifications = opts.verifications ?? createVerificationStore();
57
95
  const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
96
+ const baseUrl = (opts.baseUrl ?? process.env.OKE_APP_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
97
+ const tmpl =
98
+ opts.from !== undefined
99
+ ? channel.email({ from: opts.from }).template("auth-magic-link", {
100
+ description: "Magic-link sign-in email",
101
+ schema: z.object({
102
+ email: z.string(),
103
+ token: z.string(),
104
+ link: z.string(),
105
+ }),
106
+ locales: ["en", "ar"],
107
+ })
108
+ : magicLinkTemplate;
58
109
 
59
110
  const request = flow({
60
111
  name: "auth.requestMagicLink",
@@ -66,7 +117,8 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
66
117
  devToken: z.string().optional(),
67
118
  }),
68
119
  errors: { AuthFailed, AuthRateLimited },
69
- do: async (input) => {
120
+ effects: { sends: ["auth-magic-link"] },
121
+ do: async (input, fx) => {
70
122
  const email = normalizeEmail(input.email);
71
123
  if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
72
124
  const token = `ml_${crypto.randomUUID().replace(/-/g, "")}`;
@@ -80,6 +132,11 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
80
132
  consumedAt: null,
81
133
  attempts: 0,
82
134
  });
135
+ const link = `${baseUrl}/auth/magic-link/verify?token=${encodeURIComponent(token)}`;
136
+ await fx.send(tmpl, {
137
+ to: email,
138
+ data: { email, token, link },
139
+ });
83
140
  return {
84
141
  ok: true as const,
85
142
  ...(opts.exposeDevToken ? { devToken: token } : {}),
@@ -136,6 +193,9 @@ export function magicLink(opts: MagicLinkOptions = {}): PluginDef {
136
193
 
137
194
  return plugin("magicLink", { version: "0.0.1", config: { method: "magic-link" } })
138
195
  .needs("auth")
196
+ .needs("channel")
197
+ .channelTemplate(tmpl)
198
+ .channelCatalog(magicLinkCatalog)
139
199
  .binding(bindPublicAuth("/magic-link/request", request, "otp"))
140
200
  .binding(bindPublicAuth("/magic-link/verify", verify, "otp"));
141
201
  }