okengine 0.5.1 → 0.6.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.
Files changed (105) hide show
  1. package/README.md +148 -13
  2. package/package.json +4 -3
  3. package/site/content/docs/elements/ai.mdx +82 -1
  4. package/site/content/docs/elements/channel.mdx +77 -8
  5. package/site/content/docs/elements/flow.mdx +20 -17
  6. package/site/content/docs/plugins/email-otp.mdx +25 -19
  7. package/site/content/docs/plugins/magic-link.mdx +27 -21
  8. package/site/content/docs/reference/configuration.mdx +12 -4
  9. package/site/content/docs/reference/environment-variables.mdx +42 -13
  10. package/site/content/docs/reference/errors.mdx +14 -0
  11. package/site/content/docs/reference/fx.mdx +68 -16
  12. package/site/content/docs/reference/i18n.mdx +313 -0
  13. package/site/content/docs/reference/index.mdx +6 -1
  14. package/site/content/docs/reference/meta.json +1 -0
  15. package/site/content/docs/reference/plugins.mdx +1 -0
  16. package/src/auth/auth.test.ts +3 -0
  17. package/src/auth/bindings.ts +1 -1
  18. package/src/auth/method-context.ts +12 -2
  19. package/src/cli/openbao-restart.integration.test.ts +106 -97
  20. package/src/compiler/aot.test.ts +16 -13
  21. package/src/compiler/effects-infer.ts +46 -0
  22. package/src/console/server/ai.test.ts +34 -5
  23. package/src/docker/compose.ts +9 -0
  24. package/src/docker/docker.test.ts +39 -0
  25. package/src/docker/dockerfile.integration.test.ts +126 -119
  26. package/src/docker/index.ts +11 -1
  27. package/src/docker/recipes/index.ts +3 -1
  28. package/src/docker/recipes/ollama.ts +43 -0
  29. package/src/docker/stack-id.ts +2 -0
  30. package/src/docker/stack.integration.test.ts +118 -102
  31. package/src/drivers/ai-mock.ts +60 -0
  32. package/src/drivers/ai-ollama-tools.integration.test.ts +109 -0
  33. package/src/drivers/ai-ollama.integration.test.ts +181 -0
  34. package/src/drivers/ai-ollama.ts +327 -0
  35. package/src/drivers/ai-openai-compatible.ts +211 -21
  36. package/src/drivers/ai-providers.test.ts +179 -2
  37. package/src/drivers/ai-stream.test.ts +195 -0
  38. package/src/drivers/ai-types.ts +42 -1
  39. package/src/drivers/channel-fcm.ts +49 -53
  40. package/src/drivers/channel-msegat.ts +61 -0
  41. package/src/drivers/channel-sently-map.ts +57 -0
  42. package/src/drivers/channel-sently.test.ts +99 -0
  43. package/src/drivers/channel-smtp.ts +8 -2
  44. package/src/drivers/channel-sndr.ts +28 -0
  45. package/src/drivers/channel-taqnyat.ts +57 -0
  46. package/src/drivers/channel-types.ts +79 -2
  47. package/src/drivers/channel-unifonic.ts +26 -43
  48. package/src/drivers/channel-wa-cloud.ts +33 -47
  49. package/src/drivers/channel-webpush.ts +39 -239
  50. package/src/drivers/index.ts +25 -1
  51. package/src/drivers/ollama.ts +14 -0
  52. package/src/elements/ai/rate.test.ts +53 -0
  53. package/src/elements/ai/rate.ts +66 -0
  54. package/src/elements/ai/redacted-prompt.test.ts +90 -0
  55. package/src/elements/ai/runtime.ts +330 -100
  56. package/src/elements/ai/tools.test.ts +99 -0
  57. package/src/elements/ai.test.ts +26 -2
  58. package/src/elements/ai.ts +10 -1
  59. package/src/elements/channel/costs.test.ts +2 -2
  60. package/src/elements/channel/costs.ts +14 -2
  61. package/src/elements/channel/mime.ts +11 -0
  62. package/src/elements/channel/runtime.ts +94 -0
  63. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  64. package/src/elements/channel.ts +10 -1
  65. package/src/elements/index.ts +9 -0
  66. package/src/i18n/catalogs/ar.ts +67 -0
  67. package/src/i18n/catalogs/en.ts +68 -0
  68. package/src/i18n/failure-message.test.ts +56 -0
  69. package/src/i18n/failure-message.ts +93 -0
  70. package/src/i18n/format.ts +67 -0
  71. package/src/i18n/index.ts +57 -0
  72. package/src/i18n/locale-context.ts +48 -0
  73. package/src/i18n/messages.test.ts +173 -0
  74. package/src/i18n/messages.ts +169 -0
  75. package/src/i18n/types.ts +90 -0
  76. package/src/index.ts +26 -0
  77. package/src/kernel/app.ts +92 -2
  78. package/src/kernel/boot-bind/ai.test.ts +60 -0
  79. package/src/kernel/boot-bind/ai.ts +125 -2
  80. package/src/kernel/boot-bind/channel.test.ts +68 -3
  81. package/src/kernel/boot-bind/channel.ts +93 -2
  82. package/src/kernel/boot.test.ts +4 -3
  83. package/src/kernel/boot.ts +1 -1
  84. package/src/kernel/errors.ts +56 -5
  85. package/src/kernel/fx.test.ts +27 -0
  86. package/src/kernel/fx.ts +74 -18
  87. package/src/kernel/pipeline.test.ts +4 -0
  88. package/src/kernel/pipeline.ts +1 -1
  89. package/src/kernel/plugin.ts +16 -0
  90. package/src/kernel/registry.ts +15 -0
  91. package/src/plugins/auth/shared.ts +5 -1
  92. package/src/plugins/auth-delivery.mailpit.integration.test.ts +336 -0
  93. package/src/plugins/auth-methods.security.test.ts +12 -10
  94. package/src/plugins/email-otp.ts +54 -1
  95. package/src/plugins/index.ts +16 -2
  96. package/src/plugins/magic-link.ts +63 -3
  97. package/src/plugins/username-policy.test.ts +302 -0
  98. package/src/plugins/username.ts +290 -9
  99. package/src/release/exports.test.ts +26 -0
  100. package/src/release/exports.ts +64 -5
  101. package/src/release/index.ts +5 -0
  102. package/src/release/measure.exports.test.ts +13 -1
  103. package/src/release/measure.ts +84 -14
  104. package/src/release/official-plugins.ts +46 -0
  105. package/src/release/readme.test.ts +30 -2
package/src/kernel/app.ts CHANGED
@@ -35,6 +35,10 @@ import {
35
35
  type GateOptions,
36
36
  type ResolvedGateConfig,
37
37
  } from "../elements/gate/config.ts";
38
+ import type { TemplateCatalog } from "../elements/channel/runtime.ts";
39
+ import { parseAcceptLanguage } from "../elements/channel/locale.ts";
40
+ import { runWithLocale } from "../i18n/locale-context.ts";
41
+ import { getMessageCatalogs, matchConfiguredLocale } from "../i18n/messages.ts";
38
42
  import { runDurable } from "../elements/clock/durable.ts";
39
43
  import { isFlow, type AnyFlowDef } from "./flow.ts";
40
44
  import { fxRetry } from "./concurrency.ts";
@@ -567,6 +571,9 @@ export function oke(options: OkeOptions): OkeApp {
567
571
  secret: authBinding.secret,
568
572
  sessions: authBinding.sessions,
569
573
  now: authBinding.now,
574
+ passwordPolicy: gateConfig.auth?.passwordPolicy,
575
+ password: gateConfig.auth?.password,
576
+ breachCheck: gateConfig.auth?.breachCheck,
570
577
  });
571
578
  } else {
572
579
  setActiveGateAuthContext(undefined);
@@ -663,11 +670,14 @@ export function oke(options: OkeOptions): OkeApp {
663
670
  const pluginSignals = pluginRegistry.signalContributions();
664
671
  const pluginClocks = pluginRegistry.clockContributions();
665
672
  const pluginChannelTemplates = pluginRegistry.channelTemplateContributions();
673
+ const pluginChannelCatalogs = pluginRegistry.channelCatalogContributions();
666
674
  if (pluginSecrets.length > 0) available.add("vault");
667
675
  if (pluginGates.length > 0) available.add("gate");
668
676
  if (pluginSignals.length > 0) available.add("signal");
669
677
  if (pluginClocks.length > 0) available.add("clock");
670
- if (pluginChannelTemplates.length > 0) available.add("channel");
678
+ if (pluginChannelTemplates.length > 0 || pluginChannelCatalogs.length > 0) {
679
+ available.add("channel");
680
+ }
671
681
  // gate.auth (auto-absorbed auth plugin) satisfies `.needs("auth")`.
672
682
  if (gateConfig.auth) available.add("auth");
673
683
  assertPluginNeeds(caps, { pluginNames, available });
@@ -676,6 +686,7 @@ export function oke(options: OkeOptions): OkeApp {
676
686
  const baseSignals = overrides?.signals ?? options.signals ?? [];
677
687
  const baseClocks = overrides?.clocks ?? options.clocks ?? [];
678
688
  const baseChannel = overrides?.channel ?? options.channel;
689
+ const mergedCatalog = mergeTemplateCatalogs(baseChannel?.catalog, ...pluginChannelCatalogs);
679
690
 
680
691
  const merged: BootOptions = {
681
692
  env: bootEnv,
@@ -692,6 +703,11 @@ export function oke(options: OkeOptions): OkeApp {
692
703
  channel: {
693
704
  ...(baseChannel ?? {}),
694
705
  templates: [...(baseChannel?.templates ?? []), ...pluginChannelTemplates],
706
+ ...(mergedCatalog ? { catalog: mergedCatalog } : {}),
707
+ defaultLocale:
708
+ baseChannel?.defaultLocale ??
709
+ (overrides?.config ?? options.config)?.i18n?.default ??
710
+ "en",
695
711
  },
696
712
  ai: overrides?.ai ?? options.ai,
697
713
  runs: overrides?.runs ?? options.runs,
@@ -754,10 +770,52 @@ export function oke(options: OkeOptions): OkeApp {
754
770
  readonly principal?: ResolvedPrincipal;
755
771
  /** Frozen origin identity for {@link Fx.principal} across `fx.call`. */
756
772
  readonly originPrincipal?: FxPrincipal;
773
+ /** Explicit locale override for {@link Fx.t} / channel sends. */
774
+ readonly locale?: string;
757
775
  },
758
776
  ): Promise<ExecuteResult> {
759
777
  registerFlow(flowDef);
760
778
 
779
+ const i18nConfig = options.config?.i18n;
780
+ const configuredLocales = i18nConfig?.locales ?? ["en", "ar"];
781
+ const defaultLocale = i18nConfig?.default ?? "en";
782
+ const acceptLanguage = extras?.request?.headers.get("accept-language") ?? undefined;
783
+ const resolvedLocale = matchConfiguredLocale(
784
+ extras?.locale ?? parseAcceptLanguage(acceptLanguage),
785
+ configuredLocales,
786
+ defaultLocale,
787
+ );
788
+
789
+ return runWithLocale({ locale: resolvedLocale, defaultLocale }, () =>
790
+ executeInLocale({
791
+ flowDef,
792
+ input,
793
+ trigger,
794
+ extras,
795
+ resolvedLocale,
796
+ defaultLocale,
797
+ }),
798
+ );
799
+ }
800
+
801
+ async function executeInLocale(args: {
802
+ readonly flowDef: AnyFlowDef;
803
+ readonly input: unknown;
804
+ readonly trigger: Trigger;
805
+ readonly extras?: {
806
+ readonly request?: Request;
807
+ readonly params?: Record<string, string>;
808
+ readonly validated?: boolean;
809
+ readonly auth?: ResolvedPrincipal | FxAuth;
810
+ readonly operator?: FxOperator;
811
+ readonly principal?: ResolvedPrincipal;
812
+ readonly originPrincipal?: FxPrincipal;
813
+ readonly locale?: string;
814
+ };
815
+ readonly resolvedLocale: string;
816
+ readonly defaultLocale: string;
817
+ }): Promise<ExecuteResult> {
818
+ const { flowDef, input, trigger, extras, resolvedLocale, defaultLocale } = args;
761
819
  const booted = await ensureBoot();
762
820
 
763
821
  const unitBag = flowDef.unit !== undefined ? unitHooks.get(flowDef.unit) : undefined;
@@ -860,6 +918,12 @@ export function oke(options: OkeOptions): OkeApp {
860
918
  effects: flowDef.effects,
861
919
  runTelemetry: telemetry,
862
920
  now,
921
+ i18n: {
922
+ locale: resolvedLocale,
923
+ defaultLocale,
924
+ catalogs: getMessageCatalogs(),
925
+ ...options.fx?.i18n,
926
+ },
863
927
  ...(principals ? { auth: principals.auth as FxAuth, operator: principals.operator } : {}),
864
928
  ...(extras?.originPrincipal ? { principal: extras.originPrincipal } : {}),
865
929
  ...(capability ? { capability } : {}),
@@ -882,7 +946,10 @@ export function oke(options: OkeOptions): OkeApp {
882
946
  target,
883
947
  callInput,
884
948
  { kind: "internal" } satisfies InternalTrigger,
885
- { originPrincipal: freezePrincipal(fx.principal) },
949
+ {
950
+ originPrincipal: freezePrincipal(fx.principal),
951
+ locale: resolvedLocale,
952
+ },
886
953
  );
887
954
  if (inner.failure) return inner.failure;
888
955
  return inner.output;
@@ -1300,5 +1367,28 @@ function archiveFromInput(
1300
1367
  return Object.keys(out).length > 0 ? out : undefined;
1301
1368
  }
1302
1369
 
1370
+ /**
1371
+ * Deep-merge channel template catalogs (later parts win per locale).
1372
+ *
1373
+ * @param parts - Catalog fragments (undefined skipped)
1374
+ */
1375
+ function mergeTemplateCatalogs(
1376
+ ...parts: readonly (TemplateCatalog | undefined)[]
1377
+ ): TemplateCatalog | undefined {
1378
+ const out: Record<
1379
+ string,
1380
+ Record<string, { readonly subject?: string; readonly text?: string; readonly html?: string }>
1381
+ > = {};
1382
+ let any = false;
1383
+ for (const part of parts) {
1384
+ if (!part) continue;
1385
+ any = true;
1386
+ for (const [template, locales] of Object.entries(part)) {
1387
+ out[template] = { ...(out[template] ?? {}), ...locales };
1388
+ }
1389
+ }
1390
+ return any ? out : undefined;
1391
+ }
1392
+
1303
1393
  /** @internal expose smart router type for tests */
1304
1394
  export type { HttpTrigger, SmartRouter };
@@ -0,0 +1,60 @@
1
+ /**
2
+ * AI boot binder — shared driver switch + fail-loud resolution.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { mockAiDriver } from "../../drivers/ai-mock.ts";
7
+ import { ollamaAiDriver } from "../../drivers/ai-ollama.ts";
8
+ import { aiDriverFor, aiUrlFor, bindAi, resolveAiDriverId } from "./ai.ts";
9
+
10
+ describe("ai boot binder", () => {
11
+ test("aiDriverFor maps protocol ids; unknown / reserved throw", () => {
12
+ expect(aiDriverFor("mock").id).toBe("mock");
13
+ expect(aiDriverFor("ollama")).toBe(ollamaAiDriver);
14
+ expect(() => aiDriverFor("bedrock")).toThrow(/not implemented/);
15
+ expect(() => aiDriverFor("nope")).toThrow(/unknown AI driver/);
16
+ });
17
+
18
+ test("resolveAiDriverId defaults to mock; honours config + OKE_AI_DRIVER in docker", () => {
19
+ expect(resolveAiDriverId({}, "local")).toBe("mock");
20
+ expect(resolveAiDriverId({ config: { drivers: { ai: { local: "ollama" } } } }, "local")).toBe(
21
+ "ollama",
22
+ );
23
+
24
+ const prev = process.env.OKE_AI_DRIVER;
25
+ process.env.OKE_AI_DRIVER = "ollama";
26
+ try {
27
+ expect(resolveAiDriverId({}, "local", true)).toBe("ollama");
28
+ } finally {
29
+ if (prev === undefined) delete process.env.OKE_AI_DRIVER;
30
+ else process.env.OKE_AI_DRIVER = prev;
31
+ }
32
+ });
33
+
34
+ test("aiUrlFor fails loud in docker without OKE_AI_URL", () => {
35
+ const prevUrl = process.env.OKE_AI_URL;
36
+ const prevHost = process.env.OLLAMA_HOST;
37
+ delete process.env.OKE_AI_URL;
38
+ delete process.env.OLLAMA_HOST;
39
+ try {
40
+ expect(() => aiUrlFor(true)).toThrow(/OKE_AI_URL/);
41
+ expect(aiUrlFor(false)).toBe("http://127.0.0.1:11434");
42
+ } finally {
43
+ if (prevUrl !== undefined) process.env.OKE_AI_URL = prevUrl;
44
+ if (prevHost !== undefined) process.env.OLLAMA_HOST = prevHost;
45
+ }
46
+ });
47
+
48
+ test("bindAi uses injected defaultDriver over config (tests stay on mock)", () => {
49
+ const runtime = bindAi(
50
+ {
51
+ config: { drivers: { ai: { local: "ollama" } } },
52
+ ai: { defaultDriver: mockAiDriver },
53
+ },
54
+ undefined,
55
+ () => 0,
56
+ "local",
57
+ );
58
+ expect(runtime.autoCacheDisabled).toBe(true);
59
+ });
60
+ });
@@ -1,28 +1,151 @@
1
1
  /**
2
2
  * Lazy AI binder — loaded only when AI is declared.
3
+ *
4
+ * Resolves `drivers.ai` the same way store.index resolves its driver map:
5
+ * shared `aiDriverFor` switch, fail-loud on unknown / reserved-unimplemented
6
+ * ids, never a silent mock fallback when another driver is configured.
3
7
  */
4
8
 
9
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
10
+ import { anthropicAiDriver } from "../../drivers/ai-anthropic.ts";
5
11
  import { mockAiDriver } from "../../drivers/ai-mock.ts";
12
+ import {
13
+ normalizeOllamaBaseUrl,
14
+ ollamaAiDriver,
15
+ OLLAMA_DEFAULT_BASE_URL,
16
+ } from "../../drivers/ai-ollama.ts";
17
+ import { openaiCompatibleAiDriver } from "../../drivers/ai-openai-compatible.ts";
18
+ import type { AiDriver, AiOpenOptions } from "../../drivers/ai-types.ts";
6
19
  import { createAiRuntime, type AiRuntime } from "../../elements/ai.ts";
7
20
  import type { GateRuntime } from "../../elements/gate.ts";
8
21
  import type { BootOptions } from "../boot.ts";
9
22
 
10
23
  /**
11
- * Construct an AI runtime (mock default; shares gate for agents).
24
+ * Construct an AI runtime from config / injection.
12
25
  *
13
26
  * @param options - Boot options
14
27
  * @param gate - Gate runtime for agent tool checks
15
28
  * @param now - Clock
29
+ * @param env - Active environment
30
+ * @param docker - Prefer compose AI URL when active
16
31
  */
17
32
  export function bindAi(
18
33
  options: BootOptions,
19
34
  gate: GateRuntime | undefined,
20
35
  now: () => number,
36
+ env: ConfigEnv = "local",
37
+ docker = false,
21
38
  ): AiRuntime {
39
+ const id = resolveAiDriverId(options, env, docker);
40
+ const driver =
41
+ options.ai?.defaultDriver ?? withOpenDefaults(aiDriverFor(id), openDefaultsFor(id, docker));
22
42
  return createAiRuntime({
23
43
  ...(options.ai ?? {}),
24
- defaultDriver: options.ai?.defaultDriver ?? mockAiDriver,
44
+ defaultDriver: driver,
25
45
  gates: options.ai?.gates ?? gate,
26
46
  now,
27
47
  });
28
48
  }
49
+
50
+ /**
51
+ * Resolve the configured AI driver for one environment.
52
+ *
53
+ * Dev / test default is `mock`. There is **no** production default — prod must
54
+ * declare. Docker may override via `OKE_AI_DRIVER`.
55
+ *
56
+ * @param options - Boot options
57
+ * @param env - Active environment
58
+ * @param docker - Docker mode
59
+ */
60
+ export function resolveAiDriverId(options: BootOptions, env: ConfigEnv, docker = false): string {
61
+ const fromEnv = process.env.OKE_AI_DRIVER?.trim();
62
+ if (docker && fromEnv) return fromEnv;
63
+ const resolved = resolveDriverId(options.config?.drivers?.ai, env);
64
+ if (resolved) return resolved;
65
+ return "mock";
66
+ }
67
+
68
+ /**
69
+ * Single id → AI driver switch — shared by boot (and future Console) so
70
+ * resolution can never drift into two maintained copies.
71
+ *
72
+ * @param id - Protocol driver id
73
+ */
74
+ export function aiDriverFor(id: string): AiDriver {
75
+ switch (id) {
76
+ case "mock":
77
+ return mockAiDriver;
78
+ case "anthropic":
79
+ return anthropicAiDriver;
80
+ case "openai-compatible":
81
+ return openaiCompatibleAiDriver;
82
+ case "ollama":
83
+ return ollamaAiDriver;
84
+ case "bedrock":
85
+ case "vertex":
86
+ throw new Error(`oke boot: AI driver "${id}" is reserved but not implemented yet`);
87
+ default:
88
+ throw new Error(`oke boot: unknown AI driver "${id}"`);
89
+ }
90
+ }
91
+
92
+ /**
93
+ * Open defaults for a resolved driver id (URL / key from env).
94
+ *
95
+ * @param id - Driver id
96
+ * @param docker - Docker mode
97
+ */
98
+ export function openDefaultsFor(id: string, docker = false): AiOpenOptions {
99
+ if (id === "ollama") {
100
+ return {
101
+ baseUrl: aiUrlFor(docker),
102
+ ...(process.env.OKE_AI_MODEL?.trim() ? { model: process.env.OKE_AI_MODEL.trim() } : {}),
103
+ };
104
+ }
105
+ if (id === "anthropic") {
106
+ const apiKey = process.env.ANTHROPIC_API_KEY?.trim();
107
+ return {
108
+ ...(apiKey ? { apiKey } : {}),
109
+ ...(process.env.ANTHROPIC_MODEL?.trim() ? { model: process.env.ANTHROPIC_MODEL.trim() } : {}),
110
+ };
111
+ }
112
+ if (id === "openai-compatible") {
113
+ const apiKey = process.env.OPENAI_API_KEY?.trim();
114
+ const baseUrl = process.env.OPENAI_BASE_URL?.trim() || process.env.OKE_AI_URL?.trim();
115
+ return {
116
+ ...(apiKey ? { apiKey } : {}),
117
+ ...(baseUrl ? { baseUrl } : {}),
118
+ };
119
+ }
120
+ return {};
121
+ }
122
+
123
+ /**
124
+ * Ollama base URL — docker fails loud when compose did not write one.
125
+ *
126
+ * @param docker - Docker mode
127
+ */
128
+ export function aiUrlFor(docker = false): string {
129
+ const raw = process.env.OKE_AI_URL?.trim() || process.env.OLLAMA_HOST?.trim();
130
+ if (raw) return normalizeOllamaBaseUrl(raw);
131
+ if (docker) {
132
+ throw new Error(
133
+ "oke boot: ollama driver needs OKE_AI_URL (did `oke dev -d` write docker/.env.docker?)",
134
+ );
135
+ }
136
+ return OLLAMA_DEFAULT_BASE_URL;
137
+ }
138
+
139
+ /**
140
+ * Merge fixed open defaults under per-call options.
141
+ *
142
+ * @param driver - Base driver
143
+ * @param defaults - Env-derived open options
144
+ */
145
+ function withOpenDefaults(driver: AiDriver, defaults: AiOpenOptions): AiDriver {
146
+ if (Object.keys(defaults).length === 0) return driver;
147
+ return {
148
+ id: driver.id,
149
+ open: (opts = {}) => driver.open({ ...defaults, ...opts }),
150
+ };
151
+ }
@@ -1,15 +1,34 @@
1
1
  /**
2
- * Channel binder — profile selection + SMTP env parsing.
2
+ * Channel binder — profile selection + env parsing.
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
6
- import { resolveEmailDriverId, smtpOptionsFromEnv } from "./channel.ts";
6
+ import {
7
+ msegatOptionsFromEnv,
8
+ resendOptionsFromEnv,
9
+ resolveEmailDriverId,
10
+ resolveSmsDriverId,
11
+ smtpOptionsFromEnv,
12
+ sndrOptionsFromEnv,
13
+ taqnyatOptionsFromEnv,
14
+ unifonicOptionsFromEnv,
15
+ } from "./channel.ts";
7
16
 
8
17
  const previous = {
9
18
  url: process.env.SMTP_URL,
10
19
  user: process.env.SMTP_USER,
11
20
  password: process.env.SMTP_PASSWORD,
12
21
  okeUrl: process.env.OKE_CHANNEL_EMAIL_URL,
22
+ resend: process.env.RESEND_API_KEY,
23
+ sndr: process.env.SNDR_API_KEY,
24
+ sndrBase: process.env.SNDR_BASE_URL,
25
+ taqBearer: process.env.TAQNYAT_BEARER_TOKEN,
26
+ taqSender: process.env.TAQNYAT_SENDER,
27
+ msegatUser: process.env.MSEGAT_USERNAME,
28
+ msegatKey: process.env.MSEGAT_API_KEY,
29
+ msegatSender: process.env.MSEGAT_SENDER,
30
+ unifonicSid: process.env.UNIFONIC_APPSID,
31
+ unifonicSender: process.env.UNIFONIC_SENDER,
13
32
  };
14
33
 
15
34
  afterEach(() => {
@@ -17,6 +36,16 @@ afterEach(() => {
17
36
  restoreEnv("SMTP_USER", previous.user);
18
37
  restoreEnv("SMTP_PASSWORD", previous.password);
19
38
  restoreEnv("OKE_CHANNEL_EMAIL_URL", previous.okeUrl);
39
+ restoreEnv("RESEND_API_KEY", previous.resend);
40
+ restoreEnv("SNDR_API_KEY", previous.sndr);
41
+ restoreEnv("SNDR_BASE_URL", previous.sndrBase);
42
+ restoreEnv("TAQNYAT_BEARER_TOKEN", previous.taqBearer);
43
+ restoreEnv("TAQNYAT_SENDER", previous.taqSender);
44
+ restoreEnv("MSEGAT_USERNAME", previous.msegatUser);
45
+ restoreEnv("MSEGAT_API_KEY", previous.msegatKey);
46
+ restoreEnv("MSEGAT_SENDER", previous.msegatSender);
47
+ restoreEnv("UNIFONIC_APPSID", previous.unifonicSid);
48
+ restoreEnv("UNIFONIC_SENDER", previous.unifonicSender);
20
49
  });
21
50
 
22
51
  describe("bindChannel driver resolution", () => {
@@ -24,7 +53,8 @@ describe("bindChannel driver resolution", () => {
24
53
  config: {
25
54
  drivers: {
26
55
  channel: {
27
- email: { local: "console", docker: "smtp", test: "console", prod: "smtp" },
56
+ email: { local: "console", docker: "smtp", test: "console", prod: "sndr" },
57
+ sms: { local: "console", docker: "taqnyat", test: "msegat", prod: "taqnyat" },
28
58
  },
29
59
  },
30
60
  },
@@ -33,6 +63,13 @@ describe("bindChannel driver resolution", () => {
33
63
  test("uses console locally and smtp in docker", () => {
34
64
  expect(resolveEmailDriverId(options, "local", false)).toBe("console");
35
65
  expect(resolveEmailDriverId(options, "docker", true)).toBe("smtp");
66
+ expect(resolveEmailDriverId(options, "prod", false)).toBe("sndr");
67
+ });
68
+
69
+ test("resolves sms driver ids", () => {
70
+ expect(resolveSmsDriverId(options, "local")).toBe("console");
71
+ expect(resolveSmsDriverId(options, "docker")).toBe("taqnyat");
72
+ expect(resolveSmsDriverId(options, "test")).toBe("msegat");
36
73
  });
37
74
 
38
75
  test("parses SMTP_URL and optional auth overrides", () => {
@@ -52,6 +89,34 @@ describe("bindChannel driver resolution", () => {
52
89
  delete process.env.OKE_CHANNEL_EMAIL_URL;
53
90
  expect(() => smtpOptionsFromEnv(true)).toThrow("docker/.env.docker");
54
91
  });
92
+
93
+ test("resend and sndr env helpers", () => {
94
+ process.env.RESEND_API_KEY = "re_x";
95
+ expect(resendOptionsFromEnv()).toEqual({ apiKey: "re_x" });
96
+ process.env.SNDR_API_KEY = "sndr_test_x";
97
+ process.env.SNDR_BASE_URL = "https://api.example.test";
98
+ expect(sndrOptionsFromEnv()).toEqual({
99
+ apiKey: "sndr_test_x",
100
+ url: "https://api.example.test",
101
+ });
102
+ });
103
+
104
+ test("taqnyat, msegat, and unifonic env helpers", () => {
105
+ process.env.TAQNYAT_BEARER_TOKEN = "bearer";
106
+ process.env.TAQNYAT_SENDER = "Brand";
107
+ expect(taqnyatOptionsFromEnv()).toEqual({ bearerToken: "bearer", sender: "Brand" });
108
+ process.env.MSEGAT_USERNAME = "user";
109
+ process.env.MSEGAT_API_KEY = "key";
110
+ process.env.MSEGAT_SENDER = "Brand";
111
+ expect(msegatOptionsFromEnv()).toEqual({
112
+ userName: "user",
113
+ apiKey: "key",
114
+ sender: "Brand",
115
+ });
116
+ process.env.UNIFONIC_APPSID = "sid";
117
+ process.env.UNIFONIC_SENDER = "Brand";
118
+ expect(unifonicOptionsFromEnv()).toEqual({ appSid: "sid", sender: "Brand" });
119
+ });
55
120
  });
56
121
 
57
122
  function restoreEnv(key: string, value: string | undefined): void {
@@ -4,7 +4,12 @@
4
4
 
5
5
  import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
6
6
  import { openConsoleChannel } from "../../drivers/channel-console.ts";
7
+ import { openMsegatChannel } from "../../drivers/channel-msegat.ts";
8
+ import { openResendChannel } from "../../drivers/channel-resend.ts";
7
9
  import { openSmtpChannel } from "../../drivers/channel-smtp.ts";
10
+ import { openSndrChannel } from "../../drivers/channel-sndr.ts";
11
+ import { openTaqnyatChannel } from "../../drivers/channel-taqnyat.ts";
12
+ import { openUnifonicChannel } from "../../drivers/channel-unifonic.ts";
8
13
  import type { ChannelDriver, ChannelOpenOptions } from "../../drivers/channel-types.ts";
9
14
  import { createChannelRuntime, type ChannelRuntime } from "../../elements/channel.ts";
10
15
  import type { BootOptions } from "../boot.ts";
@@ -12,6 +17,9 @@ import type { BootOptions } from "../boot.ts";
12
17
  /**
13
18
  * Construct a Channel runtime (console inbox default).
14
19
  *
20
+ * When `options.channel.drivers` is omitted, opens the configured email driver
21
+ * and, if `drivers.channel.sms` resolves, appends that SMS driver to the chain.
22
+ *
15
23
  * @param options - Boot options
16
24
  * @param env - Active environment
17
25
  * @param now - Clock
@@ -25,18 +33,36 @@ export function bindChannel(
25
33
  ): ChannelRuntime {
26
34
  return createChannelRuntime({
27
35
  ...(options.channel ?? {}),
28
- drivers: options.channel?.drivers ?? [channelDriverFor(options, env, docker)],
36
+ drivers: options.channel?.drivers ?? defaultDrivers(options, env, docker),
29
37
  now,
30
38
  });
31
39
  }
32
40
 
33
- function channelDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
41
+ function defaultDrivers(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver[] {
42
+ const drivers: ChannelDriver[] = [emailDriverFor(options, env, docker)];
43
+ const sms = smsDriverFor(options, env);
44
+ if (sms) drivers.push(sms);
45
+ return drivers;
46
+ }
47
+
48
+ function emailDriverFor(options: BootOptions, env: ConfigEnv, docker: boolean): ChannelDriver {
34
49
  const id = resolveEmailDriverId(options, env, docker);
35
50
  if (id === "console") return openConsoleChannel();
36
51
  if (id === "smtp") return openSmtpChannel(smtpOptionsFromEnv(docker));
52
+ if (id === "resend") return openResendChannel(resendOptionsFromEnv());
53
+ if (id === "sndr") return openSndrChannel(sndrOptionsFromEnv());
37
54
  throw new Error(`oke boot: unknown email channel driver "${id}"`);
38
55
  }
39
56
 
57
+ function smsDriverFor(options: BootOptions, env: ConfigEnv): ChannelDriver | undefined {
58
+ const id = resolveSmsDriverId(options, env);
59
+ if (!id || id === "console") return undefined;
60
+ if (id === "taqnyat") return openTaqnyatChannel(taqnyatOptionsFromEnv());
61
+ if (id === "msegat") return openMsegatChannel(msegatOptionsFromEnv());
62
+ if (id === "unifonic") return openUnifonicChannel(unifonicOptionsFromEnv());
63
+ throw new Error(`oke boot: unknown sms channel driver "${id}"`);
64
+ }
65
+
40
66
  /**
41
67
  * Resolve the configured email driver for one environment.
42
68
  *
@@ -54,6 +80,16 @@ export function resolveEmailDriverId(
54
80
  );
55
81
  }
56
82
 
83
+ /**
84
+ * Resolve the configured SMS driver for one environment.
85
+ *
86
+ * @param options - Boot options
87
+ * @param env - Active environment
88
+ */
89
+ export function resolveSmsDriverId(options: BootOptions, env: ConfigEnv): string | undefined {
90
+ return resolveDriverId(options.config?.drivers?.channel?.sms, env);
91
+ }
92
+
57
93
  /**
58
94
  * Resolve SMTP connection options from `SMTP_URL` plus optional auth overrides.
59
95
  *
@@ -81,3 +117,58 @@ export function smtpOptionsFromEnv(docker = false): ChannelOpenOptions {
81
117
  ...(pass ? { pass } : {}),
82
118
  };
83
119
  }
120
+
121
+ /**
122
+ * Resolve Resend options from env.
123
+ */
124
+ export function resendOptionsFromEnv(): ChannelOpenOptions {
125
+ const apiKey = process.env.RESEND_API_KEY?.trim();
126
+ if (!apiKey) throw new Error("oke boot: resend channel needs RESEND_API_KEY");
127
+ return { apiKey };
128
+ }
129
+
130
+ /**
131
+ * Resolve SNDR options from env.
132
+ */
133
+ export function sndrOptionsFromEnv(): ChannelOpenOptions {
134
+ const apiKey = process.env.SNDR_API_KEY?.trim();
135
+ if (!apiKey) throw new Error("oke boot: sndr channel needs SNDR_API_KEY");
136
+ const url = process.env.SNDR_BASE_URL?.trim();
137
+ return { apiKey, ...(url ? { url } : {}) };
138
+ }
139
+
140
+ /**
141
+ * Resolve Taqnyat SMS options from env.
142
+ */
143
+ export function taqnyatOptionsFromEnv(): ChannelOpenOptions {
144
+ const bearerToken = process.env.TAQNYAT_BEARER_TOKEN?.trim() ?? process.env.TAQNYAT_TOKEN?.trim();
145
+ const sender = process.env.TAQNYAT_SENDER?.trim();
146
+ if (!bearerToken) {
147
+ throw new Error("oke boot: taqnyat channel needs TAQNYAT_BEARER_TOKEN");
148
+ }
149
+ if (!sender) throw new Error("oke boot: taqnyat channel needs TAQNYAT_SENDER");
150
+ return { bearerToken, sender };
151
+ }
152
+
153
+ /**
154
+ * Resolve Msegat SMS options from env.
155
+ */
156
+ export function msegatOptionsFromEnv(): ChannelOpenOptions {
157
+ const userName = process.env.MSEGAT_USERNAME?.trim();
158
+ const apiKey = process.env.MSEGAT_API_KEY?.trim();
159
+ const sender = process.env.MSEGAT_SENDER?.trim();
160
+ if (!userName) throw new Error("oke boot: msegat channel needs MSEGAT_USERNAME");
161
+ if (!apiKey) throw new Error("oke boot: msegat channel needs MSEGAT_API_KEY");
162
+ if (!sender) throw new Error("oke boot: msegat channel needs MSEGAT_SENDER");
163
+ return { userName, apiKey, sender };
164
+ }
165
+
166
+ /**
167
+ * Resolve Unifonic SMS options from env.
168
+ */
169
+ export function unifonicOptionsFromEnv(): ChannelOpenOptions {
170
+ const appSid = process.env.UNIFONIC_APPSID?.trim() ?? process.env.UNIFONIC_APP_SID?.trim();
171
+ const sender = process.env.UNIFONIC_SENDER?.trim();
172
+ if (!appSid) throw new Error("oke boot: unifonic channel needs UNIFONIC_APPSID");
173
+ return { appSid, ...(sender ? { sender } : {}) };
174
+ }
@@ -79,7 +79,7 @@ describe("boot — lazy element needs", () => {
79
79
  expect(needs.signal).toBe(false);
80
80
  });
81
81
 
82
- test("oke() Store-only graph stays under the prior 41.4 kB baseline", async () => {
82
+ test("oke() Store-only graph stays under the prior 50 kB baseline", async () => {
83
83
  const dir = await mkdtemp(join(tmpdir(), "oke-store-only-"));
84
84
  const entry = join(dir, "entry.ts");
85
85
  const appPath = join(import.meta.dir, "app.ts");
@@ -116,8 +116,9 @@ describe("boot — lazy element needs", () => {
116
116
  if (raw.byteLength === 0) continue;
117
117
  total += Bun.gzipSync(new Uint8Array(raw)).byteLength;
118
118
  }
119
- // Prior eager-bind baseline: ~41.4 kB for oke() alone.
120
- expect(total).toBeLessThan(41_400);
119
+ // Rebased after AI stream/tool-loop + fx.t/i18n on the shared fx surface
120
+ // (~49.5 kB gzip). Still far below an eager full-element bind.
121
+ expect(total).toBeLessThan(50_000);
121
122
  } finally {
122
123
  await rm(dir, { recursive: true, force: true });
123
124
  }
@@ -388,7 +388,7 @@ export async function bootApplication(input: BootOptions = {}): Promise<BootResu
388
388
  // 6. AI
389
389
  let ai = pre.ai;
390
390
  if (needs.ai && !ai) {
391
- ai = aiBind!.bindAi(options, gate, now);
391
+ ai = aiBind!.bindAi(options, gate, now, env, docker);
392
392
  }
393
393
 
394
394
  // 7. Runs