okengine 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +25 -12
  9. package/site/content/docs/elements/clock.mdx +17 -15
  10. package/site/content/docs/elements/flow.mdx +6 -2
  11. package/site/content/docs/elements/signal.mdx +10 -8
  12. package/site/content/docs/elements/store.mdx +165 -0
  13. package/site/content/docs/get-started/index.mdx +5 -0
  14. package/site/content/docs/get-started/installation.mdx +18 -16
  15. package/site/content/docs/index.mdx +5 -0
  16. package/site/content/docs/meta.json +10 -1
  17. package/site/content/docs/plugins/index.mdx +1 -2
  18. package/site/content/docs/plugins/magic-link.mdx +44 -2
  19. package/site/content/docs/plugins/meta.json +1 -2
  20. package/site/content/docs/plugins/otp.mdx +202 -0
  21. package/site/content/docs/plugins/two-factor.mdx +2 -1
  22. package/site/content/docs/reference/cli.md +5 -2
  23. package/site/content/docs/reference/configuration.mdx +21 -3
  24. package/site/content/docs/reference/environment-variables.mdx +21 -8
  25. package/site/content/docs/reference/plugins.mdx +1 -1
  26. package/src/auth/auth.test.ts +36 -0
  27. package/src/auth/bindings.ts +3 -12
  28. package/src/auth/identity.ts +33 -0
  29. package/src/auth/index.ts +5 -0
  30. package/src/auth/otp-capability.ts +119 -0
  31. package/src/auth/otp-seal.test.ts +61 -0
  32. package/src/auth/otp-seal.ts +84 -0
  33. package/src/auth/schema.ts +3 -0
  34. package/src/auth/sessions.ts +26 -27
  35. package/src/auth/tables.ts +4 -0
  36. package/src/auth/verification.ts +61 -1
  37. package/src/cli/db-seed.ts +359 -0
  38. package/src/cli/db.test.ts +341 -3
  39. package/src/cli/db.ts +75 -8
  40. package/src/cli/dev-app-runner.ts +4 -0
  41. package/src/cli/docker.ts +4 -1
  42. package/src/cli/load-config.images.test.ts +26 -0
  43. package/src/cli/load-config.ts +10 -2
  44. package/src/cli/registry.ts +38 -2
  45. package/src/compiler/effects-infer.ts +1 -0
  46. package/src/config/index.ts +4 -0
  47. package/src/console/server/operator-db.ts +34 -9
  48. package/src/docker/compose.ts +162 -6
  49. package/src/docker/derive.ts +60 -3
  50. package/src/docker/docker.test.ts +374 -1
  51. package/src/docker/helpers.ts +2 -0
  52. package/src/docker/index.ts +11 -0
  53. package/src/docker/recipes/caddy.ts +51 -0
  54. package/src/docker/recipes/dragonfly.ts +31 -0
  55. package/src/docker/recipes/index.ts +25 -2
  56. package/src/docker/recipes/pgdog.ts +84 -0
  57. package/src/docker/recipes/redis.ts +6 -3
  58. package/src/docker/recipes/traefik.ts +83 -0
  59. package/src/docker/recipes/valkey.ts +30 -0
  60. package/src/docker/stack-id.ts +5 -0
  61. package/src/docker/types.ts +18 -0
  62. package/src/drivers/channel-sently.test.ts +8 -0
  63. package/src/drivers/channel-taqnyat-mail.ts +34 -0
  64. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  65. package/src/drivers/channel-types.ts +72 -0
  66. package/src/drivers/clock-postgres.test.ts +258 -0
  67. package/src/drivers/clock-postgres.ts +410 -0
  68. package/src/drivers/index.ts +18 -0
  69. package/src/drivers/journal-postgres.test.ts +175 -0
  70. package/src/drivers/journal-postgres.ts +492 -0
  71. package/src/elements/channel/otp-delivery.test.ts +76 -0
  72. package/src/elements/channel/otp-delivery.ts +291 -0
  73. package/src/elements/channel/runtime.ts +203 -114
  74. package/src/elements/channel.test.ts +71 -0
  75. package/src/elements/channel.ts +12 -2
  76. package/src/elements/clock/chaos-child.ts +280 -41
  77. package/src/elements/clock/durable.ts +7 -0
  78. package/src/elements/clock/reconcile.ts +2 -2
  79. package/src/elements/clock/runtime.ts +5 -3
  80. package/src/elements/clock.ts +1 -1
  81. package/src/elements/store/seed.test.ts +27 -0
  82. package/src/elements/store/seed.ts +68 -0
  83. package/src/elements/store/sql-session.test.ts +39 -0
  84. package/src/elements/store/sql-session.ts +55 -0
  85. package/src/elements/store/upsert-app.test.ts +103 -0
  86. package/src/elements/store.ts +5 -0
  87. package/src/index.ts +18 -0
  88. package/src/kernel/app.ts +221 -14
  89. package/src/kernel/boot-bind/channel.test.ts +16 -0
  90. package/src/kernel/boot-bind/channel.ts +64 -0
  91. package/src/kernel/boot-bind/clock.ts +17 -6
  92. package/src/kernel/boot-bind/gate.ts +14 -19
  93. package/src/kernel/boot-bind/honor-config.test.ts +123 -4
  94. package/src/kernel/boot-bind/journal.ts +89 -0
  95. package/src/kernel/boot-bind/signal.ts +20 -0
  96. package/src/kernel/boot-bind/store.test.ts +82 -0
  97. package/src/kernel/boot-bind/store.ts +22 -0
  98. package/src/kernel/boot.test.ts +6 -4
  99. package/src/kernel/boot.ts +53 -13
  100. package/src/kernel/concurrency.ts +1 -1
  101. package/src/kernel/fx.test.ts +9 -0
  102. package/src/kernel/fx.ts +175 -5
  103. package/src/kernel/graceful-shutdown.test.ts +76 -0
  104. package/src/kernel/graceful-shutdown.ts +106 -0
  105. package/src/kernel/horizontal-child.ts +257 -0
  106. package/src/kernel/horizontal.integration.test.ts +229 -0
  107. package/src/kernel/index.ts +14 -0
  108. package/src/kernel/journal-boot.test.ts +397 -0
  109. package/src/kernel/journal-suspend.ts +35 -0
  110. package/src/kernel/journal.test.ts +142 -0
  111. package/src/kernel/journal.ts +202 -27
  112. package/src/kernel/ready.test.ts +76 -0
  113. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  114. package/src/plugins/auth-methods.security.test.ts +20 -27
  115. package/src/plugins/auth-methods.test.ts +7 -6
  116. package/src/plugins/index.ts +12 -8
  117. package/src/plugins/magic-link.ts +1 -23
  118. package/src/plugins/otp.test.ts +236 -0
  119. package/src/plugins/otp.ts +570 -0
  120. package/src/plugins/taqnyat.live.test.ts +172 -0
  121. package/src/release/official-plugins.ts +1 -2
  122. package/site/content/docs/plugins/email-otp.mdx +0 -117
  123. package/site/content/docs/plugins/phone-number.mdx +0 -111
  124. package/src/plugins/email-otp.ts +0 -214
  125. package/src/plugins/phone-number.ts +0 -149
@@ -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-protocol image recipe (Redis · Valkey · Dragonfly · KeyDB).
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-protocol servers. */
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|valkey|dragonfly|keydb/i.test(i),
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
+ };
@@ -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
  /**
@@ -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
  /**
@@ -7,6 +7,7 @@ import { openFcmChannel } from "./channel-fcm.ts";
7
7
  import { openMsegatChannel } from "./channel-msegat.ts";
8
8
  import { openSndrChannel } from "./channel-sndr.ts";
9
9
  import { openTaqnyatChannel } from "./channel-taqnyat.ts";
10
+ import { openTaqnyatMailChannel } from "./channel-taqnyat-mail.ts";
10
11
  import { openUnifonicChannel } from "./channel-unifonic.ts";
11
12
  import { openWaCloudChannel } from "./channel-wa-cloud.ts";
12
13
  import { openWebPushChannel } from "./channel-webpush.ts";
@@ -26,6 +27,13 @@ describe("sently channel drivers", () => {
26
27
  expect(d.channel?.mediums).toContain("sms");
27
28
  });
28
29
 
30
+ test("taqnyat-mail requires bearer + campaignName", () => {
31
+ expect(() => openTaqnyatMailChannel({ bearerToken: "t" })).toThrow("campaignName");
32
+ const d = openTaqnyatMailChannel({ bearerToken: "t", campaignName: "auth" });
33
+ expect(d.id).toBe("taqnyat-mail");
34
+ expect(d.transport?.provider).toBe("taqnyat-mail");
35
+ });
36
+
29
37
  test("msegat requires userName + apiKey + sender", () => {
30
38
  expect(() => openMsegatChannel({ userName: "u", apiKey: "k" })).toThrow("sender");
31
39
  const d = openMsegatChannel({ userName: "u", apiKey: "k", sender: "Brand" });
@@ -0,0 +1,34 @@
1
+ /**
2
+ * `taqnyat-mail` channel driver — Email via sently's Taqnyat Mail transport.
3
+ *
4
+ * Additive email option (alongside smtp / resend / sndr). Taqnyat's
5
+ * `mailSend.php` requires a campaign name — set via options or
6
+ * `TAQNYAT_CAMPAIGN`.
7
+ */
8
+
9
+ import { TaqnyatMailTransport } from "sently/transports/taqnyat-mail";
10
+ import type { ChannelDriver, ChannelOpenOptions } from "./channel-types.ts";
11
+
12
+ /**
13
+ * Open a Taqnyat Email driver.
14
+ *
15
+ * @param options - `bearerToken`/`token`/`apiKey` + `campaignName`
16
+ */
17
+ export function openTaqnyatMailChannel(options: ChannelOpenOptions = {}): ChannelDriver {
18
+ const bearerToken = options.bearerToken ?? options.token ?? options.apiKey;
19
+ if (!bearerToken) {
20
+ throw new Error("taqnyat-mail channel: bearerToken (or token/apiKey) is required");
21
+ }
22
+ const campaignName = options.campaignName;
23
+ if (!campaignName) {
24
+ throw new Error("taqnyat-mail channel: campaignName is required");
25
+ }
26
+ const transport = new TaqnyatMailTransport({ bearerToken, campaignName });
27
+ return { id: "taqnyat-mail", transport };
28
+ }
29
+
30
+ /** Taqnyat Email driver factory. */
31
+ export const taqnyatMailChannelDriver = {
32
+ id: "taqnyat-mail" as const,
33
+ open: openTaqnyatMailChannel,
34
+ };
@@ -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
+ };
@@ -58,6 +58,74 @@ export interface WhatsAppTransport {
58
58
  close?(): Promise<void>;
59
59
  }
60
60
 
61
+ /**
62
+ * Provider-managed OTP vendor extra on an SMS transport (Taqnyat Verify).
63
+ * Kept off the base {@link SmsTransport} surface — only transports that expose
64
+ * these methods support Channel provider OTP.
65
+ */
66
+ export interface SmsOtpTransport {
67
+ sendOtp(options: ChannelOtpSendOptions): Promise<ChannelOtpSendResult>;
68
+ verifyOtp(options: ChannelOtpVerifyOptions): Promise<ChannelOtpVerifyResult>;
69
+ }
70
+
71
+ /** Options for {@link SmsOtpTransport.sendOtp} (Taqnyat Verify). */
72
+ export interface ChannelOtpSendOptions {
73
+ /** Recipient phone number (E.164). */
74
+ readonly to: string;
75
+ /** Unique id for this verification flow (required again on verify). */
76
+ readonly requestId: string;
77
+ /** Message language (`en` or `ar`). */
78
+ readonly lang?: "en" | "ar";
79
+ /** Optional note appended to the OTP SMS. */
80
+ readonly note?: string;
81
+ /** Sender id override. */
82
+ readonly from?: string;
83
+ }
84
+
85
+ /** Result of a successful provider OTP send. */
86
+ export interface ChannelOtpSendResult {
87
+ /** Echo of the {@link ChannelOtpSendOptions.requestId}. */
88
+ readonly requestId: string;
89
+ /** Recipient as passed in. */
90
+ readonly to: string;
91
+ /** Provider status code (`5` = code sent). */
92
+ readonly code: number;
93
+ /** Raw response body text. */
94
+ readonly response: string;
95
+ /** Provider identifier. */
96
+ readonly provider: string;
97
+ }
98
+
99
+ /** Options for {@link SmsOtpTransport.verifyOtp}. */
100
+ export interface ChannelOtpVerifyOptions {
101
+ /** Recipient phone number (same as send). */
102
+ readonly to: string;
103
+ /** Same {@link ChannelOtpSendOptions.requestId} used when sending. */
104
+ readonly requestId: string;
105
+ /** OTP code the user entered. */
106
+ readonly code: string;
107
+ /** Message language (`en` or `ar`). */
108
+ readonly lang?: "en" | "ar";
109
+ /** Sender id override. */
110
+ readonly from?: string;
111
+ /** Optional note. */
112
+ readonly note?: string;
113
+ }
114
+
115
+ /** Result of a successful provider OTP check (failures throw). */
116
+ export interface ChannelOtpVerifyResult {
117
+ /** Always `true` when the call resolves. */
118
+ readonly ok: true;
119
+ /** Provider status code (`10` = completed; `13`/`19` = already verified). */
120
+ readonly code: number;
121
+ /** Provider message when present. */
122
+ readonly message: string;
123
+ /** Raw response body text. */
124
+ readonly response: string;
125
+ /** Provider identifier. */
126
+ readonly provider: string;
127
+ }
128
+
61
129
  /** Sently-compatible push transport (structural). */
62
130
  export interface PushTransport {
63
131
  readonly provider?: string;
@@ -79,6 +147,8 @@ export type ChannelDriverId =
79
147
  | "resend"
80
148
  | "sndr"
81
149
  | "taqnyat"
150
+ | "taqnyat-mail"
151
+ | "taqnyat-whatsapp"
82
152
  | "msegat"
83
153
  | "unifonic"
84
154
  | "wa-cloud"
@@ -183,6 +253,8 @@ export interface ChannelOpenOptions {
183
253
  readonly vapidPublicKey?: string;
184
254
  readonly vapidPrivateKey?: string;
185
255
  readonly vapidSubject?: string;
256
+ /** Taqnyat Email campaign name (required by `mailSend.php`). */
257
+ readonly campaignName?: string;
186
258
  /** Injected fetch for HTTP drivers. */
187
259
  readonly fetch?: typeof globalThis.fetch;
188
260
  /** Dev inbox sink for console driver. */