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.
- 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 +3 -1
- package/site/content/docs/elements/signal.mdx +10 -8
- package/site/content/docs/elements/store.mdx +34 -0
- package/site/content/docs/get-started/index.mdx +5 -0
- 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 +2 -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 -2
- package/site/content/docs/reference/cli.md +3 -2
- package/site/content/docs/reference/configuration.mdx +16 -0
- package/site/content/docs/reference/environment-variables.mdx +9 -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/dev-app-runner.ts +4 -0
- package/src/cli/docker.ts +4 -1
- package/src/cli/load-config.images.test.ts +4 -0
- package/src/cli/load-config.ts +3 -0
- package/src/cli/registry.ts +1 -1
- 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-taqnyat-whatsapp.ts +94 -0
- package/src/drivers/channel-types.ts +1 -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 +152 -114
- package/src/elements/channel.ts +12 -2
- package/src/index.ts +3 -0
- package/src/kernel/app.ts +60 -4
- package/src/kernel/boot-bind/channel.ts +51 -0
- package/src/kernel/boot-bind/gate.ts +14 -19
- package/src/kernel/boot-bind/honor-config.test.ts +18 -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 +5 -5
- package/src/kernel/fx.test.ts +3 -0
- package/src/kernel/fx.ts +49 -0
- 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 +8 -0
- 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 +19 -29
- 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 +4 -6
- 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 -172
- package/src/plugins/email-otp.ts +0 -214
- 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
|
-
|
|
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
|
}
|
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 {
|
package/src/index.ts
CHANGED