okengine 0.2.8 → 0.3.2

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 (180) hide show
  1. package/AGENTS.md +17 -15
  2. package/README.md +57 -29
  3. package/package.json +11 -16
  4. package/site/content/docs/ai/llms-txt.mdx +54 -0
  5. package/site/content/docs/ai/mcp.mdx +123 -0
  6. package/site/content/docs/ai/meta.json +5 -0
  7. package/site/content/docs/ai/skills.mdx +53 -0
  8. package/site/content/docs/console/access.mdx +29 -0
  9. package/site/content/docs/console/ai.mdx +35 -0
  10. package/site/content/docs/console/architecture.mdx +35 -0
  11. package/site/content/docs/console/channels.mdx +37 -0
  12. package/site/content/docs/console/clock.mdx +31 -0
  13. package/site/content/docs/console/flows.mdx +31 -0
  14. package/site/content/docs/console/gates.mdx +35 -0
  15. package/site/content/docs/console/manifest-diff.mdx +34 -0
  16. package/site/content/docs/console/meta.json +23 -0
  17. package/site/content/docs/console/overview.mdx +40 -0
  18. package/site/content/docs/console/plugins.mdx +41 -0
  19. package/site/content/docs/console/privacy.mdx +32 -0
  20. package/site/content/docs/console/runs.mdx +40 -0
  21. package/site/content/docs/console/signals.mdx +31 -0
  22. package/site/content/docs/console/store.mdx +32 -0
  23. package/site/content/docs/console/tenancy.mdx +32 -0
  24. package/site/content/docs/console/traces.mdx +34 -0
  25. package/site/content/docs/console/vault.mdx +37 -0
  26. package/site/content/docs/elements/ai.mdx +180 -0
  27. package/site/content/docs/elements/channel.mdx +167 -0
  28. package/site/content/docs/elements/clock.mdx +182 -0
  29. package/site/content/docs/elements/flow.mdx +288 -0
  30. package/site/content/docs/elements/gate.mdx +171 -0
  31. package/site/content/docs/elements/meta.json +5 -0
  32. package/site/content/docs/elements/signal.mdx +171 -0
  33. package/site/content/docs/elements/store.mdx +320 -0
  34. package/site/content/docs/elements/vault.mdx +263 -0
  35. package/site/content/docs/get-started/basic-usage.mdx +124 -0
  36. package/site/content/docs/get-started/comparison.mdx +65 -0
  37. package/site/content/docs/get-started/installation.mdx +113 -0
  38. package/site/content/docs/get-started/introduction.mdx +123 -0
  39. package/site/content/docs/get-started/meta.json +5 -0
  40. package/site/content/docs/index.mdx +63 -0
  41. package/site/content/docs/meta.json +5 -0
  42. package/site/content/docs/plugins/compression.mdx +60 -0
  43. package/site/content/docs/plugins/cors.mdx +92 -0
  44. package/site/content/docs/plugins/csrf.mdx +96 -0
  45. package/site/content/docs/plugins/ip-allowlist.mdx +92 -0
  46. package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
  47. package/site/content/docs/plugins/meta.json +15 -0
  48. package/site/content/docs/plugins/security-headers.mdx +136 -0
  49. package/site/content/docs/reference/cli.md +101 -0
  50. package/site/content/docs/reference/configuration.mdx +159 -0
  51. package/site/content/docs/reference/environment-variables.mdx +87 -0
  52. package/site/content/docs/reference/errors.mdx +80 -0
  53. package/site/content/docs/reference/fx.mdx +117 -0
  54. package/site/content/docs/reference/meta.json +5 -0
  55. package/site/content/docs/reference/plugins.mdx +249 -0
  56. package/site/content/docs/reference/security.md +63 -0
  57. package/src/auth/auth.test.ts +3 -0
  58. package/src/cli/ask-dev-mode.ts +1 -1
  59. package/src/cli/db.ts +87 -17
  60. package/src/cli/dev-db-push.test.ts +32 -2
  61. package/src/cli/dev-schema-sync.test.ts +66 -0
  62. package/src/cli/dev-schema-sync.ts +139 -0
  63. package/src/cli/dev.test.ts +123 -1
  64. package/src/cli/dev.ts +150 -8
  65. package/src/cli/doc-staleness.test.ts +4 -4
  66. package/src/cli/docker-cli.test.ts +20 -0
  67. package/src/cli/docker.ts +10 -0
  68. package/src/cli/drizzle-env.test.ts +67 -0
  69. package/src/cli/drizzle-env.ts +78 -0
  70. package/src/cli/ensure-drizzle-config.ts +50 -0
  71. package/src/cli/hero-meta.test.ts +1 -1
  72. package/src/cli/load-config.ts +6 -0
  73. package/src/cli/mode.ts +24 -4
  74. package/src/cli/openbao-bootstrap.test.ts +147 -0
  75. package/src/cli/openbao-bootstrap.ts +280 -0
  76. package/src/cli/openbao-restart.integration.test.ts +136 -0
  77. package/src/cli/ports.test.ts +7 -5
  78. package/src/cli/ports.ts +6 -2
  79. package/src/cli/resolve-dev-sql-env.test.ts +48 -0
  80. package/src/cli/resolve-dev-sql-env.ts +42 -0
  81. package/src/cli/stack.ts +7 -4
  82. package/src/cli/vault-cmd.ts +63 -0
  83. package/src/client/types.ts +7 -1
  84. package/src/compiler/extract.test.ts +40 -0
  85. package/src/compiler/extract.ts +123 -1
  86. package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
  87. package/src/compiler/fixtures/skyport.expected.json +1 -1
  88. package/src/compiler/response.ts +12 -0
  89. package/src/config/define-config.test.ts +4 -6
  90. package/src/config/index.ts +2 -15
  91. package/src/console/server/app.ts +2 -0
  92. package/src/console/server/vault.ts +21 -6
  93. package/src/docker/compose.ts +112 -16
  94. package/src/docker/derive.ts +7 -1
  95. package/src/docker/docker.test.ts +103 -0
  96. package/src/docker/index.ts +11 -1
  97. package/src/docker/recipes/index.ts +3 -2
  98. package/src/docker/recipes/openbao.ts +47 -0
  99. package/src/docker/recipes/redis.ts +5 -1
  100. package/src/docker/recipes/rustfs.ts +2 -3
  101. package/src/docker/stack-id.test.ts +43 -8
  102. package/src/docker/stack-id.ts +99 -20
  103. package/src/docker/stack.ts +36 -4
  104. package/src/docker/types.ts +3 -0
  105. package/src/docs-origin.ts +4 -4
  106. package/src/drivers/drizzle-dialect.test.ts +20 -0
  107. package/src/drivers/drizzle-dialect.ts +37 -0
  108. package/src/drivers/index.ts +1 -2
  109. package/src/drivers/memory.ts +278 -39
  110. package/src/drivers/s3.ts +10 -1
  111. package/src/drivers/vault-driver-removal.test.ts +55 -0
  112. package/src/drivers/vault-openbao.test.ts +97 -0
  113. package/src/drivers/vault-openbao.ts +102 -35
  114. package/src/drivers/vault-types.ts +3 -10
  115. package/src/elements/store/declare.ts +4 -1
  116. package/src/elements/store/resource-list-docs.fixture.ts +56 -0
  117. package/src/elements/store/resource-list-docs.test.ts +79 -0
  118. package/src/elements/store/resource.test.ts +253 -0
  119. package/src/elements/store/resource.ts +786 -0
  120. package/src/elements/store/sql-condition.test.ts +132 -0
  121. package/src/elements/store/sql-condition.ts +284 -46
  122. package/src/elements/store/sql-session.test.ts +86 -1
  123. package/src/elements/store/sql-session.ts +187 -27
  124. package/src/elements/store/table.ts +34 -4
  125. package/src/elements/store.ts +16 -0
  126. package/src/elements/vault/runtime.ts +1 -1
  127. package/src/elements/vault.test.ts +1 -28
  128. package/src/elements/vault.ts +1 -1
  129. package/src/kernel/app.ts +59 -25
  130. package/src/kernel/boot-bind/channel.test.ts +60 -0
  131. package/src/kernel/boot-bind/channel.ts +64 -2
  132. package/src/kernel/boot-bind/store.test.ts +10 -1
  133. package/src/kernel/boot-bind/store.ts +49 -2
  134. package/src/kernel/boot.test.ts +0 -1
  135. package/src/kernel/boot.ts +1 -1
  136. package/src/kernel/edge.test.ts +68 -0
  137. package/src/kernel/errors.registry.test.ts +1 -1
  138. package/src/kernel/flow.ts +8 -0
  139. package/src/kernel/fx.test.ts +23 -3
  140. package/src/kernel/fx.ts +115 -18
  141. package/src/kernel/hooks.test.ts +33 -0
  142. package/src/kernel/hooks.ts +22 -0
  143. package/src/kernel/index.ts +7 -0
  144. package/src/kernel/on.ts +44 -3
  145. package/src/kernel/plugin.ts +33 -3
  146. package/src/kernel/registry-isolation.test.ts +74 -0
  147. package/src/kernel/registry.ts +22 -1
  148. package/src/kernel/triggers.ts +59 -0
  149. package/src/manifest/fixtures/skyport.excerpt.json +1 -1
  150. package/src/manifest/fixtures/skyport.manifest.json +1 -1
  151. package/src/manifest/index.ts +1 -1
  152. package/src/manifest/types.ts +1 -1
  153. package/src/manifest/validate.ts +2 -2
  154. package/src/plugins/compression.test.ts +127 -0
  155. package/src/plugins/compression.ts +94 -0
  156. package/src/plugins/config-source.test.ts +204 -0
  157. package/src/plugins/config-source.ts +209 -0
  158. package/src/plugins/cors.test.ts +138 -0
  159. package/src/plugins/cors.ts +129 -0
  160. package/src/plugins/csrf.test.ts +102 -0
  161. package/src/plugins/csrf.ts +86 -0
  162. package/src/plugins/headers.ts +54 -0
  163. package/src/plugins/index.ts +26 -0
  164. package/src/plugins/ip-allowlist.test.ts +105 -0
  165. package/src/plugins/ip-allowlist.ts +76 -0
  166. package/src/plugins/maintenance-mode.test.ts +91 -0
  167. package/src/plugins/maintenance-mode.ts +85 -0
  168. package/src/plugins/security-headers.test.ts +243 -0
  169. package/src/plugins/security-headers.ts +255 -0
  170. package/src/release/measure.ts +1 -2
  171. package/src/test/create-test-app.ts +14 -2
  172. package/docs/spec/console.md +0 -762
  173. package/docs/spec/example.md +0 -1374
  174. package/docs/spec/four-applications.md +0 -1376
  175. package/docs/spec/unified-theory.md +0 -498
  176. package/src/cli/doc-drift.test.ts +0 -147
  177. package/src/cli/doc-drift.ts +0 -401
  178. package/src/cli/doctor-diff-examples.ts +0 -90
  179. package/src/drivers/vault-sops.ts +0 -246
  180. /package/{spec/manifest.v1.schema.json → manifest.v1.schema.json} +0 -0
package/src/cli/mode.ts CHANGED
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * `oke mode [local|docker]` — read or set the saved `oke dev` preference.
3
+ * Setting a mode also syncs domain schema for that env (emit + push).
3
4
  */
4
5
 
5
6
  import { parseDevMode, readDevMode, writeDevMode, type DevMode } from "./dev-mode.ts";
7
+ import { sqlDialectForEnv, syncDevSchema } from "./dev-schema-sync.ts";
8
+ import { loadOkeConfig } from "./load-config.ts";
6
9
 
7
10
  /**
8
11
  * CLI entry for `oke mode`.
@@ -16,8 +19,11 @@ export async function modeCli(args: readonly string[]): Promise<number> {
16
19
  console.log(`oke mode [local|docker]
17
20
 
18
21
  Get or set the default infrastructure mode for \`oke dev\`
19
- (saved in .oke/mode). Session flags \`oke dev --local\` /
20
- \`oke dev --docker\` never change this preference.
22
+ (saved in .oke/mode). Setting a mode syncs domain schema for that env:
23
+ emits schema.generated.ts for the active dialect, then runs \`oke db push\`.
24
+ Data planes stay isolated — switching modes never copies rows.
25
+ Session flags \`oke dev --local\` / \`oke dev --docker\` never change this
26
+ preference.
21
27
  `);
22
28
  return 0;
23
29
  }
@@ -40,6 +46,20 @@ Get or set the default infrastructure mode for \`oke dev\`
40
46
  return 1;
41
47
  }
42
48
  await writeDevMode(cwd, next satisfies DevMode);
43
- console.log(`oke mode: saved ${next}`);
44
- return 0;
49
+
50
+ try {
51
+ const result = await syncDevSchema(cwd, next);
52
+ console.log(`oke mode: saved ${next} · dialect ${result.dialect}`);
53
+ return result.code;
54
+ } catch (err) {
55
+ const loaded = await loadOkeConfig(cwd).catch(() => null);
56
+ const dialect = loaded?.config
57
+ ? sqlDialectForEnv(loaded.config, next).dialect
58
+ : next === "docker"
59
+ ? "postgresql"
60
+ : "sqlite";
61
+ console.error(`oke mode: saved ${next} · dialect ${dialect}`);
62
+ console.error(err instanceof Error ? err.message : String(err));
63
+ return 1;
64
+ }
45
65
  }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * OpenBao bootstrap — material durability gates (`.oke/` ignore + real
3
+ * `0600`, init-then-write-fail must not report success, initialized-without-
4
+ * key fails loud).
5
+ */
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+ import { mkdtemp, rm } from "node:fs/promises";
9
+ import { statSync } from "node:fs";
10
+ import { tmpdir } from "node:os";
11
+ import { join } from "node:path";
12
+ import {
13
+ assertMode,
14
+ ensureOpenBao,
15
+ OpenBaoBootstrapError,
16
+ OPENBAO_STATE_DIR_REL,
17
+ } from "./openbao-bootstrap.ts";
18
+
19
+ let dirs: string[] = [];
20
+
21
+ async function tempDir(): Promise<string> {
22
+ const dir = await mkdtemp(join(tmpdir(), "oke-openbao-boot-"));
23
+ dirs.push(dir);
24
+ return dir;
25
+ }
26
+
27
+ afterEach(async () => {
28
+ for (const dir of dirs) await rm(dir, { recursive: true, force: true });
29
+ dirs = [];
30
+ });
31
+
32
+ /** Scripted fake OpenBao (seal-status → init → unseal → policy → token). */
33
+ function fakeOpenBao(calls: { url: string; method: string; body?: string }[]) {
34
+ const routes: Record<string, () => Response> = {
35
+ "GET /v1/sys/seal-status": () =>
36
+ Response.json({ sealed: false, initialized: false, t: 1, n: 1, progress: 0 }),
37
+ "POST /v1/sys/init": () =>
38
+ Response.json({ keys: ["unseal-key-1"], keys_base64: ["dW5zZWFs"], root_token: "root-tok" }),
39
+ "POST /v1/sys/unseal": () => Response.json({ sealed: false, t: 1, n: 1, progress: 0 }),
40
+ "POST /v1/sys/mounts/secret": () => Response.json({}),
41
+ "POST /v1/sys/policy/oke-app": () => new Response(null, { status: 204 }),
42
+ "POST /v1/auth/token/create": () =>
43
+ Response.json({ auth: { client_token: "app-tok", policies: ["oke-app"] } }),
44
+ };
45
+ const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => {
46
+ const url = String(input);
47
+ const method = init?.method ?? "GET";
48
+ calls.push({ url, method, body: typeof init?.body === "string" ? init.body : undefined });
49
+ const path = new URL(url).pathname;
50
+ const handler = routes[`${method} ${path}`];
51
+ if (!handler) return new Response("not found", { status: 404 });
52
+ return handler();
53
+ }) as typeof globalThis.fetch;
54
+ return fetchFn;
55
+ }
56
+
57
+ describe("openbao bootstrap material gates", () => {
58
+ test("`.oke/` is gitignored (check-ignore, not inference)", () => {
59
+ const root = join(import.meta.dir, "../..");
60
+ for (const p of [
61
+ `${OPENBAO_STATE_DIR_REL}/unseal.key`,
62
+ `${OPENBAO_STATE_DIR_REL}/root.token`,
63
+ ]) {
64
+ const proc = Bun.spawnSync(["git", "check-ignore", "-v", p], {
65
+ cwd: root,
66
+ stdout: "pipe",
67
+ stderr: "pipe",
68
+ });
69
+ expect(proc.exitCode).toBe(0);
70
+ expect(proc.stdout.toString()).toContain(".gitignore");
71
+ }
72
+ });
73
+
74
+ test("init writes 0600 files + 0700 dir (real mode) before success", async () => {
75
+ const cwd = await tempDir();
76
+ const calls: { url: string; method: string; body?: string }[] = [];
77
+ const result = await ensureOpenBao({
78
+ cwd,
79
+ url: "http://127.0.0.1:8200",
80
+ names: ["STRIPE_KEY", "DATABASE_URL"],
81
+ fetch: fakeOpenBao(calls),
82
+ });
83
+ expect(result.initializedNow).toBe(true);
84
+ expect(result.appToken).toBe("app-tok");
85
+
86
+ const stateDir = join(cwd, OPENBAO_STATE_DIR_REL);
87
+ expect(statSync(stateDir).mode & 0o777).toBe(0o700);
88
+ expect(statSync(join(stateDir, "unseal.key")).mode & 0o777).toBe(0o600);
89
+ expect(statSync(join(stateDir, "root.token")).mode & 0o777).toBe(0o600);
90
+ expect(statSync(join(stateDir, "app.token")).mode & 0o777).toBe(0o600);
91
+ assertMode(join(stateDir, "unseal.key"), 0o600);
92
+
93
+ // Policy is scoped to the two declared paths only.
94
+ const policyCall = calls.find((c) => c.url.endsWith("/v1/sys/policy/oke-app"));
95
+ expect(policyCall?.body).toContain("secret/data/STRIPE_KEY");
96
+ expect(policyCall?.body).toContain("secret/data/DATABASE_URL");
97
+ expect(policyCall?.body).not.toContain("secret/data/*");
98
+ });
99
+
100
+ test("post-init material write failure does NOT report success", async () => {
101
+ const cwd = await tempDir();
102
+ // Block the state dir with a *file* so mkdir/write fails after init.
103
+ const blocker = join(cwd, ".oke");
104
+ await Bun.write(blocker, "not-a-dir");
105
+
106
+ const calls: { url: string; method: string; body?: string }[] = [];
107
+ let failed: unknown;
108
+ try {
109
+ await ensureOpenBao({
110
+ cwd,
111
+ url: "http://127.0.0.1:8200",
112
+ names: ["STRIPE_KEY"],
113
+ fetch: fakeOpenBao(calls),
114
+ });
115
+ } catch (err) {
116
+ failed = err;
117
+ }
118
+ expect(failed).toBeDefined();
119
+ // init happened (vault now initialized server-side) but we never returned
120
+ // success without durable material.
121
+ expect(calls.some((c) => c.url.endsWith("/v1/sys/init"))).toBe(true);
122
+ expect(calls.some((c) => c.url.endsWith("/v1/auth/token/create"))).toBe(false);
123
+ });
124
+
125
+ test("initialized but host unseal.key missing → loud permanent-loss error", async () => {
126
+ const cwd = await tempDir();
127
+ const calls: { url: string; method: string; body?: string }[] = [];
128
+ const fetchFn = fakeOpenBao(calls);
129
+ // Seal-status says initialized (data exists) but the host has no material.
130
+ const initialized = (async (input: string | URL | Request, init?: RequestInit) => {
131
+ const url = String(input);
132
+ if (url.endsWith("/v1/sys/seal-status")) {
133
+ return Response.json({ sealed: true, initialized: true });
134
+ }
135
+ return fetchFn(input as string, init);
136
+ }) as typeof globalThis.fetch;
137
+
138
+ let failed: unknown;
139
+ try {
140
+ await ensureOpenBao({ cwd, url: "http://127.0.0.1:8200", fetch: initialized });
141
+ } catch (err) {
142
+ failed = err;
143
+ }
144
+ expect(failed).toBeInstanceOf(OpenBaoBootstrapError);
145
+ expect(String(failed)).toContain("unrecoverable");
146
+ });
147
+ });
@@ -0,0 +1,280 @@
1
+ /**
2
+ * OpenBao first-boot bootstrap — real init/unseal for the docker / prod vault.
3
+ *
4
+ * Durability contract (see the plan's three verification gates):
5
+ *
6
+ * 1. Host material lives under `.oke/openbao/` — `.gitignore` covers it,
7
+ * directory mode `0700`, files `0600`, asserted after every write.
8
+ * 2. `POST /v1/sys/init` is NOT transactional with the host filesystem. We
9
+ * write material atomically (temp → fsync → rename → chmod → verify) and
10
+ * only then treat init as successful. When OpenBao is initialized but host
11
+ * material is missing/corrupt, boot fails loud (permanent-loss message) —
12
+ * never re-init, never soft-empty.
13
+ * 3. The root token never lands in `docker/.env.docker`; only the minted
14
+ * least-privilege app token (`OKE_VAULT_TOKEN`) does.
15
+ */
16
+
17
+ import {
18
+ chmodSync,
19
+ existsSync,
20
+ mkdirSync,
21
+ readFileSync,
22
+ renameSync,
23
+ statSync,
24
+ writeFileSync,
25
+ } from "node:fs";
26
+ import { openSync, fsyncSync, closeSync } from "node:fs";
27
+ import { dirname, join, resolve } from "node:path";
28
+
29
+ /** Material directory relative to the project root. */
30
+ export const OPENBAO_STATE_DIR_REL = join(".oke", "openbao");
31
+ /** OpenBao KV mount used by OKE apps. */
32
+ export const OPENBAO_MOUNT = "secret";
33
+
34
+ /** Fetch contract (injectable for tests). */
35
+ export type OpenBaoFetch = typeof globalThis.fetch;
36
+
37
+ /** Options for {@link ensureOpenBao}. */
38
+ export interface OpenBaoBootstrapOptions {
39
+ readonly cwd?: string;
40
+ /** Base URL, e.g. `http://127.0.0.1:22042`. */
41
+ readonly url: string;
42
+ /** Declared vault contract names (least-privilege policy scope). */
43
+ readonly names?: readonly string[];
44
+ readonly fetch?: OpenBaoFetch;
45
+ /** Override state dir (tests). */
46
+ readonly stateDir?: string;
47
+ }
48
+
49
+ /** Result of a successful bootstrap. */
50
+ export interface OpenBaoBootstrapResult {
51
+ readonly url: string;
52
+ /** App token bound to the `oke-app` policy (never root). */
53
+ readonly appToken: string;
54
+ /** Absolute path to the state dir that holds root token + unseal key. */
55
+ readonly stateDir: string;
56
+ /** Whether this run performed first-time init. */
57
+ readonly initializedNow: boolean;
58
+ }
59
+
60
+ /** Fail-loud bootstrap error (sealed with no key / unreachable / permanent loss). */
61
+ export class OpenBaoBootstrapError extends Error {
62
+ constructor(message: string) {
63
+ super(message);
64
+ this.name = "OpenBaoBootstrapError";
65
+ }
66
+ }
67
+
68
+ interface SysSealStatus {
69
+ readonly sealed: boolean;
70
+ readonly initialized?: boolean;
71
+ }
72
+
73
+ /**
74
+ * Assert real filesystem modes (`0600` file / `0700` dir) — not a comment.
75
+ *
76
+ * @param path - File or directory
77
+ * @param expected - Expected `mode & 0o777`
78
+ */
79
+ export function assertMode(path: string, expected: number): void {
80
+ const mode = statSync(path).mode & 0o777;
81
+ if (mode !== expected) {
82
+ throw new OpenBaoBootstrapError(
83
+ `openbao bootstrap: ${path} mode ${mode.toString(8)} — expected ${expected.toString(8)}`,
84
+ );
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Atomically write `0600` material: temp → fsync → rename → chmod → re-read.
90
+ *
91
+ * @param path - Destination
92
+ * @param value - Contents
93
+ */
94
+ function writeMaterial(path: string, value: string): void {
95
+ mkdirSync(dirname(path), { recursive: true });
96
+ chmodSync(dirname(path), 0o700);
97
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
98
+ writeFileSync(tmp, `${value}\n`, { mode: 0o600 });
99
+ const fd = openSync(tmp, "r");
100
+ try {
101
+ fsyncSync(fd);
102
+ } finally {
103
+ closeSync(fd);
104
+ }
105
+ renameSync(tmp, path);
106
+ chmodSync(path, 0o600);
107
+ assertMode(path, 0o600);
108
+ const back = readFileSync(path, "utf8").trim();
109
+ if (back !== value) {
110
+ throw new OpenBaoBootstrapError(`openbao bootstrap: verify failed reading ${path}`);
111
+ }
112
+ }
113
+
114
+ async function api<T>(fetchFn: OpenBaoFetch, url: string, init?: RequestInit): Promise<T> {
115
+ const res = await fetchFn(url, init);
116
+ if (!res.ok) {
117
+ throw new OpenBaoBootstrapError(
118
+ `openbao bootstrap: ${init?.method ?? "GET"} ${url} → ${res.status}`,
119
+ );
120
+ }
121
+ const text = await res.text();
122
+ return (text ? JSON.parse(text) : {}) as T;
123
+ }
124
+
125
+ /**
126
+ * Ensure OpenBao is initialized + unsealed, mint/reuse the app token.
127
+ *
128
+ * Fails loud when: server unreachable · initialized but host unseal key
129
+ * missing/corrupt · still sealed after unseal. Never re-inits an existing
130
+ * backend; never writes root material into the compose env.
131
+ *
132
+ * @param options - URL / cwd / declared names
133
+ */
134
+ export async function ensureOpenBao(
135
+ options: OpenBaoBootstrapOptions,
136
+ ): Promise<OpenBaoBootstrapResult> {
137
+ const fetchFn = options.fetch ?? globalThis.fetch;
138
+ const url = options.url.replace(/\/$/, "");
139
+ const stateDir = resolve(options.cwd ?? process.cwd(), options.stateDir ?? OPENBAO_STATE_DIR_REL);
140
+ const unsealPath = join(stateDir, "unseal.key");
141
+ const rootPath = join(stateDir, "root.token");
142
+ const appPath = join(stateDir, "app.token");
143
+
144
+ const initHeaders = { "content-type": "application/json" };
145
+
146
+ let status: SysSealStatus | undefined;
147
+ const deadline = Date.now() + 30_000;
148
+ let lastErr: unknown;
149
+ while (Date.now() < deadline) {
150
+ try {
151
+ status = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
152
+ break;
153
+ } catch (err) {
154
+ if (err instanceof OpenBaoBootstrapError) throw err;
155
+ lastErr = err;
156
+ await Bun.sleep(250);
157
+ }
158
+ }
159
+ if (!status) {
160
+ throw new OpenBaoBootstrapError(
161
+ `openbao bootstrap: unreachable at ${url} — ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
162
+ );
163
+ }
164
+
165
+ let initializedNow = false;
166
+ let rootToken: string;
167
+ let unsealKey: string;
168
+
169
+ if (!status.initialized) {
170
+ // First boot — init 1-of-1, then make material durable BEFORE success.
171
+ const init = await api<{ keys: string[]; root_token: string }>(fetchFn, `${url}/v1/sys/init`, {
172
+ method: "POST",
173
+ headers: initHeaders,
174
+ body: JSON.stringify({ secret_shares: 1, secret_threshold: 1 }),
175
+ });
176
+ unsealKey = init.keys[0] ?? "";
177
+ rootToken = init.root_token;
178
+ if (!unsealKey || !rootToken) {
179
+ throw new OpenBaoBootstrapError(
180
+ "openbao bootstrap: init returned no unseal key / root token",
181
+ );
182
+ }
183
+ // If this write throws, we fail loud while keys are still in memory.
184
+ mkdirSync(stateDir, { recursive: true });
185
+ chmodSync(stateDir, 0o700);
186
+ writeMaterial(unsealPath, unsealKey);
187
+ writeMaterial(rootPath, rootToken);
188
+ initializedNow = true;
189
+ } else {
190
+ // Initialized — material MUST exist on the host (no re-init, no silent).
191
+ if (!existsSync(unsealPath) || !existsSync(rootPath)) {
192
+ throw new OpenBaoBootstrapError(
193
+ `openbao bootstrap: OpenBao is initialized but ${stateDir} is missing ` +
194
+ `unseal.key/root.token. Without the unseal key the data is unrecoverable. ` +
195
+ `Restore your backup of .oke/openbao/ — permanent loss otherwise.`,
196
+ );
197
+ }
198
+ unsealKey = readFileSync(unsealPath, "utf8").trim();
199
+ rootToken = readFileSync(rootPath, "utf8").trim();
200
+ if (!unsealKey || !rootToken) {
201
+ throw new OpenBaoBootstrapError(
202
+ `openbao bootstrap: ${stateDir} material is empty/corrupt — restore your backup.`,
203
+ );
204
+ }
205
+ }
206
+
207
+ // Unseal whenever sealed (every restart).
208
+ let seal = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
209
+ if (seal.sealed) {
210
+ await api(fetchFn, `${url}/v1/sys/unseal`, {
211
+ method: "POST",
212
+ headers: initHeaders,
213
+ body: JSON.stringify({ key: unsealKey }),
214
+ });
215
+ seal = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
216
+ if (seal.sealed) {
217
+ throw new OpenBaoBootstrapError("openbao bootstrap: still sealed after unseal");
218
+ }
219
+ }
220
+
221
+ const rootHeaders = { "X-Vault-Token": rootToken, "content-type": "application/json" };
222
+
223
+ // Enable KV v2 at `secret/` once (mount exists after our first init).
224
+ if (initializedNow) {
225
+ try {
226
+ await api(fetchFn, `${url}/v1/sys/mounts/${OPENBAO_MOUNT}`, {
227
+ method: "POST",
228
+ headers: rootHeaders,
229
+ body: JSON.stringify({ type: "kv", options: { version: "2" } }),
230
+ });
231
+ } catch {
232
+ // Mount may already exist (e.g. re-race); policy sync below still runs.
233
+ }
234
+ }
235
+
236
+ // Least-privilege policy scoped to the app's declared secret paths.
237
+ const names = [...new Set(options.names ?? [])].filter((n) => /^[\w.-]+$/.test(n));
238
+ const policy = names
239
+ .flatMap((n) => [
240
+ `path "${OPENBAO_MOUNT}/data/${n}" {\n capabilities = ["create", "update", "read", "delete"]\n}`,
241
+ `path "${OPENBAO_MOUNT}/metadata/${n}" {\n capabilities = ["read", "list", "delete"]\n}`,
242
+ ])
243
+ .join("\n\n");
244
+ await api(fetchFn, `${url}/v1/sys/policy/oke-app`, {
245
+ method: "POST",
246
+ headers: rootHeaders,
247
+ body: JSON.stringify({ policy: `${policy}\n` }),
248
+ });
249
+
250
+ // Reuse the persisted app token when present; mint once otherwise.
251
+ let appToken = existsSync(appPath) ? readFileSync(appPath, "utf8").trim() : "";
252
+ if (!appToken) {
253
+ const minted = await api<{ auth: { client_token: string } }>(
254
+ fetchFn,
255
+ `${url}/v1/auth/token/create`,
256
+ {
257
+ method: "POST",
258
+ headers: rootHeaders,
259
+ body: JSON.stringify({ policies: ["oke-app"], ttl: "0" }),
260
+ },
261
+ );
262
+ appToken = minted.auth.client_token;
263
+ writeMaterial(appPath, appToken);
264
+ }
265
+
266
+ return { url, appToken, stateDir, initializedNow };
267
+ }
268
+
269
+ /**
270
+ * Build an `OKE_VAULT_*` env overlay for the app / Console from a bootstrap.
271
+ *
272
+ * @param result - Bootstrap result
273
+ */
274
+ export function openbaoStackEnv(result: OpenBaoBootstrapResult): Record<string, string> {
275
+ return {
276
+ OKE_VAULT_URL: result.url,
277
+ OKE_VAULT_TOKEN: result.appToken,
278
+ OKE_VAULT_MOUNT: OPENBAO_MOUNT,
279
+ };
280
+ }
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Integration: real OpenBao survives a container restart with secrets intact.
3
+ *
4
+ * Proves the acceptance criterion — set a secret, kill/restart the container
5
+ * on the same Raft volume, re-run the bootstrap unseal, and read the secret
6
+ * back. Skips when no Docker daemon is available.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import { mkdtemp, rm } from "node:fs/promises";
11
+ import { tmpdir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { deriveInfrastructure, writeDerivedFiles } from "../docker/index.ts";
14
+ import { ensureOpenBao } from "./openbao-bootstrap.ts";
15
+
16
+ async function dockerAvailable(): Promise<boolean> {
17
+ try {
18
+ const proc = Bun.spawn(["docker", "info"], { stdout: "pipe", stderr: "pipe" });
19
+ return (await proc.exited) === 0;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+
25
+ async function compose(project: string, dir: string, args: readonly string[]): Promise<number> {
26
+ const proc = Bun.spawn(["docker", "compose", "-p", project, ...args], {
27
+ cwd: dir,
28
+ stdout: "pipe",
29
+ stderr: "pipe",
30
+ });
31
+ return proc.exited;
32
+ }
33
+
34
+ describe("openbao restart persistence (real container)", () => {
35
+ test("secret set before restart is readable after restart + unseal", async () => {
36
+ if (!(await dockerAvailable())) {
37
+ console.warn("skipping: docker daemon not available");
38
+ return;
39
+ }
40
+
41
+ const dir = await mkdtemp(join(tmpdir(), "oke-openbao-it-"));
42
+ const dockerDir = join(dir, "docker");
43
+ const project = `oke-bao-${Date.now()}`;
44
+ const composeFiles = ["compose.yml", "compose.vault.yml"];
45
+ try {
46
+ const derived = deriveInfrastructure({
47
+ images: { vault: "openbao/openbao:2.6.1" },
48
+ app: "baotest",
49
+ includeApp: false,
50
+ composeDir: "docker",
51
+ });
52
+ await writeDerivedFiles(derived, dockerDir, { writeStackEnv: true });
53
+ const spec = derived.specs.find((s) => s.role === "vault")!;
54
+ const url = `http://127.0.0.1:${spec.hostPort}`;
55
+
56
+ const up = await compose(project, dockerDir, [
57
+ ...composeFiles.flatMap((f) => ["-f", f]),
58
+ "up",
59
+ "-d",
60
+ ]);
61
+ expect(up).toBe(0);
62
+
63
+ const names = ["STRIPE_KEY"];
64
+ let first;
65
+ try {
66
+ first = await ensureOpenBao({ cwd: dir, url, names });
67
+ } catch (err) {
68
+ const logs = Bun.spawn(
69
+ [
70
+ "docker",
71
+ "compose",
72
+ "-p",
73
+ project,
74
+ ...composeFiles.flatMap((f) => ["-f", f]),
75
+ "logs",
76
+ "vault",
77
+ ],
78
+ { cwd: dockerDir, stdout: "pipe", stderr: "pipe" },
79
+ );
80
+ console.error(await new Response(logs.stdout).text());
81
+ console.error(await new Response(logs.stderr).text());
82
+ await logs.exited;
83
+ throw err;
84
+ }
85
+ expect(first.appToken.length).toBeGreaterThan(0);
86
+
87
+ const write = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
88
+ method: "POST",
89
+ headers: { "X-Vault-Token": first.appToken, "content-type": "application/json" },
90
+ body: JSON.stringify({ data: { value: "sk_restart_persistent" } }),
91
+ });
92
+ if (!write.ok) console.error("write failed", write.status, await write.text());
93
+ expect(write.ok).toBe(true);
94
+
95
+ // Kill + start the same volume (a real restart, not a fresh stack).
96
+ const restart = await compose(project, dockerDir, [
97
+ ...composeFiles.flatMap((f) => ["-f", f]),
98
+ "restart",
99
+ ]);
100
+ expect(restart).toBe(0);
101
+ // Poll the API until the server is back (sealed or unsealed).
102
+ let apiUp = false;
103
+ for (let i = 0; i < 60; i++) {
104
+ try {
105
+ const probe = await fetch(`${url}/v1/sys/seal-status`);
106
+ if (probe.ok) {
107
+ apiUp = true;
108
+ break;
109
+ }
110
+ } catch {
111
+ // not up yet
112
+ }
113
+ await Bun.sleep(500);
114
+ }
115
+ expect(apiUp).toBe(true);
116
+
117
+ const second = await ensureOpenBao({ cwd: dir, url, names });
118
+ expect(second.initializedNow).toBe(false);
119
+ expect(second.appToken).toBe(first.appToken);
120
+
121
+ const read = await fetch(`${url}/v1/secret/data/STRIPE_KEY`, {
122
+ headers: { "X-Vault-Token": second.appToken },
123
+ });
124
+ expect(read.ok).toBe(true);
125
+ const body = (await read.json()) as { data?: { data?: { value?: string } } };
126
+ expect(body.data?.data?.value).toBe("sk_restart_persistent");
127
+ } finally {
128
+ await compose(project, dockerDir, [
129
+ ...composeFiles.flatMap((f) => ["-f", f]),
130
+ "down",
131
+ "-v",
132
+ ]).catch(() => 0);
133
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
134
+ }
135
+ }, 180_000);
136
+ });
@@ -28,15 +28,17 @@ describe("findFreePort", () => {
28
28
  });
29
29
 
30
30
  describe("resolveDevPorts", () => {
31
- test("keeps app · console · mcp distinct when preferred collide", async () => {
32
- // Everything busy except 6531, 6534, 6536 — force increments.
31
+ test("keeps app · console · mcp · docsMcp distinct when preferred collide", async () => {
32
+ // Everything busy except 6531, 6534, 6536, 6537 — force increments.
33
33
  const busy = new Set([6530, 6533, 6535]);
34
- const ports = await resolveDevPorts({ app: 6530, console: 6533, mcp: 6535 }, async (p) =>
35
- busy.has(p),
34
+ const ports = await resolveDevPorts(
35
+ { app: 6530, console: 6533, mcp: 6535, docsMcp: 6536 },
36
+ async (p) => busy.has(p),
36
37
  );
37
38
  expect(ports.app).toBe(6531);
38
39
  expect(ports.console).toBe(6534);
39
40
  expect(ports.mcp).toBe(6536);
40
- expect(new Set([ports.app, ports.console, ports.mcp]).size).toBe(3);
41
+ expect(ports.docsMcp).toBe(6537);
42
+ expect(new Set([ports.app, ports.console, ports.mcp, ports.docsMcp]).size).toBe(4);
41
43
  });
42
44
  });
package/src/cli/ports.ts CHANGED
@@ -52,7 +52,7 @@ export async function findFreePort(
52
52
  }
53
53
 
54
54
  /**
55
- * Resolve distinct free ports for app · Console · MCP (dev only).
55
+ * Resolve distinct free ports for app · Console · MCP · docs MCP (dev only).
56
56
  *
57
57
  * @param preferred - Preferred ports
58
58
  * @param probe - Injectable busy check (tests)
@@ -62,12 +62,14 @@ export async function resolveDevPorts(
62
62
  readonly app: number;
63
63
  readonly console: number;
64
64
  readonly mcp: number;
65
+ readonly docsMcp: number;
65
66
  },
66
67
  probe: (port: number) => Promise<boolean> = isPortInUse,
67
68
  ): Promise<{
68
69
  readonly app: number;
69
70
  readonly console: number;
70
71
  readonly mcp: number;
72
+ readonly docsMcp: number;
71
73
  }> {
72
74
  const occupied = new Set<number>();
73
75
  const app = await findFreePort(preferred.app, occupied, probe);
@@ -75,5 +77,7 @@ export async function resolveDevPorts(
75
77
  const consolePort = await findFreePort(preferred.console, occupied, probe);
76
78
  if (consolePort !== 0) occupied.add(consolePort);
77
79
  const mcp = await findFreePort(preferred.mcp, occupied, probe);
78
- return { app, console: consolePort, mcp };
80
+ if (mcp !== 0) occupied.add(mcp);
81
+ const docsMcp = await findFreePort(preferred.docsMcp, occupied, probe);
82
+ return { app, console: consolePort, mcp, docsMcp };
79
83
  }