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.
Files changed (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. 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: "emailOtp", file: "email-otp.ts", category: "auth" },
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>
@@ -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
- }