@timqi/pier 0.0.29 → 0.1.1

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 (165) hide show
  1. package/README.md +58 -125
  2. package/dist/agent/config.js +24 -11
  3. package/dist/agent/credentials.js +11 -23
  4. package/dist/agent/events.js +43 -62
  5. package/dist/agent/listing.js +113 -68
  6. package/dist/agent/pi.js +204 -211
  7. package/dist/boards/boards.js +19 -29
  8. package/dist/channels/attach.js +14 -42
  9. package/dist/channels/chains.js +33 -37
  10. package/dist/channels/chunk.js +8 -28
  11. package/dist/channels/commands.js +3 -14
  12. package/dist/channels/config.js +33 -52
  13. package/dist/channels/control.js +4 -13
  14. package/dist/channels/conversations.js +8 -25
  15. package/dist/channels/dedup.js +8 -17
  16. package/dist/channels/gatekeeper.js +13 -23
  17. package/dist/channels/lark-api.js +23 -63
  18. package/dist/channels/lark-outbound.js +12 -44
  19. package/dist/channels/lark-panel.js +8 -24
  20. package/dist/channels/lark-render.js +18 -62
  21. package/dist/channels/lark.js +52 -141
  22. package/dist/channels/lines.js +13 -15
  23. package/dist/channels/panel.js +16 -36
  24. package/dist/channels/receipts.js +29 -52
  25. package/dist/channels/routes.js +3 -9
  26. package/dist/channels/runtime.js +12 -23
  27. package/dist/channels/slack-api.js +34 -86
  28. package/dist/channels/slack-directory.js +7 -23
  29. package/dist/channels/slack-outbound.js +12 -56
  30. package/dist/channels/slack-panel.js +4 -13
  31. package/dist/channels/slack-render.js +23 -91
  32. package/dist/channels/slack-tool.js +48 -171
  33. package/dist/channels/slack.js +73 -239
  34. package/dist/channels/telegram-api.js +8 -20
  35. package/dist/channels/telegram-panel.js +5 -21
  36. package/dist/channels/telegram-render.js +13 -40
  37. package/dist/channels/telegram.js +54 -146
  38. package/dist/channels/types.js +5 -16
  39. package/dist/cli.js +17 -41
  40. package/dist/config-sync.js +87 -4
  41. package/dist/core/hub.js +7 -20
  42. package/dist/core/identity.js +20 -59
  43. package/dist/core/inbound-file.js +15 -49
  44. package/dist/core/inbox.js +13 -35
  45. package/dist/core/queue.js +3 -5
  46. package/dist/core/reply.js +41 -142
  47. package/dist/core/router.js +209 -260
  48. package/dist/core/types.js +17 -1
  49. package/dist/db.js +98 -252
  50. package/dist/drain.js +58 -51
  51. package/dist/extensions/index.js +3 -11
  52. package/dist/extensions/web/anthropic.js +3 -9
  53. package/dist/extensions/web/artifacts.js +2 -5
  54. package/dist/extensions/web/content.js +6 -14
  55. package/dist/extensions/web/http.js +2 -6
  56. package/dist/extensions/web/language.js +8 -18
  57. package/dist/extensions/web/openai.js +1 -1
  58. package/dist/extensions/web/provider.js +5 -18
  59. package/dist/extensions/web/tools.js +19 -63
  60. package/dist/lock.js +98 -0
  61. package/dist/log.js +9 -26
  62. package/dist/main.js +87 -179
  63. package/dist/paths.js +10 -26
  64. package/dist/secrets.js +19 -46
  65. package/dist/service.js +33 -75
  66. package/dist/settings.js +42 -65
  67. package/dist/tasks/agent.js +129 -114
  68. package/dist/tasks/callbacks.js +9 -19
  69. package/dist/tasks/command.js +29 -14
  70. package/dist/tasks/definitions.js +39 -62
  71. package/dist/tasks/execution.js +46 -42
  72. package/dist/tasks/groups.js +41 -35
  73. package/dist/tasks/messages.js +121 -182
  74. package/dist/tasks/outbox.js +61 -55
  75. package/dist/tasks/routes.js +5 -11
  76. package/dist/tasks/runs.js +14 -13
  77. package/dist/tasks/service.js +53 -54
  78. package/dist/tasks/store.js +53 -30
  79. package/dist/tasks/tool.js +132 -61
  80. package/dist/tools-task.js +20 -60
  81. package/dist/tools.js +100 -327
  82. package/dist/update.js +21 -44
  83. package/dist/web/auth.js +118 -179
  84. package/dist/web/config-sync.js +2 -2
  85. package/dist/web/config.js +3 -7
  86. package/dist/web/explorer.js +10 -21
  87. package/dist/web/fs.js +20 -42
  88. package/dist/web/instance.js +45 -85
  89. package/dist/web/providers.js +14 -13
  90. package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
  91. package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
  92. package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
  93. package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
  94. package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
  95. package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
  96. package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
  97. package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
  98. package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
  99. package/dist/web/public/assets/index-DzXDXra_.js +85 -0
  100. package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
  101. package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
  102. package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
  103. package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
  104. package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
  105. package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
  106. package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
  107. package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
  108. package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
  109. package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
  110. package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
  111. package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
  112. package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
  113. package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
  114. package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
  115. package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
  116. package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
  117. package/dist/web/public/index.html +100 -130
  118. package/dist/web/public/index.html.br +0 -0
  119. package/dist/web/public/index.html.gz +0 -0
  120. package/dist/web/public/manifest.webmanifest +2 -2
  121. package/dist/web/public/manifest.webmanifest.br +0 -0
  122. package/dist/web/public/manifest.webmanifest.gz +0 -0
  123. package/dist/web/public/sw.js +14 -2
  124. package/dist/web/public/sw.js.br +0 -0
  125. package/dist/web/public/sw.js.gz +0 -0
  126. package/dist/web/push.js +55 -77
  127. package/dist/web/route.js +3 -7
  128. package/dist/web/server.js +131 -180
  129. package/dist/web/session-state.js +14 -54
  130. package/dist/web/types.js +2 -4
  131. package/dist/web/webpush.js +10 -25
  132. package/docs/deploy.md +115 -330
  133. package/package.json +2 -1
  134. package/skills/pier-boards/SKILL.md +81 -160
  135. package/skills/pier-help/SKILL.md +23 -20
  136. package/skills/pier-slack/SKILL.md +2 -2
  137. package/skills/pier-tasks/SKILL.md +153 -160
  138. package/dist/config-sync-fetch.js +0 -84
  139. package/dist/limits.js +0 -14
  140. package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
  141. package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
  142. package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
  143. package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
  144. package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
  145. package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
  146. package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
  147. package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
  148. package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
  149. package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
  150. package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
  151. package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
  152. package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
  153. package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
  154. package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
  155. package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
  156. package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
  157. package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
  158. package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
  159. package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
  160. package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
  161. package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
  162. package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
  163. package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
  164. package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
  165. package/dist/web/public/assets/tasks-DTiCi2mH.js.gz +0 -0
package/dist/web/auth.js CHANGED
@@ -1,74 +1,48 @@
1
- // The boundary in front of every HTTP surface: one shared password.
2
- //
3
- // Single-account on purpose. Pier has one workspace, so there is nobody to
4
- // tell apart — an internet-facing deployment needs a *boundary*, not
5
- // identities. Multiple people using it share the page, and share the password.
6
- //
7
- // Nothing to configure before first run: the store generates a password on an
8
- // empty database, keeps only its scrypt hash, and prints the plaintext once to
9
- // the log. There is no window where the port is open and unclaimed — the
10
- // password exists before the listener does — and no env var for an operator to
11
- // get wrong. Forgot it? Delete the row and restart; a new one is printed.
12
- //
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.
1
+ // The boundary in front of every HTTP surface: one shared password, generated
2
+ // before the listener opens and printed once. The cookie is "<id>.<token>" and
3
+ // the database keeps only the token's SHA-256, one row per browser: a copy of
4
+ // pier.db cannot be turned into a session, and one browser can be signed out
5
+ // alone. A cookie, not a bearer header, because EventSource sends no headers.
20
6
  import { createHash, randomBytes, randomInt, scryptSync, timingSafeEqual } from "node:crypto";
21
7
  import { getConnInfo } from "@hono/node-server/conninfo";
22
8
  import { deleteCookie, getCookie, setCookie } from "hono/cookie";
23
9
  import { pierDb, statements, transact } from "../db.js";
10
+ import { readCapped } from "../core/inbox.js";
24
11
  import { logger } from "../log.js";
25
12
  const log = logger("auth");
26
13
  const COOKIE = "pier_session";
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. */
14
+ /** Sliding: a stolen cookie is dead a week after its last use. */
29
15
  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. */
16
+ /** Absolute, so daily use cannot slide one cookie forever: every browser
17
+ * re-authenticates a quarter after it signed in. */
18
+ const MAX_AGE_MS = 90 * 24 * 60 * 60_000;
19
+ /** One `seen_at` write per browser per five minutes instead of one per request. */
33
20
  const TOUCH_MS = 5 * 60_000;
34
- /** `revoke(ALL)` — not an id any row can have, so it cannot collide with one. */
21
+ /** Not an id any row can have. */
35
22
  export const ALL = "*";
36
- /** Failed attempts one client may make before it has to wait out the window. */
37
23
  const MAX_FAILURES = 10;
38
24
  const WINDOW_MS = 15 * 60_000;
39
25
  /** Distinct throttle buckets retained at once; the last is shared overflow. */
40
26
  const MAX_FAILURE_CLIENTS = 1024;
41
27
  const OVERFLOW_CLIENT = "\0overflow";
42
- /** Shortest password a human may choose. The generated one is longer; this is
43
- * the floor under which the throttle above stops being enough. */
28
+ /** The floor under which the throttle above stops being enough. */
44
29
  const MIN_LENGTH = 10;
30
+ /** A password and a path fit; a stranger may not ask for more parsing than that. */
31
+ const MAX_LOGIN_BODY = 4096;
45
32
  // scrypt at Node's defaults (N=16384): ~50ms per attempt, which is the point.
46
33
  const KEY_BYTES = 32;
47
- /**
48
- * Human-readable and unambiguous: no 0/O, 1/l/I, so it survives being read off
49
- * a terminal and typed into a phone. 15 characters from a 31-symbol alphabet is
50
- * ~74 bits — this is the only thing between the internet and a shell.
51
- *
52
- * `randomInt` rejection-samples. Folding a random byte with `% 31` would have
53
- * quietly favoured the first eight symbols, which is the kind of bias nothing
54
- * ever reports.
55
- */
34
+ /** No 0/O, 1/l/I, so it survives being typed off a terminal into a phone; 15
35
+ * of 31 symbols is ~74 bits. `randomInt` rejection-samples — `% 31` on a byte
36
+ * would favour the first eight symbols. */
56
37
  function generatePassword() {
57
38
  const alphabet = "abcdefghjkmnpqrstuvwxyz23456789";
58
39
  const chars = Array.from({ length: 15 }, () => alphabet[randomInt(alphabet.length)]).join("");
59
40
  return `${chars.slice(0, 5)}-${chars.slice(5, 10)}-${chars.slice(10)}`;
60
41
  }
61
- /**
62
- * The stored credential: one row, one password, hashed.
63
- *
64
- * Generation happens in the constructor because "no password" is not a state
65
- * Pier may ever serve in — a boot that cannot print the password it just made
66
- * should fail at boot, not open a door.
67
- */
42
+ /** Generation happens in the constructor: "no password" is not a state Pier
43
+ * may ever serve in. */
68
44
  export class AuthStore {
69
45
  #db;
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
46
  #sql;
73
47
  #revokeListeners = new Set();
74
48
  constructor(db = pierDb(), print = (m) => log.info(m)) {
@@ -79,11 +53,8 @@ export class AuthStore {
79
53
  const password = generatePassword();
80
54
  const salt = randomBytes(16).toString("hex");
81
55
  row = { salt, hash: hash(password, salt), createdAt: Date.now() };
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.
56
+ // Also the forgotten-password path ("DELETE FROM auth"): browsers signed
57
+ // in under the old one must not walk through it.
87
58
  transact(this.#db, () => {
88
59
  this.#sql("INSERT INTO auth(id, salt, hash, created_at) VALUES (1, ?, ?, ?)")
89
60
  .run(salt, hash(password, salt), Date.now());
@@ -93,48 +64,34 @@ export class AuthStore {
93
64
  `only its hash is stored — it is not printed again. ` +
94
65
  `Lost it? "DELETE FROM auth" in the database, then restart.\n`);
95
66
  }
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.
67
+ // A session that expired while Pier was down must not keep notifying a phone.
99
68
  this.sweep();
100
69
  }
101
70
  #row() {
102
71
  return this.#sql("SELECT salt, hash, created_at AS createdAt FROM auth WHERE id = 1")
103
72
  .get();
104
73
  }
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. */
74
+ /** Private: callers must also tell the listeners; `revoke(ALL)` is that pair. */
107
75
  #dropSessions() {
108
76
  this.#sql("DELETE FROM web_sessions").run();
109
77
  }
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. */
78
+ /** Deleted, not merely refused: a push subscription hangs off the row. */
115
79
  sweep() {
116
- const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? RETURNING id")
117
- .all(Date.now() - TTL_MS);
80
+ const now = Date.now();
81
+ const swept = this.#sql("DELETE FROM web_sessions WHERE seen_at <= ? OR created_at <= ? RETURNING id").all(now - TTL_MS, now - MAX_AGE_MS);
118
82
  for (const row of swept)
119
83
  this.#revoked(row.id);
120
84
  if (swept.length)
121
85
  log.info(`swept ${String(swept.length)} expired session(s)`);
122
86
  }
123
- /** Whether this is the password, compared in constant time. */
124
87
  verify(password) {
125
88
  const row = this.#row();
126
89
  return row ? sameSecret(hash(password, row.salt), row.hash) : false;
127
90
  }
128
- /**
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.
133
- */
91
+ /** Every session goes too, the caller's included: a password is changed
92
+ * because the old one may be known. Listeners hear it after the commit. */
134
93
  setPassword(password) {
135
94
  const salt = randomBytes(16).toString("hex");
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
95
  transact(this.#db, () => {
139
96
  this.#sql("UPDATE auth SET salt = ?, hash = ?, created_at = ? WHERE id = 1")
140
97
  .run(salt, hash(password, salt), Date.now());
@@ -142,37 +99,30 @@ export class AuthStore {
142
99
  });
143
100
  this.#revoked(ALL);
144
101
  }
145
- /** Sign a browser in: one row, and the cookie value that opens it. */
146
102
  open(ip, agent) {
147
103
  const now = Date.now();
148
104
  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.
105
+ // The id names the row; the 256-bit token is the only part that resists guessing.
151
106
  const id = randomBytes(9).toString("base64url");
152
107
  const token = randomBytes(32).toString("base64url");
153
108
  this.#sql("INSERT INTO web_sessions(id, token_hash, created_at, seen_at, ip, agent)" +
154
109
  " VALUES (?, ?, ?, ?, ?, ?)").run(id, digest(token), now, now, ip, agent.slice(0, 200));
155
110
  return `${id}.${token}`;
156
111
  }
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
- */
112
+ /** `renewed` is the cue to resend the cookie with a new Max-Age: the window
113
+ * must slide on both sides or the browser drops a cookie the database honours. */
163
114
  check(cookie) {
164
115
  const [id, token] = (cookie ?? "").split(".");
165
116
  if (!id || !token)
166
117
  return undefined;
167
- const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt FROM web_sessions WHERE id = ?").get(id);
118
+ const row = this.#sql("SELECT token_hash AS tokenHash, seen_at AS seenAt, created_at AS createdAt" +
119
+ " FROM web_sessions WHERE id = ?").get(id);
168
120
  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.
121
+ // Deleted here, not left for the next sweep: a session nobody may use must
122
+ // stop being a device Pier notifies at the same moment.
173
123
  if (!row)
174
124
  return undefined;
175
- if (now - row.seenAt >= TTL_MS) {
125
+ if (now - row.seenAt >= TTL_MS || now - row.createdAt >= MAX_AGE_MS) {
176
126
  this.revoke(id);
177
127
  return undefined;
178
128
  }
@@ -183,8 +133,7 @@ export class AuthStore {
183
133
  this.#sql("UPDATE web_sessions SET seen_at = ? WHERE id = ?").run(now, id);
184
134
  return { id, renewed: true };
185
135
  }
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. */
136
+ /** Listeners hear the same id: a revoked cookie must also close what it opened. */
188
137
  revoke(id) {
189
138
  if (id === ALL)
190
139
  this.#dropSessions();
@@ -192,20 +141,15 @@ export class AuthStore {
192
141
  this.#sql("DELETE FROM web_sessions WHERE id = ?").run(id);
193
142
  this.#revoked(id);
194
143
  }
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
144
  list() {
198
145
  return this.#sql("SELECT id, created_at AS createdAt, seen_at AS seenAt, ip, agent" +
199
146
  " FROM web_sessions WHERE seen_at > ? ORDER BY seen_at DESC").all(Date.now() - TTL_MS);
200
147
  }
201
- /** A long-lived authenticated surface closes itself when a cookie is
202
- * revoked. The store and listeners share the process lifetime. */
148
+ /** A long-lived authenticated surface (SSE) closes itself when its cookie is revoked. */
203
149
  onRevoke(listener) {
204
150
  this.#revokeListeners.add(listener);
205
151
  }
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. */
152
+ /** The row is already gone; a surface that never hears stays open on a dead session. */
209
153
  #revoked(id) {
210
154
  for (const listener of this.#revokeListeners) {
211
155
  try {
@@ -218,17 +162,9 @@ export class AuthStore {
218
162
  }
219
163
  }
220
164
  const hash = (password, salt) => scryptSync(password, salt, KEY_BYTES).toString("hex");
221
- /** What the database keeps instead of the cookie's token. */
222
165
  const digest = (token) => createHash("sha256").update(token).digest("hex");
223
- /**
224
- * What a logged-out visitor must still reach: the login form, published
225
- * boards, and the stylesheet those boards link — a published board rendering
226
- * unstyled for the person it was published to is the same bug as not serving
227
- * it. Both live under `/p/*` (the stylesheet at `/p/_assets/pier.css`), the
228
- * single exempt prefix `docs/architecture.md` reserved for this, so the rule
229
- * is one prefix here and one prefix in anything fronting Pier; `/boards/*`
230
- * stays behind the boundary.
231
- */
166
+ /** The login form and `/p/*` — published boards and their stylesheet — are the
167
+ * single exempt prefix docs/architecture.md reserves; `/boards/*` stays behind. */
232
168
  function isPublic(method, path) {
233
169
  if (path === "/login")
234
170
  return method === "GET" || method === "HEAD" || method === "POST";
@@ -241,17 +177,16 @@ function sameSecret(a, b) {
241
177
  const bytes = (s) => createHash("sha256").update(s).digest();
242
178
  return timingSafeEqual(bytes(a), bytes(b));
243
179
  }
244
- /**
245
- * Only a same-origin path may be returned to after login. `//evil.example` is a
246
- * protocol-relative URL rather than a path, and browsers normalize a backslash
247
- * to a slash, so `/\evil.example` is the same trick spelled differently — both
248
- * are what a `startsWith("/")` check alone hands an open redirect to.
249
- */
250
- const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])/.test(raw) ? raw : "/";
251
- // Failed logins per client, in memory: a restart clearing them is fine, since
252
- // the window is minutes and the point is to make guessing slow, not to keep
253
- // books. Expired entries are pruned, and fresh identities spill into one
254
- // overflow bucket once the fixed map cap is reached.
180
+ /** `//evil.example` is protocol-relative and browsers normalize `/\evil.example`
181
+ * to it — and they strip whitespace and control characters from a Location
182
+ * first, which turns `/<TAB>/evil.example` into one as well. Only a plain
183
+ * path survives; anything else is an open redirect. */
184
+ const safeNext = (raw) => typeof raw === "string" && /^\/(?![/\\])\S*$/.test(raw) &&
185
+ ![...raw].some((ch) => ch <= "\u001f" || ch === "\u007f")
186
+ ? raw
187
+ : "/";
188
+ // In memory: the window is minutes, and the point is to make guessing slow.
189
+ // Fresh identities spill into one overflow bucket once the cap is reached.
255
190
  const failures = new Map();
256
191
  const loopback = (address) => address === "::1" || address.startsWith("127.") || address.startsWith("::ffff:127.");
257
192
  function remoteOf(c) {
@@ -260,8 +195,8 @@ function remoteOf(c) {
260
195
  ? getConnInfo(c).remote.address
261
196
  : undefined;
262
197
  }
263
- /** Trust a forwarded address only from a local reverse proxy. The rightmost
264
- * hop is the address that proxy appended, not one the client put at the front. */
198
+ /** A forwarded address is trusted only from a local reverse proxy, and only
199
+ * the rightmost hop, which that proxy appended. */
265
200
  function clientOf(c) {
266
201
  const remote = remoteOf(c);
267
202
  if (remote && !loopback(remote))
@@ -288,8 +223,7 @@ function noteFailure(client) {
288
223
  else
289
224
  failures.set(client, { count: 1, resetAt: Date.now() + WINDOW_MS });
290
225
  }
291
- /** Browsers name the source of unsafe requests. Compare hosts rather than
292
- * schemes because TLS commonly terminates at the reverse proxy. */
226
+ /** Hosts, not schemes: TLS commonly terminates at the reverse proxy. */
293
227
  function originMatches(origin, host) {
294
228
  if (!origin)
295
229
  return true; // curl and other non-browser clients
@@ -310,29 +244,36 @@ function sameOrigin(c) {
310
244
  : undefined;
311
245
  return originMatches(c.req.header("origin"), forwarded || c.req.header("host") || new URL(c.req.url).host);
312
246
  }
313
- /** Which row this request's cookie names. The boundary already verified the
314
- * token; this reads the id back off the value it accepted. Exported so a
315
- * surface that belongs to one browser (its push subscription) names it the
316
- * same way, rather than parsing the cookie a second way. */
247
+ /** The boundary already verified the token; exported so a push subscription
248
+ * names its browser the same way. */
317
249
  export const sessionIdOf = (c) => (getCookie(c, COOKIE) ?? "").split(".")[0] ?? "";
318
- /** Every route, in one place — no per-route opt-in to forget on the next one. */
250
+ /** Stamped on the response the route actually returned: a route that hands
251
+ * back a native `Response` (`Response.json`, `queueResponse`) replaces `c.res`
252
+ * and loses anything set on the context before it. Absent-only, so a board
253
+ * keeps the headers it chose (boards/boards.ts). */
254
+ function sealBoundary(c) {
255
+ // Public responses too: the login form must not be frameable either, and a
256
+ // served file must not be sniffed into a type its content-type denies.
257
+ if (!c.res.headers.has("x-frame-options"))
258
+ c.header("x-frame-options", "DENY");
259
+ if (!c.res.headers.has("x-content-type-options"))
260
+ c.header("x-content-type-options", "nosniff");
261
+ }
319
262
  export function requireAuth(store) {
320
263
  return async (c, next) => {
321
- // On every response, public ones included: the login form is the one page
322
- // strangers reach, and it must not be frameable either.
323
- c.header("x-frame-options", "DENY");
324
- if (isPublic(c.req.method, c.req.path))
325
- return next();
264
+ if (isPublic(c.req.method, c.req.path)) {
265
+ await next();
266
+ sealBoundary(c);
267
+ return;
268
+ }
326
269
  const cookie = getCookie(c, COOKIE);
327
270
  const session = store.check(cookie);
328
271
  const unsafe = c.req.method !== "GET" && c.req.method !== "HEAD";
329
272
  if (session && unsafe && !sameOrigin(c)) {
330
273
  log.warn(`blocked ${c.req.method} ${c.req.path} from origin ${c.req.header("origin")}`);
331
- return c.json({ error: "forbidden origin" }, 403);
274
+ c.res = c.json({ error: "forbidden origin" }, 403);
332
275
  }
333
- if (session) {
334
- // The database just moved the deadline; the browser is told the same, or
335
- // it would drop a cookie that is still good.
276
+ else if (session) {
336
277
  if (session.renewed && cookie)
337
278
  setSessionCookie(c, cookie);
338
279
  await next();
@@ -340,48 +281,61 @@ export function requireAuth(store) {
340
281
  if (!c.res.headers.has("cache-control")) {
341
282
  c.header("cache-control", c.req.path.startsWith("/api/") ? "private, no-store" : "private");
342
283
  }
343
- return;
344
284
  }
345
- // An API caller gets a status it can act on; a navigation gets the form.
346
- // Anything non-GET is a client call too — never a link worth redirecting.
347
- if (c.req.path.startsWith("/api/") || unsafe) {
348
- return c.json({ error: "unauthorized" }, 401);
285
+ else if (c.req.path.startsWith("/api/") || unsafe) {
286
+ // An API caller gets a status it can act on; a navigation gets the form.
287
+ c.res = c.json({ error: "unauthorized" }, 401);
288
+ }
289
+ else {
290
+ c.res = c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`);
349
291
  }
350
- return c.redirect(`/login?next=${encodeURIComponent(c.req.path)}`);
292
+ sealBoundary(c);
351
293
  };
352
294
  }
353
295
  export function registerAuthRoutes(app, store) {
354
296
  app.get("/login", (c) => c.html(loginPage(safeNext(c.req.query("next")))));
355
297
  app.post("/login", async (c) => {
356
298
  const client = clientOf(c);
357
- const form = await c.req.parseBody();
358
- const next = safeNext(form.next);
299
+ // Before the body is touched: parsing is work, and this is the one write a
300
+ // stranger may reach. The remembered destination is a casualty of that.
359
301
  if (throttled(client)) {
360
- // The one surface strangers can reach: a burst here is the only warning
361
- // an operator gets that the port is being knocked on.
302
+ // A burst here is the only warning an operator gets that the port is being knocked on.
362
303
  log.warn(`login throttled for ${client}`);
363
- return c.html(loginPage(next, "Too many attempts. Wait a few minutes."), 429);
304
+ return c.html(loginPage("/", "Too many attempts. Wait a few minutes."), 429);
305
+ }
306
+ if (!c.req.header("content-type")?.startsWith("application/x-www-form-urlencoded")) {
307
+ return c.text("expected the sign-in form", 400);
308
+ }
309
+ // The read is the bound, not `content-length`: a chunked request declares
310
+ // no length, and a parser handed the whole stream is the work being denied.
311
+ let body;
312
+ try {
313
+ body = await readCapped(c.req.raw.body, MAX_LOGIN_BODY);
314
+ }
315
+ catch (err) {
316
+ log.warn(`sign-in body refused from ${client}: ${String(err)}`);
317
+ return c.text("sign-in body too large", 413);
364
318
  }
365
- if (!store.verify(typeof form.password === "string" ? form.password : "")) {
319
+ const form = new URLSearchParams(new TextDecoder().decode(body));
320
+ const next = safeNext(form.get("next"));
321
+ if (!store.verify(form.get("password") ?? "")) {
366
322
  noteFailure(client);
367
323
  log.warn(`wrong password from ${client}`);
368
324
  return c.html(loginPage(next, "Wrong password."), 401);
369
325
  }
370
326
  failures.delete(client);
371
327
  log.info(`login from ${client}`);
372
- // Signing in again replaces this browser's session rather than adding one:
373
- // the cookie it is about to drop would otherwise stay valid for a week, as
374
- // a row nobody can recognize in the device list. Verified first — the id in
375
- // an unverified cookie is a string the caller chose, and `ALL` is one of
376
- // the strings they could choose.
328
+ // Replaces this browser's session rather than adding one. Verified first:
329
+ // the id in an unverified cookie is a string the caller chose, and `ALL` is
330
+ // one of them.
377
331
  const previous = store.check(getCookie(c, COOKIE));
378
332
  if (previous)
379
333
  store.revoke(previous.id);
380
334
  setSessionCookie(c, store.open(client, c.req.header("user-agent") ?? ""));
381
335
  return c.redirect(next);
382
336
  });
383
- // Re-authenticate before rotating the credential. The global boundary also
384
- // requires a live cookie; knowing a password is not permission to call APIs.
337
+ // Re-authenticates: the boundary requires a live cookie, and knowing a
338
+ // password is not permission to call APIs.
385
339
  app.post("/api/password", async (c) => {
386
340
  const client = clientOf(c);
387
341
  const body = (await c.req.json().catch(() => null));
@@ -398,27 +352,20 @@ export function registerAuthRoutes(app, store) {
398
352
  }
399
353
  failures.delete(client);
400
354
  store.setPassword(next);
401
- // The rotation drops every session row, this caller's included — a password
402
- // is changed because the old one may be known, and "everyone signs in
403
- // again" is the whole point. Clear the dead cookie; the client sends the
404
- // person to the login form with the password they just chose.
355
+ // The rotation dropped this caller's row too; clear the dead cookie.
405
356
  deleteCookie(c, COOKIE, { path: "/" });
406
357
  return c.json({ ok: true });
407
358
  });
408
- // Signed-in browsers, so "sign out that one" is something an operator can
409
- // see before doing. Not /api/sessions: that is the agent's sessions, and one
410
- // vocabulary for two unrelated things is how the wrong one gets ended.
359
+ // Not /api/sessions: that is the agent's sessions, and one vocabulary for
360
+ // two unrelated things is how the wrong one gets ended.
411
361
  app.get("/api/devices", (c) => {
412
362
  const current = sessionIdOf(c);
413
363
  return c.json(store.list().map((d) => ({ ...d, current: d.id === current })));
414
364
  });
415
- // Real revocation: the row goes, and the cookie holding its token opens
416
- // nothing on the next request. Ending this browser's own session is the same
417
- // call, so the client clears the cookie it is about to stop being able to use.
418
365
  app.post("/api/devices/:id/signout", (c) => {
419
366
  const id = c.req.param("id");
420
- // One row per call. Signing everyone out is the password change above,
421
- // which is the only thing that also invalidates the password they know.
367
+ // Signing everyone out is the password change, which also invalidates
368
+ // the password they know.
422
369
  if (id === ALL)
423
370
  return c.json({ error: "not a session id" }, 400);
424
371
  store.revoke(id);
@@ -427,36 +374,28 @@ export function registerAuthRoutes(app, store) {
427
374
  deleteCookie(c, COOKIE, { path: "/" });
428
375
  return c.json({ ok: true });
429
376
  });
430
- // Signs out this browser: the row is deleted, not just the cookie cleared,
431
- // so a copy of that cookie taken beforehand is dead too. Behind the boundary
432
- // like every write — only a signed-in browser has anything to end.
377
+ // The row is deleted, not just the cookie cleared, so a copy of that cookie is dead too.
433
378
  app.post("/logout", (c) => {
434
379
  store.revoke(sessionIdOf(c));
435
380
  deleteCookie(c, COOKIE, { path: "/" });
436
381
  return c.json({ ok: true });
437
382
  });
438
383
  }
439
- /** The signed-in cookie — set at login, and again whenever the sliding window
440
- * moved, which is why it takes the value rather than making one. */
441
384
  function setSessionCookie(c, value) {
442
385
  setCookie(c, COOKIE, value, {
443
386
  path: "/",
444
387
  httpOnly: true,
445
388
  sameSite: "Lax",
446
- // Set only over TLS: a Secure cookie on plain http is dropped, which
447
- // would lock out the loopback and SSH-tunnel setups. The forwarded scheme
448
- // counts only from a local proxy — anywhere else it is a header the client
449
- // wrote, and a stranger must not get to decide this flag.
389
+ // A Secure cookie on plain http is dropped, locking out loopback and SSH
390
+ // tunnels. The forwarded scheme counts only from a local proxy: anywhere
391
+ // else a stranger wrote the header.
450
392
  secure: new URL(c.req.url).protocol === "https:" ||
451
393
  (c.req.header("x-forwarded-proto")?.split(",").at(-1)?.trim() === "https" &&
452
394
  loopback(remoteOf(c) ?? "")),
453
395
  maxAge: TTL_MS / 1000,
454
396
  });
455
397
  }
456
- /**
457
- * Self-contained HTML: the login page must render before the workbench bundle
458
- * is reachable, so it links nothing the boundary would refuse to serve.
459
- */
398
+ /** Self-contained: it links nothing the boundary would refuse to serve. */
460
399
  function loginPage(next, error) {
461
400
  const attr = (s) => s.replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
462
401
  return `<!doctype html>
@@ -82,8 +82,8 @@ export function registerConfigSyncRoutes(app, deps) {
82
82
  try {
83
83
  await deps.reconcile();
84
84
  }
85
- catch {
86
- log.error("Could not reconcile configuration sync task");
85
+ catch (reconcile) {
86
+ log.error("Could not reconcile configuration sync task", reconcile);
87
87
  }
88
88
  return c.json({ error: err instanceof Error ? err.message : "Configuration sync failed", status: deps.status() }, 409);
89
89
  }
@@ -1,12 +1,8 @@
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.
1
+ // The agent files a scope is configured by, through the ConfigStore. Not via
2
+ // web/fs.ts: a scope is "global" or a cwd Pi already knows, never a browser path.
6
3
  import { guarded } from "./route.js";
7
4
  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.
5
+ // Only cwds Pi already knows are accepted — never an arbitrary path.
10
6
  const parseScope = async (raw) => {
11
7
  if (!raw || raw === "global")
12
8
  return { kind: "global" };
@@ -1,21 +1,17 @@
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.
1
+ // What git knows about a project directory, for the Files view. Every route
2
+ // runs git and nothing else; reading files is web/fs.ts.
5
3
  import { execFile } from "node:child_process";
6
4
  import { promisify } from "node:util";
7
5
  import { scoped } from "./fs.js";
8
6
  import { guarded } from "./route.js";
9
7
  const run = promisify(execFile);
10
8
  const MAX_DIFF_BYTES = 2 * 1024 * 1024;
11
- /** A ref never starts with `-`: execFile blocks the shell, this blocks the
12
- * argument parser (`--output=…` is a write). Git validates the rest. */
9
+ /** execFile blocks the shell; this blocks the argument parser (`--output=…` is a write). */
13
10
  const REF_RE = /^[^-\s][^\s]*$/;
14
- /** git in `root`, output capped — a diff is display data, not an archive. */
11
+ /** Output capped: a diff is display data, not an archive. */
15
12
  const git = async (root, ...args) => (await run("git", ["-C", root, ...args], { maxBuffer: MAX_DIFF_BYTES })).stdout;
16
13
  export function registerExplorerRoutes(app) {
17
- // Git refs for the diff pickers: current branch, branches+tags, recent
18
- // commits. Not a repo → { branch: null }, which the UI renders as "no git".
14
+ // Not a repo → { branch: null }, which the UI renders as "no git".
19
15
  guarded(app, "GET", "/api/explorer/git", 404, async (c) => {
20
16
  c.header("cache-control", "no-store");
21
17
  const root = await scoped(c.req.query("root"));
@@ -27,19 +23,14 @@ export function registerExplorerRoutes(app) {
27
23
  // not a repo, or no commits yet
28
24
  return c.json({ branch: null, refs: [], commits: [], worktrees: [] });
29
25
  }
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
26
  const [worktreeList, refList, commitLog] = await Promise.all([
34
27
  git(root, "worktree", "list", "--porcelain"),
35
28
  git(root, "for-each-ref", "--format=%(refname:short)\t%(subject)", "refs/heads", "refs/tags"),
36
29
  // Unit/record separators, because a body is multi-line by nature.
37
30
  git(root, "log", "-20", "--format=%h\u001f%at\u001f%an\u001f%ae\u001f%s\u001f%b\u001e"),
38
31
  ]);
39
- // Every checkout of this repository, from git rather than from the
40
- // sessions that happen to live in one: a worktree created ten seconds ago
41
- // has no session in it yet, and that is exactly when its files are worth
42
- // opening. Detached heads have no `branch` line, so the path stands alone.
32
+ // From git, not from sessions: a worktree created ten seconds ago has no
33
+ // session yet. Detached heads have no `branch` line.
43
34
  const worktrees = worktreeList
44
35
  .split("\n\n")
45
36
  .map((block) => {
@@ -65,9 +56,8 @@ export function registerExplorerRoutes(app) {
65
56
  });
66
57
  return c.json({ branch, refs, commits, worktrees });
67
58
  });
68
- // One endpoint, two shapes: without `file` the changed-file list
69
- // (name-status), with it that file's unified diff. `head` empty or absent
70
- // means the working tree.
59
+ // Without `file` the changed-file list, with it that file's diff. `head`
60
+ // empty means the working tree.
71
61
  guarded(app, "GET", "/api/explorer/diff", 404, async (c) => {
72
62
  c.header("cache-control", "no-store");
73
63
  const root = await scoped(c.req.query("root"));
@@ -76,8 +66,7 @@ export function registerExplorerRoutes(app) {
76
66
  if (!REF_RE.test(base) || (head !== "" && !REF_RE.test(head)))
77
67
  return c.json({ error: "invalid ref" }, 400);
78
68
  const range = head ? [base, head] : [base];
79
- // Context radius for per-file diffs — the UI asks for a huge one to render
80
- // the whole file with changes toned inline, not a bare patch.
69
+ // The UI asks for a huge radius to render the whole file with changes toned inline.
81
70
  const context = Math.min(99_999, Math.max(0, Math.trunc(Number(c.req.query("context"))) || 0));
82
71
  const file = c.req.query("file");
83
72
  if (file === undefined) {