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.
- 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/plugins/email-otp.mdx +25 -19
- package/site/content/docs/plugins/magic-link.mdx +27 -21
- 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/method-context.ts +12 -2
- 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 +12 -10
- package/src/plugins/email-otp.ts +54 -1
- package/src/plugins/index.ts +16 -2
- package/src/plugins/magic-link.ts +63 -3
- package/src/plugins/username-policy.test.ts +302 -0
- package/src/plugins/username.ts +290 -9
- 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,
|
|
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
|
|
424
|
-
test("magic / email-otp
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
});
|
package/src/plugins/email-otp.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/plugins/index.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
-
|
|
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
|
}
|