okengine 0.6.0 → 0.7.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 (162) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +98 -71
  3. package/package.json +4 -4
  4. package/site/content/docs/console/clock.mdx +25 -5
  5. package/site/content/docs/console/signals.mdx +13 -4
  6. package/site/content/docs/elements/ai.mdx +29 -2
  7. package/site/content/docs/elements/channel.mdx +90 -20
  8. package/site/content/docs/elements/clock.mdx +56 -24
  9. package/site/content/docs/elements/flow.mdx +2 -0
  10. package/site/content/docs/elements/gate.mdx +8 -1
  11. package/site/content/docs/elements/signal.mdx +173 -29
  12. package/site/content/docs/elements/store.mdx +464 -73
  13. package/site/content/docs/elements/vault.mdx +15 -5
  14. package/site/content/docs/get-started/basic-usage.mdx +13 -6
  15. package/site/content/docs/get-started/installation.mdx +49 -13
  16. package/site/content/docs/get-started/introduction.mdx +1 -1
  17. package/site/content/docs/reference/cli.md +30 -2
  18. package/site/content/docs/reference/configuration.mdx +18 -17
  19. package/site/content/docs/reference/environment-variables.mdx +44 -18
  20. package/site/content/docs/reference/errors.mdx +12 -11
  21. package/site/content/docs/reference/fx.mdx +15 -13
  22. package/site/content/docs/reference/security.md +3 -1
  23. package/src/cli/ai-setup/ai-setup.test.ts +144 -0
  24. package/src/cli/ai-setup/apply.ts +214 -0
  25. package/src/cli/ai-setup/catalog.ts +263 -0
  26. package/src/cli/ai-setup/detect-ollama.ts +166 -0
  27. package/src/cli/ai-setup/index.ts +228 -0
  28. package/src/cli/ai-setup/prompts.ts +649 -0
  29. package/src/cli/ai-setup/recommend.test.ts +100 -0
  30. package/src/cli/ai-setup/recommend.ts +203 -0
  31. package/src/cli/ai.ts +38 -0
  32. package/src/cli/db-auto-push.test.ts +6 -1
  33. package/src/cli/db-auto-push.ts +8 -3
  34. package/src/cli/dev-db-push.test.ts +59 -0
  35. package/src/cli/dev.test.ts +40 -0
  36. package/src/cli/dev.ts +55 -1
  37. package/src/cli/docker-clean.test.ts +179 -0
  38. package/src/cli/docker-clean.ts +406 -0
  39. package/src/cli/docker.ts +16 -4
  40. package/src/cli/ensure-drizzle-config.ts +1 -1
  41. package/src/cli/hero-meta.test.ts +6 -5
  42. package/src/cli/hero-meta.ts +7 -1
  43. package/src/cli/index.ts +5 -0
  44. package/src/cli/load-config.ts +4 -4
  45. package/src/cli/openbao-bootstrap.test.ts +1 -0
  46. package/src/cli/openbao-bootstrap.ts +9 -2
  47. package/src/cli/openbao-restart.integration.test.ts +106 -97
  48. package/src/cli/registry.ts +79 -1
  49. package/src/client/live-gap.test.ts +35 -0
  50. package/src/client/transport.test.ts +21 -0
  51. package/src/client/transport.ts +27 -5
  52. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  53. package/src/config/define-config.test.ts +6 -6
  54. package/src/config/index.ts +1 -1
  55. package/src/console/server/console.test.ts +45 -0
  56. package/src/console/server/flows.ts +39 -9
  57. package/src/console/server/i18n.ts +41 -0
  58. package/src/console/server/vault.ts +12 -38
  59. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  60. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  74. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  75. package/src/console/ui/dist/index.html +1 -1
  76. package/src/console/ui/shell/components/ui.tsx +5 -1
  77. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  78. package/src/docker/cleanup.test.ts +193 -0
  79. package/src/docker/cleanup.ts +355 -0
  80. package/src/docker/docker.test.ts +4 -0
  81. package/src/docker/dockerfile.integration.test.ts +126 -119
  82. package/src/docker/index.ts +19 -0
  83. package/src/docker/recipes/ollama.ts +10 -5
  84. package/src/docker/stack.integration.test.ts +118 -102
  85. package/src/drivers/ai-ollama-tools.integration.test.ts +8 -6
  86. package/src/drivers/ai-ollama.integration.test.ts +3 -19
  87. package/src/drivers/ai-ollama.ts +45 -2
  88. package/src/drivers/ai-providers.test.ts +31 -0
  89. package/src/drivers/channel-fcm.ts +49 -53
  90. package/src/drivers/channel-msegat.ts +61 -0
  91. package/src/drivers/channel-sently-map.ts +57 -0
  92. package/src/drivers/channel-sently.test.ts +99 -0
  93. package/src/drivers/channel-sndr.ts +28 -0
  94. package/src/drivers/channel-taqnyat.ts +57 -0
  95. package/src/drivers/channel-types.ts +79 -2
  96. package/src/drivers/channel-unifonic.ts +26 -43
  97. package/src/drivers/channel-wa-cloud.ts +33 -47
  98. package/src/drivers/channel-webpush.ts +39 -239
  99. package/src/drivers/index.ts +7 -0
  100. package/src/drivers/signal-engine.ts +98 -25
  101. package/src/drivers/signal-nats.ts +4 -4
  102. package/src/drivers/signal-postgres.ts +158 -64
  103. package/src/drivers/signal-redis.ts +4 -4
  104. package/src/drivers/signal-types.ts +61 -2
  105. package/src/drivers/vault-driver-removal.test.ts +5 -1
  106. package/src/elements/channel/costs.test.ts +2 -2
  107. package/src/elements/channel/costs.ts +14 -2
  108. package/src/elements/channel/mime.ts +11 -0
  109. package/src/elements/channel/runtime.ts +94 -0
  110. package/src/elements/channel/sndr-webhooks.test.ts +26 -0
  111. package/src/elements/channel.ts +10 -1
  112. package/src/elements/clock/chaos-child.ts +150 -0
  113. package/src/elements/clock/chaos.test.ts +204 -0
  114. package/src/elements/clock/reconcile.ts +110 -0
  115. package/src/elements/clock/runtime.ts +11 -0
  116. package/src/elements/clock.test.ts +164 -2
  117. package/src/elements/clock.ts +1 -0
  118. package/src/elements/gate/runtime.ts +6 -0
  119. package/src/elements/index.ts +9 -0
  120. package/src/elements/signal/chaos-child.ts +41 -4
  121. package/src/elements/signal/declare.ts +4 -1
  122. package/src/elements/signal/delivery-modes.test.ts +179 -0
  123. package/src/elements/signal/key-ordering.test.ts +306 -0
  124. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  125. package/src/elements/signal/optional-emit.test.ts +76 -0
  126. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  127. package/src/elements/signal/orphan-messages.test.ts +152 -0
  128. package/src/elements/signal/runtime.ts +11 -4
  129. package/src/elements/signal/schema-emit.test.ts +107 -0
  130. package/src/elements/signal.test.ts +89 -5
  131. package/src/elements/store/files-fx.ts +104 -0
  132. package/src/elements/store/files-image.test.ts +251 -0
  133. package/src/elements/store/files-image.ts +494 -0
  134. package/src/elements/store/runtime.ts +60 -0
  135. package/src/elements/store.ts +24 -0
  136. package/src/elements/vault/boot-chain.ts +150 -0
  137. package/src/elements/vault/runtime.ts +8 -0
  138. package/src/kernel/app.ts +3 -1
  139. package/src/kernel/boot-bind/channel.test.ts +68 -3
  140. package/src/kernel/boot-bind/channel.ts +93 -2
  141. package/src/kernel/boot-bind/clock.ts +52 -7
  142. package/src/kernel/boot-bind/gate.ts +71 -3
  143. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  144. package/src/kernel/boot-bind/signal.ts +64 -7
  145. package/src/kernel/boot-bind/vault.ts +27 -8
  146. package/src/kernel/boot.ts +10 -2
  147. package/src/kernel/errors.ts +8 -2
  148. package/src/kernel/fx.test.ts +13 -0
  149. package/src/kernel/fx.ts +27 -4
  150. package/src/plugins/auth-delivery.mailpit.integration.test.ts +10 -4
  151. package/src/release/exports.test.ts +26 -0
  152. package/src/release/exports.ts +64 -5
  153. package/src/release/index.ts +5 -0
  154. package/src/release/measure.exports.test.ts +13 -1
  155. package/src/release/measure.ts +76 -13
  156. package/src/release/official-plugins.ts +46 -0
  157. package/src/release/readme.test.ts +30 -2
  158. package/src/runtime/dev-request-log.test.ts +20 -1
  159. package/src/runtime/dev-request-log.ts +38 -0
  160. package/src/term.test.ts +15 -0
  161. package/src/term.ts +8 -3
  162. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
@@ -0,0 +1,150 @@
1
+ /**
2
+ * Shared Vault resolution-chain assembly for app boot and Console.
3
+ *
4
+ * Order (first hit wins): process.env → .env.local → compose .env.docker →
5
+ * backend layer (`env` seed / openbao / memory / managed).
6
+ */
7
+
8
+ import { existsSync, readFileSync } from "node:fs";
9
+ import { resolve } from "node:path";
10
+ import type { ConfigEnv } from "../../config/index.ts";
11
+ import {
12
+ envVaultDriver,
13
+ managedVaultDriver,
14
+ memoryVaultDriver,
15
+ openbaoVaultDriver,
16
+ } from "../../drivers/index.ts";
17
+ import { parseDotenv } from "../../drivers/vault-dotenv-parse.ts";
18
+ import type { VaultDriverId } from "../../drivers/vault-types.ts";
19
+ import { resolveComposeEnvPath } from "./chain.ts";
20
+ import type { VaultChainLayer } from "./runtime.ts";
21
+
22
+ /** Options for {@link buildVaultBootChain}. */
23
+ export interface BuildVaultBootChainOptions {
24
+ /** Project root (defaults to `process.cwd()`). */
25
+ readonly cwd?: string;
26
+ /** Resolved `drivers.vault` id for the active env. */
27
+ readonly driverId: VaultDriverId;
28
+ /** Active config env — controls fail-loud vs soft fallback for openbao. */
29
+ readonly env?: ConfigEnv;
30
+ /** Seed secrets for the terminal memory / managed layer. */
31
+ readonly seed?: Readonly<Record<string, string>>;
32
+ /**
33
+ * When `openbao` is selected but credentials are missing:
34
+ * - `"throw"` — fail loud (default for `prod` / `docker`)
35
+ * - `"memory"` — soft-fallback to a seeded memory bag (Console / local)
36
+ */
37
+ readonly missingOpenbao?: "throw" | "memory";
38
+ }
39
+
40
+ /**
41
+ * Normalize a config label to a {@link VaultDriverId}.
42
+ *
43
+ * Accepts legacy `"dotenv"` as an alias for `"env"`.
44
+ *
45
+ * @param raw - Config string
46
+ */
47
+ export function normalizeVaultDriverId(raw: string): VaultDriverId {
48
+ if (raw === "dotenv") return "env";
49
+ if (raw === "env" || raw === "openbao" || raw === "memory" || raw === "managed") {
50
+ return raw;
51
+ }
52
+ throw new Error(
53
+ `oke boot: unknown vault driver "${raw}" (expected env · openbao · memory · managed)`,
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Build the standard Vault resolution chain for app boot / Console.
59
+ *
60
+ * @param options - Driver id + cwd + seed
61
+ */
62
+ export function buildVaultBootChain(options: BuildVaultBootChainOptions): VaultChainLayer[] {
63
+ const cwd = options.cwd ?? process.cwd();
64
+ const seed = options.seed ?? {};
65
+ const composeEnv = resolveComposeEnvPath(cwd);
66
+
67
+ let composeText = "";
68
+ if (existsSync(composeEnv.path)) {
69
+ composeText = readFileSync(composeEnv.path, "utf8").toString();
70
+ }
71
+ const envMap = parseDotenv(composeText);
72
+ const openbaoUrl = process.env.OKE_VAULT_URL ?? envMap.get("OKE_VAULT_URL");
73
+ const openbaoToken = process.env.OKE_VAULT_TOKEN ?? envMap.get("OKE_VAULT_TOKEN");
74
+ const openbaoMount = process.env.OKE_VAULT_MOUNT ?? envMap.get("OKE_VAULT_MOUNT") ?? "secret";
75
+
76
+ const envLayers: VaultChainLayer[] = [
77
+ { driver: envVaultDriver, source: "process.env" },
78
+ {
79
+ driver: envVaultDriver,
80
+ source: ".env.local",
81
+ options: { path: resolve(cwd, ".env.local") },
82
+ },
83
+ {
84
+ driver: envVaultDriver,
85
+ source: composeEnv.source,
86
+ options: { path: composeEnv.path },
87
+ },
88
+ ];
89
+
90
+ switch (options.driverId) {
91
+ case "memory":
92
+ return [
93
+ {
94
+ driver: memoryVaultDriver,
95
+ source: "driver",
96
+ options: { secrets: seed },
97
+ },
98
+ ];
99
+ case "managed":
100
+ return [
101
+ {
102
+ driver: managedVaultDriver,
103
+ source: "driver",
104
+ options: { secrets: seed },
105
+ },
106
+ ];
107
+ case "env":
108
+ return [
109
+ ...envLayers,
110
+ {
111
+ driver: memoryVaultDriver,
112
+ source: "driver",
113
+ options: { secrets: seed },
114
+ },
115
+ ];
116
+ case "openbao": {
117
+ if (openbaoUrl && openbaoToken) {
118
+ return [
119
+ ...envLayers,
120
+ {
121
+ driver: openbaoVaultDriver,
122
+ source: "driver",
123
+ options: { url: openbaoUrl, token: openbaoToken, mount: openbaoMount },
124
+ },
125
+ ];
126
+ }
127
+ const env = options.env ?? "local";
128
+ const missing =
129
+ options.missingOpenbao ?? (env === "prod" || env === "docker" ? "throw" : "memory");
130
+ if (missing === "throw") {
131
+ throw new Error(
132
+ 'oke boot: vault driver "openbao" needs OKE_VAULT_URL and OKE_VAULT_TOKEN ' +
133
+ "(did `oke dev -d` bootstrap OpenBao / write docker/.env.docker?)",
134
+ );
135
+ }
136
+ return [
137
+ ...envLayers,
138
+ {
139
+ driver: memoryVaultDriver,
140
+ source: "driver",
141
+ options: { secrets: seed },
142
+ },
143
+ ];
144
+ }
145
+ default: {
146
+ const _exhaustive: never = options.driverId;
147
+ throw new Error(`oke boot: unhandled vault driver ${String(_exhaustive)}`);
148
+ }
149
+ }
150
+ }
@@ -87,6 +87,11 @@ export interface CreateVaultRuntimeOptions {
87
87
  export interface VaultRuntime {
88
88
  /** Declared contracts by name. */
89
89
  readonly contracts: ReadonlyMap<string, VaultSecretDecl>;
90
+ /**
91
+ * Driver ids of each chain layer in order (`env` · `openbao` · `memory` · `managed`).
92
+ * Available before {@link boot}.
93
+ */
94
+ readonly chainDriverIds: readonly string[];
90
95
  /**
91
96
  * Open every chain layer, merge (first wins), validate all contracts,
92
97
  * register redaction. Throws {@link VaultBootError} listing every gap.
@@ -245,8 +250,11 @@ export function createVaultRuntime(options: CreateVaultRuntimeOptions = {}): Vau
245
250
  booted = true;
246
251
  }
247
252
 
253
+ const chainDriverIds = (options.chain ?? []).map((layer) => layer.driver.id);
254
+
248
255
  return {
249
256
  contracts,
257
+ chainDriverIds,
250
258
  get booted() {
251
259
  return booted;
252
260
  },
package/src/kernel/app.ts CHANGED
@@ -55,6 +55,7 @@ import {
55
55
  } from "./fx.ts";
56
56
  import {
57
57
  currentDevSurface,
58
+ failureDetailFromResponse,
58
59
  logDevRequest,
59
60
  shouldLogDevRequests,
60
61
  } from "../runtime/dev-request-log.ts";
@@ -1180,7 +1181,7 @@ export function oke(options: OkeOptions): OkeApp {
1180
1181
  const url = new URL(request.url);
1181
1182
  const method = request.method.toUpperCase();
1182
1183
 
1183
- const respond = (response: Response): Response => {
1184
+ const respond = async (response: Response): Promise<Response> => {
1184
1185
  if (shouldLogDevRequests()) {
1185
1186
  logDevRequest({
1186
1187
  surface: currentDevSurface(),
@@ -1189,6 +1190,7 @@ export function oke(options: OkeOptions): OkeApp {
1189
1190
  flow: flowLabel,
1190
1191
  status: response.status,
1191
1192
  ms: Math.round(performance.now() - started),
1193
+ detail: await failureDetailFromResponse(response),
1192
1194
  });
1193
1195
  }
1194
1196
  return response;
@@ -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
+ }
@@ -2,9 +2,13 @@
2
2
  * Lazy clock binder — loaded only when Clock is declared.
3
3
  */
4
4
 
5
+ import { mkdirSync } from "node:fs";
6
+ import { dirname, resolve } from "node:path";
5
7
  import {
6
8
  clock as declareClock,
7
9
  createClockRuntime,
10
+ createFileCronStore,
11
+ createMemoryCronStore,
8
12
  createTestClockRuntime,
9
13
  type ClockDecl,
10
14
  type ClockRuntime,
@@ -18,9 +22,27 @@ export interface BindClockResult {
18
22
  readonly clockDecls: ReadonlyMap<string, ClockDecl>;
19
23
  }
20
24
 
25
+ /** Default on-disk cron store path (multi-process leader election). */
26
+ export const DEFAULT_FILE_CRON_PATH = ".oke/crons.json";
27
+
28
+ /**
29
+ * Resolve `drivers.clock` for the active env.
30
+ *
31
+ * @param options - Boot options
32
+ * @param env - Active environment
33
+ */
34
+ export function resolveClockDriverId(options: BootOptions, env: ConfigEnv): string {
35
+ const resolved = resolveDriverId(options.config?.drivers?.clock, env);
36
+ if (resolved) return resolved;
37
+ return env === "test" ? "frozen" : "memory";
38
+ }
39
+
21
40
  /**
22
41
  * Construct / adopt a Clock runtime, register decls, reconcile.
23
42
  *
43
+ * Supported ids: `frozen` · `memory` · `file`. `postgres` (and any other id)
44
+ * fail loud — there is no postgres CronStore.
45
+ *
24
46
  * @param options - Boot options
25
47
  * @param env - Active environment
26
48
  * @param now - Clock
@@ -32,13 +54,36 @@ export async function bindClock(
32
54
  now: () => number,
33
55
  prebuilt?: ClockRuntime,
34
56
  ): Promise<BindClockResult> {
35
- const clockDriver =
36
- resolveDriverId(options.config?.drivers?.clock, env) ?? (env === "test" ? "frozen" : "memory");
37
- const clock =
38
- prebuilt ??
39
- (clockDriver === "frozen" || env === "test"
40
- ? createTestClockRuntime(now(), { instanceId: options.instanceId })
41
- : createClockRuntime({ instanceId: options.instanceId, now }));
57
+ const clockDriver = resolveClockDriverId(options, env);
58
+
59
+ let clock: ClockRuntime;
60
+ if (prebuilt) {
61
+ clock = prebuilt;
62
+ } else if (clockDriver === "frozen" || env === "test") {
63
+ clock = createTestClockRuntime(now(), { instanceId: options.instanceId });
64
+ } else if (clockDriver === "memory") {
65
+ clock = createClockRuntime({
66
+ instanceId: options.instanceId,
67
+ now,
68
+ store: createMemoryCronStore(),
69
+ });
70
+ } else if (clockDriver === "file") {
71
+ const path = resolve(process.cwd(), DEFAULT_FILE_CRON_PATH);
72
+ mkdirSync(dirname(path), { recursive: true });
73
+ clock = createClockRuntime({
74
+ instanceId: options.instanceId,
75
+ now,
76
+ store: createFileCronStore(path),
77
+ });
78
+ } else if (clockDriver === "postgres") {
79
+ throw new Error(
80
+ 'oke boot: clock driver "postgres" is not implemented — use "memory", "file", or "frozen"',
81
+ );
82
+ } else {
83
+ throw new Error(
84
+ `oke boot: unknown clock driver "${clockDriver}" (expected memory · file · frozen)`,
85
+ );
86
+ }
42
87
 
43
88
  const clockDecls = new Map<string, ClockDecl>();
44
89
  for (const c of options.clocks ?? []) {
@@ -2,18 +2,86 @@
2
2
  * Lazy gate binder — loaded only when Gate is declared (or required by AI).
3
3
  */
4
4
 
5
+ import { resolveDriverId, type ConfigEnv } from "../../config/index.ts";
5
6
  import { memoryDrivers } from "../../drivers/memory.ts";
7
+ import { redisDriver } from "../../drivers/redis.ts";
8
+ import type { KvClientLike } from "../../drivers/types.ts";
6
9
  import { createGateRuntime, type GateRuntime } from "../../elements/gate.ts";
7
10
  import type { BootOptions } from "../boot.ts";
8
11
 
12
+ /** Namespace for gate rate-limit counters (never shares an app kv declaration). */
13
+ export const GATE_KV_NAMESPACE = "oke:gates";
14
+
9
15
  /**
10
- * Construct a Gate runtime backed by a memory kv namespace.
16
+ * Resolve the KV backend for Gate rates same facet as `drivers.store.kv`.
17
+ *
18
+ * @param options - Boot options
19
+ * @param env - Active environment
20
+ * @param docker - Docker mode
21
+ */
22
+ export function resolveGateKvDriverId(
23
+ options: BootOptions,
24
+ env: ConfigEnv,
25
+ docker = false,
26
+ ): "memory" | "redis" {
27
+ const fromEnv = process.env.OKE_KV_DRIVER?.trim();
28
+ if (docker && fromEnv === "redis") return "redis";
29
+ if (docker && fromEnv === "memory") return "memory";
30
+ const resolved = resolveDriverId(options.config?.drivers?.store?.kv, env);
31
+ if (resolved === "redis") return "redis";
32
+ if (resolved === "memory") return "memory";
33
+ return docker ? "redis" : "memory";
34
+ }
35
+
36
+ function kvUrlFor(docker: boolean): string | undefined {
37
+ const url = process.env.REDIS_URL ?? process.env.OKE_STORE_KV_URL ?? undefined;
38
+ if (!url) {
39
+ if (docker) {
40
+ throw new Error(
41
+ "oke boot: gate redis kv needs REDIS_URL (did `oke dev -d` write docker/.env.docker?)",
42
+ );
43
+ }
44
+ return undefined;
45
+ }
46
+ return url;
47
+ }
48
+
49
+ /**
50
+ * Construct a Gate runtime backed by `drivers.store.kv` (namespace {@link GATE_KV_NAMESPACE}).
11
51
  *
12
52
  * @param options - Boot options
13
53
  * @param now - Clock
54
+ * @param env - Active environment
55
+ * @param docker - Docker mode
14
56
  */
15
- export async function bindGate(options: BootOptions, now: () => number): Promise<GateRuntime> {
16
- const kvNs = await memoryDrivers.kv.open({ name: "oke:gates" });
57
+ export async function bindGate(
58
+ options: BootOptions,
59
+ now: () => number,
60
+ env: ConfigEnv = "local",
61
+ docker = false,
62
+ ): Promise<GateRuntime> {
63
+ const kvId = resolveGateKvDriverId(options, env, docker);
64
+ const injected = options.clients?.kv as KvClientLike | undefined;
65
+
66
+ let kvNs;
67
+ if (kvId === "redis") {
68
+ const url = injected ? undefined : kvUrlFor(docker);
69
+ if (!injected && url === undefined) {
70
+ // Local without REDIS_URL — soft-fallback to memory (same as missing redis for store would fail;
71
+ // gate rates should not brick local apps that only declared policy gates).
72
+ kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
73
+ } else {
74
+ kvNs = await redisDriver.open({
75
+ name: GATE_KV_NAMESPACE,
76
+ ...(url !== undefined ? { url } : {}),
77
+ ...(injected !== undefined ? { client: injected } : {}),
78
+ nowMs: now,
79
+ });
80
+ }
81
+ } else {
82
+ kvNs = await memoryDrivers.kv.open({ name: GATE_KV_NAMESPACE, nowMs: now });
83
+ }
84
+
17
85
  return createGateRuntime({
18
86
  gates: options.gates ?? [],
19
87
  kv: kvNs,