okengine 0.9.0 → 0.10.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 (175) hide show
  1. package/AGENTS.md +1 -1
  2. package/manifest.v1.schema.json +9 -1
  3. package/package.json +3 -3
  4. package/site/content/docs/console/runs.mdx +10 -0
  5. package/site/content/docs/console/traces.mdx +4 -0
  6. package/site/content/docs/elements/ai.mdx +54 -32
  7. package/site/content/docs/elements/channel.mdx +105 -14
  8. package/site/content/docs/elements/clock.mdx +1 -0
  9. package/site/content/docs/elements/flow.mdx +42 -8
  10. package/site/content/docs/get-started/basic-usage.mdx +5 -1
  11. package/site/content/docs/get-started/installation.mdx +9 -5
  12. package/site/content/docs/meta.json +2 -0
  13. package/site/content/docs/plugins/otp.mdx +58 -46
  14. package/site/content/docs/providers/azure-redis.mdx +85 -0
  15. package/site/content/docs/providers/cockroachdb.mdx +86 -0
  16. package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
  17. package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
  18. package/site/content/docs/providers/elasticache.mdx +92 -0
  19. package/site/content/docs/providers/index.mdx +101 -0
  20. package/site/content/docs/providers/memorystore.mdx +89 -0
  21. package/site/content/docs/providers/meta.json +20 -0
  22. package/site/content/docs/providers/neon.mdx +115 -0
  23. package/site/content/docs/providers/redis-cloud.mdx +93 -0
  24. package/site/content/docs/providers/supabase.mdx +117 -0
  25. package/site/content/docs/providers/upstash.mdx +91 -0
  26. package/site/content/docs/providers/yugabytedb.mdx +95 -0
  27. package/site/content/docs/recipes/caddy.mdx +128 -0
  28. package/site/content/docs/recipes/cockroachdb.mdx +127 -0
  29. package/site/content/docs/recipes/dragonfly.mdx +125 -0
  30. package/site/content/docs/recipes/index.mdx +146 -0
  31. package/site/content/docs/recipes/llama-cpp.mdx +153 -0
  32. package/site/content/docs/recipes/mailpit.mdx +136 -0
  33. package/site/content/docs/recipes/meilisearch.mdx +139 -0
  34. package/site/content/docs/recipes/meta.json +31 -0
  35. package/site/content/docs/recipes/ollama.mdx +142 -0
  36. package/site/content/docs/recipes/openbao.mdx +141 -0
  37. package/site/content/docs/recipes/pgdog.mdx +137 -0
  38. package/site/content/docs/recipes/postgres.mdx +136 -0
  39. package/site/content/docs/recipes/redis.mdx +130 -0
  40. package/site/content/docs/recipes/rustfs.mdx +148 -0
  41. package/site/content/docs/recipes/sglang.mdx +105 -0
  42. package/site/content/docs/recipes/supabase-docker.mdx +145 -0
  43. package/site/content/docs/recipes/timescale.mdx +129 -0
  44. package/site/content/docs/recipes/traefik.mdx +136 -0
  45. package/site/content/docs/recipes/valkey.mdx +121 -0
  46. package/site/content/docs/recipes/vllm.mdx +106 -0
  47. package/site/content/docs/recipes/yugabytedb.mdx +135 -0
  48. package/site/content/docs/reference/cli.md +42 -6
  49. package/site/content/docs/reference/configuration.mdx +5 -3
  50. package/site/content/docs/reference/environment-variables.mdx +12 -12
  51. package/site/content/docs/reference/fx.mdx +38 -4
  52. package/src/auth/otp-capability.ts +14 -14
  53. package/src/cli/ai-setup/ai-setup.test.ts +32 -12
  54. package/src/cli/ai-setup/apply.ts +65 -23
  55. package/src/cli/ai-setup/catalog.ts +1316 -35
  56. package/src/cli/ai-setup/detect-ollama.ts +47 -0
  57. package/src/cli/ai-setup/index.ts +44 -7
  58. package/src/cli/ai-setup/prompts.ts +430 -486
  59. package/src/cli/ai-setup/recommend.test.ts +150 -50
  60. package/src/cli/ai-setup/recommend.ts +118 -101
  61. package/src/cli/ask-seed.test.ts +68 -0
  62. package/src/cli/ask-seed.ts +79 -0
  63. package/src/cli/db-seed-redact.test.ts +25 -0
  64. package/src/cli/db-seed.ts +36 -7
  65. package/src/cli/db.test.ts +1 -1
  66. package/src/cli/db.ts +2 -2
  67. package/src/cli/dev-controls.test.ts +107 -0
  68. package/src/cli/dev-controls.ts +354 -0
  69. package/src/cli/dev-schema-sync.ts +12 -4
  70. package/src/cli/dev.test.ts +50 -30
  71. package/src/cli/dev.ts +607 -66
  72. package/src/cli/drizzle-env.test.ts +40 -6
  73. package/src/cli/drizzle-env.ts +22 -1
  74. package/src/cli/ensure-drizzle-config.ts +6 -4
  75. package/src/cli/hero-meta.test.ts +13 -1
  76. package/src/cli/hero-meta.ts +28 -6
  77. package/src/cli/index.ts +5 -0
  78. package/src/cli/registry.ts +24 -0
  79. package/src/cli/replay.test.ts +105 -0
  80. package/src/cli/replay.ts +206 -0
  81. package/src/compiler/extract.ts +3 -3
  82. package/src/config/index.ts +3 -3
  83. package/src/console/server/claim.ts +3 -1
  84. package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
  85. package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
  86. package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
  87. package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
  88. package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
  89. package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
  90. package/src/console/ui/dist/index.html +1 -1
  91. package/src/console/ui/overview/slo.ts +31 -3
  92. package/src/console/ui/overview/types.ts +6 -0
  93. package/src/console/ui/runs/errors.test.ts +53 -0
  94. package/src/console/ui/runs/errors.ts +88 -0
  95. package/src/console/ui/runs/index.ts +8 -0
  96. package/src/console/ui/runs/search.ts +16 -0
  97. package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
  98. package/src/docker/ai-model-status.test.ts +101 -0
  99. package/src/docker/ai-model-status.ts +276 -0
  100. package/src/docker/compose-health.test.ts +115 -0
  101. package/src/docker/compose-health.ts +234 -0
  102. package/src/docker/compose.ts +24 -8
  103. package/src/docker/derive.ts +35 -9
  104. package/src/docker/docker.test.ts +304 -12
  105. package/src/docker/helpers.ts +56 -0
  106. package/src/docker/index.ts +52 -0
  107. package/src/docker/ollama-pull.ts +232 -0
  108. package/src/docker/recipes/cockroach.ts +24 -0
  109. package/src/docker/recipes/index.ts +32 -0
  110. package/src/docker/recipes/llama-cpp.ts +273 -0
  111. package/src/docker/recipes/ollama.ts +19 -23
  112. package/src/docker/recipes/pgdog.ts +4 -1
  113. package/src/docker/recipes/postgres.ts +5 -18
  114. package/src/docker/recipes/sglang.ts +55 -0
  115. package/src/docker/recipes/supabase.ts +21 -0
  116. package/src/docker/recipes/timescale.ts +18 -0
  117. package/src/docker/recipes/vllm.ts +44 -0
  118. package/src/docker/recipes/yugabyte.ts +23 -0
  119. package/src/docker/stack-id.test.ts +22 -0
  120. package/src/docker/stack-id.ts +38 -4
  121. package/src/docker/types.ts +15 -0
  122. package/src/drivers/ai-ollama.integration.test.ts +7 -4
  123. package/src/drivers/signal-engine.ts +10 -0
  124. package/src/drivers/signal-postgres.ts +24 -2
  125. package/src/drivers/signal-types.ts +10 -0
  126. package/src/elements/channel/fallback.test.ts +91 -0
  127. package/src/elements/channel/hard-bounce.test.ts +82 -0
  128. package/src/elements/channel/injection.test.ts +153 -0
  129. package/src/elements/channel/locale-ar.test.ts +102 -0
  130. package/src/elements/channel/otp-delivery.ts +5 -2
  131. package/src/elements/channel/receipts-ledger.test.ts +100 -0
  132. package/src/elements/channel/runtime.ts +24 -1
  133. package/src/elements/channel/test-helpers.ts +60 -0
  134. package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
  135. package/src/elements/store/cache.ts +1 -1
  136. package/src/elements/store/emit-drizzle.ts +43 -9
  137. package/src/elements/store/runtime.ts +1 -1
  138. package/src/elements/store/schema-decl.test.ts +2 -0
  139. package/src/elements/store/schema-decl.ts +15 -0
  140. package/src/elements/store/seed.ts +1 -1
  141. package/src/index.ts +1 -0
  142. package/src/kernel/app.ts +118 -11
  143. package/src/kernel/boot-bind/signal.ts +6 -2
  144. package/src/kernel/boot-bind/store.ts +2 -1
  145. package/src/kernel/boot.test.ts +6 -5
  146. package/src/kernel/boot.ts +8 -3
  147. package/src/kernel/compensate.test.ts +131 -0
  148. package/src/kernel/correlation.test.ts +108 -0
  149. package/src/kernel/effect-timing.test.ts +82 -0
  150. package/src/kernel/flow.ts +38 -0
  151. package/src/kernel/fx-runs.test.ts +49 -0
  152. package/src/kernel/fx.test.ts +14 -0
  153. package/src/kernel/fx.ts +111 -1
  154. package/src/manifest/types.ts +8 -2
  155. package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
  156. package/src/plugins/auth-methods.security.test.ts +3 -3
  157. package/src/plugins/auth-methods.test.ts +1 -1
  158. package/src/plugins/index.ts +2 -2
  159. package/src/plugins/otp.test.ts +21 -19
  160. package/src/plugins/otp.ts +36 -34
  161. package/src/plugins/taqnyat.live.test.ts +3 -3
  162. package/src/runs/collect.ts +3 -0
  163. package/src/runs/export-otlp.ts +64 -0
  164. package/src/runs/index.ts +16 -0
  165. package/src/runs/parquet.ts +9 -0
  166. package/src/runs/types.ts +5 -0
  167. package/src/runs/window.test.ts +68 -0
  168. package/src/runs/window.ts +148 -0
  169. package/src/runtime/boot-warn.test.ts +45 -0
  170. package/src/runtime/boot-warn.ts +17 -0
  171. package/src/runtime/dev-request-log.ts +5 -5
  172. package/src/term.test.ts +95 -10
  173. package/src/term.ts +369 -14
  174. package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
  175. package/src/console/ui/dist/assets/panel-runs-D5zf-D9c.js +0 -1
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Hard-bounce auto-suppression adversarial proofs:
3
+ * - ingestOutcome(hard-bounce) adds prior-bounce suppression
4
+ * - subsequent send is blocked before the transport is called
5
+ * - soft-bounce / complaint do not auto-suppress
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { channel, createChannelRuntime } from "../channel.ts";
10
+ import { countingOkTransport, driverFromTransport } from "./test-helpers.ts";
11
+
12
+ describe("hard-bounce auto-suppression", () => {
13
+ test("hard bounce auto-adds suppression and blocks the next send before transport", async () => {
14
+ const { transport, calls } = countingOkTransport("smtp");
15
+ const runtime = createChannelRuntime({
16
+ templates: [channel.template("news", { medium: "email" })],
17
+ drivers: [driverFromTransport("smtp", transport)],
18
+ catalog: { news: { en: { subject: "Hi", text: "Hello" } } },
19
+ });
20
+
21
+ const first = await runtime.send("news", { to: "bounce@example.com" });
22
+ expect(first.ok).toBe(true);
23
+ expect(calls.count).toBe(1);
24
+
25
+ const sent = runtime.receipts.all()[0]!;
26
+ runtime.ingestOutcome({
27
+ messageId: sent.messageId!,
28
+ state: "hard-bounce",
29
+ to: "bounce@example.com",
30
+ medium: "email",
31
+ });
32
+
33
+ const listed = runtime.suppression.list();
34
+ expect(
35
+ listed.some((e) => e.subject === "bounce@example.com" && e.reason === "prior-bounce"),
36
+ ).toBe(true);
37
+
38
+ const blocked = await runtime.send("news", { to: "bounce@example.com" });
39
+ expect(blocked.ok).toBe(false);
40
+ expect(blocked.driverId).toBe("suppression");
41
+ expect(blocked.attempts).toEqual([]);
42
+ expect(calls.count).toBe(1);
43
+
44
+ const last = runtime.receipts.all().at(-1)!;
45
+ expect(last.status).toBe("suppressed/prior-bounce");
46
+ expect(last.attempts).toEqual([]);
47
+ });
48
+
49
+ test("soft-bounce and complaint do not auto-add prior-bounce suppression", async () => {
50
+ const { transport, calls } = countingOkTransport("smtp");
51
+ const runtime = createChannelRuntime({
52
+ templates: [channel.template("news", { medium: "email" })],
53
+ drivers: [driverFromTransport("smtp", transport)],
54
+ });
55
+
56
+ await runtime.send("news", { to: "soft@example.com" });
57
+ const softId = runtime.receipts.all()[0]!.messageId!;
58
+ runtime.ingestOutcome({
59
+ messageId: softId,
60
+ state: "soft-bounce",
61
+ to: "soft@example.com",
62
+ medium: "email",
63
+ });
64
+ expect(runtime.suppression.list().some((e) => e.reason === "prior-bounce")).toBe(false);
65
+
66
+ await runtime.send("news", { to: "complaint@example.com" });
67
+ const complaintId = runtime.receipts
68
+ .all()
69
+ .find((r) => r.to === "complaint@example.com")!.messageId!;
70
+ runtime.ingestOutcome({
71
+ messageId: complaintId,
72
+ state: "delivered-then-complained",
73
+ to: "complaint@example.com",
74
+ medium: "email",
75
+ });
76
+ expect(runtime.suppression.list().some((e) => e.reason === "prior-bounce")).toBe(false);
77
+
78
+ const again = await runtime.send("news", { to: "soft@example.com" });
79
+ expect(again.ok).toBe(true);
80
+ expect(calls.count).toBeGreaterThanOrEqual(3);
81
+ });
82
+ });
@@ -0,0 +1,153 @@
1
+ /**
2
+ * Injection escape hatch adversarial proofs:
3
+ * - custom suppression / receipts are used instead of process-local defaults
4
+ * - consent-only injection is wrapped into suppression
5
+ * - injecting both skips the process-local boot warn
6
+ */
7
+
8
+ import { afterEach, describe, expect, test } from "bun:test";
9
+ import type { ChannelMedium } from "../../manifest/types.ts";
10
+ import {
11
+ channel,
12
+ createChannelRuntime,
13
+ createConsentStore,
14
+ createReceiptLedger,
15
+ createSuppressionStore,
16
+ resetChannelProcessLocalWarnForTests,
17
+ type DeliveryReceipt,
18
+ type DeliveryStatus,
19
+ type ReceiptLedger,
20
+ type SuppressionStore,
21
+ } from "../channel.ts";
22
+ import { driverFromTransport, okTransport } from "./test-helpers.ts";
23
+
24
+ afterEach(() => {
25
+ resetChannelProcessLocalWarnForTests();
26
+ });
27
+
28
+ function trackingReceipts(): ReceiptLedger & { records: number; updates: number } {
29
+ const inner = createReceiptLedger();
30
+ const ledger = {
31
+ records: 0,
32
+ updates: 0,
33
+ record(receipt: DeliveryReceipt) {
34
+ ledger.records += 1;
35
+ inner.record(receipt);
36
+ },
37
+ all: () => inner.all(),
38
+ forTemplate: (t: string) => inner.forTemplate(t),
39
+ byMessageId: (id: string) => inner.byMessageId(id),
40
+ updateStatus(
41
+ messageId: string,
42
+ patch: { status: DeliveryStatus; at?: number; error?: string },
43
+ ) {
44
+ ledger.updates += 1;
45
+ return inner.updateStatus(messageId, patch);
46
+ },
47
+ };
48
+ return ledger;
49
+ }
50
+
51
+ function trackingSuppression(): SuppressionStore & {
52
+ checks: number;
53
+ bounces: number;
54
+ } {
55
+ const inner = createSuppressionStore();
56
+ const store = {
57
+ checks: 0,
58
+ bounces: 0,
59
+ get consent() {
60
+ return inner.consent;
61
+ },
62
+ isSuppressed(subject: string, medium: ChannelMedium) {
63
+ store.checks += 1;
64
+ return inner.isSuppressed(subject, medium);
65
+ },
66
+ optOut(subject: string, medium: ChannelMedium | "all") {
67
+ inner.optOut(subject, medium);
68
+ },
69
+ addPriorBounce(subject: string, medium: ChannelMedium | "all") {
70
+ store.bounces += 1;
71
+ inner.addPriorBounce(subject, medium);
72
+ },
73
+ clear(subject: string, medium: ChannelMedium | "all") {
74
+ inner.clear(subject, medium);
75
+ },
76
+ list: () => inner.list(),
77
+ };
78
+ return store;
79
+ }
80
+
81
+ describe("channel store injection escape hatch", () => {
82
+ test("injected suppression and receipts are genuinely used", async () => {
83
+ const suppression = trackingSuppression();
84
+ const receipts = trackingReceipts();
85
+ const runtime = createChannelRuntime({
86
+ templates: [channel.template("news", { medium: "email" })],
87
+ drivers: [driverFromTransport("smtp", okTransport("smtp"))],
88
+ suppression,
89
+ receipts,
90
+ });
91
+
92
+ expect(runtime.suppression).toBe(suppression);
93
+ expect(runtime.receipts).toBe(receipts);
94
+
95
+ await runtime.send("news", { to: "a@b.c" });
96
+ expect(suppression.checks).toBeGreaterThanOrEqual(1);
97
+ expect(receipts.records).toBe(1);
98
+
99
+ const mid = receipts.all()[0]!.messageId!;
100
+ runtime.ingestOutcome({
101
+ messageId: mid,
102
+ state: "hard-bounce",
103
+ to: "a@b.c",
104
+ medium: "email",
105
+ });
106
+ expect(suppression.bounces).toBe(1);
107
+ expect(receipts.updates).toBe(1);
108
+
109
+ await runtime.send("news", { to: "a@b.c" });
110
+ expect(receipts.all().at(-1)!.status).toBe("suppressed/prior-bounce");
111
+ expect(receipts.records).toBe(2);
112
+ });
113
+
114
+ test("consent-only injection is consulted via wrapped suppression", async () => {
115
+ const consent = createConsentStore();
116
+ consent.optOut("x@y.z", "email");
117
+ const receipts = trackingReceipts();
118
+ const runtime = createChannelRuntime({
119
+ templates: [channel.template("news", { medium: "email" })],
120
+ drivers: [driverFromTransport("smtp", okTransport("smtp"))],
121
+ consent,
122
+ receipts,
123
+ });
124
+
125
+ const result = await runtime.send("news", { to: "x@y.z" });
126
+ expect(result.ok).toBe(false);
127
+ expect(receipts.records).toBe(1);
128
+ expect(receipts.all()[0]!.status).toBe("suppressed/opted-out");
129
+ });
130
+
131
+ test("injecting both suppression and receipts skips process-local warn", () => {
132
+ resetChannelProcessLocalWarnForTests();
133
+ const warns: string[] = [];
134
+ const original = console.warn;
135
+ console.warn = (...args: unknown[]) => {
136
+ warns.push(args.map(String).join(" "));
137
+ };
138
+ try {
139
+ createChannelRuntime({
140
+ suppression: createSuppressionStore(),
141
+ receipts: createReceiptLedger(),
142
+ });
143
+ expect(warns.some((w) => w.includes("process-local memory"))).toBe(false);
144
+
145
+ resetChannelProcessLocalWarnForTests();
146
+ warns.length = 0;
147
+ createChannelRuntime({});
148
+ expect(warns.some((w) => w.includes("process-local memory"))).toBe(true);
149
+ } finally {
150
+ console.warn = original;
151
+ }
152
+ });
153
+ });
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Arabic / RTL catalog round-trip proofs:
3
+ * - {{field}} interpolation inside Arabic bodies
4
+ * - ar vs ar-SA exact catalog key behavior
5
+ * - isRtlLocale for display (Channel does not rewrite HTML dir)
6
+ */
7
+
8
+ import { describe, expect, test } from "bun:test";
9
+ import { createChannelInbox, openConsoleChannel } from "../../drivers/index.ts";
10
+ import { channel, createChannelRuntime, isRtlLocale } from "../channel.ts";
11
+
12
+ describe("Arabic template catalog", () => {
13
+ test("interpolates variables inside Arabic subject and text", async () => {
14
+ const inbox = createChannelInbox();
15
+ const runtime = createChannelRuntime({
16
+ templates: [
17
+ channel.template("order-confirmed", {
18
+ medium: "email",
19
+ locales: ["en", "ar"],
20
+ }),
21
+ ],
22
+ drivers: [openConsoleChannel({ inbox })],
23
+ catalog: {
24
+ "order-confirmed": {
25
+ en: {
26
+ subject: "Order {{orderId}}",
27
+ text: "Hello {{name}}, order {{orderId}} is confirmed.",
28
+ },
29
+ ar: {
30
+ subject: "طلب {{orderId}}",
31
+ text: "مرحبًا {{name}}، تم تأكيد الطلب {{orderId}}.",
32
+ },
33
+ },
34
+ },
35
+ });
36
+
37
+ await runtime.send("order-confirmed", {
38
+ to: "user@example.com",
39
+ locale: "ar",
40
+ data: { name: "علي", orderId: "ORD-42" },
41
+ });
42
+
43
+ const entry = inbox.entries[0]!;
44
+ expect(entry.subject).toBe("طلب ORD-42");
45
+ expect(entry.text).toBe("مرحبًا علي، تم تأكيد الطلب ORD-42.");
46
+ expect(entry.subject).not.toContain("{{");
47
+ expect(entry.text).not.toContain("{{");
48
+ // Email console inbox omits locale; the receipt is the ledger of truth.
49
+ expect(runtime.receipts.all()[0]!.locale).toBe("ar");
50
+ });
51
+
52
+ test("Accept-Language ar-SA misses catalog key ar and falls back to default/en body", async () => {
53
+ const inbox = createChannelInbox();
54
+ const runtime = createChannelRuntime({
55
+ templates: [channel.template("hello", { medium: "email", locales: ["en", "ar"] })],
56
+ drivers: [openConsoleChannel({ inbox })],
57
+ defaultLocale: "en",
58
+ catalog: {
59
+ hello: {
60
+ en: { subject: "Hi {{name}}", text: "Hello {{name}}" },
61
+ ar: { subject: "مرحبا {{name}}", text: "أهلا {{name}}" },
62
+ },
63
+ },
64
+ });
65
+
66
+ await runtime.send("hello", {
67
+ to: "a@b.c",
68
+ acceptLanguage: "ar-SA,en;q=0.8",
69
+ data: { name: "سارة" },
70
+ });
71
+
72
+ const receipt = runtime.receipts.all()[0]!;
73
+ expect(receipt.locale).toBe("ar-SA");
74
+ // Exact key miss → defaultLocale "en" body with interpolation still applied
75
+ expect(inbox.entries[0]!.subject).toBe("Hi سارة");
76
+ expect(inbox.entries[0]!.text).toBe("Hello سارة");
77
+ });
78
+
79
+ test("exact ar catalog key selects Arabic when locale is ar", async () => {
80
+ const inbox = createChannelInbox();
81
+ const runtime = createChannelRuntime({
82
+ templates: [channel.template("hello", { medium: "email" })],
83
+ drivers: [openConsoleChannel({ inbox })],
84
+ catalog: {
85
+ hello: {
86
+ en: { subject: "Hi", text: "Hello" },
87
+ ar: { subject: "مرحبا {{name}}", text: "أهلا {{name}}" },
88
+ },
89
+ },
90
+ });
91
+
92
+ await runtime.send("hello", { to: "a@b.c", locale: "ar", data: { name: "نورة" } });
93
+ expect(inbox.entries[0]!.subject).toBe("مرحبا نورة");
94
+ expect(inbox.entries[0]!.text).toBe("أهلا نورة");
95
+ });
96
+
97
+ test("isRtlLocale marks Arabic (and ar-SA) as RTL for display helpers", () => {
98
+ expect(isRtlLocale("ar")).toBe(true);
99
+ expect(isRtlLocale("ar-SA")).toBe(true);
100
+ expect(isRtlLocale("en")).toBe(false);
101
+ });
102
+ });
@@ -73,8 +73,11 @@ type MediumSendResult = {
73
73
  };
74
74
 
75
75
  /**
76
- * Whether an error should advance to the next medium (provider outage),
77
- * not permanent client mistakes (invalid address).
76
+ * Whether an error should advance to the next transport / medium
77
+ * (provider outage), not permanent client mistakes (invalid address).
78
+ *
79
+ * Shared by OTP cross-medium delivery and same-medium email/SMS
80
+ * {@link FallbackTransport} chains on the Channel runtime.
78
81
  *
79
82
  * @param error - Thrown error
80
83
  */
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Receipt ledger adversarial proofs — status progression with Signal-like rigor:
3
+ * sent → fallback → suppressed/* → ingestOutcome hard-bounce update →
4
+ * blocked/invalid-address
5
+ */
6
+
7
+ import { describe, expect, test } from "bun:test";
8
+ import { channel, createChannelRuntime, createConsentStore } from "../channel.ts";
9
+ import {
10
+ countingOkTransport,
11
+ driverFromTransport,
12
+ failingTransport,
13
+ okTransport,
14
+ } from "./test-helpers.ts";
15
+
16
+ describe("receipt ledger accuracy", () => {
17
+ test("clean send records sent with messageId lookup", async () => {
18
+ const runtime = createChannelRuntime({
19
+ templates: [channel.template("hello", { medium: "email" })],
20
+ drivers: [driverFromTransport("smtp", okTransport("smtp"))],
21
+ catalog: { hello: { en: { subject: "Hi", text: "Hello" } } },
22
+ });
23
+
24
+ const result = await runtime.send("hello", { to: "a@b.c" });
25
+ expect(result.ok).toBe(true);
26
+
27
+ const receipt = runtime.receipts.forTemplate("hello")[0]!;
28
+ expect(receipt.status).toBe("sent");
29
+ expect(receipt.attempts).toHaveLength(1);
30
+ expect(receipt.attempts[0]!.ok).toBe(true);
31
+ expect(receipt.messageId).toBe(result.messageId);
32
+ expect(runtime.receipts.byMessageId(result.messageId)?.id).toBe(receipt.id);
33
+ });
34
+
35
+ test("failover send records fallback with ordered attempts", async () => {
36
+ const runtime = createChannelRuntime({
37
+ templates: [channel.template("hello", { medium: "email" })],
38
+ drivers: [
39
+ driverFromTransport("smtp", failingTransport("smtp")),
40
+ driverFromTransport("resend", okTransport("resend")),
41
+ ],
42
+ });
43
+
44
+ await runtime.send("hello", { to: "a@b.c", via: ["smtp", "resend"] });
45
+ const receipt = runtime.receipts.forTemplate("hello")[0]!;
46
+ expect(receipt.status).toBe("fallback");
47
+ expect(receipt.attempts.length).toBeGreaterThanOrEqual(2);
48
+ expect(receipt.attempts[0]!.ok).toBe(false);
49
+ expect(receipt.attempts.some((a) => a.ok)).toBe(true);
50
+ });
51
+
52
+ test("opt-out and prior-bounce leave empty attempts", async () => {
53
+ const consent = createConsentStore();
54
+ consent.optOut("out@example.com", "email");
55
+ const { transport, calls } = countingOkTransport("smtp");
56
+ const runtime = createChannelRuntime({
57
+ templates: [channel.template("news", { medium: "email" })],
58
+ drivers: [driverFromTransport("smtp", transport)],
59
+ consent,
60
+ });
61
+
62
+ const opted = await runtime.send("news", { to: "out@example.com" });
63
+ expect(opted.ok).toBe(false);
64
+ expect(runtime.receipts.all()[0]!.status).toBe("suppressed/opted-out");
65
+ expect(runtime.receipts.all()[0]!.attempts).toEqual([]);
66
+ expect(calls.count).toBe(0);
67
+
68
+ await runtime.send("news", { to: "bounce@example.com" });
69
+ const sent = runtime.receipts.all().find((r) => r.to === "bounce@example.com")!;
70
+ runtime.ingestOutcome({
71
+ messageId: sent.messageId!,
72
+ state: "hard-bounce",
73
+ to: "bounce@example.com",
74
+ medium: "email",
75
+ });
76
+ expect(runtime.receipts.byMessageId(sent.messageId!)!.status).toBe("hard-bounce");
77
+
78
+ const blocked = await runtime.send("news", { to: "bounce@example.com" });
79
+ expect(blocked.ok).toBe(false);
80
+ const last = runtime.receipts.all().at(-1)!;
81
+ expect(last.status).toBe("suppressed/prior-bounce");
82
+ expect(last.attempts).toEqual([]);
83
+ });
84
+
85
+ test("invalid-address failure classifies as blocked/invalid-address", async () => {
86
+ const runtime = createChannelRuntime({
87
+ templates: [channel.template("hello", { medium: "email" })],
88
+ drivers: [
89
+ driverFromTransport(
90
+ "smtp",
91
+ failingTransport("smtp", new Error("invalid recipient address")),
92
+ ),
93
+ ],
94
+ });
95
+
96
+ const result = await runtime.send("hello", { to: "bad" });
97
+ expect(result.ok).toBe(false);
98
+ expect(runtime.receipts.forTemplate("hello")[0]!.status).toBe("blocked/invalid-address");
99
+ });
100
+ });
@@ -19,6 +19,7 @@ import type {
19
19
  SmsOtpTransport,
20
20
  } from "../../drivers/channel-types.ts";
21
21
  import type { ChannelMedium } from "../../manifest/types.ts";
22
+ import { emitBootWarn } from "../../runtime/boot-warn.ts";
22
23
  import type { ConsentStore } from "./consent.ts";
23
24
  import type { ChannelTemplateDecl } from "./declare.ts";
24
25
  import { DEFAULT_MEDIUM_COSTS, type MediumCosts } from "./costs.ts";
@@ -34,6 +35,7 @@ import {
34
35
  import { createSuppressionStore, type SuppressionStore } from "./suppression.ts";
35
36
  import {
36
37
  deliverOtpAcrossChannels,
38
+ shouldFallbackOtpMedium,
37
39
  type DeliverOtpOptions,
38
40
  type DeliverOtpResult,
39
41
  } from "./otp-delivery.ts";
@@ -162,7 +164,7 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
162
164
  const receipts = options.receipts ?? createReceiptLedger();
163
165
  if ((usingDefaultSuppression || usingDefaultReceipts) && !channelProcessLocalWarned) {
164
166
  channelProcessLocalWarned = true;
165
- console.warn(
167
+ emitBootWarn(
166
168
  "oke boot: Channel suppression/consent/receipts default to process-local memory — " +
167
169
  "opt-out, bounce, and receipt state on one instance is invisible to others. " +
168
170
  "Inject shared stores for multi-instance, or run a single Channel consumer, until a " +
@@ -233,6 +235,8 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
233
235
 
234
236
  const recorded: FallbackAttempt[] = [];
235
237
  const fallback = new FallbackTransport(transports, {
238
+ // Same permanent-vs-provider distinction as OTP cross-medium failover.
239
+ shouldFallback: shouldFallbackOtpMedium,
236
240
  onFallback(failedIndex, error) {
237
241
  const provider =
238
242
  transports[failedIndex]?.provider ?? chain[failedIndex]?.id ?? `driver-${failedIndex}`;
@@ -287,6 +291,16 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
287
291
  });
288
292
  }
289
293
  }
294
+ // Permanent client errors abort failover by rethrowing the original error
295
+ // (no FallbackError.attempts / onFallback) — still record the failed try.
296
+ if (attempts.length === 0) {
297
+ attempts.push({
298
+ driverId: transports[0]?.provider ?? chain[0]?.id ?? "email",
299
+ ok: false,
300
+ error: err instanceof Error ? err.message : String(err),
301
+ at: now(),
302
+ });
303
+ }
290
304
  return {
291
305
  result: {
292
306
  ok: false,
@@ -318,6 +332,7 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
318
332
  options.retry ? new RetryTransport(transport) : transport,
319
333
  );
320
334
  const fallback = new FallbackTransport(transports, {
335
+ shouldFallback: shouldFallbackOtpMedium,
321
336
  onFallback(failedIndex, error) {
322
337
  const provider =
323
338
  transports[failedIndex]?.provider ?? sms[failedIndex]?.driver.id ?? `sms-${failedIndex}`;
@@ -376,6 +391,14 @@ export function createChannelRuntime(options: CreateChannelRuntimeOptions = {}):
376
391
  });
377
392
  }
378
393
  }
394
+ if (attempts.length === 0) {
395
+ attempts.push({
396
+ driverId: transports[0]?.provider ?? sms[0]?.driver.id ?? "sms",
397
+ ok: false,
398
+ error: err instanceof Error ? err.message : String(err),
399
+ at: now(),
400
+ });
401
+ }
379
402
  return {
380
403
  result: {
381
404
  ok: false,
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared fakes for Channel adversarial tests.
3
+ */
4
+
5
+ import type { MailOptions, SendResult, Transport } from "sently";
6
+ import type { ChannelDriver } from "../../drivers/channel-types.ts";
7
+
8
+ /** Flatten a sently address field for fake SendResult envelopes. */
9
+ export function addressToString(input: MailOptions["to"]): string {
10
+ if (typeof input === "string") return input;
11
+ if (Array.isArray(input)) return "batch";
12
+ return input.address;
13
+ }
14
+
15
+ /** Create a sently-compatible transport that always fails with `error`. */
16
+ export function failingTransport(
17
+ provider: string,
18
+ error: unknown = new Error(`${provider} down`),
19
+ ): Transport {
20
+ return {
21
+ provider,
22
+ async send(_options: MailOptions): Promise<SendResult> {
23
+ throw error;
24
+ },
25
+ };
26
+ }
27
+
28
+ /** Create a sently-compatible transport that succeeds. */
29
+ export function okTransport(provider: string, calls?: { count: number }): Transport {
30
+ return {
31
+ provider,
32
+ async send(options: MailOptions): Promise<SendResult> {
33
+ if (calls) calls.count += 1;
34
+ const to = addressToString(options.to);
35
+ const from = addressToString(options.from);
36
+ return {
37
+ messageId: `${provider}-msg`,
38
+ accepted: [to],
39
+ rejected: [],
40
+ response: "ok",
41
+ envelope: { from, to: [to] },
42
+ provider,
43
+ };
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Counting success transport — proves a send actually hit the wire. */
49
+ export function countingOkTransport(provider: string): {
50
+ transport: Transport;
51
+ calls: { count: number };
52
+ } {
53
+ const calls = { count: 0 };
54
+ return { transport: okTransport(provider, calls), calls };
55
+ }
56
+
57
+ /** Wrap a Transport as a ChannelDriver. */
58
+ export function driverFromTransport(id: ChannelDriver["id"], transport: Transport): ChannelDriver {
59
+ return { id, transport };
60
+ }