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.
- package/AGENTS.md +1 -1
- package/manifest.v1.schema.json +9 -1
- package/package.json +3 -3
- package/site/content/docs/console/runs.mdx +10 -0
- package/site/content/docs/console/traces.mdx +4 -0
- package/site/content/docs/elements/ai.mdx +54 -32
- package/site/content/docs/elements/channel.mdx +105 -14
- package/site/content/docs/elements/clock.mdx +1 -0
- package/site/content/docs/elements/flow.mdx +42 -8
- package/site/content/docs/get-started/basic-usage.mdx +5 -1
- package/site/content/docs/get-started/installation.mdx +9 -5
- package/site/content/docs/meta.json +2 -0
- package/site/content/docs/plugins/otp.mdx +58 -46
- package/site/content/docs/providers/azure-redis.mdx +85 -0
- package/site/content/docs/providers/cockroachdb.mdx +86 -0
- package/site/content/docs/providers/digitalocean-caching.mdx +88 -0
- package/site/content/docs/providers/dragonfly-cloud.mdx +82 -0
- package/site/content/docs/providers/elasticache.mdx +92 -0
- package/site/content/docs/providers/index.mdx +101 -0
- package/site/content/docs/providers/memorystore.mdx +89 -0
- package/site/content/docs/providers/meta.json +20 -0
- package/site/content/docs/providers/neon.mdx +115 -0
- package/site/content/docs/providers/redis-cloud.mdx +93 -0
- package/site/content/docs/providers/supabase.mdx +117 -0
- package/site/content/docs/providers/upstash.mdx +91 -0
- package/site/content/docs/providers/yugabytedb.mdx +95 -0
- package/site/content/docs/recipes/caddy.mdx +128 -0
- package/site/content/docs/recipes/cockroachdb.mdx +127 -0
- package/site/content/docs/recipes/dragonfly.mdx +125 -0
- package/site/content/docs/recipes/index.mdx +146 -0
- package/site/content/docs/recipes/llama-cpp.mdx +153 -0
- package/site/content/docs/recipes/mailpit.mdx +136 -0
- package/site/content/docs/recipes/meilisearch.mdx +139 -0
- package/site/content/docs/recipes/meta.json +31 -0
- package/site/content/docs/recipes/ollama.mdx +142 -0
- package/site/content/docs/recipes/openbao.mdx +141 -0
- package/site/content/docs/recipes/pgdog.mdx +137 -0
- package/site/content/docs/recipes/postgres.mdx +136 -0
- package/site/content/docs/recipes/redis.mdx +130 -0
- package/site/content/docs/recipes/rustfs.mdx +148 -0
- package/site/content/docs/recipes/sglang.mdx +105 -0
- package/site/content/docs/recipes/supabase-docker.mdx +145 -0
- package/site/content/docs/recipes/timescale.mdx +129 -0
- package/site/content/docs/recipes/traefik.mdx +136 -0
- package/site/content/docs/recipes/valkey.mdx +121 -0
- package/site/content/docs/recipes/vllm.mdx +106 -0
- package/site/content/docs/recipes/yugabytedb.mdx +135 -0
- package/site/content/docs/reference/cli.md +42 -6
- package/site/content/docs/reference/configuration.mdx +5 -3
- package/site/content/docs/reference/environment-variables.mdx +12 -12
- package/site/content/docs/reference/fx.mdx +38 -4
- package/src/auth/otp-capability.ts +14 -14
- package/src/cli/ai-setup/ai-setup.test.ts +32 -12
- package/src/cli/ai-setup/apply.ts +65 -23
- package/src/cli/ai-setup/catalog.ts +1316 -35
- package/src/cli/ai-setup/detect-ollama.ts +47 -0
- package/src/cli/ai-setup/index.ts +44 -7
- package/src/cli/ai-setup/prompts.ts +430 -486
- package/src/cli/ai-setup/recommend.test.ts +150 -50
- package/src/cli/ai-setup/recommend.ts +118 -101
- package/src/cli/ask-seed.test.ts +68 -0
- package/src/cli/ask-seed.ts +79 -0
- package/src/cli/db-seed-redact.test.ts +25 -0
- package/src/cli/db-seed.ts +36 -7
- package/src/cli/db.test.ts +1 -1
- package/src/cli/db.ts +2 -2
- package/src/cli/dev-controls.test.ts +107 -0
- package/src/cli/dev-controls.ts +354 -0
- package/src/cli/dev-schema-sync.ts +12 -4
- package/src/cli/dev.test.ts +50 -30
- package/src/cli/dev.ts +607 -66
- package/src/cli/drizzle-env.test.ts +40 -6
- package/src/cli/drizzle-env.ts +22 -1
- package/src/cli/ensure-drizzle-config.ts +6 -4
- package/src/cli/hero-meta.test.ts +13 -1
- package/src/cli/hero-meta.ts +28 -6
- package/src/cli/index.ts +5 -0
- package/src/cli/registry.ts +24 -0
- package/src/cli/replay.test.ts +105 -0
- package/src/cli/replay.ts +206 -0
- package/src/compiler/extract.ts +3 -3
- package/src/config/index.ts +3 -3
- package/src/console/server/claim.ts +3 -1
- package/src/console/ui/dist/assets/{index-ClX0ZCe5.js → index-D-9KFce5.js} +2 -2
- package/src/console/ui/dist/assets/panel-overview-CW0CQEc2.js +1 -0
- package/src/console/ui/dist/assets/panel-runs-BxuyDI3x.js +1 -0
- package/src/console/ui/dist/assets/{panel-signals-Dsxdu_AR.js → panel-signals-BXofbj7v.js} +1 -1
- package/src/console/ui/dist/assets/{panel-store-hy7O8HOs.js → panel-store-B8mH-arU.js} +1 -1
- package/src/console/ui/dist/assets/{panel-traces-CBiAe5go.js → panel-traces-DSA-ZWnf.js} +1 -1
- package/src/console/ui/dist/index.html +1 -1
- package/src/console/ui/overview/slo.ts +31 -3
- package/src/console/ui/overview/types.ts +6 -0
- package/src/console/ui/runs/errors.test.ts +53 -0
- package/src/console/ui/runs/errors.ts +88 -0
- package/src/console/ui/runs/index.ts +8 -0
- package/src/console/ui/runs/search.ts +16 -0
- package/src/console/ui/shell/panels/runs/RunsPanel.tsx +72 -1
- package/src/docker/ai-model-status.test.ts +101 -0
- package/src/docker/ai-model-status.ts +276 -0
- package/src/docker/compose-health.test.ts +115 -0
- package/src/docker/compose-health.ts +234 -0
- package/src/docker/compose.ts +24 -8
- package/src/docker/derive.ts +35 -9
- package/src/docker/docker.test.ts +304 -12
- package/src/docker/helpers.ts +56 -0
- package/src/docker/index.ts +52 -0
- package/src/docker/ollama-pull.ts +232 -0
- package/src/docker/recipes/cockroach.ts +24 -0
- package/src/docker/recipes/index.ts +32 -0
- package/src/docker/recipes/llama-cpp.ts +273 -0
- package/src/docker/recipes/ollama.ts +19 -23
- package/src/docker/recipes/pgdog.ts +4 -1
- package/src/docker/recipes/postgres.ts +5 -18
- package/src/docker/recipes/sglang.ts +55 -0
- package/src/docker/recipes/supabase.ts +21 -0
- package/src/docker/recipes/timescale.ts +18 -0
- package/src/docker/recipes/vllm.ts +44 -0
- package/src/docker/recipes/yugabyte.ts +23 -0
- package/src/docker/stack-id.test.ts +22 -0
- package/src/docker/stack-id.ts +38 -4
- package/src/docker/types.ts +15 -0
- package/src/drivers/ai-ollama.integration.test.ts +7 -4
- package/src/drivers/signal-engine.ts +10 -0
- package/src/drivers/signal-postgres.ts +24 -2
- package/src/drivers/signal-types.ts +10 -0
- package/src/elements/channel/fallback.test.ts +91 -0
- package/src/elements/channel/hard-bounce.test.ts +82 -0
- package/src/elements/channel/injection.test.ts +153 -0
- package/src/elements/channel/locale-ar.test.ts +102 -0
- package/src/elements/channel/otp-delivery.ts +5 -2
- package/src/elements/channel/receipts-ledger.test.ts +100 -0
- package/src/elements/channel/runtime.ts +24 -1
- package/src/elements/channel/test-helpers.ts +60 -0
- package/src/elements/channel/whatsapp-compliance.test.ts +124 -0
- package/src/elements/store/cache.ts +1 -1
- package/src/elements/store/emit-drizzle.ts +43 -9
- package/src/elements/store/runtime.ts +1 -1
- package/src/elements/store/schema-decl.test.ts +2 -0
- package/src/elements/store/schema-decl.ts +15 -0
- package/src/elements/store/seed.ts +1 -1
- package/src/index.ts +1 -0
- package/src/kernel/app.ts +118 -11
- package/src/kernel/boot-bind/signal.ts +6 -2
- package/src/kernel/boot-bind/store.ts +2 -1
- package/src/kernel/boot.test.ts +6 -5
- package/src/kernel/boot.ts +8 -3
- package/src/kernel/compensate.test.ts +131 -0
- package/src/kernel/correlation.test.ts +108 -0
- package/src/kernel/effect-timing.test.ts +82 -0
- package/src/kernel/flow.ts +38 -0
- package/src/kernel/fx-runs.test.ts +49 -0
- package/src/kernel/fx.test.ts +14 -0
- package/src/kernel/fx.ts +111 -1
- package/src/manifest/types.ts +8 -2
- package/src/plugins/auth-delivery.mailpit.integration.test.ts +1 -1
- package/src/plugins/auth-methods.security.test.ts +3 -3
- package/src/plugins/auth-methods.test.ts +1 -1
- package/src/plugins/index.ts +2 -2
- package/src/plugins/otp.test.ts +21 -19
- package/src/plugins/otp.ts +36 -34
- package/src/plugins/taqnyat.live.test.ts +3 -3
- package/src/runs/collect.ts +3 -0
- package/src/runs/export-otlp.ts +64 -0
- package/src/runs/index.ts +16 -0
- package/src/runs/parquet.ts +9 -0
- package/src/runs/types.ts +5 -0
- package/src/runs/window.test.ts +68 -0
- package/src/runs/window.ts +148 -0
- package/src/runtime/boot-warn.test.ts +45 -0
- package/src/runtime/boot-warn.ts +17 -0
- package/src/runtime/dev-request-log.ts +5 -5
- package/src/term.test.ts +95 -10
- package/src/term.ts +369 -14
- package/src/console/ui/dist/assets/panel-overview-DONDzBd2.js +0 -1
- 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
|
|
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
|
-
|
|
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
|
+
}
|