@prismshadow/penguin-server 0.2.17 → 0.2.18

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 (90) hide show
  1. package/dist/api/types.d.ts +181 -17
  2. package/dist/api/types.js +9 -1
  3. package/dist/auth-token.js +1 -1
  4. package/dist/{chunk-HPOHMZCH.js → chunk-5YLLSHY3.js} +61 -2
  5. package/dist/chunk-5YLLSHY3.js.map +1 -0
  6. package/dist/{chunk-D3JNA6ZI.js → chunk-B3PSKR3D.js} +2 -2
  7. package/dist/chunk-B3PSKR3D.js.map +1 -0
  8. package/dist/{chunk-HDBZRVA5.js → chunk-BRMNPLU6.js} +9 -1
  9. package/dist/chunk-BRMNPLU6.js.map +1 -0
  10. package/dist/index.js +8331 -6769
  11. package/dist/index.js.map +1 -1
  12. package/dist/machine-status.js +1 -1
  13. package/dist/plugin/index.d.ts +57 -16
  14. package/dist/reset-admin-password.js +2 -2
  15. package/package.json +4 -4
  16. package/web-dist/assets/{abnfDiagram-VCTEODGH-DMzkb4l4.js → abnfDiagram-VCTEODGH-C_Ej4MyZ.js} +1 -1
  17. package/web-dist/assets/{addon-clipboard-BtD2BVSi.js → addon-clipboard-eONBCa8x.js} +1 -1
  18. package/web-dist/assets/{addon-fit-Dr6HWsi_.js → addon-fit-Dk6bkT8u.js} +1 -1
  19. package/web-dist/assets/{addon-web-links-VEE3YLzQ.js → addon-web-links-e0JpGvGv.js} +1 -1
  20. package/web-dist/assets/{arc-CvMPOU_B.js → arc-ytwqobEL.js} +1 -1
  21. package/web-dist/assets/{architectureDiagram-5GKGNRK7-Bk65mIBz.js → architectureDiagram-5GKGNRK7-oxT9SNOE.js} +1 -1
  22. package/web-dist/assets/{blockDiagram-I7D4REHJ-D54-XSNs.js → blockDiagram-I7D4REHJ-D8OXfpb9.js} +1 -1
  23. package/web-dist/assets/{c4Diagram-7LVT6UL2-Dm954LSt.js → c4Diagram-7LVT6UL2-BNYNzSWK.js} +1 -1
  24. package/web-dist/assets/channel-7Be_pU-b.js +1 -0
  25. package/web-dist/assets/{chunk-2Q5K7J3B-D_LJ_7Cn.js → chunk-2Q5K7J3B-C2yNhFmh.js} +1 -1
  26. package/web-dist/assets/{chunk-5VM5RSS4-B8RI4tl3.js → chunk-5VM5RSS4-CAeuhwbB.js} +1 -1
  27. package/web-dist/assets/{chunk-F27PBJKO-aVSYH9JV.js → chunk-F27PBJKO-DSuRiLxP.js} +1 -1
  28. package/web-dist/assets/{chunk-IMKFNOWR-DnkdWeq2.js → chunk-IMKFNOWR-MStar4cf.js} +1 -1
  29. package/web-dist/assets/{chunk-JWPE2WC7-C2pJzKuW.js → chunk-JWPE2WC7-CIgQwRv4.js} +1 -1
  30. package/web-dist/assets/{chunk-POPQ4Y6H-Bx7Iikzx.js → chunk-POPQ4Y6H-CA8evx9g.js} +1 -1
  31. package/web-dist/assets/{chunk-SVP7TREG-qJC2SMqr.js → chunk-SVP7TREG-bR7-FT1W.js} +1 -1
  32. package/web-dist/assets/{chunk-TICWLB2K-Crl_BgKI.js → chunk-TICWLB2K-C0ZdCnvI.js} +1 -1
  33. package/web-dist/assets/{chunk-XXDRQBXY-BFFemuGm.js → chunk-XXDRQBXY-CzozFLFI.js} +1 -1
  34. package/web-dist/assets/classDiagram-ZZMXUADV-BjVhvDIH.js +1 -0
  35. package/web-dist/assets/classDiagram-v2-VYDZK3BY-BjVhvDIH.js +1 -0
  36. package/web-dist/assets/{cose-bilkent-JH36ORCC-Bmh2Kx7h.js → cose-bilkent-JH36ORCC-nkjrY6eI.js} +1 -1
  37. package/web-dist/assets/{cynefin-OW5HDTMX-BveNWnUV.js → cynefin-OW5HDTMX-D2A0h062.js} +1 -1
  38. package/web-dist/assets/{cynefinDiagram-5FMLGOSQ-85itazbJ.js → cynefinDiagram-5FMLGOSQ-DFZQ7d_T.js} +1 -1
  39. package/web-dist/assets/{dagre-GXQ25YYZ-DIZT-KY_.js → dagre-GXQ25YYZ-DPYLLARd.js} +1 -1
  40. package/web-dist/assets/{diagram-S7CK7UJ4-Dum-g63f.js → diagram-S7CK7UJ4-B204Nrur.js} +1 -1
  41. package/web-dist/assets/{diagram-UQ7AKVKN-DallMKJl.js → diagram-UQ7AKVKN-DWORCZlS.js} +1 -1
  42. package/web-dist/assets/{diagram-VSXAHHWV-DWELqxfN.js → diagram-VSXAHHWV-Su6Bzm4Z.js} +1 -1
  43. package/web-dist/assets/{diagram-VX7I27RA-BNs0gB9E.js → diagram-VX7I27RA-B4p8ssYj.js} +1 -1
  44. package/web-dist/assets/{diagram-Z3DM3KII-C9mF9xAd.js → diagram-Z3DM3KII-BgMz7jjW.js} +1 -1
  45. package/web-dist/assets/{ebnfDiagram-PWID7BFC-D_Diy9HH.js → ebnfDiagram-PWID7BFC-DBCS3Cwc.js} +1 -1
  46. package/web-dist/assets/{erDiagram-RLTQ6QDP-4QTS5VIh.js → erDiagram-RLTQ6QDP-DEm39S5C.js} +1 -1
  47. package/web-dist/assets/{flowDiagram-HODETNUW-CliTRrtV.js → flowDiagram-HODETNUW-CEUQGUza.js} +1 -1
  48. package/web-dist/assets/{ganttDiagram-EL5Y4UJY-DCRd-Ss4.js → ganttDiagram-EL5Y4UJY-myYTgwFp.js} +1 -1
  49. package/web-dist/assets/{gitGraphDiagram-WWUBYQGX-CTz13GjS.js → gitGraphDiagram-WWUBYQGX-BAQ0Kl1C.js} +1 -1
  50. package/web-dist/assets/{highlighter-BScH1Ud3.js → highlighter-DRTMVWxI.js} +2 -2
  51. package/web-dist/assets/{highlighter-core-DKz8Z6-I.js → highlighter-core-DgISzHrX.js} +1 -1
  52. package/web-dist/assets/index-PA-IFoA6.js +1300 -0
  53. package/web-dist/assets/index-oCEyg9BT.css +1 -0
  54. package/web-dist/assets/{infoDiagram-27XIBGKW-DNfTEPAV.js → infoDiagram-27XIBGKW-B2WDMvX9.js} +1 -1
  55. package/web-dist/assets/{ishikawaDiagram-5VMMS53U-B7aCjjG1.js → ishikawaDiagram-5VMMS53U-8qHTttjq.js} +1 -1
  56. package/web-dist/assets/{journeyDiagram-3NMN7TZE-COmDALVr.js → journeyDiagram-3NMN7TZE-DKE_xRCO.js} +1 -1
  57. package/web-dist/assets/{kanban-definition-UXKFOSKX-BzUfPINh.js → kanban-definition-UXKFOSKX-EnJbHEmV.js} +1 -1
  58. package/web-dist/assets/{layout-BwzuPU4M.js → layout-BlhCFmEZ.js} +1 -1
  59. package/web-dist/assets/{linear-WPZTYjz3.js → linear-Dw2VzU-D.js} +1 -1
  60. package/web-dist/assets/{mermaid.core-zKzlwpkw.js → mermaid.core-CaXNMz57.js} +6 -6
  61. package/web-dist/assets/{mindmap-definition-YA3MSWOX-DCRg7eAx.js → mindmap-definition-YA3MSWOX-DnTTfqbu.js} +1 -1
  62. package/web-dist/assets/{pegDiagram-XKGWAZYB-DyphzDLU.js → pegDiagram-XKGWAZYB-C3SArVd6.js} +1 -1
  63. package/web-dist/assets/{pieDiagram-E7YTZNPT-BI5DueGA.js → pieDiagram-E7YTZNPT-CTOmO6ke.js} +1 -1
  64. package/web-dist/assets/{quadrantDiagram-AXDQQJYC-GZxSMY2Z.js → quadrantDiagram-AXDQQJYC-CbUsMwzO.js} +1 -1
  65. package/web-dist/assets/{railroadDiagram-O6MQD6OU-DEFCigBf.js → railroadDiagram-O6MQD6OU-Co2hsvaZ.js} +1 -1
  66. package/web-dist/assets/{requirementDiagram-BXWQKSXE-Br0thc4j.js → requirementDiagram-BXWQKSXE-BqFG0eYQ.js} +1 -1
  67. package/web-dist/assets/{sankeyDiagram-P5KCCOFB-DYf0pY67.js → sankeyDiagram-P5KCCOFB-YgQHJlB7.js} +1 -1
  68. package/web-dist/assets/{sequenceDiagram-WJ2MYXX4-1EFMboX4.js → sequenceDiagram-WJ2MYXX4-CdDrP6O2.js} +1 -1
  69. package/web-dist/assets/{sizeCapture-INFHLROL-BuOFv-5q.js → sizeCapture-INFHLROL-CKwbEVhU.js} +1 -1
  70. package/web-dist/assets/{stateDiagram-D77RDMKH-Bg3H1tjX.js → stateDiagram-D77RDMKH-kTJR3t2p.js} +1 -1
  71. package/web-dist/assets/stateDiagram-v2-MP3YSRHH-DzaSnZwj.js +1 -0
  72. package/web-dist/assets/{swimlanes-42K2YHIH-BuOa6Osb.js → swimlanes-42K2YHIH-DCAO3rCQ.js} +1 -1
  73. package/web-dist/assets/swimlanesDiagram-VR7AAH4N-DNfCLejq.js +8 -0
  74. package/web-dist/assets/{timeline-definition-24CTP7MA-BDekfdDT.js → timeline-definition-24CTP7MA-DEjnyzKw.js} +1 -1
  75. package/web-dist/assets/{vennDiagram-4TSXK5OY-DnC09UI4.js → vennDiagram-4TSXK5OY-DSihUvCq.js} +1 -1
  76. package/web-dist/assets/{wardleyDiagram-VM6X3IG4-BXKvxuZQ.js → wardleyDiagram-VM6X3IG4-CnAoMeCH.js} +1 -1
  77. package/web-dist/assets/{xterm-oY4DYg5s.js → xterm-3JwPT74G.js} +1 -1
  78. package/web-dist/assets/{xychartDiagram-S5SC5T6Z-DdDC1VLb.js → xychartDiagram-S5SC5T6Z-43dHqZTr.js} +1 -1
  79. package/web-dist/index.html +2 -2
  80. package/web-dist/workbench-ui.css +164 -34
  81. package/dist/chunk-D3JNA6ZI.js.map +0 -1
  82. package/dist/chunk-HDBZRVA5.js.map +0 -1
  83. package/dist/chunk-HPOHMZCH.js.map +0 -1
  84. package/web-dist/assets/channel-CVHI75kz.js +0 -1
  85. package/web-dist/assets/classDiagram-ZZMXUADV-CBdzAPFu.js +0 -1
  86. package/web-dist/assets/classDiagram-v2-VYDZK3BY-CBdzAPFu.js +0 -1
  87. package/web-dist/assets/index-BUvJ5Ctt.js +0 -1299
  88. package/web-dist/assets/index-DRj7nvPY.css +0 -1
  89. package/web-dist/assets/stateDiagram-v2-MP3YSRHH-DfaDaPAd.js +0 -1
  90. package/web-dist/assets/swimlanesDiagram-VR7AAH4N-BopAC7b8.js +0 -8
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/db/database.ts","../src/db/migrations/runner.ts","../src/db/migrations/steps/2026-08-31-messaging-bindings.ts","../src/db/migrations/steps/2026-08-31-messaging-delivery-flags.ts","../src/db/migrations/steps/2026-09-02-drop-goal-state.ts","../src/db/migrations/steps/2026-09-04-machines.ts","../src/db/migrations/steps/2026-09-14-user-profile.ts","../src/db/migrations/steps/2026-09-16-company-mode-channels.ts","../src/db/migrations/steps/2026-09-16-company-mode-desk-notices.ts","../src/db/migrations/steps/2026-09-16-company-mode-org-caches.ts","../src/db/migrations/steps/2026-09-17-model-promotions.ts","../src/db/migrations/steps/2026-09-21-model-provider-auth-tokens.ts","../src/db/migrations/steps/2026-09-22-sessions-sandbox.ts","../src/db/migrations/steps/2026-09-23-machines-columns.ts","../src/db/migrations/steps/2026-09-23-model-tables-adoption.ts","../src/db/migrations/steps/2026-10-04-browser-extensions.ts","../src/db/migrations/steps/2026-10-10-agent-api.ts","../src/db/migrations/steps/2026-10-12-messaging-conversations.ts","../src/db/migrations/steps/2026-10-12-sessions-kept-at.ts","../src/db/migrations/steps/2026-10-14-agent-api-concurrency.ts","../src/db/migrations/steps/2026-10-14-agent-api-session-settings.ts","../src/db/migrations/steps/2026-10-16-machines-adopted-at.ts","../src/db/migrations/steps/2026-10-26-user-drafts.ts","../src/db/migrations/index.ts","../src/db/schema.ts"],"sourcesContent":["/**\n * SQLite connection & initialization (node:sqlite DatabaseSync).\n *\n * Single process, single writer: a synchronous API is sufficient and avoids a connection\n * pool; WAL mode and foreign key constraints are enabled. Table-creation SQL runs on open\n * (idempotent), with no migration branches (product not yet released).\n */\nimport { mkdirSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type { DatabaseSync } from \"node:sqlite\";\nimport { migrate } from \"./migrations/index.js\";\nimport { SCHEMA_SQL } from \"./schema.js\";\n\n// Fetch the runtime module via process.getBuiltinModule (node >=22.3): avoids static\n// resolution of `node:sqlite` by bundlers/vite (some tools' builtin lists don't yet\n// recognize this experimental module).\nconst sqlite = process.getBuiltinModule(\"node:sqlite\");\n\n/**\n * Opens an EXISTING database for a narrow write, without running any schema work.\n *\n * `openDatabase` migrates — CREATE TABLE, several ensureColumn calls, a DROP INDEX and a backfill.\n * That is right for the process that owns the database and wrong for a short-lived CLI writing\n * one row (`penguin auth token`): after `penguin update` the CLI on disk can be NEWER than the\n * running server, and migrating under a live server's prepared statements is not something a\n * token mint should do. The caller has already established that the file exists.\n */\nexport function openExistingDatabase(dbPath: string): DatabaseSync {\n const db = new sqlite.DatabaseSync(dbPath);\n db.exec(\"PRAGMA foreign_keys = ON;\");\n // Wait for the live server's write lock instead of failing SQLITE_BUSY at once.\n db.exec(\"PRAGMA busy_timeout = 5000;\");\n return db;\n}\n\n/**\n * Opens an EXISTING database for reading only — SQLite refuses every write on the connection,\n * so a reader cannot change the file even by mistake. For a question asked of a database some\n * other process owns (`penguin server status`, run over ssh by a controller): the answer must\n * not reshape that server's schema, and must not be able to. The caller has established that\n * the file exists; a read-only open of a missing file is an error, not a creation.\n */\nexport function openDatabaseReadOnly(dbPath: string): DatabaseSync {\n const db = new sqlite.DatabaseSync(dbPath, { readOnly: true });\n db.exec(\"PRAGMA busy_timeout = 5000;\");\n return db;\n}\n\n/** Open (creating if necessary) the database: ensure the parent directory exists, set PRAGMAs, run table creation. */\nexport function openDatabase(dbPath: string): DatabaseSync {\n if (dbPath !== \":memory:\") {\n mkdirSync(path.dirname(dbPath), { recursive: true });\n }\n const db = new sqlite.DatabaseSync(dbPath);\n db.exec(\"PRAGMA journal_mode = WAL;\");\n // A second connection (the CLI minting a token while the server runs) waits for the write\n // lock rather than failing SQLITE_BUSY at once.\n db.exec(\"PRAGMA busy_timeout = 5000;\");\n db.exec(\"PRAGMA foreign_keys = ON;\");\n db.exec(SCHEMA_SQL);\n // Columns added to the schema after a web.db was formed: CREATE TABLE IF NOT EXISTS never\n // touches an existing table, so they are ALTERed in here. Keep the list in sync with\n // schema.ts; drop entries only in a release allowed to break existing web.db files.\n ensureColumn(db, \"sessions\", \"client\", \"TEXT\");\n ensureColumn(db, \"sessions\", \"has_trace\", \"INTEGER NOT NULL DEFAULT 0\");\n ensureColumn(db, \"sessions\", \"fork_count\", \"INTEGER NOT NULL DEFAULT 0\");\n ensureColumn(db, \"sessions\", \"thinking_level\", \"TEXT\");\n ensureColumn(db, \"sessions\", \"sandbox\", \"TEXT\");\n // A v0.2.0 web.db has auth_sessions without `via`; CREATE TABLE IF NOT EXISTS never adds a\n // column, so every INSERT would fail \"no column named via\" until this runs.\n ensureColumn(db, \"auth_sessions\", \"via\", \"TEXT\");\n ensureColumn(db, \"trace_files\", \"page_stats\", \"TEXT\");\n ensureColumn(db, \"messaging_bindings\", \"line_per_message\", \"INTEGER NOT NULL DEFAULT 0\");\n ensureColumn(db, \"messaging_bindings\", \"final_reply_only\", \"INTEGER NOT NULL DEFAULT 0\");\n // DEFAULT 1, unlike every other added flag here: this one's default is the CORRECTED\n // behaviour, not the previous one. Sending the model's Markdown as characters was the\n // defect, so an existing binding starts rendering it — a visible change to every relayed\n // message, recorded in the release's backward-compatibility entry.\n ensureColumn(db, \"messaging_bindings\", \"render_markdown\", \"INTEGER NOT NULL DEFAULT 1\");\n ensureColumn(db, \"messaging_bindings\", \"last_inbound_message_id\", \"TEXT\");\n // Superseded by idx_usage_session_ts (session_id, ts), which SCHEMA_SQL just created on\n // this database: the old index is a strict prefix of it, so every query it served is\n // served identically. Dropping is safe — an index is derived, never data.\n db.exec(\"DROP INDEX IF EXISTS idx_usage_session\");\n // The uniqueness index of the old binding model, where a bot account belonged to one\n // Session forever and a second Session could not even SAVE its credentials. Enabling a\n // connection is the binding now, so exclusivity is checked per enable (the state route's\n // 409 account_enabled_elsewhere) and the same account may sit saved on many Sessions —\n // which this index would reject. SCHEMA_SQL has just created idx_messaging_by_account\n // over the same columns, so the by-account lookup stays indexed. Dropping is safe: an\n // index is derived, never data. It is however ONE-WAY — an older build recreates the\n // unique index on open, which fails outright once duplicate (channel, account_id) rows\n // exist, so a downgrade after two Sessions saved the same bot needs those rows removed.\n db.exec(\"DROP INDEX IF EXISTS idx_messaging_account\");\n upgradeLastActiveAt(db);\n // The runtime's own open is the ONLY place a contract migration is applied: it owns the\n // process, so there is no boot to roll back out from under it.\n migrate(db);\n return db;\n}\n\n/**\n * One-time `sessions.last_active_at` upgrade, ALTER + backfill in a single transaction.\n *\n * The backfill runs **only in the open that actually adds the column** (SQLite's ALTER\n * TABLE ADD COLUMN is transactional, so a crash mid-upgrade rolls back to \"no column\" and\n * the next open redoes both halves — never a half-migrated table, and never a full\n * `sessions` scan on the millions of opens that follow). Legacy rows take the session's\n * most recent request timestamp — usage_records, covered by idx_usage_session_ts, the\n * closest persisted proxy for \"last Trace activity\" — else their own created_at.\n */\nfunction upgradeLastActiveAt(db: DatabaseSync): void {\n db.exec(\"BEGIN\");\n try {\n if (ensureColumn(db, \"sessions\", \"last_active_at\", \"TEXT\")) {\n db.exec(\n `UPDATE sessions SET last_active_at = COALESCE(\n (SELECT MAX(ts) FROM usage_records WHERE usage_records.session_id = sessions.session_id),\n created_at\n )`,\n );\n }\n db.exec(\"COMMIT\");\n } catch (err) {\n db.exec(\"ROLLBACK\");\n throw err;\n }\n}\n\n/**\n * Idempotent per-column upgrade for databases formed before the column existed.\n * Returns whether this call actually ALTERed the table (false = the column was already\n * there), so a caller can gate one-time backfill work on it.\n */\nexport function ensureColumn(\n db: DatabaseSync,\n table: string,\n column: string,\n ddl: string,\n): boolean {\n const cols = db.prepare(`PRAGMA table_info(${table})`).all() as { name: string }[];\n if (cols.some((c) => c.name === column)) return false;\n db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${ddl}`);\n return true;\n}\n","/**\n * Applies and reverts named migrations against the `schema_migrations` ledger.\n *\n * The ledger records which migrations ran, by NAME, one row each, in the order they ran (a\n * rowid table hands every insert max(rowid) + 1, so rowid order is application order). The\n * migration list is passed in, so this file knows nothing about any particular migration.\n */\nimport type { DatabaseSync } from \"node:sqlite\";\nimport type { Migration } from \"./migration.js\";\n\n/**\n * The ledger's DDL. An on-disk contract shared by every build that has the ledger: an older\n * one reads the names a newer one wrote. Never changed, only ever created.\n */\nconst LEDGER_DDL = `\n CREATE TABLE IF NOT EXISTS schema_migrations (\n name TEXT PRIMARY KEY,\n applied_at TEXT NOT NULL\n );\n`;\n\nexport class IrreversibleMigrationError extends Error {\n constructor(readonly migration: string) {\n super(\n `migration ${migration} declares no down and cannot be rolled back. ` +\n `Restore the database from a backup taken before it was applied.`,\n );\n this.name = \"IrreversibleMigrationError\";\n }\n}\n\nexport class UnknownMigrationError extends Error {\n constructor(readonly migration: string) {\n super(\n `migration ${migration} was applied by a build that knows it, and this build does not: ` +\n `it cannot roll it back. Roll back with that build.`,\n );\n this.name = \"UnknownMigrationError\";\n }\n}\n\nfunction hasLedger(db: DatabaseSync): boolean {\n return (\n db\n .prepare(\"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'schema_migrations'\")\n .get() !== undefined\n );\n}\n\n/** The names this database's ledger records, in the order they were applied. Empty without a ledger. */\nexport function appliedMigrations(db: DatabaseSync): string[] {\n if (!hasLedger(db)) return [];\n const rows = db.prepare(\"SELECT name FROM schema_migrations ORDER BY rowid\").all() as {\n name: string;\n }[];\n return rows.map((r) => r.name);\n}\n\nexport interface MigrateResult {\n /**\n * True when this run found no ledger and created it: a database from before the ledger\n * (stamped with a `user_version` whose meaning depended on the line that wrote it), or a\n * new one. Every migration then ran, as the re-runnable no-op it is where its work was done.\n */\n adopted: boolean;\n /** Names applied by this run, in order. */\n applied: readonly string[];\n /**\n * Contract migrations this swap-path run left pending, in order: the runtime's next open on\n * a build that declares them applies them. Always empty off the swap path.\n */\n deferred: readonly string[];\n}\n\n/**\n * Applies every migration in `migrations` whose name the ledger does not record, in\n * declaration order. Names in the ledger that `migrations` does not declare — written by a\n * newer build or another line — are left alone.\n *\n * Each migration and its ledger row commit together, so an interrupted run leaves the database\n * with exactly the migrations that fully applied. An already-current database does no work\n * and writes nothing. `PRAGMA user_version` is neither read nor written.\n *\n * `swapPath` marks the caller as a booting pushed platform. Expand migrations apply as\n * anywhere else; contract migrations (`swapSafe: false`) are skipped and stay pending — never\n * applied, never a reason to refuse the boot — and are returned in `deferred`. A hot push\n * therefore never fails on a migration it is not allowed to run, and never runs a contract.\n * (One expand removes something all the same: see steps/2026-09-16-company-mode-channels.ts.)\n */\nexport function migrate(\n db: DatabaseSync,\n migrations: readonly Migration[],\n { swapPath = false }: { swapPath?: boolean } = {},\n): MigrateResult {\n const adopted = !hasLedger(db);\n const done = new Set(appliedMigrations(db));\n const pending = migrations.filter((m) => !done.has(m.name));\n if (adopted) db.exec(LEDGER_DDL);\n const record = db.prepare(\"INSERT INTO schema_migrations (name, applied_at) VALUES (?, ?)\");\n const applied: string[] = [];\n const deferred: string[] = [];\n for (const m of pending) {\n if (swapPath && !m.swapSafe) {\n deferred.push(m.name);\n continue;\n }\n db.exec(\"BEGIN\");\n try {\n m.up(db);\n record.run(m.name, new Date().toISOString());\n db.exec(\"COMMIT\");\n } catch (err) {\n db.exec(\"ROLLBACK\");\n throw new Error(`migration ${m.name} failed: ${String(err)}`, { cause: err });\n }\n applied.push(m.name);\n }\n return { adopted, applied, deferred };\n}\n\n/**\n * Reverts every migration applied AFTER `target`, newest-applied first; `null` reverts them\n * all. Order is the ledger's, not the declaration's: a migration another line's build applied\n * late is undone early.\n *\n * An operator's tool, and a destructive one — see each migration's `down` for what its rows\n * were. Deliberately not reachable from the swap path: a failed platform boot reverts the\n * PLATFORM, never the schema.\n *\n * Refused whole, before anything runs, when any migration in range declares no `down` or is\n * not one this build declares. Each `down` commits with the removal of its ledger row, so an\n * interrupted run stops between two whole steps.\n */\nexport function rollbackTo(\n db: DatabaseSync,\n migrations: readonly Migration[],\n target: string | null,\n): { reverted: readonly string[] } {\n const applied = appliedMigrations(db);\n const from = target === null ? 0 : applied.indexOf(target) + 1;\n if (target !== null && from === 0) {\n throw new Error(`migration ${target} is not applied to this database`);\n }\n const byName = new Map(migrations.map((m) => [m.name, m]));\n const toRevert = applied.slice(from).reverse();\n for (const name of toRevert) {\n const m = byName.get(name);\n if (m === undefined) throw new UnknownMigrationError(name);\n if (m.down === null) throw new IrreversibleMigrationError(name);\n }\n const forget = db.prepare(\"DELETE FROM schema_migrations WHERE name = ?\");\n const reverted: string[] = [];\n for (const name of toRevert) {\n db.exec(\"BEGIN\");\n try {\n byName.get(name)!.down!(db);\n forget.run(name);\n db.exec(\"COMMIT\");\n } catch (err) {\n db.exec(\"ROLLBACK\");\n throw new Error(`rollback of migration ${name} failed: ${String(err)}`, { cause: err });\n }\n reverted.push(name);\n }\n return { reverted };\n}\n","import type { Migration } from \"../migration.js\";\n\nexport const messagingBindings: Migration = {\n name: \"2026-08-31-messaging-bindings\",\n // Purely additive: one new table plus three indexes, no column of any existing table\n // touched. A platform that rolls back to a predecessor without messaging simply never\n // queries them.\n swapSafe: true,\n up(db) {\n // 0.2.4 → 0.2.7. Frozen copy of the DDL as of 0.2.7; do not re-derive from schema.ts.\n // IF NOT EXISTS because a database from before migrations may already be at 0.2.7, and\n // because every migration is re-runnable (see index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS messaging_bindings (\n session_id TEXT NOT NULL,\n channel TEXT NOT NULL,\n account_id TEXT NOT NULL,\n config_json TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 0,\n line_per_message INTEGER NOT NULL DEFAULT 0,\n last_chat_id TEXT,\n last_chat_is_direct INTEGER NOT NULL DEFAULT 1,\n last_inbound_message_id TEXT,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (session_id, channel)\n );\n CREATE INDEX IF NOT EXISTS idx_messaging_by_account ON messaging_bindings(channel, account_id);\n CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);\n CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);\n `);\n },\n // LOSES every messaging binding: the channel credentials, the enabled flag and the\n // last-chat memory all live in the table this drops. The auth_sessions indexes are\n // derived and cost nothing to lose.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_auth_sessions_user;\n DROP INDEX IF EXISTS idx_auth_sessions_expires;\n DROP INDEX IF EXISTS idx_messaging_by_account;\n DROP TABLE IF EXISTS messaging_bindings;\n `);\n },\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const messagingDeliveryFlags: Migration = {\n name: \"2026-08-31-messaging-delivery-flags\",\n // Two columns with defaults on an existing table: a rollback to a predecessor that\n // does not know them leaves them at their defaults and reads nothing.\n swapSafe: true,\n up(db) {\n // 0.2.7 → 0.2.8. ensureColumn rather than a bare ALTER because the declarative\n // track may already have added these (see RE-RUNNABLE in index.ts).\n ensureColumn(db, \"messaging_bindings\", \"final_reply_only\", \"INTEGER NOT NULL DEFAULT 0\");\n ensureColumn(db, \"messaging_bindings\", \"render_markdown\", \"INTEGER NOT NULL DEFAULT 1\");\n },\n // LOSES both delivery preferences on every binding; the bindings themselves survive.\n // Dropped in reverse order for symmetry with `up`. Neither column is indexed, which is\n // what lets SQLite drop them at all.\n down(db) {\n db.exec(\"ALTER TABLE messaging_bindings DROP COLUMN render_markdown\");\n db.exec(\"ALTER TABLE messaging_bindings DROP COLUMN final_reply_only\");\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const dropGoalState: Migration = {\n name: \"2026-09-02-drop-goal-state\",\n // CONTRACT: drops a table no build since 0.2.10 reads. A pushed platform rolled back to\n // 0.2.9 mid-process would prepare its goal statements against a table that is gone, so it\n // is left pending on the swap path and applied by the runtime's own open; until then the\n // table just sits there unused.\n swapSafe: false,\n up(db) {\n // 0.2.9 → 0.2.10. goal_state held goal mode's run state, one row per goal run, read\n // back only for the chat page's goal banner; the goal plugin's GOAL.json in the\n // Session scratchpad is that record now (see runtime/goal-events.ts). IF EXISTS only\n // because a database this build created never had the table.\n db.exec(`\n DROP INDEX IF EXISTS idx_goal_session;\n DROP TABLE IF EXISTS goal_state;\n `);\n },\n // Recreates the table exactly as 0.2.9 declared it — EMPTY. LOSES every goal run ever\n // recorded (objective, status, budget, used, rounds per run): the rows only ever fed the\n // banner of a finished goal, and a build with this migration reads the goal file instead.\n down(db) {\n db.exec(`\n CREATE TABLE IF NOT EXISTS goal_state (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n session_id TEXT NOT NULL,\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n objective TEXT NOT NULL,\n status TEXT NOT NULL,\n budget INTEGER NOT NULL,\n used INTEGER NOT NULL DEFAULT 0,\n rounds INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const machines: Migration = {\n name: \"2026-09-04-machines\",\n // Three new tables, nothing existing touched: a platform rolled back to one without\n // machines never queries them. Swap-safe on its own; a database still behind\n // 2026-09-02-drop-goal-state is refused whole by that one first, which is the rule.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the machines feature; do not re-derive from schema.ts.\n // IF NOT EXISTS because the declarative track may already have created them (RE-RUNNABLE in index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS machine (\n singleton INTEGER PRIMARY KEY CHECK (singleton = 1),\n machine_id TEXT NOT NULL\n );\n CREATE TABLE IF NOT EXISTS machines (\n address TEXT PRIMARY KEY,\n machine_id TEXT,\n version TEXT,\n installed_at TEXT,\n session_pid INTEGER,\n remote_port INTEGER,\n platform TEXT\n );\n CREATE TABLE IF NOT EXISTS machine_project (\n project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,\n addresses TEXT NOT NULL\n );\n `);\n },\n // LOSES this server's own machine id (every stored reference to it on other machines\n // then points at nothing), what was installed where, the sessions held, and which\n // machines each Project used.\n down(db) {\n db.exec(`\n DROP TABLE IF EXISTS machine_project;\n DROP TABLE IF EXISTS machines;\n DROP TABLE IF EXISTS machine;\n `);\n },\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const userProfile: Migration = {\n name: \"2026-09-14-user-profile\",\n // Two nullable columns on `users`, no default and nothing existing rewritten: a platform\n // rolled back to a predecessor that does not know them never selects or writes them, and\n // every existing account simply reads NULL — no profile yet.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the user-profile feature; do not re-derive from schema.ts.\n // SQLite has no ADD COLUMN IF NOT EXISTS, and the declarative track may already have\n // added both (RE-RUNNABLE in index.ts), so each one goes through the same table_info guard the\n // declarative track uses.\n ensureColumn(db, \"users\", \"display_name\", \"TEXT\");\n ensureColumn(db, \"users\", \"avatar\", \"TEXT\");\n },\n // LOSES every stored nickname and avatar: both live entirely in the two columns this\n // drops, and nothing else on disk holds a copy. Dropped in reverse order for symmetry\n // with `up`; neither column is indexed, which is what lets SQLite drop them at all.\n down(db) {\n const cols = db.prepare(\"PRAGMA table_info(users)\").all() as { name: string }[];\n const has = (name: string): boolean => cols.some((c) => c.name === name);\n if (has(\"avatar\")) db.exec(\"ALTER TABLE users DROP COLUMN avatar\");\n if (has(\"display_name\")) db.exec(\"ALTER TABLE users DROP COLUMN display_name\");\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const companyModeChannels: Migration = {\n name: \"2026-09-16-company-mode-channels\",\n // The organization's single group chat became channels: a message lives in\n // `channels/<channel_id>/<date>.jsonl`, so the tail-scan cursor and each user's read\n // cursor are per channel. `org_chat_state` / `org_chat_reads` are dropped for\n // `org_channel_state` / `org_channel_reads`: the tables are renamed and the channel\n // belongs in the primary key, neither of which SQLite does in place. Nothing is carried\n // over: company mode is unreleased, and every row was either rebuildable from the files\n // (the scan cursor) or one pass of \"everything looks unread\" (the read cursor). A\n // predecessor build reads neither table's shape, only its own writes, so this is\n // swap-safe.\n //\n // By the expand / contract rule (migration.ts) the two DROPs are a contract, and this is the\n // one expand that removes anything on the swap path. It stays an expand because it is frozen\n // and cannot be deferred: the same step creates the channel tables a pushed platform needs.\n // A new change of this kind is split into an expand and a later contract instead.\n swapSafe: true,\n up(db) {\n db.exec(`\n DROP TABLE IF EXISTS org_chat_state;\n DROP TABLE IF EXISTS org_chat_reads;\n CREATE TABLE IF NOT EXISTS org_channel_state ( -- DERIVED CACHE (company mode): tail-scan byte cursor per channel and day file\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n date TEXT NOT NULL,\n offset_bytes INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, channel_id, date)\n );\n CREATE TABLE IF NOT EXISTS org_channel_reads ( -- user data (company mode): each user's read cursor in one channel\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n user_id TEXT NOT NULL,\n last_read_id TEXT NOT NULL,\n PRIMARY KEY (project_id, org_id, channel_id, user_id)\n );\n `);\n },\n // Recreates the two tables under their old names, exactly as 2026-09-16-company-mode-org-caches declared them —\n // EMPTY. LOSES every scan cursor (re-derived by the next pass, which republishes the\n // messages it re-reads) and every read cursor (users see the recent days as unread once).\n down(db) {\n db.exec(`\n DROP TABLE IF EXISTS org_channel_state;\n DROP TABLE IF EXISTS org_channel_reads;\n CREATE TABLE IF NOT EXISTS org_chat_state ( -- DERIVED CACHE (company mode): tail-scan byte cursor per chat day file\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n date TEXT NOT NULL,\n offset_bytes INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, date)\n );\n CREATE TABLE IF NOT EXISTS org_chat_reads ( -- user data (company mode): each user's read cursor in an organization's chat\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n user_id TEXT NOT NULL,\n last_read_id TEXT NOT NULL,\n PRIMARY KEY (project_id, org_id, user_id)\n );\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const companyModeDeskNotices: Migration = {\n name: \"2026-09-16-company-mode-desk-notices\",\n // Additive: one new table and its index. A ticket change no longer starts a work run at\n // the owner's desk; it is queued here and delivered inside the body of that employee's\n // next calendar sweep. A predecessor build never reads the table, so a rollback survives\n // it — with whatever rows are in it left undelivered.\n swapSafe: true,\n up(db) {\n db.exec(`\n CREATE TABLE IF NOT EXISTS org_desk_notices ( -- DERIVED CACHE (company mode): ticket changes waiting for an employee's next calendar sweep; dropping it loses only the digests not delivered yet\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n change TEXT NOT NULL,\n at TEXT NOT NULL\n );\n CREATE INDEX IF NOT EXISTS idx_org_desk_notices_agent ON org_desk_notices(project_id, org_id, agent_id);\n `);\n },\n // LOSES every queued notice: the ticket changes an employee has not been told about yet.\n // The change itself is in the ticket file and its history either way; what\n // goes is the \"Since your last sweep\" line that would have named it.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_org_desk_notices_agent;\n DROP TABLE IF EXISTS org_desk_notices;\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const companyModeOrgCaches: Migration = {\n name: \"2026-09-16-company-mode-org-caches\",\n // Additive: seven new tables for company mode (organizations of Agents), no change to any\n // existing table. Every row is either rebuildable from the organization's files (desks.toml,\n // the tickets' Sessions headers, the calendar files, usage records) or a user's own read\n // cursor; a predecessor build never touches them.\n swapSafe: true,\n up(db) {\n db.exec(`\n CREATE TABLE IF NOT EXISTS org_sessions ( -- DERIVED CACHE (company mode): desk sessions, rebuilt from each organization's desks.toml (current + previous); trigger_hop is chat-chain accounting and reads 0 after a rebuild\n session_id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n current INTEGER NOT NULL DEFAULT 1,\n trigger_hop INTEGER NOT NULL DEFAULT 0\n );\n CREATE INDEX IF NOT EXISTS idx_org_sessions_org ON org_sessions(project_id, org_id);\n CREATE TABLE IF NOT EXISTS org_ticket_sessions ( -- DERIVED CACHE (company mode): ticket <-> contributing session, rebuilt from each ticket's Sessions header\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n trigger_hop INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, ticket_id, session_id)\n );\n CREATE INDEX IF NOT EXISTS idx_org_ticket_sessions_session ON org_ticket_sessions(session_id);\n CREATE TABLE IF NOT EXISTS org_calendar_state ( -- calendar run state (company mode; the files are declarative intent), same rules as schedule_state\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n name TEXT NOT NULL,\n start_at_ms INTEGER NOT NULL,\n def_hash TEXT NOT NULL,\n last_slot_ms INTEGER,\n last_fired_at TEXT,\n fired_once INTEGER NOT NULL DEFAULT 0,\n missed INTEGER NOT NULL DEFAULT 0,\n invalid_reason TEXT,\n last_outcome TEXT,\n PRIMARY KEY (project_id, org_id, agent_id, name)\n );\n CREATE TABLE IF NOT EXISTS org_ticket_state ( -- DERIVED CACHE (company mode): the last (status, owner, blocked) seen per ticket, so a change is notified once; rebuilt silently from the ticket files\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n status TEXT NOT NULL,\n owner TEXT NOT NULL DEFAULT '',\n blocked TEXT NOT NULL DEFAULT '',\n blocked_by TEXT NOT NULL DEFAULT '',\n PRIMARY KEY (project_id, org_id, ticket_id)\n );\n CREATE TABLE IF NOT EXISTS org_budget_state ( -- DERIVED CACHE (company mode): warn / pause marks per employee and period, recomputed from usage and the chart's budgets\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n period TEXT NOT NULL,\n warned_at TEXT,\n paused_at TEXT,\n PRIMARY KEY (project_id, org_id, agent_id, period)\n );\n `);\n // The two chat tables 2026-09-16-company-mode-channels replaced. Re-running this migration after that\n // one (adoption runs every migration again) must not bring them back, so they are created\n // only while the channel tables that superseded them are absent.\n const superseded =\n db\n .prepare(\"SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'org_channel_state'\")\n .get() !== undefined;\n if (!superseded) {\n db.exec(`\n CREATE TABLE IF NOT EXISTS org_chat_state ( -- DERIVED CACHE (company mode): tail-scan byte cursor per chat day file\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n date TEXT NOT NULL,\n offset_bytes INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, date)\n );\n CREATE TABLE IF NOT EXISTS org_chat_reads ( -- user data (company mode): each user's read cursor in an organization's chat\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n user_id TEXT NOT NULL,\n last_read_id TEXT NOT NULL,\n PRIMARY KEY (project_id, org_id, user_id)\n );\n `);\n }\n },\n // Drops the seven tables. LOSES: chat read cursors (each user's \"read up to here\" marks) and\n // the run marks that stop a calendar slot, a ticket change or a budget alert from firing\n // twice — a build with this migration re-registers everything from the files, without\n // backfilling missed slots, and users see every chat message as unread once.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_org_ticket_sessions_session;\n DROP INDEX IF EXISTS idx_org_sessions_org;\n DROP TABLE IF EXISTS org_budget_state;\n DROP TABLE IF EXISTS org_chat_reads;\n DROP TABLE IF EXISTS org_chat_state;\n DROP TABLE IF EXISTS org_ticket_state;\n DROP TABLE IF EXISTS org_calendar_state;\n DROP TABLE IF EXISTS org_ticket_sessions;\n DROP TABLE IF EXISTS org_sessions;\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const modelPromotions: Migration = {\n name: \"2026-09-17-model-promotions\",\n // Additive: one new table, nothing existing touched. A predecessor build never reads it\n // and prices every row at the number in its Project file, so a rollback survives it.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the model-promotions feature; do not re-derive from\n // schema.ts. IF NOT EXISTS because the declarative track may already have created it\n // (RE-RUNNABLE in index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS model_promotions ( -- NOT a cache rebuildable from files: the only record of a Project row's running promotion (.project_config.toml keeps the list price), written by preset seeding at Project creation, \"sync presets\" and Penguin Go authorization / sync, and read by cost; lost rows price usage at list until the next sync writes them back\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n discount REAL NOT NULL CHECK (discount > 0 AND discount < 1),\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider, model_id)\n );\n `);\n },\n // LOSES every stored promotion: no file holds a copy of the fractions, so usage is priced\n // at the Project files' list prices until the next \"sync presets\" or Penguin Go sync\n // writes them back.\n down(db) {\n db.exec(\"DROP TABLE IF EXISTS model_promotions\");\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const modelProviderAuthTokens: Migration = {\n name: \"2026-09-21-model-provider-auth-tokens\",\n // Additive: one new table, nothing existing touched. A predecessor build never reads it;\n // the access token already written into .project_config.toml keeps working until it\n // expires, so rollback only loses silent refresh state.\n // KEEP until every supported upgrade starts from a database that already has this table.\n // The release that raises the minimum database baseline past it owns removing this\n // migration and its fixture; the table itself remains while provider token refresh uses it.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the provider-auth-token feature; do not re-derive from\n // schema.ts. IF NOT EXISTS because the declarative track may already have created it\n // (RE-RUNNABLE in index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS model_provider_auth_tokens ( -- Server-side OAuth refresh metadata for provider groups. The current request token remains in .project_config.toml as api_key; this table holds only the refresh material that must never be returned to the frontend or written into Project files.\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n refresh_token TEXT NOT NULL,\n access_token_expires_at TEXT,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider)\n );\n `);\n },\n // LOSES every stored refresh token and access-token expiry. Existing access tokens in\n // Project files remain until they expire; users re-authorize to restore silent refresh.\n down(db) {\n db.exec(\"DROP TABLE IF EXISTS model_provider_auth_tokens\");\n },\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const sessionsSandbox: Migration = {\n name: \"2026-09-22-sessions-sandbox\",\n // The Session's own sandbox policy (JSON), snapshotted from the server's settings when\n // the Session is created, so a later settings change leaves running Sessions alone.\n // NULL = a row from before this column: it takes the settings in force at its next\n // command and keeps them from then on. Swap-safe: a pushed platform boots against the\n // runtime's already-open database, so the column has to arrive by a migration.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"sessions\", \"sandbox\", \"TEXT\");\n },\n // Not a DROP: schema.ts declares the column, and dropping it would put every Session\n // back under whatever the settings say.\n down() {},\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const machinesColumns: Migration = {\n name: \"2026-09-23-machines-columns\",\n // A machines table can predate `machines` — a data root that ran the machines line\n // before it was released kept forwards, not sessions — and that migration's IF NOT\n // EXISTS adopted it whatever its columns were, so the first connect failed on the row's\n // insert. Adds what is missing, nullable; a platform rolled back to `machines` declared these\n // columns itself, so there is nothing for it to mind.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"machines\", \"session_pid\", \"INTEGER\");\n ensureColumn(db, \"machines\", \"platform\", \"TEXT\");\n },\n // Nothing to undo: what this added, `machines` already declares, and a column it found in place\n // was never this migration's to take away.\n down() {},\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const modelTablesAdoption: Migration = {\n name: \"2026-09-23-model-tables-adoption\",\n // A data root that ran the chain before it was restacked onto main (2026-09-23) is\n // stamped 16 under THAT line's numbering, where 9 and 10 were 2026-09-22-sessions-sandbox and\n // 2026-09-23-machines-columns. This line numbers main's 2026-09-17-model-promotions and\n // 2026-09-21-model-provider-auth-tokens 9 and 10 and everything after them two higher, so such a\n // root reads both as already applied, reaches 18 without `model_promotions` or\n // `model_provider_auth_tokens`, and answers 500 wherever cost or provider auth reads\n // them (the models page, an organization's overview). Seen on the release instance at\n // the first push of the restacked line.\n //\n // Re-runs 9's and 10's own `up` — the frozen DDL, both `IF NOT EXISTS`, so a root that\n // took them in their proper place finds its work done. The ledger ended the numbering\n // hazard: adopting a numbered root runs 2026-09-17-model-promotions and 2026-09-21-model-provider-auth-tokens\n // themselves. Remove together with the ledger's adoption of numbered roots (index.ts).\n swapSafe: true,\n up(db) {\n db.exec(`\n CREATE TABLE IF NOT EXISTS model_promotions (\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n discount REAL NOT NULL CHECK (discount > 0 AND discount < 1),\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider, model_id)\n );\n CREATE TABLE IF NOT EXISTS model_provider_auth_tokens (\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n refresh_token TEXT NOT NULL,\n access_token_expires_at TEXT,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider)\n );\n `);\n },\n // Nothing to undo: the tables are 2026-09-17-model-promotions' and 2026-09-21-model-provider-auth-tokens', and\n // their own downs drop them.\n down() {},\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const browserExtensions: Migration = {\n name: \"2026-10-04-browser-extensions\",\n // Additive: one new table and its index. The Chrome backend's pairings — one row per\n // PenguinHarness Browser extension a user paired, its token stored hashed. A predecessor\n // build never reads the table, so a rollback survives it; the paired extensions then simply\n // fail to connect until a build that knows them is back.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the system-Chrome feature; do not re-derive from schema.ts.\n // IF NOT EXISTS because the declarative track may already have created it (RE-RUNNABLE in\n // index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS browser_extensions ( -- one row per Chrome paired to a user (builtin-browser/extension-pairing.ts): the PenguinHarness Browser extension's long-lived credential, stored only hashed; NOT rebuildable — a lost row means pairing that Chrome again\n extension_id TEXT PRIMARY KEY,\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n token_hash TEXT NOT NULL UNIQUE,\n name TEXT NOT NULL,\n version TEXT NOT NULL,\n created_at TEXT NOT NULL,\n last_seen_at TEXT,\n revoked_at TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_browser_extensions_user ON browser_extensions(user_id);\n `);\n },\n // LOSES every pairing: each user's Chrome has to be paired again (a new code from the\n // Browser panel) before agents can drive it.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_browser_extensions_user;\n DROP TABLE IF EXISTS browser_extensions;\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const agentApi: Migration = {\n name: \"2026-10-10-agent-api\",\n // Additive: two new tables and an index. The Agent API's per-Agent settings (the switch,\n // keyless access, the approval mode API Sessions are created with) and its keys, stored\n // hashed. A predecessor build never reads them and serves no /api/amsp route, so a\n // rollback survives it: callers get 404 until a build that knows the tables is back.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the Agent API feature; do not re-derive from schema.ts.\n // IF NOT EXISTS because the declarative track may already have created them (ADOPTION).\n db.exec(`\n CREATE TABLE IF NOT EXISTS agent_api ( -- per-Agent public API settings (amsp/routes.ts); NOT rebuildable from files\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 0,\n open INTEGER NOT NULL DEFAULT 0,\n approval_mode TEXT NOT NULL DEFAULT 'allow-all',\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, agent_id)\n );\n CREATE TABLE IF NOT EXISTS agent_api_keys ( -- one row per key the API tab or the CLI minted; the caller alone holds the key\n key_id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n token_hash TEXT NOT NULL UNIQUE,\n prefix TEXT NOT NULL,\n name TEXT NOT NULL,\n created_by TEXT NOT NULL REFERENCES users(user_id),\n created_at TEXT NOT NULL,\n last_used_at TEXT\n );\n CREATE INDEX IF NOT EXISTS idx_agent_api_keys_agent ON agent_api_keys(project_id, agent_id);\n `);\n },\n // LOSES every Agent's API settings and every key: each exposed Agent reads as disabled\n // again (404 to its callers), and every caller needs a newly created key once it is back\n // on — no file holds a copy of either.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_agent_api_keys_agent;\n DROP TABLE IF EXISTS agent_api_keys;\n DROP TABLE IF EXISTS agent_api;\n `);\n },\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const messagingConversations: Migration = {\n name: \"2026-10-12-messaging-conversations\",\n // Additive: one new table. The WeChat connector's per-(bot, user) delivery state — the\n // context token every send must echo, how many sends it has funded, and replies held for\n // the user's next message. A predecessor build never reads it and keeps its tokens in\n // memory as it always did, so a rollback survives it.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the WeChat delivery fix; do not re-derive from schema.ts.\n // IF NOT EXISTS because the declarative track may already have created it (ADOPTION).\n db.exec(`\n CREATE TABLE IF NOT EXISTS messaging_conversations ( -- per-(bot, chat) delivery state a connector must keep across reconnects and restarts (WeChat: the context token, its send count, held replies); deleted once no messaging_bindings row references (channel, account_id) — see MessagingBindingsRepo\n channel TEXT NOT NULL,\n account_id TEXT NOT NULL,\n chat_id TEXT NOT NULL,\n state_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel, account_id, chat_id)\n );\n `);\n },\n // LOSES every stored context token and every held reply: replies sent before each user's\n // next message are refused again, and what was held never arrives.\n down(db) {\n db.exec(\"DROP TABLE IF EXISTS messaging_conversations\");\n },\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const sessionsKeptAt: Migration = {\n name: \"2026-10-12-sessions-kept-at\",\n // The \"keep in sidebar\" mark: written when the user unarchives a Session, cleared when they\n // archive it. The listing rule files a background Session (api / schedule / cli) away unless\n // it carries the mark, so existing rows are listed by the rule as they stand and nothing is\n // rewritten. Swap-safe: a pushed platform boots against the runtime's already-open database,\n // so the column has to arrive by a migration.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"sessions\", \"kept_at\", \"TEXT\");\n },\n // Not a DROP: schema.ts declares the column, and dropping it would file every kept\n // background Session away again.\n down() {},\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const agentApiConcurrency: Migration = {\n name: \"2026-10-14-agent-api-concurrency\",\n // How many of an Agent API Agent's runs may be live at once.\n // Retain while a supported data root can predate this column; the maintainer raising\n // that baseline removes this upgrade once every supported data root has it.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"agent_api\", \"max_concurrent_runs\", \"INTEGER NOT NULL DEFAULT 4\");\n },\n // Keep the configured limit on rollback; older builds ignore this column.\n down() {},\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const agentApiSessionSettings: Migration = {\n name: \"2026-10-14-agent-api-session-settings\",\n // The per-Agent API defaults a new Session starts with: which provider, model, thinking\n // level and workspace it opens in. Nullable additions preserve existing keys, exposure,\n // approvals and new-Session defaults.\n // Retain while a supported data root can predate these columns; the maintainer raising\n // that baseline removes this upgrade after all supported data roots have them.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"agent_api\", \"provider\", \"TEXT\");\n ensureColumn(db, \"agent_api\", \"model_id\", \"TEXT\");\n ensureColumn(db, \"agent_api\", \"thinking_level\", \"TEXT\");\n ensureColumn(db, \"agent_api\", \"workspace\", \"TEXT\");\n },\n // Keep the saved defaults on rollback; older builds ignore the additional columns.\n down() {},\n};\n","import { ensureColumn } from \"../../database.js\";\nimport type { Migration } from \"../migration.js\";\n\nexport const machinesAdoptedAt: Migration = {\n name: \"2026-10-16-machines-adopted-at\",\n // When this server adopted a machine (first recorded it): the Sessions that machine held\n // before then read as CLI tasks through this server (session-sources.ts sourceSeenBy). A\n // machine recorded before this column has none and never gets one, so the rule does not apply\n // to it (user decision, 2026-10-15) — nothing on either side is rewritten. Swap-safe: a pushed platform boots against the runtime's\n // already-open database, so the column has to arrive by a migration.\n swapSafe: true,\n up(db) {\n ensureColumn(db, \"machines\", \"adopted_at\", \"TEXT\");\n },\n // Keep the adoption times on rollback; older builds ignore the column.\n down() {},\n};\n","import type { Migration } from \"../migration.js\";\n\nexport const userDrafts: Migration = {\n name: \"2026-10-26-user-drafts\",\n // Additive: two new tables and two indexes. A user's unsent drafts and the custom background\n // image, which used to live only in the browser that made them. A predecessor build never\n // reads either table, so a rollback survives it; its Web App then keeps drafts and the image\n // in that browser alone, as it always did.\n swapSafe: true,\n up(db) {\n // Frozen copy of the DDL as of the account-preferences change; do not re-derive from\n // schema.ts. IF NOT EXISTS because the declarative track may already have created them\n // (RE-RUNNABLE in index.ts).\n db.exec(`\n CREATE TABLE IF NOT EXISTS user_drafts ( -- a user's unsent drafts (composer text, parked conversations, form answers), so they follow the account to another browser; bounded per user, dropped with the Session or Project they belong to\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n draft_key TEXT NOT NULL,\n session_id TEXT,\n project_id TEXT,\n value_json TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (user_id, draft_key)\n );\n CREATE INDEX IF NOT EXISTS idx_user_drafts_session ON user_drafts(session_id);\n CREATE INDEX IF NOT EXISTS idx_user_drafts_project ON user_drafts(project_id);\n CREATE TABLE IF NOT EXISTS user_background_images ( -- the custom background image of a user's appearance settings, apart from ui_prefs so the megabyte it may hold is read only by the one route that serves it\n user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,\n image TEXT NOT NULL,\n image_id TEXT NOT NULL,\n updated_at TEXT NOT NULL\n );\n `);\n },\n // LOSES every draft and background image the accounts hold on the server. A browser that still\n // caches them keeps showing its own copy; another browser starts without them.\n down(db) {\n db.exec(`\n DROP INDEX IF EXISTS idx_user_drafts_project;\n DROP INDEX IF EXISTS idx_user_drafts_session;\n DROP TABLE IF EXISTS user_drafts;\n DROP TABLE IF EXISTS user_background_images;\n `);\n },\n};\n","/**\n * Named schema migrations, recorded in a ledger.\n *\n * `schema.ts` declares the shape a FRESH database is created with. It cannot express a\n * CHANGE — `CREATE TABLE IF NOT EXISTS` only ever says \"should exist\". A migration says what\n * CHANGED; the `schema_migrations` ledger records, by name, which ones a database has run\n * (runner.ts). Every unrecorded migration is applied on open, in the order declared below.\n *\n * NAMES, NOT NUMBERS. A migration's name is its identity. Migrations used to be numbered and\n * stamped in `PRAGMA user_version`, and parallel PR lines kept giving one number two meanings:\n * a root stamped by one line skipped another line's migrations of the same numbers. Names do\n * not collide that way — two lines that each add a migration add two names, and a root that\n * ran one of them runs the other when it meets it. A build ignores names it does not declare,\n * so an older build keeps running on a database a newer one migrated.\n *\n * FROZEN. A migration spells out its own SQL and never imports SCHEMA_SQL, and once pushed its\n * name and DDL never change: some root may already have recorded it, and a recorded migration\n * never runs again there. A correction is a NEW migration.\n *\n * RE-RUNNABLE. Every `up` must leave a database that already has its effect exactly as it\n * was: `IF NOT EXISTS`, `ensureColumn`, `PRAGMA table_info` guards, and a shape check before\n * any table is rebuilt or data moved. The ledger records names, not shapes, so this is what\n * lets a root be adopted (below), and what lets the declarative track — SCHEMA_SQL and\n * openDatabase's ensureColumn list, which still run first at every runtime open — get there\n * before a migration does.\n *\n * SWAP-SAFE. A pushed platform boots against a live database and is ROLLED BACK to its\n * predecessor if it fails; the predecessor then runs on whatever the migration already did.\n * Additive work survives that, narrowing work does not. Anything that drops, retypes,\n * constrains, or reshapes in a way the predecessor cannot read or write is a CONTRACT\n * (`swapSafe: false`): the swap path leaves it pending and boots without it, and the runtime's\n * own open applies it (migration.ts). A hot push never refuses on a migration.\n *\n * DOWN, AND WHO DOES NOT CALL IT. Every migration declares an undo — or `null` to say it has\n * none. `down` is NOT the hot-update rollback mechanism: a failed platform boot reverts the\n * platform and leaves the schema where the migration put it, which `swapSafe` makes safe.\n * `down` is an operator's tool (`rollbackTo`), and LOSSY by nature: undoing \"add a table\" drops\n * the table with its rows. Each `down` names what its rows were.\n *\n * ADOPTION OF NUMBERED ROOTS. A database with no ledger — one stamped in `user_version` by a\n * build from before the ledger, whatever line it came from, or a new one — runs EVERY\n * migration once, re-runnably, and records them all. `user_version` is not read: the same\n * number meant different migrations on different lines. Nor is it written any more; it stays\n * at whatever the last numbered build left. Remove this adoption once no supported root can\n * still be without the ledger — the release that sets that baseline owns it.\n */\nimport type { DatabaseSync } from \"node:sqlite\";\nimport type { Migration } from \"./migration.js\";\nimport * as runner from \"./runner.js\";\nimport { messagingBindings } from \"./steps/2026-08-31-messaging-bindings.js\";\nimport { messagingDeliveryFlags } from \"./steps/2026-08-31-messaging-delivery-flags.js\";\nimport { dropGoalState } from \"./steps/2026-09-02-drop-goal-state.js\";\nimport { machines } from \"./steps/2026-09-04-machines.js\";\nimport { userProfile } from \"./steps/2026-09-14-user-profile.js\";\nimport { companyModeChannels } from \"./steps/2026-09-16-company-mode-channels.js\";\nimport { companyModeDeskNotices } from \"./steps/2026-09-16-company-mode-desk-notices.js\";\nimport { companyModeOrgCaches } from \"./steps/2026-09-16-company-mode-org-caches.js\";\nimport { modelPromotions } from \"./steps/2026-09-17-model-promotions.js\";\nimport { modelProviderAuthTokens } from \"./steps/2026-09-21-model-provider-auth-tokens.js\";\nimport { sessionsSandbox } from \"./steps/2026-09-22-sessions-sandbox.js\";\nimport { machinesColumns } from \"./steps/2026-09-23-machines-columns.js\";\nimport { modelTablesAdoption } from \"./steps/2026-09-23-model-tables-adoption.js\";\nimport { browserExtensions } from \"./steps/2026-10-04-browser-extensions.js\";\nimport { agentApi } from \"./steps/2026-10-10-agent-api.js\";\nimport { messagingConversations } from \"./steps/2026-10-12-messaging-conversations.js\";\nimport { sessionsKeptAt } from \"./steps/2026-10-12-sessions-kept-at.js\";\nimport { agentApiConcurrency } from \"./steps/2026-10-14-agent-api-concurrency.js\";\nimport { agentApiSessionSettings } from \"./steps/2026-10-14-agent-api-session-settings.js\";\nimport { machinesAdoptedAt } from \"./steps/2026-10-16-machines-adopted-at.js\";\nimport { userDrafts } from \"./steps/2026-10-26-user-drafts.js\";\n\nexport type { Migration } from \"./migration.js\";\nexport { IrreversibleMigrationError, UnknownMigrationError, appliedMigrations } from \"./runner.js\";\nexport type { MigrateResult } from \"./runner.js\";\n\n/**\n * Every migration this build declares, in the order a database that lacks them applies them.\n * A new migration is appended, its name carrying the `yyyy-mm-dd-` date it was introduced\n * (migration.ts); an existing one is never moved, renamed or removed while some root may\n * still lack it.\n */\nexport const MIGRATIONS: readonly Migration[] = [\n messagingBindings,\n messagingDeliveryFlags,\n dropGoalState,\n machines,\n userProfile,\n companyModeOrgCaches,\n companyModeChannels,\n companyModeDeskNotices,\n modelPromotions,\n modelProviderAuthTokens,\n sessionsSandbox,\n machinesColumns,\n browserExtensions,\n agentApi,\n messagingConversations,\n sessionsKeptAt,\n agentApiSessionSettings,\n agentApiConcurrency,\n machinesAdoptedAt,\n modelTablesAdoption,\n userDrafts,\n];\n\n/**\n * Applies every declared migration the ledger does not record. `swapPath` marks the caller as\n * a booting pushed platform, where contract migrations stay pending (runner.ts).\n */\nexport function migrate(\n db: DatabaseSync,\n options: { swapPath?: boolean } = {},\n): runner.MigrateResult {\n return runner.migrate(db, MIGRATIONS, options);\n}\n\n/** Reverts every migration applied after `target` (`null`: all), newest-applied first. */\nexport function rollbackTo(\n db: DatabaseSync,\n target: string | null,\n): { reverted: readonly string[] } {\n return runner.rollbackTo(db, MIGRATIONS, target);\n}\n","/**\n * SQLite table-creation SQL.\n *\n * SQLite stores only indexes and aggregates: users / login sessions / Project authorization /\n * Agent & Session indexes / usage summaries / error records / UI preferences. Agent State,\n * Trace, and Workspace still follow the local directory-based storage rules.\n * Product not yet released: no migration branches — everything is CREATE IF NOT EXISTS, formed\n * once. The one exception: columns added to an existing table after release of a web.db are\n * ALTERed in by the idempotent per-column guard in database.ts (ensureColumn), since CREATE\n * TABLE IF NOT EXISTS never touches an existing table. A *reshaped* index follows the same\n * rule under a new name, with the superseded one dropped on open (idx_usage_session →\n * idx_usage_session_ts): CREATE INDEX IF NOT EXISTS never rebuilds an existing index either.\n */\n\nexport const MODEL_PROMOTIONS_SQL = `\nCREATE TABLE IF NOT EXISTS model_promotions ( -- NOT a cache rebuildable from files: the only record of a Project row's running promotion (.project_config.toml keeps the list price), written by preset seeding at Project creation, \"sync presets\" and Penguin Go authorization / sync, and read by cost; lost rows price usage at list until the next sync writes them back\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n model_id TEXT NOT NULL,\n discount REAL NOT NULL CHECK (discount > 0 AND discount < 1), -- fraction off the row's list price: billed = list × (1 − discount)\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider, model_id)\n);`;\n\nexport const MODEL_PROVIDER_AUTH_TOKENS_SQL = `\nCREATE TABLE IF NOT EXISTS model_provider_auth_tokens ( -- Server-side OAuth refresh metadata for provider groups. The current request token remains in .project_config.toml as api_key; this table holds only the refresh material that must never be returned to the frontend or written into Project files.\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n provider TEXT NOT NULL,\n refresh_token TEXT NOT NULL,\n access_token_expires_at TEXT,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, provider)\n);`;\n\nexport const SCHEMA_SQL = `\nCREATE TABLE IF NOT EXISTS users (\n user_id TEXT PRIMARY KEY, -- semantic id doubles as login name: ^[a-z][a-z0-9_-]{1,31}$\n password_hash TEXT NOT NULL, -- scrypt$N$r$p$salt$hash(base64)\n is_admin INTEGER NOT NULL DEFAULT 0, -- 1 for the built-in admin (seeded at startup)\n password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it\n display_name TEXT, -- nickname shown in place of user_id (1-32 characters); NULL = never set, surfaces show the id\n avatar TEXT, -- avatar as a data URL (image/png|jpeg|webp, <= 131072 characters); NULL = never set, surfaces draw the letter placeholder\n created_at TEXT NOT NULL\n);\n-- Server-side sessions backing the HttpOnly cookie: the cookie carries a 32-byte random\n-- token, the row stores only its sha256. A session outlives a restart (it is on disk), which\n-- is why the model is a row and not a signed statement.\nCREATE TABLE IF NOT EXISTS auth_sessions (\n token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n created_at TEXT NOT NULL,\n expires_at TEXT NOT NULL, -- 30-day sliding renewal, topped up in place when <29 days remain\n via TEXT -- 'password'|'desktop'|'setup'|'cli'|'remote'; NULL = legacy (password)\n);\n-- deleteExpired runs on every login and mint, deleteByUser on every admin reset; without\n-- these both scan the whole table.\nCREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);\nCREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);\n\nCREATE TABLE IF NOT EXISTS projects (\n project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml\n owner_user_id TEXT NOT NULL REFERENCES users(user_id),\n created_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS project_members ( -- member grants only; owners are not stored here\n project_id TEXT NOT NULL REFERENCES projects(project_id) ON DELETE CASCADE,\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n created_at TEXT NOT NULL,\n PRIMARY KEY (project_id, user_id)\n);\n${MODEL_PROMOTIONS_SQL}\n${MODEL_PROVIDER_AUTH_TOKENS_SQL}\nCREATE TABLE IF NOT EXISTS agents ( -- index only; name/description live in system_config.yaml\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n created_at TEXT NOT NULL,\n PRIMARY KEY (project_id, agent_id)\n);\nCREATE TABLE IF NOT EXISTS sessions (\n session_id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n provider TEXT NOT NULL, -- provider group of the session's CURRENT model (paired with model_id as a model reference; moves with each in-session switch)\n model_id TEXT NOT NULL, -- upstream model id of the current model (sent to MMSP as-is; never concatenate <provider>/<id>)\n workspace TEXT NOT NULL,\n approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- allow-all|deny-all|read-only|always-ask\n thinking_level TEXT, -- level pinned for THIS session (none|low|medium|high|xhigh|max); NULL=not pinned, runs follow the Agent config\n title TEXT, -- auto-generated by the model after the first exchange; NULL=not yet (frontend shows \"New chat\")\n archived_at TEXT, -- the user archived it (ISO time); NULL = not archived by hand (the listing rule may still file it away)\n kept_at TEXT, -- \"keep in sidebar\": set when the user unarchives, cleared when they archive; NULL = the listing rule decides (a background source is archived, a user Session is active). Never written by a run.\n client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace) | 'org' (company mode) | 'api' (an Agent API run); NULL = legacy row, treated as web\n has_trace INTEGER NOT NULL DEFAULT 0, -- cache: a Trace record exists (set at task start / adoption / subagent registration; lazily backfilled by list hydration)\n fork_count INTEGER NOT NULL DEFAULT 0, -- monotonically allocated child-fork number for this source Session; never decremented when a fork is deleted\n sandbox TEXT, -- the Session's sandbox policy snapshot (JSON SandboxSettings); NULL = legacy row, snapshotted at its next command\n last_active_at TEXT, -- last activity this server drove for the session (ISO; stamped once when a run starts and once when it ends, initialized to created_at); monotonic, never moves backwards; NULL only before openDatabase's one-time backfill\n created_at TEXT NOT NULL\n); -- the Session's SOURCE (user/api/schedule/subagent/cli/company) is NOT stored: session_meta in the Trace is the single source of truth (see runtime/session-sources.ts); \"client\" is a different, DB-only axis (who created the row), read for the source only where the Trace records none ('org' → company)\nCREATE INDEX IF NOT EXISTS idx_sessions_agent_created ON sessions(project_id, agent_id, created_at DESC);\nCREATE TABLE IF NOT EXISTS usage_records (\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n date TEXT NOT NULL, -- local date yyyy-mm-dd (aggregation key; same convention as Trace date directories)\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n session_id TEXT NOT NULL, -- top-level Session (child-session usage counts toward its main Session)\n origin_session_id TEXT, -- immediate source child Session (last of the origin chain); NULL=main session\n provider TEXT NOT NULL, -- provider group: paired with model_id as the attribution key; always GROUP BY provider, model_id\n model_id TEXT NOT NULL, -- upstream model id (paired with provider; same model_id across providers aggregates separately)\n cache_read INTEGER NOT NULL,\n cache_write INTEGER NOT NULL,\n output INTEGER NOT NULL,\n total INTEGER NOT NULL, -- from token_usage.request (one row per Request)\n status TEXT NOT NULL DEFAULT 'completed' -- request outcome: completed=success (with tokens); others=failure (0 tokens, for success rate)\n); -- cost is not stored: computed at query time from current pricing\nCREATE INDEX IF NOT EXISTS idx_usage_project_date ON usage_records(project_id, date);\nCREATE INDEX IF NOT EXISTS idx_usage_session_ts ON usage_records(session_id, ts); -- (session_id) alone was idx_usage_session (dropped on open): ts makes the last_active_at backfill's MAX(ts) a covering index scan instead of a random row lookup per usage record\nCREATE TABLE IF NOT EXISTS error_records ( -- server-side error capture (the Costs page's \"Errors\" tab)\n id INTEGER PRIMARY KEY AUTOINCREMENT,\n ts TEXT NOT NULL,\n date TEXT NOT NULL, -- local date yyyy-mm-dd (same convention as usage_records)\n project_id TEXT, -- nullable: sign-in/registration and process-level errors have no Project context\n agent_id TEXT,\n session_id TEXT,\n source TEXT NOT NULL, -- http | session | usage | title | subagent | process | llm | environment | compaction | schedule | messaging | organization | id_suggest | plugin\n kind TEXT NOT NULL, -- expected (HttpError, business 4xx) | unexpected (500/runtime)\n code TEXT NOT NULL, -- HttpError.code / internal / session_run_failed / ...\n status INTEGER, -- HTTP status code; NULL for non-HTTP sources\n message TEXT NOT NULL -- truncated to 500 chars (no stack stored: stacks go to logs only)\n);\nCREATE INDEX IF NOT EXISTS idx_error_project_date ON error_records(project_id, date);\nCREATE TABLE IF NOT EXISTS schedule_state ( -- schedule runtime state (files are declarative intent; the system never writes back)\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n name TEXT NOT NULL, -- file name (without .toml) doubles as the identifier\n creator_user_id TEXT, -- creator (recorded on API create; hand-edited file reconciliation falls back to the Project owner)\n start_at_ms INTEGER NOT NULL, -- defines identity: a start_at change counts as a new task instance, resetting fire state\n def_hash TEXT NOT NULL, -- file content fingerprint: any change clears the invalid flag (the file takes effect again after edits)\n last_slot_ms INTEGER, -- latest due slot already consumed (advances on fire or skip; no re-fire across restarts)\n last_fired_at TEXT, -- last actual fire time (for display)\n fired_once INTEGER NOT NULL DEFAULT 0, -- one-shot task has fired\n missed INTEGER NOT NULL DEFAULT 0, -- one-shot task was missed (flagged by startup/registration reconciliation; not backfilled)\n invalid_reason TEXT, -- invalidation reason (e.g. the bound Session was deleted); NULL=healthy\n PRIMARY KEY (project_id, agent_id, name)\n);\nCREATE TABLE IF NOT EXISTS messaging_bindings ( -- Session ↔ messaging-channel bot bindings (runtime/messaging/ holds the long connections)\n session_id TEXT NOT NULL, -- a Session keeps at most one saved config PER channel (composite PK below); at most ONE of them is enabled at a time (route-enforced, 409 another_channel_enabled), and enabling is what binds the account to this Session\n channel TEXT NOT NULL, -- messaging channel discriminator ('feishu' | 'telegram' | 'qq' | 'wechat')\n account_id TEXT NOT NULL, -- channel-scoped bot/app identity (feishu: app_id; telegram: the bot token's numeric id; qq: the bot's App ID; wechat: the scanned bot id); NOT unique — any number of Sessions may keep the same account saved side by side, each with its own config and last-chat memory; only ENABLING is exclusive (route-enforced, 409 account_enabled_elsewhere: one account has one event stream, two live connections on it would race)\n config_json TEXT NOT NULL, -- channel-specific credentials/config JSON (feishu: appId/appSecret/baseDomain; telegram: botToken; qq: appId/appSecret; wechat: botId/botToken/baseUrl/userId); secrets plaintext at rest (same trade-off as the proxy address in server_settings), masked at every API surface; a cleared secret is stored as \"\" (the row and its account identity stay)\n enabled INTEGER NOT NULL DEFAULT 0, -- INTENT state (the connection's runtime status stays in memory): new bindings start disabled — saving credentials never opens a connection, the explicit state toggle does\n line_per_message INTEGER NOT NULL DEFAULT 0, -- delivery preference: 1 = every non-blank line of a relayed assistant reply is sent as its own message (a reply written as spoken lines arrives as spoken lines), 0 = one message per reply. Channel-agnostic, so it lives here rather than in config_json, which each connector owns\n final_reply_only INTEGER NOT NULL DEFAULT 0, -- delivery preference: 1 = a run's intermediate assistant messages are held and only its LAST completed one is sent, at the run's end, 0 = every completed assistant message mirrors the moment it completes. Channel-agnostic like the column above and independent of it: with both set, that one final text is the one split per line\n render_markdown INTEGER NOT NULL DEFAULT 1, -- delivery preference: 1 = a relayed reply's Markdown is rendered in the channel's own markup (Telegram HTML, a Feishu card, QQ markdown, the subset WeChat itself reads), 0 = the reply's characters are sent as written. DEFAULT 1 because the model writes Markdown and showing it raw was the defect; the per-channel subsets and the plain-text fallback live in the connectors\n last_chat_id TEXT, -- most recent inbound chat (NULL until the bot is messaged once; replies and test messages target it); CONNECTOR-OPAQUE, not necessarily a bare chat id — telegram appends the forum topic (see telegram-connector.ts chatRefOf), so only that channel's connector may interpret it\n last_chat_is_direct INTEGER NOT NULL DEFAULT 1, -- 1 = direct chat (reply by chat id), 0 = group chat (prefer reply-to-message)\n last_inbound_message_id TEXT, -- channel id of the most recently PROCESSED inbound message (NULL until one arrives); the durable half of the bridge's redelivery guard, so a channel replaying an event this server already turned into a Task after a restart does not run it twice\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (session_id, channel)\n);\nCREATE INDEX IF NOT EXISTS idx_messaging_by_account ON messaging_bindings(channel, account_id); -- serves the enable guard's by-account lookup; deliberately NOT unique, unlike its predecessor idx_messaging_account (dropped on open) which made an account exclusive to one Session forever\nCREATE TABLE IF NOT EXISTS messaging_conversations ( -- per-(bot, chat) delivery state a connector must keep across reconnects and restarts (WeChat: the context token, its send count, held replies); deleted once no messaging_bindings row references (channel, account_id) — see MessagingBindingsRepo\n channel TEXT NOT NULL, -- messaging channel discriminator (only 'wechat' writes rows today)\n account_id TEXT NOT NULL, -- the bot, as messaging_bindings.account_id names it\n chat_id TEXT NOT NULL, -- the connector's own chat id (wechat: the user's ilink_user_id)\n state_json TEXT NOT NULL, -- connector-owned JSON (wechat: {contextToken, tokenAt, spent, held, heldSince}); the token is a conversation handle at rest, same trade-off as config_json's credentials\n updated_at TEXT NOT NULL,\n PRIMARY KEY (channel, account_id, chat_id)\n);\nCREATE TABLE IF NOT EXISTS ui_prefs (\n user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,\n prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON\n);\nCREATE TABLE IF NOT EXISTS user_drafts ( -- a user's unsent drafts (composer text, parked conversations, form answers), so they follow the account to another browser; bounded per user, dropped with the Session or Project they belong to\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n draft_key TEXT NOT NULL, -- <kind>:<id> (api/types.ts DRAFT_KINDS)\n session_id TEXT, -- the Session the key names (session / a2ui kinds); NULL otherwise\n project_id TEXT, -- the Project the key names (chat / parked / org / channel kinds); NULL otherwise\n value_json TEXT NOT NULL, -- the draft as the Web App wrote it\n updated_at TEXT NOT NULL, -- last write (ISO); the oldest go first when the user's bound is exceeded\n PRIMARY KEY (user_id, draft_key)\n);\nCREATE INDEX IF NOT EXISTS idx_user_drafts_session ON user_drafts(session_id);\nCREATE INDEX IF NOT EXISTS idx_user_drafts_project ON user_drafts(project_id);\nCREATE TABLE IF NOT EXISTS user_background_images ( -- the custom background image of a user's appearance settings, apart from ui_prefs so the megabyte it may hold is read only by the one route that serves it\n user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,\n image TEXT NOT NULL, -- data URL (image/png|jpeg|webp, <= BACKGROUND_IMAGE_MAX_CHARS characters)\n image_id TEXT NOT NULL, -- content hash; mirrored into ui_prefs.backgroundImageId so a browser fetches the image only when it changed\n updated_at TEXT NOT NULL\n);\nCREATE TABLE IF NOT EXISTS browser_extensions ( -- one row per Chrome paired to a user (builtin-browser/extension-pairing.ts): the PenguinHarness Browser extension's long-lived credential, stored only hashed; NOT rebuildable — a lost row means pairing that Chrome again\n extension_id TEXT PRIMARY KEY, -- random id shown in the Web App's list\n user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,\n token_hash TEXT NOT NULL UNIQUE, -- sha256(token) hex; the extension alone holds the token\n name TEXT NOT NULL, -- how the extension names this Chrome (\"Chrome 130 on macOS\")\n version TEXT NOT NULL, -- the extension's version, as of its last hello\n created_at TEXT NOT NULL,\n last_seen_at TEXT, -- last connect or disconnect; NULL = never connected\n revoked_at TEXT -- set by Revoke; a revoked token's socket is closed 4003\n);\nCREATE INDEX IF NOT EXISTS idx_browser_extensions_user ON browser_extensions(user_id);\nCREATE TABLE IF NOT EXISTS agent_api ( -- per-Agent public API settings (amsp/routes.ts); NOT rebuildable from files\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n enabled INTEGER NOT NULL DEFAULT 0, -- the API tab's switch; off = every /api/amsp request for it is 404 agent_not_found\n open INTEGER NOT NULL DEFAULT 0, -- keyless access: a request without Authorization is accepted; off = 401 unauthorized\n approval_mode TEXT NOT NULL DEFAULT 'allow-all', -- copied onto each new API Session\n provider TEXT, -- NULL pair follows the Project default model\n model_id TEXT,\n thinking_level TEXT, -- NULL follows Agent/Project configuration\n workspace TEXT, -- NULL creates a temporary Workspace\n max_concurrent_runs INTEGER NOT NULL DEFAULT 4,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (project_id, agent_id)\n);\nCREATE TABLE IF NOT EXISTS agent_api_keys ( -- one row per key the API tab or the CLI minted; the caller alone holds the key\n key_id TEXT PRIMARY KEY, -- 16 base64url chars, shown in the list\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n token_hash TEXT NOT NULL UNIQUE, -- sha256(key) hex\n prefix TEXT NOT NULL, -- the key's first 16 characters (\"penguin_\" + 8), shown in the list\n name TEXT NOT NULL, -- 1-64 characters\n created_by TEXT NOT NULL REFERENCES users(user_id),\n created_at TEXT NOT NULL,\n last_used_at TEXT -- stamped once per run request that authenticated with it; NULL = never\n);\nCREATE INDEX IF NOT EXISTS idx_agent_api_keys_agent ON agent_api_keys(project_id, agent_id);\nCREATE TABLE IF NOT EXISTS server_settings ( -- admin-level server-global settings (GET/PUT /api/admin/settings)\n key TEXT PRIMARY KEY, -- setting name, e.g. 'proxy_for_app'\n value TEXT NOT NULL -- JSON-encoded value; an absent row means the setting's built-in default\n);\nCREATE TABLE IF NOT EXISTS machine ( -- this server's OWN identity, minted here and never assigned from outside (what 'penguin server status' reports)\n singleton INTEGER PRIMARY KEY CHECK (singleton = 1), -- exactly one row, enforced by the schema rather than by convention\n machine_id TEXT NOT NULL -- 16 base64url characters; what every stored reference to this machine points at\n);\nCREATE TABLE IF NOT EXISTS machines ( -- one row per machine this server installed on or reached (machines/service.ts)\n address TEXT PRIMARY KEY, -- 'ssh:<alias>'\n machine_id TEXT, -- that machine's own id, once heard\n version TEXT, -- what this server last installed there; NULL = never\n installed_at TEXT,\n session_pid INTEGER, -- the ssh session this server holds to it; what a successor App closes after a hot swap\n remote_port INTEGER, -- its server's port over there, as of the last connect\n platform TEXT, -- linux | darwin | win32, as the install found it: the shell dialect the status probe speaks to it in\n adopted_at TEXT -- when this server first recorded this machine (reset when the address answers with another machine's id): Sessions it held before then read as CLI tasks through this server. NULL = recorded before the column existed; never filled in later, and the rule does not apply\n);\nCREATE TABLE IF NOT EXISTS machine_project ( -- which machines a Project uses; no row = none yet. Follows the Project out, like project_members\n project_id TEXT PRIMARY KEY REFERENCES projects(project_id) ON DELETE CASCADE,\n addresses TEXT NOT NULL -- JSON array of 'ssh:<alias>'\n);\nCREATE TABLE IF NOT EXISTS trace_files ( -- DERIVED CACHE of the on-disk Trace tree (services/trace-index.ts): the directories stay the single source of truth, every row is rebuildable from disk, and a row is never authority for absence — consumers reconcile + retry on a miss, so a stale index costs one extra scan, never a false 404\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n file_index INTEGER NOT NULL, -- shard index NNN of <session_id>_NNN.jsonl (name/path are reconstructed from the row, never stored: the data root may move)\n date TEXT NOT NULL, -- date directory name (local yyyy-mm-dd)\n size_bytes INTEGER NOT NULL, -- last observed size (listings stat-refresh the returned page and write back; an actively-appended shard may lag in between)\n page_stats TEXT, -- cached message-window scan state at this shard's END (services/message-window.ts ShardPrefixRecord JSON); immutable shards only, NULL = not yet computed, nulled whenever size_bytes changes\n PRIMARY KEY (project_id, agent_id, session_id, file_index)\n);\nCREATE INDEX IF NOT EXISTS idx_trace_files_agent_date ON trace_files(project_id, agent_id, date);\nCREATE INDEX IF NOT EXISTS idx_trace_files_session ON trace_files(session_id);\nCREATE TABLE IF NOT EXISTS trace_sessions ( -- per-session facts read ONCE at registration from the earliest shard's head (same derived-cache rules as trace_files; listing needs zero head-reads)\n session_id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n source TEXT, -- session_meta source as the head carried it: 'user' | 'api' | 'schedule' | 'subagent' | 'cli' | 'company'; NULL = the head records none (written before the source was required: read as 'company' for an 'org' row, else 'user' — readRecordedSource); 'benchmark' from older rows reads as 'cli' (normalizeSessionSource; meaningless while meta_read = 0)\n workspace TEXT NOT NULL DEFAULT '',\n title TEXT, -- first-prompt fallback title (sessions.title always wins when present)\n provider TEXT, -- model reference from session_meta: the model the Session was created on (CLI adoption reads it from here, and the runtime's own answer corrects the row at load; NULL = meta unreadable / legacy without provider)\n model_id TEXT,\n first_ts TEXT, -- first record's timestamp (adoption's createdAt fallback when the id embeds no time)\n meta_read INTEGER NOT NULL DEFAULT 0 -- 1 once the head parsed; 0 = facts unknown, retried by the next reconcile that touches the session\n);\nCREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id, agent_id);\nCREATE TABLE IF NOT EXISTS org_sessions ( -- DERIVED CACHE (company mode): desk sessions, rebuilt from each organization's desks.toml (current + previous); trigger_hop is mention-chain accounting and reads 0 after a rebuild\n session_id TEXT PRIMARY KEY,\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n current INTEGER NOT NULL DEFAULT 1,\n trigger_hop INTEGER NOT NULL DEFAULT 0\n);\nCREATE INDEX IF NOT EXISTS idx_org_sessions_org ON org_sessions(project_id, org_id);\nCREATE TABLE IF NOT EXISTS org_ticket_sessions ( -- DERIVED CACHE (company mode): ticket <-> contributing session, rebuilt from each ticket's Sessions header\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n session_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n trigger_hop INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, ticket_id, session_id)\n);\nCREATE INDEX IF NOT EXISTS idx_org_ticket_sessions_session ON org_ticket_sessions(session_id);\nCREATE TABLE IF NOT EXISTS org_calendar_state ( -- calendar run state (company mode; the files are declarative intent), same rules as schedule_state\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n name TEXT NOT NULL,\n start_at_ms INTEGER NOT NULL,\n def_hash TEXT NOT NULL,\n last_slot_ms INTEGER,\n last_fired_at TEXT,\n fired_once INTEGER NOT NULL DEFAULT 0,\n missed INTEGER NOT NULL DEFAULT 0,\n invalid_reason TEXT,\n last_outcome TEXT,\n PRIMARY KEY (project_id, org_id, agent_id, name)\n);\nCREATE TABLE IF NOT EXISTS org_ticket_state ( -- DERIVED CACHE (company mode): the last (status, owner, blocked) seen per ticket, so a change is notified once; rebuilt silently from the ticket files\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n status TEXT NOT NULL,\n owner TEXT NOT NULL DEFAULT '',\n blocked TEXT NOT NULL DEFAULT '',\n blocked_by TEXT NOT NULL DEFAULT '',\n PRIMARY KEY (project_id, org_id, ticket_id)\n);\nCREATE TABLE IF NOT EXISTS org_channel_state ( -- DERIVED CACHE (company mode): tail-scan byte cursor per channel and day file\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n date TEXT NOT NULL,\n offset_bytes INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (project_id, org_id, channel_id, date)\n);\nCREATE TABLE IF NOT EXISTS org_channel_reads ( -- user data (company mode): each user's read cursor in one channel\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n channel_id TEXT NOT NULL,\n user_id TEXT NOT NULL,\n last_read_id TEXT NOT NULL,\n PRIMARY KEY (project_id, org_id, channel_id, user_id)\n);\nCREATE TABLE IF NOT EXISTS org_budget_state ( -- DERIVED CACHE (company mode): warn / pause marks per employee and period, recomputed from usage and the chart's budgets\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n period TEXT NOT NULL,\n warned_at TEXT,\n paused_at TEXT,\n PRIMARY KEY (project_id, org_id, agent_id, period)\n);\nCREATE TABLE IF NOT EXISTS org_desk_notices ( -- DERIVED CACHE (company mode): ticket changes waiting for an employee's next calendar sweep; dropping it loses only the digests not delivered yet\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n project_id TEXT NOT NULL,\n org_id TEXT NOT NULL,\n agent_id TEXT NOT NULL,\n ticket_id TEXT NOT NULL,\n change TEXT NOT NULL,\n at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_org_desk_notices_agent ON org_desk_notices(project_id, org_id, agent_id);\n`;\n"],"mappings":"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","names":["migrate","migrate"]}
@@ -436,7 +436,7 @@ var AuthService = class {
436
436
  this.authSessions.touch(tokenHash, new Date(now.getTime() + this.sessionTtlMs).toISOString());
437
437
  renewed = true;
438
438
  }
439
- const via = session.via === "remote" ? "token" : session.via === "desktop" ? "desktop" : session.via === "setup" ? "setup" : "password";
439
+ const via = session.via === "remote" ? "remote" : session.via === "desktop" ? "desktop" : session.via === "setup" ? "setup" : "password";
440
440
  return { user, via, renewed };
441
441
  }
442
442
  /**
@@ -498,4 +498,4 @@ export {
498
498
  AuthService,
499
499
  InitialProjectProvisioner
500
500
  };
501
- //# sourceMappingURL=chunk-D3JNA6ZI.js.map
501
+ //# sourceMappingURL=chunk-B3PSKR3D.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth/password.ts","../src/auth/service.ts","../src/auth/api-token.ts","../src/http/errors.ts"],"sourcesContent":["import { Component, Interface } from \"@prismshadow/penguin-core/kernel\";\n/**\n * Password hashing (slow-hash storage).\n *\n * Uses node:crypto's scrypt (built-in, no extra dependency, meets the same\n * slow-hash requirement as bcrypt/argon2). Storage format:\n * `scrypt$N$r$p$<salt b64>$<hash b64>` — parameters are stored alongside the hash,\n * so old hashes remain verifiable after future parameter tuning; comparison uses\n * timingSafeEqual to guard against timing side-channels, and a sign-in for an account\n * that does not exist costs the same derivation as a wrong password\n * (verifyAccountPassword).\n */\nimport { randomBytes, scrypt, timingSafeEqual } from \"node:crypto\";\n\n/**\n * Work factor for new hashes. `hashPassword` takes it as a defaulted argument so the server's\n * own test suite can hash at a token cost: almost every case there seeds an admin, provisions\n * users and logs them in, and one derivation at this strength costs on the order of 100ms.\n * Nothing on the production path passes the argument, and no configuration reaches it —\n * `buildAppDeps`, which carries the test override, is not part of this package's exports.\n */\nexport const SCRYPT_COST = 16384;\nconst SCRYPT_R = 8;\nconst SCRYPT_P = 1;\nconst SALT_BYTES = 16;\nconst KEY_BYTES = 64;\n\nfunction scryptAsync(\n password: string,\n salt: Buffer,\n keyLen: number,\n n: number,\n r: number,\n p: number,\n): Promise<Buffer> {\n return new Promise((resolve, reject) => {\n scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {\n if (err) reject(err);\n else resolve(key);\n });\n });\n}\n\n/**\n * Generates a password hash in `scrypt$N$r$p$salt$hash` format. `cost` is scrypt's N and\n * is recorded in the hash, so `verifyPassword` re-derives at whatever cost produced the\n * stored string.\n */\nexport async function hashPassword(password: string, cost: number = SCRYPT_COST): Promise<string> {\n const salt = randomBytes(SALT_BYTES);\n const key = await scryptAsync(password, salt, KEY_BYTES, cost, SCRYPT_R, SCRYPT_P);\n return [\n \"scrypt\",\n String(cost),\n String(SCRYPT_R),\n String(SCRYPT_P),\n salt.toString(\"base64\"),\n key.toString(\"base64\"),\n ].join(\"$\");\n}\n\n/**\n * What checking a password against a stored string found. `match` and `mismatch` each cost one\n * scrypt derivation; `unverifiable` — not the `scrypt$N$r$p$salt$hash` format, or parameters\n * scrypt refuses — cost none.\n */\nexport type PasswordCheck = \"match\" | \"mismatch\" | \"unverifiable\";\n\n/** Checks a password against a stored hash. Never throws. */\nexport async function checkPassword(password: string, stored: string): Promise<PasswordCheck> {\n const parts = stored.split(\"$\");\n if (parts.length !== 6 || parts[0] !== \"scrypt\") return \"unverifiable\";\n const n = Number(parts[1]);\n const r = Number(parts[2]);\n const p = Number(parts[3]);\n if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return \"unverifiable\";\n const salt = Buffer.from(parts[4]!, \"base64\");\n const expected = Buffer.from(parts[5]!, \"base64\");\n if (salt.length === 0 || expected.length === 0) return \"unverifiable\";\n let actual: Buffer;\n try {\n actual = await scryptAsync(password, salt, expected.length, n, r, p);\n } catch {\n return \"unverifiable\";\n }\n return timingSafeEqual(actual, expected) ? \"match\" : \"mismatch\";\n}\n\n/** Verifies a password; returns false if the stored string has an invalid format (never throws, so the login path can uniformly treat it as a credential error). */\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n return (await checkPassword(password, stored)) === \"match\";\n}\n\n/**\n * Verifies a sign-in password for an account that may not exist, at the cost a real account's\n * check has. `stored` is the account's hash, or null when there is no such account. When there is\n * nothing to derive against — no account, an empty hash, or one `checkPassword` finds\n * unverifiable — the password is checked against `dummyHash` instead and the result is false, so\n * every call runs exactly one scrypt derivation and how long a failure takes does not tell a\n * missing username from a wrong password. `check` is a parameter so a test can count the\n * derivations.\n */\nexport async function verifyAccountPassword(\n password: string,\n stored: string | null,\n dummyHash: () => Promise<string>,\n check: (password: string, stored: string) => Promise<PasswordCheck> = checkPassword,\n): Promise<boolean> {\n const outcome = stored === null ? \"unverifiable\" : await check(password, stored);\n if (outcome !== \"unverifiable\") return outcome === \"match\";\n await check(password, await dummyHash());\n return false;\n}\n\n/** Hashes the passwords this server writes; a test stands in a cheap one. */\n@Interface()\nexport abstract class PasswordHasher {\n abstract hash(password: string): Promise<string>;\n}\n\n/** scrypt at full strength. */\n@Component()\nexport class ScryptHasher implements PasswordHasher {\n hash(password: string): Promise<string> {\n return hashPassword(password, SCRYPT_COST);\n }\n}\n","/**\n * Auth service: admin seeding / login / logout / password change / session validation.\n *\n * No open registration: an empty users table seeds `admin` with a random password hashed and\n * discarded unseen, claimed through the first-login link; every other user is created by an\n * admin. Sessions are rows in auth_sessions (cookie holds the token, the row its sha256), so\n * they outlive a restart and renew in place.\n */\nimport { randomBytes } from \"node:crypto\";\nimport { tokensEqual } from \"./api-token.js\";\nimport type { UserInfo } from \"../api/types.js\";\nimport { HttpError } from \"../http/errors.js\";\nimport type { UserRow } from \"../db/repos/users.js\";\nimport { sessionTokenHash } from \"../db/repos/auth-sessions.js\";\nimport type { SessionViaValue, AuthSessionsRepo } from \"../db/repos/auth-sessions.js\";\nimport type { AuthRuntimeState } from \"./runtime-state.js\";\nimport { verifyAccountPassword, verifyPassword } from \"./password.js\";\nimport type { PasswordHasher } from \"./password.js\";\nimport { Component, Use, Interface } from \"@prismshadow/penguin-core/kernel\";\nimport type { AuthState, Clock, Config } from \"../hmr/capabilities.js\";\nimport type { Auth, AuthSessions, Users } from \"../mechanisms/identity.js\";\n\nexport const MIN_PASSWORD_LENGTH = 8;\nexport const REMOTE_SESSION_TTL_MS = 60 * 60_000;\n\n/** Built-in admin user_id. */\nexport const ADMIN_USER_ID = \"admin\";\n\n/**\n * Login throttling, per userId. After LOGIN_FREE_ATTEMPTS failures each attempt waits an\n * exponentially growing delay (1s doubling to a 60s cap), settling at a guess per minute.\n * Kept for nonexistent userIds too, so it is not an account-existence oracle — and neither is\n * the time a failure takes: a nonexistent userId is checked against a dummy hash (loginDummyHash).\n * Deliberately NOT covered: a concurrent burst before the first failure lands, and guesses\n * spread across many accounts.\n */\nconst LOGIN_FREE_ATTEMPTS = 5;\nconst LOGIN_BACKOFF_START_MS = 1000;\nconst LOGIN_BACKOFF_CAP_MS = 60_000;\n/** Failure entries idle longer than this are swept (bounds the map; far above the cap). */\nconst LOGIN_FAILURE_IDLE_MS = 15 * 60_000;\n\n/** Characters of base64url in a seeded password — 18 random bytes, 144 bits. */\nconst SEED_PASSWORD_CHARS = 24;\n\n/**\n * Never read or typed — hashed at once and discarded — which is what lets it be long enough\n * that the login endpoint, reachable the moment the account exists, has no search space.\n */\nexport function generateInitialAdminPassword(): string {\n return randomBytes(SEED_PASSWORD_CHARS).toString(\"base64url\").slice(0, SEED_PASSWORD_CHARS);\n}\n\n/**\n * \"desktop\" and \"setup\" may set a password without the old one (theirs is random and was never\n * shown); desktop-only routes open to \"desktop\" alone. \"remote\" is the one-hour browser session\n * Penguin Remote exchanges the local API token for: it counts as the person wherever a check\n * tells a person from the token (human-only writes, identity claims, who drives a run), opens\n * no desktop-only route, and changes the password only with the old one. \"token\" is the local\n * API token presented as a Bearer header, and nothing else. Anything else reads as \"password\".\n */\nexport type SessionVia = \"password\" | \"desktop\" | \"setup\" | \"remote\" | \"token\";\n\n/** Row -> DTO. The two profile columns are omitted rather than sent as null when unset. */\nexport function toUserInfo(row: UserRow): UserInfo {\n return {\n userId: row.userId,\n isAdmin: row.isAdmin,\n passwordIsInitial: row.passwordIsInitial,\n ...(row.displayName !== null ? { displayName: row.displayName } : {}),\n ...(row.avatar !== null ? { avatar: row.avatar } : {}),\n createdAt: row.createdAt,\n };\n}\n\n@Component()\nexport class AuthService implements Auth {\n @Use() private readonly users!: Users;\n @Use() private readonly authSessions!: AuthSessions;\n /**\n * Process-scoped auth values (runtime-state.ts). Held by the RUNTIME so the link a boot\n * printed survives a platform push; everything else about auth is rebuilt with the App.\n */\n @Use() private readonly state!: AuthState;\n /** Provisions the initial Project at signup — declared at the consumer, below. */\n @Use() private readonly provisioner!: InitialProjectProvisioner;\n @Use() private readonly config!: Config;\n @Use() private readonly clock!: Clock;\n @Use() private readonly hasher!: PasswordHasher;\n /** Session lifetime, for the cookie that must expire WITH the session, not before it. */\n get sessionTtlMs(): number {\n return this.config.authSessionTtlMs;\n }\n private get sessionRenewMs(): number {\n return this.config.authSessionRenewMs;\n }\n /** Fixed initial password for the seeded admin; null generates a random one at seed time. */\n private get seedAdminPassword(): string | null {\n return this.config.seedAdminPassword;\n }\n\n setup(): void {\n this.authSessions.deleteExpired(this.clock.now().toISOString());\n }\n\n /**\n * Null once the server is claimed, so the only setup session that exists is a printed one.\n * On demand rather than in the constructor: seedAdmin() runs after it, so \"claimed?\" has no\n * answer there. A cached link whose row is gone is re-minted, not handed out dead.\n */\n mintFirstLogin(): string | null {\n if (!this.adminPasswordIsInitial()) return null;\n if (\n this.state.firstLoginToken !== null &&\n this.authenticateWithMeta(this.state.firstLoginToken) === null\n ) {\n this.state.firstLoginToken = null;\n }\n this.state.firstLoginToken ??= this.issueSession(ADMIN_USER_ID, \"setup\");\n return this.state.firstLoginToken;\n }\n\n /**\n * For the startup notice's pinned-seed gate (index.ts): a pin usually means the operator\n * knows the password, but an offline reset makes it stale, and the gate must see through\n * that or the rescue link stays suppressed.\n */\n async adminPasswordIs(password: string): Promise<boolean> {\n const row = this.users.findById(ADMIN_USER_ID);\n return row !== null && (await verifyPassword(password, row.passwordHash));\n }\n\n /**\n * Startup seeding (idempotent): creates the built-in admin and adopts default_project when\n * the users table is empty; if the initial Project fails, the user row is rolled back and\n * the server retries on next startup.\n */\n async seedAdmin(): Promise<void> {\n if (this.users.count() > 0) return;\n const password = this.seedAdminPassword ?? generateInitialAdminPassword();\n // A pinned override must meet the same policy as every other password — rejected before\n // any insert, so a configuration typo cannot create a trivially weak privileged account.\n if (password.length < MIN_PASSWORD_LENGTH) {\n throw new Error(\n `PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`,\n );\n }\n const user: UserRow = {\n userId: ADMIN_USER_ID,\n passwordHash: await this.hasher.hash(password),\n isAdmin: true,\n passwordIsInitial: true,\n displayName: null,\n avatar: null,\n createdAt: this.clock.now().toISOString(),\n };\n this.users.insert(user);\n try {\n await this.provisioner.provisionInitialProject(user, true);\n } catch (err) {\n this.users.delete(user.userId);\n throw err;\n }\n }\n\n /**\n * Compared against the printed value, not merely verified: a cookie made out of ANY valid\n * token would let one person hand another a link into the sender's account. Not single-use,\n * because a prefetching browser would burn it before its reader clicked.\n */\n redeemFirstLogin(given: string): string | null {\n const expected = this.state.firstLoginToken;\n if (expected === null || given === \"\" || !tokensEqual(given, expected)) return null;\n return this.authenticateWithMeta(expected) === null ? null : expected;\n }\n\n /** Whether the built-in admin still runs on its initial password (gates the first-login link). */\n adminPasswordIsInitial(): boolean {\n return this.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;\n }\n\n /** Consecutive login failures per userId (see the throttling comment on the constants). */\n private readonly loginFailures = new Map<string, { failures: number; lastFailureAt: number }>();\n\n /** Cache for loginDummyHash. */\n private dummyHash: string | null = null;\n\n /**\n * The hash a sign-in with no account to check is verified against (verifyAccountPassword), so\n * it costs what a wrong password costs. Made by this server's own hasher, hence at the cost its\n * real hashes carry, from a random password nobody holds; computed on the first such sign-in\n * and kept.\n */\n private async loginDummyHash(): Promise<string> {\n this.dummyHash ??= await this.hasher.hash(randomBytes(18).toString(\"base64url\"));\n return this.dummyHash;\n }\n\n /** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */\n private loginDelayMs(failures: number): number {\n const excess = failures - LOGIN_FREE_ATTEMPTS;\n if (excess <= 0) return 0;\n return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);\n }\n\n async login(userId: string, password: string): Promise<{ user: UserInfo; token: string }> {\n const nowMs = this.clock.now().getTime();\n for (const [key, entry] of this.loginFailures) {\n if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);\n }\n const failed = this.loginFailures.get(userId);\n if (failed) {\n const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);\n if (nowMs < readyAt) {\n throw new HttpError(\n 429,\n \"too_many_attempts\",\n `Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1000)}s.`,\n );\n }\n }\n const row = this.users.findById(userId);\n // A missing account, or one whose hash cannot be checked, still costs one scrypt\n // derivation, and fails with the same 401 as a wrong password.\n const ok = await verifyAccountPassword(password, row?.passwordHash ?? null, () =>\n this.loginDummyHash(),\n );\n if (!row || !ok) {\n this.loginFailures.set(userId, {\n failures: (failed?.failures ?? 0) + 1,\n lastFailureAt: this.clock.now().getTime(),\n });\n throw new HttpError(401, \"invalid_credentials\", \"Incorrect username or password.\");\n }\n this.loginFailures.delete(userId);\n this.authSessions.deleteExpired(this.clock.now().toISOString());\n return { user: toUserInfo(row), token: this.issueSession(row.userId, \"password\") };\n }\n\n /**\n * Whether a user is an admin, by id — for a caller that has a user's NAME rather than an\n * authenticated request: the terminal relay reads the owner out of a pty reference the\n * runtime has already matched against the signed-in user (machines/terminal-relay.ts), and\n * what is left to decide is whether that user may reach machines at all. Unknown users are\n * not admins.\n */\n isAdmin(userId: string): boolean {\n return this.users.findById(userId)?.isAdmin === true;\n }\n\n /**\n * Desktop-mode sign-in: an admin session with no password check — the claim route already\n * redeemed the shell's one-shot token, which is the credential here. Throws only on a\n * broken deployment (seeding runs before the route exists).\n */\n loginDesktop(): { user: UserInfo; token: string } {\n const row = this.users.findById(ADMIN_USER_ID);\n if (!row) {\n throw new HttpError(500, \"internal\", \"Built-in admin has not been seeded.\");\n }\n return { user: toUserInfo(row), token: this.issueSession(row.userId, \"desktop\") };\n }\n\n issueRemoteSession(): { token: string; expiresAt: string; maxAgeSeconds: number } {\n const row = this.users.findById(ADMIN_USER_ID);\n if (!row) {\n throw new HttpError(500, \"internal\", \"Built-in admin has not been seeded.\");\n }\n const maxAgeSeconds = Math.floor(REMOTE_SESSION_TTL_MS / 1000);\n return {\n ...this.authSessions.issue({\n userId: row.userId,\n via: \"remote\",\n now: this.clock.now(),\n ttlMs: REMOTE_SESSION_TTL_MS,\n }),\n maxAgeSeconds,\n };\n }\n\n /** Self password change (user settings): validates the old password first; the current session stays valid. */\n async changePassword(userId: string, oldPassword: string, newPassword: string): Promise<void> {\n const row = this.users.findById(userId);\n if (!row || !(await verifyPassword(oldPassword, row.passwordHash))) {\n throw new HttpError(400, \"password_mismatch\", \"Current password is incorrect.\");\n }\n await this.setPassword(userId, newPassword);\n }\n\n /** No old-password check; the me route gates which sessions may call this. */\n async setInitialPassword(userId: string, newPassword: string): Promise<void> {\n await this.setPassword(userId, newPassword);\n }\n\n /**\n * Validation comes FIRST, so a rejected password leaves the first-login link alive: burning\n * it on a typo would strand the claimer until a restart.\n */\n private async setPassword(userId: string, newPassword: string): Promise<void> {\n if (newPassword.length < MIN_PASSWORD_LENGTH) {\n throw new HttpError(400, \"invalid_password\", \"Password must be at least 8 characters.\");\n }\n this.users.updatePassword(userId, await this.hasher.hash(newPassword), false);\n // A successful set proves at least what the successful login that resets the counter\n // proves (the old password, or a desktop/setup session), so it resets it too. Without\n // this, anyone spamming the login endpoint holds the backoff window open, and the sign-in\n // that follows a first-login claim (routes/me.ts) would 429 AFTER the password committed —\n // reporting failure for a change that took, with the claimer's setup session already gone.\n this.loginFailures.delete(userId);\n // EVERY first-login session for this account, not just the link this process printed: an\n // earlier boot's link can still be live in a terminal scrollback, and a `setup` session is\n // allowed to change the password without knowing the old one — so one left behind is an\n // account takeover waiting to happen.\n if (userId === ADMIN_USER_ID) {\n this.authSessions.deleteByUserAndVia(userId, \"setup\");\n this.state.firstLoginToken = null;\n }\n }\n\n /** Ends a session: the row is the session, so logout deletes it. Unknown token is a no-op. */\n logout(token: string): void {\n this.authSessions.delete(sessionTokenHash(token));\n }\n\n /**\n * The current boot's local API token (what control-env injection hands to tool\n * subprocesses); null when none was minted. It lives on the runtime state, not on this\n * service: the token was written to `<root>/api-token` by the process, so it must\n * outlive the App a push replaces (auth/runtime-state.ts).\n */\n localApiToken(): string | null {\n return this.state.apiToken;\n }\n\n /**\n * Validates a Bearer token against the boot's local API token (constant-time compare):\n * a match authenticates as the built-in admin — holding the token proves filesystem\n * access to the data root, which is admin authority (see auth/api-token.ts). Returns\n * null on mismatch, when no token was minted, or when the admin row is missing.\n */\n authenticateApiToken(token: string): { user: UserRow; via: SessionVia } | null {\n const apiToken = this.state.apiToken;\n if (apiToken === null || token.length === 0) return null;\n if (!tokensEqual(token, apiToken)) return null;\n const user = this.users.findById(ADMIN_USER_ID);\n return user === null ? null : { user, via: \"token\" };\n }\n\n /**\n * Validates the cookie token. Sliding renewal tops the expiry up IN PLACE, so the cookie\n * value never changes and there is no second identity to revoke. Only a session whose own\n * span reaches the renewal window slides — a one-hour minted token must expire at its hour,\n * not stretch by being used.\n */\n authenticateWithMeta(token: string): { user: UserRow; via: SessionVia; renewed: boolean } | null {\n const tokenHash = sessionTokenHash(token);\n const session = this.authSessions.findByTokenHash(tokenHash);\n if (!session) return null;\n const now = this.clock.now();\n const expiresAt = Date.parse(session.expiresAt);\n if (expiresAt <= now.getTime()) {\n this.authSessions.delete(tokenHash);\n return null;\n }\n const user = this.users.findById(session.userId);\n if (!user) return null;\n const renewable = expiresAt - Date.parse(session.createdAt) >= this.sessionRenewMs;\n let renewed = false;\n if (renewable && expiresAt - now.getTime() < this.sessionRenewMs) {\n this.authSessions.touch(tokenHash, new Date(now.getTime() + this.sessionTtlMs).toISOString());\n renewed = true;\n }\n const via: SessionVia =\n session.via === \"remote\"\n ? \"remote\"\n : session.via === \"desktop\"\n ? \"desktop\"\n : session.via === \"setup\"\n ? \"setup\"\n : \"password\";\n return { user, via, renewed };\n }\n\n /**\n * Whether the session behind a cookie token still exists and has not expired — the same\n * two facts {@link authenticateWithMeta} establishes, without its sliding renewal or its\n * user lookup. A connection that outlives the request that opened it has to re-ask\n * periodically (the SSE heartbeat does, once per beat), and asking through the renewing\n * path would top the expiry up every time: a window nobody has touched since last week\n * would stay signed in purely because it is still connected.\n */\n sessionIsLive(token: string): boolean {\n const session = this.authSessions.findByTokenHash(sessionTokenHash(token));\n if (!session) return false;\n return Date.parse(session.expiresAt) > this.clock.now().getTime();\n }\n\n private issueSession(userId: string, via: SessionViaValue): string {\n return this.authSessions.issue({\n userId,\n via,\n now: this.clock.now(),\n ttlMs: this.sessionTtlMs,\n }).token;\n }\n}\n\n/** What signing a new user in needs of project administration — declared at the consumer. */\n@Interface()\nexport abstract class InitialProjectProvisioner {\n abstract provisionInitialProject(user: UserRow, isAdmin: boolean): Promise<void>;\n}\n","/**\n * Local API token (`<root>/api-token`): the machine-local credential behind\n * `Authorization: Bearer`.\n *\n * Minted fresh at every server boot (same recipe as auth-session tokens) and written to\n * the data root with owner-only permissions, so anything that can read the file can call\n * the API as the admin. That equivalence is the authorization model, not an accident:\n * local filesystem access to the data root already IS admin authority — the same rule\n * `penguin server reset-admin-password` stands on (whoever can run it owns web.db\n * anyway) — and agents driving their own harness through the CLI is the product feature\n * this token exists for (server-driven Sessions hand it to tool subprocesses as\n * PENGUIN_API_TOKEN). Rotation per boot bounds the life of any leaked copy to the\n * process that minted it.\n */\nimport { createHash, randomBytes, timingSafeEqual } from \"node:crypto\";\nimport fs from \"node:fs\";\nimport path from \"node:path\";\n\nexport function apiTokenPath(root: string): string {\n return path.join(root, \"api-token\");\n}\n\n/** Mints a boot-scoped local API token (the auth-session token recipe). */\nexport function mintApiToken(): string {\n return randomBytes(32).toString(\"base64url\");\n}\n\n/**\n * Persists the boot token (owner-only file, tmp + rename so a concurrent reader never\n * sees a partial write). Best-effort like the initial-password file: an exotic read-only\n * root must not stop the server — local CLI callers then fall back to\n * PENGUIN_API_TOKEN.\n */\nexport function storeApiToken(root: string, token: string): void {\n try {\n fs.mkdirSync(root, { recursive: true });\n const target = apiTokenPath(root);\n const tmp = `${target}.${process.pid}.tmp`;\n fs.writeFileSync(tmp, `${token}\\n`, { mode: 0o600 });\n fs.renameSync(tmp, target);\n } catch {\n // Best-effort: Bearer auth still works for callers holding the token some other way.\n }\n}\n\n/** The stored token, or null when absent/unreadable/empty (shared with the CLI's file fallback). */\nexport function readApiToken(root: string): string | null {\n try {\n const value = fs.readFileSync(apiTokenPath(root), \"utf8\").trim();\n return value === \"\" ? null : value;\n } catch {\n return null;\n }\n}\n\n/**\n * Constant-time equality for token checks: both sides are reduced to fixed-size sha256\n * digests first, so the comparison's timing depends on neither the content nor the\n * length of an attacker-supplied value.\n */\nexport function tokensEqual(a: string, b: string): boolean {\n const digest = (v: string): Buffer => createHash(\"sha256\").update(v).digest();\n return timingSafeEqual(digest(a), digest(b));\n}\n","/**\n * Unified HTTP error: `{error: {code, message, details?}}` response body,\n * with message in English.\n *\n * Routes and services express business errors via throw HttpError; app-level onError\n * uniformly converges these into a JSON response, with unknown errors converged to 500\n * (never leaking internal details to the client).\n *\n * `details` carries the facts a client acts on beside the code — the id a 409 is about, say — so\n * that no client has to parse them out of the message, which is prose and may change.\n */\nimport type { Context } from \"hono\";\nimport type { ErrorBody, ErrorDetails } from \"../api/types.js\";\n\nexport class HttpError extends Error {\n constructor(\n readonly status: number,\n readonly code: string,\n message: string,\n readonly retryAfterSeconds?: number,\n readonly details?: ErrorDetails,\n ) {\n super(message);\n this.name = \"HttpError\";\n }\n}\n\n/** A 400 for a request the route cannot take as it is; validate.ts re-exports it beside its checks. */\nexport function badRequest(message: string): HttpError {\n return new HttpError(400, \"bad_request\", message);\n}\n\nexport function errorBody(code: string, message: string, details?: ErrorDetails): ErrorBody {\n return { error: { code, message, ...(details !== undefined ? { details } : {}) } };\n}\n\n/**\n * Model missing a credential: the provider SDK throws this at **client-construction\n * time** (hit by both creating a Session and resuming a Session); the original message\n * is full of environment variable names, meaningless to a user — uniformly replaced\n * with a single actionable sentence. The frontend produces localized text by code\n * (message is only a fallback); see web's lib/api-error.ts.\n */\nexport function isMissingCredential(err: unknown): boolean {\n const message = err instanceof Error ? err.message : String(err);\n return /missing credentials|api[_ ]?key/i.test(message);\n}\n\nexport function modelCredentialMissing(modelId: string): HttpError {\n return new HttpError(\n 400,\n \"model_credential_missing\",\n `Model ${modelId} has no API key yet. Configure it on the Models page first.`,\n );\n}\n\n/** app.onError handler: maps HttpError through as-is; everything else is logged and converged to 500. */\nexport function handleError(err: Error, c: Context): Response {\n if (err instanceof HttpError) {\n if (err.retryAfterSeconds !== undefined) {\n c.header(\"Retry-After\", String(err.retryAfterSeconds));\n }\n return c.json(errorBody(err.code, err.message, err.details), err.status as 400);\n }\n // Unknown error: print the stack for diagnosis, but never expose details externally.\n console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);\n return c.json(errorBody(\"internal\", \"Internal server error.\"), 500);\n}\n"],"mappings":";;;;;;;;;;;AAAA,SAAS,WAAW,iBAAiB;AAYrC,SAAS,aAAa,QAAQ,uBAAuB;AAS9C,IAAM,cAAc;AAC3B,IAAM,WAAW;AACjB,IAAM,WAAW;AACjB,IAAM,aAAa;AACnB,IAAM,YAAY;AAElB,SAAS,YACP,UACA,MACA,QACA,GACA,GACA,GACiB;AACjB,SAAO,IAAI,QAAQ,CAAC,SAAS,WAAW;AACtC,WAAO,UAAU,MAAM,QAAQ,EAAE,GAAG,GAAG,GAAG,GAAG,QAAQ,MAAM,OAAO,KAAK,GAAG,CAAC,KAAK,QAAQ;AACtF,UAAI,IAAK,QAAO,GAAG;AAAA,UACd,SAAQ,GAAG;AAAA,IAClB,CAAC;AAAA,EACH,CAAC;AACH;AAOA,eAAsB,aAAa,UAAkB,OAAe,aAA8B;AAChG,QAAM,OAAO,YAAY,UAAU;AACnC,QAAM,MAAM,MAAM,YAAY,UAAU,MAAM,WAAW,MAAM,UAAU,QAAQ;AACjF,SAAO;AAAA,IACL;AAAA,IACA,OAAO,IAAI;AAAA,IACX,OAAO,QAAQ;AAAA,IACf,OAAO,QAAQ;AAAA,IACf,KAAK,SAAS,QAAQ;AAAA,IACtB,IAAI,SAAS,QAAQ;AAAA,EACvB,EAAE,KAAK,GAAG;AACZ;AAUA,eAAsB,cAAc,UAAkB,QAAwC;AAC5F,QAAM,QAAQ,OAAO,MAAM,GAAG;AAC9B,MAAI,MAAM,WAAW,KAAK,MAAM,CAAC,MAAM,SAAU,QAAO;AACxD,QAAM,IAAI,OAAO,MAAM,CAAC,CAAC;AACzB,QAAM,IAAI,OAAO,MAAM,CAAC,CAAC;AACzB,QAAM,IAAI,OAAO,MAAM,CAAC,CAAC;AACzB,MAAI,CAAC,OAAO,UAAU,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,KAAK,CAAC,OAAO,UAAU,CAAC,EAAG,QAAO;AACjF,QAAM,OAAO,OAAO,KAAK,MAAM,CAAC,GAAI,QAAQ;AAC5C,QAAM,WAAW,OAAO,KAAK,MAAM,CAAC,GAAI,QAAQ;AAChD,MAAI,KAAK,WAAW,KAAK,SAAS,WAAW,EAAG,QAAO;AACvD,MAAI;AACJ,MAAI;AACF,aAAS,MAAM,YAAY,UAAU,MAAM,SAAS,QAAQ,GAAG,GAAG,CAAC;AAAA,EACrE,QAAQ;AACN,WAAO;AAAA,EACT;AACA,SAAO,gBAAgB,QAAQ,QAAQ,IAAI,UAAU;AACvD;AAGA,eAAsB,eAAe,UAAkB,QAAkC;AACvF,SAAQ,MAAM,cAAc,UAAU,MAAM,MAAO;AACrD;AAWA,eAAsB,sBACpB,UACA,QACA,WACA,QAAsE,eACpD;AAClB,QAAM,UAAU,WAAW,OAAO,iBAAiB,MAAM,MAAM,UAAU,MAAM;AAC/E,MAAI,YAAY,eAAgB,QAAO,YAAY;AACnD,QAAM,MAAM,UAAU,MAAM,UAAU,CAAC;AACvC,SAAO;AACT;AAhHA;AAmHA,8BAAC,UAAU;AACJ,IAAe,iBAAf,MAA8B;AAErC;AAFO;AAAe,iBAAf,8CADP,4BACsB;AAAf,4BAAe;AApHtB,8BAAAA;AAyHA,4BAAC,UAAU;AACJ,IAAM,eAAN,MAA6C;AAAA,EAClD,KAAK,UAAmC;AACtC,WAAO,aAAa,UAAU,WAAW;AAAA,EAC3C;AACF;AAJOA,SAAA;AAAM,eAAN,kBAAAA,QAAA,mBADP,0BACa;AAAN,kBAAAA,QAAA,GAAM;;;AClHb,SAAS,eAAAC,oBAAmB;;;ACM5B,SAAS,YAAY,eAAAC,cAAa,mBAAAC,wBAAuB;AACzD,OAAO,QAAQ;AACf,OAAO,UAAU;AAEV,SAAS,aAAa,MAAsB;AACjD,SAAO,KAAK,KAAK,MAAM,WAAW;AACpC;AAGO,SAAS,eAAuB;AACrC,SAAOD,aAAY,EAAE,EAAE,SAAS,WAAW;AAC7C;AAQO,SAAS,cAAc,MAAc,OAAqB;AAC/D,MAAI;AACF,OAAG,UAAU,MAAM,EAAE,WAAW,KAAK,CAAC;AACtC,UAAM,SAAS,aAAa,IAAI;AAChC,UAAM,MAAM,GAAG,MAAM,IAAI,QAAQ,GAAG;AACpC,OAAG,cAAc,KAAK,GAAG,KAAK;AAAA,GAAM,EAAE,MAAM,IAAM,CAAC;AACnD,OAAG,WAAW,KAAK,MAAM;AAAA,EAC3B,QAAQ;AAAA,EAER;AACF;AAiBO,SAAS,YAAY,GAAW,GAAoB;AACzD,QAAM,SAAS,CAAC,MAAsB,WAAW,QAAQ,EAAE,OAAO,CAAC,EAAE,OAAO;AAC5E,SAAOE,iBAAgB,OAAO,CAAC,GAAG,OAAO,CAAC,CAAC;AAC7C;;;ACjDO,IAAM,YAAN,cAAwB,MAAM;AAAA,EACnC,YACW,QACA,MACT,SACS,mBACA,SACT;AACA,UAAM,OAAO;AANJ;AACA;AAEA;AACA;AAGT,SAAK,OAAO;AAAA,EACd;AAAA,EARW;AAAA,EACA;AAAA,EAEA;AAAA,EACA;AAKb;AAGO,SAAS,WAAW,SAA4B;AACrD,SAAO,IAAI,UAAU,KAAK,eAAe,OAAO;AAClD;AAEO,SAAS,UAAU,MAAc,SAAiB,SAAmC;AAC1F,SAAO,EAAE,OAAO,EAAE,MAAM,SAAS,GAAI,YAAY,SAAY,EAAE,QAAQ,IAAI,CAAC,EAAG,EAAE;AACnF;AASO,SAAS,oBAAoB,KAAuB;AACzD,QAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,SAAO,mCAAmC,KAAK,OAAO;AACxD;AAEO,SAAS,uBAAuB,SAA4B;AACjE,SAAO,IAAI;AAAA,IACT;AAAA,IACA;AAAA,IACA,SAAS,OAAO;AAAA,EAClB;AACF;AAGO,SAAS,YAAY,KAAY,GAAsB;AAC5D,MAAI,eAAe,WAAW;AAC5B,QAAI,IAAI,sBAAsB,QAAW;AACvC,QAAE,OAAO,eAAe,OAAO,IAAI,iBAAiB,CAAC;AAAA,IACvD;AACA,WAAO,EAAE,KAAK,UAAU,IAAI,MAAM,IAAI,SAAS,IAAI,OAAO,GAAG,IAAI,MAAa;AAAA,EAChF;AAEA,UAAQ,MAAM,iCAAiC,IAAI,SAAS,IAAI,OAAO,EAAE;AACzE,SAAO,EAAE,KAAK,UAAU,YAAY,wBAAwB,GAAG,GAAG;AACpE;;;AFjDA,SAAS,aAAAC,YAAW,KAAK,aAAAC,kBAAiB;AAInC,IAAM,sBAAsB;AAC5B,IAAM,wBAAwB,KAAK;AAGnC,IAAM,gBAAgB;AAU7B,IAAM,sBAAsB;AAC5B,IAAM,yBAAyB;AAC/B,IAAM,uBAAuB;AAE7B,IAAM,wBAAwB,KAAK;AAGnC,IAAM,sBAAsB;AAMrB,SAAS,+BAAuC;AACrD,SAAOC,aAAY,mBAAmB,EAAE,SAAS,WAAW,EAAE,MAAM,GAAG,mBAAmB;AAC5F;AAaO,SAAS,WAAW,KAAwB;AACjD,SAAO;AAAA,IACL,QAAQ,IAAI;AAAA,IACZ,SAAS,IAAI;AAAA,IACb,mBAAmB,IAAI;AAAA,IACvB,GAAI,IAAI,gBAAgB,OAAO,EAAE,aAAa,IAAI,YAAY,IAAI,CAAC;AAAA,IACnE,GAAI,IAAI,WAAW,OAAO,EAAE,QAAQ,IAAI,OAAO,IAAI,CAAC;AAAA,IACpD,WAAW,IAAI;AAAA,EACjB;AACF;AAzEA,gIAAAC;AA2EA,2BAACC,WAAU,IAET,cAAC,IAAI,IACL,qBAAC,IAAI,IAKL,cAAC,IAAI,IAEL,oBAAC,IAAI,IACL,eAAC,IAAI,IACL,cAAC,IAAI,IACL,eAAC,IAAI;AAZA,IAAM,cAAN,MAAkC;AAAA,EAAlC;AACE,wBAAiB,SAAjB,kBAAAD,QAAA,6BAAAA,QAAA;AACA,wBAAiB,gBAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AAKA,wBAAiB,SAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AAEA,wBAAiB,eAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AACA,wBAAiB,UAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AACA,wBAAiB,SAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AACA,wBAAiB,UAAjB,kBAAAA,QAAA,8BAAAA,QAAA;AA8FP;AAAA,wBAAiB,iBAAgB,oBAAI,IAAyD;AAG9F;AAAA,wBAAQ,aAA2B;AAAA;AAAA;AAAA,EA/FnC,IAAI,eAAuB;AACzB,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EACA,IAAY,iBAAyB;AACnC,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA;AAAA,EAEA,IAAY,oBAAmC;AAC7C,WAAO,KAAK,OAAO;AAAA,EACrB;AAAA,EAEA,QAAc;AACZ,SAAK,aAAa,cAAc,KAAK,MAAM,IAAI,EAAE,YAAY,CAAC;AAAA,EAChE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,iBAAgC;AAC9B,QAAI,CAAC,KAAK,uBAAuB,EAAG,QAAO;AAC3C,QACE,KAAK,MAAM,oBAAoB,QAC/B,KAAK,qBAAqB,KAAK,MAAM,eAAe,MAAM,MAC1D;AACA,WAAK,MAAM,kBAAkB;AAAA,IAC/B;AACA,SAAK,MAAM,oBAAoB,KAAK,aAAa,eAAe,OAAO;AACvE,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,gBAAgB,UAAoC;AACxD,UAAM,MAAM,KAAK,MAAM,SAAS,aAAa;AAC7C,WAAO,QAAQ,QAAS,MAAM,eAAe,UAAU,IAAI,YAAY;AAAA,EACzE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,MAAM,YAA2B;AAC/B,QAAI,KAAK,MAAM,MAAM,IAAI,EAAG;AAC5B,UAAM,WAAW,KAAK,qBAAqB,6BAA6B;AAGxE,QAAI,SAAS,SAAS,qBAAqB;AACzC,YAAM,IAAI;AAAA,QACR,gDAAgD,mBAAmB;AAAA,MACrE;AAAA,IACF;AACA,UAAM,OAAgB;AAAA,MACpB,QAAQ;AAAA,MACR,cAAc,MAAM,KAAK,OAAO,KAAK,QAAQ;AAAA,MAC7C,SAAS;AAAA,MACT,mBAAmB;AAAA,MACnB,aAAa;AAAA,MACb,QAAQ;AAAA,MACR,WAAW,KAAK,MAAM,IAAI,EAAE,YAAY;AAAA,IAC1C;AACA,SAAK,MAAM,OAAO,IAAI;AACtB,QAAI;AACF,YAAM,KAAK,YAAY,wBAAwB,MAAM,IAAI;AAAA,IAC3D,SAAS,KAAK;AACZ,WAAK,MAAM,OAAO,KAAK,MAAM;AAC7B,YAAM;AAAA,IACR;AAAA,EACF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,iBAAiB,OAA8B;AAC7C,UAAM,WAAW,KAAK,MAAM;AAC5B,QAAI,aAAa,QAAQ,UAAU,MAAM,CAAC,YAAY,OAAO,QAAQ,EAAG,QAAO;AAC/E,WAAO,KAAK,qBAAqB,QAAQ,MAAM,OAAO,OAAO;AAAA,EAC/D;AAAA;AAAA,EAGA,yBAAkC;AAChC,WAAO,KAAK,MAAM,SAAS,aAAa,GAAG,sBAAsB;AAAA,EACnE;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAcA,MAAc,iBAAkC;AAC9C,SAAK,cAAc,MAAM,KAAK,OAAO,KAAKD,aAAY,EAAE,EAAE,SAAS,WAAW,CAAC;AAC/E,WAAO,KAAK;AAAA,EACd;AAAA;AAAA,EAGQ,aAAa,UAA0B;AAC7C,UAAM,SAAS,WAAW;AAC1B,QAAI,UAAU,EAAG,QAAO;AACxB,WAAO,KAAK,IAAI,yBAAyB,MAAM,SAAS,IAAI,oBAAoB;AAAA,EAClF;AAAA,EAEA,MAAM,MAAM,QAAgB,UAA8D;AACxF,UAAM,QAAQ,KAAK,MAAM,IAAI,EAAE,QAAQ;AACvC,eAAW,CAAC,KAAK,KAAK,KAAK,KAAK,eAAe;AAC7C,UAAI,QAAQ,MAAM,gBAAgB,sBAAuB,MAAK,cAAc,OAAO,GAAG;AAAA,IACxF;AACA,UAAM,SAAS,KAAK,cAAc,IAAI,MAAM;AAC5C,QAAI,QAAQ;AACV,YAAM,UAAU,OAAO,gBAAgB,KAAK,aAAa,OAAO,QAAQ;AACxE,UAAI,QAAQ,SAAS;AACnB,cAAM,IAAI;AAAA,UACR;AAAA,UACA;AAAA,UACA,kDAAkD,KAAK,MAAM,UAAU,SAAS,GAAI,CAAC;AAAA,QACvF;AAAA,MACF;AAAA,IACF;AACA,UAAM,MAAM,KAAK,MAAM,SAAS,MAAM;AAGtC,UAAM,KAAK,MAAM;AAAA,MAAsB;AAAA,MAAU,KAAK,gBAAgB;AAAA,MAAM,MAC1E,KAAK,eAAe;AAAA,IACtB;AACA,QAAI,CAAC,OAAO,CAAC,IAAI;AACf,WAAK,cAAc,IAAI,QAAQ;AAAA,QAC7B,WAAW,QAAQ,YAAY,KAAK;AAAA,QACpC,eAAe,KAAK,MAAM,IAAI,EAAE,QAAQ;AAAA,MAC1C,CAAC;AACD,YAAM,IAAI,UAAU,KAAK,uBAAuB,iCAAiC;AAAA,IACnF;AACA,SAAK,cAAc,OAAO,MAAM;AAChC,SAAK,aAAa,cAAc,KAAK,MAAM,IAAI,EAAE,YAAY,CAAC;AAC9D,WAAO,EAAE,MAAM,WAAW,GAAG,GAAG,OAAO,KAAK,aAAa,IAAI,QAAQ,UAAU,EAAE;AAAA,EACnF;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EASA,QAAQ,QAAyB;AAC/B,WAAO,KAAK,MAAM,SAAS,MAAM,GAAG,YAAY;AAAA,EAClD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAOA,eAAkD;AAChD,UAAM,MAAM,KAAK,MAAM,SAAS,aAAa;AAC7C,QAAI,CAAC,KAAK;AACR,YAAM,IAAI,UAAU,KAAK,YAAY,qCAAqC;AAAA,IAC5E;AACA,WAAO,EAAE,MAAM,WAAW,GAAG,GAAG,OAAO,KAAK,aAAa,IAAI,QAAQ,SAAS,EAAE;AAAA,EAClF;AAAA,EAEA,qBAAkF;AAChF,UAAM,MAAM,KAAK,MAAM,SAAS,aAAa;AAC7C,QAAI,CAAC,KAAK;AACR,YAAM,IAAI,UAAU,KAAK,YAAY,qCAAqC;AAAA,IAC5E;AACA,UAAM,gBAAgB,KAAK,MAAM,wBAAwB,GAAI;AAC7D,WAAO;AAAA,MACL,GAAG,KAAK,aAAa,MAAM;AAAA,QACzB,QAAQ,IAAI;AAAA,QACZ,KAAK;AAAA,QACL,KAAK,KAAK,MAAM,IAAI;AAAA,QACpB,OAAO;AAAA,MACT,CAAC;AAAA,MACD;AAAA,IACF;AAAA,EACF;AAAA;AAAA,EAGA,MAAM,eAAe,QAAgB,aAAqB,aAAoC;AAC5F,UAAM,MAAM,KAAK,MAAM,SAAS,MAAM;AACtC,QAAI,CAAC,OAAO,CAAE,MAAM,eAAe,aAAa,IAAI,YAAY,GAAI;AAClE,YAAM,IAAI,UAAU,KAAK,qBAAqB,gCAAgC;AAAA,IAChF;AACA,UAAM,KAAK,YAAY,QAAQ,WAAW;AAAA,EAC5C;AAAA;AAAA,EAGA,MAAM,mBAAmB,QAAgB,aAAoC;AAC3E,UAAM,KAAK,YAAY,QAAQ,WAAW;AAAA,EAC5C;AAAA;AAAA;AAAA;AAAA;AAAA,EAMA,MAAc,YAAY,QAAgB,aAAoC;AAC5E,QAAI,YAAY,SAAS,qBAAqB;AAC5C,YAAM,IAAI,UAAU,KAAK,oBAAoB,yCAAyC;AAAA,IACxF;AACA,SAAK,MAAM,eAAe,QAAQ,MAAM,KAAK,OAAO,KAAK,WAAW,GAAG,KAAK;AAM5E,SAAK,cAAc,OAAO,MAAM;AAKhC,QAAI,WAAW,eAAe;AAC5B,WAAK,aAAa,mBAAmB,QAAQ,OAAO;AACpD,WAAK,MAAM,kBAAkB;AAAA,IAC/B;AAAA,EACF;AAAA;AAAA,EAGA,OAAO,OAAqB;AAC1B,SAAK,aAAa,OAAO,iBAAiB,KAAK,CAAC;AAAA,EAClD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,gBAA+B;AAC7B,WAAO,KAAK,MAAM;AAAA,EACpB;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,qBAAqB,OAA0D;AAC7E,UAAM,WAAW,KAAK,MAAM;AAC5B,QAAI,aAAa,QAAQ,MAAM,WAAW,EAAG,QAAO;AACpD,QAAI,CAAC,YAAY,OAAO,QAAQ,EAAG,QAAO;AAC1C,UAAM,OAAO,KAAK,MAAM,SAAS,aAAa;AAC9C,WAAO,SAAS,OAAO,OAAO,EAAE,MAAM,KAAK,QAAQ;AAAA,EACrD;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAQA,qBAAqB,OAA4E;AAC/F,UAAM,YAAY,iBAAiB,KAAK;AACxC,UAAM,UAAU,KAAK,aAAa,gBAAgB,SAAS;AAC3D,QAAI,CAAC,QAAS,QAAO;AACrB,UAAM,MAAM,KAAK,MAAM,IAAI;AAC3B,UAAM,YAAY,KAAK,MAAM,QAAQ,SAAS;AAC9C,QAAI,aAAa,IAAI,QAAQ,GAAG;AAC9B,WAAK,aAAa,OAAO,SAAS;AAClC,aAAO;AAAA,IACT;AACA,UAAM,OAAO,KAAK,MAAM,SAAS,QAAQ,MAAM;AAC/C,QAAI,CAAC,KAAM,QAAO;AAClB,UAAM,YAAY,YAAY,KAAK,MAAM,QAAQ,SAAS,KAAK,KAAK;AACpE,QAAI,UAAU;AACd,QAAI,aAAa,YAAY,IAAI,QAAQ,IAAI,KAAK,gBAAgB;AAChE,WAAK,aAAa,MAAM,WAAW,IAAI,KAAK,IAAI,QAAQ,IAAI,KAAK,YAAY,EAAE,YAAY,CAAC;AAC5F,gBAAU;AAAA,IACZ;AACA,UAAM,MACJ,QAAQ,QAAQ,WACZ,WACA,QAAQ,QAAQ,YACd,YACA,QAAQ,QAAQ,UACd,UACA;AACV,WAAO,EAAE,MAAM,KAAK,QAAQ;AAAA,EAC9B;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA,EAUA,cAAc,OAAwB;AACpC,UAAM,UAAU,KAAK,aAAa,gBAAgB,iBAAiB,KAAK,CAAC;AACzE,QAAI,CAAC,QAAS,QAAO;AACrB,WAAO,KAAK,MAAM,QAAQ,SAAS,IAAI,KAAK,MAAM,IAAI,EAAE,QAAQ;AAAA,EAClE;AAAA,EAEQ,aAAa,QAAgB,KAA8B;AACjE,WAAO,KAAK,aAAa,MAAM;AAAA,MAC7B;AAAA,MACA;AAAA,MACA,KAAK,KAAK,MAAM,IAAI;AAAA,MACpB,OAAO,KAAK;AAAA,IACd,CAAC,EAAE;AAAA,EACL;AACF;AAzUOC,SAAA;AACE,kBAAAA,QAAA,GAAiB,SAAxB,YADW;AAEJ,kBAAAA,QAAA,GAAiB,gBAAxB,mBAFW;AAOJ,kBAAAA,QAAA,GAAiB,SAAxB,YAPW;AASJ,kBAAAA,QAAA,GAAiB,eAAxB,kBATW;AAUJ,kBAAAA,QAAA,GAAiB,UAAxB,aAVW;AAWJ,kBAAAA,QAAA,GAAiB,SAAxB,YAXW;AAYJ,kBAAAA,QAAA,GAAiB,UAAxB,aAZW;AAAA,cAAN,kBAAAA,QAAA,kBADP,yBACa;AAAN,kBAAAA,QAAA,GAAM;AA5Eb,2CAAAA;AAwZA,yCAACE,WAAU;AACJ,IAAe,4BAAf,MAAyC;AAEhD;AAFOF,SAAA;AAAe,4BAAf,kBAAAA,QAAA,gCADP,uCACsB;AAAf,kBAAAA,QAAA,GAAe;","names":["_init","randomBytes","randomBytes","timingSafeEqual","timingSafeEqual","Component","Interface","randomBytes","_init","Component","Interface"]}
@@ -1,4 +1,8 @@
1
1
  // src/api/types.ts
2
+ var BACKGROUND_IMAGE_MAX_CHARS = 1024 * 1024;
3
+ var DRAFT_KINDS = ["chat", "parked", "org", "channel", "session", "a2ui"];
4
+ var DRAFT_VALUE_MAX_CHARS = 256 * 1024;
5
+ var DRAFTS_PATCH_MAX_ENTRIES = 50;
2
6
  var API_SOCKET_PATH = "/api/socket";
3
7
  function sessionKindPath(pathname) {
4
8
  return pathname === "/api/me/password" || pathname === "/api/desktop" || pathname.startsWith("/api/desktop/") || /^\/api\/projects\/[^/]+\/dirs\/access$/.test(pathname) || pathname.startsWith("/api/") && pathname.endsWith("/reveal");
@@ -20,10 +24,14 @@ var MACHINE_PHASES = [
20
24
  var TICKET_SLUG_PATTERN = /^[a-z]+(?:-[a-z]+)*$/;
21
25
 
22
26
  export {
27
+ BACKGROUND_IMAGE_MAX_CHARS,
28
+ DRAFT_KINDS,
29
+ DRAFT_VALUE_MAX_CHARS,
30
+ DRAFTS_PATCH_MAX_ENTRIES,
23
31
  API_SOCKET_PATH,
24
32
  sessionKindPath,
25
33
  apiSocketPath,
26
34
  MACHINE_PHASES,
27
35
  TICKET_SLUG_PATTERN
28
36
  };
29
- //# sourceMappingURL=chunk-HDBZRVA5.js.map
37
+ //# sourceMappingURL=chunk-BRMNPLU6.js.map