okengine 0.8.0 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +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 +10 -5
- 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 +214 -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 +238 -0
- package/src/plugins/otp.ts +572 -0
- package/src/plugins/taqnyat.live.test.ts +5 -7
- 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,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PgDog image recipe — Postgres wire-protocol connection pooler.
|
|
3
|
+
*
|
|
4
|
+
* Sits in front of the `postgres` recipe. Transaction pooling is set
|
|
5
|
+
* explicitly (`pooler_mode = "transaction"` — also PgDog's upstream default).
|
|
6
|
+
* Config shape matches upstream docs: `pgdog.toml` + `users.toml`
|
|
7
|
+
* (https://docs.pgdog.dev/configuration/).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import type { ImageRecipe } from "../types.ts";
|
|
11
|
+
|
|
12
|
+
/** Backend Postgres service name in the compose network. */
|
|
13
|
+
export const PGDOG_BACKEND_SERVICE = "store-sql";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Build `pgdog.toml` — general listen settings + one primary database.
|
|
17
|
+
*
|
|
18
|
+
* @param opts - Database name clients use + Postgres host on the compose network
|
|
19
|
+
*/
|
|
20
|
+
export function buildPgDogToml(opts: {
|
|
21
|
+
readonly database: string;
|
|
22
|
+
readonly postgresHost?: string;
|
|
23
|
+
}): string {
|
|
24
|
+
const host = opts.postgresHost ?? PGDOG_BACKEND_SERVICE;
|
|
25
|
+
const db = opts.database;
|
|
26
|
+
return [
|
|
27
|
+
"[general]",
|
|
28
|
+
'host = "0.0.0.0"',
|
|
29
|
+
"port = 6432",
|
|
30
|
+
'pooler_mode = "transaction"',
|
|
31
|
+
"",
|
|
32
|
+
"[[databases]]",
|
|
33
|
+
`name = ${tomlString(db)}`,
|
|
34
|
+
`host = ${tomlString(host)}`,
|
|
35
|
+
"port = 5432",
|
|
36
|
+
`database_name = ${tomlString(db)}`,
|
|
37
|
+
"",
|
|
38
|
+
].join("\n");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Build `users.toml` — one user/database pair (same creds as Postgres).
|
|
43
|
+
*
|
|
44
|
+
* @param opts - Client/server credentials
|
|
45
|
+
*/
|
|
46
|
+
export function buildPgDogUsersToml(opts: {
|
|
47
|
+
readonly user: string;
|
|
48
|
+
readonly password: string;
|
|
49
|
+
readonly database: string;
|
|
50
|
+
}): string {
|
|
51
|
+
return [
|
|
52
|
+
"[[users]]",
|
|
53
|
+
`name = ${tomlString(opts.user)}`,
|
|
54
|
+
`password = ${tomlString(opts.password)}`,
|
|
55
|
+
`database = ${tomlString(opts.database)}`,
|
|
56
|
+
"",
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** PgDog pooler. Postgres wire protocol on 6432. */
|
|
61
|
+
export const pgdog: ImageRecipe = {
|
|
62
|
+
id: "pgdog",
|
|
63
|
+
port: 6432,
|
|
64
|
+
match: (i) => /pgdog/i.test(i),
|
|
65
|
+
apply: () => ({
|
|
66
|
+
volumes: ["./pgdog.toml:/pgdog/pgdog.toml:ro", "./users.toml:/pgdog/users.toml:ro"],
|
|
67
|
+
dependsOn: {
|
|
68
|
+
[PGDOG_BACKEND_SERVICE]: { condition: "service_healthy" },
|
|
69
|
+
},
|
|
70
|
+
healthcheck: {
|
|
71
|
+
test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 6432 || exit 1"],
|
|
72
|
+
interval: "5s",
|
|
73
|
+
timeout: "3s",
|
|
74
|
+
retries: 12,
|
|
75
|
+
start_period: "5s",
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
url: (_s, c) =>
|
|
79
|
+
`postgres://${c.user}:${encodeURIComponent(c.password)}@${c.host}:${c.port}/${c.database}`,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function tomlString(value: string): string {
|
|
83
|
+
return `"${value.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`;
|
|
84
|
+
}
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Redis
|
|
2
|
+
* Redis image recipe — default `store.kv` pin (`redis:*`).
|
|
3
|
+
*
|
|
4
|
+
* Valkey and Dragonfly are separate recipes — same `redis` driver /
|
|
5
|
+
* `redis://` URL, different binaries. Pin via `images["store.kv"]`.
|
|
3
6
|
*/
|
|
4
7
|
|
|
5
8
|
import { credEnv } from "../helpers.ts";
|
|
6
9
|
import type { ImageRecipe } from "../types.ts";
|
|
7
10
|
|
|
8
|
-
/** Redis
|
|
11
|
+
/** Redis Open Source — Redis protocol on 6379. */
|
|
9
12
|
export const redis: ImageRecipe = {
|
|
10
13
|
id: "redis",
|
|
11
14
|
port: 6379,
|
|
12
|
-
match: (i) => /redis
|
|
15
|
+
match: (i) => /(?:^|\/)redis(?:[:@/]|$)/i.test(i) || /keydb/i.test(i),
|
|
13
16
|
apply: (s) => ({
|
|
14
17
|
command: [
|
|
15
18
|
"sh",
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traefik image recipe — Docker-label auto-discovery reverse proxy.
|
|
3
|
+
*
|
|
4
|
+
* Opt-in via `images.proxy`. Scaled `app` replicas
|
|
5
|
+
* (`docker compose up --scale app=N`) are discovered via the Docker
|
|
6
|
+
* provider — no Caddyfile-style reconfiguration.
|
|
7
|
+
*
|
|
8
|
+
* Security: the recipe never mounts the raw Docker socket into Traefik.
|
|
9
|
+
* A `tecnativa/docker-socket-proxy` companion exposes a filtered API
|
|
10
|
+
* (containers/events/ping/version only) on the internal compose network.
|
|
11
|
+
* Traefik’s own docs recommend this pattern for Docker API access.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { APP_PORT } from "../../runtime/types.ts";
|
|
15
|
+
import type { ImageRecipe } from "../types.ts";
|
|
16
|
+
|
|
17
|
+
/** Filtered Docker API proxy — only this service mounts `docker.sock`. */
|
|
18
|
+
export const SOCKET_PROXY_IMAGE = "tecnativa/docker-socket-proxy:v0.5.0";
|
|
19
|
+
|
|
20
|
+
/** Compose service name for the socket proxy companion. */
|
|
21
|
+
export const SOCKET_PROXY_SERVICE = "socket-proxy";
|
|
22
|
+
|
|
23
|
+
/** Traefik routing labels applied to the `app` service. */
|
|
24
|
+
export function traefikAppLabels(appPort: number = APP_PORT): Record<string, string> {
|
|
25
|
+
return {
|
|
26
|
+
"traefik.enable": "true",
|
|
27
|
+
"traefik.http.routers.app.rule": "Host(`${OKE_PROXY_HOST:-localhost}`)",
|
|
28
|
+
"traefik.http.routers.app.entrypoints": "websecure",
|
|
29
|
+
"traefik.http.routers.app.tls.certresolver": "letsencrypt",
|
|
30
|
+
"traefik.http.services.app.loadbalancer.server.port": String(appPort),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Traefik reverse proxy. HTTP 80 + HTTPS 443; Docker provider via socket-proxy. */
|
|
35
|
+
export const traefik: ImageRecipe = {
|
|
36
|
+
id: "traefik",
|
|
37
|
+
port: 80,
|
|
38
|
+
match: (i) => /traefik/i.test(i),
|
|
39
|
+
apply: () => ({
|
|
40
|
+
extraPorts: [{ host: 443, container: 443 }],
|
|
41
|
+
command: [
|
|
42
|
+
"--ping=true",
|
|
43
|
+
"--providers.docker=true",
|
|
44
|
+
`--providers.docker.endpoint=tcp://${SOCKET_PROXY_SERVICE}:2375`,
|
|
45
|
+
"--providers.docker.exposedbydefault=false",
|
|
46
|
+
"--entrypoints.web.address=:80",
|
|
47
|
+
"--entrypoints.websecure.address=:443",
|
|
48
|
+
"--entrypoints.web.http.redirections.entrypoint.to=websecure",
|
|
49
|
+
"--certificatesresolvers.letsencrypt.acme.httpchallenge=true",
|
|
50
|
+
"--certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web",
|
|
51
|
+
"--certificatesresolvers.letsencrypt.acme.email=${OKE_PROXY_ACME_EMAIL:-admin@example.com}",
|
|
52
|
+
"--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json",
|
|
53
|
+
],
|
|
54
|
+
volumes: ["proxy-letsencrypt:/letsencrypt"],
|
|
55
|
+
dependsOn: {
|
|
56
|
+
[SOCKET_PROXY_SERVICE]: { condition: "service_started" },
|
|
57
|
+
},
|
|
58
|
+
healthcheck: {
|
|
59
|
+
test: ["CMD", "traefik", "healthcheck", "--ping"],
|
|
60
|
+
interval: "10s",
|
|
61
|
+
timeout: "3s",
|
|
62
|
+
retries: 5,
|
|
63
|
+
},
|
|
64
|
+
services: {
|
|
65
|
+
[SOCKET_PROXY_SERVICE]: {
|
|
66
|
+
image: SOCKET_PROXY_IMAGE,
|
|
67
|
+
environment: {
|
|
68
|
+
CONTAINERS: "1",
|
|
69
|
+
EVENTS: "1",
|
|
70
|
+
PING: "1",
|
|
71
|
+
VERSION: "1",
|
|
72
|
+
NETWORKS: "1",
|
|
73
|
+
},
|
|
74
|
+
volumes: ["/var/run/docker.sock:/var/run/docker.sock:ro"],
|
|
75
|
+
networks: ["oke"],
|
|
76
|
+
},
|
|
77
|
+
app: {
|
|
78
|
+
labels: traefikAppLabels(),
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
}),
|
|
82
|
+
url: (_s, c) => `https://${c.host}`,
|
|
83
|
+
};
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Valkey image recipe — Redis-wire-compatible (BSD-3-Clause, Linux Foundation).
|
|
3
|
+
*
|
|
4
|
+
* Opt-in via `images["store.kv"]` (driver id stays `redis`). Official image:
|
|
5
|
+
* `valkey/valkey`.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { credEnv } from "../helpers.ts";
|
|
9
|
+
import type { ImageRecipe } from "../types.ts";
|
|
10
|
+
|
|
11
|
+
/** Valkey — Redis protocol on 6379. */
|
|
12
|
+
export const valkey: ImageRecipe = {
|
|
13
|
+
id: "valkey",
|
|
14
|
+
port: 6379,
|
|
15
|
+
match: (i) => /valkey/i.test(i),
|
|
16
|
+
apply: (s) => ({
|
|
17
|
+
command: [
|
|
18
|
+
"sh",
|
|
19
|
+
"-c",
|
|
20
|
+
'exec valkey-server --requirepass "$$OKE_STORE_KV_PASSWORD" --maxmemory "$${OKE_STORE_KV_MAXMEMORY:-0}" --maxmemory-policy "$${OKE_STORE_KV_MAXMEMORY_POLICY:-noeviction}"',
|
|
21
|
+
],
|
|
22
|
+
healthcheck: {
|
|
23
|
+
test: ["CMD", "valkey-cli", "-a", credEnv(s, "PASSWORD"), "ping"],
|
|
24
|
+
interval: "5s",
|
|
25
|
+
timeout: "3s",
|
|
26
|
+
retries: 10,
|
|
27
|
+
},
|
|
28
|
+
}),
|
|
29
|
+
url: (_s, c) => `redis://:${encodeURIComponent(c.password)}@${c.host}:${c.port}`,
|
|
30
|
+
};
|
package/src/docker/stack-id.ts
CHANGED
|
@@ -58,6 +58,8 @@ export function hostPortForInstance(
|
|
|
58
58
|
if (role === "channel.email") return 20_000 + n;
|
|
59
59
|
if (role === "vault") return 22_000 + n;
|
|
60
60
|
if (role === "ai") return 23_000 + n;
|
|
61
|
+
if (role === "pgdog") return 24_000 + n;
|
|
62
|
+
if (role === "proxy") return 25_000 + n;
|
|
61
63
|
return defaultHostPort(role, containerPort) + n;
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -79,6 +81,7 @@ export function extraHostPortForInstance(
|
|
|
79
81
|
const n = instancePortOffset(instanceId);
|
|
80
82
|
if (role === "store.files") return 19_000 + n;
|
|
81
83
|
if (role === "channel.email") return 21_000 + n;
|
|
84
|
+
if (role === "proxy") return 26_000 + n;
|
|
82
85
|
return hostPort + n;
|
|
83
86
|
}
|
|
84
87
|
|
|
@@ -123,6 +126,8 @@ export const STACK_CONTROL_KEYS = [
|
|
|
123
126
|
"MP_SMTP_AUTH_ACCEPT_ANY",
|
|
124
127
|
"MP_SMTP_AUTH_ALLOW_INSECURE",
|
|
125
128
|
"OKE_AI_MODEL",
|
|
129
|
+
"OKE_PROXY_HOST",
|
|
130
|
+
"OKE_PROXY_ACME_EMAIL",
|
|
126
131
|
] as const;
|
|
127
132
|
|
|
128
133
|
/**
|
package/src/docker/types.ts
CHANGED
|
@@ -56,6 +56,9 @@ export interface RecipeExtraPort {
|
|
|
56
56
|
readonly container: number;
|
|
57
57
|
}
|
|
58
58
|
|
|
59
|
+
/** Compose `ulimits` value — soft/hard object or a single limit. */
|
|
60
|
+
export type RecipeUlimit = number | { readonly soft?: number; readonly hard?: number };
|
|
61
|
+
|
|
59
62
|
/** Image-specific compose fragment from {@link ImageRecipe.apply}. */
|
|
60
63
|
export interface RecipeApplyResult {
|
|
61
64
|
readonly environment?: Readonly<Record<string, string>>;
|
|
@@ -64,8 +67,23 @@ export interface RecipeApplyResult {
|
|
|
64
67
|
readonly healthcheck?: ComposeHealthcheck;
|
|
65
68
|
readonly volumes?: readonly string[];
|
|
66
69
|
readonly user?: string;
|
|
70
|
+
/** Compose `ulimits` (e.g. Dragonfly `memlock: -1`). */
|
|
71
|
+
readonly ulimits?: Readonly<Record<string, RecipeUlimit>>;
|
|
72
|
+
/** Labels on this recipe's own compose service. */
|
|
73
|
+
readonly labels?: Readonly<Record<string, string>>;
|
|
67
74
|
/** Additional published ports (e.g. Mailpit UI, RustFS console). */
|
|
68
75
|
readonly extraPorts?: readonly RecipeExtraPort[];
|
|
76
|
+
/**
|
|
77
|
+
* Compose `depends_on` (e.g. pooler waits for Postgres).
|
|
78
|
+
* Keys are compose service names (`store-sql`), not role keys.
|
|
79
|
+
*/
|
|
80
|
+
readonly dependsOn?: Readonly<Record<string, { readonly condition: string }>>;
|
|
81
|
+
/**
|
|
82
|
+
* Extra `services` entries merged into this role's compose layer —
|
|
83
|
+
* companions (e.g. Docker socket proxy) or overlays on peers
|
|
84
|
+
* (e.g. Traefik routing labels on `app`).
|
|
85
|
+
*/
|
|
86
|
+
readonly services?: Readonly<Record<string, Record<string, unknown>>>;
|
|
69
87
|
}
|
|
70
88
|
|
|
71
89
|
/**
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `taqnyat-whatsapp` channel driver — WhatsApp via sently's Taqnyat transport.
|
|
3
|
+
*
|
|
4
|
+
* Exposes {@link TaqnyatWhatsAppTransport} (including vendor-extra
|
|
5
|
+
* `sendWithFailover`) on `whatsappTransport` for structural detection.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
TaqnyatWhatsAppTransport,
|
|
10
|
+
type TaqnyatWhatsAppFailover,
|
|
11
|
+
} from "sently/transports/taqnyat-whatsapp";
|
|
12
|
+
import { mapSentlySendError, mapSentlySendResult } from "./channel-sently-map.ts";
|
|
13
|
+
import type {
|
|
14
|
+
ChannelDriver,
|
|
15
|
+
ChannelMessage,
|
|
16
|
+
ChannelOpenOptions,
|
|
17
|
+
ChannelSendResult,
|
|
18
|
+
ChannelTransport,
|
|
19
|
+
} from "./channel-types.ts";
|
|
20
|
+
|
|
21
|
+
/** Structural vendor-extra for provider-side SMS/email failover. */
|
|
22
|
+
export type WhatsAppFailoverTransport = {
|
|
23
|
+
sendWithFailover(
|
|
24
|
+
options:
|
|
25
|
+
| { to: string; text: string }
|
|
26
|
+
| { to: string; template: { name: string; language: string } },
|
|
27
|
+
failover: TaqnyatWhatsAppFailover,
|
|
28
|
+
): Promise<{ readonly messageId: string; readonly status: string; readonly response: string }>;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether a WhatsApp transport exposes `sendWithFailover` (structural).
|
|
33
|
+
*
|
|
34
|
+
* @param t - Candidate transport
|
|
35
|
+
*/
|
|
36
|
+
export function hasWhatsAppSendWithFailover(t: unknown): t is WhatsAppFailoverTransport {
|
|
37
|
+
return (
|
|
38
|
+
!!t &&
|
|
39
|
+
typeof t === "object" &&
|
|
40
|
+
typeof (t as { sendWithFailover?: unknown }).sendWithFailover === "function"
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Open a Taqnyat WhatsApp driver.
|
|
46
|
+
*
|
|
47
|
+
* @param options - `bearerToken` / `token` / `apiKey`
|
|
48
|
+
*/
|
|
49
|
+
export function openTaqnyatWhatsAppChannel(options: ChannelOpenOptions = {}): ChannelDriver {
|
|
50
|
+
const bearerToken = options.bearerToken ?? options.token ?? options.apiKey;
|
|
51
|
+
if (!bearerToken) {
|
|
52
|
+
throw new Error("taqnyat-whatsapp: bearerToken (or token/apiKey) is required");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const transport = new TaqnyatWhatsAppTransport({ bearerToken });
|
|
56
|
+
|
|
57
|
+
const channel: ChannelTransport = {
|
|
58
|
+
provider: "taqnyat-whatsapp",
|
|
59
|
+
mediums: ["whatsapp"],
|
|
60
|
+
async send(message: ChannelMessage): Promise<ChannelSendResult> {
|
|
61
|
+
try {
|
|
62
|
+
const templateName =
|
|
63
|
+
message.template ??
|
|
64
|
+
(typeof message.data?.template === "string" ? message.data.template : undefined);
|
|
65
|
+
const language =
|
|
66
|
+
message.locale ??
|
|
67
|
+
(typeof message.data?.language === "string" ? message.data.language : "en");
|
|
68
|
+
|
|
69
|
+
const result = templateName
|
|
70
|
+
? await transport.send({
|
|
71
|
+
to: message.to,
|
|
72
|
+
template: { name: templateName, language },
|
|
73
|
+
})
|
|
74
|
+
: await transport.send({
|
|
75
|
+
to: message.to,
|
|
76
|
+
text: message.text ?? String(message.data?.otp ?? ""),
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return mapSentlySendResult("taqnyat-whatsapp", result);
|
|
80
|
+
} catch (err) {
|
|
81
|
+
return mapSentlySendError("taqnyat-whatsapp", err);
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
verify: () => transport.verify(),
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return { id: "taqnyat-whatsapp", channel, whatsappTransport: transport };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Taqnyat WhatsApp driver factory. */
|
|
91
|
+
export const taqnyatWhatsAppChannelDriver = {
|
|
92
|
+
id: "taqnyat-whatsapp" as const,
|
|
93
|
+
open: openTaqnyatWhatsAppChannel,
|
|
94
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tier-2 OTP cross-medium FallbackTransport delivery.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from "bun:test";
|
|
6
|
+
import { openConsoleChannel } from "../../drivers/channel-console.ts";
|
|
7
|
+
import type { ChannelSendResult } from "../../drivers/channel-types.ts";
|
|
8
|
+
import { deliverOtpAcrossChannels, shouldFallbackOtpMedium } from "./otp-delivery.ts";
|
|
9
|
+
|
|
10
|
+
describe("otp-delivery", () => {
|
|
11
|
+
test("shouldFallbackOtpMedium rejects invalid-address errors", () => {
|
|
12
|
+
expect(shouldFallbackOtpMedium(new Error("invalid recipient address"))).toBe(false);
|
|
13
|
+
expect(shouldFallbackOtpMedium(Object.assign(new Error("x"), { statusCode: 400 }))).toBe(false);
|
|
14
|
+
expect(shouldFallbackOtpMedium(new Error("provider timeout"))).toBe(true);
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
test("FailoverTransport advances to next medium on provider error", async () => {
|
|
18
|
+
let calls = 0;
|
|
19
|
+
const send = async (template: string): Promise<ChannelSendResult> => {
|
|
20
|
+
calls += 1;
|
|
21
|
+
if (template === "auth-otp-sms") {
|
|
22
|
+
return {
|
|
23
|
+
ok: false,
|
|
24
|
+
messageId: "x",
|
|
25
|
+
driverId: "sms",
|
|
26
|
+
attempts: [{ driverId: "sms", ok: false, error: "provider timeout", at: Date.now() }],
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
return {
|
|
30
|
+
ok: true,
|
|
31
|
+
messageId: "e1",
|
|
32
|
+
driverId: "email",
|
|
33
|
+
attempts: [{ driverId: "email", ok: true, at: Date.now() }],
|
|
34
|
+
};
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
const result = await deliverOtpAcrossChannels([openConsoleChannel()], send, {
|
|
38
|
+
channels: ["sms", "email"],
|
|
39
|
+
templates: { sms: "auth-otp-sms", email: "auth-otp-email" },
|
|
40
|
+
phone: "+15551234567",
|
|
41
|
+
email: "a@example.com",
|
|
42
|
+
data: { otp: "123456" },
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(result.ok).toBe(true);
|
|
46
|
+
expect(result.channel).toBe("email");
|
|
47
|
+
expect(calls).toBe(2);
|
|
48
|
+
expect(result.attempts.some((a) => !a.ok)).toBe(true);
|
|
49
|
+
expect(result.attempts.some((a) => a.ok)).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("only: delivers a single channel (explicit resend)", async () => {
|
|
53
|
+
const templates: string[] = [];
|
|
54
|
+
const send = async (template: string): Promise<ChannelSendResult> => {
|
|
55
|
+
templates.push(template);
|
|
56
|
+
return {
|
|
57
|
+
ok: true,
|
|
58
|
+
messageId: "m",
|
|
59
|
+
driverId: "console",
|
|
60
|
+
attempts: [{ driverId: "console", ok: true, at: Date.now() }],
|
|
61
|
+
};
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const result = await deliverOtpAcrossChannels([openConsoleChannel()], send, {
|
|
65
|
+
channels: ["sms", "email"],
|
|
66
|
+
templates: { sms: "auth-otp-sms", email: "auth-otp-email" },
|
|
67
|
+
phone: "+15551234567",
|
|
68
|
+
email: "a@example.com",
|
|
69
|
+
data: { otp: "123456" },
|
|
70
|
+
only: "email",
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result.channel).toBe("email");
|
|
74
|
+
expect(templates).toEqual(["auth-otp-email"]);
|
|
75
|
+
});
|
|
76
|
+
});
|