@prismshadow/penguin-server 0.2.7 → 0.2.8

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 (58) hide show
  1. package/README.md +4 -4
  2. package/dist/api/types.d.ts +389 -18
  3. package/dist/auth-token.d.ts +29 -0
  4. package/dist/auth-token.js +51 -0
  5. package/dist/auth-token.js.map +1 -0
  6. package/dist/{chunk-WKJ3ANJP.js → chunk-622KL7BQ.js} +92 -381
  7. package/dist/chunk-622KL7BQ.js.map +1 -0
  8. package/dist/chunk-K4ZSUT72.js +34 -0
  9. package/dist/chunk-K4ZSUT72.js.map +1 -0
  10. package/dist/chunk-UGAFPYVX.js +374 -0
  11. package/dist/chunk-UGAFPYVX.js.map +1 -0
  12. package/dist/extension/index.d.ts +212 -0
  13. package/dist/extension/index.js +1 -0
  14. package/dist/extension/index.js.map +1 -0
  15. package/dist/index.js +5641 -1299
  16. package/dist/index.js.map +1 -1
  17. package/dist/initial-password.d.ts +9 -12
  18. package/dist/initial-password.js +3 -7
  19. package/dist/install.ps1 +729 -0
  20. package/dist/install.sh +785 -0
  21. package/dist/reset-admin-password.d.ts +4 -7
  22. package/dist/reset-admin-password.js +11 -9
  23. package/dist/reset-admin-password.js.map +1 -1
  24. package/dist/secret-file.d.ts +3 -0
  25. package/dist/secret-file.js +17 -0
  26. package/dist/secret-file.js.map +1 -0
  27. package/package.json +19 -3
  28. package/web-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  29. package/web-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  30. package/web-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  31. package/web-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  32. package/web-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  33. package/web-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  34. package/web-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  35. package/web-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  36. package/web-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  37. package/web-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  38. package/web-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  39. package/web-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  40. package/web-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/web-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  42. package/web-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  43. package/web-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  44. package/web-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  45. package/web-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  46. package/web-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  47. package/web-dist/assets/{addon-fit-BXTX0Xqu.js → addon-fit-DHOzI3Ur.js} +1 -1
  48. package/web-dist/assets/{addon-web-links-KebkunbU.js → addon-web-links-DC7759H1.js} +1 -1
  49. package/web-dist/assets/{highlighter-B3HctNDB.js → highlighter-CGPO6Q4b.js} +1 -1
  50. package/web-dist/assets/index-CaFDHl-w.js +451 -0
  51. package/web-dist/assets/index-D8Bk9LtJ.css +1 -0
  52. package/web-dist/assets/{xterm-fEUfDV0F.js → xterm-DY5ko-BU.js} +1 -1
  53. package/web-dist/index.html +2 -2
  54. package/dist/chunk-UUJCF46V.js +0 -50
  55. package/dist/chunk-UUJCF46V.js.map +0 -1
  56. package/dist/chunk-WKJ3ANJP.js.map +0 -1
  57. package/web-dist/assets/index-BbLm3DrY.css +0 -1
  58. package/web-dist/assets/index-DwgTjTHO.js +0 -189
@@ -0,0 +1,51 @@
1
+ import {
2
+ AuthSessionsRepo,
3
+ UsersRepo,
4
+ openExistingDatabase
5
+ } from "./chunk-622KL7BQ.js";
6
+
7
+ // src/auth-token.ts
8
+ import path from "path";
9
+ import fs from "fs";
10
+ var CLI_TOKEN_TTL_MS = 60 * 6e4;
11
+ var CLI_TOKEN_MAX_TTL_MS = 30 * 24 * 60 * 6e4;
12
+ function explain(err, dbPath) {
13
+ const message = err instanceof Error ? err.message : String(err);
14
+ if (message.includes("no column named via")) {
15
+ return `${dbPath} predates this release \u2014 start the server once (\`penguin web\`) to upgrade it`;
16
+ }
17
+ if (message.includes("unable to open database")) {
18
+ return `cannot open ${dbPath} \u2014 minting needs the data root's own OS account; otherwise sign in with \`penguin auth login --server <url>\``;
19
+ }
20
+ return message;
21
+ }
22
+ function mintApiToken(root, opts = {}) {
23
+ const userId = opts.userId ?? "admin";
24
+ const dbPath = opts.dbPath ?? path.join(root, "web.db");
25
+ if (!fs.existsSync(dbPath)) return { outcome: "no_server" };
26
+ let db = null;
27
+ try {
28
+ db = openExistingDatabase(dbPath);
29
+ if (new UsersRepo(db).findById(userId) === null) {
30
+ return { outcome: "failed", detail: `no such account: ${userId}` };
31
+ }
32
+ const { token, expiresAt } = new AuthSessionsRepo(db).issue({
33
+ userId,
34
+ via: "cli",
35
+ now: opts.now ?? /* @__PURE__ */ new Date(),
36
+ ttlMs: opts.ttlMs ?? CLI_TOKEN_TTL_MS,
37
+ maxTtlMs: CLI_TOKEN_MAX_TTL_MS
38
+ });
39
+ return { outcome: "minted", token, userId, expiresAt };
40
+ } catch (err) {
41
+ return { outcome: "failed", detail: explain(err, dbPath) };
42
+ } finally {
43
+ db?.close();
44
+ }
45
+ }
46
+ export {
47
+ CLI_TOKEN_MAX_TTL_MS,
48
+ CLI_TOKEN_TTL_MS,
49
+ mintApiToken
50
+ };
51
+ //# sourceMappingURL=auth-token.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/auth-token.ts"],"sourcesContent":["/**\n * Mints an API session by inserting a row into the root's `web.db` — no running server needed.\n * Authorized by being able to read and write that root, which already holds every credential\n * the token could reach. On a multi-user box that means the OS account running the server;\n * everyone else signs in with `penguin auth login --server <url>`.\n */\nimport path from \"node:path\";\nimport fs from \"node:fs\";\nimport { openExistingDatabase } from \"./db/database.js\";\nimport { AuthSessionsRepo } from \"./db/repos/auth-sessions.js\";\nimport { UsersRepo } from \"./db/repos/users.js\";\n\n/** An hour: long enough for a controller to finish, short enough to forget. */\nexport const CLI_TOKEN_TTL_MS = 60 * 60_000;\n\n/**\n * Ceiling on a caller-supplied `--ttl-seconds`, matching the default session TTL (config.ts):\n * a token that lives in a file must not outlive an ordinary browser session.\n */\nexport const CLI_TOKEN_MAX_TTL_MS = 30 * 24 * 60 * 60_000;\n\nexport type MintTokenResult =\n | { outcome: \"minted\"; token: string; userId: string; expiresAt: string }\n /** No web.db on this root — the server has never run here, so there is no account to mint for. */\n | { outcome: \"no_server\" }\n | { outcome: \"failed\"; detail: string };\n\n/**\n * Turns a failure into something the person at the terminal can act on. Both cases are\n * ordinary situations, not crashes: a database owned by another OS account, and one written\n * by an older release (minting deliberately does not migrate — see openExistingDatabase).\n */\nfunction explain(err: unknown, dbPath: string): string {\n const message = err instanceof Error ? err.message : String(err);\n if (message.includes(\"no column named via\")) {\n return `${dbPath} predates this release — start the server once (\\`penguin web\\`) to upgrade it`;\n }\n if (message.includes(\"unable to open database\")) {\n return (\n `cannot open ${dbPath} — minting needs the data root's own OS account; ` +\n \"otherwise sign in with `penguin auth login --server <url>`\"\n );\n }\n return message;\n}\n\n/** Issues an API session token for the data root at `root`. */\nexport function mintApiToken(\n root: string,\n opts: { userId?: string; ttlMs?: number; dbPath?: string; now?: Date } = {},\n): MintTokenResult {\n const userId = opts.userId ?? \"admin\";\n const dbPath = opts.dbPath ?? path.join(root, \"web.db\");\n // Existence check first: openExistingDatabase would otherwise create an empty file, and a\n // root with no web.db has no account to mint for.\n if (!fs.existsSync(dbPath)) return { outcome: \"no_server\" };\n\n let db: ReturnType<typeof openExistingDatabase> | null = null;\n try {\n db = openExistingDatabase(dbPath);\n if (new UsersRepo(db).findById(userId) === null) {\n return { outcome: \"failed\", detail: `no such account: ${userId}` };\n }\n // The repo is the single minting point (token shape, hash, TTL ceiling), so a CLI-minted\n // row cannot drift from one the server issues itself.\n const { token, expiresAt } = new AuthSessionsRepo(db).issue({\n userId,\n via: \"cli\",\n now: opts.now ?? new Date(),\n ttlMs: opts.ttlMs ?? CLI_TOKEN_TTL_MS,\n maxTtlMs: CLI_TOKEN_MAX_TTL_MS,\n });\n return { outcome: \"minted\", token, userId, expiresAt };\n } catch (err) {\n return { outcome: \"failed\", detail: explain(err, dbPath) };\n } finally {\n db?.close();\n }\n}\n"],"mappings":";;;;;;;AAMA,OAAO,UAAU;AACjB,OAAO,QAAQ;AAMR,IAAM,mBAAmB,KAAK;AAM9B,IAAM,uBAAuB,KAAK,KAAK,KAAK;AAanD,SAAS,QAAQ,KAAc,QAAwB;AACrD,QAAM,UAAU,eAAe,QAAQ,IAAI,UAAU,OAAO,GAAG;AAC/D,MAAI,QAAQ,SAAS,qBAAqB,GAAG;AAC3C,WAAO,GAAG,MAAM;AAAA,EAClB;AACA,MAAI,QAAQ,SAAS,yBAAyB,GAAG;AAC/C,WACE,eAAe,MAAM;AAAA,EAGzB;AACA,SAAO;AACT;AAGO,SAAS,aACd,MACA,OAAyE,CAAC,GACzD;AACjB,QAAM,SAAS,KAAK,UAAU;AAC9B,QAAM,SAAS,KAAK,UAAU,KAAK,KAAK,MAAM,QAAQ;AAGtD,MAAI,CAAC,GAAG,WAAW,MAAM,EAAG,QAAO,EAAE,SAAS,YAAY;AAE1D,MAAI,KAAqD;AACzD,MAAI;AACF,SAAK,qBAAqB,MAAM;AAChC,QAAI,IAAI,UAAU,EAAE,EAAE,SAAS,MAAM,MAAM,MAAM;AAC/C,aAAO,EAAE,SAAS,UAAU,QAAQ,oBAAoB,MAAM,GAAG;AAAA,IACnE;AAGA,UAAM,EAAE,OAAO,UAAU,IAAI,IAAI,iBAAiB,EAAE,EAAE,MAAM;AAAA,MAC1D;AAAA,MACA,KAAK;AAAA,MACL,KAAK,KAAK,OAAO,oBAAI,KAAK;AAAA,MAC1B,OAAO,KAAK,SAAS;AAAA,MACrB,UAAU;AAAA,IACZ,CAAC;AACD,WAAO,EAAE,SAAS,UAAU,OAAO,QAAQ,UAAU;AAAA,EACvD,SAAS,KAAK;AACZ,WAAO,EAAE,SAAS,UAAU,QAAQ,QAAQ,KAAK,MAAM,EAAE;AAAA,EAC3D,UAAE;AACA,QAAI,MAAM;AAAA,EACZ;AACF;","names":[]}
@@ -11,13 +11,21 @@ CREATE TABLE IF NOT EXISTS users (
11
11
  password_is_initial INTEGER NOT NULL DEFAULT 0, -- 1=initial password (seeded/admin-set); cleared once the user changes it
12
12
  created_at TEXT NOT NULL
13
13
  );
14
+ -- Server-side sessions backing the HttpOnly cookie: the cookie carries a 32-byte random
15
+ -- token, the row stores only its sha256. A session outlives a restart (it is on disk), which
16
+ -- is why the model is a row and not a signed statement.
14
17
  CREATE TABLE IF NOT EXISTS auth_sessions (
15
18
  token_hash TEXT PRIMARY KEY, -- sha256(token) hex; the cookie stores only the raw token
16
19
  user_id TEXT NOT NULL REFERENCES users(user_id) ON DELETE CASCADE,
17
20
  created_at TEXT NOT NULL,
18
- expires_at TEXT NOT NULL, -- 7-day sliding renewal (topped up when <6 days remain)
19
- via TEXT -- 'password' | 'desktop'; NULL = legacy row (password)
21
+ expires_at TEXT NOT NULL, -- 30-day sliding renewal, topped up in place when <29 days remain
22
+ via TEXT -- 'password'|'desktop'|'setup'|'cli'; NULL = legacy (password)
20
23
  );
24
+ -- deleteExpired runs on every login and mint, deleteByUser on every admin reset; without
25
+ -- these both scan the whole table.
26
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_expires ON auth_sessions(expires_at);
27
+ CREATE INDEX IF NOT EXISTS idx_auth_sessions_user ON auth_sessions(user_id);
28
+
21
29
  CREATE TABLE IF NOT EXISTS projects (
22
30
  project_id TEXT PRIMARY KEY, -- directory name doubles as id; display name lives in project_config.toml
23
31
  owner_user_id TEXT NOT NULL REFERENCES users(user_id),
@@ -114,18 +122,22 @@ CREATE TABLE IF NOT EXISTS goal_state ( -- goal-mode runtime state (GOAL.
114
122
  );
115
123
  CREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);
116
124
  CREATE TABLE IF NOT EXISTS messaging_bindings ( -- Session \u2194 messaging-channel bot bindings (runtime/messaging/ holds the long connections)
117
- 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)
118
- channel TEXT NOT NULL, -- messaging channel discriminator ('feishu' | 'telegram')
119
- account_id TEXT NOT NULL, -- channel-scoped bot/app identity (feishu: app_id; telegram: the bot token's numeric id); one binding per account per channel (idx_messaging_account: one account has one event stream, two Sessions would race it)
120
- config_json TEXT NOT NULL, -- channel-specific credentials/config JSON (feishu: appId/appSecret/baseDomain; telegram: botToken); 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)
125
+ 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
126
+ channel TEXT NOT NULL, -- messaging channel discriminator ('feishu' | 'telegram' | 'qq')
127
+ 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); NOT unique \u2014 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)
128
+ config_json TEXT NOT NULL, -- channel-specific credentials/config JSON (feishu: appId/appSecret/baseDomain; telegram: botToken; qq: appId/appSecret); 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)
121
129
  enabled INTEGER NOT NULL DEFAULT 0, -- INTENT state (the connection's runtime status stays in memory): new bindings start disabled \u2014 saving credentials never opens a connection, the explicit state toggle does
122
- last_chat_id TEXT, -- most recent inbound chat (NULL until the bot is messaged once; replies and test messages target it)
130
+ 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
131
+ 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
132
+ 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), 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
133
+ 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 \u2014 telegram appends the forum topic (see telegram-connector.ts chatRefOf), so only that channel's connector may interpret it
123
134
  last_chat_is_direct INTEGER NOT NULL DEFAULT 1, -- 1 = direct chat (reply by chat id), 0 = group chat (prefer reply-to-message)
135
+ 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
124
136
  created_at TEXT NOT NULL,
125
137
  updated_at TEXT NOT NULL,
126
138
  PRIMARY KEY (session_id, channel)
127
139
  );
128
- CREATE UNIQUE INDEX IF NOT EXISTS idx_messaging_account ON messaging_bindings(channel, account_id);
140
+ CREATE 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
129
141
  CREATE TABLE IF NOT EXISTS ui_prefs (
130
142
  user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
131
143
  prefs_json TEXT NOT NULL -- {theme?, lastProjectId?, ...} free-form JSON
@@ -163,12 +175,19 @@ CREATE INDEX IF NOT EXISTS idx_trace_sessions_agent ON trace_sessions(project_id
163
175
 
164
176
  // src/db/database.ts
165
177
  var sqlite = process.getBuiltinModule("node:sqlite");
178
+ function openExistingDatabase(dbPath) {
179
+ const db = new sqlite.DatabaseSync(dbPath);
180
+ db.exec("PRAGMA foreign_keys = ON;");
181
+ db.exec("PRAGMA busy_timeout = 5000;");
182
+ return db;
183
+ }
166
184
  function openDatabase(dbPath) {
167
185
  if (dbPath !== ":memory:") {
168
186
  mkdirSync(path.dirname(dbPath), { recursive: true });
169
187
  }
170
188
  const db = new sqlite.DatabaseSync(dbPath);
171
189
  db.exec("PRAGMA journal_mode = WAL;");
190
+ db.exec("PRAGMA busy_timeout = 5000;");
172
191
  db.exec("PRAGMA foreign_keys = ON;");
173
192
  db.exec(SCHEMA_SQL);
174
193
  ensureColumn(db, "sessions", "client", "TEXT");
@@ -177,7 +196,12 @@ function openDatabase(dbPath) {
177
196
  ensureColumn(db, "sessions", "thinking_level", "TEXT");
178
197
  ensureColumn(db, "auth_sessions", "via", "TEXT");
179
198
  ensureColumn(db, "trace_files", "page_stats", "TEXT");
199
+ ensureColumn(db, "messaging_bindings", "line_per_message", "INTEGER NOT NULL DEFAULT 0");
200
+ ensureColumn(db, "messaging_bindings", "final_reply_only", "INTEGER NOT NULL DEFAULT 0");
201
+ ensureColumn(db, "messaging_bindings", "render_markdown", "INTEGER NOT NULL DEFAULT 1");
202
+ ensureColumn(db, "messaging_bindings", "last_inbound_message_id", "TEXT");
180
203
  db.exec("DROP INDEX IF EXISTS idx_usage_session");
204
+ db.exec("DROP INDEX IF EXISTS idx_messaging_account");
181
205
  upgradeLastActiveAt(db);
182
206
  return db;
183
207
  }
@@ -205,45 +229,6 @@ function ensureColumn(db, table, column, ddl) {
205
229
  return true;
206
230
  }
207
231
 
208
- // src/db/repos/auth-sessions.ts
209
- var AuthSessionsRepo = class {
210
- constructor(db) {
211
- this.db = db;
212
- }
213
- db;
214
- insert(row) {
215
- this.db.prepare(
216
- "INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)"
217
- ).run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
218
- }
219
- findByTokenHash(tokenHash) {
220
- const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
221
- if (!r) return null;
222
- return {
223
- tokenHash: r.token_hash,
224
- userId: r.user_id,
225
- createdAt: r.created_at,
226
- expiresAt: r.expires_at,
227
- via: r.via ?? null
228
- };
229
- }
230
- /** Sliding renewal: update the expiration time. */
231
- touch(tokenHash, expiresAt) {
232
- this.db.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?").run(expiresAt, tokenHash);
233
- }
234
- delete(tokenHash) {
235
- this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
236
- }
237
- /** Opportunistically clean up expired sessions (called during login/validation). */
238
- deleteExpired(nowIso) {
239
- this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
240
- }
241
- /** Clear all sessions for a user (forces re-login after an admin resets the password). */
242
- deleteByUser(userId) {
243
- this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
244
- }
245
- };
246
-
247
232
  // src/db/repos/users.ts
248
233
  function mapRow(r) {
249
234
  return {
@@ -293,357 +278,83 @@ var UsersRepo = class {
293
278
  }
294
279
  };
295
280
 
296
- // src/auth/password.ts
297
- import { randomBytes, scrypt, timingSafeEqual } from "crypto";
298
- var SCRYPT_COST = 16384;
299
- var SCRYPT_R = 8;
300
- var SCRYPT_P = 1;
301
- var SALT_BYTES = 16;
302
- var KEY_BYTES = 64;
303
- function scryptAsync(password, salt, keyLen, n, r, p) {
304
- return new Promise((resolve, reject) => {
305
- scrypt(password, salt, keyLen, { N: n, r, p, maxmem: 128 * 1024 * 1024 }, (err, key) => {
306
- if (err) reject(err);
307
- else resolve(key);
308
- });
309
- });
310
- }
311
- async function hashPassword(password, cost = SCRYPT_COST) {
312
- const salt = randomBytes(SALT_BYTES);
313
- const key = await scryptAsync(password, salt, KEY_BYTES, cost, SCRYPT_R, SCRYPT_P);
314
- return [
315
- "scrypt",
316
- String(cost),
317
- String(SCRYPT_R),
318
- String(SCRYPT_P),
319
- salt.toString("base64"),
320
- key.toString("base64")
321
- ].join("$");
322
- }
323
- async function verifyPassword(password, stored) {
324
- const parts = stored.split("$");
325
- if (parts.length !== 6 || parts[0] !== "scrypt") return false;
326
- const n = Number(parts[1]);
327
- const r = Number(parts[2]);
328
- const p = Number(parts[3]);
329
- if (!Number.isInteger(n) || !Number.isInteger(r) || !Number.isInteger(p)) return false;
330
- let salt;
331
- let expected;
332
- try {
333
- salt = Buffer.from(parts[4], "base64");
334
- expected = Buffer.from(parts[5], "base64");
335
- } catch {
336
- return false;
337
- }
338
- if (salt.length === 0 || expected.length === 0) return false;
339
- let actual;
340
- try {
341
- actual = await scryptAsync(password, salt, expected.length, n, r, p);
342
- } catch {
343
- return false;
344
- }
345
- return actual.length === expected.length && timingSafeEqual(actual, expected);
346
- }
347
-
348
- // src/auth/service.ts
349
- import { createHash as createHash2, randomBytes as randomBytes3, randomInt } from "crypto";
350
-
351
- // src/auth/api-token.ts
352
- import { createHash, randomBytes as randomBytes2, timingSafeEqual as timingSafeEqual2 } from "crypto";
353
- import fs from "fs";
354
- import path2 from "path";
355
- function apiTokenPath(root) {
356
- return path2.join(root, "api-token");
357
- }
358
- function mintApiToken() {
359
- return randomBytes2(32).toString("base64url");
360
- }
361
- function storeApiToken(root, token) {
362
- try {
363
- fs.mkdirSync(root, { recursive: true });
364
- const target = apiTokenPath(root);
365
- const tmp = `${target}.${process.pid}.tmp`;
366
- fs.writeFileSync(tmp, `${token}
367
- `, { mode: 384 });
368
- fs.renameSync(tmp, target);
369
- } catch {
370
- }
371
- }
372
- function tokensEqual(a, b) {
373
- const digest = (v) => createHash("sha256").update(v).digest();
374
- return timingSafeEqual2(digest(a), digest(b));
375
- }
376
-
377
- // src/http/errors.ts
378
- var HttpError = class extends Error {
379
- constructor(status, code, message) {
380
- super(message);
381
- this.status = status;
382
- this.code = code;
383
- this.name = "HttpError";
384
- }
385
- status;
386
- code;
387
- };
388
- function errorBody(code, message) {
389
- return { error: { code, message } };
390
- }
391
- function isMissingCredential(err) {
392
- const message = err instanceof Error ? err.message : String(err);
393
- return /missing credentials|api[_ ]?key/i.test(message);
394
- }
395
- function modelCredentialMissing(modelId) {
396
- return new HttpError(
397
- 400,
398
- "model_credential_missing",
399
- `Model ${modelId} has no API key yet. Configure it on the Models page first.`
400
- );
401
- }
402
- function handleError(err, c) {
403
- if (err instanceof HttpError) {
404
- return c.json(errorBody(err.code, err.message), err.status);
405
- }
406
- console.error(`[server] Unhandled exception: ${err.stack ?? err.message}`);
407
- return c.json(errorBody("internal", "Internal server error."), 500);
408
- }
409
-
410
- // src/auth/service.ts
411
- var MIN_PASSWORD_LENGTH = 8;
412
- var ADMIN_USER_ID = "admin";
413
- var LOGIN_FREE_ATTEMPTS = 5;
414
- var LOGIN_BACKOFF_START_MS = 1e3;
415
- var LOGIN_BACKOFF_CAP_MS = 6e4;
416
- var LOGIN_FAILURE_IDLE_MS = 15 * 6e4;
417
- function generateInitialAdminPassword() {
418
- return "penguin-" + String(randomInt(0, 1e4)).padStart(4, "0");
419
- }
420
- function sha256Hex(value) {
421
- return createHash2("sha256").update(value).digest("hex");
422
- }
423
- function toUserInfo(row) {
424
- return {
425
- userId: row.userId,
426
- isAdmin: row.isAdmin,
427
- passwordIsInitial: row.passwordIsInitial,
428
- createdAt: row.createdAt
429
- };
281
+ // src/db/repos/auth-sessions.ts
282
+ import { createHash, randomBytes } from "crypto";
283
+ function sessionTokenHash(token) {
284
+ return createHash("sha256").update(token).digest("hex");
430
285
  }
431
- var AuthService = class {
432
- constructor(deps) {
433
- this.deps = deps;
434
- this.provisioner = deps.provisionInitialProject;
435
- this.now = deps.now ?? (() => /* @__PURE__ */ new Date());
436
- this.hashCost = deps.passwordHashCost ?? SCRYPT_COST;
286
+ var AuthSessionsRepo = class {
287
+ constructor(db) {
288
+ this.db = db;
437
289
  }
438
- deps;
439
- /**
440
- * What a fresh user is provisioned with is business policy, answered by the CURRENT
441
- * App: the platform installs its answer at create over the claimed auth capability
442
- * (ordinary capability use, not a registry entry), and each swap's successor overwrites
443
- * it. Starts as the constructor-supplied fallback so standalone/test constructions
444
- * keep working unchanged.
445
- */
446
- provisioner;
447
- now;
448
- hashCost;
290
+ db;
449
291
  /**
450
- * Startup seeding (idempotent): creates the built-in admin and adopts
451
- * default_project when the users table is empty; if the initial Project fails,
452
- * the user row is rolled back and the server retries on next startup.
453
- * Returns the initial password when it actually seeded — the caller prints it,
454
- * the only place a generated password is ever shown — and null when users
455
- * already exist.
292
+ * Mints a session and returns the raw token for a cookie or the terminal. `maxTtlMs` caps a
293
+ * caller-supplied lifetime (`--ttl-seconds`): one longer than an ordinary session would make
294
+ * a leaked token file outlive every other credential, sliding forever. Expired rows are
295
+ * swept here too, so a cron that only mints does not accumulate them.
456
296
  */
457
- async seedAdmin() {
458
- if (this.deps.users.count() > 0) return null;
459
- const password = this.deps.seedAdminPassword ?? generateInitialAdminPassword();
460
- if (password.length < MIN_PASSWORD_LENGTH) {
461
- throw new Error(
462
- `PENGUIN_SEED_ADMIN_PASSWORD must be at least ${MIN_PASSWORD_LENGTH} characters.`
463
- );
464
- }
465
- const user = {
466
- userId: ADMIN_USER_ID,
467
- passwordHash: await hashPassword(password, this.hashCost),
468
- isAdmin: true,
469
- passwordIsInitial: true,
470
- createdAt: this.now().toISOString()
471
- };
472
- this.deps.users.insert(user);
473
- try {
474
- await this.provisioner(user, true);
475
- } catch (err) {
476
- this.deps.users.delete(user.userId);
477
- throw err;
478
- }
479
- return password;
480
- }
481
- /** Installs the current App's provisioning policy (see the `provisioner` field). */
482
- setProvisioner(provision) {
483
- this.provisioner = provision;
484
- }
485
- /** Whether the built-in admin still runs on its initial password (drives the startup reminder notice). */
486
- adminPasswordIsInitial() {
487
- return this.deps.users.findById(ADMIN_USER_ID)?.passwordIsInitial === true;
488
- }
489
- /** Consecutive login failures per userId (see the throttling comment on the constants). */
490
- loginFailures = /* @__PURE__ */ new Map();
491
- /** The wait imposed after `failures` consecutive failures (0 while within the free attempts). */
492
- loginDelayMs(failures) {
493
- const excess = failures - LOGIN_FREE_ATTEMPTS;
494
- if (excess <= 0) return 0;
495
- return Math.min(LOGIN_BACKOFF_START_MS * 2 ** (excess - 1), LOGIN_BACKOFF_CAP_MS);
496
- }
497
- async login(userId, password) {
498
- const nowMs = this.now().getTime();
499
- for (const [key, entry] of this.loginFailures) {
500
- if (nowMs - entry.lastFailureAt > LOGIN_FAILURE_IDLE_MS) this.loginFailures.delete(key);
501
- }
502
- const failed = this.loginFailures.get(userId);
503
- if (failed) {
504
- const readyAt = failed.lastFailureAt + this.loginDelayMs(failed.failures);
505
- if (nowMs < readyAt) {
506
- throw new HttpError(
507
- 429,
508
- "too_many_attempts",
509
- `Too many failed sign-in attempts. Try again in ${Math.ceil((readyAt - nowMs) / 1e3)}s.`
510
- );
511
- }
512
- }
513
- const row = this.deps.users.findById(userId);
514
- const ok = row !== null && await verifyPassword(password, row.passwordHash);
515
- if (!row || !ok) {
516
- this.loginFailures.set(userId, {
517
- failures: (failed?.failures ?? 0) + 1,
518
- lastFailureAt: this.now().getTime()
519
- });
520
- throw new HttpError(401, "invalid_credentials", "Incorrect username or password.");
521
- }
522
- this.loginFailures.delete(userId);
523
- this.deps.authSessions.deleteExpired(this.now().toISOString());
524
- return { user: toUserInfo(row), token: this.issueSession(row.userId, "password") };
297
+ issue(opts) {
298
+ const ttlMs = Math.max(1e3, Math.min(opts.ttlMs, opts.maxTtlMs ?? opts.ttlMs));
299
+ const token = randomBytes(32).toString("base64url");
300
+ const createdAt = opts.now.toISOString();
301
+ const expiresAt = new Date(opts.now.getTime() + ttlMs).toISOString();
302
+ this.deleteExpired(createdAt);
303
+ this.insert({
304
+ tokenHash: sessionTokenHash(token),
305
+ userId: opts.userId,
306
+ createdAt,
307
+ expiresAt,
308
+ via: opts.via
309
+ });
310
+ return { token, expiresAt };
525
311
  }
526
- /**
527
- * Desktop-mode sign-in: issues an admin session WITHOUT a password check — the caller
528
- * (the desktop-login route) has already redeemed the shell's one-shot token, which is
529
- * the credential here. Throws if the admin has not been seeded yet (desktop-login runs
530
- * after startup seeding, so this only trips on a broken deployment).
531
- */
532
- loginDesktop() {
533
- const row = this.deps.users.findById(ADMIN_USER_ID);
534
- if (!row) {
535
- throw new HttpError(500, "internal", "Built-in admin has not been seeded.");
536
- }
537
- this.deps.authSessions.deleteExpired(this.now().toISOString());
538
- return { user: toUserInfo(row), token: this.issueSession(row.userId, "desktop") };
312
+ insert(row) {
313
+ this.db.prepare(
314
+ "INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)"
315
+ ).run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);
539
316
  }
540
- /** Self password change (user settings): validates the old password, and on success clears the initial-password flag; the current session remains valid. */
541
- async changePassword(userId, oldPassword, newPassword) {
542
- const row = this.deps.users.findById(userId);
543
- if (!row || !await verifyPassword(oldPassword, row.passwordHash)) {
544
- throw new HttpError(400, "password_mismatch", "Current password is incorrect.");
545
- }
546
- if (newPassword.length < MIN_PASSWORD_LENGTH) {
547
- throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
548
- }
549
- this.deps.users.updatePassword(userId, await hashPassword(newPassword, this.hashCost), false);
550
- this.deps.onPasswordChanged?.(userId);
317
+ findByTokenHash(tokenHash) {
318
+ const r = this.db.prepare("SELECT * FROM auth_sessions WHERE token_hash = ?").get(tokenHash);
319
+ if (!r) return null;
320
+ return {
321
+ tokenHash: r.token_hash,
322
+ userId: r.user_id,
323
+ createdAt: r.created_at,
324
+ expiresAt: r.expires_at,
325
+ via: r.via ?? null
326
+ };
551
327
  }
552
- /**
553
- * Desktop-session password set: no old-password check. Only reachable for sessions
554
- * established via desktop-login (the me route gates on sessionVia) — the seed password
555
- * of a desktop-created root is random and never shown, so its holder has nothing to
556
- * type into an old-password field; the shell's token already proved machine ownership.
557
- */
558
- async setPasswordDesktop(userId, newPassword) {
559
- if (newPassword.length < MIN_PASSWORD_LENGTH) {
560
- throw new HttpError(400, "invalid_password", "Password must be at least 8 characters.");
561
- }
562
- this.deps.users.updatePassword(userId, await hashPassword(newPassword, this.hashCost), false);
563
- this.deps.onPasswordChanged?.(userId);
328
+ /** Sliding renewal: update the expiration time. */
329
+ touch(tokenHash, expiresAt) {
330
+ this.db.prepare("UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?").run(expiresAt, tokenHash);
564
331
  }
565
- logout(token) {
566
- this.deps.authSessions.delete(sha256Hex(token));
332
+ delete(tokenHash) {
333
+ this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
567
334
  }
568
- /**
569
- * The boot-scoped local API token (`<root>/api-token`, see auth/api-token.ts): held in
570
- * memory by the process that minted it. Null until the startup assembly installs it
571
- * (standalone/test constructions without one simply never authenticate a Bearer
572
- * header).
573
- */
574
- apiToken = null;
575
- /** Installs the boot's local API token (called once by the startup assembly). */
576
- setLocalApiToken(token) {
577
- this.apiToken = token;
335
+ /** Opportunistically clean up expired sessions (called during login/validation). */
336
+ deleteExpired(nowIso) {
337
+ this.db.prepare("DELETE FROM auth_sessions WHERE expires_at < ?").run(nowIso);
578
338
  }
579
- /** The current boot's local API token (what control-env injection hands to tool subprocesses); null when none was installed. */
580
- localApiToken() {
581
- return this.apiToken;
339
+ /** Clear all sessions for a user (forces re-login after an admin resets the password). */
340
+ deleteByUser(userId) {
341
+ this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ?").run(userId);
582
342
  }
583
343
  /**
584
- * Validates a Bearer token against the boot's local API token (constant-time compare):
585
- * a match authenticates as the built-in admin — holding the token proves filesystem
586
- * access to the data root, which is admin authority (see auth/api-token.ts). Returns
587
- * null on mismatch, when no token is installed, or when the admin row is missing.
344
+ * Clear a user's sessions of one kind. Used to end EVERY first-login session when a password
345
+ * is set: an earlier boot's link may still be live in someone's scrollback, and a `setup`
346
+ * session may change the password without knowing the old one.
588
347
  */
589
- authenticateApiToken(token) {
590
- if (this.apiToken === null || token.length === 0) return null;
591
- if (!tokensEqual(token, this.apiToken)) return null;
592
- const user = this.deps.users.findById(ADMIN_USER_ID);
593
- return user === null ? null : { user, via: "token" };
594
- }
595
- /** Validates the cookie token: returns null if expired/unknown; sliding renewal once less than 6 days remain. */
596
- authenticateWithMeta(token) {
597
- const tokenHash = sha256Hex(token);
598
- const session = this.deps.authSessions.findByTokenHash(tokenHash);
599
- if (!session) return null;
600
- const now = this.now();
601
- const expiresAt = Date.parse(session.expiresAt);
602
- if (!(expiresAt > now.getTime())) {
603
- this.deps.authSessions.delete(tokenHash);
604
- return null;
605
- }
606
- if (expiresAt - now.getTime() < this.deps.sessionRenewMs) {
607
- this.deps.authSessions.touch(
608
- tokenHash,
609
- new Date(now.getTime() + this.deps.sessionTtlMs).toISOString()
610
- );
611
- }
612
- const user = this.deps.users.findById(session.userId);
613
- if (!user) return null;
614
- return { user, via: session.via === "desktop" ? "desktop" : "password" };
615
- }
616
- issueSession(userId, via) {
617
- const token = randomBytes3(32).toString("base64url");
618
- const now = this.now();
619
- this.deps.authSessions.insert({
620
- tokenHash: sha256Hex(token),
621
- userId,
622
- createdAt: now.toISOString(),
623
- expiresAt: new Date(now.getTime() + this.deps.sessionTtlMs).toISOString(),
624
- via
625
- });
626
- return token;
348
+ deleteByUserAndVia(userId, via) {
349
+ this.db.prepare("DELETE FROM auth_sessions WHERE user_id = ? AND via = ?").run(userId, via);
627
350
  }
628
351
  };
629
352
 
630
353
  export {
354
+ openExistingDatabase,
631
355
  openDatabase,
632
- AuthSessionsRepo,
633
356
  UsersRepo,
634
- HttpError,
635
- errorBody,
636
- isMissingCredential,
637
- modelCredentialMissing,
638
- handleError,
639
- mintApiToken,
640
- storeApiToken,
641
- SCRYPT_COST,
642
- hashPassword,
643
- MIN_PASSWORD_LENGTH,
644
- ADMIN_USER_ID,
645
- generateInitialAdminPassword,
646
- toUserInfo,
647
- AuthService
357
+ sessionTokenHash,
358
+ AuthSessionsRepo
648
359
  };
649
- //# sourceMappingURL=chunk-WKJ3ANJP.js.map
360
+ //# sourceMappingURL=chunk-622KL7BQ.js.map