@timqi/pier 0.0.16 → 0.0.17

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.
@@ -33,8 +33,8 @@
33
33
  which style.css pays for with the safe-area inset. -->
34
34
  <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
35
35
  <title>Pier</title>
36
- <script type="module" crossorigin src="/assets/index-DVUvzNK1.js"></script>
37
- <link rel="stylesheet" crossorigin href="/assets/index-2E9_cwpg.css">
36
+ <script type="module" crossorigin src="/assets/index-DAxKtidH.js"></script>
37
+ <link rel="stylesheet" crossorigin href="/assets/index-CtR-Ob4T.css">
38
38
  </head>
39
39
  <!-- The document never scrolls: this is a fixed-viewport workbench, and every
40
40
  scrollable region is an inner pane. h-dvh, not h-screen, because 100vh can
package/dist/web/push.js CHANGED
@@ -10,6 +10,7 @@
10
10
  import { readableTitle } from "../core/identity.js";
11
11
  import { pierDb } from "../db.js";
12
12
  import { logger } from "../log.js";
13
+ import { sessionIdOf } from "./auth.js";
13
14
  import { generateVapidKeys, sendPush, } from "./webpush.js";
14
15
  const log = logger("push");
15
16
  /** Devices kept at once. A browser mints a new subscription whenever the old
@@ -29,6 +30,10 @@ const MAX_BODY_CHARS = 160;
29
30
  * browser bug rather than as an unnamed session — and a listing that could
30
31
  * not answer must not silence the push. */
31
32
  const label = (s) => readableTitle(s?.title) || s?.cwd.split("/").filter(Boolean).at(-1) || "Pier session";
33
+ /** SQLite's "that parent row does not exist" — the session ended. Anything
34
+ * else that goes wrong here is a broken database, not a signed-out browser. */
35
+ const FOREIGN_KEY_VIOLATION = 787;
36
+ const sessionGone = (err) => err.errcode === FOREIGN_KEY_VIOLATION;
32
37
  /** Subscriptions and the instance's VAPID identity. Both are per-instance
33
38
  * facts nobody edits by hand, so they live beside every other one. */
34
39
  export class PushStore {
@@ -59,14 +64,16 @@ export class PushStore {
59
64
  .all();
60
65
  }
61
66
  /** Upsert: a browser re-posts the same subscription on every load, which is
62
- * what repairs a row this instance lost. */
63
- save(target, label) {
67
+ * what repairs a row this instance lost — and what re-attaches one to the
68
+ * session that is signed in now. */
69
+ save(target, label, sessionId) {
64
70
  this.#db
65
- .prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at)
66
- VALUES (?, ?, ?, ?, ?)
71
+ .prepare(`INSERT INTO push_subscriptions(endpoint, p256dh, auth, label, created_at, session_id)
72
+ VALUES (?, ?, ?, ?, ?, ?)
67
73
  ON CONFLICT(endpoint) DO UPDATE SET
68
- p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label`)
69
- .run(target.endpoint, target.p256dh, target.auth, label, Date.now());
74
+ p256dh = excluded.p256dh, auth = excluded.auth, label = excluded.label,
75
+ session_id = excluded.session_id`)
76
+ .run(target.endpoint, target.p256dh, target.auth, label, Date.now(), sessionId);
70
77
  this.#db
71
78
  .prepare(`DELETE FROM push_subscriptions WHERE endpoint NOT IN
72
79
  (SELECT endpoint FROM push_subscriptions ORDER BY created_at DESC LIMIT ?)`)
@@ -222,7 +229,23 @@ export function registerPushRoutes(app, deps) {
222
229
  if (!target)
223
230
  return c.json({ error: "not a push subscription" }, 400);
224
231
  const label = String(body.label ?? "a browser").slice(0, 80);
225
- store.save(target, label);
232
+ // The session that is asking owns the subscription, and the foreign key is
233
+ // what makes signing this browser out take the subscription with it. It can
234
+ // refuse: the boundary let this request in and the browser was signed out
235
+ // while its body was still arriving. Say so rather than 500 — the browser
236
+ // is about to be sent to the login form by its next request anyway.
237
+ try {
238
+ store.save(target, label, sessionIdOf(c));
239
+ }
240
+ catch (err) {
241
+ // Only that. A full or read-only database answering 401 would send a
242
+ // signed-in browser to the login form, where the password it types will
243
+ // not help either.
244
+ if (!sessionGone(err))
245
+ throw err;
246
+ log.warn(`subscription refused for a session that ended: ${String(err)}`);
247
+ return c.json({ error: "session ended" }, 401);
248
+ }
226
249
  log.info(`subscribed ${label}`);
227
250
  return c.json({ ok: true }, 201);
228
251
  });
@@ -11,7 +11,7 @@ import { isAbsolute } from "node:path";
11
11
  import { spawn } from "node-pty";
12
12
  import { WebSocketServer } from "ws";
13
13
  import { logger } from "../log.js";
14
- import { upgradeAuthorized } from "./auth.js";
14
+ import { ALL, upgradeAuthorized } from "./auth.js";
15
15
  const log = logger("terminal");
16
16
  /** Live shells at once — a guard against forgotten spawns, not a quota. */
17
17
  const MAX_TERMS = 8;
@@ -276,10 +276,18 @@ export function attachTerminal(server, auth, opts = {}) {
276
276
  });
277
277
  server.once("close", () => hub.close());
278
278
  wss.on("error", (err) => log.error("terminal WebSocket server failed", err));
279
- auth.onRotation(() => {
280
- log.info("password changed; closing terminal clients");
281
- for (const client of wss.clients)
282
- client.close(1008, "password changed");
279
+ // A shell outlives the request that opened it, so revocation has to reach it
280
+ // here: the session that opened this socket was signed out (or every session
281
+ // was, by a password change), and the terminal goes with it.
282
+ const sessionOf = new WeakMap();
283
+ const revoked = new WeakSet();
284
+ auth.onRevoke((id) => {
285
+ for (const client of wss.clients) {
286
+ if (id !== ALL && sessionOf.get(client) !== id)
287
+ continue;
288
+ revoked.add(client);
289
+ client.close(1008, "signed out");
290
+ }
283
291
  });
284
292
  server.on("upgrade", (req, socket, head) => {
285
293
  let url;
@@ -296,14 +304,15 @@ export function attachTerminal(server, auth, opts = {}) {
296
304
  socket.destroy();
297
305
  return;
298
306
  }
299
- if (!upgradeAuthorized(auth, req)) {
307
+ const sessionId = upgradeAuthorized(auth, req);
308
+ if (!sessionId) {
300
309
  log.warn("refused terminal upgrade (unauthorized)");
301
310
  socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
302
311
  socket.destroy();
303
312
  return;
304
313
  }
305
- const authKey = auth.cookieKey;
306
314
  wss.handleUpgrade(req, socket, head, (ws) => {
315
+ sessionOf.set(ws, sessionId);
307
316
  alive.add(ws);
308
317
  ws.on("pong", () => alive.add(ws));
309
318
  // Frames can land while attach() is resolving realpath; hold a bounded
@@ -320,9 +329,10 @@ export function attachTerminal(server, auth, opts = {}) {
320
329
  ws.on("message", (data, binary) => {
321
330
  if (closed)
322
331
  return;
323
- if (auth.cookieKey !== authKey) {
332
+ // A frame can already be in flight when the close above goes out.
333
+ if (revoked.has(ws)) {
324
334
  closed = true;
325
- ws.close(1008, "password changed");
335
+ ws.close(1008, "signed out");
326
336
  return;
327
337
  }
328
338
  if (binary) {
package/docs/deploy.md CHANGED
@@ -229,8 +229,16 @@ sqlite3 ~/.pier/db/pier.db 'DELETE FROM auth'
229
229
  pier restart
230
230
  ```
231
231
 
232
- Changing the password invalidates every session cookie: the cookies are signed
233
- with the stored hash.
232
+ Changing the password signs out every browser: the session rows behind the
233
+ cookies are deleted. So does the recovery above — a new password does not leave
234
+ the old one's browsers signed in. To end one browser without changing the
235
+ password, use Settings → Instance → Signed-in devices. A session that is not
236
+ used expires 7 days after its last request. However a session ends — signed
237
+ out, expired, password changed, password recovered — its push subscription is
238
+ deleted with it, so that browser stops being notified as well.
239
+
240
+ The upgrade that introduced these rows signs everyone out once: a cookie issued
241
+ before it names no row. Have the password to hand.
234
242
 
235
243
  ## Restarting and reloading
236
244
 
@@ -400,7 +408,8 @@ elsewhere, pick a tunnel rather than a wider bind:
400
408
  there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
401
409
  `X-Forwarded-For`; Pier uses the external host for write-origin checks and
402
410
  counts login failures per forwarded client. Its session cookie is marked
403
- `Secure` when the proxy reports `X-Forwarded-Proto: https`. The proxy must
411
+ `Secure` when a loopback proxy reports `X-Forwarded-Proto: https` (the header
412
+ is ignored from anywhere else, where it is a header the client wrote). The proxy must
404
413
  pass WebSocket upgrades for `/api/terminal` (Caddy does automatically;
405
414
  nginx needs its usual HTTP/1.1 `Upgrade`/`Connection` forwarding).
406
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.16",
3
+ "version": "0.0.17",
4
4
  "description": "A self-hosted workspace for coding agents: web workbench and IM channels in front of Pi sessions",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": "github:timqi/pier",