okengine 0.2.1 → 0.2.3

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 (59) hide show
  1. package/README.md +8 -8
  2. package/package.json +2 -1
  3. package/src/cli/dev-app-runner.ts +6 -2
  4. package/src/cli/dev.ts +84 -15
  5. package/src/cli/doctor.ts +3 -18
  6. package/src/cli/index.ts +4 -1
  7. package/src/cli/json-out.test.ts +1 -1
  8. package/src/cli/load-config.images.test.ts +75 -0
  9. package/src/cli/load-config.ts +67 -2
  10. package/src/cli/ports.test.ts +51 -0
  11. package/src/cli/ports.ts +81 -0
  12. package/src/cli/registry.help.test.ts +18 -0
  13. package/src/cli/registry.ts +0 -4
  14. package/src/cli/safe-defaults.test.ts +3 -3
  15. package/src/console/server/app.ts +2 -1
  16. package/src/console/server/claim.test.ts +7 -4
  17. package/src/console/server/claim.ts +2 -7
  18. package/src/console/server/flows.ts +1 -0
  19. package/src/console/server/index.ts +5 -0
  20. package/src/console/server/operator-db.test.ts +75 -0
  21. package/src/console/server/operator-db.ts +324 -0
  22. package/src/console/server/serve.ts +39 -0
  23. package/src/console/server/state.ts +13 -1
  24. package/src/console/ui/dist/assets/index-Bnf_3Hei.js +10 -0
  25. package/src/console/ui/dist/assets/panel-access-Dd37LU2c.js +64 -0
  26. package/src/console/ui/dist/assets/panel-ai-CC7LR6-J.js +1 -0
  27. package/src/console/ui/dist/assets/panel-architecture-B5b3iKCz.js +1 -0
  28. package/src/console/ui/dist/assets/panel-channels-CeNjTKXp.js +1 -0
  29. package/src/console/ui/dist/assets/panel-clock-DgFTLoHV.js +1 -0
  30. package/src/console/ui/dist/assets/panel-diff-DxehccqB.js +1 -0
  31. package/src/console/ui/dist/assets/panel-flows-BtrVn-Eg.js +45 -0
  32. package/src/console/ui/dist/assets/panel-gates-Z9MKRGdH.js +1 -0
  33. package/src/console/ui/dist/assets/panel-overview-Dt_AeXgd.js +1 -0
  34. package/src/console/ui/dist/assets/panel-plugins-DWd0TowH.js +1 -0
  35. package/src/console/ui/dist/assets/panel-runs-DGstFHeq.js +1 -0
  36. package/src/console/ui/dist/assets/panel-signals-DzEa2Fnt.js +1 -0
  37. package/src/console/ui/dist/assets/panel-store-BJkbNgxx.js +1 -0
  38. package/src/console/ui/dist/assets/panel-traces-BaRVO3gM.js +1 -0
  39. package/src/console/ui/dist/assets/panel-vault-BbfWdox0.js +1 -0
  40. package/src/console/ui/dist/assets/rolldown-runtime-CNC7AqOf.js +1 -0
  41. package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
  42. package/src/console/ui/dist/favicon.svg +7 -0
  43. package/src/console/ui/dist/index.html +15 -0
  44. package/src/console/ui/shell/App.tsx +34 -2
  45. package/src/console/ui/shell/components/oke-logo.tsx +40 -0
  46. package/src/console/ui/shell/index.html +1 -0
  47. package/src/console/ui/shell/layout/Shell.tsx +2 -3
  48. package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
  49. package/src/console/ui/shell/public/favicon.svg +7 -0
  50. package/src/console/ui/shell/setup/Wizard.tsx +5 -2
  51. package/src/docker/derive.ts +3 -1
  52. package/src/docker/dockerfile.integration.test.ts +1 -1
  53. package/src/docker/stack.integration.test.ts +1 -1
  54. package/src/docs-origin.ts +19 -0
  55. package/src/kernel/errors.registry.test.ts +4 -2
  56. package/src/kernel/errors.ts +3 -1
  57. package/src/kernel/fx.test.ts +2 -2
  58. package/src/term.test.ts +51 -0
  59. package/src/term.ts +158 -0
@@ -373,10 +373,6 @@ export function formatOkeHelp(): string {
373
373
  }
374
374
  }
375
375
  lines.push("");
376
- lines.push("Flags: long form is canonical; short form is convenience only.");
377
- lines.push("JSON: doctor · stack · images list · gates list accept --json|-j");
378
- lines.push(" (stdout = JSON only; hints/progress on stderr).");
379
- lines.push("");
380
376
  return `${lines.join("\n")}\n`;
381
377
  }
382
378
 
@@ -16,7 +16,7 @@ describe("oke safe-default overrides", () => {
16
16
  const dir = await mkdtemp(join(tmpdir(), "oke-safe-docker-"));
17
17
  await Bun.write(
18
18
  join(dir, "oke.config.ts"),
19
- `export default { images: { "store.sql": "postgres:16-alpine" } }\n`,
19
+ `export default { images: { "store.sql": "postgres:18-alpine" } }\n`,
20
20
  );
21
21
  // Without --prod, derive still runs but prod overlays are not requested.
22
22
  // We assert the parser default by calling runDockerDerive via dockerCli
@@ -25,7 +25,7 @@ describe("oke safe-default overrides", () => {
25
25
  const off = await runDockerDerive({
26
26
  cwd: dir,
27
27
  outDir: dir,
28
- images: { "store.sql": "postgres:16-alpine" },
28
+ images: { "store.sql": "postgres:18-alpine" },
29
29
  dryRun: true,
30
30
  write: () => {},
31
31
  });
@@ -35,7 +35,7 @@ describe("oke safe-default overrides", () => {
35
35
  const on = await runDockerDerive({
36
36
  cwd: dir,
37
37
  outDir: dir,
38
- images: { "store.sql": "postgres:16-alpine" },
38
+ images: { "store.sql": "postgres:18-alpine" },
39
39
  prod: true,
40
40
  dryRun: true,
41
41
  write: () => {},
@@ -49,7 +49,8 @@ export function createConsoleApp(
49
49
  options: CreateConsoleAppOptions = {},
50
50
  ): ConsoleAppHandle {
51
51
  const state = createConsoleState(options);
52
- if (!options.silentClaim) {
52
+ // Spec §2.5 — claim prints only while setup is open (no operators yet).
53
+ if (!options.silentClaim && !state.setupClosed) {
53
54
  printClaimCodeOnce(state.claim);
54
55
  }
55
56
 
@@ -52,10 +52,13 @@ describe("claim code", () => {
52
52
 
53
53
  test("prints to boot log exactly once", () => {
54
54
  const state = mintClaimCode(() => 1);
55
- const lines: string[] = [];
56
- printClaimCodeOnce(state, (l) => lines.push(l));
57
- printClaimCodeOnce(state, (l) => lines.push(l));
58
- expect(lines.filter((l) => l.includes(state.code)).length).toBe(1);
55
+ const chunks: string[] = [];
56
+ printClaimCodeOnce(state, (l) => chunks.push(l));
57
+ printClaimCodeOnce(state, (l) => chunks.push(l));
58
+ const joined = chunks.join("");
59
+ expect(joined).toContain(state.code);
60
+ expect(joined).toContain("Claim code");
61
+ expect(chunks.length).toBe(1);
59
62
  });
60
63
 
61
64
  test("constantTimeEqual is length-safe", () => {
@@ -5,6 +5,7 @@
5
5
  * and rate-limited (console §2.5 · §10.4).
6
6
  */
7
7
 
8
+ import { formatClaimNote } from "../../term.ts";
8
9
  import {
9
10
  AUTH_RATE_LIMIT,
10
11
  AUTH_RATE_WINDOW_MS,
@@ -67,13 +68,7 @@ export function printClaimCodeOnce(
67
68
  ): void {
68
69
  if (state.printed) return;
69
70
  state.printed = true;
70
- write("");
71
- write("┌─────────────────────────────────────────────────────────┐");
72
- write("│ oke Console — first-admin claim code (expires 30 min) │");
73
- write(`│ ${state.code} │`);
74
- write("│ Whoever can read this log already owns the server. │");
75
- write("└─────────────────────────────────────────────────────────┘");
76
- write("");
71
+ write(formatClaimNote(state.code));
77
72
  }
78
73
 
79
74
  /** Result of {@link verifyClaimCode}. */
@@ -1771,6 +1771,7 @@ function createSetupClaim(state: ConsoleState) {
1771
1771
  name: input.name,
1772
1772
  password: input.password,
1773
1773
  });
1774
+ state.persistOperator(op.id);
1774
1775
  const issued = await issueOperatorSession(state, op.id);
1775
1776
  fx.log.info("console.setup.claim", { operatorId: op.id });
1776
1777
  return sessionPayload(op.id, op.email, op.name, issued);
@@ -71,6 +71,11 @@ export {
71
71
  type ClaimCodeState,
72
72
  type ClaimVerifyResult,
73
73
  } from "./claim.ts";
74
+ export {
75
+ openConsolePersistence,
76
+ resolveConsoleSecret,
77
+ type ConsolePersistence,
78
+ } from "./operator-db.ts";
74
79
  export {
75
80
  createConsoleBindings,
76
81
  } from "./flows.ts";
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Durable Console operators under `.oke/console.sqlite`.
3
+ */
4
+
5
+ import { afterEach, describe, expect, test } from "bun:test";
6
+ import { mkdtemp, rm } from "node:fs/promises";
7
+ import { tmpdir } from "node:os";
8
+ import { join } from "node:path";
9
+ import { createOperator } from "../../auth/operator.ts";
10
+ import { createConsoleApp } from "./app.ts";
11
+ import {
12
+ openConsolePersistence,
13
+ resolveConsoleSecret,
14
+ } from "./operator-db.ts";
15
+
16
+ describe("console operator persistence", () => {
17
+ const dirs: string[] = [];
18
+
19
+ afterEach(async () => {
20
+ await Promise.all(
21
+ dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })),
22
+ );
23
+ });
24
+
25
+ test("secret is stable across resolveConsoleSecret calls", async () => {
26
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-secret-"));
27
+ dirs.push(cwd);
28
+ const a = await resolveConsoleSecret(cwd);
29
+ const b = await resolveConsoleSecret(cwd);
30
+ expect(a).toBe(b);
31
+ expect(a.length).toBeGreaterThan(16);
32
+ });
33
+
34
+ test("claim persists and second boot skips claim print", async () => {
35
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-ops-"));
36
+ dirs.push(cwd);
37
+
38
+ const first = await openConsolePersistence(cwd);
39
+ expect(first.operators.operators.size).toBe(0);
40
+ const op = await createOperator(first.operators, {
41
+ email: "ops@example.com",
42
+ name: "Ops",
43
+ password: "password123",
44
+ });
45
+ first.persistOperator(op.id);
46
+ first.close();
47
+
48
+ const second = await openConsolePersistence(cwd);
49
+ expect(second.operators.operators.size).toBe(1);
50
+ expect(second.operators.operators.get(op.id)?.email).toBe(
51
+ "ops@example.com",
52
+ );
53
+ expect(second.operators.credentials.has(op.id)).toBe(true);
54
+
55
+ const printed: string[] = [];
56
+ const origLog = console.log;
57
+ console.log = (line?: unknown) => {
58
+ printed.push(String(line ?? ""));
59
+ };
60
+ try {
61
+ const app = createConsoleApp({
62
+ cwd,
63
+ secret: second.secret,
64
+ operators: second.operators,
65
+ persistOperator: second.persistOperator,
66
+ silentClaim: false,
67
+ });
68
+ expect(app.state.setupClosed).toBe(true);
69
+ expect(printed.join("\n")).not.toContain("Claim code");
70
+ } finally {
71
+ console.log = origLog;
72
+ second.close();
73
+ }
74
+ });
75
+ });
@@ -0,0 +1,324 @@
1
+ /**
2
+ * Durable operator plane for Console — `.oke/console.sqlite` + secret file.
3
+ *
4
+ * Spec: wizard closes permanently once the first operator exists (console §2.5).
5
+ * Claim codes stay ephemeral; operators and the signing secret must survive restarts.
6
+ */
7
+
8
+ import { mkdirSync } from "node:fs";
9
+ import { join } from "node:path";
10
+ import { Database } from "bun:sqlite";
11
+ import {
12
+ createOperatorStore,
13
+ type OperatorStore,
14
+ } from "../../auth/operator.ts";
15
+ import type {
16
+ OperatorCredentialRow,
17
+ OperatorRow,
18
+ OperatorSsoLinkRow,
19
+ } from "../../auth/tables.ts";
20
+ import { AUTH_TABLES } from "../../auth/tables.ts";
21
+
22
+ /** Relative paths under project cwd. */
23
+ export const CONSOLE_OKE_DIR = ".oke";
24
+ export const CONSOLE_SQLITE_NAME = "console.sqlite";
25
+ export const CONSOLE_SECRET_NAME = "console.secret";
26
+
27
+ /** Opened Console persistence handle. */
28
+ export interface ConsolePersistence {
29
+ /** Stable signing secret. */
30
+ readonly secret: string;
31
+ /** Hydrated operator Maps. */
32
+ readonly operators: OperatorStore;
33
+ /** Persist (or update) one operator + credential + roles/sso. */
34
+ readonly persistOperator: (operatorId: string) => void;
35
+ /** Close the SQLite connection. */
36
+ readonly close: () => void;
37
+ }
38
+
39
+ /**
40
+ * Ensure `.oke/` exists and return absolute paths.
41
+ *
42
+ * @param cwd - Project root
43
+ */
44
+ export function consoleOkePaths(cwd: string): {
45
+ readonly dir: string;
46
+ readonly sqlite: string;
47
+ readonly secret: string;
48
+ } {
49
+ const dir = join(cwd, CONSOLE_OKE_DIR);
50
+ mkdirSync(dir, { recursive: true });
51
+ return {
52
+ dir,
53
+ sqlite: join(dir, CONSOLE_SQLITE_NAME),
54
+ secret: join(dir, CONSOLE_SECRET_NAME),
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Load or create a stable Console signing secret under `.oke/console.secret`.
60
+ *
61
+ * @param cwd - Project root
62
+ * @param envSecret - Optional `OKE_CONSOLE_SECRET` override
63
+ */
64
+ export async function resolveConsoleSecret(
65
+ cwd: string,
66
+ envSecret?: string,
67
+ ): Promise<string> {
68
+ if (envSecret !== undefined && envSecret.length > 0) return envSecret;
69
+ const paths = consoleOkePaths(cwd);
70
+ const file = Bun.file(paths.secret);
71
+ if (await file.exists()) {
72
+ const existing = (await file.text()).trim();
73
+ if (existing.length > 0) return existing;
74
+ }
75
+ const bytes = new Uint8Array(32);
76
+ crypto.getRandomValues(bytes);
77
+ const secret = [...bytes]
78
+ .map((b) => b.toString(16).padStart(2, "0"))
79
+ .join("");
80
+ await Bun.write(paths.secret, `${secret}\n`);
81
+ return secret;
82
+ }
83
+
84
+ /**
85
+ * Open Console operator DB, migrate schema, hydrate Maps.
86
+ *
87
+ * @param cwd - Project root
88
+ * @param options - Optional env secret override
89
+ */
90
+ export async function openConsolePersistence(
91
+ cwd: string,
92
+ options: { readonly envSecret?: string } = {},
93
+ ): Promise<ConsolePersistence> {
94
+ const paths = consoleOkePaths(cwd);
95
+ const secret = await resolveConsoleSecret(cwd, options.envSecret);
96
+ const db = new Database(paths.sqlite, { create: true });
97
+ db.exec("PRAGMA journal_mode = WAL;");
98
+ migrateOperatorSchema(db);
99
+ const operators = loadOperatorStore(db);
100
+
101
+ return {
102
+ secret,
103
+ operators,
104
+ persistOperator(operatorId: string) {
105
+ persistOperator(db, operators, operatorId);
106
+ },
107
+ close() {
108
+ db.close();
109
+ },
110
+ };
111
+ }
112
+
113
+ /**
114
+ * Create operator tables if missing.
115
+ *
116
+ * @param db - bun:sqlite database
117
+ */
118
+ export function migrateOperatorSchema(db: Database): void {
119
+ db.exec(`
120
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operators} (
121
+ id TEXT PRIMARY KEY NOT NULL,
122
+ email TEXT NOT NULL UNIQUE,
123
+ name TEXT NOT NULL,
124
+ status TEXT NOT NULL,
125
+ mfa_enabled INTEGER NOT NULL DEFAULT 0,
126
+ invited_by TEXT,
127
+ last_seen_at INTEGER
128
+ );
129
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorCredentials} (
130
+ operator_id TEXT PRIMARY KEY NOT NULL,
131
+ password_hash TEXT NOT NULL,
132
+ login_enabled INTEGER NOT NULL DEFAULT 1,
133
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
134
+ );
135
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorSsoLinks} (
136
+ operator_id TEXT NOT NULL,
137
+ provider TEXT NOT NULL,
138
+ subject TEXT NOT NULL,
139
+ PRIMARY KEY (operator_id, provider, subject),
140
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
141
+ );
142
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorRoles} (
143
+ operator_id TEXT NOT NULL,
144
+ role TEXT NOT NULL,
145
+ PRIMARY KEY (operator_id, role),
146
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
147
+ );
148
+ `);
149
+ }
150
+
151
+ /**
152
+ * Hydrate an {@link OperatorStore} from SQLite.
153
+ *
154
+ * @param db - Open database
155
+ */
156
+ export function loadOperatorStore(db: Database): OperatorStore {
157
+ const store = createOperatorStore();
158
+
159
+ const opRows = db
160
+ .query(
161
+ `SELECT id, email, name, status, mfa_enabled, invited_by, last_seen_at
162
+ FROM ${AUTH_TABLES.operators}`,
163
+ )
164
+ .all() as Array<{
165
+ id: string;
166
+ email: string;
167
+ name: string;
168
+ status: OperatorRow["status"];
169
+ mfa_enabled: number;
170
+ invited_by: string | null;
171
+ last_seen_at: number | null;
172
+ }>;
173
+
174
+ for (const row of opRows) {
175
+ store.operators.set(row.id, {
176
+ id: row.id,
177
+ email: row.email,
178
+ name: row.name,
179
+ status: row.status,
180
+ mfaEnabled: row.mfa_enabled === 1,
181
+ invitedBy: row.invited_by,
182
+ lastSeenAt: row.last_seen_at,
183
+ });
184
+ }
185
+
186
+ const credRows = db
187
+ .query(
188
+ `SELECT operator_id, password_hash, login_enabled
189
+ FROM ${AUTH_TABLES.operatorCredentials}`,
190
+ )
191
+ .all() as Array<{
192
+ operator_id: string;
193
+ password_hash: string;
194
+ login_enabled: number;
195
+ }>;
196
+
197
+ for (const row of credRows) {
198
+ const cred: OperatorCredentialRow = {
199
+ operatorId: row.operator_id,
200
+ passwordHash: row.password_hash,
201
+ loginEnabled: row.login_enabled === 1,
202
+ };
203
+ store.credentials.set(row.operator_id, cred);
204
+ }
205
+
206
+ const ssoRows = db
207
+ .query(
208
+ `SELECT operator_id, provider, subject FROM ${AUTH_TABLES.operatorSsoLinks}`,
209
+ )
210
+ .all() as Array<{
211
+ operator_id: string;
212
+ provider: string;
213
+ subject: string;
214
+ }>;
215
+
216
+ for (const row of ssoRows) {
217
+ const link: OperatorSsoLinkRow = {
218
+ operatorId: row.operator_id,
219
+ provider: row.provider,
220
+ subject: row.subject,
221
+ };
222
+ const list = store.ssoLinks.get(row.operator_id) ?? [];
223
+ list.push(link);
224
+ store.ssoLinks.set(row.operator_id, list);
225
+ }
226
+
227
+ const roleRows = db
228
+ .query(
229
+ `SELECT operator_id, role FROM ${AUTH_TABLES.operatorRoles}`,
230
+ )
231
+ .all() as Array<{ operator_id: string; role: string }>;
232
+
233
+ for (const row of roleRows) {
234
+ const list = store.roles.get(row.operator_id) ?? [];
235
+ list.push(row.role);
236
+ store.roles.set(row.operator_id, list);
237
+ }
238
+
239
+ return store;
240
+ }
241
+
242
+ /**
243
+ * Upsert one operator (and related rows) from the in-memory store.
244
+ *
245
+ * @param db - Open database
246
+ * @param store - In-memory store
247
+ * @param operatorId - Operator id
248
+ */
249
+ export function persistOperator(
250
+ db: Database,
251
+ store: OperatorStore,
252
+ operatorId: string,
253
+ ): void {
254
+ const op = store.operators.get(operatorId);
255
+ if (!op) {
256
+ throw new Error(`oke console: unknown operator ${operatorId}`);
257
+ }
258
+ const cred = store.credentials.get(operatorId);
259
+ if (!cred) {
260
+ throw new Error(`oke console: missing credential for ${operatorId}`);
261
+ }
262
+
263
+ const upsertOp = db.query(
264
+ `INSERT INTO ${AUTH_TABLES.operators}
265
+ (id, email, name, status, mfa_enabled, invited_by, last_seen_at)
266
+ VALUES ($id, $email, $name, $status, $mfa, $invited, $seen)
267
+ ON CONFLICT(id) DO UPDATE SET
268
+ email = excluded.email,
269
+ name = excluded.name,
270
+ status = excluded.status,
271
+ mfa_enabled = excluded.mfa_enabled,
272
+ invited_by = excluded.invited_by,
273
+ last_seen_at = excluded.last_seen_at`,
274
+ );
275
+ upsertOp.run({
276
+ $id: op.id,
277
+ $email: op.email,
278
+ $name: op.name,
279
+ $status: op.status,
280
+ $mfa: op.mfaEnabled ? 1 : 0,
281
+ $invited: op.invitedBy,
282
+ $seen: op.lastSeenAt,
283
+ });
284
+
285
+ const upsertCred = db.query(
286
+ `INSERT INTO ${AUTH_TABLES.operatorCredentials}
287
+ (operator_id, password_hash, login_enabled)
288
+ VALUES ($id, $hash, $enabled)
289
+ ON CONFLICT(operator_id) DO UPDATE SET
290
+ password_hash = excluded.password_hash,
291
+ login_enabled = excluded.login_enabled`,
292
+ );
293
+ upsertCred.run({
294
+ $id: cred.operatorId,
295
+ $hash: cred.passwordHash,
296
+ $enabled: cred.loginEnabled ? 1 : 0,
297
+ });
298
+
299
+ db.query(
300
+ `DELETE FROM ${AUTH_TABLES.operatorSsoLinks} WHERE operator_id = $id`,
301
+ ).run({ $id: operatorId });
302
+ const insertSso = db.query(
303
+ `INSERT INTO ${AUTH_TABLES.operatorSsoLinks} (operator_id, provider, subject)
304
+ VALUES ($id, $provider, $subject)`,
305
+ );
306
+ for (const link of store.ssoLinks.get(operatorId) ?? []) {
307
+ insertSso.run({
308
+ $id: link.operatorId,
309
+ $provider: link.provider,
310
+ $subject: link.subject,
311
+ });
312
+ }
313
+
314
+ db.query(
315
+ `DELETE FROM ${AUTH_TABLES.operatorRoles} WHERE operator_id = $id`,
316
+ ).run({ $id: operatorId });
317
+ const insertRole = db.query(
318
+ `INSERT INTO ${AUTH_TABLES.operatorRoles} (operator_id, role)
319
+ VALUES ($id, $role)`,
320
+ );
321
+ for (const role of store.roles.get(operatorId) ?? []) {
322
+ insertRole.run({ $id: operatorId, $role: role });
323
+ }
324
+ }
@@ -22,6 +22,7 @@ import {
22
22
  type CreateConsoleAppOptions,
23
23
  } from "./app.ts";
24
24
  import { createLiveWebsocket, type ConsoleLiveData } from "./live.ts";
25
+ import { openConsolePersistence } from "./operator-db.ts";
25
26
  import {
26
27
  CONSOLE_COOKIES,
27
28
  consoleSessionCookie,
@@ -37,6 +38,11 @@ export interface ServeConsoleOptions extends CreateConsoleAppOptions {
37
38
  readonly staticDir?: string;
38
39
  /** Boot environment — use `"dev"` / `"prod"` so Bearer auth is production-like. */
39
40
  readonly env?: "dev" | "prod" | "test";
41
+ /**
42
+ * Persist operators + secret under `.oke/` (default true).
43
+ * Set false for ephemeral test servers.
44
+ */
45
+ readonly persist?: boolean;
40
46
  }
41
47
 
42
48
  /** Running Console server handle. */
@@ -54,9 +60,24 @@ export async function serveConsole(
54
60
  ): Promise<ConsoleServerHandle> {
55
61
  const hostname = options.hostname ?? "127.0.0.1";
56
62
  const port = options.port ?? CONSOLE_PORT;
63
+ const cwd = options.cwd ?? process.cwd();
64
+ const wantPersist = options.persist !== false && options.operators === undefined;
65
+ const persistence = wantPersist
66
+ ? await openConsolePersistence(cwd, {
67
+ envSecret: options.secret ?? process.env.OKE_CONSOLE_SECRET,
68
+ })
69
+ : null;
57
70
  const handle = createConsoleApp({
58
71
  ...options,
72
+ cwd,
59
73
  silentClaim: options.silentClaim ?? false,
74
+ ...(persistence
75
+ ? {
76
+ secret: options.secret ?? persistence.secret,
77
+ operators: persistence.operators,
78
+ persistOperator: persistence.persistOperator,
79
+ }
80
+ : {}),
60
81
  });
61
82
 
62
83
  const env = options.env ?? "dev";
@@ -160,6 +181,7 @@ export async function serveConsole(
160
181
  stop(closeActive = false) {
161
182
  server.stop(closeActive);
162
183
  void handle.app.stop();
184
+ persistence?.close();
163
185
  },
164
186
  };
165
187
  }
@@ -172,11 +194,28 @@ export async function serveConsole(
172
194
  export async function startConsoleApp(
173
195
  options: CreateConsoleAppOptions & {
174
196
  readonly env?: "dev" | "prod" | "test";
197
+ /** Opt-in durable operators under `.oke/` (default false for unit tests). */
198
+ readonly persist?: boolean;
175
199
  } = {},
176
200
  ): Promise<ConsoleAppHandle> {
201
+ const cwd = options.cwd ?? process.cwd();
202
+ const persistence =
203
+ options.persist === true && options.operators === undefined
204
+ ? await openConsolePersistence(cwd, {
205
+ envSecret: options.secret ?? process.env.OKE_CONSOLE_SECRET,
206
+ })
207
+ : null;
177
208
  const handle = createConsoleApp({
178
209
  ...options,
210
+ cwd,
179
211
  silentClaim: options.silentClaim ?? true,
212
+ ...(persistence
213
+ ? {
214
+ secret: options.secret ?? persistence.secret,
215
+ operators: persistence.operators,
216
+ persistOperator: persistence.persistOperator,
217
+ }
218
+ : {}),
180
219
  });
181
220
  await bootConsoleApp(handle);
182
221
  return handle;
@@ -345,6 +345,11 @@ export interface ConsoleState {
345
345
  }>;
346
346
  /** Whether first operator exists (wizard permanently closed). */
347
347
  get setupClosed(): boolean;
348
+ /**
349
+ * Persist an operator after claim/create (SQLite under `.oke/`).
350
+ * No-op when persistence is disabled (tests / memory-only).
351
+ */
352
+ persistOperator: (operatorId: string) => void;
348
353
  /**
349
354
  * Per-email login attempt timestamps for credential-check rate limiting
350
355
  * (console §10.4 — same 5/60s strategy as setup-claim).
@@ -398,6 +403,10 @@ export interface CreateConsoleStateOptions {
398
403
  readonly okeConfig?: OkeConfig | null;
399
404
  /** Host plugin registry for scopes / capabilities. */
400
405
  readonly pluginRegistry?: PluginRegistry | null;
406
+ /** Pre-hydrated operator store (from `.oke/console.sqlite`). */
407
+ readonly operators?: OperatorStore;
408
+ /** Persist hook after claim/create. */
409
+ readonly persistOperator?: (operatorId: string) => void;
401
410
  }
402
411
 
403
412
  /**
@@ -413,10 +422,12 @@ export function createConsoleState(
413
422
  process.env.OKE_CONSOLE_SECRET ??
414
423
  `oke-console-dev-${crypto.randomUUID()}`;
415
424
  const now = options.now ?? (() => Date.now());
425
+ const operators = options.operators ?? createOperatorStore();
416
426
  const claim = mintClaimCode(now);
417
- const operators = createOperatorStore();
418
427
  const sessions = createSessionStore();
419
428
  const liveSubscribers = new Set<(msg: ConsoleLiveMessage) => void>();
429
+ const persistOperator =
430
+ options.persistOperator ?? ((_operatorId: string) => {});
420
431
 
421
432
  const signalConfig = createMemorySignalConfigStore();
422
433
  const gateAuth = createDefaultGateAuthStores();
@@ -843,6 +854,7 @@ export function createConsoleState(
843
854
  get setupClosed() {
844
855
  return operators.operators.size > 0;
845
856
  },
857
+ persistOperator,
846
858
  };
847
859
 
848
860
  return state;