okengine 0.8.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 (89) 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 +3 -1
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +202 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +236 -0
  83. package/src/plugins/otp.ts +570 -0
  84. package/src/plugins/taqnyat.live.test.ts +4 -6
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -0,0 +1,291 @@
1
+ /**
2
+ * Tier-2 OTP multi-medium delivery via sently {@link FallbackTransport}.
3
+ *
4
+ * Within each medium, {@link ChannelRuntime.send} already runs the existing
5
+ * email/SMS FallbackTransport chains. This module reuses FallbackTransport
6
+ * again for **cross-medium** provider-error failover — adapters normalize
7
+ * per-medium sends to one options shape. Explicit user resend never calls
8
+ * this chain; it delivers a single channel only.
9
+ */
10
+
11
+ import { FallbackError, FallbackTransport } from "sently/transports/fallback";
12
+ import type { TaqnyatWhatsAppFailover } from "sently/transports/taqnyat-whatsapp";
13
+ import type { OtpChannel } from "../../auth/verification.ts";
14
+ import { hasWhatsAppSendWithFailover } from "../../drivers/channel-taqnyat-whatsapp.ts";
15
+ import type {
16
+ ChannelAttempt,
17
+ ChannelDriver,
18
+ ChannelSendResult,
19
+ } from "../../drivers/channel-types.ts";
20
+
21
+ /** Minimal send surface used by OTP multi-medium delivery. */
22
+ export type OtpChannelSend = (
23
+ template: string,
24
+ options: {
25
+ readonly to: string;
26
+ readonly data?: Readonly<Record<string, unknown>>;
27
+ readonly locale?: string;
28
+ },
29
+ ) => Promise<ChannelSendResult>;
30
+
31
+ /** Template names keyed by OTP channel. */
32
+ export type OtpTemplateMap = Readonly<Partial<Record<OtpChannel, string>>>;
33
+
34
+ /** Options for cross-medium OTP delivery. */
35
+ export interface DeliverOtpOptions {
36
+ /** Preferred channel order (build-time declaration). */
37
+ readonly channels: readonly OtpChannel[];
38
+ /** Template name per medium. */
39
+ readonly templates: OtpTemplateMap;
40
+ readonly email?: string;
41
+ readonly phone?: string;
42
+ readonly data: Readonly<Record<string, unknown>>;
43
+ readonly locale?: string;
44
+ /**
45
+ * When set, deliver **only** this channel (explicit resend) — no
46
+ * cross-medium FallbackTransport walk.
47
+ */
48
+ readonly only?: OtpChannel;
49
+ }
50
+
51
+ /** Result of a Tier-2 OTP delivery attempt. */
52
+ export interface DeliverOtpResult {
53
+ readonly ok: true;
54
+ readonly channel: OtpChannel;
55
+ readonly attempts: readonly ChannelAttempt[];
56
+ readonly messageId: string;
57
+ readonly driverId: string;
58
+ }
59
+
60
+ type MediumSendOpts = {
61
+ readonly channel: OtpChannel;
62
+ readonly to: string;
63
+ readonly template: string;
64
+ readonly data: Readonly<Record<string, unknown>>;
65
+ readonly locale?: string;
66
+ };
67
+
68
+ type MediumSendResult = {
69
+ readonly messageId: string;
70
+ readonly status: string;
71
+ readonly provider: string;
72
+ readonly channel: OtpChannel;
73
+ };
74
+
75
+ /**
76
+ * Whether an error should advance to the next medium (provider outage),
77
+ * not permanent client mistakes (invalid address).
78
+ *
79
+ * @param error - Thrown error
80
+ */
81
+ export function shouldFallbackOtpMedium(error: unknown): boolean {
82
+ const msg = (error instanceof Error ? error.message : String(error)).toLowerCase();
83
+ if (
84
+ /invalid.*(address|email|recipient|phone)|unknown user|no such user|mailbox unavailable|550 5\.1\.1/.test(
85
+ msg,
86
+ )
87
+ ) {
88
+ return false;
89
+ }
90
+ const status = (error as { statusCode?: number })?.statusCode;
91
+ if (status === 400 || status === 401 || status === 403) return false;
92
+ return true;
93
+ }
94
+
95
+ /**
96
+ * Resolve the recipient address for a channel.
97
+ *
98
+ * @param channel - Medium
99
+ * @param email - Email when present
100
+ * @param phone - Phone when present
101
+ */
102
+ export function addressForChannel(
103
+ channel: OtpChannel,
104
+ email: string | undefined,
105
+ phone: string | undefined,
106
+ ): string | undefined {
107
+ if (channel === "email") return email;
108
+ return phone;
109
+ }
110
+
111
+ /**
112
+ * Deliver an OTP across declared channels using sently FallbackTransport.
113
+ *
114
+ * @param drivers - Bound drivers (for Taqnyat WhatsApp sendWithFailover)
115
+ * @param send - ChannelRuntime.send bound function
116
+ * @param opts - Delivery options
117
+ * @param now - Clock
118
+ */
119
+ export async function deliverOtpAcrossChannels(
120
+ drivers: readonly ChannelDriver[],
121
+ send: OtpChannelSend,
122
+ opts: DeliverOtpOptions,
123
+ now: () => number = () => Date.now(),
124
+ ): Promise<DeliverOtpResult> {
125
+ const ordered = opts.only ? [opts.only] : [...opts.channels];
126
+ const adapters: Array<{
127
+ readonly provider: string;
128
+ send(o: MediumSendOpts): Promise<MediumSendResult>;
129
+ }> = [];
130
+
131
+ for (const channel of ordered) {
132
+ const template = opts.templates[channel];
133
+ const to = addressForChannel(channel, opts.email, opts.phone);
134
+ if (!template || !to) continue;
135
+
136
+ if (channel === "whatsapp" && !opts.only) {
137
+ const wa = drivers.find((d) => hasWhatsAppSendWithFailover(d.whatsappTransport));
138
+ if (wa && hasWhatsAppSendWithFailover(wa.whatsappTransport)) {
139
+ const transport = wa.whatsappTransport;
140
+ const failover = buildTaqnyatWhatsAppFailover(opts, ordered);
141
+ if (failover.sms || failover.mail) {
142
+ adapters.push({
143
+ provider: "taqnyat-whatsapp",
144
+ async send() {
145
+ const result = await transport.sendWithFailover(
146
+ { to, text: String(opts.data.otp ?? "") },
147
+ failover,
148
+ );
149
+ return {
150
+ messageId: result.messageId,
151
+ status: result.status,
152
+ provider: "taqnyat-whatsapp",
153
+ channel: "whatsapp" as const,
154
+ };
155
+ },
156
+ });
157
+ // Provider-side failover already covers later sms/email — skip client adapters for those.
158
+ break;
159
+ }
160
+ }
161
+ }
162
+
163
+ adapters.push({
164
+ provider: channel,
165
+ async send() {
166
+ const result = await send(template, {
167
+ to,
168
+ data: opts.data,
169
+ ...(opts.locale ? { locale: opts.locale } : {}),
170
+ });
171
+ if (!result.ok) {
172
+ const errMsg =
173
+ result.attempts
174
+ .map((a) => a.error)
175
+ .filter(Boolean)
176
+ .join("; ") || `${channel} delivery failed`;
177
+ const err = new Error(errMsg) as Error & { statusCode?: number };
178
+ if (/invalid.*(address|email|recipient|phone)/i.test(errMsg)) {
179
+ err.statusCode = 400;
180
+ }
181
+ throw err;
182
+ }
183
+ return {
184
+ messageId: result.messageId,
185
+ status: "sent",
186
+ provider: result.driverId,
187
+ channel,
188
+ };
189
+ },
190
+ });
191
+ }
192
+
193
+ if (adapters.length === 0) {
194
+ throw new Error(
195
+ "channel: otp delivery has no viable channel — need a matching address for a declared channel",
196
+ );
197
+ }
198
+
199
+ const attempts: ChannelAttempt[] = [];
200
+ const fallback = new FallbackTransport(adapters, {
201
+ shouldFallback: shouldFallbackOtpMedium,
202
+ onFallback(failedIndex, error) {
203
+ const provider = adapters[failedIndex]?.provider ?? `medium-${failedIndex}`;
204
+ attempts.push({
205
+ driverId: provider,
206
+ ok: false,
207
+ error: error instanceof Error ? error.message : String(error),
208
+ at: now(),
209
+ });
210
+ },
211
+ });
212
+
213
+ try {
214
+ const first = ordered.find((ch) => {
215
+ const template = opts.templates[ch];
216
+ const to = addressForChannel(ch, opts.email, opts.phone);
217
+ return !!template && !!to;
218
+ });
219
+ if (!first) {
220
+ throw new Error("channel: otp delivery has no viable channel");
221
+ }
222
+ const template = opts.templates[first]!;
223
+ const to = addressForChannel(first, opts.email, opts.phone)!;
224
+ const result = await fallback.send({
225
+ channel: first,
226
+ to,
227
+ template,
228
+ data: opts.data,
229
+ ...(opts.locale ? { locale: opts.locale } : {}),
230
+ });
231
+ attempts.push({
232
+ driverId: result.provider,
233
+ ok: true,
234
+ at: now(),
235
+ messageId: result.messageId,
236
+ });
237
+ return {
238
+ ok: true,
239
+ channel: result.channel,
240
+ attempts,
241
+ messageId: result.messageId,
242
+ driverId: result.provider,
243
+ };
244
+ } catch (err) {
245
+ if (err instanceof FallbackError) {
246
+ for (const a of err.attempts) {
247
+ if (!attempts.some((x) => x.driverId === a.provider && !x.ok)) {
248
+ attempts.push({
249
+ driverId: a.provider,
250
+ ok: false,
251
+ error: a.error instanceof Error ? a.error.message : String(a.error),
252
+ at: now(),
253
+ });
254
+ }
255
+ }
256
+ throw new Error(
257
+ `channel: otp delivery failed on all channels (${err.attempts.map((a) => a.provider).join(" → ")})`,
258
+ );
259
+ }
260
+ throw err;
261
+ }
262
+ }
263
+
264
+ function buildTaqnyatWhatsAppFailover(
265
+ opts: DeliverOtpOptions,
266
+ ordered: readonly OtpChannel[],
267
+ ): TaqnyatWhatsAppFailover {
268
+ const waIndex = ordered.indexOf("whatsapp");
269
+ const rest = waIndex >= 0 ? ordered.slice(waIndex + 1) : [];
270
+ const otp = String(opts.data.otp ?? "");
271
+ const sender = process.env.TAQNYAT_SENDER?.trim() ?? "";
272
+ const campaign = process.env.TAQNYAT_CAMPAIGN?.trim() ?? "oke-otp";
273
+ const out: TaqnyatWhatsAppFailover = {};
274
+ if (rest.includes("sms") && opts.phone && sender) {
275
+ out.sms = {
276
+ sender,
277
+ campaign,
278
+ body: `Your sign-in code is: ${otp}`,
279
+ };
280
+ }
281
+ if (rest.includes("email") && opts.email) {
282
+ out.mail = {
283
+ from: process.env.TAQNYAT_MAIL_FROM?.trim() ?? "OKE <no-reply@oke.local>",
284
+ to: opts.email,
285
+ campaign,
286
+ subject: "Your sign-in code",
287
+ msg: `Your one-time sign-in code is: ${otp}`,
288
+ };
289
+ }
290
+ return out;
291
+ }
@@ -32,6 +32,11 @@ import {
32
32
  type ReceiptLedger,
33
33
  } from "./receipts.ts";
34
34
  import { createSuppressionStore, type SuppressionStore } from "./suppression.ts";
35
+ import {
36
+ deliverOtpAcrossChannels,
37
+ type DeliverOtpOptions,
38
+ type DeliverOtpResult,
39
+ } from "./otp-delivery.ts";
35
40
 
36
41
  /** Locale catalog: template → locale → rendered body. */
37
42
  export type TemplateCatalog = Readonly<
@@ -115,6 +120,13 @@ export interface ChannelRuntime {
115
120
  * @param options - Recipient + requestId + code (+ lang / note / from)
116
121
  */
117
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>;
118
130
  /**
119
131
  * Ingest a post-send provider outcome (bounce / complaint / …).
120
132
  * Hard bounce auto-adds suppression. Console projects the ledger — never
@@ -130,15 +142,33 @@ export interface ChannelRuntime {
130
142
  *
131
143
  * @param options - Templates + drivers + catalog
132
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
+
133
152
  export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}): ChannelRuntime {
134
153
  const templates = new Map<string, ChannelTemplateDecl>();
135
154
  for (const t of options.templates ?? []) {
136
155
  templates.set(t.name, t);
137
156
  }
157
+ const usingDefaultSuppression = options.suppression === undefined;
158
+ const usingDefaultReceipts = options.receipts === undefined;
138
159
  const suppression =
139
160
  options.suppression ??
140
161
  createSuppressionStore(options.consent ? { consent: options.consent } : {});
141
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
+ }
142
172
  const costs = options.costs ?? { ...DEFAULT_MEDIUM_COSTS };
143
173
  const catalog = options.catalog ?? {};
144
174
  const defaultLocale = options.defaultLocale ?? "en";
@@ -433,6 +463,124 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
433
463
  );
434
464
  }
435
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
+
436
584
  return {
437
585
  templates,
438
586
  suppression,
@@ -447,6 +595,9 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
447
595
  const { otp } = otpSmsDriver();
448
596
  return otp.verifyOtp(opts);
449
597
  },
598
+ async deliverOtp(opts) {
599
+ return deliverOtpAcrossChannels(drivers, sendTemplate, opts, now);
600
+ },
450
601
  ingestOutcome(input) {
451
602
  const at = input.at ?? now();
452
603
  const existing = receipts.byMessageId(input.messageId);
@@ -481,119 +632,6 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
481
632
  receipts.record(receipt);
482
633
  return receipt;
483
634
  },
484
- async send(template, opts) {
485
- const decl = templates.get(template);
486
- if (!decl) {
487
- throw new Error(`channel: unknown template "${template}"`);
488
- }
489
- const medium = decl.medium;
490
- const checkMedium: ChannelMedium = medium === "any" ? "email" : medium;
491
-
492
- const suppressed = suppression.isSuppressed(opts.to, checkMedium);
493
- if (suppressed.suppressed) {
494
- const status: DeliveryStatus =
495
- suppressed.reason === "opted-out" ? "suppressed/opted-out" : "suppressed/prior-bounce";
496
- const resolved = resolveLocale({
497
- locale: opts.locale,
498
- profileLocale: opts.profileLocale,
499
- acceptLanguage: opts.acceptLanguage,
500
- defaultLocale,
501
- });
502
- const receipt: DeliveryReceipt = {
503
- id: crypto.randomUUID(),
504
- template,
505
- to: opts.to,
506
- medium,
507
- locale: resolved.locale,
508
- localeChain: resolved.chain,
509
- status,
510
- attempts: [],
511
- at: now(),
512
- error: suppressed.reason === "opted-out" ? "opted out" : "prior hard bounce",
513
- };
514
- receipts.record(receipt);
515
- return {
516
- ok: false,
517
- messageId: receipt.id,
518
- driverId: "suppression",
519
- attempts: [],
520
- };
521
- }
522
-
523
- const resolved = resolveLocale({
524
- locale: opts.locale,
525
- profileLocale: opts.profileLocale,
526
- acceptLanguage: opts.acceptLanguage,
527
- defaultLocale,
528
- });
529
- const locale = resolved.locale;
530
- const localeChain: readonly LocaleChainStep[] = resolved.chain;
531
- const body = resolveBody(template, locale, opts.data ?? {});
532
- const chain = driversFor(medium, opts.via);
533
-
534
- let result: ChannelSendResult;
535
- let attempts: ChannelAttempt[];
536
-
537
- if (medium === "email" || (medium === "any" && chain.some((d) => d.transport))) {
538
- const from = decl.from ?? "oke@localhost";
539
- const sendResult = await sendViaEmailChain(chain, {
540
- from,
541
- to: opts.to,
542
- subject: opts.subject ?? body.subject ?? template,
543
- text: body.text,
544
- html: body.html,
545
- template,
546
- data: opts.data ? { ...opts.data } : undefined,
547
- });
548
- result = sendResult.result;
549
- attempts = sendResult.attempts;
550
- } else {
551
- const sendResult = await sendViaChannelChain(chain, {
552
- medium: medium === "any" ? "sms" : medium,
553
- to: opts.to,
554
- from: decl.from,
555
- subject: opts.subject ?? body.subject,
556
- text: body.text,
557
- html: body.html,
558
- data: opts.data,
559
- locale,
560
- template,
561
- pushSubscription: opts.pushSubscription,
562
- });
563
- result = sendResult.result;
564
- attempts = sendResult.attempts;
565
- }
566
-
567
- let status: DeliveryStatus;
568
- if (result.ok) {
569
- status = attempts.filter((a) => !a.ok).length > 0 ? "fallback" : "sent";
570
- } else {
571
- status = classifySendFailure(attempts);
572
- }
573
-
574
- receipts.record({
575
- id: crypto.randomUUID(),
576
- template,
577
- to: opts.to,
578
- medium,
579
- locale,
580
- localeChain,
581
- status,
582
- messageId: result.messageId,
583
- driverId: result.driverId,
584
- attempts,
585
- at: now(),
586
- ...(result.ok
587
- ? {}
588
- : {
589
- error: attempts
590
- .map((a) => a.error)
591
- .filter(Boolean)
592
- .join("; "),
593
- }),
594
- });
595
-
596
- return { ...result, attempts };
597
- },
635
+ send: sendTemplate,
598
636
  };
599
637
  }
@@ -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 {
package/src/index.ts CHANGED
@@ -41,8 +41,11 @@ export {
41
41
  PluginNeedsError,
42
42
  assertPluginNeeds,
43
43
  freezePrincipal,
44
+ installGracefulShutdown,
45
+ releaseInstanceLeases,
44
46
  type OkeApp,
45
47
  type OkeOptions,
48
+ type ReadyState,
46
49
  type FlowDef,
47
50
  type FlowFailure,
48
51
  type FlowErrorValue,