okengine 0.2.2 → 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 (39) hide show
  1. package/package.json +1 -1
  2. package/src/cli/dev.ts +68 -6
  3. package/src/cli/doctor.ts +3 -18
  4. package/src/cli/index.ts +4 -1
  5. package/src/cli/json-out.test.ts +1 -1
  6. package/src/cli/load-config.images.test.ts +75 -0
  7. package/src/cli/load-config.ts +67 -2
  8. package/src/cli/ports.test.ts +51 -0
  9. package/src/cli/ports.ts +81 -0
  10. package/src/cli/registry.help.test.ts +18 -0
  11. package/src/cli/registry.ts +0 -4
  12. package/src/cli/safe-defaults.test.ts +3 -3
  13. package/src/console/server/app.ts +2 -1
  14. package/src/console/server/flows.ts +1 -0
  15. package/src/console/server/index.ts +5 -0
  16. package/src/console/server/operator-db.test.ts +75 -0
  17. package/src/console/server/operator-db.ts +324 -0
  18. package/src/console/server/serve.ts +39 -0
  19. package/src/console/server/state.ts +13 -1
  20. package/src/console/ui/dist/assets/{index-B71Yl_SS.js → index-Bnf_3Hei.js} +3 -3
  21. package/src/console/ui/dist/assets/{panel-overview-Bd48d9km.js → panel-overview-Dt_AeXgd.js} +1 -1
  22. package/src/console/ui/dist/assets/{panel-runs-BwsWqKeB.js → panel-runs-DGstFHeq.js} +1 -1
  23. package/src/console/ui/dist/assets/{panel-signals-9najbZY2.js → panel-signals-DzEa2Fnt.js} +1 -1
  24. package/src/console/ui/dist/assets/{panel-store-OHkP2pDp.js → panel-store-BJkbNgxx.js} +1 -1
  25. package/src/console/ui/dist/assets/{panel-traces-tn2JoY8U.js → panel-traces-BaRVO3gM.js} +1 -1
  26. package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
  27. package/src/console/ui/dist/favicon.svg +7 -0
  28. package/src/console/ui/dist/index.html +3 -2
  29. package/src/console/ui/shell/App.tsx +34 -2
  30. package/src/console/ui/shell/components/oke-logo.tsx +40 -0
  31. package/src/console/ui/shell/index.html +1 -0
  32. package/src/console/ui/shell/layout/Shell.tsx +2 -3
  33. package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
  34. package/src/console/ui/shell/public/favicon.svg +7 -0
  35. package/src/console/ui/shell/setup/Wizard.tsx +5 -2
  36. package/src/docker/derive.ts +3 -1
  37. package/src/docker/dockerfile.integration.test.ts +1 -1
  38. package/src/docker/stack.integration.test.ts +1 -1
  39. package/src/console/ui/dist/assets/style-Cnl7WLya.css +0 -3
@@ -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;