okengine 0.2.2 → 0.2.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.
Files changed (66) hide show
  1. package/README.md +30 -4
  2. package/docs/spec/example.md +12 -3
  3. package/docs/spec/four-applications.md +10 -3
  4. package/package.json +9 -40
  5. package/src/cli/dev-app-runner.ts +45 -6
  6. package/src/cli/dev.ts +204 -23
  7. package/src/cli/docker-cli.test.ts +10 -8
  8. package/src/cli/docker.ts +18 -7
  9. package/src/cli/doctor.ts +3 -18
  10. package/src/cli/hero-meta.test.ts +85 -0
  11. package/src/cli/hero-meta.ts +252 -0
  12. package/src/cli/index.ts +4 -1
  13. package/src/cli/json-out.test.ts +1 -1
  14. package/src/cli/load-config.images.test.ts +126 -0
  15. package/src/cli/load-config.ts +145 -2
  16. package/src/cli/ports.test.ts +51 -0
  17. package/src/cli/ports.ts +81 -0
  18. package/src/cli/registry.help.test.ts +18 -0
  19. package/src/cli/registry.ts +0 -4
  20. package/src/cli/safe-defaults.test.ts +3 -3
  21. package/src/config/define-config.test.ts +61 -0
  22. package/src/config/index.ts +89 -6
  23. package/src/config/resolve-driver.test.ts +30 -0
  24. package/src/console/server/app.ts +2 -1
  25. package/src/console/server/flows.ts +4 -1
  26. package/src/console/server/index.ts +5 -0
  27. package/src/console/server/operator-db.test.ts +213 -0
  28. package/src/console/server/operator-db.ts +470 -0
  29. package/src/console/server/plugin.ts +20 -0
  30. package/src/console/server/serve.ts +47 -1
  31. package/src/console/server/state.ts +25 -2
  32. package/src/console/ui/dist/assets/{index-B71Yl_SS.js → index-Dy4jht9P.js} +3 -3
  33. package/src/console/ui/dist/assets/{panel-overview-Bd48d9km.js → panel-overview-Dt_AeXgd.js} +1 -1
  34. package/src/console/ui/dist/assets/{panel-runs-BwsWqKeB.js → panel-runs-DGstFHeq.js} +1 -1
  35. package/src/console/ui/dist/assets/{panel-signals-9najbZY2.js → panel-signals-DzEa2Fnt.js} +1 -1
  36. package/src/console/ui/dist/assets/{panel-store-OHkP2pDp.js → panel-store-BJkbNgxx.js} +1 -1
  37. package/src/console/ui/dist/assets/{panel-traces-tn2JoY8U.js → panel-traces-BaRVO3gM.js} +1 -1
  38. package/src/console/ui/dist/assets/style-C8MxEWPd.css +3 -0
  39. package/src/console/ui/dist/favicon.svg +7 -0
  40. package/src/console/ui/dist/index.html +3 -2
  41. package/src/console/ui/shell/App.tsx +44 -4
  42. package/src/console/ui/shell/components/oke-logo.tsx +40 -0
  43. package/src/console/ui/shell/index.html +1 -0
  44. package/src/console/ui/shell/layout/Shell.tsx +2 -3
  45. package/src/console/ui/shell/panels/overview/OverviewPanel.tsx +8 -0
  46. package/src/console/ui/shell/public/favicon.svg +7 -0
  47. package/src/console/ui/shell/setup/Wizard.tsx +5 -2
  48. package/src/docker/compose.ts +45 -14
  49. package/src/docker/derive.ts +32 -10
  50. package/src/docker/docker.test.ts +28 -4
  51. package/src/docker/dockerfile.integration.test.ts +3 -1
  52. package/src/docker/index.ts +10 -0
  53. package/src/docker/stack-id.test.ts +86 -0
  54. package/src/docker/stack-id.ts +108 -0
  55. package/src/docker/stack.integration.test.ts +17 -8
  56. package/src/docker/types.ts +20 -1
  57. package/src/kernel/app.ts +39 -8
  58. package/src/kernel/boot-bind/store.test.ts +60 -0
  59. package/src/kernel/boot-bind/store.ts +142 -12
  60. package/src/kernel/boot.ts +28 -4
  61. package/src/mcp/server.ts +99 -57
  62. package/src/runtime/dev-request-log.test.ts +33 -0
  63. package/src/runtime/dev-request-log.ts +130 -0
  64. package/src/term.test.ts +98 -5
  65. package/src/term.ts +287 -6
  66. package/src/console/ui/dist/assets/style-Cnl7WLya.css +0 -3
@@ -0,0 +1,213 @@
1
+ /**
2
+ * Durable Console operators + sessions 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 { issueSession, verifyAccess } from "../../auth/sessions.ts";
11
+ import { bootConsoleApp, createConsoleApp } from "./app.ts";
12
+ import {
13
+ openConsolePersistence,
14
+ resolveConsoleSecret,
15
+ } from "./operator-db.ts";
16
+
17
+ describe("console operator persistence", () => {
18
+ const dirs: string[] = [];
19
+
20
+ afterEach(async () => {
21
+ await Promise.all(
22
+ dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })),
23
+ );
24
+ });
25
+
26
+ test("secret is stable across resolveConsoleSecret calls", async () => {
27
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-secret-"));
28
+ dirs.push(cwd);
29
+ const a = await resolveConsoleSecret(cwd);
30
+ const b = await resolveConsoleSecret(cwd);
31
+ expect(a).toBe(b);
32
+ expect(a.length).toBeGreaterThan(16);
33
+ });
34
+
35
+ test("claim persists and second boot skips claim print", async () => {
36
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-ops-"));
37
+ dirs.push(cwd);
38
+
39
+ const first = await openConsolePersistence(cwd);
40
+ expect(first.operators.operators.size).toBe(0);
41
+ const op = await createOperator(first.operators, {
42
+ email: "ops@example.com",
43
+ name: "Ops",
44
+ password: "password123",
45
+ });
46
+ first.persistOperator(op.id);
47
+ first.close();
48
+
49
+ const second = await openConsolePersistence(cwd);
50
+ expect(second.operators.operators.size).toBe(1);
51
+ expect(second.operators.operators.get(op.id)?.email).toBe(
52
+ "ops@example.com",
53
+ );
54
+ expect(second.operators.credentials.has(op.id)).toBe(true);
55
+
56
+ const printed: string[] = [];
57
+ const origLog = console.log;
58
+ console.log = (line?: unknown) => {
59
+ printed.push(String(line ?? ""));
60
+ };
61
+ try {
62
+ const app = createConsoleApp({
63
+ cwd,
64
+ secret: second.secret,
65
+ operators: second.operators,
66
+ sessions: second.sessions,
67
+ persistOperator: second.persistOperator,
68
+ persistSessions: second.persistSessions,
69
+ silentClaim: false,
70
+ });
71
+ expect(app.state.setupClosed).toBe(true);
72
+ expect(printed.join("\n")).not.toContain("Claim code");
73
+ } finally {
74
+ console.log = origLog;
75
+ second.close();
76
+ }
77
+ });
78
+
79
+ test("sessions survive reopen so access tokens still verify", async () => {
80
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-sess-"));
81
+ dirs.push(cwd);
82
+
83
+ const first = await openConsolePersistence(cwd);
84
+ const op = await createOperator(first.operators, {
85
+ email: "ops@example.com",
86
+ name: "Ops",
87
+ password: "password123",
88
+ });
89
+ first.persistOperator(op.id);
90
+ const issued = await issueSession(
91
+ first.sessions,
92
+ { secret: first.secret },
93
+ { id: op.id, plane: "operator", scopes: ["console:*"] },
94
+ );
95
+ first.persistSessions();
96
+ first.close();
97
+
98
+ const second = await openConsolePersistence(cwd);
99
+ expect(second.sessions.sessions.size).toBe(1);
100
+ const claims = await verifyAccess(
101
+ second.sessions,
102
+ second.secret,
103
+ issued.accessToken,
104
+ );
105
+ expect(claims.sub).toBe(op.id);
106
+ second.close();
107
+ });
108
+
109
+ test("claim then reopen keeps session.me authorized", async () => {
110
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-roundtrip-"));
111
+ dirs.push(cwd);
112
+
113
+ const firstPersist = await openConsolePersistence(cwd);
114
+ const first = createConsoleApp({
115
+ cwd,
116
+ secret: firstPersist.secret,
117
+ operators: firstPersist.operators,
118
+ sessions: firstPersist.sessions,
119
+ persistOperator: firstPersist.persistOperator,
120
+ persistSessions: firstPersist.persistSessions,
121
+ silentClaim: true,
122
+ });
123
+ await bootConsoleApp(first);
124
+ let accessToken = "";
125
+ try {
126
+ const claimRes = await first.app.fetch(
127
+ new Request("http://console.test/console/setup/claim", {
128
+ method: "POST",
129
+ headers: { "content-type": "application/json" },
130
+ body: JSON.stringify({
131
+ claimCode: first.state.claim.code,
132
+ email: "ops@example.com",
133
+ name: "Ops",
134
+ password: "password123",
135
+ }),
136
+ }),
137
+ );
138
+ expect(claimRes.status).toBe(200);
139
+ const body = (await claimRes.json()) as {
140
+ data: { accessToken: string };
141
+ };
142
+ accessToken = body.data.accessToken;
143
+ } finally {
144
+ await first.app.stop();
145
+ firstPersist.close();
146
+ }
147
+
148
+ const secondPersist = await openConsolePersistence(cwd);
149
+ const second = createConsoleApp({
150
+ cwd,
151
+ secret: secondPersist.secret,
152
+ operators: secondPersist.operators,
153
+ sessions: secondPersist.sessions,
154
+ persistOperator: secondPersist.persistOperator,
155
+ persistSessions: secondPersist.persistSessions,
156
+ silentClaim: true,
157
+ });
158
+ await bootConsoleApp(second);
159
+ try {
160
+ const me = await second.app.fetch(
161
+ new Request("http://console.test/console/session/me", {
162
+ headers: { authorization: `Bearer ${accessToken}` },
163
+ }),
164
+ );
165
+ expect(me.status).toBe(200);
166
+ const meBody = (await me.json()) as {
167
+ data: { email: string };
168
+ };
169
+ expect(meBody.data.email).toBe("ops@example.com");
170
+ } finally {
171
+ await second.app.stop();
172
+ secondPersist.close();
173
+ }
174
+ });
175
+
176
+ test("stale Bearer on setup.status does not 401", async () => {
177
+ const cwd = await mkdtemp(join(tmpdir(), "oke-console-stale-"));
178
+ dirs.push(cwd);
179
+ const persistence = await openConsolePersistence(cwd);
180
+ const op = await createOperator(persistence.operators, {
181
+ email: "ops@example.com",
182
+ name: "Ops",
183
+ password: "password123",
184
+ });
185
+ persistence.persistOperator(op.id);
186
+
187
+ const handle = createConsoleApp({
188
+ cwd,
189
+ secret: persistence.secret,
190
+ operators: persistence.operators,
191
+ // Empty sessions — token cannot verify; public flow must still succeed.
192
+ persistOperator: persistence.persistOperator,
193
+ persistSessions: persistence.persistSessions,
194
+ silentClaim: true,
195
+ });
196
+ await bootConsoleApp(handle);
197
+ try {
198
+ const res = await handle.app.fetch(
199
+ new Request("http://console.test/console/setup/status", {
200
+ headers: { authorization: "Bearer totally-forged-token" },
201
+ }),
202
+ );
203
+ expect(res.status).toBe(200);
204
+ const body = (await res.json()) as {
205
+ data: { setupClosed: boolean };
206
+ };
207
+ expect(body.data.setupClosed).toBe(true);
208
+ } finally {
209
+ await handle.app.stop();
210
+ persistence.close();
211
+ }
212
+ });
213
+ });
@@ -0,0 +1,470 @@
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, sessions, and the signing secret must
6
+ * survive restarts.
7
+ */
8
+
9
+ import { mkdirSync } from "node:fs";
10
+ import { join } from "node:path";
11
+ import { Database } from "bun:sqlite";
12
+ import {
13
+ createOperatorStore,
14
+ type OperatorStore,
15
+ } from "../../auth/operator.ts";
16
+ import {
17
+ createSessionStore,
18
+ type SessionStore,
19
+ } from "../../auth/sessions.ts";
20
+ import type {
21
+ OperatorCredentialRow,
22
+ OperatorRow,
23
+ OperatorSsoLinkRow,
24
+ RefreshTokenRow,
25
+ SessionRow,
26
+ } from "../../auth/tables.ts";
27
+ import { AUTH_TABLES } from "../../auth/tables.ts";
28
+
29
+ /** Relative paths under project cwd. */
30
+ export const CONSOLE_OKE_DIR = ".oke";
31
+ export const CONSOLE_SQLITE_NAME = "console.sqlite";
32
+ export const CONSOLE_SECRET_NAME = "console.secret";
33
+
34
+ /** Opened Console persistence handle. */
35
+ export interface ConsolePersistence {
36
+ /** Stable signing secret. */
37
+ readonly secret: string;
38
+ /** Hydrated operator Maps. */
39
+ readonly operators: OperatorStore;
40
+ /** Hydrated session + refresh Maps. */
41
+ readonly sessions: SessionStore;
42
+ /** Persist (or update) one operator + credential + roles/sso. */
43
+ readonly persistOperator: (operatorId: string) => void;
44
+ /** Persist the full session store (issue / revoke). */
45
+ readonly persistSessions: () => void;
46
+ /** Close the SQLite connection. */
47
+ readonly close: () => void;
48
+ }
49
+
50
+ /**
51
+ * Ensure `.oke/` exists and return absolute paths.
52
+ *
53
+ * @param cwd - Project root
54
+ */
55
+ export function consoleOkePaths(cwd: string): {
56
+ readonly dir: string;
57
+ readonly sqlite: string;
58
+ readonly secret: string;
59
+ } {
60
+ const dir = join(cwd, CONSOLE_OKE_DIR);
61
+ mkdirSync(dir, { recursive: true });
62
+ return {
63
+ dir,
64
+ sqlite: join(dir, CONSOLE_SQLITE_NAME),
65
+ secret: join(dir, CONSOLE_SECRET_NAME),
66
+ };
67
+ }
68
+
69
+ /**
70
+ * Load or create a stable Console signing secret under `.oke/console.secret`.
71
+ *
72
+ * @param cwd - Project root
73
+ * @param envSecret - Optional `OKE_CONSOLE_SECRET` override
74
+ */
75
+ export async function resolveConsoleSecret(
76
+ cwd: string,
77
+ envSecret?: string,
78
+ ): Promise<string> {
79
+ if (envSecret !== undefined && envSecret.length > 0) return envSecret;
80
+ const paths = consoleOkePaths(cwd);
81
+ const file = Bun.file(paths.secret);
82
+ if (await file.exists()) {
83
+ const existing = (await file.text()).trim();
84
+ if (existing.length > 0) return existing;
85
+ }
86
+ const bytes = new Uint8Array(32);
87
+ crypto.getRandomValues(bytes);
88
+ const secret = [...bytes]
89
+ .map((b) => b.toString(16).padStart(2, "0"))
90
+ .join("");
91
+ await Bun.write(paths.secret, `${secret}\n`);
92
+ return secret;
93
+ }
94
+
95
+ /**
96
+ * Open Console operator DB, migrate schema, hydrate Maps.
97
+ *
98
+ * @param cwd - Project root
99
+ * @param options - Optional env secret override
100
+ */
101
+ export async function openConsolePersistence(
102
+ cwd: string,
103
+ options: { readonly envSecret?: string } = {},
104
+ ): Promise<ConsolePersistence> {
105
+ const paths = consoleOkePaths(cwd);
106
+ const secret = await resolveConsoleSecret(cwd, options.envSecret);
107
+ const db = new Database(paths.sqlite, { create: true });
108
+ db.exec("PRAGMA journal_mode = WAL;");
109
+ migrateOperatorSchema(db);
110
+ const operators = loadOperatorStore(db);
111
+ const sessions = loadSessionStore(db);
112
+
113
+ return {
114
+ secret,
115
+ operators,
116
+ sessions,
117
+ persistOperator(operatorId: string) {
118
+ persistOperator(db, operators, operatorId);
119
+ },
120
+ persistSessions() {
121
+ persistSessions(db, sessions);
122
+ },
123
+ close() {
124
+ db.close();
125
+ },
126
+ };
127
+ }
128
+
129
+ /**
130
+ * Create operator tables if missing.
131
+ *
132
+ * @param db - bun:sqlite database
133
+ */
134
+ export function migrateOperatorSchema(db: Database): void {
135
+ db.exec(`
136
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operators} (
137
+ id TEXT PRIMARY KEY NOT NULL,
138
+ email TEXT NOT NULL UNIQUE,
139
+ name TEXT NOT NULL,
140
+ status TEXT NOT NULL,
141
+ mfa_enabled INTEGER NOT NULL DEFAULT 0,
142
+ invited_by TEXT,
143
+ last_seen_at INTEGER
144
+ );
145
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorCredentials} (
146
+ operator_id TEXT PRIMARY KEY NOT NULL,
147
+ password_hash TEXT NOT NULL,
148
+ login_enabled INTEGER NOT NULL DEFAULT 1,
149
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
150
+ );
151
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorSsoLinks} (
152
+ operator_id TEXT NOT NULL,
153
+ provider TEXT NOT NULL,
154
+ subject TEXT NOT NULL,
155
+ PRIMARY KEY (operator_id, provider, subject),
156
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
157
+ );
158
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.operatorRoles} (
159
+ operator_id TEXT NOT NULL,
160
+ role TEXT NOT NULL,
161
+ PRIMARY KEY (operator_id, role),
162
+ FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
163
+ );
164
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.sessions} (
165
+ id TEXT PRIMARY KEY NOT NULL,
166
+ plane TEXT NOT NULL,
167
+ principal_id TEXT NOT NULL,
168
+ family_id TEXT NOT NULL,
169
+ revoked_at INTEGER,
170
+ created_at INTEGER NOT NULL,
171
+ expires_at INTEGER NOT NULL
172
+ );
173
+ CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
174
+ id TEXT PRIMARY KEY NOT NULL,
175
+ session_id TEXT NOT NULL,
176
+ family_id TEXT NOT NULL,
177
+ hash TEXT NOT NULL,
178
+ expires_at INTEGER NOT NULL,
179
+ used_at INTEGER,
180
+ revoked_at INTEGER
181
+ );
182
+ `);
183
+ }
184
+
185
+ /**
186
+ * Hydrate a {@link SessionStore} from SQLite.
187
+ *
188
+ * @param db - Open database
189
+ */
190
+ export function loadSessionStore(db: Database): SessionStore {
191
+ const store = createSessionStore();
192
+
193
+ const sessionRows = db
194
+ .query(
195
+ `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at
196
+ FROM ${AUTH_TABLES.sessions}`,
197
+ )
198
+ .all() as Array<{
199
+ id: string;
200
+ plane: SessionRow["plane"];
201
+ principal_id: string;
202
+ family_id: string;
203
+ revoked_at: number | null;
204
+ created_at: number;
205
+ expires_at: number;
206
+ }>;
207
+
208
+ for (const row of sessionRows) {
209
+ const session: SessionRow = {
210
+ id: row.id,
211
+ plane: row.plane,
212
+ principalId: row.principal_id,
213
+ familyId: row.family_id,
214
+ revokedAt: row.revoked_at,
215
+ createdAt: row.created_at,
216
+ expiresAt: row.expires_at,
217
+ };
218
+ store.sessions.set(session.id, session);
219
+ }
220
+
221
+ const refreshRows = db
222
+ .query(
223
+ `SELECT id, session_id, family_id, hash, expires_at, used_at, revoked_at
224
+ FROM ${AUTH_TABLES.refreshTokens}`,
225
+ )
226
+ .all() as Array<{
227
+ id: string;
228
+ session_id: string;
229
+ family_id: string;
230
+ hash: string;
231
+ expires_at: number;
232
+ used_at: number | null;
233
+ revoked_at: number | null;
234
+ }>;
235
+
236
+ for (const row of refreshRows) {
237
+ const token: RefreshTokenRow = {
238
+ id: row.id,
239
+ sessionId: row.session_id,
240
+ familyId: row.family_id,
241
+ hash: row.hash,
242
+ expiresAt: row.expires_at,
243
+ usedAt: row.used_at,
244
+ revokedAt: row.revoked_at,
245
+ };
246
+ store.refresh.set(token.id, token);
247
+ }
248
+
249
+ return store;
250
+ }
251
+
252
+ /**
253
+ * Replace session tables with the in-memory store snapshot.
254
+ *
255
+ * @param db - Open database
256
+ * @param store - In-memory session store
257
+ */
258
+ export function persistSessions(db: Database, store: SessionStore): void {
259
+ db.exec(`DELETE FROM ${AUTH_TABLES.refreshTokens}`);
260
+ db.exec(`DELETE FROM ${AUTH_TABLES.sessions}`);
261
+
262
+ const insertSession = db.query(
263
+ `INSERT INTO ${AUTH_TABLES.sessions}
264
+ (id, plane, principal_id, family_id, revoked_at, created_at, expires_at)
265
+ VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires)`,
266
+ );
267
+ for (const session of store.sessions.values()) {
268
+ insertSession.run({
269
+ $id: session.id,
270
+ $plane: session.plane,
271
+ $principal: session.principalId,
272
+ $family: session.familyId,
273
+ $revoked: session.revokedAt,
274
+ $created: session.createdAt,
275
+ $expires: session.expiresAt,
276
+ });
277
+ }
278
+
279
+ const insertRefresh = db.query(
280
+ `INSERT INTO ${AUTH_TABLES.refreshTokens}
281
+ (id, session_id, family_id, hash, expires_at, used_at, revoked_at)
282
+ VALUES ($id, $session, $family, $hash, $expires, $used, $revoked)`,
283
+ );
284
+ for (const token of store.refresh.values()) {
285
+ insertRefresh.run({
286
+ $id: token.id,
287
+ $session: token.sessionId,
288
+ $family: token.familyId,
289
+ $hash: token.hash,
290
+ $expires: token.expiresAt,
291
+ $used: token.usedAt,
292
+ $revoked: token.revokedAt,
293
+ });
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Hydrate an {@link OperatorStore} from SQLite.
299
+ *
300
+ * @param db - Open database
301
+ */
302
+ export function loadOperatorStore(db: Database): OperatorStore {
303
+ const store = createOperatorStore();
304
+
305
+ const opRows = db
306
+ .query(
307
+ `SELECT id, email, name, status, mfa_enabled, invited_by, last_seen_at
308
+ FROM ${AUTH_TABLES.operators}`,
309
+ )
310
+ .all() as Array<{
311
+ id: string;
312
+ email: string;
313
+ name: string;
314
+ status: OperatorRow["status"];
315
+ mfa_enabled: number;
316
+ invited_by: string | null;
317
+ last_seen_at: number | null;
318
+ }>;
319
+
320
+ for (const row of opRows) {
321
+ store.operators.set(row.id, {
322
+ id: row.id,
323
+ email: row.email,
324
+ name: row.name,
325
+ status: row.status,
326
+ mfaEnabled: row.mfa_enabled === 1,
327
+ invitedBy: row.invited_by,
328
+ lastSeenAt: row.last_seen_at,
329
+ });
330
+ }
331
+
332
+ const credRows = db
333
+ .query(
334
+ `SELECT operator_id, password_hash, login_enabled
335
+ FROM ${AUTH_TABLES.operatorCredentials}`,
336
+ )
337
+ .all() as Array<{
338
+ operator_id: string;
339
+ password_hash: string;
340
+ login_enabled: number;
341
+ }>;
342
+
343
+ for (const row of credRows) {
344
+ const cred: OperatorCredentialRow = {
345
+ operatorId: row.operator_id,
346
+ passwordHash: row.password_hash,
347
+ loginEnabled: row.login_enabled === 1,
348
+ };
349
+ store.credentials.set(row.operator_id, cred);
350
+ }
351
+
352
+ const ssoRows = db
353
+ .query(
354
+ `SELECT operator_id, provider, subject FROM ${AUTH_TABLES.operatorSsoLinks}`,
355
+ )
356
+ .all() as Array<{
357
+ operator_id: string;
358
+ provider: string;
359
+ subject: string;
360
+ }>;
361
+
362
+ for (const row of ssoRows) {
363
+ const link: OperatorSsoLinkRow = {
364
+ operatorId: row.operator_id,
365
+ provider: row.provider,
366
+ subject: row.subject,
367
+ };
368
+ const list = store.ssoLinks.get(row.operator_id) ?? [];
369
+ list.push(link);
370
+ store.ssoLinks.set(row.operator_id, list);
371
+ }
372
+
373
+ const roleRows = db
374
+ .query(
375
+ `SELECT operator_id, role FROM ${AUTH_TABLES.operatorRoles}`,
376
+ )
377
+ .all() as Array<{ operator_id: string; role: string }>;
378
+
379
+ for (const row of roleRows) {
380
+ const list = store.roles.get(row.operator_id) ?? [];
381
+ list.push(row.role);
382
+ store.roles.set(row.operator_id, list);
383
+ }
384
+
385
+ return store;
386
+ }
387
+
388
+ /**
389
+ * Upsert one operator (and related rows) from the in-memory store.
390
+ *
391
+ * @param db - Open database
392
+ * @param store - In-memory store
393
+ * @param operatorId - Operator id
394
+ */
395
+ export function persistOperator(
396
+ db: Database,
397
+ store: OperatorStore,
398
+ operatorId: string,
399
+ ): void {
400
+ const op = store.operators.get(operatorId);
401
+ if (!op) {
402
+ throw new Error(`oke console: unknown operator ${operatorId}`);
403
+ }
404
+ const cred = store.credentials.get(operatorId);
405
+ if (!cred) {
406
+ throw new Error(`oke console: missing credential for ${operatorId}`);
407
+ }
408
+
409
+ const upsertOp = db.query(
410
+ `INSERT INTO ${AUTH_TABLES.operators}
411
+ (id, email, name, status, mfa_enabled, invited_by, last_seen_at)
412
+ VALUES ($id, $email, $name, $status, $mfa, $invited, $seen)
413
+ ON CONFLICT(id) DO UPDATE SET
414
+ email = excluded.email,
415
+ name = excluded.name,
416
+ status = excluded.status,
417
+ mfa_enabled = excluded.mfa_enabled,
418
+ invited_by = excluded.invited_by,
419
+ last_seen_at = excluded.last_seen_at`,
420
+ );
421
+ upsertOp.run({
422
+ $id: op.id,
423
+ $email: op.email,
424
+ $name: op.name,
425
+ $status: op.status,
426
+ $mfa: op.mfaEnabled ? 1 : 0,
427
+ $invited: op.invitedBy,
428
+ $seen: op.lastSeenAt,
429
+ });
430
+
431
+ const upsertCred = db.query(
432
+ `INSERT INTO ${AUTH_TABLES.operatorCredentials}
433
+ (operator_id, password_hash, login_enabled)
434
+ VALUES ($id, $hash, $enabled)
435
+ ON CONFLICT(operator_id) DO UPDATE SET
436
+ password_hash = excluded.password_hash,
437
+ login_enabled = excluded.login_enabled`,
438
+ );
439
+ upsertCred.run({
440
+ $id: cred.operatorId,
441
+ $hash: cred.passwordHash,
442
+ $enabled: cred.loginEnabled ? 1 : 0,
443
+ });
444
+
445
+ db.query(
446
+ `DELETE FROM ${AUTH_TABLES.operatorSsoLinks} WHERE operator_id = $id`,
447
+ ).run({ $id: operatorId });
448
+ const insertSso = db.query(
449
+ `INSERT INTO ${AUTH_TABLES.operatorSsoLinks} (operator_id, provider, subject)
450
+ VALUES ($id, $provider, $subject)`,
451
+ );
452
+ for (const link of store.ssoLinks.get(operatorId) ?? []) {
453
+ insertSso.run({
454
+ $id: link.operatorId,
455
+ $provider: link.provider,
456
+ $subject: link.subject,
457
+ });
458
+ }
459
+
460
+ db.query(
461
+ `DELETE FROM ${AUTH_TABLES.operatorRoles} WHERE operator_id = $id`,
462
+ ).run({ $id: operatorId });
463
+ const insertRole = db.query(
464
+ `INSERT INTO ${AUTH_TABLES.operatorRoles} (operator_id, role)
465
+ VALUES ($id, $role)`,
466
+ );
467
+ for (const role of store.roles.get(operatorId) ?? []) {
468
+ insertRole.run({ $id: operatorId, $role: role });
469
+ }
470
+ }
@@ -30,6 +30,26 @@ export function consolePlugin(): PluginDef {
30
30
  entry: "./panels/traces.js",
31
31
  })
32
32
  .table("oke_console_prefs", { plane: "operator" })
33
+ // Public flows must not 401 when a stale Bearer/cookie is present —
34
+ // kernel onAuth verifies before beforeHandle's public-flow exemption.
35
+ // Rebuild from URL: `new Request(req, { headers })` re-copies Authorization.
36
+ .hook("onRequest", (ctx) => {
37
+ if (!PUBLIC_CONSOLE_FLOWS.has(ctx.flow.name) || !ctx.request) return;
38
+ const auth = ctx.request.headers.get("authorization");
39
+ if (!auth?.startsWith("Bearer ")) return;
40
+ const headers = new Headers();
41
+ for (const [key, value] of ctx.request.headers) {
42
+ if (key.toLowerCase() === "authorization") continue;
43
+ headers.set(key, value);
44
+ }
45
+ const method = ctx.request.method;
46
+ const init: RequestInit = { method, headers };
47
+ if (method !== "GET" && method !== "HEAD") {
48
+ init.body = ctx.request.body;
49
+ (init as RequestInit & { duplex: "half" }).duplex = "half";
50
+ }
51
+ ctx.request = new Request(ctx.request.url, init);
52
+ })
33
53
  .hook("beforeHandle", (ctx, fxOrErr) => {
34
54
  const fx = fxOrErr as Fx;
35
55
  const name = ctx.flow.name;