okengine 0.7.0 → 0.9.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 +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- package/src/plugins/phone-number.ts +0 -149
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real Taqnyat end-to-end: otp({ tier: 1 }) via Verify API + magic-link via Taqnyat Mail.
|
|
3
|
+
*
|
|
4
|
+
* Double-gated — runs ONLY when BOTH:
|
|
5
|
+
* 1. The global per-medium opt-in flag is set explicitly
|
|
6
|
+
* (`OKE_SMS_LIVE=1` / `OKE_EMAIL_LIVE=1`), and
|
|
7
|
+
* 2. the matching real credentials are present in the environment.
|
|
8
|
+
*
|
|
9
|
+
* Credential presence alone is NEVER enough — this mirrors sently's own
|
|
10
|
+
* opt-in live suite and prevents burning provider quota on a routine
|
|
11
|
+
* `bun test` run with stray credentials in the shell. The flags are
|
|
12
|
+
* provider-agnostic: any future SMS or email provider live suite gates on
|
|
13
|
+
* the same medium flag plus its own credentials.
|
|
14
|
+
*
|
|
15
|
+
* Skip is always visible (`console.log("skip: …")` + `test.skip`), never a
|
|
16
|
+
* silent pass.
|
|
17
|
+
*
|
|
18
|
+
* Setup:
|
|
19
|
+
* # SMS OTP — sends exactly ONE real SMS (the plugin's own send; its
|
|
20
|
+
* # provider response is captured for the code-5 assertion)
|
|
21
|
+
* export OKE_SMS_LIVE=1
|
|
22
|
+
* export TAQNYAT_TOKEN=… (or TAQNYAT_BEARER_TOKEN)
|
|
23
|
+
* export TAQNYAT_SENDER=YourBrand
|
|
24
|
+
* export OKE_TEST_TAQNYAT_PHONE=+9665xxxxxxxx (or TAQNYAT_TO)
|
|
25
|
+
* # Mail
|
|
26
|
+
* export OKE_EMAIL_LIVE=1
|
|
27
|
+
* export TAQNYAT_MAIL_TOKEN=…
|
|
28
|
+
* export TAQNYAT_CAMPAIGN=auth
|
|
29
|
+
* export OKE_TEST_TAQNYAT_MAIL=you@example.com (or TAQNYAT_MAIL_TO)
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
33
|
+
import { oke } from "../kernel/app.ts";
|
|
34
|
+
import { resetFlowSeq } from "../kernel/flow.ts";
|
|
35
|
+
import { resetBindings } from "../kernel/on.ts";
|
|
36
|
+
import { magicLink } from "./magic-link.ts";
|
|
37
|
+
import { otp } from "./otp.ts";
|
|
38
|
+
|
|
39
|
+
const SECRET = "test-secret-at-least-16";
|
|
40
|
+
|
|
41
|
+
afterEach(() => {
|
|
42
|
+
resetBindings();
|
|
43
|
+
resetFlowSeq();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function jsonPost(path: string, body: unknown): Request {
|
|
47
|
+
return new Request(`http://localhost${path}`, {
|
|
48
|
+
method: "POST",
|
|
49
|
+
headers: { "content-type": "application/json" },
|
|
50
|
+
body: JSON.stringify(body),
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const WANT_SMS = process.env.OKE_SMS_LIVE === "1";
|
|
55
|
+
const WANT_MAIL = process.env.OKE_EMAIL_LIVE === "1";
|
|
56
|
+
|
|
57
|
+
const SMS_TOKEN = process.env.TAQNYAT_TOKEN ?? process.env.TAQNYAT_BEARER_TOKEN;
|
|
58
|
+
const SMS_CREDS = Boolean(SMS_TOKEN) && Boolean(process.env.TAQNYAT_SENDER);
|
|
59
|
+
// OKE_TEST_TAQNYAT_PHONE wins; TAQNYAT_TO (sently's convention) is the alias.
|
|
60
|
+
const SMS_PHONE = process.env.OKE_TEST_TAQNYAT_PHONE ?? process.env.TAQNYAT_TO;
|
|
61
|
+
const LIVE_SMS = WANT_SMS && SMS_CREDS && Boolean(SMS_PHONE);
|
|
62
|
+
|
|
63
|
+
const MAIL_CREDS = Boolean(process.env.TAQNYAT_MAIL_TOKEN) && Boolean(process.env.TAQNYAT_CAMPAIGN);
|
|
64
|
+
const MAIL_TO = process.env.OKE_TEST_TAQNYAT_MAIL ?? process.env.TAQNYAT_MAIL_TO;
|
|
65
|
+
const LIVE_MAIL = WANT_MAIL && MAIL_CREDS && Boolean(MAIL_TO);
|
|
66
|
+
|
|
67
|
+
if (!LIVE_SMS) {
|
|
68
|
+
console.log(
|
|
69
|
+
WANT_SMS
|
|
70
|
+
? "skip: taqnyat SMS OTP live (missing TAQNYAT_TOKEN/TAQNYAT_SENDER or OKE_TEST_TAQNYAT_PHONE)"
|
|
71
|
+
: "skip: taqnyat SMS OTP live (OKE_SMS_LIVE≠1)",
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
if (!LIVE_MAIL) {
|
|
75
|
+
console.log(
|
|
76
|
+
WANT_MAIL
|
|
77
|
+
? "skip: taqnyat mail live (missing TAQNYAT_MAIL_TOKEN/TAQNYAT_CAMPAIGN or OKE_TEST_TAQNYAT_MAIL)"
|
|
78
|
+
: "skip: taqnyat mail live (OKE_EMAIL_LIVE≠1)",
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const liveSms = LIVE_SMS ? test : test.skip;
|
|
83
|
+
const liveMail = LIVE_MAIL ? test : test.skip;
|
|
84
|
+
|
|
85
|
+
describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
|
|
86
|
+
liveSms(
|
|
87
|
+
"phone OTP request → real sendOtp → Taqnyat success code 5",
|
|
88
|
+
async () => {
|
|
89
|
+
const app = oke({
|
|
90
|
+
name: `taqnyat-live-${crypto.randomUUID()}`,
|
|
91
|
+
env: "test",
|
|
92
|
+
registry: "ignore",
|
|
93
|
+
gate: { auth: { secret: SECRET } },
|
|
94
|
+
config: {
|
|
95
|
+
drivers: { channel: { sms: { test: "taqnyat" } } },
|
|
96
|
+
},
|
|
97
|
+
}).plug(otp({ tier: 1 }));
|
|
98
|
+
await app.boot({ env: "test" });
|
|
99
|
+
|
|
100
|
+
// Wrap the live transport so the plugin's single real send also proves
|
|
101
|
+
// the provider accepted it (success code 5) — exactly one SMS, never two.
|
|
102
|
+
const sms = app.bootResult?.channel?.drivers.find((d) => d.id === "taqnyat")?.smsTransport as
|
|
103
|
+
| {
|
|
104
|
+
sendOtp(o: {
|
|
105
|
+
to: string;
|
|
106
|
+
requestId: string;
|
|
107
|
+
lang?: "en" | "ar";
|
|
108
|
+
}): Promise<{ code: number }>;
|
|
109
|
+
}
|
|
110
|
+
| undefined;
|
|
111
|
+
expect(sms).toBeDefined();
|
|
112
|
+
const realSendOtp = sms!.sendOtp.bind(sms);
|
|
113
|
+
let providerCode: number | undefined;
|
|
114
|
+
sms!.sendOtp = async (opts) => {
|
|
115
|
+
const result = await realSendOtp(opts);
|
|
116
|
+
providerCode = result.code;
|
|
117
|
+
return result;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const res = await app.fetch(jsonPost("/auth/otp/request", { phone: SMS_PHONE, lang: "en" }));
|
|
121
|
+
const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
|
|
122
|
+
if (res.status !== 200) {
|
|
123
|
+
console.log("taqnyat sendOtp response", res.status, body);
|
|
124
|
+
}
|
|
125
|
+
expect(res.status).toBe(200);
|
|
126
|
+
expect(body.data?.ok).toBe(true);
|
|
127
|
+
// Local dev leak must stay off in the provider path.
|
|
128
|
+
expect((body.data as { devOtp?: string }).devOtp).toBeUndefined();
|
|
129
|
+
// Taqnyat Verify documented success code — from the plugin's own send.
|
|
130
|
+
expect(providerCode).toBe(5);
|
|
131
|
+
|
|
132
|
+
await app.stop();
|
|
133
|
+
},
|
|
134
|
+
30_000,
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
describe("taqnyat live — magic-link via Taqnyat Mail", () => {
|
|
139
|
+
liveMail(
|
|
140
|
+
"magic-link request → real TaqnyatMailTransport send",
|
|
141
|
+
async () => {
|
|
142
|
+
const app = oke({
|
|
143
|
+
name: `taqnyat-mail-live-${crypto.randomUUID()}`,
|
|
144
|
+
env: "test",
|
|
145
|
+
registry: "ignore",
|
|
146
|
+
gate: { auth: { secret: SECRET } },
|
|
147
|
+
config: {
|
|
148
|
+
drivers: { channel: { email: { test: "taqnyat-mail" } } },
|
|
149
|
+
},
|
|
150
|
+
}).plug(magicLink({ baseUrl: "http://app.test:6530" }));
|
|
151
|
+
await app.boot({ env: "test" });
|
|
152
|
+
|
|
153
|
+
const res = await app.fetch(jsonPost("/auth/magic-link/request", { email: MAIL_TO }));
|
|
154
|
+
const body = (await res.json()) as { data?: { ok: true }; error?: { message?: string } };
|
|
155
|
+
if (res.status !== 200) {
|
|
156
|
+
console.log("taqnyat mail send response", res.status, body);
|
|
157
|
+
}
|
|
158
|
+
expect(res.status).toBe(200);
|
|
159
|
+
expect(body.data?.ok).toBe(true);
|
|
160
|
+
expect((body.data as { devToken?: string }).devToken).toBeUndefined();
|
|
161
|
+
|
|
162
|
+
// A successful send is recorded in the Channel receipt ledger.
|
|
163
|
+
const receipts = app.bootResult?.channel?.receipts.all() ?? [];
|
|
164
|
+
const sent = receipts.find((r) => r.to === MAIL_TO);
|
|
165
|
+
expect(sent?.status === "sent" || sent?.status === "fallback").toBe(true);
|
|
166
|
+
expect(sent?.driverId).toContain("taqnyat");
|
|
167
|
+
|
|
168
|
+
await app.stop();
|
|
169
|
+
},
|
|
170
|
+
30_000,
|
|
171
|
+
);
|
|
172
|
+
});
|
|
@@ -33,8 +33,7 @@ export const OFFICIAL_PLUGIN_BUDGETS: readonly OfficialPluginBudget[] = [
|
|
|
33
33
|
{ name: "username", file: "username.ts", category: "auth" },
|
|
34
34
|
{ name: "anonymous", file: "anonymous.ts", category: "auth" },
|
|
35
35
|
{ name: "magicLink", file: "magic-link.ts", category: "auth" },
|
|
36
|
-
{ name: "
|
|
37
|
-
{ name: "phoneNumber", file: "phone-number.ts", category: "auth" },
|
|
36
|
+
{ name: "otp", file: "otp.ts", category: "auth" },
|
|
38
37
|
{ name: "twoFactor", file: "two-factor.ts", category: "auth" },
|
|
39
38
|
{ name: "passkey", file: "passkey.ts", category: "auth" },
|
|
40
39
|
{ name: "headers", file: "headers.ts", category: "security" },
|
|
@@ -1,117 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Email OTP"
|
|
3
|
-
description: "Official plugin — six-digit email one-time codes to sign in under /auth."
|
|
4
|
-
icon: "MailCheck"
|
|
5
|
-
source: "docs/spec/unified-theory.md"
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
`emailOtp()` stores a hashed 6-digit code (default 10 minutes, 5 attempts). Request sends the
|
|
9
|
-
code via Channel (`auth-email-otp`); verify creates the user on first success and returns hybrid
|
|
10
|
-
session tokens.
|
|
11
|
-
|
|
12
|
-
<Callout title="The one rule">
|
|
13
|
-
Enable `gate.auth`, then `.plug(emailOtp())`. Never log raw OTPs. Delivery goes through `fx.send`;
|
|
14
|
-
use `exposeDevOtp` only for local DX without SMTP.
|
|
15
|
-
</Callout>
|
|
16
|
-
|
|
17
|
-
## Quick start
|
|
18
|
-
|
|
19
|
-
<Steps>
|
|
20
|
-
|
|
21
|
-
<Step>
|
|
22
|
-
### Plug it
|
|
23
|
-
|
|
24
|
-
```typescript title="src/app.ts"
|
|
25
|
-
import { oke } from "okengine";
|
|
26
|
-
import { emailOtp } from "okengine/plugins";
|
|
27
|
-
|
|
28
|
-
export const app = oke({
|
|
29
|
-
name: "shop",
|
|
30
|
-
env: "local",
|
|
31
|
-
gate: { auth: {} },
|
|
32
|
-
}).plug(emailOtp());
|
|
33
|
-
```
|
|
34
|
-
|
|
35
|
-
</Step>
|
|
36
|
-
|
|
37
|
-
<Step>
|
|
38
|
-
### Request a code
|
|
39
|
-
|
|
40
|
-
```typescript
|
|
41
|
-
const { data } = await api.auth.requestEmailOtp({ email: "ali@example.com" });
|
|
42
|
-
// Channel delivers auth-email-otp; data.devOtp only when exposeDevOtp
|
|
43
|
-
```
|
|
44
|
-
|
|
45
|
-
`POST /auth/email-otp/request` — prior active codes for that email are invalidated.
|
|
46
|
-
|
|
47
|
-
</Step>
|
|
48
|
-
|
|
49
|
-
<Step>
|
|
50
|
-
### Verify
|
|
51
|
-
|
|
52
|
-
```typescript
|
|
53
|
-
const { data } = await api.auth.verifyEmailOtp({
|
|
54
|
-
email: "ali@example.com",
|
|
55
|
-
otp,
|
|
56
|
-
});
|
|
57
|
-
```
|
|
58
|
-
|
|
59
|
-
`POST /auth/email-otp/verify`. Wrong code increments attempts; after 5 →
|
|
60
|
-
`invalid_credentials`.
|
|
61
|
-
|
|
62
|
-
</Step>
|
|
63
|
-
|
|
64
|
-
</Steps>
|
|
65
|
-
|
|
66
|
-
## Options
|
|
67
|
-
|
|
68
|
-
| Option | Type | Default | Meaning |
|
|
69
|
-
| --------------- | ------------------- | -------------------------- | --------------------------------------- |
|
|
70
|
-
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
71
|
-
| `sessions` | `SessionStore` | active\* | Session store |
|
|
72
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
73
|
-
| `from` | `string` | `OKE <no-reply@oke.local>` | Template From address |
|
|
74
|
-
| `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
|
|
75
|
-
| `identities` | `IdentityStore` | new | Email → user map |
|
|
76
|
-
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
77
|
-
|
|
78
|
-
## Surfaces
|
|
79
|
-
|
|
80
|
-
| Flow | Path | Gate |
|
|
81
|
-
| ---------------------- | ------------------------------ | ------------------------ |
|
|
82
|
-
| `auth.requestEmailOtp` | `POST /auth/email-otp/request` | `gate.public` + otp rate |
|
|
83
|
-
| `auth.verifyEmailOtp` | `POST /auth/email-otp/verify` | `gate.public` + otp rate |
|
|
84
|
-
|
|
85
|
-
**Consequence:** the plugin contributes the `auth-email-otp` Channel template and EN/AR
|
|
86
|
-
catalog bodies (`{{otp}}`). Override copy by merging your own catalog at boot.
|
|
87
|
-
|
|
88
|
-
## Troubleshooting
|
|
89
|
-
|
|
90
|
-
<Accordions>
|
|
91
|
-
<Accordion title="No email with the code">
|
|
92
|
-
|
|
93
|
-
In `local` / `test` the `console` driver captures mail — open the inbox, or run
|
|
94
|
-
`oke dev --docker` and check Mailpit. For unit tests without SMTP, set `exposeDevOtp: true`.
|
|
95
|
-
|
|
96
|
-
</Accordion>
|
|
97
|
-
<Accordion title="AuthFailed after a few tries">
|
|
98
|
-
|
|
99
|
-
Five failed attempts consume the challenge. Request a new OTP.
|
|
100
|
-
|
|
101
|
-
</Accordion>
|
|
102
|
-
</Accordions>
|
|
103
|
-
|
|
104
|
-
## Learn more
|
|
105
|
-
|
|
106
|
-
- [Magic link](/docs/plugins/magic-link) — link instead of a code
|
|
107
|
-
- [Phone number](/docs/plugins/phone-number) — SMS OTP (E.164; delivery deferred)
|
|
108
|
-
- [Channel](/docs/elements/channel) — `fx.send`, Mailpit, consents
|
|
109
|
-
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
110
|
-
|
|
111
|
-
## Next
|
|
112
|
-
|
|
113
|
-
<Cards>
|
|
114
|
-
<Card title="Phone number" description="E.164 SMS OTP." href="/docs/plugins/phone-number" />
|
|
115
|
-
<Card title="Magic link" description="Email link sign-in." href="/docs/plugins/magic-link" />
|
|
116
|
-
<Card title="Channel" description="Email delivery and Mailpit." href="/docs/elements/channel" />
|
|
117
|
-
</Cards>
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: "Phone number"
|
|
3
|
-
description: "Official plugin — E.164 phone OTP request and verify under /auth."
|
|
4
|
-
icon: "Smartphone"
|
|
5
|
-
source: "docs/spec/unified-theory.md"
|
|
6
|
-
---
|
|
7
|
-
|
|
8
|
-
`phoneNumber()` signs people in with a phone number in E.164 form (`+15551234567`). Codes are
|
|
9
|
-
6 digits, hashed, 10 minutes, 5 attempts — same security shape as email OTP.
|
|
10
|
-
|
|
11
|
-
<Callout title="The one rule">
|
|
12
|
-
Enable `gate.auth`, then `.plug(phoneNumber())`. Reject non-E.164 numbers. SMS Channel delivery is
|
|
13
|
-
not wired yet — use `exposeDevOtp` locally. Prefer stronger methods when phone is the only factor.
|
|
14
|
-
</Callout>
|
|
15
|
-
|
|
16
|
-
## Quick start
|
|
17
|
-
|
|
18
|
-
<Steps>
|
|
19
|
-
|
|
20
|
-
<Step>
|
|
21
|
-
### Plug it
|
|
22
|
-
|
|
23
|
-
```typescript title="src/app.ts"
|
|
24
|
-
import { oke } from "okengine";
|
|
25
|
-
import { phoneNumber } from "okengine/plugins";
|
|
26
|
-
|
|
27
|
-
export const app = oke({
|
|
28
|
-
name: "shop",
|
|
29
|
-
env: "local",
|
|
30
|
-
gate: { auth: {} },
|
|
31
|
-
}).plug(phoneNumber({ exposeDevOtp: true }));
|
|
32
|
-
```
|
|
33
|
-
|
|
34
|
-
</Step>
|
|
35
|
-
|
|
36
|
-
<Step>
|
|
37
|
-
### Request a code
|
|
38
|
-
|
|
39
|
-
```typescript
|
|
40
|
-
const { data, error } = await api.auth.requestPhoneOtp({ phone: "+15551234567" });
|
|
41
|
-
```
|
|
42
|
-
|
|
43
|
-
`POST /auth/phone/request`. Invalid E.164 → `AuthFailed` / `invalid_phone`.
|
|
44
|
-
|
|
45
|
-
</Step>
|
|
46
|
-
|
|
47
|
-
<Step>
|
|
48
|
-
### Verify
|
|
49
|
-
|
|
50
|
-
```typescript
|
|
51
|
-
const { data } = await api.auth.verifyPhoneOtp({
|
|
52
|
-
phone: "+15551234567",
|
|
53
|
-
otp: data!.devOtp!,
|
|
54
|
-
});
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
`POST /auth/phone/verify` — first success creates a phone → `userId` mapping.
|
|
58
|
-
|
|
59
|
-
</Step>
|
|
60
|
-
|
|
61
|
-
</Steps>
|
|
62
|
-
|
|
63
|
-
## Options
|
|
64
|
-
|
|
65
|
-
| Option | Type | Default | Meaning |
|
|
66
|
-
| --------------- | ------------------- | -------- | --------------------------------------- |
|
|
67
|
-
| `secret` | `string` | active\* | HMAC secret (\*from `gate.auth`) |
|
|
68
|
-
| `sessions` | `SessionStore` | active\* | Session store |
|
|
69
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
70
|
-
| `exposeDevOtp` | `boolean` | `false` | Include raw OTP in the request response |
|
|
71
|
-
| `phones` | `PhoneStore` | new | Phone → userId map |
|
|
72
|
-
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
73
|
-
|
|
74
|
-
## Surfaces
|
|
75
|
-
|
|
76
|
-
| Flow | Path | Gate |
|
|
77
|
-
| ---------------------- | -------------------------- | ------------------------ |
|
|
78
|
-
| `auth.requestPhoneOtp` | `POST /auth/phone/request` | `gate.public` + otp rate |
|
|
79
|
-
| `auth.verifyPhoneOtp` | `POST /auth/phone/verify` | `gate.public` + otp rate |
|
|
80
|
-
|
|
81
|
-
E.164 pattern: `^\+[1-9]\d{7,14}$`.
|
|
82
|
-
|
|
83
|
-
## Troubleshooting
|
|
84
|
-
|
|
85
|
-
<Accordions>
|
|
86
|
-
<Accordion title="invalid_phone">
|
|
87
|
-
|
|
88
|
-
Number must start with `+` and a non-zero country digit, then 7–14 more digits. No spaces or
|
|
89
|
-
dashes.
|
|
90
|
-
|
|
91
|
-
</Accordion>
|
|
92
|
-
<Accordion title="No SMS arrived">
|
|
93
|
-
|
|
94
|
-
v1 does not send SMS. Use `exposeDevOtp` in local/test, or deliver via your own Channel Flow.
|
|
95
|
-
|
|
96
|
-
</Accordion>
|
|
97
|
-
</Accordions>
|
|
98
|
-
|
|
99
|
-
## Learn more
|
|
100
|
-
|
|
101
|
-
- [Email OTP](/docs/plugins/email-otp) — email variant of the same OTP rules
|
|
102
|
-
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
103
|
-
- [Channel](/docs/elements/channel) — when you wire SMS yourself
|
|
104
|
-
|
|
105
|
-
## Next
|
|
106
|
-
|
|
107
|
-
<Cards>
|
|
108
|
-
<Card title="Email OTP" description="Six-digit email codes." href="/docs/plugins/email-otp" />
|
|
109
|
-
<Card title="Two-factor" description="TOTP step-up." href="/docs/plugins/two-factor" />
|
|
110
|
-
<Card title="Gate" description="Builtin auth and policies." href="/docs/elements/gate" />
|
|
111
|
-
</Cards>
|
package/src/plugins/email-otp.ts
DELETED
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
/**
|
|
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.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import {
|
|
10
|
-
createIdentityStore,
|
|
11
|
-
normalizeEmail,
|
|
12
|
-
type IdentityStore,
|
|
13
|
-
type UserIdentityRow,
|
|
14
|
-
} from "../auth/identity.ts";
|
|
15
|
-
import { issueSessionWithScopes } from "../auth/sessions.ts";
|
|
16
|
-
import {
|
|
17
|
-
createVerificationStore,
|
|
18
|
-
findActiveVerification,
|
|
19
|
-
generateOtp,
|
|
20
|
-
hashChallenge,
|
|
21
|
-
putVerification,
|
|
22
|
-
type VerificationStore,
|
|
23
|
-
} from "../auth/verification.ts";
|
|
24
|
-
import { channel } from "../elements/channel.ts";
|
|
25
|
-
import { plugin, type PluginDef } from "../kernel/plugin.ts";
|
|
26
|
-
import {
|
|
27
|
-
AuthFailed,
|
|
28
|
-
AuthRateLimited,
|
|
29
|
-
SessionTokensOut,
|
|
30
|
-
bindPublicAuth,
|
|
31
|
-
createMethodRuntime,
|
|
32
|
-
fail,
|
|
33
|
-
flow,
|
|
34
|
-
z,
|
|
35
|
-
type AuthMethodOptions,
|
|
36
|
-
} from "./auth/shared.ts";
|
|
37
|
-
|
|
38
|
-
const DEFAULT_TTL_MS = 10 * 60 * 1000;
|
|
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;
|
|
67
|
-
|
|
68
|
-
/** Options for {@link emailOtp}. */
|
|
69
|
-
export interface EmailOtpOptions extends AuthMethodOptions {
|
|
70
|
-
/** Challenge TTL (default 10m). */
|
|
71
|
-
readonly ttlMs?: number;
|
|
72
|
-
readonly identities?: IdentityStore;
|
|
73
|
-
readonly verifications?: VerificationStore;
|
|
74
|
-
/** Return raw OTP in the request response (test / local). */
|
|
75
|
-
readonly exposeDevOtp?: boolean;
|
|
76
|
-
/** Override the template `from` address. */
|
|
77
|
-
readonly from?: string;
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
/**
|
|
81
|
-
* Email OTP request + verify (6-digit, hashed, 5 attempts).
|
|
82
|
-
*
|
|
83
|
-
* @param opts - TTL / stores / dev OTP
|
|
84
|
-
*/
|
|
85
|
-
export function emailOtp(opts: EmailOtpOptions = {}): PluginDef {
|
|
86
|
-
const runtime = createMethodRuntime(opts);
|
|
87
|
-
const identities = opts.identities ?? createIdentityStore();
|
|
88
|
-
const verifications = opts.verifications ?? createVerificationStore();
|
|
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;
|
|
101
|
-
|
|
102
|
-
const request = flow({
|
|
103
|
-
name: "auth.requestEmailOtp",
|
|
104
|
-
unit: "auth",
|
|
105
|
-
plane: "user",
|
|
106
|
-
in: z.object({ email: z.string().min(3) }),
|
|
107
|
-
out: z.object({
|
|
108
|
-
ok: z.literal(true),
|
|
109
|
-
devOtp: z.string().optional(),
|
|
110
|
-
}),
|
|
111
|
-
errors: { AuthFailed, AuthRateLimited },
|
|
112
|
-
effects: { sends: ["auth-email-otp"] },
|
|
113
|
-
do: async (input, fx) => {
|
|
114
|
-
const email = normalizeEmail(input.email);
|
|
115
|
-
if (!email.includes("@")) return fail("AuthFailed", { reason: "invalid_email" });
|
|
116
|
-
const otp = generateOtp(6);
|
|
117
|
-
const now = runtime.now();
|
|
118
|
-
// Invalidate prior active challenges for this email.
|
|
119
|
-
for (const row of verifications.rows.values()) {
|
|
120
|
-
if (row.identifier === `email-otp:${email}` && row.consumedAt === null) {
|
|
121
|
-
row.consumedAt = now;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
putVerification(verifications, {
|
|
125
|
-
id: crypto.randomUUID(),
|
|
126
|
-
identifier: `email-otp:${email}`,
|
|
127
|
-
value: await hashChallenge(otp),
|
|
128
|
-
expiresAt: now + ttlMs,
|
|
129
|
-
createdAt: now,
|
|
130
|
-
consumedAt: null,
|
|
131
|
-
attempts: 0,
|
|
132
|
-
});
|
|
133
|
-
await fx.send(tmpl, {
|
|
134
|
-
to: email,
|
|
135
|
-
data: { email, otp },
|
|
136
|
-
});
|
|
137
|
-
return {
|
|
138
|
-
ok: true as const,
|
|
139
|
-
...(opts.exposeDevOtp ? { devOtp: otp } : {}),
|
|
140
|
-
};
|
|
141
|
-
},
|
|
142
|
-
});
|
|
143
|
-
|
|
144
|
-
const verify = flow({
|
|
145
|
-
name: "auth.verifyEmailOtp",
|
|
146
|
-
unit: "auth",
|
|
147
|
-
plane: "user",
|
|
148
|
-
in: z.object({
|
|
149
|
-
email: z.string().min(3),
|
|
150
|
-
otp: z.string().min(4).max(8),
|
|
151
|
-
}),
|
|
152
|
-
out: SessionTokensOut,
|
|
153
|
-
errors: { AuthFailed, AuthRateLimited },
|
|
154
|
-
do: async (input) => {
|
|
155
|
-
const email = normalizeEmail(input.email);
|
|
156
|
-
const now = runtime.now();
|
|
157
|
-
const row = findActiveVerification(verifications, `email-otp:${email}`, now);
|
|
158
|
-
if (!row) return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
159
|
-
if (row.attempts >= MAX_ATTEMPTS) {
|
|
160
|
-
row.consumedAt = now;
|
|
161
|
-
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
162
|
-
}
|
|
163
|
-
const hash = await hashChallenge(input.otp.trim());
|
|
164
|
-
if (hash !== row.value) {
|
|
165
|
-
row.attempts += 1;
|
|
166
|
-
if (row.attempts >= MAX_ATTEMPTS) row.consumedAt = now;
|
|
167
|
-
return fail("AuthFailed", { reason: "invalid_credentials" });
|
|
168
|
-
}
|
|
169
|
-
row.consumedAt = now;
|
|
170
|
-
const user = ensureUserByEmail(identities, email, now);
|
|
171
|
-
const issued = await issueSessionWithScopes(runtime.sessions, runtime.crypto, {
|
|
172
|
-
id: user.id,
|
|
173
|
-
plane: "user",
|
|
174
|
-
scopes: [],
|
|
175
|
-
});
|
|
176
|
-
return {
|
|
177
|
-
accessToken: issued.accessToken,
|
|
178
|
-
refreshToken: issued.refreshToken,
|
|
179
|
-
accessExpiresAt: issued.accessExpiresAt,
|
|
180
|
-
userId: user.id,
|
|
181
|
-
};
|
|
182
|
-
},
|
|
183
|
-
});
|
|
184
|
-
|
|
185
|
-
return plugin("emailOtp", { version: "0.0.1", config: { method: "email-otp" } })
|
|
186
|
-
.needs("auth")
|
|
187
|
-
.needs("channel")
|
|
188
|
-
.channelTemplate(tmpl)
|
|
189
|
-
.channelCatalog(emailOtpCatalog)
|
|
190
|
-
.binding(bindPublicAuth("/email-otp/request", request, "otp"))
|
|
191
|
-
.binding(bindPublicAuth("/email-otp/verify", verify, "otp"));
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
function ensureUserByEmail(store: IdentityStore, email: string, now: number): UserIdentityRow {
|
|
195
|
-
const existingId = store.byEmail.get(email);
|
|
196
|
-
if (existingId) {
|
|
197
|
-
const existing = store.users.get(existingId);
|
|
198
|
-
if (existing) return existing;
|
|
199
|
-
}
|
|
200
|
-
const id = crypto.randomUUID();
|
|
201
|
-
const user: UserIdentityRow = {
|
|
202
|
-
id,
|
|
203
|
-
email,
|
|
204
|
-
name: email.split("@")[0] || "user",
|
|
205
|
-
emailVerified: true,
|
|
206
|
-
status: "active",
|
|
207
|
-
createdAt: now,
|
|
208
|
-
updatedAt: now,
|
|
209
|
-
extra: {},
|
|
210
|
-
};
|
|
211
|
-
store.users.set(id, user);
|
|
212
|
-
store.byEmail.set(email, id);
|
|
213
|
-
return user;
|
|
214
|
-
}
|