@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.
- package/README.md +4 -4
- package/dist/api/types.d.ts +389 -18
- 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-WKJ3ANJP.js → chunk-622KL7BQ.js} +92 -381
- package/dist/chunk-622KL7BQ.js.map +1 -0
- package/dist/chunk-K4ZSUT72.js +34 -0
- package/dist/chunk-K4ZSUT72.js.map +1 -0
- package/dist/chunk-UGAFPYVX.js +374 -0
- package/dist/chunk-UGAFPYVX.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 +5641 -1299
- 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-DHOzI3Ur.js} +1 -1
- package/web-dist/assets/{addon-web-links-KebkunbU.js → addon-web-links-DC7759H1.js} +1 -1
- package/web-dist/assets/{highlighter-B3HctNDB.js → highlighter-CGPO6Q4b.js} +1 -1
- package/web-dist/assets/index-CaFDHl-w.js +451 -0
- package/web-dist/assets/index-D8Bk9LtJ.css +1 -0
- package/web-dist/assets/{xterm-fEUfDV0F.js → xterm-DY5ko-BU.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
|
@@ -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, --
|
|
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')
|
|
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
|
-
|
|
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
|
|
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-622KL7BQ.js.map
|