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.
- package/package.json +3 -2
- package/site/content/docs/deployment/docker-swarm.mdx +228 -0
- package/site/content/docs/deployment/docker.mdx +212 -0
- package/site/content/docs/deployment/index.mdx +83 -0
- package/site/content/docs/deployment/kubernetes.mdx +176 -0
- package/site/content/docs/deployment/meta.json +5 -0
- package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
- package/site/content/docs/elements/channel.mdx +25 -12
- package/site/content/docs/elements/clock.mdx +17 -15
- package/site/content/docs/elements/flow.mdx +6 -2
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +165 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- package/site/content/docs/get-started/installation.mdx +18 -16
- package/site/content/docs/index.mdx +5 -0
- package/site/content/docs/meta.json +10 -1
- package/site/content/docs/plugins/index.mdx +1 -2
- package/site/content/docs/plugins/magic-link.mdx +44 -2
- package/site/content/docs/plugins/meta.json +1 -2
- package/site/content/docs/plugins/otp.mdx +202 -0
- package/site/content/docs/plugins/two-factor.mdx +2 -1
- package/site/content/docs/reference/cli.md +5 -2
- package/site/content/docs/reference/configuration.mdx +21 -3
- package/site/content/docs/reference/environment-variables.mdx +21 -8
- package/site/content/docs/reference/plugins.mdx +1 -1
- package/src/auth/auth.test.ts +36 -0
- package/src/auth/bindings.ts +3 -12
- package/src/auth/identity.ts +33 -0
- package/src/auth/index.ts +5 -0
- package/src/auth/otp-capability.ts +119 -0
- package/src/auth/otp-seal.test.ts +61 -0
- package/src/auth/otp-seal.ts +84 -0
- package/src/auth/schema.ts +3 -0
- package/src/auth/sessions.ts +26 -27
- package/src/auth/tables.ts +4 -0
- package/src/auth/verification.ts +61 -1
- package/src/cli/db-seed.ts +359 -0
- package/src/cli/db.test.ts +341 -3
- package/src/cli/db.ts +75 -8
- package/src/cli/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +26 -0
- package/src/cli/load-config.ts +10 -2
- package/src/cli/registry.ts +38 -2
- package/src/compiler/effects-infer.ts +1 -0
- package/src/config/index.ts +4 -0
- package/src/console/server/operator-db.ts +34 -9
- package/src/docker/compose.ts +162 -6
- package/src/docker/derive.ts +60 -3
- package/src/docker/docker.test.ts +374 -1
- package/src/docker/helpers.ts +2 -0
- package/src/docker/index.ts +11 -0
- package/src/docker/recipes/caddy.ts +51 -0
- package/src/docker/recipes/dragonfly.ts +31 -0
- package/src/docker/recipes/index.ts +25 -2
- package/src/docker/recipes/pgdog.ts +84 -0
- package/src/docker/recipes/redis.ts +6 -3
- package/src/docker/recipes/traefik.ts +83 -0
- package/src/docker/recipes/valkey.ts +30 -0
- package/src/docker/stack-id.ts +5 -0
- package/src/docker/types.ts +18 -0
- package/src/drivers/channel-sently.test.ts +8 -0
- package/src/drivers/channel-taqnyat-mail.ts +34 -0
- package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +72 -0
- package/src/drivers/clock-postgres.test.ts +258 -0
- package/src/drivers/clock-postgres.ts +410 -0
- package/src/drivers/index.ts +18 -0
- package/src/drivers/journal-postgres.test.ts +175 -0
- package/src/drivers/journal-postgres.ts +492 -0
- package/src/elements/channel/otp-delivery.test.ts +76 -0
- package/src/elements/channel/otp-delivery.ts +291 -0
- package/src/elements/channel/runtime.ts +203 -114
- package/src/elements/channel.test.ts +71 -0
- package/src/elements/channel.ts +12 -2
- package/src/elements/clock/chaos-child.ts +280 -41
- package/src/elements/clock/durable.ts +7 -0
- package/src/elements/clock/reconcile.ts +2 -2
- package/src/elements/clock/runtime.ts +5 -3
- package/src/elements/clock.ts +1 -1
- package/src/elements/store/seed.test.ts +27 -0
- package/src/elements/store/seed.ts +68 -0
- package/src/elements/store/sql-session.test.ts +39 -0
- package/src/elements/store/sql-session.ts +55 -0
- package/src/elements/store/upsert-app.test.ts +103 -0
- package/src/elements/store.ts +5 -0
- package/src/index.ts +18 -0
- package/src/kernel/app.ts +221 -14
- package/src/kernel/boot-bind/channel.test.ts +16 -0
- package/src/kernel/boot-bind/channel.ts +64 -0
- package/src/kernel/boot-bind/clock.ts +17 -6
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +123 -4
- package/src/kernel/boot-bind/journal.ts +89 -0
- package/src/kernel/boot-bind/signal.ts +20 -0
- package/src/kernel/boot-bind/store.test.ts +82 -0
- package/src/kernel/boot-bind/store.ts +22 -0
- package/src/kernel/boot.test.ts +6 -4
- package/src/kernel/boot.ts +53 -13
- package/src/kernel/concurrency.ts +1 -1
- package/src/kernel/fx.test.ts +9 -0
- package/src/kernel/fx.ts +175 -5
- package/src/kernel/graceful-shutdown.test.ts +76 -0
- package/src/kernel/graceful-shutdown.ts +106 -0
- package/src/kernel/horizontal-child.ts +257 -0
- package/src/kernel/horizontal.integration.test.ts +229 -0
- package/src/kernel/index.ts +14 -0
- package/src/kernel/journal-boot.test.ts +397 -0
- package/src/kernel/journal-suspend.ts +35 -0
- package/src/kernel/journal.test.ts +142 -0
- package/src/kernel/journal.ts +202 -27
- package/src/kernel/ready.test.ts +76 -0
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
- package/src/plugins/auth-methods.security.test.ts +20 -27
- package/src/plugins/auth-methods.test.ts +7 -6
- package/src/plugins/index.ts +12 -8
- package/src/plugins/magic-link.ts +1 -23
- package/src/plugins/otp.test.ts +236 -0
- package/src/plugins/otp.ts +570 -0
- package/src/plugins/taqnyat.live.test.ts +172 -0
- package/src/release/official-plugins.ts +1 -2
- package/site/content/docs/plugins/email-otp.mdx +0 -117
- package/site/content/docs/plugins/phone-number.mdx +0 -111
- package/src/plugins/email-otp.ts +0 -214
- 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
|
-
|
|
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
|
+
});
|
package/src/elements/channel.ts
CHANGED
|
@@ -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` · `
|
|
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 {
|