@timqi/pier 0.0.15 → 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-BWDlAMK2.js"></script>
37
- <link rel="stylesheet" crossorigin href="/assets/index-DHqZnZr7.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
@@ -177,9 +177,9 @@ journalctl --user -u pier --since -1h | grep 'tasks:' # one area
177
177
  ```
178
178
 
179
179
  Every line is `area: message` — `core`, `agent`, `tasks`, `slack`, `telegram`,
180
- `channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`, `secrets`,
181
- `settings`, `credentials`, `update`, `web.providers`, `pier` — so an area is a
182
- grep
180
+ `lark`, `channels`, `slack.tool`, `auth`, `boards`, `client`, `db`, `drain`,
181
+ `secrets`, `settings`, `credentials`, `update`, `tools`, `terminal`, `push`,
182
+ `web`, `web.providers`, `pier` — so an area is a grep
183
183
  and a level is a `-p`. The level reaches journald as a syslog priority prefix, which Pier
184
184
  emits only when systemd says the output is a journal (`$JOURNAL_STREAM`); run
185
185
  in a terminal, the same lines carry a timestamp and a level word instead.
@@ -229,14 +229,23 @@ 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
 
237
245
  ```sh
238
246
  pier restart # finish active work, then restart the service
239
247
  pier reload # apply channel config and recycle idle sessions in place
248
+ pier tools sync # install/update the managed CLI tools
240
249
  ```
241
250
 
242
251
  Both commands signal the installed systemd service; they are not foreground
@@ -246,6 +255,14 @@ next process. At the deadline it records every aborted IM turn first, and the
246
255
  next process posts that note after its adapter starts. Cleanup after the deadline
247
256
  has one shared 10-second bound regardless of how many sessions are stuck.
248
257
 
258
+ `pier tools sync` converges the command-line tools switched on in Console →
259
+ Settings — they install into `~/.pier/tools/bin`, which the service puts first
260
+ on the PATH every session, task and terminal inherits. Normally a switch runs
261
+ it for you as a task; typing it is for a machine that was offline when one was
262
+ flipped. One sync runs at a time per machine: an overlapping one waits for the
263
+ lock (and converges on the switches as they stand when its turn comes) instead
264
+ of racing the other's installs.
265
+
249
266
  `pier reload` does not stop active work. It reloads Slack and Telegram adapters
250
267
  and immediately evicts idle sessions nobody is watching, so their next message
251
268
  opens with current agent files and configuration. Streaming sessions and
@@ -391,7 +408,8 @@ elsewhere, pick a tunnel rather than a wider bind:
391
408
  there, preserve the external `Host` (or pass `X-Forwarded-Host`), and pass
392
409
  `X-Forwarded-For`; Pier uses the external host for write-origin checks and
393
410
  counts login failures per forwarded client. Its session cookie is marked
394
- `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
395
413
  pass WebSocket upgrades for `/api/terminal` (Caddy does automatically;
396
414
  nginx needs its usual HTTP/1.1 `Upgrade`/`Connection` forwarding).
397
415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@timqi/pier",
3
- "version": "0.0.15",
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",