@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.
- package/README.md +58 -125
- package/dist/agent/config.js +24 -11
- package/dist/agent/credentials.js +11 -23
- package/dist/agent/events.js +43 -62
- package/dist/agent/listing.js +113 -68
- package/dist/agent/pi.js +204 -211
- package/dist/boards/boards.js +19 -29
- package/dist/channels/attach.js +14 -42
- package/dist/channels/chains.js +33 -37
- package/dist/channels/chunk.js +8 -28
- package/dist/channels/commands.js +3 -14
- package/dist/channels/config.js +33 -52
- package/dist/channels/control.js +4 -13
- package/dist/channels/conversations.js +8 -25
- package/dist/channels/dedup.js +8 -17
- package/dist/channels/gatekeeper.js +13 -23
- package/dist/channels/lark-api.js +23 -63
- package/dist/channels/lark-outbound.js +12 -44
- package/dist/channels/lark-panel.js +8 -24
- package/dist/channels/lark-render.js +18 -62
- package/dist/channels/lark.js +52 -141
- package/dist/channels/lines.js +13 -15
- package/dist/channels/panel.js +16 -36
- package/dist/channels/receipts.js +29 -52
- package/dist/channels/routes.js +3 -9
- package/dist/channels/runtime.js +12 -23
- package/dist/channels/slack-api.js +34 -86
- package/dist/channels/slack-directory.js +7 -23
- package/dist/channels/slack-outbound.js +12 -56
- package/dist/channels/slack-panel.js +4 -13
- package/dist/channels/slack-render.js +23 -91
- package/dist/channels/slack-tool.js +48 -171
- package/dist/channels/slack.js +73 -239
- package/dist/channels/telegram-api.js +8 -20
- package/dist/channels/telegram-panel.js +5 -21
- package/dist/channels/telegram-render.js +13 -40
- package/dist/channels/telegram.js +54 -146
- package/dist/channels/types.js +5 -16
- package/dist/cli.js +17 -41
- package/dist/config-sync.js +87 -4
- package/dist/core/hub.js +7 -20
- package/dist/core/identity.js +20 -59
- package/dist/core/inbound-file.js +15 -49
- package/dist/core/inbox.js +13 -35
- package/dist/core/queue.js +3 -5
- package/dist/core/reply.js +41 -142
- package/dist/core/router.js +209 -260
- package/dist/core/types.js +17 -1
- package/dist/db.js +98 -252
- package/dist/drain.js +58 -51
- package/dist/extensions/index.js +3 -11
- package/dist/extensions/web/anthropic.js +3 -9
- package/dist/extensions/web/artifacts.js +2 -5
- package/dist/extensions/web/content.js +6 -14
- package/dist/extensions/web/http.js +2 -6
- package/dist/extensions/web/language.js +8 -18
- package/dist/extensions/web/openai.js +1 -1
- package/dist/extensions/web/provider.js +5 -18
- package/dist/extensions/web/tools.js +19 -63
- package/dist/lock.js +98 -0
- package/dist/log.js +9 -26
- package/dist/main.js +87 -179
- package/dist/paths.js +10 -26
- package/dist/secrets.js +19 -46
- package/dist/service.js +33 -75
- package/dist/settings.js +42 -65
- package/dist/tasks/agent.js +129 -114
- package/dist/tasks/callbacks.js +9 -19
- package/dist/tasks/command.js +29 -14
- package/dist/tasks/definitions.js +39 -62
- package/dist/tasks/execution.js +46 -42
- package/dist/tasks/groups.js +41 -35
- package/dist/tasks/messages.js +121 -182
- package/dist/tasks/outbox.js +61 -55
- package/dist/tasks/routes.js +5 -11
- package/dist/tasks/runs.js +14 -13
- package/dist/tasks/service.js +53 -54
- package/dist/tasks/store.js +53 -30
- package/dist/tasks/tool.js +132 -61
- package/dist/tools-task.js +20 -60
- package/dist/tools.js +100 -327
- package/dist/update.js +21 -44
- package/dist/web/auth.js +118 -179
- package/dist/web/config-sync.js +2 -2
- package/dist/web/config.js +3 -7
- package/dist/web/explorer.js +10 -21
- package/dist/web/fs.js +20 -42
- package/dist/web/instance.js +45 -85
- package/dist/web/providers.js +14 -13
- package/dist/web/public/assets/{activity-D3m4L2IL.js → activity-B89_hH7q.js} +2 -2
- package/dist/web/public/assets/activity-B89_hH7q.js.br +0 -0
- package/dist/web/public/assets/activity-B89_hH7q.js.gz +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js +1 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.br +0 -0
- package/dist/web/public/assets/boards-BeKW0ZXK.js.gz +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js +4 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.br +0 -0
- package/dist/web/public/assets/explorer-DIuMlaV3.js.gz +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js +85 -0
- package/dist/web/public/assets/index-DzXDXra_.js.br +0 -0
- package/dist/web/public/assets/index-DzXDXra_.js.gz +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css +2 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.br +0 -0
- package/dist/web/public/assets/index-eqQLVS8Q.css.gz +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js +1 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.br +0 -0
- package/dist/web/public/assets/runs-Cwy0mN8i.js.gz +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js +5 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.br +0 -0
- package/dist/web/public/assets/settings-DzZLmujq.js.gz +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js +3 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.br +0 -0
- package/dist/web/public/assets/task-runs-BCakxFk8.js.gz +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js +4 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.br +0 -0
- package/dist/web/public/assets/tasks-BlzEbk11.js.gz +0 -0
- package/dist/web/public/index.html +100 -130
- package/dist/web/public/index.html.br +0 -0
- package/dist/web/public/index.html.gz +0 -0
- package/dist/web/public/manifest.webmanifest +2 -2
- package/dist/web/public/manifest.webmanifest.br +0 -0
- package/dist/web/public/manifest.webmanifest.gz +0 -0
- package/dist/web/public/sw.js +14 -2
- package/dist/web/public/sw.js.br +0 -0
- package/dist/web/public/sw.js.gz +0 -0
- package/dist/web/push.js +55 -77
- package/dist/web/route.js +3 -7
- package/dist/web/server.js +131 -180
- package/dist/web/session-state.js +14 -54
- package/dist/web/types.js +2 -4
- package/dist/web/webpush.js +10 -25
- package/docs/deploy.md +115 -330
- package/package.json +2 -1
- package/skills/pier-boards/SKILL.md +81 -160
- package/skills/pier-help/SKILL.md +23 -20
- package/skills/pier-slack/SKILL.md +2 -2
- package/skills/pier-tasks/SKILL.md +153 -160
- package/dist/config-sync-fetch.js +0 -84
- package/dist/limits.js +0 -14
- package/dist/web/public/assets/activity-D3m4L2IL.js.br +0 -0
- package/dist/web/public/assets/activity-D3m4L2IL.js.gz +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js +0 -1
- package/dist/web/public/assets/boards-BIObcQeX.js.br +0 -0
- package/dist/web/public/assets/boards-BIObcQeX.js.gz +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js +0 -4
- package/dist/web/public/assets/explorer-C_rSWPNB.js.br +0 -0
- package/dist/web/public/assets/explorer-C_rSWPNB.js.gz +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css +0 -2
- package/dist/web/public/assets/index-CX3fYZY5.css.br +0 -0
- package/dist/web/public/assets/index-CX3fYZY5.css.gz +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js +0 -85
- package/dist/web/public/assets/index-uFsZkKOQ.js.br +0 -0
- package/dist/web/public/assets/index-uFsZkKOQ.js.gz +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js +0 -1
- package/dist/web/public/assets/runs-Ch6DZq6O.js.br +0 -0
- package/dist/web/public/assets/runs-Ch6DZq6O.js.gz +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js +0 -5
- package/dist/web/public/assets/settings-BWcEIEcv.js.br +0 -0
- package/dist/web/public/assets/settings-BWcEIEcv.js.gz +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js +0 -3
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.br +0 -0
- package/dist/web/public/assets/task-runs-DPkwv2UE.js.gz +0 -0
- package/dist/web/public/assets/tasks-DTiCi2mH.js +0 -4
- package/dist/web/public/assets/tasks-DTiCi2mH.js.br +0 -0
- 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
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
31
|
-
*
|
|
32
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
49
|
-
*
|
|
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
|
-
*
|
|
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
|
-
//
|
|
83
|
-
//
|
|
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
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
|
117
|
-
|
|
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
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
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
|
-
//
|
|
170
|
-
//
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
-
*
|
|
246
|
-
*
|
|
247
|
-
*
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
// the window is minutes and the point is to make guessing slow
|
|
253
|
-
//
|
|
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
|
-
/**
|
|
264
|
-
* hop
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
314
|
-
*
|
|
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
|
-
/**
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
-
|
|
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
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
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
|
-
|
|
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
|
-
|
|
358
|
-
|
|
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
|
-
//
|
|
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(
|
|
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
|
-
|
|
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
|
-
//
|
|
373
|
-
// the
|
|
374
|
-
//
|
|
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-
|
|
384
|
-
//
|
|
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
|
|
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
|
-
//
|
|
409
|
-
//
|
|
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
|
-
//
|
|
421
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
447
|
-
//
|
|
448
|
-
//
|
|
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, "&").replace(/"/g, """).replace(/</g, "<");
|
|
462
401
|
return `<!doctype html>
|
package/dist/web/config-sync.js
CHANGED
|
@@ -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
|
}
|
package/dist/web/config.js
CHANGED
|
@@ -1,12 +1,8 @@
|
|
|
1
|
-
// The agent files a scope is configured by
|
|
2
|
-
//
|
|
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
|
-
//
|
|
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" };
|
package/dist/web/explorer.js
CHANGED
|
@@ -1,21 +1,17 @@
|
|
|
1
|
-
// What git knows about a project directory, for the
|
|
2
|
-
//
|
|
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
|
-
/**
|
|
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
|
-
/**
|
|
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
|
-
//
|
|
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
|
-
//
|
|
40
|
-
//
|
|
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
|
-
//
|
|
69
|
-
//
|
|
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
|
-
//
|
|
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) {
|