@timqi/pier 0.0.16 → 0.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dist/agent/events.js +16 -4
  2. package/dist/agent/listing.js +12 -13
  3. package/dist/agent/pi.js +28 -2
  4. package/dist/boards/boards.js +7 -6
  5. package/dist/channels/attach.js +18 -4
  6. package/dist/channels/config.js +7 -2
  7. package/dist/channels/dedup.js +9 -1
  8. package/dist/channels/lark.js +2 -6
  9. package/dist/channels/receipts.js +21 -6
  10. package/dist/channels/slack-tool.js +86 -25
  11. package/dist/channels/slack.js +2 -10
  12. package/dist/channels/telegram.js +1 -0
  13. package/dist/core/hub.js +14 -4
  14. package/dist/core/inbox.js +1 -1
  15. package/dist/core/reply.js +64 -0
  16. package/dist/db.js +105 -0
  17. package/dist/main.js +1 -0
  18. package/dist/secrets.js +18 -6
  19. package/dist/settings.js +2 -11
  20. package/dist/tasks/agent.js +14 -1
  21. package/dist/tasks/definitions.js +3 -1
  22. package/dist/tasks/messages.js +1 -2
  23. package/dist/tasks/service.js +5 -0
  24. package/dist/tasks/store.js +44 -12
  25. package/dist/tools.js +42 -18
  26. package/dist/web/auth.js +212 -68
  27. package/dist/web/config.js +61 -0
  28. package/dist/web/explorer.js +20 -70
  29. package/dist/web/fs.js +159 -0
  30. package/dist/web/instance.js +11 -0
  31. package/dist/web/public/assets/activity-BsoWK6vt.js +5 -0
  32. package/dist/web/public/assets/activity-BsoWK6vt.js.br +0 -0
  33. package/dist/web/public/assets/activity-BsoWK6vt.js.gz +0 -0
  34. package/dist/web/public/assets/boards-DEMQ51m1.js +1 -0
  35. package/dist/web/public/assets/boards-DEMQ51m1.js.br +0 -0
  36. package/dist/web/public/assets/boards-DEMQ51m1.js.gz +0 -0
  37. package/dist/web/public/assets/code-DjWvqL94.js +1 -0
  38. package/dist/web/public/assets/explorer-BTGQ6Syc.js +5 -0
  39. package/dist/web/public/assets/explorer-BTGQ6Syc.js.br +0 -0
  40. package/dist/web/public/assets/explorer-BTGQ6Syc.js.gz +0 -0
  41. package/dist/web/public/assets/{ghostty-web-C4ivXTBE.js → ghostty-web-D8CLFi_l.js} +1 -1
  42. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.br +0 -0
  43. package/dist/web/public/assets/ghostty-web-D8CLFi_l.js.gz +0 -0
  44. package/dist/web/public/assets/hljs-tWqyD59G.js +3 -0
  45. package/dist/web/public/assets/hljs-tWqyD59G.js.br +0 -0
  46. package/dist/web/public/assets/hljs-tWqyD59G.js.gz +0 -0
  47. package/dist/web/public/assets/index-BFzmvpGX.js +78 -0
  48. package/dist/web/public/assets/index-BFzmvpGX.js.br +0 -0
  49. package/dist/web/public/assets/index-BFzmvpGX.js.gz +0 -0
  50. package/dist/web/public/assets/{index-2E9_cwpg.css → index-yNocr797.css} +1 -1
  51. package/dist/web/public/assets/index-yNocr797.css.br +0 -0
  52. package/dist/web/public/assets/index-yNocr797.css.gz +0 -0
  53. package/dist/web/public/assets/settings-DSTKXN2o.js +6 -0
  54. package/dist/web/public/assets/settings-DSTKXN2o.js.br +0 -0
  55. package/dist/web/public/assets/settings-DSTKXN2o.js.gz +0 -0
  56. package/dist/web/public/assets/tasks-Dy1SCAE_.js +4 -0
  57. package/dist/web/public/assets/tasks-Dy1SCAE_.js.br +0 -0
  58. package/dist/web/public/assets/tasks-Dy1SCAE_.js.gz +0 -0
  59. package/dist/web/public/assets/terminal-zLyOvGto.js +2 -0
  60. package/dist/web/public/assets/terminal-zLyOvGto.js.br +0 -0
  61. package/dist/web/public/assets/terminal-zLyOvGto.js.gz +0 -0
  62. package/dist/web/public/icon.svg.br +0 -0
  63. package/dist/web/public/icon.svg.gz +0 -0
  64. package/dist/web/public/index.html +2 -2
  65. package/dist/web/public/index.html.br +0 -0
  66. package/dist/web/public/index.html.gz +0 -0
  67. package/dist/web/public/manifest.webmanifest.br +0 -0
  68. package/dist/web/public/manifest.webmanifest.gz +0 -0
  69. package/dist/web/public/sw.js.br +0 -0
  70. package/dist/web/public/sw.js.gz +0 -0
  71. package/dist/web/push.js +30 -7
  72. package/dist/web/route.js +17 -0
  73. package/dist/web/server.js +104 -23
  74. package/dist/web/terminal.js +21 -16
  75. package/docs/deploy.md +12 -3
  76. package/package.json +1 -1
  77. package/skills/pier-slack/SKILL.md +4 -16
  78. package/dist/web/files.js +0 -183
  79. package/dist/web/public/assets/index-DVUvzNK1.js +0 -93
package/dist/web/auth.js CHANGED
@@ -10,19 +10,29 @@
10
10
  // password exists before the listener does — and no env var for an operator to
11
11
  // get wrong. Forgot it? Delete the row and restart; a new one is printed.
12
12
  //
13
- // The cookie is a signed expiry, not a stored session id: an HMAC keyed by the
14
- // stored hash. No session table, no pruning and changing the password
15
- // changes the key, so every cookie already out there dies with it. That is the
16
- // whole revocation story a single-user system needs. A cookie (not a bearer
17
- // header) because the workbench lives on SSE, and EventSource sends no headers.
18
- import { createHash, createHmac, randomBytes, randomInt, scryptSync, timingSafeEqual, } from "node:crypto";
13
+ // The cookie is "<id>.<token>", and the database keeps the token's SHA-256
14
+ // one row per signed-in browser. Two things follow, and both are why this is
15
+ // not the signed expiry it used to be: a copy of pier.db cannot be turned into
16
+ // a session (there is no signing key in it to forge with), and a single
17
+ // browser can be signed out without changing the password everyone shares. A
18
+ // cookie (not a bearer header) because the workbench lives on SSE, and
19
+ // EventSource sends no headers.
20
+ import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
19
21
  import { getConnInfo } from "@hono/node-server/conninfo";
20
22
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
21
- import { pierDb } from "../db.js";
23
+ import { pierDb, statements, transact } from "../db.js";
22
24
  import { logger } from "../log.js";
23
25
  const log = logger("auth");
24
26
  const COOKIE = "pier_session";
25
- const TTL_MS = 90 * 24 * 60 * 60_000;
27
+ /** How long an idle browser stays signed in. Sliding: a session in daily use
28
+ * never expires, and a stolen cookie is dead a week after its last use. */
29
+ const TTL_MS = 7 * 24 * 60 * 60_000;
30
+ /** How stale `seen_at` may get before a request writes. Renewal rides on it,
31
+ * so this is also how coarse "last seen" is — one write per browser per five
32
+ * minutes instead of one per request. */
33
+ const TOUCH_MS = 5 * 60_000;
34
+ /** `revoke(ALL)` — not an id any row can have, so it cannot collide with one. */
35
+ export const ALL = "*";
26
36
  /** Failed attempts one client may make before it has to wait out the window. */
27
37
  const MAX_FAILURES = 10;
28
38
  const WINDOW_MS = 15 * 60_000;
@@ -57,63 +67,159 @@ function generatePassword() {
57
67
  */
58
68
  export class AuthStore {
59
69
  #db;
60
- /** HMAC key for cookies: the hash, so rotating the password expires them. */
61
- #key;
62
- #rotationListeners = new Set();
70
+ /** Compiled once each: `check()` runs two of these on every request that
71
+ * carries a cookie, which is every request the workbench makes. */
72
+ #sql;
73
+ #revokeListeners = new Set();
63
74
  constructor(db = pierDb(), print = (m) => log.info(m)) {
64
75
  this.#db = db;
76
+ this.#sql = statements(db);
65
77
  let row = this.#row();
66
78
  if (!row) {
67
79
  const password = generatePassword();
68
80
  const salt = randomBytes(16).toString("hex");
69
81
  row = { salt, hash: hash(password, salt), createdAt: Date.now() };
70
- this.#db
71
- .prepare("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
72
- .run(row.salt, row.hash, row.createdAt);
82
+ // Recovery is "DELETE FROM auth and restart", so this branch is also how
83
+ // a forgotten password is replaced and the browsers signed in under the
84
+ // old one must not walk through it. Their rows go with the credential,
85
+ // in one transaction: half of this leaves a new password and live old
86
+ // cookies, which is the state recovery exists to end.
87
+ transact(this.#db, () => {
88
+ this.#sql("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
89
+ .run(salt, hash(password, salt), Date.now());
90
+ this.#dropSessions();
91
+ });
73
92
  print(`\nthis instance had no password, so one was generated:\n\n ${password}\n\n` +
74
93
  `only its hash is stored — it is not printed again. ` +
75
94
  `Lost it? "DELETE FROM auth" in the database, then restart.\n`);
76
95
  }
77
- this.#key = row.hash;
96
+ // Boot is the one moment that comes around on its own. Without it, a
97
+ // session that expired while Pier was down would sit there notifying a
98
+ // phone until somebody happened to sign in.
99
+ this.sweep();
78
100
  }
79
101
  #row() {
80
- return this.#db
81
- .prepare("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
102
+ return this.#sql("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
82
103
  .get();
83
104
  }
105
+ /** Every signed-in browser at once. Private: the callers that mean it also
106
+ * have to tell the listeners, and `revoke(ALL)` is that pair in public. */
107
+ #dropSessions() {
108
+ this.#sql("DELETE FROM web_sessions").run();
109
+ }
110
+ /** Sessions nobody may use any more, deleted rather than merely refused: a
111
+ * row is what a push subscription hangs off, so "expired" has to become
112
+ * "gone" without waiting for the browser to come back and be told. Run at
113
+ * boot and whenever somebody signs in — the two moments the process has a
114
+ * reason to look at this table at all. */
115
+ sweep() {
116
+ const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
117
+ .all(Date.now() - TTL_MS);
118
+ for (const row of swept)
119
+ this.#revoked(row.id);
120
+ if (swept.length)
121
+ log.info(`swept ${String(swept.length)} expired session(s)`);
122
+ }
84
123
  /** Whether this is the password, compared in constant time. */
85
124
  verify(password) {
86
125
  const row = this.#row();
87
126
  return row ? sameSecret(hash(password, row.salt), row.hash) : false;
88
127
  }
89
128
  /**
90
- * Replace the password, salt and all.
91
- *
92
- * The new hash becomes the cookie key, so every cookie signed with the old
93
- * one every other browser, and the caller's own — stops verifying. That is
94
- * the point: a password is changed because the old one may be known.
129
+ * Replace the password, salt and all — and with it every session, the
130
+ * caller's own included. That is the point: a password is changed because the
131
+ * old one may be known, so nothing that was signed in under it stays signed
132
+ * in. Listeners hear it after the commit, never before.
95
133
  */
96
134
  setPassword(password) {
97
135
  const salt = randomBytes(16).toString("hex");
98
- const next = hash(password, salt);
99
- this.#db
100
- .prepare("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
101
- .run(salt, next, Date.now());
102
- this.#key = next;
103
- for (const listener of this.#rotationListeners)
104
- listener();
136
+ // Credential and sessions change together or not at all — a crash between
137
+ // the two writes is exactly the state "everyone signs in again" denies.
138
+ transact(this.#db, () => {
139
+ this.#sql("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
140
+ .run(salt, hash(password, salt), Date.now());
141
+ this.#dropSessions();
142
+ });
143
+ this.#revoked(ALL);
144
+ }
145
+ /** Sign a browser in: one row, and the cookie value that opens it. */
146
+ open(ip, agent) {
147
+ const now = Date.now();
148
+ this.sweep();
149
+ // The id names the row and the token proves it: 72 bits is plenty for a
150
+ // name, and the 256-bit token is the only part that has to resist guessing.
151
+ const id = randomBytes(9).toString("base64url");
152
+ const token = randomBytes(32).toString("base64url");
153
+ this.#sql("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
154
+ " VALUES (?, ?, ?, ?, ?, ?)").run(id, digest(token), now, now, ip, agent.slice(0, 200));
155
+ return `${id}.${token}`;
156
+ }
157
+ /**
158
+ * The row this cookie names, if the token matches and the row is live.
159
+ * `renewed` says the deadline just moved, which is the caller's cue to send
160
+ * the browser a cookie with the new Max-Age — the sliding window has to slide
161
+ * on both sides or the browser drops a cookie the database still honours.
162
+ */
163
+ check(cookie) {
164
+ const [id, token] = (cookie ?? "").split(".");
165
+ if (!id || !token)
166
+ return undefined;
167
+ const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?").get(id);
168
+ const now = Date.now();
169
+ // One clock: last use is the deadline, so there is no second column that
170
+ // can disagree with it about when this session ends. An expired row is
171
+ // deleted here rather than left for the next login to sweep — a session
172
+ // nobody may use must stop being a device Pier notifies at the same moment.
173
+ if (!row)
174
+ return undefined;
175
+ if (now - row.seenAt >= TTL_MS) {
176
+ this.revoke(id);
177
+ return undefined;
178
+ }
179
+ if (!sameSecret(digest(token), row.tokenHash))
180
+ return undefined;
181
+ if (now - row.seenAt < TOUCH_MS)
182
+ return { id, renewed: false };
183
+ this.#sql("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
184
+ return { id, renewed: true };
185
+ }
186
+ /** Sign out one browser, or every one of them (`ALL`). Listeners hear the
187
+ * same id: a revoked cookie must also close what it opened. */
188
+ revoke(id) {
189
+ if (id === ALL)
190
+ this.#dropSessions();
191
+ else
192
+ this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
193
+ this.#revoked(id);
194
+ }
195
+ /** Signed-in browsers, most recently seen first. Never the token — the list
196
+ * is shown to whoever is signed in, and it is not a set of credentials. */
197
+ list() {
198
+ return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
199
+ " FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
105
200
  }
106
- /** A long-lived authenticated surface closes itself when every cookie is
201
+ /** A long-lived authenticated surface closes itself when a cookie is
107
202
  * revoked. The store and listeners share the process lifetime. */
108
- onRotation(listener) {
109
- this.#rotationListeners.add(listener);
203
+ onRevoke(listener) {
204
+ this.#revokeListeners.add(listener);
110
205
  }
111
- /** Cookie signing key. Never the password: that is not stored anywhere. */
112
- get cookieKey() {
113
- return this.#key;
206
+ /** One listener throwing must not cost the next one its notification: the
207
+ * row is already gone, so a surface that never hears about it stays open on
208
+ * a session that no longer exists. */
209
+ #revoked(id) {
210
+ for (const listener of this.#revokeListeners) {
211
+ try {
212
+ listener(id);
213
+ }
214
+ catch (err) {
215
+ log.error(`a revocation listener failed for session ${id}`, err);
216
+ }
217
+ }
114
218
  }
115
219
  }
116
220
  const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
221
+ /** What the database keeps instead of the cookie's token. */
222
+ const digest = (token) => createHash("sha256").update(token).digest("hex");
117
223
  /**
118
224
  * What a logged-out visitor must still reach: the login form, published
119
225
  * boards, and the stylesheet those boards link — a published board rendering
@@ -130,18 +236,10 @@ function isPublic(method, path) {
130
236
  return false;
131
237
  return path.startsWith("/p/");
132
238
  }
133
- const sign = (secret, expiresAt) => createHmac("sha256", secret).update(String(expiresAt)).digest("base64url");
134
239
  /** Constant-time equality that also hides length: both sides are digested. */
135
240
  function sameSecret(a, b) {
136
- const digest = (s) => createHash("sha256").update(s).digest();
137
- return timingSafeEqual(digest(a), digest(b));
138
- }
139
- function valid(secret, cookie) {
140
- const [exp, sig] = (cookie ?? "").split(".");
141
- const expiresAt = Number(exp);
142
- if (!sig || !Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())
143
- return false;
144
- return sameSecret(sig, sign(secret, expiresAt));
241
+ const bytes = (s) => createHash("sha256").update(s).digest();
242
+ return timingSafeEqual(bytes(a), bytes(b));
145
243
  }
146
244
  /**
147
245
  * Only a same-origin path may be returned to after login. `//evil.example` is a
@@ -213,22 +311,30 @@ function sameOrigin(c) {
213
311
  : undefined;
214
312
  return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
215
313
  }
314
+ /** Which row this request's cookie names. The boundary already verified the
315
+ * token; this reads the id back off the value it accepted. Exported so a
316
+ * surface that belongs to one browser (its push subscription) names it the
317
+ * same way, rather than parsing the cookie a second way. */
318
+ export const sessionIdOf = (c) => (getCookie(c, COOKIE) ?? "").split(".")[0] ?? "";
216
319
  /** The same cookie + Origin boundary for a WebSocket upgrade, where no Hono
217
- * context exists before the handshake completes. */
320
+ * context exists before the handshake completes. The session id comes back
321
+ * with the verdict: a socket outlives the request that opened it, so it has
322
+ * to know which row signing out would close it. */
218
323
  export function upgradeAuthorized(store, req) {
219
324
  const raw = req.headers.cookie
220
325
  ?.split(";")
221
326
  .map((part) => part.trim())
222
327
  .find((part) => part.startsWith(`${COOKIE}=`))
223
328
  ?.slice(COOKIE.length + 1);
224
- if (!valid(store.cookieKey, raw))
225
- return false;
329
+ const session = store.check(raw);
330
+ if (!session)
331
+ return undefined;
226
332
  const remote = req.socket.remoteAddress ?? "";
227
333
  const forwardedHeader = req.headers["x-forwarded-host"];
228
334
  const forwarded = loopback(remote) && typeof forwardedHeader === "string"
229
335
  ? forwardedHeader.split(",").at(-1)?.trim()
230
336
  : undefined;
231
- return originMatches(req.headers.origin, forwarded || req.headers.host);
337
+ return originMatches(req.headers.origin, forwarded || req.headers.host) ? session.id : undefined;
232
338
  }
233
339
  /** Every route, in one place — no per-route opt-in to forget on the next one. */
234
340
  export function requireAuth(store) {
@@ -238,13 +344,18 @@ export function requireAuth(store) {
238
344
  c.header("x-frame-options", "DENY");
239
345
  if (isPublic(c.req.method, c.req.path))
240
346
  return next();
241
- const authenticated = valid(store.cookieKey, getCookie(c, COOKIE));
347
+ const cookie = getCookie(c, COOKIE);
348
+ const session = store.check(cookie);
242
349
  const unsafe = c.req.method !== "GET" && c.req.method !== "HEAD";
243
- if (authenticated && unsafe && !sameOrigin(c)) {
350
+ if (session && unsafe && !sameOrigin(c)) {
244
351
  log.warn(`blocked ${c.req.method} ${c.req.path} from origin ${c.req.header("origin")}`);
245
352
  return c.json({ error: "forbidden origin" }, 403);
246
353
  }
247
- if (authenticated) {
354
+ if (session) {
355
+ // The database just moved the deadline; the browser is told the same, or
356
+ // it would drop a cookie that is still good.
357
+ if (session.renewed && cookie)
358
+ setSessionCookie(c, cookie);
248
359
  await next();
249
360
  // Cookie-authenticated content must not become public in a shared proxy.
250
361
  if (!c.res.headers.has("cache-control")) {
@@ -279,7 +390,15 @@ export function registerAuthRoutes(app, store) {
279
390
  }
280
391
  failures.delete(client);
281
392
  log.info(`login from ${client}`);
282
- issueCookie(c, store);
393
+ // Signing in again replaces this browser's session rather than adding one:
394
+ // the cookie it is about to drop would otherwise stay valid for a week, as
395
+ // a row nobody can recognize in the device list. Verified first — the id in
396
+ // an unverified cookie is a string the caller chose, and `ALL` is one of
397
+ // the strings they could choose.
398
+ const previous = store.check(getCookie(c, COOKIE));
399
+ if (previous)
400
+ store.revoke(previous.id);
401
+ setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
283
402
  return c.redirect(next);
284
403
  });
285
404
  // Re-authenticate before rotating the credential. The global boundary also
@@ -300,33 +419,58 @@ export function registerAuthRoutes(app, store) {
300
419
  }
301
420
  failures.delete(client);
302
421
  store.setPassword(next);
303
- // The rotation just killed every cookie out there, this caller's included —
304
- // a password is changed because the old one may be known, and "everyone
305
- // signs in again" is the whole point. Clear the dead cookie; the client
306
- // sends the person to the login form with the password they just chose.
422
+ // The rotation drops every session row, this caller's included — a password
423
+ // is changed because the old one may be known, and "everyone signs in
424
+ // again" is the whole point. Clear the dead cookie; the client sends the
425
+ // person to the login form with the password they just chose.
307
426
  deleteCookie(c, COOKIE, { path: "/" });
308
427
  return c.json({ ok: true });
309
428
  });
310
- // Signs out this browser by clearing its cookie. The value itself stays
311
- // verifiable until it expires it is a signature, not a stored id — so the
312
- // full revocation story remains the password change above. Behind the
313
- // boundary like every write: only a signed-in browser has anything to end.
429
+ // Signed-in browsers, so "sign out that one" is something an operator can
430
+ // see before doing. Not /api/sessions: that is the agent's sessions, and one
431
+ // vocabulary for two unrelated things is how the wrong one gets ended.
432
+ app.get("/api/devices", (c) => {
433
+ const current = sessionIdOf(c);
434
+ return c.json(store.list().map((d) => ({ ...d, current: d.id === current })));
435
+ });
436
+ // Real revocation: the row goes, and the cookie holding its token opens
437
+ // nothing on the next request. Ending this browser's own session is the same
438
+ // call, so the client clears the cookie it is about to stop being able to use.
439
+ app.post("/api/devices/:id/signout", (c) => {
440
+ const id = c.req.param("id");
441
+ // One row per call. Signing everyone out is the password change above,
442
+ // which is the only thing that also invalidates the password they know.
443
+ if (id === ALL)
444
+ return c.json({ error: "not a session id" }, 400);
445
+ store.revoke(id);
446
+ log.info(`signed out session ${id}`);
447
+ if (id === sessionIdOf(c))
448
+ deleteCookie(c, COOKIE, { path: "/" });
449
+ return c.json({ ok: true });
450
+ });
451
+ // Signs out this browser: the row is deleted, not just the cookie cleared,
452
+ // so a copy of that cookie taken beforehand is dead too. Behind the boundary
453
+ // like every write — only a signed-in browser has anything to end.
314
454
  app.post("/logout", (c) => {
455
+ store.revoke(sessionIdOf(c));
315
456
  deleteCookie(c, COOKIE, { path: "/" });
316
457
  return c.json({ ok: true });
317
458
  });
318
459
  }
319
- /** The signed-in cookie, set the same way by login and by a password change. */
320
- function issueCookie(c, store) {
321
- const expiresAt = Date.now() + TTL_MS;
322
- setCookie(c, COOKIE, `${expiresAt}.${sign(store.cookieKey, expiresAt)}`, {
460
+ /** The signed-in cookie set at login, and again whenever the sliding window
461
+ * moved, which is why it takes the value rather than making one. */
462
+ function setSessionCookie(c, value) {
463
+ setCookie(c, COOKIE, value, {
323
464
  path: "/",
324
465
  httpOnly: true,
325
466
  sameSite: "Lax",
326
467
  // Set only over TLS: a Secure cookie on plain http is dropped, which
327
- // would lock out the loopback and SSH-tunnel setups.
328
- secure: c.req.header("x-forwarded-proto") === "https" ||
329
- new URL(c.req.url).protocol === "https:",
468
+ // would lock out the loopback and SSH-tunnel setups. The forwarded scheme
469
+ // counts only from a local proxy — anywhere else it is a header the client
470
+ // wrote, and a stranger must not get to decide this flag.
471
+ secure: new URL(c.req.url).protocol === "https:" ||
472
+ (c.req.header("x-forwarded-proto")?.split(",").at(-1)?.trim() === "https" &&
473
+ loopback(remoteOf(c) ?? "")),
330
474
  maxAge: TTL_MS / 1000,
331
475
  });
332
476
  }
@@ -0,0 +1,61 @@
1
+ // The agent files a scope is configured by — Pi's own config, skills and
2
+ // extensions — read and written through the ConfigStore, never as paths. A
3
+ // scope is "global" or a project cwd Pi already knows, which is why this is
4
+ // the one filesystem-shaped surface that does not go through web/fs.ts: it
5
+ // never takes a path from the browser at all.
6
+ import { guarded } from "./route.js";
7
+ export function registerConfigRoutes(app, { factory, config, onConfigWritten }) {
8
+ // Scope comes from the client as "global" or a project cwd; only cwds Pi
9
+ // already knows (the session list) are accepted — never an arbitrary path.
10
+ const parseScope = async (raw) => {
11
+ if (!raw || raw === "global")
12
+ return { kind: "global" };
13
+ const known = await factory.list();
14
+ return known.some((s) => s.cwd === raw) ? { kind: "project", cwd: raw } : null;
15
+ };
16
+ app.get("/api/config", async (c) => {
17
+ c.header("cache-control", "no-store");
18
+ const scope = await parseScope(c.req.query("scope"));
19
+ if (!scope)
20
+ return c.json({ error: "unknown scope" }, 400);
21
+ return c.json({
22
+ // Where this scope's files live on disk — the UI labels "Global" with it.
23
+ dir: scope.kind === "global" ? config.globalDir : scope.cwd,
24
+ files: await config.listFiles(scope),
25
+ resources: await config.listResources(scope),
26
+ });
27
+ });
28
+ guarded(app, "GET", "/api/config/files/:name", 400, async (c) => {
29
+ c.header("cache-control", "no-store");
30
+ const scope = await parseScope(c.req.query("scope"));
31
+ if (!scope)
32
+ return c.json({ error: "unknown scope" }, 400);
33
+ return c.json({ content: await config.readFile(scope, c.req.param("name")) });
34
+ });
35
+ guarded(app, "PUT", "/api/config/files/:name", 400, async (c) => {
36
+ const scope = await parseScope(c.req.query("scope"));
37
+ if (!scope)
38
+ return c.json({ error: "unknown scope" }, 400);
39
+ const body = await c.req.json().catch(() => null);
40
+ if (typeof body?.content !== "string" || typeof body?.expected !== "string") {
41
+ return c.json({ error: "content and expected content required" }, 400);
42
+ }
43
+ const name = c.req.param("name");
44
+ await config.writeFile(scope, name, body.content, body.expected);
45
+ onConfigWritten?.();
46
+ return c.json({ ok: true, content: await config.readFile(scope, name) });
47
+ });
48
+ // Resource names may contain slashes — query params, not path params.
49
+ guarded(app, "GET", "/api/config/resource", 400, async (c) => {
50
+ c.header("cache-control", "no-store");
51
+ const scope = await parseScope(c.req.query("scope"));
52
+ if (!scope)
53
+ return c.json({ error: "unknown scope" }, 400);
54
+ const kind = c.req.query("kind");
55
+ const name = c.req.query("name");
56
+ if ((kind !== "extensions" && kind !== "skills") || !name) {
57
+ return c.json({ error: "kind and name required" }, 400);
58
+ }
59
+ return c.json({ content: await config.readResource(scope, kind, name) });
60
+ });
61
+ }
@@ -1,28 +1,12 @@
1
- // Files view backend: read-only directory listing, file bytes and git
2
- // ref/diff queries for the Console's Files view. `root` is any directory the
3
- // process can read sessions work in worktrees and siblings of their cwd, and
4
- // an owner who is already past the Console password can reach those paths
5
- // anyway. `path` is still confined to the `root` it was asked under, so a
6
- // listing can never widen itself, and nothing here writes.
1
+ // What git knows about a project directory, for the Console's Files view: the
2
+ // refs, commits and worktrees its pickers offer, and the diffs it tones into a
3
+ // file. Every route here runs git and nothing else reading the directory and
4
+ // the files themselves is web/fs.ts, which also owns the scoping both share.
7
5
  import { execFile } from "node:child_process";
8
- import { readdir, readFile, realpath, stat } from "node:fs/promises";
9
- import { basename, extname, isAbsolute, resolve, sep } from "node:path";
10
6
  import { promisify } from "node:util";
11
- import { guarded } from "./files.js";
7
+ import { scoped } from "./fs.js";
8
+ import { guarded } from "./route.js";
12
9
  const run = promisify(execFile);
13
- // Inline-renderable binary types; text is sniffed, everything else downloads.
14
- const BINARY_TYPES = {
15
- ".png": "image/png",
16
- ".jpg": "image/jpeg",
17
- ".jpeg": "image/jpeg",
18
- ".gif": "image/gif",
19
- ".webp": "image/webp",
20
- ".avif": "image/avif",
21
- ".bmp": "image/bmp",
22
- ".svg": "image/svg+xml",
23
- ".pdf": "application/pdf",
24
- };
25
- const MAX_FILE_BYTES = 32 * 1024 * 1024;
26
10
  const MAX_DIFF_BYTES = 2 * 1024 * 1024;
27
11
  /** A ref never starts with `-`: execFile blocks the shell, this blocks the
28
12
  * argument parser (`--output=…` is a write). Git validates the rest. */
@@ -30,53 +14,11 @@ const REF_RE = /^[^-\s][^\s]*$/;
30
14
  /** git in `root`, output capped — a diff is display data, not an archive. */
31
15
  const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
32
16
  export function registerExplorerRoutes(app) {
33
- /** The scope check every route shares: `root` must be an absolute directory,
34
- * and `path` must resolve inside it (realpath both ends — neither `..` nor a
35
- * symlink steps outside). Returns the real target. */
36
- const resolveScoped = async (root, path = "") => {
37
- if (!root || !isAbsolute(root))
38
- throw new Error("root must be an absolute directory");
39
- const real = await realpath(root);
40
- if (!(await stat(real)).isDirectory())
41
- throw new Error("root must be an absolute directory");
42
- const target = await realpath(resolve(real, path));
43
- if (target !== real && !target.startsWith(real + sep))
44
- throw new Error("path escapes root");
45
- return target;
46
- };
47
- // Directory listing, names only. `.git` is plumbing, not content.
48
- guarded(app, "GET", "/api/explorer/ls", 404, async (c) => {
49
- c.header("cache-control", "no-store");
50
- const dir = await resolveScoped(c.req.query("root"), c.req.query("path"));
51
- const entries = (await readdir(dir, { withFileTypes: true }))
52
- .filter((e) => e.name !== ".git" && (e.isDirectory() || e.isFile()))
53
- .map((e) => ({ name: e.name, dir: e.isDirectory() }))
54
- .sort((a, b) => Number(b.dir) - Number(a.dir) || a.name.localeCompare(b.name));
55
- return c.json({ entries });
56
- });
57
- // File bytes, read-only. Known binary types render inline; anything else is
58
- // sniffed — a NUL in the head means bytes we can't vouch for, so it
59
- // downloads instead of rendering (that is how a file starts executing).
60
- guarded(app, "GET", "/api/explorer/file", 404, async (c) => {
61
- const file = await resolveScoped(c.req.query("root"), c.req.query("path"));
62
- if (!(await stat(file)).isFile())
63
- throw new Error("not a file");
64
- const bytes = await readFile(file);
65
- if (bytes.byteLength > MAX_FILE_BYTES)
66
- return c.json({ error: "file too large" }, 413);
67
- const binary = BINARY_TYPES[extname(file).toLowerCase()];
68
- const text = !binary && !bytes.subarray(0, 8192).includes(0);
69
- return c.body(bytes, 200, {
70
- "content-type": binary ?? (text ? "text/plain; charset=utf-8" : "application/octet-stream"),
71
- "content-disposition": `${binary || text ? "inline" : "attachment"}; filename*=UTF-8''${encodeURIComponent(basename(file))}`,
72
- "cache-control": "no-store",
73
- });
74
- });
75
17
  // Git refs for the diff pickers: current branch, branches+tags, recent
76
18
  // commits. Not a repo → { branch: null }, which the UI renders as "no git".
77
19
  guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
78
20
  c.header("cache-control", "no-store");
79
- const root = await resolveScoped(c.req.query("root"));
21
+ const root = await scoped(c.req.query("root"));
80
22
  let branch;
81
23
  try {
82
24
  branch = (await git(root, "rev-parse", "--abbrev-ref", "HEAD")).trim();
@@ -85,11 +27,20 @@ export function registerExplorerRoutes(app) {
85
27
  // not a repo, or no commits yet
86
28
  return c.json({ branch: null, refs: [], commits: [], worktrees: [] });
87
29
  }
30
+ // Three reads of the same repository, none of which is an argument to
31
+ // another: one wait, not three. The rev-parse above stays alone — it is
32
+ // the guard that decides whether these three are asked at all.
33
+ const [worktreeList, refList, commitLog] = await Promise.all([
34
+ git(root, "worktree", "list", "--porcelain"),
35
+ git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"),
36
+ // Unit/record separators, because a body is multi-line by nature.
37
+ git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%ae\u001f%s\u001f%b\u001e"),
38
+ ]);
88
39
  // Every checkout of this repository, from git rather than from the
89
40
  // sessions that happen to live in one: a worktree created ten seconds ago
90
41
  // has no session in it yet, and that is exactly when its files are worth
91
42
  // opening. Detached heads have no `branch` line, so the path stands alone.
92
- const worktrees = (await git(root, "worktree", "list", "--porcelain"))
43
+ const worktrees = worktreeList
93
44
  .split("\n\n")
94
45
  .map((block) => {
95
46
  const path = /^worktree (.+)$/m.exec(block)?.[1];
@@ -97,15 +48,14 @@ export function registerExplorerRoutes(app) {
97
48
  return path ? { path, ...(on ? { branch: on } : {}) } : null;
98
49
  })
99
50
  .filter((w) => w !== null);
100
- const refs = (await git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"))
51
+ const refs = refList
101
52
  .split("\n")
102
53
  .filter(Boolean)
103
54
  .map((line) => {
104
55
  const tab = line.indexOf("\t");
105
56
  return { name: line.slice(0, tab), subject: line.slice(tab + 1) };
106
57
  });
107
- // Unit/record separators, because a body is multi-line by nature.
108
- const commits = (await git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%ae\u001f%s\u001f%b\u001e"))
58
+ const commits = commitLog
109
59
  .split("\u001e")
110
60
  .map((r) => r.trimStart())
111
61
  .filter(Boolean)
@@ -120,7 +70,7 @@ export function registerExplorerRoutes(app) {
120
70
  // means the working tree.
121
71
  guarded(app, "GET", "/api/explorer/diff", 404, async (c) => {
122
72
  c.header("cache-control", "no-store");
123
- const root = await resolveScoped(c.req.query("root"));
73
+ const root = await scoped(c.req.query("root"));
124
74
  const base = c.req.query("base") ?? "";
125
75
  const head = c.req.query("head") ?? "";
126
76
  if (!REF_RE.test(base) || (head !== "" && !REF_RE.test(head)))