okengine 0.6.1 → 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 (130) hide show
  1. package/AGENTS.md +2 -2
  2. package/README.md +73 -181
  3. package/package.json +2 -2
  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 +19 -13
  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 +13 -13
  19. package/site/content/docs/reference/environment-variables.mdx +12 -10
  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/registry.ts +79 -1
  48. package/src/client/live-gap.test.ts +35 -0
  49. package/src/client/transport.test.ts +21 -0
  50. package/src/client/transport.ts +27 -5
  51. package/src/compiler/fixtures/skyport/oke.config.ts +1 -1
  52. package/src/config/define-config.test.ts +6 -6
  53. package/src/config/index.ts +1 -1
  54. package/src/console/server/console.test.ts +45 -0
  55. package/src/console/server/flows.ts +39 -9
  56. package/src/console/server/i18n.ts +41 -0
  57. package/src/console/server/vault.ts +12 -38
  58. package/src/console/ui/dist/assets/index-ClX0ZCe5.js +10 -0
  59. package/src/console/ui/dist/assets/{panel-access-BGv45snf.js → panel-access-ri1X9Otj.js} +1 -1
  60. package/src/console/ui/dist/assets/{panel-ai-B2S7LEii.js → panel-ai-DLHjRoJk.js} +1 -1
  61. package/src/console/ui/dist/assets/{panel-architecture-D7UJh91v.js → panel-architecture-DI3bQF3J.js} +1 -1
  62. package/src/console/ui/dist/assets/{panel-channels-9T3ybqRu.js → panel-channels-BDYWX29p.js} +1 -1
  63. package/src/console/ui/dist/assets/{panel-clock-Cb1UXGRQ.js → panel-clock-i-JpTjVB.js} +1 -1
  64. package/src/console/ui/dist/assets/{panel-diff-DmYbKWmN.js → panel-diff-CFuWxiXy.js} +1 -1
  65. package/src/console/ui/dist/assets/{panel-flows-PiHwT55z.js → panel-flows-B8TRhcoe.js} +1 -1
  66. package/src/console/ui/dist/assets/{panel-gates-BQGYXvjT.js → panel-gates-CfkBgco7.js} +1 -1
  67. package/src/console/ui/dist/assets/{panel-overview-BBnRO18l.js → panel-overview-DONDzBd2.js} +1 -1
  68. package/src/console/ui/dist/assets/{panel-plugins-D0PsmVw2.js → panel-plugins-DDJwTehL.js} +1 -1
  69. package/src/console/ui/dist/assets/{panel-runs-CWuRDe0r.js → panel-runs-D5zf-D9c.js} +1 -1
  70. package/src/console/ui/dist/assets/{panel-signals-Bbg4ewpP.js → panel-signals-Dsxdu_AR.js} +1 -1
  71. package/src/console/ui/dist/assets/{panel-store-CPCbsDRa.js → panel-store-hy7O8HOs.js} +1 -1
  72. package/src/console/ui/dist/assets/{panel-traces-DVAzuA_S.js → panel-traces-CBiAe5go.js} +1 -1
  73. package/src/console/ui/dist/assets/{panel-vault-D1_MvOmo.js → panel-vault-Za1GyJfM.js} +1 -1
  74. package/src/console/ui/dist/index.html +1 -1
  75. package/src/console/ui/shell/components/ui.tsx +5 -1
  76. package/src/console/ui/shell/setup/Wizard.tsx +26 -5
  77. package/src/docker/cleanup.test.ts +193 -0
  78. package/src/docker/cleanup.ts +355 -0
  79. package/src/docker/docker.test.ts +4 -0
  80. package/src/docker/index.ts +19 -0
  81. package/src/docker/recipes/ollama.ts +10 -5
  82. package/src/drivers/ai-ollama.ts +45 -2
  83. package/src/drivers/ai-providers.test.ts +31 -0
  84. package/src/drivers/index.ts +3 -0
  85. package/src/drivers/signal-engine.ts +98 -25
  86. package/src/drivers/signal-nats.ts +4 -4
  87. package/src/drivers/signal-postgres.ts +158 -64
  88. package/src/drivers/signal-redis.ts +4 -4
  89. package/src/drivers/signal-types.ts +61 -2
  90. package/src/drivers/vault-driver-removal.test.ts +5 -1
  91. package/src/elements/clock/chaos-child.ts +150 -0
  92. package/src/elements/clock/chaos.test.ts +204 -0
  93. package/src/elements/clock/reconcile.ts +110 -0
  94. package/src/elements/clock/runtime.ts +11 -0
  95. package/src/elements/clock.test.ts +164 -2
  96. package/src/elements/clock.ts +1 -0
  97. package/src/elements/gate/runtime.ts +6 -0
  98. package/src/elements/signal/chaos-child.ts +41 -4
  99. package/src/elements/signal/declare.ts +4 -1
  100. package/src/elements/signal/delivery-modes.test.ts +179 -0
  101. package/src/elements/signal/key-ordering.test.ts +306 -0
  102. package/src/elements/signal/lease-reclaim.test.ts +123 -0
  103. package/src/elements/signal/optional-emit.test.ts +76 -0
  104. package/src/elements/signal/order-lifecycle.test.ts +130 -0
  105. package/src/elements/signal/orphan-messages.test.ts +152 -0
  106. package/src/elements/signal/runtime.ts +11 -4
  107. package/src/elements/signal/schema-emit.test.ts +107 -0
  108. package/src/elements/signal.test.ts +89 -5
  109. package/src/elements/store/files-fx.ts +104 -0
  110. package/src/elements/store/files-image.test.ts +251 -0
  111. package/src/elements/store/files-image.ts +494 -0
  112. package/src/elements/store/runtime.ts +60 -0
  113. package/src/elements/store.ts +24 -0
  114. package/src/elements/vault/boot-chain.ts +150 -0
  115. package/src/elements/vault/runtime.ts +8 -0
  116. package/src/kernel/app.ts +3 -1
  117. package/src/kernel/boot-bind/clock.ts +52 -7
  118. package/src/kernel/boot-bind/gate.ts +71 -3
  119. package/src/kernel/boot-bind/honor-config.test.ts +247 -0
  120. package/src/kernel/boot-bind/signal.ts +64 -7
  121. package/src/kernel/boot-bind/vault.ts +27 -8
  122. package/src/kernel/boot.ts +10 -2
  123. package/src/kernel/errors.ts +8 -2
  124. package/src/kernel/fx.test.ts +13 -0
  125. package/src/kernel/fx.ts +27 -4
  126. package/src/runtime/dev-request-log.test.ts +20 -1
  127. package/src/runtime/dev-request-log.ts +38 -0
  128. package/src/term.test.ts +15 -0
  129. package/src/term.ts +8 -3
  130. package/src/console/ui/dist/assets/index-CjxwRGVv.js +0 -10
package/src/cli/index.ts CHANGED
@@ -3,6 +3,7 @@
3
3
  * `oke` CLI entry.
4
4
  */
5
5
 
6
+ import { aiCli } from "./ai.ts";
6
7
  import { branchCli } from "./branch.ts";
7
8
  import { buildCli } from "./build.ts";
8
9
  import { clientAddCli } from "./client-add.ts";
@@ -79,6 +80,10 @@ if (cmd === "eval") {
79
80
  process.exit(await evalCli(sub ? [sub, ...rest] : rest));
80
81
  }
81
82
 
83
+ if (cmd === "ai") {
84
+ process.exit(await aiCli(sub ? [sub, ...rest] : rest));
85
+ }
86
+
82
87
  if (cmd === "branch") {
83
88
  process.exit(await branchCli(sub ? [sub, ...rest] : rest));
84
89
  }
@@ -47,14 +47,13 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
47
47
  const index = dockerOrProdId(config.drivers?.store?.index);
48
48
  const kv = dockerOrProdId(config.drivers?.store?.kv);
49
49
  const signal = dockerOrProdId(config.drivers?.signal);
50
- const clock = dockerOrProdId(config.drivers?.clock);
51
50
 
51
+ // Only real SQL-consuming facets pull a SQL image. Clock/signal no longer
52
+ // advertise a fictional postgres CronStore / LISTEN bus via compose.
52
53
  const needsSql =
53
54
  sql === "postgres" ||
54
55
  sql === "pgvector" ||
55
56
  index === "pgvector" ||
56
- signal === "postgres" ||
57
- clock === "postgres" ||
58
57
  (config.drivers?.prod ?? []).some((p) => p === "postgres" || p === "pgvector");
59
58
 
60
59
  if (needsSql) {
@@ -62,7 +61,8 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
62
61
  sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
63
62
  }
64
63
 
65
- const needsKv = kv === "redis" || (config.drivers?.prod ?? []).includes("redis");
64
+ const needsKv =
65
+ kv === "redis" || signal === "redis" || (config.drivers?.prod ?? []).includes("redis");
66
66
  if (needsKv) {
67
67
  out["store.kv"] = DEFAULT_KV_IMAGE;
68
68
  }
@@ -145,5 +145,6 @@ describe("openbao bootstrap material gates", () => {
145
145
  }
146
146
  expect(failed).toBeInstanceOf(OpenBaoBootstrapError);
147
147
  expect(String(failed)).toContain("unrecoverable");
148
+ expect(String(failed)).toContain("compose.vault.yml down -v");
148
149
  });
149
150
  });
@@ -221,8 +221,15 @@ export async function ensureOpenBao(
221
221
  if (!existsSync(unsealPath) || !existsSync(rootPath)) {
222
222
  throw new OpenBaoBootstrapError(
223
223
  `openbao bootstrap: OpenBao is initialized but ${stateDir} is missing ` +
224
- `unseal.key/root.token. Without the unseal key the data is unrecoverable. ` +
225
- `Restore your backup of .oke/openbao/ — permanent loss otherwise.`,
224
+ `unseal.key/root.token. Without the unseal key the vault data is unrecoverable.\n` +
225
+ `\n` +
226
+ `Usual cause in docker mode: a previous vault volume survived after the project\n` +
227
+ `(or .oke/openbao/) was deleted — Compose stop keeps volumes on purpose.\n` +
228
+ `\n` +
229
+ `Restore a backup of .oke/openbao/ if you have one.\n` +
230
+ `\n` +
231
+ `Dev reset (wipes vault secrets only, then re-init on next oke dev):\n` +
232
+ ` cd docker && docker compose -f compose.yml -f compose.vault.yml down -v`,
226
233
  );
227
234
  }
228
235
  unsealKey = readFileSync(unsealPath, "utf8").trim();
@@ -287,7 +287,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
287
287
  },
288
288
  {
289
289
  name: "docker",
290
- summary: "Dockerfile + compose.<role>.yml",
290
+ summary: "derive compose · clean leftover stacks",
291
291
  leaf: true,
292
292
  flags: [
293
293
  {
@@ -300,6 +300,24 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
300
300
  MANIFEST,
301
301
  HELP,
302
302
  ],
303
+ subcommands: [
304
+ {
305
+ name: "clean",
306
+ summary: "Remove oke-dev stacks (containers, networks, volumes)",
307
+ flags: [
308
+ {
309
+ long: "--yes",
310
+ short: "-y",
311
+ summary: "Non-interactive confirm",
312
+ },
313
+ {
314
+ long: "--all",
315
+ summary: "Every oke-dev-* stack (required with --yes outside a project)",
316
+ },
317
+ HELP,
318
+ ],
319
+ },
320
+ ],
303
321
  },
304
322
  {
305
323
  name: "images",
@@ -346,6 +364,56 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
346
364
  leaf: true,
347
365
  flags: [MANIFEST, HELP],
348
366
  },
367
+ {
368
+ name: "ai",
369
+ summary: "configure AI driver + models",
370
+ subcommands: [
371
+ {
372
+ name: "setup",
373
+ summary: "Interactive / flagged AI provider + model setup",
374
+ flags: [
375
+ {
376
+ long: "--provider",
377
+ takesValue: true,
378
+ valueName: "id",
379
+ summary: "ollama · openai · anthropic · gemini · lmstudio · openrouter · custom",
380
+ },
381
+ {
382
+ long: "--chat",
383
+ takesValue: true,
384
+ valueName: "model",
385
+ summary: "Chat model id",
386
+ },
387
+ {
388
+ long: "--vision",
389
+ takesValue: true,
390
+ valueName: "model",
391
+ summary: "Vision model id",
392
+ },
393
+ {
394
+ long: "--embed",
395
+ takesValue: true,
396
+ valueName: "model",
397
+ summary: "Embedding model id",
398
+ },
399
+ {
400
+ long: "--pull",
401
+ summary: "Pull missing Ollama models (default)",
402
+ },
403
+ {
404
+ long: "--no-pull",
405
+ summary: "Skip ollama pull",
406
+ },
407
+ {
408
+ long: "--yes",
409
+ short: "-y",
410
+ summary: "Non-interactive (requires --provider)",
411
+ },
412
+ HELP,
413
+ ],
414
+ },
415
+ ],
416
+ },
349
417
  {
350
418
  name: "branch",
351
419
  summary: "fork journaled state",
@@ -459,6 +527,16 @@ export function flagTokensFor(command: CliCommand, sub?: string): readonly strin
459
527
  }
460
528
 
461
529
  function formatCommandUsage(cmd: CliCommand): string {
530
+ // Leaf + subcommands (e.g. `oke docker` derive · `oke docker clean`).
531
+ if (cmd.leaf && cmd.subcommands && cmd.subcommands.length > 0) {
532
+ const flagHint = cmd.flags
533
+ ?.filter((f) => f.long !== "--help")
534
+ .slice(0, 2)
535
+ .map((f) => formatFlagHint(f))
536
+ .join(" ");
537
+ const subs = cmd.subcommands.map((s) => s.name).join("|");
538
+ return `oke ${cmd.name}${flagHint ? ` ${flagHint}` : ""} · ${subs}`;
539
+ }
462
540
  if (cmd.subcommands?.length === 1) {
463
541
  const sub = cmd.subcommands[0]!;
464
542
  const flagHint = sub.flags
@@ -0,0 +1,35 @@
1
+ /**
2
+ * Evidence: createClient has no live-signal subscription today.
3
+ *
4
+ * `delivery: "live"` is consumed server-side via `bus.live()` (and
5
+ * `on(signal, flow)` for flow subscribers). `okengine/client` exposes only
6
+ * HTTP REST / RPC — no SSE, WebSocket, or `api.live(…)`.
7
+ *
8
+ * Design for `client.live(signalName)` is tracked with the Signal docs page;
9
+ * do not treat this file as a stub to fill — ship the real surface when ready.
10
+ */
11
+
12
+ import { describe, expect, test } from "bun:test";
13
+
14
+ import * as client from "./index.ts";
15
+ import { createClient } from "./create.ts";
16
+
17
+ describe("createClient — live signal gap", () => {
18
+ test("public client module exports no live / subscribe helpers", () => {
19
+ const keys = Object.keys(client).sort();
20
+ expect(keys).not.toContain("live");
21
+ expect(keys).not.toContain("subscribe");
22
+ expect(keys).toContain("createClient");
23
+ expect(keys).toContain("createTransport");
24
+ });
25
+
26
+ test("createClient builds an HTTP call proxy only (no live method on the root)", async () => {
27
+ const api = createClient("http://app.test", {
28
+ fetch: async () => Response.json({ data: null, error: { code: "NotFound", data: {} } }),
29
+ });
30
+ // Root is a callable proxy for unit.flow — live is not a first-class API.
31
+ expect(typeof api).toBe("function");
32
+ expect(Object.prototype.hasOwnProperty.call(api, "live")).toBe(false);
33
+ expect("live" in (api as object)).toBe(false);
34
+ });
35
+ });
@@ -47,6 +47,27 @@ describe("transport — retry", () => {
47
47
  expect(error?.code).toBe("TransportError");
48
48
  expect(n).toBe(2);
49
49
  });
50
+
51
+ test("structured 5xx envelope is returned (not TransportError)", async () => {
52
+ const api = createClient<PingApp>("http://app.test", {
53
+ fetch: async () =>
54
+ Response.json(
55
+ {
56
+ data: null,
57
+ error: {
58
+ code: "InternalError",
59
+ data: {},
60
+ message: "password policy failed: minLength 12",
61
+ },
62
+ },
63
+ { status: 500 },
64
+ ),
65
+ });
66
+
67
+ const { error } = await api.sys.ping();
68
+ expect(error?.code).toBe("InternalError");
69
+ expect(error?.message).toMatch(/password policy/i);
70
+ });
50
71
  });
51
72
 
52
73
  describe("transport — timeout", () => {
@@ -44,6 +44,11 @@ export function createTransport(base: string, opts: ClientOptions = {}): Transpo
44
44
  await opts.auth.refresh();
45
45
  continue;
46
46
  }
47
+ if (res.status >= 500) {
48
+ const structured = await decodeIfEnvelope(res);
49
+ if (structured) return structured;
50
+ throw new Error(`HTTP ${res.status}`);
51
+ }
47
52
  return decode(res);
48
53
  } catch (err) {
49
54
  const transient = isTransient(err);
@@ -110,21 +115,38 @@ async function once(
110
115
  ctrl && opts.timeout !== undefined ? setTimeout(() => ctrl.abort(), opts.timeout) : undefined;
111
116
 
112
117
  try {
113
- const res = await fetchFn(url, {
118
+ return await fetchFn(url, {
114
119
  method,
115
120
  headers,
116
121
  body,
117
122
  signal: ctrl?.signal,
118
123
  });
119
- if (res.status >= 500) {
120
- throw new Error(`HTTP ${res.status}`);
121
- }
122
- return res;
123
124
  } finally {
124
125
  if (timer !== undefined) clearTimeout(timer);
125
126
  }
126
127
  }
127
128
 
129
+ /**
130
+ * Decode a JSON `{ data, error }` envelope from a 5xx response, or `null`
131
+ * when the body is not a structured OKE failure (retry as transport error).
132
+ *
133
+ * @param res - HTTP response (body consumed)
134
+ */
135
+ async function decodeIfEnvelope(res: Response): Promise<ClientResult | null> {
136
+ const text = await res.text();
137
+ if (!text) return null;
138
+ let json: unknown;
139
+ try {
140
+ json = JSON.parse(text);
141
+ } catch {
142
+ return null;
143
+ }
144
+ if (json !== null && typeof json === "object" && "data" in json && "error" in json) {
145
+ return json as ClientResult;
146
+ }
147
+ return null;
148
+ }
149
+
128
150
  function rpcRequest(
129
151
  base: string,
130
152
  key: string,
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  files: { prod: "s3" },
10
10
  index: { prod: "pgvector" },
11
11
  },
12
- signal: { prod: "postgres" },
12
+ signal: { prod: "redis" },
13
13
  vault: { prod: "openbao" },
14
14
  channel: { email: { prod: "smtp" } },
15
15
  ai: { prod: { driver: "anthropic" } },
@@ -26,7 +26,7 @@ describe("fillDockerFromProd / defineConfig", () => {
26
26
  });
27
27
 
28
28
  test("vault prod → docker copies the prod driver", () => {
29
- expect(fillDockerFromProd({ local: "dotenv", prod: "openbao" })?.docker).toBe("openbao");
29
+ expect(fillDockerFromProd({ local: "env", prod: "openbao" })?.docker).toBe("openbao");
30
30
  });
31
31
 
32
32
  test("defineConfig fills every element from prod", () => {
@@ -37,9 +37,9 @@ describe("fillDockerFromProd / defineConfig", () => {
37
37
  kv: { local: "memory", prod: "redis" },
38
38
  files: { local: "fs", prod: "s3" },
39
39
  },
40
- signal: { local: "memory", prod: "postgres" },
41
- clock: { local: "memory", prod: "postgres" },
42
- vault: { local: "dotenv", prod: "openbao" },
40
+ signal: { local: "memory", prod: "redis" },
41
+ clock: { local: "memory", prod: "file" },
42
+ vault: { local: "env", prod: "openbao" },
43
43
  channel: {
44
44
  email: { local: "console", prod: "smtp" },
45
45
  },
@@ -49,8 +49,8 @@ describe("fillDockerFromProd / defineConfig", () => {
49
49
  expect(cfg.drivers?.store?.sql?.docker).toBe("postgres");
50
50
  expect(cfg.drivers?.store?.kv?.docker).toBe("redis");
51
51
  expect(cfg.drivers?.store?.files?.docker).toBe("s3");
52
- expect(cfg.drivers?.signal?.docker).toBe("postgres");
53
- expect(cfg.drivers?.clock?.docker).toBe("postgres");
52
+ expect(cfg.drivers?.signal?.docker).toBe("redis");
53
+ expect(cfg.drivers?.clock?.docker).toBe("file");
54
54
  expect(cfg.drivers?.vault?.docker).toBe("openbao");
55
55
  expect(cfg.drivers?.channel?.email?.docker).toBe("smtp");
56
56
  expect(cfg.drivers?.ai?.docker).toBe("anthropic");
@@ -344,7 +344,7 @@ export function fillDriversDockerFromProd(
344
344
  * Define an `oke.config.ts` document.
345
345
  *
346
346
  * Missing `docker` driver pins are filled from `prod` so `oke dev -d` uses the
347
- * same server protocols as production (vault → dotenv for `docker/.env.docker`).
347
+ * same server protocols as production (vault → `env` for `docker/.env.docker`).
348
348
  * Legacy `dev`/`stack` keys are normalized to `local`/`docker`.
349
349
  *
350
350
  * @param config - Driver / tenancy / i18n / topology
@@ -53,6 +53,51 @@ describe("console kernel", () => {
53
53
  }
54
54
  });
55
55
 
56
+ test("claim rejects weak passwords with ClaimFailed password_policy (not 500)", async () => {
57
+ const handle = await startConsoleApp({
58
+ cwd,
59
+ secret: "test-secret-console",
60
+ silentClaim: true,
61
+ });
62
+ try {
63
+ const weak = await handle.app.fetch(
64
+ new Request("http://console.test/console/setup/claim", {
65
+ method: "POST",
66
+ headers: { "content-type": "application/json" },
67
+ body: JSON.stringify({
68
+ claimCode: handle.state.claim.code,
69
+ email: "ops@example.com",
70
+ name: "Ops",
71
+ password: "short1A",
72
+ }),
73
+ }),
74
+ );
75
+ // Zod min(12) rejects before createOperator — still not an opaque 500.
76
+ expect(weak.status).toBe(422);
77
+ const shortOkZod = await handle.app.fetch(
78
+ new Request("http://console.test/console/setup/claim", {
79
+ method: "POST",
80
+ headers: { "content-type": "application/json" },
81
+ body: JSON.stringify({
82
+ claimCode: handle.state.claim.code,
83
+ email: "ops@example.com",
84
+ name: "Ops",
85
+ password: "longenoughword",
86
+ }),
87
+ }),
88
+ );
89
+ expect(shortOkZod.status).toBe(400);
90
+ const body = (await shortOkZod.json()) as {
91
+ error: { code: string; message?: string; data?: { reason?: string } };
92
+ };
93
+ expect(body.error.code).toBe("ClaimFailed");
94
+ expect(body.error.data?.reason).toBe("password_policy");
95
+ expect(body.error.message).toMatch(/12 characters/i);
96
+ } finally {
97
+ await handle.app.stop();
98
+ }
99
+ });
100
+
56
101
  test("claim creates first operator and closes the wizard permanently", async () => {
57
102
  const handle = await startConsoleApp({
58
103
  cwd,
@@ -7,8 +7,10 @@
7
7
  import { z } from "zod";
8
8
  import {
9
9
  authenticateOperator,
10
+ BreachCheckError,
10
11
  createOperator,
11
12
  issueSession,
13
+ PasswordPolicyError,
12
14
  scopesForRoles,
13
15
  userPrincipal,
14
16
  type IssuedSession,
@@ -24,6 +26,7 @@ import { maskWideEventForConsole, piiFieldNamesFromManifest } from "./runs-pii.t
24
26
  import { ClockResourceNotFoundError, ScheduleNotOverridableError } from "./clock.ts";
25
27
  import { createFileDiff, emitStructuralDiff } from "./structural.ts";
26
28
  import type { ConsoleState } from "./state.ts";
29
+ import { consoleFailureMessage } from "./i18n.ts";
27
30
  import { PUBLIC_CONSOLE_FLOWS } from "./public-flows.ts";
28
31
  import { tenancyDeclared } from "./store.ts";
29
32
 
@@ -38,7 +41,8 @@ const ClaimIn = z.object({
38
41
  claimCode: z.string().min(1),
39
42
  email: z.string().email(),
40
43
  name: z.string().min(1),
41
- password: z.string().min(8),
44
+ /** Matches default operator password policy (minLength 12). */
45
+ password: z.string().min(12),
42
46
  });
43
47
 
44
48
  const SessionOut = z.object({
@@ -292,7 +296,11 @@ const InvokeOut = z.object({
292
296
  });
293
297
 
294
298
  const SetupClosed = z.object({ reason: z.string() });
295
- const ClaimFailed = z.object({ reason: z.string() });
299
+ const ClaimFailed = z.object({
300
+ reason: z.string(),
301
+ /** Password-policy detail lines when `reason` is `password_policy`. */
302
+ reasons: z.array(z.string()).optional(),
303
+ });
296
304
  const AuthFailed = z.object({});
297
305
  const AuthRateLimited = z.object({ reason: z.string() });
298
306
  const NotFound = z.object({ flowId: z.string() });
@@ -1688,17 +1696,39 @@ function createSetupClaim(state: ConsoleState) {
1688
1696
  errors: { SetupClosed, ClaimFailed },
1689
1697
  do: async (input: z.infer<typeof ClaimIn>, fx) => {
1690
1698
  if (state.setupClosed) {
1691
- return fail("SetupClosed", { reason: "first operator already exists" });
1699
+ const data = { reason: "first_operator_exists" };
1700
+ return fail("SetupClosed", data, { message: consoleFailureMessage("SetupClosed", data) });
1692
1701
  }
1693
1702
  const verified = verifyClaimCode(state.claim, input.claimCode, state.now);
1694
1703
  if (!verified.ok) {
1695
- return fail("ClaimFailed", { reason: verified.reason });
1704
+ const data = { reason: verified.reason };
1705
+ return fail("ClaimFailed", data, { message: consoleFailureMessage("ClaimFailed", data) });
1706
+ }
1707
+ let op: Awaited<ReturnType<typeof createOperator>>;
1708
+ try {
1709
+ op = await createOperator(state.operators, {
1710
+ email: input.email,
1711
+ name: input.name,
1712
+ password: input.password,
1713
+ });
1714
+ } catch (err) {
1715
+ if (err instanceof PasswordPolicyError) {
1716
+ const data = {
1717
+ reason: "password_policy",
1718
+ reasons: [...err.reasons],
1719
+ };
1720
+ return fail("ClaimFailed", data, {
1721
+ message: consoleFailureMessage("ClaimFailed", data),
1722
+ });
1723
+ }
1724
+ if (err instanceof BreachCheckError) {
1725
+ const data = { reason: "password_breached" };
1726
+ return fail("ClaimFailed", data, {
1727
+ message: consoleFailureMessage("ClaimFailed", data),
1728
+ });
1729
+ }
1730
+ throw err;
1696
1731
  }
1697
- const op = await createOperator(state.operators, {
1698
- email: input.email,
1699
- name: input.name,
1700
- password: input.password,
1701
- });
1702
1732
  state.persistOperator(op.id);
1703
1733
  const issued = await issueOperatorSession(state, op.id);
1704
1734
  fx.log.info("console.setup.claim", { operatorId: op.id });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Console-only failure copy — kept out of kernel built-in catalogs so the
3
+ * edge profile and app exports do not pay for operator-claim strings.
4
+ */
5
+
6
+ const en = {
7
+ ClaimFailed: "Could not create the first operator.",
8
+ "ClaimFailed.mismatch": "That claim code does not match. Copy it from the boot log.",
9
+ "ClaimFailed.expired": "That claim code expired. Restart `oke dev` for a new one.",
10
+ "ClaimFailed.rate_limited": "Too many claim attempts. Wait a minute and try again.",
11
+ "ClaimFailed.missing": "No claim code is active. Restart `oke dev` to mint one.",
12
+ "ClaimFailed.password_policy":
13
+ "Password needs at least 12 characters, including a letter and a number.",
14
+ "ClaimFailed.password_breached":
15
+ "Choose a different password — this one appears in a breach list.",
16
+ SetupClosed: "Setup is closed — an operator already exists. Sign in instead.",
17
+ "SetupClosed.first_operator_exists":
18
+ "Setup is closed — an operator already exists. Sign in instead.",
19
+ } as const;
20
+
21
+ /**
22
+ * Resolve a Console claim/setup failure message (English; boot log is EN).
23
+ *
24
+ * @param code - Failure code
25
+ * @param data - Failure data (may include `reason`)
26
+ */
27
+ export function consoleFailureMessage(code: string, data?: unknown): string {
28
+ const reason =
29
+ data !== null &&
30
+ data !== undefined &&
31
+ typeof data === "object" &&
32
+ "reason" in data &&
33
+ typeof (data as { reason: unknown }).reason === "string"
34
+ ? (data as { reason: string }).reason
35
+ : undefined;
36
+ if (reason) {
37
+ const keyed = en[`${code}.${reason}` as keyof typeof en];
38
+ if (keyed !== undefined) return keyed;
39
+ }
40
+ return en[code as keyof typeof en] ?? code;
41
+ }
@@ -6,21 +6,16 @@
6
6
  * rotation blast radius are derived — never reimplemented in the UI.
7
7
  */
8
8
 
9
- import { readFileSync } from "node:fs";
10
- import { resolve } from "node:path";
11
9
  import type { ConfigEnv } from "../../config/index.ts";
12
- import { envVaultDriver, memoryVaultDriver, openbaoVaultDriver } from "../../drivers/index.ts";
13
- import { parseDotenv } from "../../drivers/vault-dotenv-parse.ts";
10
+ import { buildVaultBootChain } from "../../elements/vault/boot-chain.ts";
14
11
  import {
15
12
  createVaultRuntime,
16
13
  vault as declareVault,
17
- type VaultChainLayer,
18
14
  type VaultResolutionSource,
19
15
  type VaultResolutionStep,
20
16
  type VaultRuntime,
21
17
  type VaultSecretDecl,
22
18
  } from "../../elements/vault.ts";
23
- import { resolveComposeEnvPath } from "../../elements/vault/chain.ts";
24
19
  import type { JournalRun, JournalStore } from "../../kernel/journal.ts";
25
20
  import type { Manifest } from "../../manifest/types.ts";
26
21
 
@@ -236,41 +231,20 @@ export async function createManifestVaultRuntime(
236
231
  });
237
232
 
238
233
  const seed = options.seed ?? {};
239
- const composeEnv = resolveComposeEnvPath(cwd);
240
- const envMap = parseDotenv(readFileSync(composeEnv.path, "utf8").toString());
241
- const openbaoUrl = process.env.OKE_VAULT_URL ?? envMap.get("OKE_VAULT_URL");
242
- const openbaoToken = process.env.OKE_VAULT_TOKEN ?? envMap.get("OKE_VAULT_TOKEN");
243
- const openbaoMount = process.env.OKE_VAULT_MOUNT ?? envMap.get("OKE_VAULT_MOUNT") ?? "secret";
244
- const driverLayer: VaultChainLayer =
245
- openbaoUrl && openbaoToken
246
- ? {
247
- driver: openbaoVaultDriver,
248
- source: "driver",
249
- options: { url: openbaoUrl, token: openbaoToken, mount: openbaoMount },
250
- }
251
- : {
252
- driver: memoryVaultDriver,
253
- source: "driver",
254
- options: { secrets: seed },
255
- };
234
+ const env = options.env ?? "local";
235
+ // Auto-detect: prefer OpenBao when credentials exist; soft-fallback to memory
236
+ // (historical Console behaviour never fail the Console for missing vault).
256
237
  const runtime = createVaultRuntime({
257
238
  secrets,
258
- allowDevFallbacks: options.allowDevFallbacks ?? (options.env ?? "local") !== "prod",
239
+ allowDevFallbacks: options.allowDevFallbacks ?? env !== "prod",
259
240
  now: options.now,
260
- chain: [
261
- { driver: envVaultDriver, source: "process.env" },
262
- {
263
- driver: envVaultDriver,
264
- source: ".env.local",
265
- options: { path: resolve(cwd, ".env.local") },
266
- },
267
- {
268
- driver: envVaultDriver,
269
- source: composeEnv.source,
270
- options: { path: composeEnv.path },
271
- },
272
- driverLayer,
273
- ],
241
+ chain: buildVaultBootChain({
242
+ cwd,
243
+ driverId: "openbao",
244
+ env,
245
+ seed,
246
+ missingOpenbao: "memory",
247
+ }),
274
248
  });
275
249
  await runtime.boot();
276
250
  return runtime;