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
@@ -11,7 +11,12 @@ import type {
11
11
  ChannelAttempt,
12
12
  ChannelDriver,
13
13
  ChannelMessage,
14
+ ChannelOtpSendOptions,
15
+ ChannelOtpSendResult,
16
+ ChannelOtpVerifyOptions,
17
+ ChannelOtpVerifyResult,
14
18
  ChannelSendResult,
19
+ SmsOtpTransport,
15
20
  } from "../../drivers/channel-types.ts";
16
21
  import type { ChannelMedium } from "../../manifest/types.ts";
17
22
  import type { ConsentStore } from "./consent.ts";
@@ -27,6 +32,11 @@ import {
27
32
  type ReceiptLedger,
28
33
  } from "./receipts.ts";
29
34
  import { createSuppressionStore, type SuppressionStore } from "./suppression.ts";
35
+ import {
36
+ deliverOtpAcrossChannels,
37
+ type DeliverOtpOptions,
38
+ type DeliverOtpResult,
39
+ } from "./otp-delivery.ts";
30
40
 
31
41
  /** Locale catalog: template → locale → rendered body. */
32
42
  export type TemplateCatalog = Readonly<
@@ -85,6 +95,8 @@ export interface ChannelRuntime {
85
95
  readonly suppression: SuppressionStore;
86
96
  readonly receipts: ReceiptLedger;
87
97
  readonly costs: MediumCosts;
98
+ /** Bound driver chain (email + SMS + …). */
99
+ readonly drivers: readonly ChannelDriver[];
88
100
  /**
89
101
  * Send a template through the driver chain.
90
102
  *
@@ -92,6 +104,29 @@ export interface ChannelRuntime {
92
104
  * @param options - Recipient / data / locale / via
93
105
  */
94
106
  send(template: string, options: ChannelSendOptions): Promise<ChannelSendResult>;
107
+ /**
108
+ * Send a provider-managed OTP via the bound Taqnyat SMS driver.
109
+ *
110
+ * Vendor extra (Taqnyat Verify API) — only valid when the configured SMS
111
+ * driver supports it. Throws loudly when no SMS driver is bound or the
112
+ * bound driver does not support provider-managed OTP.
113
+ *
114
+ * @param options - Recipient + requestId (+ lang / note / from)
115
+ */
116
+ sendOtp(options: ChannelOtpSendOptions): Promise<ChannelOtpSendResult>;
117
+ /**
118
+ * Verify a provider-managed OTP code via the bound Taqnyat SMS driver.
119
+ *
120
+ * @param options - Recipient + requestId + code (+ lang / note / from)
121
+ */
122
+ verifyOtp(options: ChannelOtpVerifyOptions): Promise<ChannelOtpVerifyResult>;
123
+ /**
124
+ * Tier-2 OTP delivery across declared channels (sently FallbackTransport).
125
+ * Pass `only` for explicit single-channel resend (no cross-medium failover).
126
+ *
127
+ * @param options - Channels, templates, addresses, OTP data
128
+ */
129
+ deliverOtp(options: DeliverOtpOptions): Promise<DeliverOtpResult>;
95
130
  /**
96
131
  * Ingest a post-send provider outcome (bounce / complaint / …).
97
132
  * Hard bounce auto-adds suppression. Console projects the ledger — never
@@ -107,15 +142,33 @@ export interface ChannelRuntime {
107
142
  *
108
143
  * @param options - Templates + drivers + catalog
109
144
  */
145
+ let channelProcessLocalWarned = false;
146
+
147
+ /** Test helper — reset the one-shot Channel process-local warn. */
148
+ export function resetChannelProcessLocalWarnForTests(): void {
149
+ channelProcessLocalWarned = false;
150
+ }
151
+
110
152
  export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}): ChannelRuntime {
111
153
  const templates = new Map<string, ChannelTemplateDecl>();
112
154
  for (const t of options.templates ?? []) {
113
155
  templates.set(t.name, t);
114
156
  }
157
+ const usingDefaultSuppression = options.suppression === undefined;
158
+ const usingDefaultReceipts = options.receipts === undefined;
115
159
  const suppression =
116
160
  options.suppression ??
117
161
  createSuppressionStore(options.consent ? { consent: options.consent } : {});
118
162
  const receipts = options.receipts ?? createReceiptLedger();
163
+ if ((usingDefaultSuppression || usingDefaultReceipts) && !channelProcessLocalWarned) {
164
+ channelProcessLocalWarned = true;
165
+ console.warn(
166
+ "oke boot: Channel suppression/consent/receipts default to process-local memory — " +
167
+ "opt-out, bounce, and receipt state on one instance is invisible to others. " +
168
+ "Inject shared stores for multi-instance, or run a single Channel consumer, until a " +
169
+ "durable driver ships.",
170
+ );
171
+ }
119
172
  const costs = options.costs ?? { ...DEFAULT_MEDIUM_COSTS };
120
173
  const catalog = options.catalog ?? {};
121
174
  const defaultLocale = options.defaultLocale ?? "en";
@@ -391,11 +444,160 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
391
444
  return "provider-error";
392
445
  }
393
446
 
447
+ function otpSmsDriver(): { driver: ChannelDriver; otp: SmsOtpTransport } {
448
+ const sms = drivers.filter((d) => d.smsTransport);
449
+ if (sms.length === 0) {
450
+ throw new Error(
451
+ "channel: no SMS driver bound — bind drivers.channel.sms (e.g. taqnyat) to send provider OTP",
452
+ );
453
+ }
454
+ for (const d of sms) {
455
+ const t = d.smsTransport as Partial<SmsOtpTransport> | undefined;
456
+ if (t && typeof t.sendOtp === "function" && typeof t.verifyOtp === "function") {
457
+ return { driver: d, otp: t as SmsOtpTransport };
458
+ }
459
+ }
460
+ const id = sms[0]?.id ?? "unknown";
461
+ throw new Error(
462
+ `channel: SMS driver "${id}" does not support provider-managed OTP; use exposeDevOtp locally or set drivers.channel.sms to "taqnyat"`,
463
+ );
464
+ }
465
+
466
+ async function sendTemplate(
467
+ template: string,
468
+ opts: ChannelSendOptions,
469
+ ): Promise<ChannelSendResult> {
470
+ const decl = templates.get(template);
471
+ if (!decl) {
472
+ throw new Error(`channel: unknown template "${template}"`);
473
+ }
474
+ const medium = decl.medium;
475
+ const checkMedium: ChannelMedium = medium === "any" ? "email" : medium;
476
+
477
+ const suppressed = suppression.isSuppressed(opts.to, checkMedium);
478
+ if (suppressed.suppressed) {
479
+ const status: DeliveryStatus =
480
+ suppressed.reason === "opted-out" ? "suppressed/opted-out" : "suppressed/prior-bounce";
481
+ const resolved = resolveLocale({
482
+ locale: opts.locale,
483
+ profileLocale: opts.profileLocale,
484
+ acceptLanguage: opts.acceptLanguage,
485
+ defaultLocale,
486
+ });
487
+ const receipt: DeliveryReceipt = {
488
+ id: crypto.randomUUID(),
489
+ template,
490
+ to: opts.to,
491
+ medium,
492
+ locale: resolved.locale,
493
+ localeChain: resolved.chain,
494
+ status,
495
+ attempts: [],
496
+ at: now(),
497
+ error: suppressed.reason === "opted-out" ? "opted out" : "prior hard bounce",
498
+ };
499
+ receipts.record(receipt);
500
+ return {
501
+ ok: false,
502
+ messageId: receipt.id,
503
+ driverId: "suppression",
504
+ attempts: [],
505
+ };
506
+ }
507
+
508
+ const resolved = resolveLocale({
509
+ locale: opts.locale,
510
+ profileLocale: opts.profileLocale,
511
+ acceptLanguage: opts.acceptLanguage,
512
+ defaultLocale,
513
+ });
514
+ const locale = resolved.locale;
515
+ const localeChain: readonly LocaleChainStep[] = resolved.chain;
516
+ const body = resolveBody(template, locale, opts.data ?? {});
517
+ const chain = driversFor(medium, opts.via);
518
+
519
+ let result: ChannelSendResult;
520
+ let attempts: ChannelAttempt[];
521
+
522
+ if (medium === "email" || (medium === "any" && chain.some((d) => d.transport))) {
523
+ const from = decl.from ?? "oke@localhost";
524
+ const sendResult = await sendViaEmailChain(chain, {
525
+ from,
526
+ to: opts.to,
527
+ subject: opts.subject ?? body.subject ?? template,
528
+ text: body.text,
529
+ html: body.html,
530
+ template,
531
+ data: opts.data ? { ...opts.data } : undefined,
532
+ });
533
+ result = sendResult.result;
534
+ attempts = sendResult.attempts;
535
+ } else {
536
+ const sendResult = await sendViaChannelChain(chain, {
537
+ medium: medium === "any" ? "sms" : medium,
538
+ to: opts.to,
539
+ from: decl.from,
540
+ subject: opts.subject ?? body.subject,
541
+ text: body.text,
542
+ html: body.html,
543
+ data: opts.data,
544
+ locale,
545
+ template,
546
+ pushSubscription: opts.pushSubscription,
547
+ });
548
+ result = sendResult.result;
549
+ attempts = sendResult.attempts;
550
+ }
551
+
552
+ let status: DeliveryStatus;
553
+ if (result.ok) {
554
+ status = attempts.filter((a) => !a.ok).length > 0 ? "fallback" : "sent";
555
+ } else {
556
+ status = classifySendFailure(attempts);
557
+ }
558
+
559
+ receipts.record({
560
+ id: crypto.randomUUID(),
561
+ template,
562
+ to: opts.to,
563
+ medium,
564
+ locale,
565
+ localeChain,
566
+ status,
567
+ messageId: result.messageId,
568
+ driverId: result.driverId,
569
+ attempts,
570
+ at: now(),
571
+ ...(result.ok
572
+ ? {}
573
+ : {
574
+ error: attempts
575
+ .map((a) => a.error)
576
+ .filter(Boolean)
577
+ .join("; "),
578
+ }),
579
+ });
580
+
581
+ return { ...result, attempts };
582
+ }
583
+
394
584
  return {
395
585
  templates,
396
586
  suppression,
397
587
  receipts,
398
588
  costs,
589
+ drivers,
590
+ async sendOtp(opts) {
591
+ const { otp } = otpSmsDriver();
592
+ return otp.sendOtp(opts);
593
+ },
594
+ async verifyOtp(opts) {
595
+ const { otp } = otpSmsDriver();
596
+ return otp.verifyOtp(opts);
597
+ },
598
+ async deliverOtp(opts) {
599
+ return deliverOtpAcrossChannels(drivers, sendTemplate, opts, now);
600
+ },
399
601
  ingestOutcome(input) {
400
602
  const at = input.at ?? now();
401
603
  const existing = receipts.byMessageId(input.messageId);
@@ -430,119 +632,6 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
430
632
  receipts.record(receipt);
431
633
  return receipt;
432
634
  },
433
- async send(template, opts) {
434
- const decl = templates.get(template);
435
- if (!decl) {
436
- throw new Error(`channel: unknown template "${template}"`);
437
- }
438
- const medium = decl.medium;
439
- const checkMedium: ChannelMedium = medium === "any" ? "email" : medium;
440
-
441
- const suppressed = suppression.isSuppressed(opts.to, checkMedium);
442
- if (suppressed.suppressed) {
443
- const status: DeliveryStatus =
444
- suppressed.reason === "opted-out" ? "suppressed/opted-out" : "suppressed/prior-bounce";
445
- const resolved = resolveLocale({
446
- locale: opts.locale,
447
- profileLocale: opts.profileLocale,
448
- acceptLanguage: opts.acceptLanguage,
449
- defaultLocale,
450
- });
451
- const receipt: DeliveryReceipt = {
452
- id: crypto.randomUUID(),
453
- template,
454
- to: opts.to,
455
- medium,
456
- locale: resolved.locale,
457
- localeChain: resolved.chain,
458
- status,
459
- attempts: [],
460
- at: now(),
461
- error: suppressed.reason === "opted-out" ? "opted out" : "prior hard bounce",
462
- };
463
- receipts.record(receipt);
464
- return {
465
- ok: false,
466
- messageId: receipt.id,
467
- driverId: "suppression",
468
- attempts: [],
469
- };
470
- }
471
-
472
- const resolved = resolveLocale({
473
- locale: opts.locale,
474
- profileLocale: opts.profileLocale,
475
- acceptLanguage: opts.acceptLanguage,
476
- defaultLocale,
477
- });
478
- const locale = resolved.locale;
479
- const localeChain: readonly LocaleChainStep[] = resolved.chain;
480
- const body = resolveBody(template, locale, opts.data ?? {});
481
- const chain = driversFor(medium, opts.via);
482
-
483
- let result: ChannelSendResult;
484
- let attempts: ChannelAttempt[];
485
-
486
- if (medium === "email" || (medium === "any" && chain.some((d) => d.transport))) {
487
- const from = decl.from ?? "oke@localhost";
488
- const sendResult = await sendViaEmailChain(chain, {
489
- from,
490
- to: opts.to,
491
- subject: opts.subject ?? body.subject ?? template,
492
- text: body.text,
493
- html: body.html,
494
- template,
495
- data: opts.data ? { ...opts.data } : undefined,
496
- });
497
- result = sendResult.result;
498
- attempts = sendResult.attempts;
499
- } else {
500
- const sendResult = await sendViaChannelChain(chain, {
501
- medium: medium === "any" ? "sms" : medium,
502
- to: opts.to,
503
- from: decl.from,
504
- subject: opts.subject ?? body.subject,
505
- text: body.text,
506
- html: body.html,
507
- data: opts.data,
508
- locale,
509
- template,
510
- pushSubscription: opts.pushSubscription,
511
- });
512
- result = sendResult.result;
513
- attempts = sendResult.attempts;
514
- }
515
-
516
- let status: DeliveryStatus;
517
- if (result.ok) {
518
- status = attempts.filter((a) => !a.ok).length > 0 ? "fallback" : "sent";
519
- } else {
520
- status = classifySendFailure(attempts);
521
- }
522
-
523
- receipts.record({
524
- id: crypto.randomUUID(),
525
- template,
526
- to: opts.to,
527
- medium,
528
- locale,
529
- localeChain,
530
- status,
531
- messageId: result.messageId,
532
- driverId: result.driverId,
533
- attempts,
534
- at: now(),
535
- ...(result.ok
536
- ? {}
537
- : {
538
- error: attempts
539
- .map((a) => a.error)
540
- .filter(Boolean)
541
- .join("; "),
542
- }),
543
- });
544
-
545
- return { ...result, attempts };
546
- },
635
+ send: sendTemplate,
547
636
  };
548
637
  }
@@ -10,6 +10,7 @@
10
10
  import { describe, expect, test } from "bun:test";
11
11
  import type { MailOptions, SendResult, Transport } from "sently";
12
12
  import { createChannelInbox, openConsoleChannel, type ChannelDriver } from "../drivers/index.ts";
13
+ import type { SmsOtpTransport, SmsTransport } from "../drivers/channel-types.ts";
13
14
  import { channel, createChannelRuntime, createConsentStore, FallbackTransport } from "./channel.ts";
14
15
 
15
16
  /** Create a sently-compatible transport that always fails. */
@@ -207,3 +208,73 @@ describe("console driver", () => {
207
208
  expect(inbox.entries[0]!.text).toBe("code 1234");
208
209
  });
209
210
  });
211
+
212
+ describe("provider-managed OTP (Taqnyat Verify)", () => {
213
+ function taqnyatSmsDriver(): ChannelDriver {
214
+ const otpTransport: SmsTransport & SmsOtpTransport = {
215
+ provider: "taqnyat-sms",
216
+ async send() {
217
+ return { messageId: "m", to: "", status: "sent", response: "" };
218
+ },
219
+ async sendOtp(opts) {
220
+ return {
221
+ requestId: opts.requestId,
222
+ to: opts.to,
223
+ code: 5,
224
+ response: "sent",
225
+ provider: "taqnyat-sms",
226
+ };
227
+ },
228
+ async verifyOtp(opts) {
229
+ return {
230
+ ok: true as const,
231
+ code: opts.code === "0000" ? 13 : 10,
232
+ message: "verified",
233
+ response: "ok",
234
+ provider: "taqnyat-sms",
235
+ };
236
+ },
237
+ };
238
+ return { id: "taqnyat", smsTransport: otpTransport };
239
+ }
240
+
241
+ test("sendOtp / verifyOtp dispatch to the taqnyat smsTransport", async () => {
242
+ const runtime = createChannelRuntime({ drivers: [taqnyatSmsDriver()] });
243
+ const sent = await runtime.sendOtp({ to: "+966500000000", requestId: "r1", lang: "en" });
244
+ expect(sent.code).toBe(5);
245
+ expect(sent.requestId).toBe("r1");
246
+ const verified = await runtime.verifyOtp({
247
+ to: "+966500000000",
248
+ requestId: "r1",
249
+ code: "6240",
250
+ });
251
+ expect(verified.ok).toBe(true);
252
+ expect(verified.code).toBe(10);
253
+ });
254
+
255
+ test("throws loudly when no SMS driver is bound", async () => {
256
+ const runtime = createChannelRuntime({});
257
+ await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
258
+ "no SMS driver bound",
259
+ );
260
+ });
261
+
262
+ test("throws loudly naming the driver when SMS driver lacks OTP support", async () => {
263
+ const twilioLike: ChannelDriver = {
264
+ id: "msegat",
265
+ smsTransport: {
266
+ provider: "msegat",
267
+ async send() {
268
+ return { messageId: "m", to: "", status: "sent", response: "" };
269
+ },
270
+ },
271
+ };
272
+ const runtime = createChannelRuntime({ drivers: [twilioLike] });
273
+ await expect(runtime.sendOtp({ to: "+966500000000", requestId: "r1" })).rejects.toThrow(
274
+ /SMS driver "msegat" does not support provider-managed OTP/,
275
+ );
276
+ await expect(
277
+ runtime.verifyOtp({ to: "+966500000000", requestId: "r1", code: "1234" }),
278
+ ).rejects.toThrow(/does not support provider-managed OTP/);
279
+ });
280
+ });
@@ -2,7 +2,8 @@
2
2
  * Channel element — reaching humans.
3
3
  *
4
4
  * Physics: email · SMS · WhatsApp · push.
5
- * Drivers: `console` · `smtp` · `resend` · `sndr` · `taqnyat` · `msegat` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
5
+ * Drivers: `console` · `smtp` · `resend` · `sndr` · `taqnyat` · `taqnyat-mail` ·
6
+ * `taqnyat-whatsapp` · `msegat` · `unifonic` · `wa-cloud` · `fcm` · `webpush`.
6
7
  *
7
8
  * Transport interface is identical to sently's so its transports run unchanged.
8
9
  * MIME, attachments, retry, and the unified error hierarchy come from sently.
@@ -19,7 +20,7 @@ export type {
19
20
  ChannelTemplateOptions,
20
21
  } from "./channel/declare.ts";
21
22
 
22
- export { createChannelRuntime } from "./channel/runtime.ts";
23
+ export { createChannelRuntime, resetChannelProcessLocalWarnForTests } from "./channel/runtime.ts";
23
24
  export type {
24
25
  ChannelRuntime,
25
26
  ChannelSendOptions,
@@ -27,6 +28,15 @@ export type {
27
28
  TemplateCatalog,
28
29
  } from "./channel/runtime.ts";
29
30
 
31
+ export {
32
+ deliverOtpAcrossChannels,
33
+ shouldFallbackOtpMedium,
34
+ addressForChannel,
35
+ type DeliverOtpOptions,
36
+ type DeliverOtpResult,
37
+ type OtpTemplateMap,
38
+ } from "./channel/otp-delivery.ts";
39
+
30
40
  export { createConsentStore, type ConsentStore, type OptOut } from "./channel/consent.ts";
31
41
 
32
42
  export {