okengine 0.8.0 → 0.9.0

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 (89) hide show
  1. package/package.json +3 -2
  2. package/site/content/docs/deployment/docker-swarm.mdx +228 -0
  3. package/site/content/docs/deployment/docker.mdx +212 -0
  4. package/site/content/docs/deployment/index.mdx +83 -0
  5. package/site/content/docs/deployment/kubernetes.mdx +176 -0
  6. package/site/content/docs/deployment/meta.json +5 -0
  7. package/site/content/docs/deployment/reverse-proxy.mdx +216 -0
  8. package/site/content/docs/elements/channel.mdx +3 -1
  9. package/site/content/docs/elements/signal.mdx +10 -8
  10. package/site/content/docs/elements/store.mdx +34 -0
  11. package/site/content/docs/get-started/index.mdx +5 -0
  12. package/site/content/docs/index.mdx +5 -0
  13. package/site/content/docs/meta.json +10 -1
  14. package/site/content/docs/plugins/index.mdx +1 -2
  15. package/site/content/docs/plugins/magic-link.mdx +2 -2
  16. package/site/content/docs/plugins/meta.json +1 -2
  17. package/site/content/docs/plugins/otp.mdx +202 -0
  18. package/site/content/docs/plugins/two-factor.mdx +2 -2
  19. package/site/content/docs/reference/cli.md +3 -2
  20. package/site/content/docs/reference/configuration.mdx +16 -0
  21. package/site/content/docs/reference/environment-variables.mdx +9 -8
  22. package/site/content/docs/reference/plugins.mdx +1 -1
  23. package/src/auth/auth.test.ts +36 -0
  24. package/src/auth/bindings.ts +3 -12
  25. package/src/auth/identity.ts +33 -0
  26. package/src/auth/index.ts +5 -0
  27. package/src/auth/otp-capability.ts +119 -0
  28. package/src/auth/otp-seal.test.ts +61 -0
  29. package/src/auth/otp-seal.ts +84 -0
  30. package/src/auth/schema.ts +3 -0
  31. package/src/auth/sessions.ts +26 -27
  32. package/src/auth/tables.ts +4 -0
  33. package/src/auth/verification.ts +61 -1
  34. package/src/cli/dev-app-runner.ts +4 -0
  35. package/src/cli/docker.ts +4 -1
  36. package/src/cli/load-config.images.test.ts +4 -0
  37. package/src/cli/load-config.ts +3 -0
  38. package/src/cli/registry.ts +1 -1
  39. package/src/console/server/operator-db.ts +34 -9
  40. package/src/docker/compose.ts +162 -6
  41. package/src/docker/derive.ts +60 -3
  42. package/src/docker/docker.test.ts +374 -1
  43. package/src/docker/helpers.ts +2 -0
  44. package/src/docker/index.ts +11 -0
  45. package/src/docker/recipes/caddy.ts +51 -0
  46. package/src/docker/recipes/dragonfly.ts +31 -0
  47. package/src/docker/recipes/index.ts +25 -2
  48. package/src/docker/recipes/pgdog.ts +84 -0
  49. package/src/docker/recipes/redis.ts +6 -3
  50. package/src/docker/recipes/traefik.ts +83 -0
  51. package/src/docker/recipes/valkey.ts +30 -0
  52. package/src/docker/stack-id.ts +5 -0
  53. package/src/docker/types.ts +18 -0
  54. package/src/drivers/channel-taqnyat-whatsapp.ts +94 -0
  55. package/src/drivers/channel-types.ts +1 -0
  56. package/src/elements/channel/otp-delivery.test.ts +76 -0
  57. package/src/elements/channel/otp-delivery.ts +291 -0
  58. package/src/elements/channel/runtime.ts +152 -114
  59. package/src/elements/channel.ts +12 -2
  60. package/src/index.ts +3 -0
  61. package/src/kernel/app.ts +60 -4
  62. package/src/kernel/boot-bind/channel.ts +51 -0
  63. package/src/kernel/boot-bind/gate.ts +14 -19
  64. package/src/kernel/boot-bind/honor-config.test.ts +18 -0
  65. package/src/kernel/boot-bind/signal.ts +20 -0
  66. package/src/kernel/boot-bind/store.test.ts +82 -0
  67. package/src/kernel/boot-bind/store.ts +22 -0
  68. package/src/kernel/boot.test.ts +5 -5
  69. package/src/kernel/fx.test.ts +3 -0
  70. package/src/kernel/fx.ts +49 -0
  71. package/src/kernel/graceful-shutdown.test.ts +76 -0
  72. package/src/kernel/graceful-shutdown.ts +106 -0
  73. package/src/kernel/horizontal-child.ts +257 -0
  74. package/src/kernel/horizontal.integration.test.ts +229 -0
  75. package/src/kernel/index.ts +8 -0
  76. package/src/kernel/ready.test.ts +76 -0
  77. package/src/plugins/auth-delivery.mailpit.integration.test.ts +5 -5
  78. package/src/plugins/auth-methods.security.test.ts +19 -29
  79. package/src/plugins/auth-methods.test.ts +7 -6
  80. package/src/plugins/index.ts +12 -8
  81. package/src/plugins/magic-link.ts +1 -23
  82. package/src/plugins/otp.test.ts +236 -0
  83. package/src/plugins/otp.ts +570 -0
  84. package/src/plugins/taqnyat.live.test.ts +4 -6
  85. package/src/release/official-plugins.ts +1 -2
  86. package/site/content/docs/plugins/email-otp.mdx +0 -117
  87. package/site/content/docs/plugins/phone-number.mdx +0 -172
  88. package/src/plugins/email-otp.ts +0 -214
  89. package/src/plugins/phone-number.ts +0 -206
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Domain-separated sealed OTP copy for Tier-2 redelivery.
3
+ *
4
+ * AES-GCM key is **never** the raw auth secret. It is derived via
5
+ * HKDF-SHA-256 with info `oke-otp-seal-v1` so session HMAC material stays
6
+ * cryptographically isolated from seal encryption.
7
+ */
8
+
9
+ /** HKDF `info` — versioned; bump when the seal format changes. */
10
+ export const OTP_SEAL_HKDF_INFO = "oke-otp-seal-v1";
11
+
12
+ const INFO_BYTES = new TextEncoder().encode(OTP_SEAL_HKDF_INFO);
13
+ const EMPTY_SALT = new Uint8Array(0);
14
+
15
+ /**
16
+ * Derive the AES-256-GCM key for OTP sealing from the method secret.
17
+ *
18
+ * @param secret - Root auth secret (`resolveMethodSecret`)
19
+ */
20
+ export async function deriveOtpSealKey(secret: string): Promise<CryptoKey> {
21
+ const ikm = new TextEncoder().encode(secret);
22
+ const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
23
+ return crypto.subtle.deriveKey(
24
+ {
25
+ name: "HKDF",
26
+ hash: "SHA-256",
27
+ salt: EMPTY_SALT,
28
+ info: INFO_BYTES,
29
+ },
30
+ baseKey,
31
+ { name: "AES-GCM", length: 256 },
32
+ false,
33
+ ["encrypt", "decrypt"],
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Seal a plaintext OTP for later redelivery (base64 `IV || ciphertext+tag`).
39
+ *
40
+ * @param secret - Root auth secret
41
+ * @param otp - Raw OTP digits
42
+ */
43
+ export async function sealOtp(secret: string, otp: string): Promise<string> {
44
+ const key = await deriveOtpSealKey(secret);
45
+ const iv = crypto.getRandomValues(new Uint8Array(12));
46
+ const cipher = await crypto.subtle.encrypt(
47
+ { name: "AES-GCM", iv },
48
+ key,
49
+ new TextEncoder().encode(otp),
50
+ );
51
+ const packed = new Uint8Array(iv.length + cipher.byteLength);
52
+ packed.set(iv, 0);
53
+ packed.set(new Uint8Array(cipher), iv.length);
54
+ return Buffer.from(packed).toString("base64");
55
+ }
56
+
57
+ /**
58
+ * Unseal a previously sealed OTP. Call only at the deliberate delivery boundary.
59
+ *
60
+ * @param secret - Root auth secret
61
+ * @param blob - Base64 sealed blob from {@link sealOtp}
62
+ */
63
+ export async function unsealOtp(secret: string, blob: string): Promise<string> {
64
+ const key = await deriveOtpSealKey(secret);
65
+ const packed = Buffer.from(blob, "base64");
66
+ if (packed.byteLength < 13) {
67
+ throw new Error("otp-seal: malformed sealed blob");
68
+ }
69
+ const iv = packed.subarray(0, 12);
70
+ const data = packed.subarray(12);
71
+ const plain = await crypto.subtle.decrypt(
72
+ { name: "AES-GCM", iv: new Uint8Array(iv) },
73
+ key,
74
+ toArrayBuffer(data),
75
+ );
76
+ return new TextDecoder().decode(plain);
77
+ }
78
+
79
+ /** Copy into a real ArrayBuffer for Web Crypto BufferSource typing. */
80
+ function toArrayBuffer(view: Uint8Array): ArrayBuffer {
81
+ const copy = new Uint8Array(view.byteLength);
82
+ copy.set(view);
83
+ return copy.buffer;
84
+ }
@@ -73,6 +73,8 @@ export const AUTH_MODEL_DEFAULT_FIELDS: Readonly<
73
73
  createdAt: "created_at",
74
74
  expiresAt: "expires_at",
75
75
  lastActiveAt: "last_active_at",
76
+ scopes: "scopes",
77
+ audience: "audience",
76
78
  },
77
79
  refreshToken: {
78
80
  id: "id",
@@ -186,6 +188,7 @@ const CORE_SQL_TYPES: Readonly<Record<string, AuthColumnSqlType>> = {
186
188
  value: "TEXT",
187
189
  description: "TEXT",
188
190
  scopes: "TEXT",
191
+ audience: "TEXT",
189
192
  lastUsedAt: "INTEGER",
190
193
  };
191
194
 
@@ -130,11 +130,10 @@ export async function issueSession(
130
130
  createdAt: t,
131
131
  expiresAt: t + absoluteCap,
132
132
  lastActiveAt: t,
133
+ scopes: [...principal.scopes],
134
+ ...(crypto.audience !== undefined ? { audience: crypto.audience } : {}),
133
135
  };
134
136
  store.sessions.set(sessionId, session);
135
- if (crypto.audience !== undefined) {
136
- sessionAudiences.set(sessionId, crypto.audience);
137
- }
138
137
 
139
138
  const refreshRaw = `rt_${cryptoRandomId()}`;
140
139
  const refreshRow: RefreshTokenRow = {
@@ -223,20 +222,15 @@ export async function rotateRefresh(
223
222
  };
224
223
  store.refresh.set(newRefresh.id, newRefresh);
225
224
 
226
- // Recover scopes from the previous access path — stored on session via
227
- // a side map would be ideal; for builtin we re-sign with empty and let
228
- // callers pass scopes through verify. We keep scopes on a claim cache:
229
- const priorScopes = sessionScopes.get(session.id) ?? [];
230
- const priorAud = sessionAudiences.get(session.id);
231
225
  const accessExpiresAt = t + accessTtl;
232
226
  const accessToken = await signAccess(crypto.secret, {
233
227
  sub: session.principalId,
234
228
  plane: session.plane,
235
229
  sid: session.id,
236
- scopes: priorScopes,
230
+ scopes: session.scopes,
237
231
  iat: t,
238
232
  exp: accessExpiresAt,
239
- ...(priorAud !== undefined ? { aud: priorAud } : {}),
233
+ ...(session.audience !== undefined ? { aud: session.audience } : {}),
240
234
  });
241
235
 
242
236
  return {
@@ -247,34 +241,44 @@ export async function rotateRefresh(
247
241
  };
248
242
  }
249
243
 
250
- /** Session id → scopes (access-token material). */
251
- const sessionScopes = new Map<string, string[]>();
252
-
253
- /** Session id → audience stamped at issue time. */
254
- const sessionAudiences = new Map<string, string>();
255
-
256
244
  /**
257
245
  * Remember scopes for refresh rotation (call after {@link issueSession}).
246
+ * Scopes live on the session row — shared with any store hydrate/persist path.
258
247
  *
248
+ * @param store - Session store
259
249
  * @param sessionId - Session id
260
250
  * @param scopes - Scopes
261
251
  */
262
- export function bindSessionScopes(sessionId: string, scopes: Iterable<string>): void {
263
- sessionScopes.set(sessionId, [...scopes]);
252
+ export function bindSessionScopes(
253
+ store: SessionStore,
254
+ sessionId: string,
255
+ scopes: Iterable<string>,
256
+ ): void {
257
+ const session = store.sessions.get(sessionId);
258
+ if (!session) return;
259
+ // Mutate in place — SessionRow is a store-owned mutable row.
260
+ (session as { scopes: string[] }).scopes = [...scopes];
264
261
  }
265
262
 
266
263
  /**
267
264
  * Remember audience for refresh rotation (call after {@link issueSession}).
268
265
  *
266
+ * @param store - Session store
269
267
  * @param sessionId - Session id
270
268
  * @param audience - Audience claim
271
269
  */
272
- export function bindSessionAudience(sessionId: string, audience: string): void {
273
- sessionAudiences.set(sessionId, audience);
270
+ export function bindSessionAudience(
271
+ store: SessionStore,
272
+ sessionId: string,
273
+ audience: string,
274
+ ): void {
275
+ const session = store.sessions.get(sessionId);
276
+ if (!session) return;
277
+ (session as { audience?: string }).audience = audience;
274
278
  }
275
279
 
276
280
  /**
277
- * Issue a session and bind scopes for later rotation.
281
+ * Issue a session with scopes (and optional audience) on the session row.
278
282
  *
279
283
  * @param store - Session store
280
284
  * @param crypto - Crypto
@@ -289,12 +293,7 @@ export async function issueSessionWithScopes(
289
293
  readonly scopes: Iterable<string>;
290
294
  },
291
295
  ): Promise<IssuedSession> {
292
- const issued = await issueSession(store, crypto, principal);
293
- bindSessionScopes(issued.session.id, principal.scopes);
294
- if (crypto.audience !== undefined) {
295
- bindSessionAudience(issued.session.id, crypto.audience);
296
- }
297
- return issued;
296
+ return issueSession(store, crypto, principal);
298
297
  }
299
298
 
300
299
  /**
@@ -121,6 +121,10 @@ export interface SessionRow {
121
121
  expiresAt: number;
122
122
  /** Last activity epoch-ms (idle timeout). Updated on refresh / access touch. */
123
123
  lastActiveAt: number;
124
+ /** Access-token scopes — survives refresh / process restart with the session row. */
125
+ scopes: string[];
126
+ /** Audience stamped at issue (`oke-console` · `oke-mcp` · `oke-app`). */
127
+ audience?: string;
124
128
  }
125
129
 
126
130
  /** Refresh token (hashed at rest); rotation with reuse detection. */
@@ -2,6 +2,9 @@
2
2
  * Shared verification / challenge store for magic-link, OTP, phone, etc.
3
3
  */
4
4
 
5
+ /** Delivery channel for Tier-2 OTP challenges. */
6
+ export type OtpChannel = "sms" | "whatsapp" | "email";
7
+
5
8
  /** One pending challenge. */
6
9
  export interface VerificationRow {
7
10
  id: string;
@@ -11,6 +14,19 @@ export interface VerificationRow {
11
14
  createdAt: number;
12
15
  consumedAt: number | null;
13
16
  attempts: number;
17
+ /**
18
+ * Tier-2 sealed OTP (AES-GCM via HKDF `oke-otp-seal-v1`).
19
+ * Wiped on consume / expire — never left after TTL.
20
+ */
21
+ sealedOtp?: string | null;
22
+ /** Last successful delivery time (resend cooldown). */
23
+ lastDeliveredAt?: number;
24
+ /** Last channel used for delivery. */
25
+ lastChannel?: OtpChannel;
26
+ /** Email address captured at request (channel-neutral challenge). */
27
+ email?: string | null;
28
+ /** E.164 phone captured at request. */
29
+ phone?: string | null;
14
30
  }
15
31
 
16
32
  /** In-memory verification store. */
@@ -25,6 +41,15 @@ export function createVerificationStore(): VerificationStore {
25
41
  return { rows: new Map() };
26
42
  }
27
43
 
44
+ /**
45
+ * Wipe the sealed OTP copy (and clear the field). Safe to call repeatedly.
46
+ *
47
+ * @param row - Challenge row
48
+ */
49
+ export function wipeSealedOtp(row: VerificationRow): void {
50
+ row.sealedOtp = null;
51
+ }
52
+
28
53
  /**
29
54
  * Store a challenge (hashed value recommended by callers).
30
55
  *
@@ -37,6 +62,7 @@ export function putVerification(store: VerificationStore, row: VerificationRow):
37
62
 
38
63
  /**
39
64
  * Find a non-consumed, non-expired challenge by identifier.
65
+ * Expired active rows have their sealed OTP wiped before being skipped.
40
66
  *
41
67
  * @param store - Store
42
68
  * @param identifier - Email / phone / username key
@@ -50,12 +76,46 @@ export function findActiveVerification(
50
76
  for (const row of store.rows.values()) {
51
77
  if (row.identifier !== identifier) continue;
52
78
  if (row.consumedAt !== null) continue;
53
- if (row.expiresAt <= now) continue;
79
+ if (row.expiresAt <= now) {
80
+ wipeSealedOtp(row);
81
+ continue;
82
+ }
54
83
  return row;
55
84
  }
56
85
  return undefined;
57
86
  }
58
87
 
88
+ /**
89
+ * Consume a challenge: set `consumedAt`, wipe sealed OTP.
90
+ *
91
+ * @param row - Active challenge
92
+ * @param now - Clock
93
+ */
94
+ export function consumeVerification(row: VerificationRow, now: number): void {
95
+ row.consumedAt = now;
96
+ wipeSealedOtp(row);
97
+ }
98
+
99
+ /**
100
+ * Invalidate every non-consumed challenge for an identifier (fresh request).
101
+ * Wipes sealed copies on the invalidated rows.
102
+ *
103
+ * @param store - Store
104
+ * @param identifier - Challenge key
105
+ * @param now - Clock
106
+ */
107
+ export function invalidateVerifications(
108
+ store: VerificationStore,
109
+ identifier: string,
110
+ now: number,
111
+ ): void {
112
+ for (const row of store.rows.values()) {
113
+ if (row.identifier === identifier && row.consumedAt === null) {
114
+ consumeVerification(row, now);
115
+ }
116
+ }
117
+ }
118
+
59
119
  /**
60
120
  * SHA-256 hex hash for challenge secrets / OTPs.
61
121
  *
@@ -17,6 +17,7 @@
17
17
 
18
18
  import { resolve } from "node:path";
19
19
  import { pathToFileURL } from "node:url";
20
+ import { installGracefulShutdown, type GracefulShutdownApp } from "../kernel/graceful-shutdown.ts";
20
21
  import { createBunRuntime } from "../runtime/bun.ts";
21
22
  import { APP_PORT, type FetchApp } from "../runtime/types.ts";
22
23
  import { clearTerminalScreen, formatAppReadyLine, formatDevHero } from "../term.ts";
@@ -33,6 +34,8 @@ type DevRunnerGlobals = typeof globalThis & {
33
34
  /** App entry shape — FetchApp plus boot before serve. */
34
35
  type BootableApp = FetchApp & {
35
36
  boot(): Promise<unknown>;
37
+ stop(): Promise<void>;
38
+ readonly bootResult?: GracefulShutdownApp["bootResult"];
36
39
  };
37
40
 
38
41
  const entry = Bun.env["OKE_ENTRY"];
@@ -68,6 +71,7 @@ const handle = createBunRuntime().serve(mod.app, {
68
71
  hostname,
69
72
  id: DEV_APP_SERVE_ID,
70
73
  });
74
+ installGracefulShutdown({ app: mod.app, handle });
71
75
 
72
76
  const appUrl = `http://${formatHostForUrl(hostname)}:${handle.port}`;
73
77
  const g = globalThis as DevRunnerGlobals;
package/src/cli/docker.ts CHANGED
@@ -105,7 +105,10 @@ export async function runDockerDerive(
105
105
  write(
106
106
  `compose merge order (cwd ${composeDir}/):\n${result.composeFiles.map((f) => ` -f ${f}`).join("\n")}\n`,
107
107
  );
108
- write(`(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n`);
108
+ write(
109
+ `(${composeDir}/compose.override.yml is layer 4 — user-owned, never written by oke)\n` +
110
+ `(${composeDir}/compose.all.yml is layers 1–3 merged — single-file alternative for Swarm / one \`-c\`)\n`,
111
+ );
109
112
  return { code: 0, result };
110
113
  } catch (err) {
111
114
  console.error(err instanceof Error ? err.message : String(err));
@@ -21,6 +21,7 @@ describe("defaultImagesFromConfig", () => {
21
21
  });
22
22
  expect(images).toEqual({
23
23
  "store.sql": "postgres:18-alpine",
24
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
24
25
  "store.kv": "redis:8-alpine",
25
26
  });
26
27
  });
@@ -35,6 +36,7 @@ describe("defaultImagesFromConfig", () => {
35
36
  },
36
37
  });
37
38
  expect(images["store.sql"]).toBe("pgvector/pgvector:pg17");
39
+ expect(images.pgdog).toBe("ghcr.io/pgdogdev/pgdog:v0.1.51");
38
40
  });
39
41
 
40
42
  test("clock postgres alone pulls store.sql image", () => {
@@ -45,6 +47,7 @@ describe("defaultImagesFromConfig", () => {
45
47
  });
46
48
  expect(images).toEqual({
47
49
  "store.sql": "postgres:18-alpine",
50
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
48
51
  });
49
52
  });
50
53
 
@@ -56,6 +59,7 @@ describe("defaultImagesFromConfig", () => {
56
59
  });
57
60
  expect(images).toEqual({
58
61
  "store.sql": "postgres:18-alpine",
62
+ pgdog: "ghcr.io/pgdogdev/pgdog:v0.1.51",
59
63
  });
60
64
  });
61
65
 
@@ -9,6 +9,7 @@ import type { Manifest } from "../manifest/types.ts";
9
9
  /** Default image pins when `images` is omitted but prod drivers need containers. */
10
10
  const DEFAULT_SQL_IMAGE = "postgres:18-alpine";
11
11
  const DEFAULT_PGVECTOR_IMAGE = "pgvector/pgvector:pg17";
12
+ const DEFAULT_PGDOG_IMAGE = "ghcr.io/pgdogdev/pgdog:v0.1.51";
12
13
  const DEFAULT_KV_IMAGE = "redis:8-alpine";
13
14
  const DEFAULT_VAULT_IMAGE = "openbao/openbao:2.6.1";
14
15
 
@@ -64,6 +65,8 @@ export function defaultImagesFromConfig(config: OkeConfig): Readonly<Record<stri
64
65
  if (needsSql) {
65
66
  out["store.sql"] =
66
67
  sql === "pgvector" || index === "pgvector" ? DEFAULT_PGVECTOR_IMAGE : DEFAULT_SQL_IMAGE;
68
+ // PgDog in front of Postgres — caps real server connections under horizontal scale.
69
+ out.pgdog = DEFAULT_PGDOG_IMAGE;
67
70
  }
68
71
 
69
72
  const needsKv =
@@ -329,7 +329,7 @@ export const OKE_COMMANDS: readonly CliCommand[] = [
329
329
  {
330
330
  long: "--prod",
331
331
  short: "-p",
332
- summary: "Prod overlays (healthchecks, limits, replicas)",
332
+ summary: "Prod overlays (app /_/ready healthcheck, deploy, limits)",
333
333
  },
334
334
  OUT,
335
335
  CONFIG,
@@ -158,7 +158,9 @@ export function migrateOperatorSchema(db: Database): void {
158
158
  revoked_at INTEGER,
159
159
  created_at INTEGER NOT NULL,
160
160
  expires_at INTEGER NOT NULL,
161
- last_active_at INTEGER
161
+ last_active_at INTEGER,
162
+ scopes TEXT NOT NULL DEFAULT '[]',
163
+ audience TEXT
162
164
  );
163
165
  CREATE TABLE IF NOT EXISTS ${AUTH_TABLES.refreshTokens} (
164
166
  id TEXT PRIMARY KEY NOT NULL,
@@ -170,20 +172,28 @@ export function migrateOperatorSchema(db: Database): void {
170
172
  revoked_at INTEGER
171
173
  );
172
174
  `);
173
- ensureSessionLastActiveColumn(db);
175
+ ensureSessionColumns(db);
174
176
  }
175
177
 
176
178
  /**
177
- * Add `last_active_at` when opening an older console.sqlite that predates idle TTL.
179
+ * Add session columns when opening an older console.sqlite.
178
180
  *
179
181
  * @param db - Open database
180
182
  */
181
- function ensureSessionLastActiveColumn(db: Database): void {
183
+ function ensureSessionColumns(db: Database): void {
182
184
  const cols = db.query(`PRAGMA table_info(${AUTH_TABLES.sessions})`).all() as Array<{
183
185
  name: string;
184
186
  }>;
185
- if (cols.some((c) => c.name === "last_active_at")) return;
186
- db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
187
+ const names = new Set(cols.map((c) => c.name));
188
+ if (!names.has("last_active_at")) {
189
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN last_active_at INTEGER`);
190
+ }
191
+ if (!names.has("scopes")) {
192
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN scopes TEXT NOT NULL DEFAULT '[]'`);
193
+ }
194
+ if (!names.has("audience")) {
195
+ db.exec(`ALTER TABLE ${AUTH_TABLES.sessions} ADD COLUMN audience TEXT`);
196
+ }
187
197
  }
188
198
 
189
199
  /**
@@ -196,7 +206,7 @@ export function loadSessionStore(db: Database): SessionStore {
196
206
 
197
207
  const sessionRows = db
198
208
  .query(
199
- `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at
209
+ `SELECT id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience
200
210
  FROM ${AUTH_TABLES.sessions}`,
201
211
  )
202
212
  .all() as Array<{
@@ -208,9 +218,20 @@ export function loadSessionStore(db: Database): SessionStore {
208
218
  created_at: number;
209
219
  expires_at: number;
210
220
  last_active_at: number | null;
221
+ scopes: string | null;
222
+ audience: string | null;
211
223
  }>;
212
224
 
213
225
  for (const row of sessionRows) {
226
+ let scopes: string[] = [];
227
+ if (row.scopes) {
228
+ try {
229
+ const parsed = JSON.parse(row.scopes) as unknown;
230
+ if (Array.isArray(parsed)) scopes = parsed.map(String);
231
+ } catch {
232
+ scopes = [];
233
+ }
234
+ }
214
235
  const session: SessionRow = {
215
236
  id: row.id,
216
237
  plane: row.plane,
@@ -220,6 +241,8 @@ export function loadSessionStore(db: Database): SessionStore {
220
241
  createdAt: row.created_at,
221
242
  expiresAt: row.expires_at,
222
243
  lastActiveAt: row.last_active_at ?? row.created_at,
244
+ scopes,
245
+ ...(row.audience ? { audience: row.audience } : {}),
223
246
  };
224
247
  store.sessions.set(session.id, session);
225
248
  }
@@ -267,8 +290,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
267
290
 
268
291
  const insertSession = db.query(
269
292
  `INSERT INTO ${AUTH_TABLES.sessions}
270
- (id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at)
271
- VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive)`,
293
+ (id, plane, principal_id, family_id, revoked_at, created_at, expires_at, last_active_at, scopes, audience)
294
+ VALUES ($id, $plane, $principal, $family, $revoked, $created, $expires, $lastActive, $scopes, $audience)`,
272
295
  );
273
296
  for (const session of store.sessions.values()) {
274
297
  insertSession.run({
@@ -280,6 +303,8 @@ export function persistSessions(db: Database, store: SessionStore): void {
280
303
  $created: session.createdAt,
281
304
  $expires: session.expiresAt,
282
305
  $lastActive: session.lastActiveAt,
306
+ $scopes: JSON.stringify(session.scopes ?? []),
307
+ $audience: session.audience ?? null,
283
308
  });
284
309
  }
285
310