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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okengine",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "One law. Eight elements. Ten exports. One package. One manifest. Every backend need is derived, never added.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -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-managed OTP
155
+ ### Provider OTP (`fx.sendOtp` / `fx.verifyOtp`)
156
156
 
157
- Flows can also send a provider-generated SMS code with `fx.sendOtp({ to, requestId, lang? })`
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. The
163
- [OTP](/docs/plugins/otp) Tier 1 uses this path automatically.
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 Tier 1 or Tier 2 delivery."
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 `tier` — there is no
9
- auto-detect. Tier 1 is provider-owned SMS Verify; Tier 2 is app-owned delivery
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({ tier: 1 }))` or
14
- `.plug(otp({ tier: 2, channels: [...] }))`. Never omit `tier`. Never log raw
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 Tier 2 (multi-channel)
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
- tier: 2,
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 (Tier 2 only)
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. Tier 1 has no resend
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
- ## Tiers
103
+ ## Modes
92
104
 
93
- | | Tier 1 | Tier 2 |
105
+ | | Provider mode | App mode |
94
106
  | -------------------- | ----------------------------- | --------------------------------------------- |
95
- | Config | `otp({ tier: 1 })` | `otp({ tier: 2, channels: [...] })` |
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="Tier 1 limitation">
103
- Resend-via-different-channel is impossible on Tier 1 — the code value is never visible to OKE. Use
104
- Tier 2 when you need SMS → email fallback for the same code.
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
- ### Tier 1 setup
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
- Tier 2, or bind a Verify-capable driver (for example `taqnyat`).
132
+ app mode, or bind a Verify-capable driver (for example `taqnyat`).
121
133
 
122
- ### Tier 2 delivery
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
- | `tier` | `1 \| 2` | required | Delivery mechanism — no auto |
141
- | `channels` | `("sms"\|"whatsapp"\|"email")[]` | required on Tier 2 | Build-time preferred order |
142
- | `ttlMs` | `number` | 10m | Challenge lifetime |
143
- | `resendCooldownMs` | `number` | 60s | Tier 2 resend spacing |
144
- | `exposeDevOtp` | `boolean` | `false` | Tier 2 only — raw OTP in response |
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 | Tier |
155
- | ----------------- | ------------------------ | ------------------------ | ------ |
156
- | `auth.requestOtp` | `POST /auth/otp/request` | `gate.public` + otp rate | 1 + 2 |
157
- | `auth.verifyOtp` | `POST /auth/otp/verify` | `gate.public` + otp rate | 1 + 2 |
158
- | `auth.resendOtp` | `POST /auth/otp/resend` | `gate.public` + otp rate | 2 only |
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(): tier is required'>
175
+ <Accordion title='otp(): mode is required'>
164
176
 
165
- You omitted `tier`. Set `tier: 1` or `tier: 2` explicitly — OKE never infers
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 on Tier 1">
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({ tier: 2, channels: [...] })`.
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 (Tier 2)">
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 (Tier 1 / Tier 2).
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 tier: 1 | 2;
11
+ readonly mode: "provider" | "app";
12
12
  readonly channels?: readonly OtpChannel[];
13
13
  }
14
14
 
15
15
  /**
16
- * Whether an SMS transport exposes provider-managed OTP (structural).
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 Tier 1: a Verify-capable SMS driver must be bound.
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 assertOtpTier1Capability(drivers: readonly ChannelDriver[]): void {
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({ tier: 1 }): no SMS driver with sendOtp/verifyOtp bound — set drivers.channel.sms to a Verify-capable driver (e.g. "taqnyat"), or switch to otp({ tier: 2, channels: [...] })',
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({ tier: 1 }): SMS driver "${id}" does not support provider-managed OTP — bind a Verify-capable driver (e.g. taqnyat), or switch to otp({ tier: 2, channels: [...] })`,
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 Tier 2: every declared channel has a deliverable driver.
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 assertOtpTier2Channels(
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({ tier: 2 }): no channel driver covers "${ch}" — configure drivers.channel.${ch === "email" ? "email" : ch === "sms" ? "sms" : "whatsapp"}`,
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.tier === 1) {
112
- assertOtpTier1Capability(drivers);
111
+ if (c.mode === "provider") {
112
+ assertOtpProviderModeCapability(drivers);
113
113
  return;
114
114
  }
115
- if (c.tier === 2) {
115
+ if (c.mode === "app") {
116
116
  const channels = c.channels ?? [];
117
- assertOtpTier2Channels(drivers, channels);
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() Tier 1 / Tier 2 capability — fail loud at boot, never silent downgrade.
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({ tier: 2, channels: ["email"], exposeDevOtp: true }));
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({ tier: 2, channels: ["email", "sms"], exposeDevOtp: true }))
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 Tier 2 uses fx.deliverOtp; exposeDev* stays off by default", async () => {
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({ tier: 2, channels: ["email", "sms"] }));
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({ tier: 2, channels: ["email"], exposeDevOtp: true }));
34
+ .plug(otp({ mode: "app", channels: ["email"], exposeDevOtp: true }));
35
35
  }
36
36
 
37
37
  describe("auth method plugins", () => {
@@ -30,9 +30,9 @@ export {
30
30
  otpEmailTemplate,
31
31
  otpSmsTemplate,
32
32
  otpWhatsappTemplate,
33
+ type OtpAppModeOptions,
33
34
  type OtpOptions,
34
- type OtpTier1Options,
35
- type OtpTier2Options,
35
+ type OtpProviderModeOptions,
36
36
  type PhoneStore,
37
37
  } from "./otp.ts";
38
38
  export {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * otp() plugin — plug-time fail-loud, Tier 2 resend / sealed lifetime, Tier 1 boot.
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 tier throws", () => {
75
- expect(() => otp({} as never)).toThrow(/tier is required/);
74
+ test("missing mode throws", () => {
75
+ expect(() => otp({} as never)).toThrow(/mode is required/);
76
76
  });
77
77
 
78
- test("tier 1 with channels throws", () => {
79
- expect(() => otp({ tier: 1, channels: ["sms"] } as never)).toThrow(/channels are forbidden/);
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("tier 1 with exposeDevOtp throws", () => {
83
- expect(() => otp({ tier: 1, exposeDevOtp: true } as never)).toThrow(
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("tier 2 without channels throws", () => {
89
- expect(() => otp({ tier: 2 } as never)).toThrow(/channels is required/);
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() Tier 1 boot", () => {
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-t1-${crypto.randomUUID()}`,
98
+ name: `otp-provider-${crypto.randomUUID()}`,
97
99
  env: "test",
98
100
  registry: "ignore",
99
101
  gate: { auth: { secret: SECRET } },
100
- }).plug(otp({ tier: 1 }));
101
- await expect(app.boot({ env: "test" })).rejects.toThrow(/tier: 1/);
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-t1-ok-${crypto.randomUUID()}`,
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({ tier: 1 }));
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() Tier 2 resend + sealed lifetime", () => {
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-t2-${crypto.randomUUID()}`,
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
- tier: 2,
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
- tier: 2,
214
+ mode: "app",
213
215
  channels: ["email"],
214
216
  exposeDevOtp: true,
215
217
  ttlMs: 1_000,
@@ -1,6 +1,6 @@
1
1
  /**
2
- * Unified OTP Gate auth method plugin — Tier 1 (provider-owned) or Tier 2
3
- * (app-owned multi-channel). Replaces emailOtp() + phoneNumber().
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
- /** Tier 1 — provider-owned OTP via fx.sendOtp / fx.verifyOtp. */
70
- export interface OtpTier1Options extends OtpBaseOptions {
71
- readonly tier: 1;
72
- /** Forbidden on Tier 1 — fail loud if set. */
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 on Tier 1 — code never exists server-side. */
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
- /** Tier 2 — app-owned OTP with multi-channel delivery. */
81
- export interface OtpTier2Options extends OtpBaseOptions {
82
- readonly tier: 2;
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}. `tier` is mandatory — no auto-detect. */
91
- export type OtpOptions = OtpTier1Options | OtpTier2Options;
90
+ /** Options for {@link otp}. `mode` is mandatory — no auto-detect. */
91
+ export type OtpOptions = OtpProviderModeOptions | OtpAppModeOptions;
92
92
 
93
- /** Email OTP template (Tier 2). */
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 (Tier 2 — plain message, not provider Verify). */
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 (Tier 2). */
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.tier !== 1 && opts.tier !== 2) {
140
- throw new Error("otp(): tier is required — set tier: 1 or tier: 2 (no auto-detect)");
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.tier === 1) {
144
+ if (opts.mode === "provider") {
143
145
  if ("channels" in opts && opts.channels !== undefined) {
144
146
  throw new Error(
145
- "otp({ tier: 1 }): channels are forbidden — provider-owned OTP cannot resend via a different channel; use tier: 2 for multi-channel",
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
- "otp({ tier: 1 }): exposeDevOtp is forbidden — the code never exists server-side",
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({ tier: 2 }): channels is required — declare at least one of "sms" | "whatsapp" | "email"',
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({ tier: 2 }): unknown channel "${String(ch)}"`);
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 (+ Tier-2 resend).
191
+ * Unified OTP request + verify (+ app-mode resend).
190
192
  *
191
- * @param opts - Must include `tier: 1` or `tier: 2`
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.tier === 2
205
+ opts.mode === "app"
204
206
  ? (opts.resendCooldownMs ?? DEFAULT_RESEND_COOLDOWN_MS)
205
207
  : DEFAULT_RESEND_COOLDOWN_MS;
206
- const channels = opts.tier === 2 ? opts.channels : ([] as const);
207
- const exposeDevOtp = opts.tier === 2 && opts.exposeDevOtp === true;
208
- const from = opts.tier === 2 ? opts.from : undefined;
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.tier === 1
227
- ? { method: "otp", tier: 1 }
228
- : { method: "otp", tier: 2, channels: [...channels] };
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.tier === 1) {
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
- // Tier 1: resend-via-different-channel is impossible — provider owns the code.
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
- // ── Tier 2 ────────────────────────────────────────────────────────────
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({ tier: 1 }) via Verify API + magic-link via Taqnyat Mail.
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-managed OTP (Taqnyat Verify)", () => {
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({ tier: 1 }));
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