okengine 0.9.0 → 0.9.1
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 +1 -1
- package/site/content/docs/elements/channel.mdx +8 -5
- package/site/content/docs/plugins/otp.mdx +58 -46
- package/src/auth/otp-capability.ts +14 -14
- package/src/kernel/app.ts +1 -1
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/auth-methods.security.test.ts +3 -3
- package/src/plugins/auth-methods.test.ts +1 -1
- package/src/plugins/index.ts +2 -2
- package/src/plugins/otp.test.ts +21 -19
- package/src/plugins/otp.ts +36 -34
- package/src/plugins/taqnyat.live.test.ts +3 -3
package/package.json
CHANGED
|
@@ -152,15 +152,18 @@ Email fallback uses ordered email transports; SMS with multiple SMS drivers uses
|
|
|
152
152
|
the same failover path. Credentials for boot-opened drivers are listed under
|
|
153
153
|
[Environment Variables](/docs/reference/environment-variables).
|
|
154
154
|
|
|
155
|
-
### Provider
|
|
155
|
+
### Provider OTP (`fx.sendOtp` / `fx.verifyOtp`)
|
|
156
156
|
|
|
157
|
-
Flows can
|
|
157
|
+
Flows can send a provider-generated SMS code with `fx.sendOtp({ to, requestId, lang? })`
|
|
158
158
|
and check it with `fx.verifyOtp({ to, requestId, code })` — Taqnyat's Verify API. Both are
|
|
159
|
-
capability-gated `send` effects and dry-run safe.
|
|
159
|
+
capability-gated `send` effects and dry-run safe. No `.plug()` required.
|
|
160
160
|
|
|
161
161
|
They dispatch only when the bound SMS driver supports Verify (`taqnyat`); any other SMS
|
|
162
|
-
driver fails loudly instead of silently falling back to a self-generated code.
|
|
163
|
-
|
|
162
|
+
driver fails loudly instead of silently falling back to a self-generated code.
|
|
163
|
+
|
|
164
|
+
[OTP](/docs/plugins/otp) in provider mode is the full auth feature built on this same
|
|
165
|
+
path (routes, sessions, rates). Custom shape? Call `fx.sendOtp` / `fx.verifyOtp` in
|
|
166
|
+
your own flow — skipping the plugin does not drop the provider connection.
|
|
164
167
|
|
|
165
168
|
`webpush` needs VAPID keys — open it yourself and include it in
|
|
166
169
|
`BootOptions.channel.drivers` (boot does not open push from env):
|
|
@@ -1,18 +1,30 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: "OTP"
|
|
3
|
-
description: "Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit
|
|
3
|
+
description: "Official plugin — one-time codes over SMS, WhatsApp, or email under /auth, with explicit provider or app mode."
|
|
4
4
|
icon: "KeyRound"
|
|
5
5
|
source: "docs/spec/unified-theory.md"
|
|
6
6
|
---
|
|
7
7
|
|
|
8
|
-
`otp()` signs people in with a one-time code. You must set `
|
|
9
|
-
auto-detect.
|
|
10
|
-
across the channels you declare.
|
|
8
|
+
`otp()` signs people in with a one-time code. You must set `mode` — there is no
|
|
9
|
+
auto-detect. Provider mode is SMS Verify via the bound driver; app mode is
|
|
10
|
+
app-owned delivery across the channels you declare.
|
|
11
11
|
|
|
12
12
|
<Callout title="The one rule">
|
|
13
|
-
Enable `gate.auth`, then `.plug(otp({
|
|
14
|
-
`.plug(otp({
|
|
15
|
-
OTPs.
|
|
13
|
+
Enable `gate.auth`, then `.plug(otp({ mode: "provider" }))` or
|
|
14
|
+
`.plug(otp({ mode: "app", channels: [...] }))`. Never omit `mode`. Never log
|
|
15
|
+
raw OTPs.
|
|
16
|
+
</Callout>
|
|
17
|
+
|
|
18
|
+
<Callout type="info" title="One otp() per app">
|
|
19
|
+
Provider and app mode cannot both be active — they claim the same fixed `/auth/otp/*` routes. Need
|
|
20
|
+
two OTP-like mechanisms? Combine `otp()` with a different plugin (e.g. `magicLink()`), not a
|
|
21
|
+
second `otp()`.
|
|
22
|
+
</Callout>
|
|
23
|
+
|
|
24
|
+
<Callout type="info" title="fx.sendOtp vs otp()">
|
|
25
|
+
`fx.sendOtp` / `fx.verifyOtp` are raw Channel capabilities — no `.plug()`. Direct use means you
|
|
26
|
+
build routes, sessions, rates, and storage yourself. `otp()` provider mode wraps that path; skip
|
|
27
|
+
it and call the raw methods in your own flow without losing the provider connection.
|
|
16
28
|
</Callout>
|
|
17
29
|
|
|
18
30
|
## Quick start
|
|
@@ -20,7 +32,7 @@ across the channels you declare.
|
|
|
20
32
|
<Steps>
|
|
21
33
|
|
|
22
34
|
<Step>
|
|
23
|
-
### Plug
|
|
35
|
+
### Plug app mode (multi-channel)
|
|
24
36
|
|
|
25
37
|
```typescript title="src/app.ts"
|
|
26
38
|
import { oke } from "okengine";
|
|
@@ -32,7 +44,7 @@ export const app = oke({
|
|
|
32
44
|
gate: { auth: {} },
|
|
33
45
|
}).plug(
|
|
34
46
|
otp({
|
|
35
|
-
|
|
47
|
+
mode: "app",
|
|
36
48
|
channels: ["sms", "whatsapp", "email"],
|
|
37
49
|
exposeDevOtp: true, // local DX only
|
|
38
50
|
}),
|
|
@@ -57,7 +69,7 @@ invalidated. Delivery follows `channels` order for addresses you pass.
|
|
|
57
69
|
</Step>
|
|
58
70
|
|
|
59
71
|
<Step>
|
|
60
|
-
### Resend on another channel (
|
|
72
|
+
### Resend on another channel (app mode only)
|
|
61
73
|
|
|
62
74
|
```typescript
|
|
63
75
|
const { data } = await api.auth.resendOtp({
|
|
@@ -67,8 +79,8 @@ const { data } = await api.auth.resendOtp({
|
|
|
67
79
|
});
|
|
68
80
|
```
|
|
69
81
|
|
|
70
|
-
Same code, same TTL. Default cooldown is 60 seconds.
|
|
71
|
-
surface — the provider owns the code.
|
|
82
|
+
Same code, same TTL. Default cooldown is 60 seconds. Provider mode has no
|
|
83
|
+
resend surface — the provider owns the code.
|
|
72
84
|
|
|
73
85
|
</Step>
|
|
74
86
|
|
|
@@ -88,23 +100,23 @@ const { data } = await api.auth.verifyOtp({
|
|
|
88
100
|
|
|
89
101
|
</Steps>
|
|
90
102
|
|
|
91
|
-
##
|
|
103
|
+
## Modes
|
|
92
104
|
|
|
93
|
-
| |
|
|
105
|
+
| | Provider mode | App mode |
|
|
94
106
|
| -------------------- | ----------------------------- | --------------------------------------------- |
|
|
95
|
-
| Config | `otp({
|
|
107
|
+
| Config | `otp({ mode: "provider" })` | `otp({ mode: "app", channels: [...] })` |
|
|
96
108
|
| Who owns the code | Provider (Verify API) | Your app |
|
|
97
109
|
| Delivery | `fx.sendOtp` / `fx.verifyOtp` | `fx.deliverOtp` (Channel templates) |
|
|
98
110
|
| Channels | SMS only | `sms` · `whatsapp` · `email` (declared order) |
|
|
99
111
|
| Resend other channel | Impossible | `POST /auth/otp/resend` |
|
|
100
112
|
| `exposeDevOtp` | Forbidden | Optional (default off) |
|
|
101
113
|
|
|
102
|
-
<Callout type="warn" title="
|
|
103
|
-
Resend-via-different-channel is impossible
|
|
104
|
-
|
|
114
|
+
<Callout type="warn" title="Provider mode limitation">
|
|
115
|
+
Resend-via-different-channel is impossible in provider mode — the code value is never visible to
|
|
116
|
+
OKE. Use app mode when you need SMS → email fallback for the same code.
|
|
105
117
|
</Callout>
|
|
106
118
|
|
|
107
|
-
###
|
|
119
|
+
### Provider mode setup
|
|
108
120
|
|
|
109
121
|
```typescript title="oke.config.ts"
|
|
110
122
|
export default {
|
|
@@ -117,9 +129,9 @@ export default {
|
|
|
117
129
|
```
|
|
118
130
|
|
|
119
131
|
Boot fails loudly if no SMS driver exposes `sendOtp` / `verifyOtp`. Switch to
|
|
120
|
-
|
|
132
|
+
app mode, or bind a Verify-capable driver (for example `taqnyat`).
|
|
121
133
|
|
|
122
|
-
###
|
|
134
|
+
### App mode delivery
|
|
123
135
|
|
|
124
136
|
| Concern | Behavior |
|
|
125
137
|
| --------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
|
@@ -135,42 +147,42 @@ Templates: `auth-otp-email`, `auth-otp-sms`, `auth-otp-whatsapp` (EN/AR,
|
|
|
135
147
|
|
|
136
148
|
## Options
|
|
137
149
|
|
|
138
|
-
| Option | Type | Default | Meaning
|
|
139
|
-
| ------------------ | -------------------------------- | -------------------------- |
|
|
140
|
-
| `
|
|
141
|
-
| `channels` | `("sms"\|"whatsapp"\|"email")[]` | required
|
|
142
|
-
| `ttlMs` | `number` | 10m | Challenge lifetime
|
|
143
|
-
| `resendCooldownMs` | `number` | 60s |
|
|
144
|
-
| `exposeDevOtp` | `boolean` | `false` |
|
|
145
|
-
| `from` | `string` | `OKE <no-reply@oke.local>` | Email template From
|
|
146
|
-
| `secret` | `string` | active\* | Auth secret (\*from `gate.auth`)
|
|
147
|
-
| `sessions` | `SessionStore` | active\* | Session store
|
|
148
|
-
| `identities` | `IdentityStore` | new | Email → user
|
|
149
|
-
| `phones` | `PhoneStore` | new | Phone → user
|
|
150
|
-
| `verifications` | `VerificationStore` | new | Challenge store
|
|
150
|
+
| Option | Type | Default | Meaning |
|
|
151
|
+
| ------------------ | -------------------------------- | -------------------------- | ----------------------------------- |
|
|
152
|
+
| `mode` | `"provider" \| "app"` | required | Delivery mechanism — no auto |
|
|
153
|
+
| `channels` | `("sms"\|"whatsapp"\|"email")[]` | required in app mode | Build-time preferred order |
|
|
154
|
+
| `ttlMs` | `number` | 10m | Challenge lifetime |
|
|
155
|
+
| `resendCooldownMs` | `number` | 60s | App-mode resend spacing |
|
|
156
|
+
| `exposeDevOtp` | `boolean` | `false` | App mode only — raw OTP in response |
|
|
157
|
+
| `from` | `string` | `OKE <no-reply@oke.local>` | Email template From |
|
|
158
|
+
| `secret` | `string` | active\* | Auth secret (\*from `gate.auth`) |
|
|
159
|
+
| `sessions` | `SessionStore` | active\* | Session store |
|
|
160
|
+
| `identities` | `IdentityStore` | new | Email → user |
|
|
161
|
+
| `phones` | `PhoneStore` | new | Phone → user |
|
|
162
|
+
| `verifications` | `VerificationStore` | new | Challenge store |
|
|
151
163
|
|
|
152
164
|
## Surfaces
|
|
153
165
|
|
|
154
|
-
| Flow | Path | Gate |
|
|
155
|
-
| ----------------- | ------------------------ | ------------------------ |
|
|
156
|
-
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate |
|
|
157
|
-
| `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate |
|
|
158
|
-
| `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate |
|
|
166
|
+
| Flow | Path | Gate | Mode |
|
|
167
|
+
| ----------------- | ------------------------ | ------------------------ | ------------- |
|
|
168
|
+
| `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | both |
|
|
169
|
+
| `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | both |
|
|
170
|
+
| `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | app mode only |
|
|
159
171
|
|
|
160
172
|
## Troubleshooting
|
|
161
173
|
|
|
162
174
|
<Accordions>
|
|
163
|
-
<Accordion title='otp():
|
|
175
|
+
<Accordion title='otp(): mode is required'>
|
|
164
176
|
|
|
165
|
-
You omitted `
|
|
166
|
-
which mechanism you meant.
|
|
177
|
+
You omitted `mode`. Set `mode: "provider"` or `mode: "app"` explicitly — OKE
|
|
178
|
+
never infers which mechanism you meant.
|
|
167
179
|
|
|
168
180
|
</Accordion>
|
|
169
|
-
<Accordion title="Boot fails
|
|
181
|
+
<Accordion title="Boot fails in provider mode">
|
|
170
182
|
|
|
171
183
|
No Verify-capable SMS driver is bound. Set `drivers.channel.sms` to `taqnyat`
|
|
172
184
|
(or another driver with `sendOtp`/`verifyOtp`), or switch to
|
|
173
|
-
`otp({
|
|
185
|
+
`otp({ mode: "app", channels: [...] })`.
|
|
174
186
|
|
|
175
187
|
</Accordion>
|
|
176
188
|
<Accordion title="resend_cooldown">
|
|
@@ -179,7 +191,7 @@ Wait for `resendCooldownMs` (default 60s). The challenge TTL is unchanged —
|
|
|
179
191
|
only delivery is rate-limited.
|
|
180
192
|
|
|
181
193
|
</Accordion>
|
|
182
|
-
<Accordion title="No email / SMS with the code (
|
|
194
|
+
<Accordion title="No email / SMS with the code (app mode)">
|
|
183
195
|
|
|
184
196
|
In `local` / `test` the `console` driver captures messages. Use
|
|
185
197
|
`exposeDevOtp: true` for unit tests without a real provider.
|
|
@@ -190,7 +202,7 @@ In `local` / `test` the `console` driver captures messages. Use
|
|
|
190
202
|
## Learn more
|
|
191
203
|
|
|
192
204
|
- [Magic link](/docs/plugins/magic-link) — link instead of a code
|
|
193
|
-
- [Channel](/docs/elements/channel) — `fx.send`, drivers, Mailpit
|
|
205
|
+
- [Channel](/docs/elements/channel) — `fx.send`, `fx.sendOtp`, drivers, Mailpit
|
|
194
206
|
- [Gate](/docs/elements/gate) — `gate.auth`
|
|
195
207
|
|
|
196
208
|
## Next
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Fail-loud capability checks for the otp() plugin (
|
|
2
|
+
* Fail-loud capability checks for the otp() plugin (provider / app mode).
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ChannelDriver, SmsOtpTransport } from "../drivers/channel-types.ts";
|
|
@@ -8,12 +8,12 @@ import type { OtpChannel } from "./verification.ts";
|
|
|
8
8
|
/** Config snapshot stored on the otp plugin for boot-time assertion. */
|
|
9
9
|
export interface OtpPluginConfig {
|
|
10
10
|
readonly method: "otp";
|
|
11
|
-
readonly
|
|
11
|
+
readonly mode: "provider" | "app";
|
|
12
12
|
readonly channels?: readonly OtpChannel[];
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
|
-
* Whether an SMS transport exposes provider
|
|
16
|
+
* Whether an SMS transport exposes provider OTP via Verify (structural).
|
|
17
17
|
*
|
|
18
18
|
* @param t - Candidate transport
|
|
19
19
|
*/
|
|
@@ -58,38 +58,38 @@ export function driverCoversMedium(drivers: readonly ChannelDriver[], medium: Ot
|
|
|
58
58
|
}
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
|
-
* Assert
|
|
61
|
+
* Assert provider mode: a Verify-capable SMS driver must be bound.
|
|
62
62
|
*
|
|
63
63
|
* @param drivers - Bound channel drivers
|
|
64
64
|
*/
|
|
65
|
-
export function
|
|
65
|
+
export function assertOtpProviderModeCapability(drivers: readonly ChannelDriver[]): void {
|
|
66
66
|
if (findOtpSmsDriver(drivers)) return;
|
|
67
67
|
const sms = drivers.filter((d) => d.smsTransport);
|
|
68
68
|
if (sms.length === 0) {
|
|
69
69
|
throw new Error(
|
|
70
|
-
'otp({
|
|
70
|
+
'otp({ mode: "provider" }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ mode: "app", channels: [...] })',
|
|
71
71
|
);
|
|
72
72
|
}
|
|
73
73
|
const id = sms[0]?.id ?? "unknown";
|
|
74
74
|
throw new Error(
|
|
75
|
-
`otp({
|
|
75
|
+
`otp({ mode: "provider" }): SMS driver "${id}" does not support provider OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ mode: "app", channels: [...] })`,
|
|
76
76
|
);
|
|
77
77
|
}
|
|
78
78
|
|
|
79
79
|
/**
|
|
80
|
-
* Assert
|
|
80
|
+
* Assert app mode: every declared channel has a deliverable driver.
|
|
81
81
|
*
|
|
82
82
|
* @param drivers - Bound channel drivers
|
|
83
83
|
* @param channels - Declared channel order
|
|
84
84
|
*/
|
|
85
|
-
export function
|
|
85
|
+
export function assertOtpAppModeChannels(
|
|
86
86
|
drivers: readonly ChannelDriver[],
|
|
87
87
|
channels: readonly OtpChannel[],
|
|
88
88
|
): void {
|
|
89
89
|
for (const ch of channels) {
|
|
90
90
|
if (!driverCoversMedium(drivers, ch)) {
|
|
91
91
|
throw new Error(
|
|
92
|
-
`otp({
|
|
92
|
+
`otp({ mode: "app" }): no channel driver covers "${ch}" — configure drivers.channel.${ch === "email" ? "email" : ch === "sms" ? "sms" : "whatsapp"}`,
|
|
93
93
|
);
|
|
94
94
|
}
|
|
95
95
|
}
|
|
@@ -108,12 +108,12 @@ export function assertOtpPluginCapability(
|
|
|
108
108
|
if (!config || typeof config !== "object") return;
|
|
109
109
|
const c = config as Partial<OtpPluginConfig>;
|
|
110
110
|
if (c.method !== "otp") return;
|
|
111
|
-
if (c.
|
|
112
|
-
|
|
111
|
+
if (c.mode === "provider") {
|
|
112
|
+
assertOtpProviderModeCapability(drivers);
|
|
113
113
|
return;
|
|
114
114
|
}
|
|
115
|
-
if (c.
|
|
115
|
+
if (c.mode === "app") {
|
|
116
116
|
const channels = c.channels ?? [];
|
|
117
|
-
|
|
117
|
+
assertOtpAppModeChannels(drivers, channels);
|
|
118
118
|
}
|
|
119
119
|
}
|
package/src/kernel/app.ts
CHANGED
|
@@ -844,7 +844,7 @@ export function oke(options: OkeOptions): OkeApp {
|
|
|
844
844
|
};
|
|
845
845
|
const result = await bootApplication(merged);
|
|
846
846
|
bootResult = result;
|
|
847
|
-
// otp()
|
|
847
|
+
// otp() provider / app mode capability — fail loud at boot, never silent downgrade.
|
|
848
848
|
if (result.channel) {
|
|
849
849
|
const { assertOtpPluginCapability } = await import("../auth/otp-capability.ts");
|
|
850
850
|
for (const entry of pluginRegistry.installed) {
|
|
@@ -290,7 +290,7 @@ describe("auth delivery — Mailpit integration", () => {
|
|
|
290
290
|
config: {
|
|
291
291
|
drivers: { channel: { email: { test: "smtp" } } },
|
|
292
292
|
},
|
|
293
|
-
}).plug(otp({
|
|
293
|
+
}).plug(otp({ mode: "app", channels: ["email"], exposeDevOtp: true }));
|
|
294
294
|
await app.boot({ env: "test" });
|
|
295
295
|
|
|
296
296
|
const res = await app.fetch(jsonPost("/auth/otp/request", { email }));
|
|
@@ -50,7 +50,7 @@ function fullAuthApp() {
|
|
|
50
50
|
.plug(username())
|
|
51
51
|
.plug(anonymous())
|
|
52
52
|
.plug(magicLink({ exposeDevToken: true }))
|
|
53
|
-
.plug(otp({
|
|
53
|
+
.plug(otp({ mode: "app", channels: ["email", "sms"], exposeDevOtp: true }))
|
|
54
54
|
.plug(twoFactor())
|
|
55
55
|
.plug(passkey({ origins: ["http://localhost"] }));
|
|
56
56
|
}
|
|
@@ -418,7 +418,7 @@ describe("auth methods — anonymous non-escalation", () => {
|
|
|
418
418
|
});
|
|
419
419
|
|
|
420
420
|
describe("auth methods — channel delivery", () => {
|
|
421
|
-
test("magic uses fx.send; otp
|
|
421
|
+
test("magic uses fx.send; otp app mode uses fx.deliverOtp; exposeDev* stays off by default", async () => {
|
|
422
422
|
resetBindings();
|
|
423
423
|
resetFlowSeq();
|
|
424
424
|
const app = oke({
|
|
@@ -428,7 +428,7 @@ describe("auth methods — channel delivery", () => {
|
|
|
428
428
|
gate: { auth: { secret: SECRET, emailAndPassword: { enabled: true } } },
|
|
429
429
|
})
|
|
430
430
|
.plug(magicLink())
|
|
431
|
-
.plug(otp({
|
|
431
|
+
.plug(otp({ mode: "app", channels: ["email", "sms"] }));
|
|
432
432
|
await app.boot({ env: "test" });
|
|
433
433
|
|
|
434
434
|
const ml = await app.fetch(jsonPost("/auth/magic-link/request", { email: "x@example.com" }));
|
|
@@ -31,7 +31,7 @@ function authApp() {
|
|
|
31
31
|
.plug(username())
|
|
32
32
|
.plug(anonymous())
|
|
33
33
|
.plug(magicLink({ exposeDevToken: true }))
|
|
34
|
-
.plug(otp({
|
|
34
|
+
.plug(otp({ mode: "app", channels: ["email"], exposeDevOtp: true }));
|
|
35
35
|
}
|
|
36
36
|
|
|
37
37
|
describe("auth method plugins", () => {
|
package/src/plugins/index.ts
CHANGED
|
@@ -30,9 +30,9 @@ export {
|
|
|
30
30
|
otpEmailTemplate,
|
|
31
31
|
otpSmsTemplate,
|
|
32
32
|
otpWhatsappTemplate,
|
|
33
|
+
type OtpAppModeOptions,
|
|
33
34
|
type OtpOptions,
|
|
34
|
-
type
|
|
35
|
-
type OtpTier2Options,
|
|
35
|
+
type OtpProviderModeOptions,
|
|
36
36
|
type PhoneStore,
|
|
37
37
|
} from "./otp.ts";
|
|
38
38
|
export {
|
package/src/plugins/otp.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* otp() plugin — plug-time fail-loud,
|
|
2
|
+
* otp() plugin — plug-time fail-loud, app-mode resend / sealed lifetime, provider-mode boot.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import { afterEach, describe, expect, test } from "bun:test";
|
|
@@ -71,44 +71,46 @@ function mockOtpSmsDriver(): ChannelDriver {
|
|
|
71
71
|
}
|
|
72
72
|
|
|
73
73
|
describe("otp() plug-time fail-loud", () => {
|
|
74
|
-
test("missing
|
|
75
|
-
expect(() => otp({} as never)).toThrow(/
|
|
74
|
+
test("missing mode throws", () => {
|
|
75
|
+
expect(() => otp({} as never)).toThrow(/mode is required/);
|
|
76
76
|
});
|
|
77
77
|
|
|
78
|
-
test("
|
|
79
|
-
expect(() => otp({
|
|
78
|
+
test('mode "provider" with channels throws', () => {
|
|
79
|
+
expect(() => otp({ mode: "provider", channels: ["sms"] } as never)).toThrow(
|
|
80
|
+
/channels are forbidden/,
|
|
81
|
+
);
|
|
80
82
|
});
|
|
81
83
|
|
|
82
|
-
test("
|
|
83
|
-
expect(() => otp({
|
|
84
|
+
test('mode "provider" with exposeDevOtp throws', () => {
|
|
85
|
+
expect(() => otp({ mode: "provider", exposeDevOtp: true } as never)).toThrow(
|
|
84
86
|
/exposeDevOtp is forbidden/,
|
|
85
87
|
);
|
|
86
88
|
});
|
|
87
89
|
|
|
88
|
-
test("
|
|
89
|
-
expect(() => otp({
|
|
90
|
+
test('mode "app" without channels throws', () => {
|
|
91
|
+
expect(() => otp({ mode: "app" } as never)).toThrow(/channels is required/);
|
|
90
92
|
});
|
|
91
93
|
});
|
|
92
94
|
|
|
93
|
-
describe("otp()
|
|
95
|
+
describe("otp() provider mode boot", () => {
|
|
94
96
|
test("fails loud without Verify-capable SMS driver", async () => {
|
|
95
97
|
const app = oke({
|
|
96
|
-
name: `otp-
|
|
98
|
+
name: `otp-provider-${crypto.randomUUID()}`,
|
|
97
99
|
env: "test",
|
|
98
100
|
registry: "ignore",
|
|
99
101
|
gate: { auth: { secret: SECRET } },
|
|
100
|
-
}).plug(otp({
|
|
101
|
-
await expect(app.boot({ env: "test" })).rejects.toThrow(/
|
|
102
|
+
}).plug(otp({ mode: "provider" }));
|
|
103
|
+
await expect(app.boot({ env: "test" })).rejects.toThrow(/mode: "provider"/);
|
|
102
104
|
});
|
|
103
105
|
|
|
104
106
|
test("boots when Verify-capable SMS driver is bound", async () => {
|
|
105
107
|
const app = oke({
|
|
106
|
-
name: `otp-
|
|
108
|
+
name: `otp-provider-ok-${crypto.randomUUID()}`,
|
|
107
109
|
env: "test",
|
|
108
110
|
registry: "ignore",
|
|
109
111
|
gate: { auth: { secret: SECRET } },
|
|
110
112
|
channel: { drivers: [mockOtpSmsDriver()] },
|
|
111
|
-
}).plug(otp({
|
|
113
|
+
}).plug(otp({ mode: "provider" }));
|
|
112
114
|
await app.boot({ env: "test" });
|
|
113
115
|
const res = await app.fetch(jsonPost("/auth/otp/request", { phone: "+15551234567" }));
|
|
114
116
|
expect(res.status).toBe(200);
|
|
@@ -120,12 +122,12 @@ describe("otp() Tier 1 boot", () => {
|
|
|
120
122
|
});
|
|
121
123
|
});
|
|
122
124
|
|
|
123
|
-
describe("otp()
|
|
125
|
+
describe("otp() app mode resend + sealed lifetime", () => {
|
|
124
126
|
test("cross-channel resend keeps same code; cooldown then verify wipes seal", async () => {
|
|
125
127
|
let now = 1_000_000;
|
|
126
128
|
const verifications: VerificationStore = createVerificationStore();
|
|
127
129
|
const app = oke({
|
|
128
|
-
name: `otp-
|
|
130
|
+
name: `otp-app-${crypto.randomUUID()}`,
|
|
129
131
|
env: "test",
|
|
130
132
|
registry: "ignore",
|
|
131
133
|
gate: { auth: { secret: SECRET } },
|
|
@@ -133,7 +135,7 @@ describe("otp() Tier 2 resend + sealed lifetime", () => {
|
|
|
133
135
|
fx: { now: () => now },
|
|
134
136
|
}).plug(
|
|
135
137
|
otp({
|
|
136
|
-
|
|
138
|
+
mode: "app",
|
|
137
139
|
channels: ["sms", "email"],
|
|
138
140
|
exposeDevOtp: true,
|
|
139
141
|
resendCooldownMs: 60_000,
|
|
@@ -209,7 +211,7 @@ describe("otp() Tier 2 resend + sealed lifetime", () => {
|
|
|
209
211
|
fx: { now: () => now },
|
|
210
212
|
}).plug(
|
|
211
213
|
otp({
|
|
212
|
-
|
|
214
|
+
mode: "app",
|
|
213
215
|
channels: ["email"],
|
|
214
216
|
exposeDevOtp: true,
|
|
215
217
|
ttlMs: 1_000,
|
package/src/plugins/otp.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Unified OTP Gate auth method plugin —
|
|
3
|
-
* (
|
|
2
|
+
* Unified OTP Gate auth method plugin — provider mode (Verify) or app mode
|
|
3
|
+
* (multi-channel). Replaces emailOtp() + phoneNumber().
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import { constantTimeEqual } from "../auth/constant-time.ts";
|
|
@@ -66,20 +66,20 @@ interface OtpBaseOptions extends AuthMethodOptions {
|
|
|
66
66
|
readonly identities?: IdentityStore;
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
/**
|
|
70
|
-
export interface
|
|
71
|
-
readonly
|
|
72
|
-
/** Forbidden
|
|
69
|
+
/** Provider mode — provider-owned OTP via fx.sendOtp / fx.verifyOtp. */
|
|
70
|
+
export interface OtpProviderModeOptions extends OtpBaseOptions {
|
|
71
|
+
readonly mode: "provider";
|
|
72
|
+
/** Forbidden in provider mode — fail loud if set. */
|
|
73
73
|
readonly channels?: never;
|
|
74
|
-
/** Forbidden
|
|
74
|
+
/** Forbidden in provider mode — code never exists server-side. */
|
|
75
75
|
readonly exposeDevOtp?: never;
|
|
76
76
|
readonly resendCooldownMs?: never;
|
|
77
77
|
readonly from?: never;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
/**
|
|
81
|
-
export interface
|
|
82
|
-
readonly
|
|
80
|
+
/** App mode — app-owned OTP with multi-channel delivery. */
|
|
81
|
+
export interface OtpAppModeOptions extends OtpBaseOptions {
|
|
82
|
+
readonly mode: "app";
|
|
83
83
|
/** Build-time preferred channel order (required). */
|
|
84
84
|
readonly channels: readonly OtpChannel[];
|
|
85
85
|
readonly resendCooldownMs?: number;
|
|
@@ -87,24 +87,24 @@ export interface OtpTier2Options extends OtpBaseOptions {
|
|
|
87
87
|
readonly from?: string;
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
/** Options for {@link otp}. `
|
|
91
|
-
export type OtpOptions =
|
|
90
|
+
/** Options for {@link otp}. `mode` is mandatory — no auto-detect. */
|
|
91
|
+
export type OtpOptions = OtpProviderModeOptions | OtpAppModeOptions;
|
|
92
92
|
|
|
93
|
-
/** Email OTP template (
|
|
93
|
+
/** Email OTP template (app mode). */
|
|
94
94
|
export const otpEmailTemplate = channel.email({ from: DEFAULT_FROM }).template("auth-otp-email", {
|
|
95
95
|
description: "OTP sign-in code (email)",
|
|
96
96
|
schema: z.object({ email: z.string(), otp: z.string() }),
|
|
97
97
|
locales: ["en", "ar"],
|
|
98
98
|
});
|
|
99
99
|
|
|
100
|
-
/** SMS OTP template (
|
|
100
|
+
/** SMS OTP template (app mode — plain message, not provider Verify). */
|
|
101
101
|
export const otpSmsTemplate = channel.sms().template("auth-otp-sms", {
|
|
102
102
|
description: "OTP sign-in code (SMS)",
|
|
103
103
|
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
104
104
|
locales: ["en", "ar"],
|
|
105
105
|
});
|
|
106
106
|
|
|
107
|
-
/** WhatsApp OTP template (
|
|
107
|
+
/** WhatsApp OTP template (app mode). */
|
|
108
108
|
export const otpWhatsappTemplate = channel.whatsapp().template("auth-otp-whatsapp", {
|
|
109
109
|
description: "OTP sign-in code (WhatsApp)",
|
|
110
110
|
schema: z.object({ phone: z.string(), otp: z.string() }),
|
|
@@ -136,18 +136,20 @@ export const otpCatalog = {
|
|
|
136
136
|
} as const;
|
|
137
137
|
|
|
138
138
|
function assertOtpOptions(opts: OtpOptions): void {
|
|
139
|
-
if (opts.
|
|
140
|
-
throw new Error(
|
|
139
|
+
if (opts.mode !== "provider" && opts.mode !== "app") {
|
|
140
|
+
throw new Error(
|
|
141
|
+
'otp(): mode is required — set mode: "provider" or mode: "app" (no auto-detect)',
|
|
142
|
+
);
|
|
141
143
|
}
|
|
142
|
-
if (opts.
|
|
144
|
+
if (opts.mode === "provider") {
|
|
143
145
|
if ("channels" in opts && opts.channels !== undefined) {
|
|
144
146
|
throw new Error(
|
|
145
|
-
|
|
147
|
+
'otp({ mode: "provider" }): channels are forbidden — provider-owned OTP cannot resend via a different channel; use mode: "app" for multi-channel',
|
|
146
148
|
);
|
|
147
149
|
}
|
|
148
150
|
if ("exposeDevOtp" in opts && (opts as { exposeDevOtp?: boolean }).exposeDevOtp === true) {
|
|
149
151
|
throw new Error(
|
|
150
|
-
|
|
152
|
+
'otp({ mode: "provider" }): exposeDevOtp is forbidden — the code never exists server-side',
|
|
151
153
|
);
|
|
152
154
|
}
|
|
153
155
|
return;
|
|
@@ -155,12 +157,12 @@ function assertOtpOptions(opts: OtpOptions): void {
|
|
|
155
157
|
const channels = opts.channels;
|
|
156
158
|
if (!channels || channels.length === 0) {
|
|
157
159
|
throw new Error(
|
|
158
|
-
'otp({
|
|
160
|
+
'otp({ mode: "app" }): channels is required — declare at least one of "sms" | "whatsapp" | "email"',
|
|
159
161
|
);
|
|
160
162
|
}
|
|
161
163
|
for (const ch of channels) {
|
|
162
164
|
if (!(OTP_CHANNELS as readonly string[]).includes(ch)) {
|
|
163
|
-
throw new Error(`otp({
|
|
165
|
+
throw new Error(`otp({ mode: "app" }): unknown channel "${String(ch)}"`);
|
|
164
166
|
}
|
|
165
167
|
}
|
|
166
168
|
}
|
|
@@ -186,9 +188,9 @@ function pickRequestChannel(
|
|
|
186
188
|
}
|
|
187
189
|
|
|
188
190
|
/**
|
|
189
|
-
* Unified OTP request + verify (+
|
|
191
|
+
* Unified OTP request + verify (+ app-mode resend).
|
|
190
192
|
*
|
|
191
|
-
* @param opts - Must include `
|
|
193
|
+
* @param opts - Must include `mode: "provider"` or `mode: "app"`
|
|
192
194
|
*/
|
|
193
195
|
export function otp(opts: OtpOptions): PluginDef {
|
|
194
196
|
assertOtpOptions(opts);
|
|
@@ -200,12 +202,12 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
200
202
|
const verifications = opts.verifications ?? createVerificationStore();
|
|
201
203
|
const ttlMs = opts.ttlMs ?? DEFAULT_TTL_MS;
|
|
202
204
|
const resendCooldownMs =
|
|
203
|
-
opts.
|
|
205
|
+
opts.mode === "app"
|
|
204
206
|
? (opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS)
|
|
205
207
|
: DEFAULT_RESEND_COOLDOWN_MS;
|
|
206
|
-
const channels = opts.
|
|
207
|
-
const exposeDevOtp = opts.
|
|
208
|
-
const from = opts.
|
|
208
|
+
const channels = opts.mode === "app" ? opts.channels : ([] as const);
|
|
209
|
+
const exposeDevOtp = opts.mode === "app" && opts.exposeDevOtp === true;
|
|
210
|
+
const from = opts.mode === "app" ? opts.from : undefined;
|
|
209
211
|
|
|
210
212
|
const emailTmpl =
|
|
211
213
|
from !== undefined
|
|
@@ -223,9 +225,9 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
223
225
|
} as const;
|
|
224
226
|
|
|
225
227
|
const configSnapshot: OtpPluginConfig =
|
|
226
|
-
opts.
|
|
227
|
-
? { method: "otp",
|
|
228
|
-
: { method: "otp",
|
|
228
|
+
opts.mode === "provider"
|
|
229
|
+
? { method: "otp", mode: "provider" }
|
|
230
|
+
: { method: "otp", mode: "app", channels: [...channels] };
|
|
229
231
|
|
|
230
232
|
const requestOut = z.object({
|
|
231
233
|
ok: z.literal(true),
|
|
@@ -233,7 +235,7 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
233
235
|
channel: z.enum(["sms", "whatsapp", "email"]).optional(),
|
|
234
236
|
});
|
|
235
237
|
|
|
236
|
-
if (opts.
|
|
238
|
+
if (opts.mode === "provider") {
|
|
237
239
|
const request = flow({
|
|
238
240
|
name: "auth.requestOtp",
|
|
239
241
|
unit: "auth",
|
|
@@ -270,7 +272,7 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
270
272
|
phone,
|
|
271
273
|
sealedOtp: null,
|
|
272
274
|
});
|
|
273
|
-
//
|
|
275
|
+
// Provider mode: resend-via-different-channel is impossible — provider owns the code.
|
|
274
276
|
return { ok: true as const, channel: "sms" as const };
|
|
275
277
|
},
|
|
276
278
|
});
|
|
@@ -344,7 +346,7 @@ export function otp(opts: OtpOptions): PluginDef {
|
|
|
344
346
|
.binding(bindPublicAuth("/otp/verify", verify, "otp"));
|
|
345
347
|
}
|
|
346
348
|
|
|
347
|
-
// ──
|
|
349
|
+
// ── App mode ───────────────────────────────────────────────────────────
|
|
348
350
|
const request = flow({
|
|
349
351
|
name: "auth.requestOtp",
|
|
350
352
|
unit: "auth",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Real Taqnyat end-to-end: otp({
|
|
2
|
+
* Real Taqnyat end-to-end: otp({ mode: "provider" }) via Verify API + magic-link via Taqnyat Mail.
|
|
3
3
|
*
|
|
4
4
|
* Double-gated — runs ONLY when BOTH:
|
|
5
5
|
* 1. The global per-medium opt-in flag is set explicitly
|
|
@@ -82,7 +82,7 @@ if (!LIVE_MAIL) {
|
|
|
82
82
|
const liveSms = LIVE_SMS ? test : test.skip;
|
|
83
83
|
const liveMail = LIVE_MAIL ? test : test.skip;
|
|
84
84
|
|
|
85
|
-
describe("taqnyat live — provider
|
|
85
|
+
describe("taqnyat live — provider OTP (Taqnyat Verify)", () => {
|
|
86
86
|
liveSms(
|
|
87
87
|
"phone OTP request → real sendOtp → Taqnyat success code 5",
|
|
88
88
|
async () => {
|
|
@@ -94,7 +94,7 @@ describe("taqnyat live — provider-managed OTP (Taqnyat Verify)", () => {
|
|
|
94
94
|
config: {
|
|
95
95
|
drivers: { channel: { sms: { test: "taqnyat" } } },
|
|
96
96
|
},
|
|
97
|
-
}).plug(otp({
|
|
97
|
+
}).plug(otp({ mode: "provider" }));
|
|
98
98
|
await app.boot({ env: "test" });
|
|
99
99
|
|
|
100
100
|
// Wrap the live transport so the plugin's single real send also proves
|