okengine 0.2.3 → 0.2.5

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 (42) 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 +136 -17
  7. package/src/cli/docker-cli.test.ts +10 -8
  8. package/src/cli/docker.ts +18 -7
  9. package/src/cli/hero-meta.test.ts +85 -0
  10. package/src/cli/hero-meta.ts +252 -0
  11. package/src/cli/load-config.images.test.ts +51 -0
  12. package/src/cli/load-config.ts +84 -6
  13. package/src/config/define-config.test.ts +61 -0
  14. package/src/config/index.ts +89 -6
  15. package/src/config/resolve-driver.test.ts +30 -0
  16. package/src/console/server/flows.ts +3 -1
  17. package/src/console/server/operator-db.test.ts +140 -2
  18. package/src/console/server/operator-db.ts +147 -1
  19. package/src/console/server/plugin.ts +20 -0
  20. package/src/console/server/serve.ts +8 -1
  21. package/src/console/server/state.ts +12 -1
  22. package/src/console/ui/dist/assets/{index-Bnf_3Hei.js → index-Dy4jht9P.js} +1 -1
  23. package/src/console/ui/dist/index.html +1 -1
  24. package/src/console/ui/shell/App.tsx +10 -2
  25. package/src/docker/compose.ts +45 -14
  26. package/src/docker/derive.ts +29 -9
  27. package/src/docker/docker.test.ts +28 -4
  28. package/src/docker/dockerfile.integration.test.ts +2 -0
  29. package/src/docker/index.ts +10 -0
  30. package/src/docker/stack-id.test.ts +86 -0
  31. package/src/docker/stack-id.ts +108 -0
  32. package/src/docker/stack.integration.test.ts +16 -7
  33. package/src/docker/types.ts +20 -1
  34. package/src/kernel/app.ts +39 -8
  35. package/src/kernel/boot-bind/store.test.ts +60 -0
  36. package/src/kernel/boot-bind/store.ts +142 -12
  37. package/src/kernel/boot.ts +28 -4
  38. package/src/mcp/server.ts +99 -57
  39. package/src/runtime/dev-request-log.test.ts +33 -0
  40. package/src/runtime/dev-request-log.ts +130 -0
  41. package/src/term.test.ts +98 -5
  42. package/src/term.ts +287 -6
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Driver map resolution — including `stack` → prod fallback.
3
+ */
4
+
5
+ import { describe, expect, test } from "bun:test";
6
+ import { resolveDriverId } from "./index.ts";
7
+
8
+ describe("resolveDriverId", () => {
9
+ test("reads the named env key", () => {
10
+ expect(
11
+ resolveDriverId({ dev: "sqlite", prod: "postgres" }, "dev"),
12
+ ).toBe("sqlite");
13
+ expect(
14
+ resolveDriverId({ dev: "sqlite", prod: "postgres" }, "prod"),
15
+ ).toBe("postgres");
16
+ });
17
+
18
+ test("stack prefers stack, then prod, then dev", () => {
19
+ expect(
20
+ resolveDriverId(
21
+ { dev: "sqlite", stack: "postgres", prod: "postgres" },
22
+ "stack",
23
+ ),
24
+ ).toBe("postgres");
25
+ expect(
26
+ resolveDriverId({ dev: "sqlite", prod: "postgres" }, "stack"),
27
+ ).toBe("postgres");
28
+ expect(resolveDriverId({ dev: "sqlite" }, "stack")).toBe("sqlite");
29
+ });
30
+ });
@@ -2366,7 +2366,7 @@ async function issueOperatorSession(
2366
2366
  ): Promise<IssuedSession> {
2367
2367
  const op = state.operators.operators.get(operatorId);
2368
2368
  if (op) op.lastSeenAt = state.now();
2369
- return issueSession(
2369
+ const issued = await issueSession(
2370
2370
  state.sessions,
2371
2371
  {
2372
2372
  secret: state.secret,
@@ -2379,6 +2379,8 @@ async function issueOperatorSession(
2379
2379
  scopes: ["console:*"],
2380
2380
  },
2381
2381
  );
2382
+ state.persistSessions();
2383
+ return issued;
2382
2384
  }
2383
2385
 
2384
2386
  function sessionPayload(
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Durable Console operators under `.oke/console.sqlite`.
2
+ * Durable Console operators + sessions under `.oke/console.sqlite`.
3
3
  */
4
4
 
5
5
  import { afterEach, describe, expect, test } from "bun:test";
@@ -7,7 +7,8 @@ import { mkdtemp, rm } from "node:fs/promises";
7
7
  import { tmpdir } from "node:os";
8
8
  import { join } from "node:path";
9
9
  import { createOperator } from "../../auth/operator.ts";
10
- import { createConsoleApp } from "./app.ts";
10
+ import { issueSession, verifyAccess } from "../../auth/sessions.ts";
11
+ import { bootConsoleApp, createConsoleApp } from "./app.ts";
11
12
  import {
12
13
  openConsolePersistence,
13
14
  resolveConsoleSecret,
@@ -62,7 +63,9 @@ describe("console operator persistence", () => {
62
63
  cwd,
63
64
  secret: second.secret,
64
65
  operators: second.operators,
66
+ sessions: second.sessions,
65
67
  persistOperator: second.persistOperator,
68
+ persistSessions: second.persistSessions,
66
69
  silentClaim: false,
67
70
  });
68
71
  expect(app.state.setupClosed).toBe(true);
@@ -72,4 +75,139 @@ describe("console operator persistence", () => {
72
75
  second.close();
73
76
  }
74
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
+ });
75
213
  });
@@ -2,7 +2,8 @@
2
2
  * Durable operator plane for Console — `.oke/console.sqlite` + secret file.
3
3
  *
4
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.
5
+ * Claim codes stay ephemeral; operators, sessions, and the signing secret must
6
+ * survive restarts.
6
7
  */
7
8
 
8
9
  import { mkdirSync } from "node:fs";
@@ -12,10 +13,16 @@ import {
12
13
  createOperatorStore,
13
14
  type OperatorStore,
14
15
  } from "../../auth/operator.ts";
16
+ import {
17
+ createSessionStore,
18
+ type SessionStore,
19
+ } from "../../auth/sessions.ts";
15
20
  import type {
16
21
  OperatorCredentialRow,
17
22
  OperatorRow,
18
23
  OperatorSsoLinkRow,
24
+ RefreshTokenRow,
25
+ SessionRow,
19
26
  } from "../../auth/tables.ts";
20
27
  import { AUTH_TABLES } from "../../auth/tables.ts";
21
28
 
@@ -30,8 +37,12 @@ export interface ConsolePersistence {
30
37
  readonly secret: string;
31
38
  /** Hydrated operator Maps. */
32
39
  readonly operators: OperatorStore;
40
+ /** Hydrated session + refresh Maps. */
41
+ readonly sessions: SessionStore;
33
42
  /** Persist (or update) one operator + credential + roles/sso. */
34
43
  readonly persistOperator: (operatorId: string) => void;
44
+ /** Persist the full session store (issue / revoke). */
45
+ readonly persistSessions: () => void;
35
46
  /** Close the SQLite connection. */
36
47
  readonly close: () => void;
37
48
  }
@@ -97,13 +108,18 @@ export async function openConsolePersistence(
97
108
  db.exec("PRAGMA journal_mode = WAL;");
98
109
  migrateOperatorSchema(db);
99
110
  const operators = loadOperatorStore(db);
111
+ const sessions = loadSessionStore(db);
100
112
 
101
113
  return {
102
114
  secret,
103
115
  operators,
116
+ sessions,
104
117
  persistOperator(operatorId: string) {
105
118
  persistOperator(db, operators, operatorId);
106
119
  },
120
+ persistSessions() {
121
+ persistSessions(db, sessions);
122
+ },
107
123
  close() {
108
124
  db.close();
109
125
  },
@@ -145,9 +161,139 @@ export function migrateOperatorSchema(db: Database): void {
145
161
  PRIMARY KEY (operator_id, role),
146
162
  FOREIGN KEY (operator_id) REFERENCES ${AUTH_TABLES.operators}(id)
147
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
+ );
148
182
  `);
149
183
  }
150
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
+
151
297
  /**
152
298
  * Hydrate an {@link OperatorStore} from SQLite.
153
299
  *
@@ -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;
@@ -11,6 +11,7 @@ import {
11
11
  resolveAllowedHosts,
12
12
  secureFetch,
13
13
  } from "../../runtime/security.ts";
14
+ import { runWithDevSurface } from "../../runtime/dev-request-log.ts";
14
15
  import { CONSOLE_PORT, type ServerHandle } from "../../runtime/types.ts";
15
16
  import {
16
17
  bindManifestSignalBus,
@@ -75,7 +76,9 @@ export async function serveConsole(
75
76
  ? {
76
77
  secret: options.secret ?? persistence.secret,
77
78
  operators: persistence.operators,
79
+ sessions: persistence.sessions,
78
80
  persistOperator: persistence.persistOperator,
81
+ persistSessions: persistence.persistSessions,
79
82
  }
80
83
  : {}),
81
84
  });
@@ -109,7 +112,9 @@ export async function serveConsole(
109
112
  const authed = withCookieAuth(request);
110
113
 
111
114
  if (url.pathname.startsWith("/console/")) {
112
- const response = await handle.app.fetch(authed);
115
+ const response = await runWithDevSurface("Console", () =>
116
+ handle.app.fetch(authed),
117
+ );
113
118
  const withCookies = await attachSessionCookies(authed, response);
114
119
  return withConsoleSecurityHeaders(withCookies);
115
120
  }
@@ -213,7 +218,9 @@ export async function startConsoleApp(
213
218
  ? {
214
219
  secret: options.secret ?? persistence.secret,
215
220
  operators: persistence.operators,
221
+ sessions: persistence.sessions,
216
222
  persistOperator: persistence.persistOperator,
223
+ persistSessions: persistence.persistSessions,
217
224
  }
218
225
  : {}),
219
226
  });
@@ -350,6 +350,11 @@ export interface ConsoleState {
350
350
  * No-op when persistence is disabled (tests / memory-only).
351
351
  */
352
352
  persistOperator: (operatorId: string) => void;
353
+ /**
354
+ * Persist sessions after issue / revoke (SQLite under `.oke/`).
355
+ * No-op when persistence is disabled (tests / memory-only).
356
+ */
357
+ persistSessions: () => void;
353
358
  /**
354
359
  * Per-email login attempt timestamps for credential-check rate limiting
355
360
  * (console §10.4 — same 5/60s strategy as setup-claim).
@@ -405,8 +410,12 @@ export interface CreateConsoleStateOptions {
405
410
  readonly pluginRegistry?: PluginRegistry | null;
406
411
  /** Pre-hydrated operator store (from `.oke/console.sqlite`). */
407
412
  readonly operators?: OperatorStore;
413
+ /** Pre-hydrated session store (from `.oke/console.sqlite`). */
414
+ readonly sessions?: SessionStore;
408
415
  /** Persist hook after claim/create. */
409
416
  readonly persistOperator?: (operatorId: string) => void;
417
+ /** Persist hook after session issue / revoke. */
418
+ readonly persistSessions?: () => void;
410
419
  }
411
420
 
412
421
  /**
@@ -424,10 +433,11 @@ export function createConsoleState(
424
433
  const now = options.now ?? (() => Date.now());
425
434
  const operators = options.operators ?? createOperatorStore();
426
435
  const claim = mintClaimCode(now);
427
- const sessions = createSessionStore();
436
+ const sessions = options.sessions ?? createSessionStore();
428
437
  const liveSubscribers = new Set<(msg: ConsoleLiveMessage) => void>();
429
438
  const persistOperator =
430
439
  options.persistOperator ?? ((_operatorId: string) => {});
440
+ const persistSessions = options.persistSessions ?? (() => {});
431
441
 
432
442
  const signalConfig = createMemorySignalConfigStore();
433
443
  const gateAuth = createDefaultGateAuthStores();
@@ -855,6 +865,7 @@ export function createConsoleState(
855
865
  return operators.operators.size > 0;
856
866
  },
857
867
  persistOperator,
868
+ persistSessions,
858
869
  };
859
870
 
860
871
  return state;