okengine 0.2.8 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -15
- package/README.md +59 -31
- package/package.json +11 -16
- package/site/content/docs/ai/llms-txt.mdx +54 -0
- package/site/content/docs/ai/mcp.mdx +123 -0
- package/site/content/docs/ai/meta.json +5 -0
- package/site/content/docs/ai/skills.mdx +53 -0
- package/site/content/docs/console/access.mdx +29 -0
- package/site/content/docs/console/ai.mdx +35 -0
- package/site/content/docs/console/architecture.mdx +35 -0
- package/site/content/docs/console/channels.mdx +37 -0
- package/site/content/docs/console/clock.mdx +31 -0
- package/site/content/docs/console/flows.mdx +31 -0
- package/site/content/docs/console/gates.mdx +35 -0
- package/site/content/docs/console/manifest-diff.mdx +34 -0
- package/site/content/docs/console/meta.json +23 -0
- package/site/content/docs/console/overview.mdx +40 -0
- package/site/content/docs/console/plugins.mdx +41 -0
- package/site/content/docs/console/privacy.mdx +32 -0
- package/site/content/docs/console/runs.mdx +40 -0
- package/site/content/docs/console/signals.mdx +31 -0
- package/site/content/docs/console/store.mdx +32 -0
- package/site/content/docs/console/tenancy.mdx +32 -0
- package/site/content/docs/console/traces.mdx +34 -0
- package/site/content/docs/console/vault.mdx +37 -0
- package/site/content/docs/elements/ai.mdx +180 -0
- package/site/content/docs/elements/channel.mdx +167 -0
- package/site/content/docs/elements/clock.mdx +182 -0
- package/site/content/docs/elements/flow.mdx +288 -0
- package/site/content/docs/elements/gate.mdx +171 -0
- package/site/content/docs/elements/meta.json +5 -0
- package/site/content/docs/elements/signal.mdx +171 -0
- package/site/content/docs/elements/store.mdx +320 -0
- package/site/content/docs/elements/vault.mdx +263 -0
- package/site/content/docs/get-started/basic-usage.mdx +124 -0
- package/site/content/docs/get-started/comparison.mdx +65 -0
- package/site/content/docs/get-started/installation.mdx +113 -0
- package/site/content/docs/get-started/introduction.mdx +123 -0
- package/site/content/docs/get-started/meta.json +5 -0
- package/site/content/docs/index.mdx +63 -0
- package/site/content/docs/meta.json +5 -0
- package/site/content/docs/plugins/compression.mdx +60 -0
- package/site/content/docs/plugins/cors.mdx +98 -0
- package/site/content/docs/plugins/csrf.mdx +96 -0
- package/site/content/docs/plugins/ip-allowlist.mdx +93 -0
- package/site/content/docs/plugins/maintenance-mode.mdx +101 -0
- package/site/content/docs/plugins/meta.json +15 -0
- package/site/content/docs/plugins/security-headers.mdx +136 -0
- package/site/content/docs/reference/cli.md +101 -0
- package/site/content/docs/reference/configuration.mdx +159 -0
- package/site/content/docs/reference/environment-variables.mdx +87 -0
- package/site/content/docs/reference/errors.mdx +80 -0
- package/site/content/docs/reference/fx.mdx +117 -0
- package/site/content/docs/reference/meta.json +5 -0
- package/site/content/docs/reference/plugins.mdx +249 -0
- package/site/content/docs/reference/security.md +63 -0
- package/src/auth/auth.test.ts +3 -0
- package/src/cli/ask-dev-mode.ts +1 -1
- package/src/cli/db.ts +87 -17
- package/src/cli/dev-db-push.test.ts +32 -2
- package/src/cli/dev-schema-sync.test.ts +66 -0
- package/src/cli/dev-schema-sync.ts +139 -0
- package/src/cli/dev.test.ts +123 -1
- package/src/cli/dev.ts +150 -8
- package/src/cli/doc-staleness.test.ts +4 -4
- package/src/cli/docker-cli.test.ts +20 -0
- package/src/cli/docker.ts +10 -0
- package/src/cli/drizzle-env.test.ts +67 -0
- package/src/cli/drizzle-env.ts +78 -0
- package/src/cli/ensure-drizzle-config.ts +50 -0
- package/src/cli/hero-meta.test.ts +1 -1
- package/src/cli/load-config.ts +6 -0
- package/src/cli/mode.ts +24 -4
- package/src/cli/openbao-bootstrap.test.ts +149 -0
- package/src/cli/openbao-bootstrap.ts +314 -0
- package/src/cli/openbao-restart.integration.test.ts +136 -0
- package/src/cli/ports.test.ts +7 -5
- package/src/cli/ports.ts +6 -2
- package/src/cli/resolve-dev-sql-env.test.ts +48 -0
- package/src/cli/resolve-dev-sql-env.ts +42 -0
- package/src/cli/stack.ts +7 -4
- package/src/cli/vault-cmd.ts +63 -0
- package/src/client/types.ts +7 -1
- package/src/compiler/extract.test.ts +40 -0
- package/src/compiler/extract.ts +123 -1
- package/src/compiler/fixtures/skyport/oke.config.ts +2 -2
- package/src/compiler/fixtures/skyport.expected.json +1 -1
- package/src/compiler/response.ts +12 -0
- package/src/config/define-config.test.ts +4 -6
- package/src/config/index.ts +2 -15
- package/src/console/server/app.ts +2 -0
- package/src/console/server/vault.ts +21 -6
- package/src/docker/compose.ts +112 -16
- package/src/docker/derive.ts +7 -1
- package/src/docker/docker.test.ts +103 -0
- package/src/docker/index.ts +11 -1
- package/src/docker/recipes/index.ts +3 -2
- package/src/docker/recipes/openbao.ts +47 -0
- package/src/docker/recipes/redis.ts +5 -1
- package/src/docker/recipes/rustfs.ts +2 -3
- package/src/docker/stack-id.test.ts +43 -8
- package/src/docker/stack-id.ts +99 -20
- package/src/docker/stack.ts +36 -4
- package/src/docker/types.ts +3 -0
- package/src/docs-origin.ts +4 -4
- package/src/drivers/drizzle-dialect.test.ts +20 -0
- package/src/drivers/drizzle-dialect.ts +37 -0
- package/src/drivers/index.ts +1 -2
- package/src/drivers/memory.ts +278 -39
- package/src/drivers/s3.ts +10 -1
- package/src/drivers/vault-driver-removal.test.ts +55 -0
- package/src/drivers/vault-openbao.test.ts +97 -0
- package/src/drivers/vault-openbao.ts +102 -35
- package/src/drivers/vault-types.ts +3 -10
- package/src/elements/store/declare.ts +4 -1
- package/src/elements/store/resource-list-docs.fixture.ts +56 -0
- package/src/elements/store/resource-list-docs.test.ts +79 -0
- package/src/elements/store/resource.test.ts +253 -0
- package/src/elements/store/resource.ts +786 -0
- package/src/elements/store/sql-condition.test.ts +132 -0
- package/src/elements/store/sql-condition.ts +284 -46
- package/src/elements/store/sql-session.test.ts +86 -1
- package/src/elements/store/sql-session.ts +187 -27
- package/src/elements/store/table.ts +34 -4
- package/src/elements/store.ts +16 -0
- package/src/elements/vault/runtime.ts +1 -1
- package/src/elements/vault.test.ts +1 -28
- package/src/elements/vault.ts +1 -1
- package/src/kernel/app.ts +59 -25
- package/src/kernel/boot-bind/channel.test.ts +60 -0
- package/src/kernel/boot-bind/channel.ts +64 -2
- package/src/kernel/boot-bind/store.test.ts +10 -1
- package/src/kernel/boot-bind/store.ts +49 -2
- package/src/kernel/boot.test.ts +0 -1
- package/src/kernel/boot.ts +1 -1
- package/src/kernel/edge.test.ts +68 -0
- package/src/kernel/errors.registry.test.ts +1 -1
- package/src/kernel/flow.ts +8 -0
- package/src/kernel/fx.test.ts +23 -3
- package/src/kernel/fx.ts +115 -18
- package/src/kernel/hooks.test.ts +33 -0
- package/src/kernel/hooks.ts +22 -0
- package/src/kernel/index.ts +7 -0
- package/src/kernel/on.ts +44 -3
- package/src/kernel/plugin.ts +33 -3
- package/src/kernel/registry-isolation.test.ts +74 -0
- package/src/kernel/registry.ts +22 -1
- package/src/kernel/triggers.ts +59 -0
- package/src/manifest/fixtures/skyport.excerpt.json +1 -1
- package/src/manifest/fixtures/skyport.manifest.json +1 -1
- package/src/manifest/index.ts +1 -1
- package/src/manifest/types.ts +1 -1
- package/src/manifest/validate.ts +2 -2
- package/src/plugins/compression.test.ts +127 -0
- package/src/plugins/compression.ts +94 -0
- package/src/plugins/config-source.test.ts +204 -0
- package/src/plugins/config-source.ts +209 -0
- package/src/plugins/cors.test.ts +172 -0
- package/src/plugins/cors.ts +153 -0
- package/src/plugins/csrf.test.ts +102 -0
- package/src/plugins/csrf.ts +86 -0
- package/src/plugins/headers.ts +54 -0
- package/src/plugins/index.ts +26 -0
- package/src/plugins/ip-allowlist.test.ts +139 -0
- package/src/plugins/ip-allowlist.ts +129 -0
- package/src/plugins/maintenance-mode.test.ts +91 -0
- package/src/plugins/maintenance-mode.ts +85 -0
- package/src/plugins/security-headers.test.ts +243 -0
- package/src/plugins/security-headers.ts +255 -0
- package/src/release/measure.ts +1 -2
- package/src/test/create-test-app.ts +14 -2
- package/docs/spec/console.md +0 -762
- package/docs/spec/example.md +0 -1374
- package/docs/spec/four-applications.md +0 -1376
- package/docs/spec/unified-theory.md +0 -498
- package/src/cli/doc-drift.test.ts +0 -147
- package/src/cli/doc-drift.ts +0 -401
- package/src/cli/doctor-diff-examples.ts +0 -90
- package/src/drivers/vault-sops.ts +0 -246
- /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).
|
|
20
|
-
|
|
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
|
-
|
|
44
|
-
|
|
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,149 @@
|
|
|
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
|
+
"GET /v1/sys/health": () =>
|
|
38
|
+
Response.json({ initialized: true, sealed: false, standby: false }, { status: 200 }),
|
|
39
|
+
"POST /v1/sys/init": () =>
|
|
40
|
+
Response.json({ keys: ["unseal-key-1"], keys_base64: ["dW5zZWFs"], root_token: "root-tok" }),
|
|
41
|
+
"POST /v1/sys/unseal": () => Response.json({ sealed: false, t: 1, n: 1, progress: 0 }),
|
|
42
|
+
"POST /v1/sys/mounts/secret": () => Response.json({}),
|
|
43
|
+
"POST /v1/sys/policy/oke-app": () => new Response(null, { status: 204 }),
|
|
44
|
+
"POST /v1/auth/token/create": () =>
|
|
45
|
+
Response.json({ auth: { client_token: "app-tok", policies: ["oke-app"] } }),
|
|
46
|
+
};
|
|
47
|
+
const fetchFn = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
48
|
+
const url = String(input);
|
|
49
|
+
const method = init?.method ?? "GET";
|
|
50
|
+
calls.push({ url, method, body: typeof init?.body === "string" ? init.body : undefined });
|
|
51
|
+
const path = new URL(url).pathname;
|
|
52
|
+
const handler = routes[`${method} ${path}`];
|
|
53
|
+
if (!handler) return new Response("not found", { status: 404 });
|
|
54
|
+
return handler();
|
|
55
|
+
}) as typeof globalThis.fetch;
|
|
56
|
+
return fetchFn;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
describe("openbao bootstrap material gates", () => {
|
|
60
|
+
test("`.oke/` is gitignored (check-ignore, not inference)", () => {
|
|
61
|
+
const root = join(import.meta.dir, "../..");
|
|
62
|
+
for (const p of [
|
|
63
|
+
`${OPENBAO_STATE_DIR_REL}/unseal.key`,
|
|
64
|
+
`${OPENBAO_STATE_DIR_REL}/root.token`,
|
|
65
|
+
]) {
|
|
66
|
+
const proc = Bun.spawnSync(["git", "check-ignore", "-v", p], {
|
|
67
|
+
cwd: root,
|
|
68
|
+
stdout: "pipe",
|
|
69
|
+
stderr: "pipe",
|
|
70
|
+
});
|
|
71
|
+
expect(proc.exitCode).toBe(0);
|
|
72
|
+
expect(proc.stdout.toString()).toContain(".gitignore");
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("init writes 0600 files + 0700 dir (real mode) before success", async () => {
|
|
77
|
+
const cwd = await tempDir();
|
|
78
|
+
const calls: { url: string; method: string; body?: string }[] = [];
|
|
79
|
+
const result = await ensureOpenBao({
|
|
80
|
+
cwd,
|
|
81
|
+
url: "http://127.0.0.1:8200",
|
|
82
|
+
names: ["STRIPE_KEY", "DATABASE_URL"],
|
|
83
|
+
fetch: fakeOpenBao(calls),
|
|
84
|
+
});
|
|
85
|
+
expect(result.initializedNow).toBe(true);
|
|
86
|
+
expect(result.appToken).toBe("app-tok");
|
|
87
|
+
|
|
88
|
+
const stateDir = join(cwd, OPENBAO_STATE_DIR_REL);
|
|
89
|
+
expect(statSync(stateDir).mode & 0o777).toBe(0o700);
|
|
90
|
+
expect(statSync(join(stateDir, "unseal.key")).mode & 0o777).toBe(0o600);
|
|
91
|
+
expect(statSync(join(stateDir, "root.token")).mode & 0o777).toBe(0o600);
|
|
92
|
+
expect(statSync(join(stateDir, "app.token")).mode & 0o777).toBe(0o600);
|
|
93
|
+
assertMode(join(stateDir, "unseal.key"), 0o600);
|
|
94
|
+
|
|
95
|
+
// Policy is scoped to the two declared paths only.
|
|
96
|
+
const policyCall = calls.find((c) => c.url.endsWith("/v1/sys/policy/oke-app"));
|
|
97
|
+
expect(policyCall?.body).toContain("secret/data/STRIPE_KEY");
|
|
98
|
+
expect(policyCall?.body).toContain("secret/data/DATABASE_URL");
|
|
99
|
+
expect(policyCall?.body).not.toContain("secret/data/*");
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test("post-init material write failure does NOT report success", async () => {
|
|
103
|
+
const cwd = await tempDir();
|
|
104
|
+
// Block the state dir with a *file* so mkdir/write fails after init.
|
|
105
|
+
const blocker = join(cwd, ".oke");
|
|
106
|
+
await Bun.write(blocker, "not-a-dir");
|
|
107
|
+
|
|
108
|
+
const calls: { url: string; method: string; body?: string }[] = [];
|
|
109
|
+
let failed: unknown;
|
|
110
|
+
try {
|
|
111
|
+
await ensureOpenBao({
|
|
112
|
+
cwd,
|
|
113
|
+
url: "http://127.0.0.1:8200",
|
|
114
|
+
names: ["STRIPE_KEY"],
|
|
115
|
+
fetch: fakeOpenBao(calls),
|
|
116
|
+
});
|
|
117
|
+
} catch (err) {
|
|
118
|
+
failed = err;
|
|
119
|
+
}
|
|
120
|
+
expect(failed).toBeDefined();
|
|
121
|
+
// init happened (vault now initialized server-side) but we never returned
|
|
122
|
+
// success without durable material.
|
|
123
|
+
expect(calls.some((c) => c.url.endsWith("/v1/sys/init"))).toBe(true);
|
|
124
|
+
expect(calls.some((c) => c.url.endsWith("/v1/auth/token/create"))).toBe(false);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("initialized but host unseal.key missing → loud permanent-loss error", async () => {
|
|
128
|
+
const cwd = await tempDir();
|
|
129
|
+
const calls: { url: string; method: string; body?: string }[] = [];
|
|
130
|
+
const fetchFn = fakeOpenBao(calls);
|
|
131
|
+
// Seal-status says initialized (data exists) but the host has no material.
|
|
132
|
+
const initialized = (async (input: string | URL | Request, init?: RequestInit) => {
|
|
133
|
+
const url = String(input);
|
|
134
|
+
if (url.endsWith("/v1/sys/seal-status")) {
|
|
135
|
+
return Response.json({ sealed: true, initialized: true });
|
|
136
|
+
}
|
|
137
|
+
return fetchFn(input as string, init);
|
|
138
|
+
}) as typeof globalThis.fetch;
|
|
139
|
+
|
|
140
|
+
let failed: unknown;
|
|
141
|
+
try {
|
|
142
|
+
await ensureOpenBao({ cwd, url: "http://127.0.0.1:8200", fetch: initialized });
|
|
143
|
+
} catch (err) {
|
|
144
|
+
failed = err;
|
|
145
|
+
}
|
|
146
|
+
expect(failed).toBeInstanceOf(OpenBaoBootstrapError);
|
|
147
|
+
expect(String(failed)).toContain("unrecoverable");
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
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
|
+
* Wait until OpenBao Raft storage is active (writable).
|
|
127
|
+
*
|
|
128
|
+
* `/v1/sys/health` returns 200 only when initialized, unsealed, and the
|
|
129
|
+
* active leader — not while sealed (503) or standby (429). Polling seal-status
|
|
130
|
+
* alone is not enough after restart.
|
|
131
|
+
*
|
|
132
|
+
* @param fetchFn - Injected fetch
|
|
133
|
+
* @param url - Base URL without trailing slash
|
|
134
|
+
*/
|
|
135
|
+
async function waitForActiveStorage(fetchFn: OpenBaoFetch, url: string): Promise<void> {
|
|
136
|
+
const deadline = Date.now() + 60_000;
|
|
137
|
+
let lastStatus = 0;
|
|
138
|
+
while (Date.now() < deadline) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetchFn(`${url}/v1/sys/health`);
|
|
141
|
+
lastStatus = res.status;
|
|
142
|
+
// Drain the body so keep-alive clients don't stall.
|
|
143
|
+
await res.arrayBuffer().catch(() => undefined);
|
|
144
|
+
if (res.status === 200) return;
|
|
145
|
+
} catch {
|
|
146
|
+
// process up, Raft not ready yet
|
|
147
|
+
}
|
|
148
|
+
await Bun.sleep(250);
|
|
149
|
+
}
|
|
150
|
+
throw new OpenBaoBootstrapError(
|
|
151
|
+
`openbao bootstrap: storage not active after unseal (health → ${lastStatus || "unreachable"})`,
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Ensure OpenBao is initialized + unsealed, mint/reuse the app token.
|
|
157
|
+
*
|
|
158
|
+
* Fails loud when: server unreachable · initialized but host unseal key
|
|
159
|
+
* missing/corrupt · still sealed after unseal. Never re-inits an existing
|
|
160
|
+
* backend; never writes root material into the compose env.
|
|
161
|
+
*
|
|
162
|
+
* @param options - URL / cwd / declared names
|
|
163
|
+
*/
|
|
164
|
+
export async function ensureOpenBao(
|
|
165
|
+
options: OpenBaoBootstrapOptions,
|
|
166
|
+
): Promise<OpenBaoBootstrapResult> {
|
|
167
|
+
const fetchFn = options.fetch ?? globalThis.fetch;
|
|
168
|
+
const url = options.url.replace(/\/$/, "");
|
|
169
|
+
const stateDir = resolve(options.cwd ?? process.cwd(), options.stateDir ?? OPENBAO_STATE_DIR_REL);
|
|
170
|
+
const unsealPath = join(stateDir, "unseal.key");
|
|
171
|
+
const rootPath = join(stateDir, "root.token");
|
|
172
|
+
const appPath = join(stateDir, "app.token");
|
|
173
|
+
|
|
174
|
+
const initHeaders = { "content-type": "application/json" };
|
|
175
|
+
|
|
176
|
+
let status: SysSealStatus | undefined;
|
|
177
|
+
const deadline = Date.now() + 30_000;
|
|
178
|
+
let lastErr: unknown;
|
|
179
|
+
while (Date.now() < deadline) {
|
|
180
|
+
try {
|
|
181
|
+
status = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
|
|
182
|
+
break;
|
|
183
|
+
} catch (err) {
|
|
184
|
+
if (err instanceof OpenBaoBootstrapError) throw err;
|
|
185
|
+
lastErr = err;
|
|
186
|
+
await Bun.sleep(250);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
if (!status) {
|
|
190
|
+
throw new OpenBaoBootstrapError(
|
|
191
|
+
`openbao bootstrap: unreachable at ${url} — ${lastErr instanceof Error ? lastErr.message : String(lastErr)}`,
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
let initializedNow = false;
|
|
196
|
+
let rootToken: string;
|
|
197
|
+
let unsealKey: string;
|
|
198
|
+
|
|
199
|
+
if (!status.initialized) {
|
|
200
|
+
// First boot — init 1-of-1, then make material durable BEFORE success.
|
|
201
|
+
const init = await api<{ keys: string[]; root_token: string }>(fetchFn, `${url}/v1/sys/init`, {
|
|
202
|
+
method: "POST",
|
|
203
|
+
headers: initHeaders,
|
|
204
|
+
body: JSON.stringify({ secret_shares: 1, secret_threshold: 1 }),
|
|
205
|
+
});
|
|
206
|
+
unsealKey = init.keys[0] ?? "";
|
|
207
|
+
rootToken = init.root_token;
|
|
208
|
+
if (!unsealKey || !rootToken) {
|
|
209
|
+
throw new OpenBaoBootstrapError(
|
|
210
|
+
"openbao bootstrap: init returned no unseal key / root token",
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
// If this write throws, we fail loud while keys are still in memory.
|
|
214
|
+
mkdirSync(stateDir, { recursive: true });
|
|
215
|
+
chmodSync(stateDir, 0o700);
|
|
216
|
+
writeMaterial(unsealPath, unsealKey);
|
|
217
|
+
writeMaterial(rootPath, rootToken);
|
|
218
|
+
initializedNow = true;
|
|
219
|
+
} else {
|
|
220
|
+
// Initialized — material MUST exist on the host (no re-init, no silent).
|
|
221
|
+
if (!existsSync(unsealPath) || !existsSync(rootPath)) {
|
|
222
|
+
throw new OpenBaoBootstrapError(
|
|
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.`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
unsealKey = readFileSync(unsealPath, "utf8").trim();
|
|
229
|
+
rootToken = readFileSync(rootPath, "utf8").trim();
|
|
230
|
+
if (!unsealKey || !rootToken) {
|
|
231
|
+
throw new OpenBaoBootstrapError(
|
|
232
|
+
`openbao bootstrap: ${stateDir} material is empty/corrupt — restore your backup.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Unseal whenever sealed (every restart).
|
|
238
|
+
let seal = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
|
|
239
|
+
if (seal.sealed) {
|
|
240
|
+
await api(fetchFn, `${url}/v1/sys/unseal`, {
|
|
241
|
+
method: "POST",
|
|
242
|
+
headers: initHeaders,
|
|
243
|
+
body: JSON.stringify({ key: unsealKey }),
|
|
244
|
+
});
|
|
245
|
+
seal = await api<SysSealStatus>(fetchFn, `${url}/v1/sys/seal-status`);
|
|
246
|
+
if (seal.sealed) {
|
|
247
|
+
throw new OpenBaoBootstrapError("openbao bootstrap: still sealed after unseal");
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Single-node Raft can report unsealed before storage is writable
|
|
252
|
+
// (`cannot write to readonly storage` / `cannot find peer` during election).
|
|
253
|
+
await waitForActiveStorage(fetchFn, url);
|
|
254
|
+
|
|
255
|
+
const rootHeaders = { "X-Vault-Token": rootToken, "content-type": "application/json" };
|
|
256
|
+
|
|
257
|
+
// Enable KV v2 at `secret/` once (mount exists after our first init).
|
|
258
|
+
if (initializedNow) {
|
|
259
|
+
try {
|
|
260
|
+
await api(fetchFn, `${url}/v1/sys/mounts/${OPENBAO_MOUNT}`, {
|
|
261
|
+
method: "POST",
|
|
262
|
+
headers: rootHeaders,
|
|
263
|
+
body: JSON.stringify({ type: "kv", options: { version: "2" } }),
|
|
264
|
+
});
|
|
265
|
+
} catch {
|
|
266
|
+
// Mount may already exist (e.g. re-race); policy sync below still runs.
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Least-privilege policy scoped to the app's declared secret paths.
|
|
271
|
+
const names = [...new Set(options.names ?? [])].filter((n) => /^[\w.-]+$/.test(n));
|
|
272
|
+
const policy = names
|
|
273
|
+
.flatMap((n) => [
|
|
274
|
+
`path "${OPENBAO_MOUNT}/data/${n}" {\n capabilities = ["create", "update", "read", "delete"]\n}`,
|
|
275
|
+
`path "${OPENBAO_MOUNT}/metadata/${n}" {\n capabilities = ["read", "list", "delete"]\n}`,
|
|
276
|
+
])
|
|
277
|
+
.join("\n\n");
|
|
278
|
+
await api(fetchFn, `${url}/v1/sys/policy/oke-app`, {
|
|
279
|
+
method: "POST",
|
|
280
|
+
headers: rootHeaders,
|
|
281
|
+
body: JSON.stringify({ policy: `${policy}\n` }),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
// Reuse the persisted app token when present; mint once otherwise.
|
|
285
|
+
let appToken = existsSync(appPath) ? readFileSync(appPath, "utf8").trim() : "";
|
|
286
|
+
if (!appToken) {
|
|
287
|
+
const minted = await api<{ auth: { client_token: string } }>(
|
|
288
|
+
fetchFn,
|
|
289
|
+
`${url}/v1/auth/token/create`,
|
|
290
|
+
{
|
|
291
|
+
method: "POST",
|
|
292
|
+
headers: rootHeaders,
|
|
293
|
+
body: JSON.stringify({ policies: ["oke-app"], ttl: "0" }),
|
|
294
|
+
},
|
|
295
|
+
);
|
|
296
|
+
appToken = minted.auth.client_token;
|
|
297
|
+
writeMaterial(appPath, appToken);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return { url, appToken, stateDir, initializedNow };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Build an `OKE_VAULT_*` env overlay for the app / Console from a bootstrap.
|
|
305
|
+
*
|
|
306
|
+
* @param result - Bootstrap result
|
|
307
|
+
*/
|
|
308
|
+
export function openbaoStackEnv(result: OpenBaoBootstrapResult): Record<string, string> {
|
|
309
|
+
return {
|
|
310
|
+
OKE_VAULT_URL: result.url,
|
|
311
|
+
OKE_VAULT_TOKEN: result.appToken,
|
|
312
|
+
OKE_VAULT_MOUNT: OPENBAO_MOUNT,
|
|
313
|
+
};
|
|
314
|
+
}
|
|
@@ -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
|
+
});
|
package/src/cli/ports.test.ts
CHANGED
|
@@ -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(
|
|
35
|
-
|
|
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(
|
|
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
|
});
|