@prismshadow/penguin-server 0.2.7 → 0.2.9
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.
- package/README.md +4 -4
- package/dist/api/types.d.ts +528 -39
- package/dist/auth-token.d.ts +29 -0
- package/dist/auth-token.js +51 -0
- package/dist/auth-token.js.map +1 -0
- package/dist/chunk-K4ZSUT72.js +34 -0
- package/dist/chunk-K4ZSUT72.js.map +1 -0
- package/dist/chunk-OTYHSDV7.js +374 -0
- package/dist/chunk-OTYHSDV7.js.map +1 -0
- package/dist/{chunk-WKJ3ANJP.js → chunk-WL5FUO3A.js} +92 -381
- package/dist/chunk-WL5FUO3A.js.map +1 -0
- package/dist/extension/index.d.ts +212 -0
- package/dist/extension/index.js +1 -0
- package/dist/extension/index.js.map +1 -0
- package/dist/index.js +7337 -1774
- package/dist/index.js.map +1 -1
- package/dist/initial-password.d.ts +9 -12
- package/dist/initial-password.js +3 -7
- package/dist/install.ps1 +729 -0
- package/dist/install.sh +785 -0
- package/dist/reset-admin-password.d.ts +4 -7
- package/dist/reset-admin-password.js +11 -9
- package/dist/reset-admin-password.js.map +1 -1
- package/dist/secret-file.d.ts +3 -0
- package/dist/secret-file.js +17 -0
- package/dist/secret-file.js.map +1 -0
- package/package.json +19 -3
- package/web-dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- package/web-dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- package/web-dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- package/web-dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- package/web-dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- package/web-dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- package/web-dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- package/web-dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- package/web-dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- package/web-dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- package/web-dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- package/web-dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- package/web-dist/assets/{addon-fit-BXTX0Xqu.js → addon-fit-BjXzaLkS.js} +1 -1
- package/web-dist/assets/{addon-web-links-KebkunbU.js → addon-web-links-D4HvCIde.js} +1 -1
- package/web-dist/assets/{highlighter-B3HctNDB.js → highlighter-Di8euoN5.js} +1 -1
- package/web-dist/assets/index-C6i84Esu.js +451 -0
- package/web-dist/assets/index-DW00p4Ny.css +1 -0
- package/web-dist/assets/{xterm-fEUfDV0F.js → xterm-XhYBaOEK.js} +1 -1
- package/web-dist/index.html +2 -2
- package/dist/chunk-UUJCF46V.js +0 -50
- package/dist/chunk-UUJCF46V.js.map +0 -1
- package/dist/chunk-WKJ3ANJP.js.map +0 -1
- package/web-dist/assets/index-BbLm3DrY.css +0 -1
- package/web-dist/assets/index-DwgTjTHO.js +0 -189
|
@@ -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, --
|
|
19
|
-
via TEXT -- '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);
|
|
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' | 'wechat')
|
|
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; wechat: the scanned bot 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; 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)
|
|
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
|
-
|
|
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, 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
|
|
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
|
|
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
|
|
297
|
-
import {
|
|
298
|
-
|
|
299
|
-
|
|
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
|
|
432
|
-
constructor(
|
|
433
|
-
this.
|
|
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
|
-
|
|
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
|
-
*
|
|
451
|
-
*
|
|
452
|
-
*
|
|
453
|
-
*
|
|
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
|
-
|
|
458
|
-
|
|
459
|
-
const
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
528
|
-
|
|
529
|
-
|
|
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
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
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
|
-
|
|
554
|
-
|
|
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
|
-
|
|
566
|
-
this.
|
|
332
|
+
delete(tokenHash) {
|
|
333
|
+
this.db.prepare("DELETE FROM auth_sessions WHERE token_hash = ?").run(tokenHash);
|
|
567
334
|
}
|
|
568
|
-
/**
|
|
569
|
-
|
|
570
|
-
|
|
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
|
-
/**
|
|
580
|
-
|
|
581
|
-
|
|
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
|
-
*
|
|
585
|
-
*
|
|
586
|
-
*
|
|
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
|
-
|
|
590
|
-
|
|
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
|
-
|
|
635
|
-
|
|
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-
|
|
360
|
+
//# sourceMappingURL=chunk-WL5FUO3A.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/db/database.ts","../src/db/schema.ts","../src/db/repos/users.ts","../src/db/repos/auth-sessions.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 { 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/** 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 // 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 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 */\nfunction ensureColumn(db: DatabaseSync, table: string, column: string, ddl: string): 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 * 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 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 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'; 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);\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 model (paired with model_id as a model reference)\n model_id TEXT NOT NULL, -- upstream model id (sent to AgentHub 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, -- archive time; NULL=not archived (shown by default; archived ones move under \"Archived\")\n client TEXT, -- creating client: 'web' (created via the Web App) | 'cli' (adopted from a CLI Trace); 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 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 schedule/subagent SOURCE 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)\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\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 goal_state ( -- goal-mode runtime state (GOAL.yaml on disk is the model-facing protocol; this row is what the UI reads)\n id INTEGER PRIMARY KEY AUTOINCREMENT, -- one row per goal run; a Session may run goals repeatedly, latest row wins for display\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, -- active | complete | blocked | budget_limited | aborted (aborted is server-side only; on-disk status stays active)\n budget INTEGER NOT NULL, -- token budget; -1 = unlimited\n used INTEGER NOT NULL DEFAULT 0, -- uncached input + output, refreshed per round (mirrors the runner's accounting)\n rounds INTEGER NOT NULL DEFAULT 0,\n created_at TEXT NOT NULL,\n updated_at TEXT NOT NULL\n);\nCREATE INDEX IF NOT EXISTS idx_goal_session ON goal_state(session_id);\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 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 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 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 origin: 'subagent' | 'schedule' | NULL = user-created (or head not yet readable)\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 (CLI adoption reads it from here; 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);\n`;\n","/**\n * users table repo: pure SQL wrapper, no business rules.\n * user_id is the login name (a semantic id, specified at creation, immutable).\n */\nimport type { DatabaseSync } from \"node:sqlite\";\n\nexport interface UserRow {\n userId: string;\n passwordHash: string;\n isAdmin: boolean;\n /** Still using the initial password (seeded / set by an admin); cleared to 0 once the user changes it. */\n passwordIsInitial: boolean;\n createdAt: string;\n}\n\nfunction mapRow(r: Record<string, unknown>): UserRow {\n return {\n userId: r.user_id as string,\n passwordHash: r.password_hash as string,\n isAdmin: (r.is_admin as number) === 1,\n passwordIsInitial: (r.password_is_initial as number) === 1,\n createdAt: r.created_at as string,\n };\n}\n\nexport class UsersRepo {\n constructor(private readonly db: DatabaseSync) {}\n\n insert(row: UserRow): void {\n this.db\n .prepare(\n \"INSERT INTO users (user_id, password_hash, is_admin, password_is_initial, created_at) VALUES (?, ?, ?, ?, ?)\",\n )\n .run(\n row.userId,\n row.passwordHash,\n row.isAdmin ? 1 : 0,\n row.passwordIsInitial ? 1 : 0,\n row.createdAt,\n );\n }\n\n findById(userId: string): UserRow | null {\n const r = this.db.prepare(\"SELECT * FROM users WHERE user_id = ?\").get(userId);\n return r ? mapRow(r) : null;\n }\n\n /** All users (for the admin user backend), ordered by creation time ascending. */\n list(): UserRow[] {\n const rows = this.db.prepare(\"SELECT * FROM users ORDER BY created_at ASC, user_id ASC\").all();\n return rows.map(mapRow);\n }\n\n count(): number {\n const r = this.db.prepare(\"SELECT COUNT(*) AS n FROM users\").get();\n return (r?.n as number) ?? 0;\n }\n\n /** Update the password hash; isInitial marks whether the password was set by someone else (seed / admin). */\n updatePassword(userId: string, passwordHash: string, isInitial: boolean): void {\n this.db\n .prepare(\"UPDATE users SET password_hash = ?, password_is_initial = ? WHERE user_id = ?\")\n .run(passwordHash, isInitial ? 1 : 0, userId);\n }\n\n /** Used by admin user deletion and account-creation compensation paths (owned Projects must be cleaned up first). */\n delete(userId: string): void {\n this.db.prepare(\"DELETE FROM users WHERE user_id = ?\").run(userId);\n }\n}\n","/**\n * auth_sessions table repo (server-side sessions backing the HttpOnly cookie). Stores only\n * sha256(token); the raw token appears only in the cookie. `issue()` is the ONE minting point,\n * so a CLI-minted row cannot drift from one the server issues itself.\n */\nimport { createHash, randomBytes } from \"node:crypto\";\nimport type { DatabaseSync } from \"node:sqlite\";\n\n/** How a session was established, as stored. NULL on rows formed before the column existed. */\nexport type SessionViaValue = \"password\" | \"desktop\" | \"setup\" | \"cli\";\n\nexport interface AuthSessionRow {\n tokenHash: string;\n userId: string;\n createdAt: string;\n expiresAt: string;\n via: SessionViaValue | null;\n}\n\n/** sha256 hex of a raw session token — the only form the database ever holds. */\nexport function sessionTokenHash(token: string): string {\n return createHash(\"sha256\").update(token).digest(\"hex\");\n}\n\nexport class AuthSessionsRepo {\n constructor(private readonly db: DatabaseSync) {}\n\n /**\n * Mints a session and returns the raw token for a cookie or the terminal. `maxTtlMs` caps a\n * caller-supplied lifetime (`--ttl-seconds`): one longer than an ordinary session would make\n * a leaked token file outlive every other credential, sliding forever. Expired rows are\n * swept here too, so a cron that only mints does not accumulate them.\n */\n issue(opts: {\n userId: string;\n via: SessionViaValue;\n now: Date;\n ttlMs: number;\n maxTtlMs?: number;\n }): { token: string; expiresAt: string } {\n const ttlMs = Math.max(1_000, Math.min(opts.ttlMs, opts.maxTtlMs ?? opts.ttlMs));\n const token = randomBytes(32).toString(\"base64url\");\n const createdAt = opts.now.toISOString();\n const expiresAt = new Date(opts.now.getTime() + ttlMs).toISOString();\n this.deleteExpired(createdAt);\n this.insert({\n tokenHash: sessionTokenHash(token),\n userId: opts.userId,\n createdAt,\n expiresAt,\n via: opts.via,\n });\n return { token, expiresAt };\n }\n\n insert(row: AuthSessionRow): void {\n this.db\n .prepare(\n \"INSERT INTO auth_sessions (token_hash, user_id, created_at, expires_at, via) VALUES (?, ?, ?, ?, ?)\",\n )\n .run(row.tokenHash, row.userId, row.createdAt, row.expiresAt, row.via);\n }\n\n findByTokenHash(tokenHash: string): AuthSessionRow | null {\n const r = this.db.prepare(\"SELECT * FROM auth_sessions WHERE token_hash = ?\").get(tokenHash);\n if (!r) return null;\n return {\n tokenHash: r.token_hash as string,\n userId: r.user_id as string,\n createdAt: r.created_at as string,\n expiresAt: r.expires_at as string,\n via: (r.via as SessionViaValue | null) ?? null,\n };\n }\n\n /** Sliding renewal: update the expiration time. */\n touch(tokenHash: string, expiresAt: string): void {\n this.db\n .prepare(\"UPDATE auth_sessions SET expires_at = ? WHERE token_hash = ?\")\n .run(expiresAt, tokenHash);\n }\n\n delete(tokenHash: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE token_hash = ?\").run(tokenHash);\n }\n\n /** Opportunistically clean up expired sessions (called during login/validation). */\n deleteExpired(nowIso: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE expires_at < ?\").run(nowIso);\n }\n\n /** Clear all sessions for a user (forces re-login after an admin resets the password). */\n deleteByUser(userId: string): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE user_id = ?\").run(userId);\n }\n\n /**\n * Clear a user's sessions of one kind. Used to end EVERY first-login session when a password\n * is set: an earlier boot's link may still be live in someone's scrollback, and a `setup`\n * session may change the password without knowing the old one.\n */\n deleteByUserAndVia(userId: string, via: SessionViaValue): void {\n this.db.prepare(\"DELETE FROM auth_sessions WHERE user_id = ? AND via = ?\").run(userId, via);\n }\n}\n"],"mappings":"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","names":[]}
|