pi-weave 0.1.7 → 0.1.9
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 +123 -37
- package/package.json +17 -5
- package/skills/weave-notepad/SKILL.md +13 -4
- package/src/core/cache/workspace.ts +466 -0
- package/src/core/concurrency.ts +36 -0
- package/src/core/frontmatter.ts +270 -23
- package/src/core/git.ts +19 -0
- package/src/core/graph/build.ts +97 -5
- package/src/core/graph/current.ts +41 -28
- package/src/core/graph/mentions.ts +170 -0
- package/src/core/graph/model.ts +39 -0
- package/src/core/graph/wikilinks.ts +5 -1
- package/src/core/index.ts +14 -0
- package/src/core/openInEditor.ts +69 -0
- package/src/core/paths.ts +7 -0
- package/src/core/sessions.ts +929 -0
- package/src/core/summaries.ts +1 -19
- package/src/core/types.ts +40 -0
- package/src/core/vault.ts +739 -57
- package/src/core/view/cluster.ts +262 -0
- package/src/core/view/detail.ts +118 -0
- package/src/core/view/focus.ts +109 -0
- package/src/core/view/health.ts +156 -0
- package/src/core/view/index.ts +15 -0
- package/src/core/view/links.ts +105 -0
- package/src/core/view/time.ts +47 -0
- package/src/core/view/tree.ts +269 -0
- package/src/core/view/types.ts +39 -0
- package/src/core/workspace.ts +3 -3
- package/src/pi/index.ts +248 -48
- package/src/pi/sessionScan.ts +104 -0
- package/src/pi/summarize.ts +24 -4
- package/src/pi/tools/noteTool.ts +13 -4
- package/src/pi/viewer/tui/branding.ts +8 -7
- package/src/pi/viewer/tui/explorer.ts +5 -3
- package/src/pi/viewer/tui/model.ts +46 -667
- package/src/pi/viewer/tui/openNote.ts +7 -56
- package/src/pi/viewer/tui/surface/explore.ts +4 -2
- package/src/pi/viewer/web/run.ts +331 -0
- package/src/web/client/api.dom.ts +40 -0
- package/src/web/client/api.ts +472 -0
- package/src/web/client/bootstrap.ts +58 -0
- package/src/web/client/context/context.model.ts +313 -0
- package/src/web/client/dist/app.js +764 -0
- package/src/web/client/graph/Graph.tsx +209 -0
- package/src/web/client/graph/column.model.ts +372 -0
- package/src/web/client/graph/dynamics.ts +176 -0
- package/src/web/client/graph/graph.model.ts +546 -0
- package/src/web/client/graph/positions.ts +380 -0
- package/src/web/client/graph/project.ts +153 -0
- package/src/web/client/graph/renderer.dom.ts +52 -0
- package/src/web/client/graph/renderer.ts +339 -0
- package/src/web/client/graph/scheme.ts +44 -0
- package/src/web/client/live.model.ts +275 -0
- package/src/web/client/live.ts +151 -0
- package/src/web/client/main.tsx +27 -0
- package/src/web/client/note/Editor.tsx +102 -0
- package/src/web/client/note/Note.tsx +113 -0
- package/src/web/client/note/editor.controller.ts +151 -0
- package/src/web/client/note/editor.model.ts +636 -0
- package/src/web/client/note/note.model.ts +738 -0
- package/src/web/client/search/SearchPalette.tsx +105 -0
- package/src/web/client/search/search.model.ts +588 -0
- package/src/web/client/search/search.ts +107 -0
- package/src/web/client/selection.storage.ts +69 -0
- package/src/web/client/shell/Columns.tsx +161 -0
- package/src/web/client/shell/ContextRail.tsx +87 -0
- package/src/web/client/shell/Divider.tsx +44 -0
- package/src/web/client/shell/FocusTrap.tsx +56 -0
- package/src/web/client/shell/Header.tsx +64 -0
- package/src/web/client/shell/HelpOverlay.tsx +70 -0
- package/src/web/client/shell/Shell.tsx +210 -0
- package/src/web/client/shell/StatusBar.tsx +28 -0
- package/src/web/client/shell/cssvars.ts +70 -0
- package/src/web/client/shell/drag.model.ts +170 -0
- package/src/web/client/shell/focus.model.ts +100 -0
- package/src/web/client/shell/keys.model.ts +453 -0
- package/src/web/client/shell/keys.ts +59 -0
- package/src/web/client/shell/layout.model.ts +526 -0
- package/src/web/client/shell/shell.model.ts +333 -0
- package/src/web/client/shell/theme.ts +490 -0
- package/src/web/client/shell/viewport.ts +29 -0
- package/src/web/client/state.ts +89 -0
- package/src/web/client/tree/Tree.tsx +141 -0
- package/src/web/client/tree/tree.model.ts +674 -0
- package/src/web/client/workspace.ts +278 -0
- package/src/web/server/page.ts +258 -0
- package/src/web/server/routes.ts +987 -0
- package/src/web/server/security.ts +361 -0
- package/src/web/server/server.ts +275 -0
- package/src/web/server/sse.ts +321 -0
- package/src/web/server/watcher.ts +507 -0
- package/src/web/shared/graph.ts +213 -0
- package/src/web/shared/layout.ts +314 -0
- package/src/web/shared/logo.ts +11 -0
- package/src/web/shared/metrics.ts +136 -0
- package/src/web/shared/view.ts +200 -0
- package/src/web/shared/wire.ts +358 -0
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The four security layers of the loopback workspace server
|
|
3
|
+
* (weave-workspace §5.1).
|
|
4
|
+
*
|
|
5
|
+
* ## Why any of this, on 127.0.0.1
|
|
6
|
+
*
|
|
7
|
+
* The retired viewer bound loopback and stopped there —
|
|
8
|
+
* `git show cef1177:src/pi/viewer/server.ts` has **zero** matches for
|
|
9
|
+
* `token`, `Origin` or `Host`. Loopback is not an authorisation boundary:
|
|
10
|
+
*
|
|
11
|
+
* - every process running as the user can reach the port, and can find it
|
|
12
|
+
* by scanning a few thousand ports faster than a page loads;
|
|
13
|
+
* - **any website the user visits** can reach it too, via DNS rebinding —
|
|
14
|
+
* `evil.com` resolves to a real address for the page load, then re-answers
|
|
15
|
+
* with `127.0.0.1` on a second lookup, and the browser now treats our
|
|
16
|
+
* server as same-origin with the attacker's JavaScript.
|
|
17
|
+
*
|
|
18
|
+
* So four layers, each defeating something the others do not:
|
|
19
|
+
*
|
|
20
|
+
* | # | Layer | Defeats |
|
|
21
|
+
* | - | ----- | ------- |
|
|
22
|
+
* | 1 | bind `127.0.0.1`, port `0` | remote reachability; a guessable port |
|
|
23
|
+
* | 2 | {@link isAllowedHost} | DNS rebinding |
|
|
24
|
+
* | 3 | {@link timingSafeMatch} over a 256-bit token | local processes, blind CSRF |
|
|
25
|
+
* | 4 | {@link checkOrigin} | cross-origin writes from a page that got past 2 |
|
|
26
|
+
*
|
|
27
|
+
* Layer 2 is the one that carries the rebinding case, and the reason it
|
|
28
|
+
* works is worth stating: the rebinding attacker controls DNS, not the
|
|
29
|
+
* browser. The browser sends `Host: evil.com` because that is the name in
|
|
30
|
+
* the URL bar, and it cannot be talked out of it — `fetch` rejects an
|
|
31
|
+
* attempt to set `Host` as a forbidden header. An allowlist of
|
|
32
|
+
* `127.0.0.1:PORT` / `localhost:PORT` / `[::1]:PORT` therefore rejects every
|
|
33
|
+
* rebound request while costing a legitimate one a string compare.
|
|
34
|
+
*
|
|
35
|
+
* Layer 4 is deliberately asymmetric, because `Origin` is asymmetric:
|
|
36
|
+
* browsers omit it on same-origin GET *navigations*, so requiring it would
|
|
37
|
+
* break typing the URL into the address bar. Validate-if-present on GET,
|
|
38
|
+
* require on everything else — which is where state changes live.
|
|
39
|
+
*
|
|
40
|
+
* ## Everything here is a pure function of request facts
|
|
41
|
+
*
|
|
42
|
+
* {@link RequestFacts} is four strings, not an `IncomingMessage`, so the
|
|
43
|
+
* table tests in `tests/web/security.test.ts` enumerate the matrix directly
|
|
44
|
+
* instead of constructing sockets. {@link requestFacts} is the one adapter
|
|
45
|
+
* from Node's request object, and it is the only part of this module that
|
|
46
|
+
* knows HTTP exists.
|
|
47
|
+
*/
|
|
48
|
+
|
|
49
|
+
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
50
|
+
|
|
51
|
+
// --- token -------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Token entropy. 256 bits: not guessable by a local process in the lifetime
|
|
55
|
+
* of a pi session, and short enough in base64url (43 chars) to survive being
|
|
56
|
+
* pasted into a terminal.
|
|
57
|
+
*/
|
|
58
|
+
export const TOKEN_BYTES = 32;
|
|
59
|
+
|
|
60
|
+
/** A fresh per-session token. */
|
|
61
|
+
export function generateToken(): string {
|
|
62
|
+
return randomBytes(TOKEN_BYTES).toString("base64url");
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Constant-time string comparison.
|
|
67
|
+
*
|
|
68
|
+
* `timingSafeEqual` **throws** on a length mismatch (`ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH`),
|
|
69
|
+
* which is the trap this wrapper exists to close: a caller that passes it
|
|
70
|
+
* two user-controlled strings has written a crash, not a comparison. The
|
|
71
|
+
* length pre-check is not a timing leak worth caring about — our token's
|
|
72
|
+
* length is a constant of the program, so an attacker learns nothing they
|
|
73
|
+
* could not read in this file.
|
|
74
|
+
*
|
|
75
|
+
* Byte length, not `String.length`: a multi-byte character would make the
|
|
76
|
+
* two disagree and re-introduce the throw.
|
|
77
|
+
*/
|
|
78
|
+
export function timingSafeMatch(a: string, b: string): boolean {
|
|
79
|
+
const left = Buffer.from(a, "utf8");
|
|
80
|
+
const right = Buffer.from(b, "utf8");
|
|
81
|
+
if (left.length !== right.length) return false;
|
|
82
|
+
// Zero-length buffers compare equal in `timingSafeEqual`, which would make
|
|
83
|
+
// "" a valid token against an "" secret. Callers never hold an empty
|
|
84
|
+
// token, but a truncated env var is one typo away, so refuse explicitly.
|
|
85
|
+
if (left.length === 0) return false;
|
|
86
|
+
return timingSafeEqual(left, right);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// --- host and origin ---------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The `Host` values a legitimate request can carry. IPv6 loopback is
|
|
93
|
+
* included in bracketed form because that is what a browser sends for
|
|
94
|
+
* `http://[::1]:PORT/`.
|
|
95
|
+
*/
|
|
96
|
+
export function allowedHosts(port: number): ReadonlySet<string> {
|
|
97
|
+
return new Set([`127.0.0.1:${port}`, `localhost:${port}`, `[::1]:${port}`]);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** The `Origin` values matching {@link allowedHosts}. Always `http:`. */
|
|
101
|
+
export function allowedOrigins(port: number): ReadonlySet<string> {
|
|
102
|
+
return new Set([`http://127.0.0.1:${port}`, `http://localhost:${port}`, `http://[::1]:${port}`]);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Layer 2. An absent `Host` is a deny: HTTP/1.1 requires it, and the only
|
|
107
|
+
* clients that omit it are hand-rolled ones probing the port.
|
|
108
|
+
*/
|
|
109
|
+
export function isAllowedHost(host: string | undefined, port: number): boolean {
|
|
110
|
+
if (host === undefined) return false;
|
|
111
|
+
return allowedHosts(port).has(host);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Layer 4. Returns `true` when the request may proceed.
|
|
116
|
+
*
|
|
117
|
+
* - present and allowlisted → ok, any method;
|
|
118
|
+
* - present and foreign → denied, any method (this is the cross-origin
|
|
119
|
+
* write, and also a cross-origin read attempt worth refusing early);
|
|
120
|
+
* - absent on `GET`/`HEAD` → ok, because browsers omit it on same-origin
|
|
121
|
+
* navigations and we would otherwise reject the address bar;
|
|
122
|
+
* - absent on anything else → denied. A state-changing request with no
|
|
123
|
+
* provenance is exactly the shape of a form-post CSRF.
|
|
124
|
+
*/
|
|
125
|
+
export function checkOrigin(method: string, origin: string | undefined, port: number): boolean {
|
|
126
|
+
if (origin !== undefined) return allowedOrigins(port).has(origin);
|
|
127
|
+
const safe = method === "GET" || method === "HEAD";
|
|
128
|
+
return safe;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// --- cookies -----------------------------------------------------------------
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Default cookie name. The `__Host-` prefix is a browser-enforced
|
|
135
|
+
* commitment: the cookie must be `Secure`, must have `Path=/`, and must
|
|
136
|
+
* carry **no** `Domain` — which together mean no sibling origin and no
|
|
137
|
+
* subdomain can set or overwrite it. That closes cookie-injection, where an
|
|
138
|
+
* attacker plants their own token so that any request the user makes is
|
|
139
|
+
* attributed to a session the attacker also holds.
|
|
140
|
+
*
|
|
141
|
+
* `Secure` on `http://127.0.0.1` is legal because loopback is a
|
|
142
|
+
* [secure context](https://w3c.github.io/webappsec-secure-contexts/), and
|
|
143
|
+
* Chrome/Firefox/Safari all honour it there. §5.1 footnote 1 asks for a
|
|
144
|
+
* fallback anyway, which is {@link FALLBACK_COOKIE_NAME}: same value, no
|
|
145
|
+
* prefix, no `Secure`, and therefore none of the guarantees above. It exists
|
|
146
|
+
* so a browser that disagrees costs a config flag rather than a redesign.
|
|
147
|
+
*/
|
|
148
|
+
export const DEFAULT_COOKIE_NAME = "__Host-weave";
|
|
149
|
+
|
|
150
|
+
/** Prefix-free fallback for a browser that rejects `Secure` on loopback. */
|
|
151
|
+
export const FALLBACK_COOKIE_NAME = "weave_token";
|
|
152
|
+
|
|
153
|
+
/** Query parameter carrying the token on the one-shot handoff URL. */
|
|
154
|
+
export const TOKEN_QUERY_PARAM = "t";
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Parse a `Cookie` request header into a map.
|
|
158
|
+
*
|
|
159
|
+
* Lenient by design — this is header parsing on an attacker-reachable path,
|
|
160
|
+
* so anything malformed is skipped rather than thrown. A pair with no `=` is
|
|
161
|
+
* dropped; a value containing `=` keeps it (base64url does not produce one,
|
|
162
|
+
* but a fallback-named cookie set by something else might).
|
|
163
|
+
*/
|
|
164
|
+
export function parseCookies(header: string | undefined): Record<string, string> {
|
|
165
|
+
const out: Record<string, string> = {};
|
|
166
|
+
if (header === undefined) return out;
|
|
167
|
+
for (const part of header.split(";")) {
|
|
168
|
+
const eq = part.indexOf("=");
|
|
169
|
+
// `eq === 0` is a nameless pair ("=v"); `eq === -1` has no separator at
|
|
170
|
+
// all. Both are malformed, and `<= 0` covers them together — which also
|
|
171
|
+
// means a name can never be the empty string below.
|
|
172
|
+
if (eq <= 0) continue;
|
|
173
|
+
out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The `Set-Cookie` value for the handoff.
|
|
180
|
+
*
|
|
181
|
+
* `HttpOnly` keeps the token out of `document.cookie`, so an XSS in the
|
|
182
|
+
* client (or in a note's rendered Markdown) cannot exfiltrate it.
|
|
183
|
+
* `SameSite=Strict` means the browser will not attach it to any
|
|
184
|
+
* cross-site-initiated request at all, which is a second, independent answer
|
|
185
|
+
* to the CSRF that {@link checkOrigin} covers.
|
|
186
|
+
*
|
|
187
|
+
* No `Max-Age`: a session cookie dies with the browser session, and the
|
|
188
|
+
* server dies with the pi session, so persisting it would only widen the
|
|
189
|
+
* window in which a stale token exists.
|
|
190
|
+
*/
|
|
191
|
+
export function buildSetCookie(name: string, token: string): string {
|
|
192
|
+
const attrs = [`${name}=${token}`, "HttpOnly", "SameSite=Strict", "Path=/"];
|
|
193
|
+
// `Secure` is mandatory for `__Host-`; the fallback name exists precisely
|
|
194
|
+
// to be usable without it.
|
|
195
|
+
if (name.startsWith("__Host-")) attrs.push("Secure");
|
|
196
|
+
return attrs.join("; ");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// --- the decision ------------------------------------------------------------
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Everything the policy needs from a request. Four strings, so the matrix is
|
|
203
|
+
* table-testable without a socket.
|
|
204
|
+
*/
|
|
205
|
+
export interface RequestFacts {
|
|
206
|
+
method: string;
|
|
207
|
+
/** Request target as sent, e.g. `/api/graph?x=1`. Never a full URL. */
|
|
208
|
+
url: string;
|
|
209
|
+
host: string | undefined;
|
|
210
|
+
origin: string | undefined;
|
|
211
|
+
/** Raw `Cookie` header. */
|
|
212
|
+
cookie: string | undefined;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Minimal structural view of a Node request — avoids importing `http` types. */
|
|
216
|
+
export interface IncomingLike {
|
|
217
|
+
method?: string | undefined;
|
|
218
|
+
url?: string | undefined;
|
|
219
|
+
headers: Record<string, string | string[] | undefined>;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Read {@link RequestFacts} off a Node request.
|
|
224
|
+
*
|
|
225
|
+
* Node lower-cases header names and collapses duplicates for these four, but
|
|
226
|
+
* a duplicated `Host` still arrives as an array on some proxies; taking the
|
|
227
|
+
* first would let an attacker append an allowlisted value to a foreign one.
|
|
228
|
+
* An array is therefore treated as absent, which fails closed.
|
|
229
|
+
*/
|
|
230
|
+
export function requestFacts(req: IncomingLike): RequestFacts {
|
|
231
|
+
return {
|
|
232
|
+
method: req.method ?? "GET",
|
|
233
|
+
url: req.url ?? "/",
|
|
234
|
+
host: single(req.headers["host"]),
|
|
235
|
+
origin: single(req.headers["origin"]),
|
|
236
|
+
cookie: single(req.headers["cookie"]),
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function single(value: string | string[] | undefined): string | undefined {
|
|
241
|
+
return typeof value === "string" ? value : undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* What the server should do with a request.
|
|
246
|
+
*
|
|
247
|
+
* - `allow`: proceed to routing.
|
|
248
|
+
* - `handoff`: the URL carried a valid token; set the cookie and `302` to
|
|
249
|
+
* {@link SecurityDecision.location}, dropping the token from the address
|
|
250
|
+
* bar (and therefore from `Referer`, from shell history, and from
|
|
251
|
+
* anything that later reads the tab's URL).
|
|
252
|
+
* - `deny`: respond `403` with `reason`. The reason is for the server log
|
|
253
|
+
* and the test table, never for the response body — telling a prober
|
|
254
|
+
* which layer stopped them is free reconnaissance.
|
|
255
|
+
*/
|
|
256
|
+
export type SecurityDecision =
|
|
257
|
+
| { kind: "allow" }
|
|
258
|
+
| { kind: "handoff"; location: string; setCookie: string }
|
|
259
|
+
| { kind: "deny"; status: 403; reason: DenyReason };
|
|
260
|
+
|
|
261
|
+
export type DenyReason = "host" | "origin" | "token";
|
|
262
|
+
|
|
263
|
+
export interface SecurityOptions {
|
|
264
|
+
port: number;
|
|
265
|
+
/** Defaults to a fresh {@link generateToken}. */
|
|
266
|
+
token?: string;
|
|
267
|
+
/** Defaults to {@link DEFAULT_COOKIE_NAME}. */
|
|
268
|
+
cookieName?: string;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export interface SecurityPolicy {
|
|
272
|
+
readonly token: string;
|
|
273
|
+
readonly cookieName: string;
|
|
274
|
+
/** `http://127.0.0.1:PORT` — the canonical origin of this server. */
|
|
275
|
+
readonly origin: string;
|
|
276
|
+
/** The one-shot handoff URL to hand to a browser. */
|
|
277
|
+
readonly entryUrl: string;
|
|
278
|
+
authorize(facts: RequestFacts): SecurityDecision;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Bind the four layers to a port and a token.
|
|
283
|
+
*
|
|
284
|
+
* A factory rather than free functions with a port argument everywhere: the
|
|
285
|
+
* allowlists are derived from the port, and deriving them once at bind time
|
|
286
|
+
* makes it impossible to check a request against the wrong port — the class
|
|
287
|
+
* of bug where a server restarts on a new port and an allowlist captured
|
|
288
|
+
* earlier keeps accepting the old one.
|
|
289
|
+
*/
|
|
290
|
+
export function createSecurityPolicy(opts: SecurityOptions): SecurityPolicy {
|
|
291
|
+
const token = opts.token ?? generateToken();
|
|
292
|
+
const cookieName = opts.cookieName ?? DEFAULT_COOKIE_NAME;
|
|
293
|
+
const { port } = opts;
|
|
294
|
+
const origin = `http://127.0.0.1:${port}`;
|
|
295
|
+
const setCookie = buildSetCookie(cookieName, token);
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
token,
|
|
299
|
+
cookieName,
|
|
300
|
+
origin,
|
|
301
|
+
entryUrl: `${origin}/?${TOKEN_QUERY_PARAM}=${token}`,
|
|
302
|
+
|
|
303
|
+
authorize(facts: RequestFacts): SecurityDecision {
|
|
304
|
+
// Order matters: cheapest and most decisive first, and never leak
|
|
305
|
+
// whether a token was *nearly* right to a caller who failed layer 2.
|
|
306
|
+
if (!isAllowedHost(facts.host, port)) return { kind: "deny", status: 403, reason: "host" };
|
|
307
|
+
if (!checkOrigin(facts.method, facts.origin, port)) {
|
|
308
|
+
return { kind: "deny", status: 403, reason: "origin" };
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const cookies = parseCookies(facts.cookie);
|
|
312
|
+
const held = cookies[cookieName];
|
|
313
|
+
if (held !== undefined && timingSafeMatch(held, token)) return { kind: "allow" };
|
|
314
|
+
|
|
315
|
+
// No valid cookie. A GET may still be the one-shot handoff.
|
|
316
|
+
const query = queryToken(facts.url);
|
|
317
|
+
if (query !== null && facts.method === "GET" && timingSafeMatch(query, token)) {
|
|
318
|
+
return { kind: "handoff", location: stripTokenParam(facts.url), setCookie };
|
|
319
|
+
}
|
|
320
|
+
return { kind: "deny", status: 403, reason: "token" };
|
|
321
|
+
},
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* The `t` parameter of a request target, or `null`.
|
|
327
|
+
*
|
|
328
|
+
* Parsed against a fixed dummy base because `req.url` is a path, not an
|
|
329
|
+
* absolute URL, and `new URL` demands one. The base is never used for
|
|
330
|
+
* anything but parsing.
|
|
331
|
+
*/
|
|
332
|
+
function queryToken(url: string): string | null {
|
|
333
|
+
return parseTarget(url).searchParams.get(TOKEN_QUERY_PARAM);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* The same target with `t` removed — the `302` location.
|
|
338
|
+
*
|
|
339
|
+
* Other parameters survive, so a deep link like `/?t=…¬e=alpha` still
|
|
340
|
+
* lands on the note after the handoff.
|
|
341
|
+
*/
|
|
342
|
+
function stripTokenParam(url: string): string {
|
|
343
|
+
const parsed = parseTarget(url);
|
|
344
|
+
parsed.searchParams.delete(TOKEN_QUERY_PARAM);
|
|
345
|
+
const query = parsed.searchParams.toString();
|
|
346
|
+
return query.length > 0 ? `${parsed.pathname}?${query}` : parsed.pathname;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** Dummy origin for parsing an origin-form request target. */
|
|
350
|
+
const PARSE_BASE = "http://weave.invalid";
|
|
351
|
+
|
|
352
|
+
function parseTarget(url: string): URL {
|
|
353
|
+
try {
|
|
354
|
+
return new URL(url, PARSE_BASE);
|
|
355
|
+
} catch {
|
|
356
|
+
// `new URL` is remarkably hard to break with an origin-form target, but
|
|
357
|
+
// the request line is attacker-controlled and a throw here would be a
|
|
358
|
+
// 500 on an unauthenticated path. Fail closed to a token-less root.
|
|
359
|
+
return new URL("/", PARSE_BASE);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The loopback workspace server — bind, lifecycle, teardown
|
|
3
|
+
* (weave-workspace §5.1, §5.4).
|
|
4
|
+
*
|
|
5
|
+
* This module owns three things and delegates everything else:
|
|
6
|
+
*
|
|
7
|
+
* 1. **Binding.** `listen(0, "127.0.0.1")` — loopback only, ephemeral port.
|
|
8
|
+
* Never a fixed port: a fixed port is a port another process can squat
|
|
9
|
+
* before us, and a port a malicious page can guess without scanning.
|
|
10
|
+
* 2. **Composition.** It builds the {@link RouteDeps} — a
|
|
11
|
+
* {@link WorkspaceCache}, a {@link SecurityPolicy}, and whichever hub and
|
|
12
|
+
* watcher it was handed — and passes them to `handleRequest`.
|
|
13
|
+
* 3. **Lifecycle.** Idle shutdown 30 minutes after the last SSE client
|
|
14
|
+
* disconnects, and a `close()` that actually releases the port.
|
|
15
|
+
*
|
|
16
|
+
* ## Why the hub and the watcher are constructor arguments
|
|
17
|
+
*
|
|
18
|
+
* `SseHub` and `Watcher` are declared in `routes.ts` (the consumer) and
|
|
19
|
+
* arrive here as optional options. That is not ceremony: it means the server
|
|
20
|
+
* can be booted with neither — which is exactly what the route tests do, so
|
|
21
|
+
* they exercise the real socket and the real security policy without a file
|
|
22
|
+
* watcher spinning up on a temp directory, and it means the SSE
|
|
23
|
+
* implementation can be written, replaced, or omitted with no edit to this
|
|
24
|
+
* file. With no hub, `/events` answers `503` and the server has no idle
|
|
25
|
+
* timer to run, because nothing can be idle.
|
|
26
|
+
*
|
|
27
|
+
* ## Idle shutdown, precisely
|
|
28
|
+
*
|
|
29
|
+
* The timer starts when the SSE client count reaches zero and is cancelled
|
|
30
|
+
* when a client attaches. A workspace with a tab open is never idle; a
|
|
31
|
+
* workspace whose last tab closed is reclaimed after
|
|
32
|
+
* {@link DEFAULT_IDLE_MS}. `setTimer` is injectable, so the test asserts the
|
|
33
|
+
* transition rather than waiting thirty minutes for it, and
|
|
34
|
+
* `timer.unref?.()` keeps a pending shutdown from holding the process open.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import { randomBytes } from "node:crypto";
|
|
38
|
+
import { createServer as createHttpServer, type Server } from "node:http";
|
|
39
|
+
import { fileURLToPath } from "node:url";
|
|
40
|
+
import { WorkspaceCache } from "../../core/cache/workspace";
|
|
41
|
+
import { resolveVaultRoot } from "../../core/paths";
|
|
42
|
+
import { handleRequest, type RouteDeps, type SseHub, type Watcher } from "./routes";
|
|
43
|
+
import { createSecurityPolicy, type SecurityPolicy } from "./security";
|
|
44
|
+
|
|
45
|
+
export type { SseHub, Watcher } from "./routes";
|
|
46
|
+
|
|
47
|
+
/** §5.4: reclaim the port 30 minutes after the last SSE client disconnects. */
|
|
48
|
+
export const DEFAULT_IDLE_MS = 30 * 60 * 1000;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The committed bundle, resolved relative to this module rather than to
|
|
52
|
+
* `process.cwd()` — the server runs in whatever directory the user invoked
|
|
53
|
+
* pi from, which is never the package root.
|
|
54
|
+
*/
|
|
55
|
+
export function defaultBundlePath(): string {
|
|
56
|
+
return fileURLToPath(new URL("../client/dist/app.js", import.meta.url));
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A `setTimeout` shaped seam, so the idle path is testable in microseconds. */
|
|
60
|
+
export interface TimerHandle {
|
|
61
|
+
unref?(): unknown;
|
|
62
|
+
}
|
|
63
|
+
export type SetTimer = (fn: () => void, ms: number) => TimerHandle;
|
|
64
|
+
export type ClearTimer = (handle: TimerHandle) => void;
|
|
65
|
+
|
|
66
|
+
export interface StartWorkspaceServerOptions {
|
|
67
|
+
cwd: string;
|
|
68
|
+
/** Defaults to `resolveVaultRoot()`, which honours `PI_WEAVE_VAULT`. */
|
|
69
|
+
vaultRoot?: string;
|
|
70
|
+
/**
|
|
71
|
+
* Live-update hub. Absent → `/events` answers `503` and the idle timer
|
|
72
|
+
* never runs. Supplied by the caller that also constructs the watcher.
|
|
73
|
+
*/
|
|
74
|
+
sse?: SseHub | undefined;
|
|
75
|
+
/** File watcher. Started during boot, closed during teardown. */
|
|
76
|
+
watcher?: Watcher | undefined;
|
|
77
|
+
/** Pre-built cache. Defaults to a fresh one over `cwd` + `vaultRoot`. */
|
|
78
|
+
cache?: WorkspaceCache | undefined;
|
|
79
|
+
/** Fixed token, for tests that need to know it before the boot resolves. */
|
|
80
|
+
token?: string | undefined;
|
|
81
|
+
/** Cookie name override — the §5.1 footnote-1 fallback. */
|
|
82
|
+
cookieName?: string | undefined;
|
|
83
|
+
/** Absolute path to the client bundle. Defaults to the committed one. */
|
|
84
|
+
bundlePath?: string | undefined;
|
|
85
|
+
/** Test seam for `POST /api/open`. */
|
|
86
|
+
openNote?: ((slug: string) => Promise<boolean>) | undefined;
|
|
87
|
+
/** Test seam for the note read; see {@link RouteDeps.readNote}. */
|
|
88
|
+
readNote?: RouteDeps["readNote"];
|
|
89
|
+
/**
|
|
90
|
+
* Self-write suppression (§6): tell the watcher to ignore a path it is
|
|
91
|
+
* about to see us change.
|
|
92
|
+
*
|
|
93
|
+
* A separate option rather than a method on {@link Watcher}, because
|
|
94
|
+
* `Watcher` here is the two-method lifecycle contract `routes.ts` declares
|
|
95
|
+
* — start and close — and widening it would force every future watcher
|
|
96
|
+
* (a poller, a remote FS) to implement suppression whether or not the
|
|
97
|
+
* concept applies to it. The caller that constructs the real watcher knows
|
|
98
|
+
* it has a `suppress`, and passes it.
|
|
99
|
+
*
|
|
100
|
+
* Defaults to {@link Watcher.suppress} when the injected watcher has one,
|
|
101
|
+
* so the common wiring is automatic and the option exists for a caller
|
|
102
|
+
* whose suppression lives somewhere else (or for a test that wants to
|
|
103
|
+
* observe it). Absent on both means writes happen unsuppressed, which is
|
|
104
|
+
* exactly right for a server booted with no watcher: there is nothing
|
|
105
|
+
* listening to feed a loop.
|
|
106
|
+
*/
|
|
107
|
+
suppress?: ((absPath: string) => void) | undefined;
|
|
108
|
+
/** Idle shutdown delay. `0` disables it entirely. */
|
|
109
|
+
idleMs?: number | undefined;
|
|
110
|
+
setTimer?: SetTimer | undefined;
|
|
111
|
+
clearTimer?: ClearTimer | undefined;
|
|
112
|
+
/** Invoked when the idle timeout fires, after `close()`. */
|
|
113
|
+
onIdleShutdown?: (() => void) | undefined;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface WorkspaceServer {
|
|
117
|
+
/** Canonical base URL, e.g. `http://127.0.0.1:53217`. */
|
|
118
|
+
url: string;
|
|
119
|
+
/** The one-shot handoff URL: `url` plus `?t=TOKEN`. Give this to a browser. */
|
|
120
|
+
entryUrl: string;
|
|
121
|
+
port: number;
|
|
122
|
+
token: string;
|
|
123
|
+
security: SecurityPolicy;
|
|
124
|
+
cache: WorkspaceCache;
|
|
125
|
+
/** Random per-boot id, also embedded in the page bootstrap. */
|
|
126
|
+
session: string;
|
|
127
|
+
/**
|
|
128
|
+
* Called by the SSE hub whenever a client attaches or detaches, so the
|
|
129
|
+
* idle timer can be armed or cancelled. Exposed rather than inferred
|
|
130
|
+
* because only the hub knows when a socket actually died.
|
|
131
|
+
*/
|
|
132
|
+
noteActivity(): void;
|
|
133
|
+
/** Release the port, the watcher and every stream. Idempotent. */
|
|
134
|
+
close(): Promise<void>;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export async function startWorkspaceServer(opts: StartWorkspaceServerOptions): Promise<WorkspaceServer> {
|
|
138
|
+
const vaultRoot = opts.vaultRoot ?? resolveVaultRoot();
|
|
139
|
+
const cache = opts.cache ?? new WorkspaceCache({ cwd: opts.cwd, vaultRoot });
|
|
140
|
+
const session = randomBytes(8).toString("hex");
|
|
141
|
+
const setTimer: SetTimer = opts.setTimer ?? ((fn, ms) => setTimeout(fn, ms));
|
|
142
|
+
const clearTimer: ClearTimer = opts.clearTimer ?? ((h) => clearTimeout(h as NodeJS.Timeout));
|
|
143
|
+
const idleMs = opts.idleMs ?? DEFAULT_IDLE_MS;
|
|
144
|
+
|
|
145
|
+
// `deps` is assembled before `listen` because the request handler closes
|
|
146
|
+
// over it, but the security policy needs the bound port — so the policy
|
|
147
|
+
// slot is filled after binding and the handler reads it through the
|
|
148
|
+
// mutable holder rather than capturing a stale value.
|
|
149
|
+
// Explicit `suppress` first, else the watcher's own if it has one (§6).
|
|
150
|
+
// Bound as a closure rather than passed as a method reference, so a
|
|
151
|
+
// watcher implementing `suppress` as a class method keeps its `this`.
|
|
152
|
+
const watcherSuppress = opts.watcher?.suppress;
|
|
153
|
+
const suppress: ((absPath: string) => void) | null =
|
|
154
|
+
opts.suppress ?? (watcherSuppress === undefined ? null : (absPath: string) => watcherSuppress.call(opts.watcher, absPath));
|
|
155
|
+
|
|
156
|
+
let deps: RouteDeps | null = null;
|
|
157
|
+
const server: Server = createHttpServer((req, res) => {
|
|
158
|
+
// Unreachable in practice — `listen` resolves before any connection is
|
|
159
|
+
// accepted — but a request arriving with no deps would be a `500` with
|
|
160
|
+
// an incomprehensible stack, so answer honestly instead.
|
|
161
|
+
/* c8 ignore next 5 */
|
|
162
|
+
if (deps === null) {
|
|
163
|
+
res.writeHead(503, { "content-type": "text/plain; charset=utf-8" });
|
|
164
|
+
res.end("pi-weave: starting\n");
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
void handleRequest(deps, req, res);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
await listen(server);
|
|
171
|
+
const port = boundPort(server);
|
|
172
|
+
const security = createSecurityPolicy({
|
|
173
|
+
port,
|
|
174
|
+
...(opts.token !== undefined ? { token: opts.token } : {}),
|
|
175
|
+
...(opts.cookieName !== undefined ? { cookieName: opts.cookieName } : {}),
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
let closed = false;
|
|
179
|
+
let idleHandle: TimerHandle | null = null;
|
|
180
|
+
|
|
181
|
+
const cancelIdle = (): void => {
|
|
182
|
+
if (idleHandle !== null) {
|
|
183
|
+
clearTimer(idleHandle);
|
|
184
|
+
idleHandle = null;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const armIdle = (): void => {
|
|
189
|
+
// No hub means `/events` answers 503, so there is no such thing as a
|
|
190
|
+
// client here and nothing for a countdown to count. The server stays up
|
|
191
|
+
// until its owner closes it.
|
|
192
|
+
if (idleMs <= 0 || closed || opts.sse === undefined) return;
|
|
193
|
+
const handle = setTimer(() => {
|
|
194
|
+
idleHandle = null;
|
|
195
|
+
void close().then(() => opts.onIdleShutdown?.());
|
|
196
|
+
}, idleMs);
|
|
197
|
+
// A pending shutdown must not be the reason the process stays alive.
|
|
198
|
+
handle.unref?.();
|
|
199
|
+
idleHandle = handle;
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const noteActivity = (): void => {
|
|
203
|
+
cancelIdle();
|
|
204
|
+
// Zero clients right now means the countdown starts now. A hub that
|
|
205
|
+
// reports a live client cancels it and says nothing more until that
|
|
206
|
+
// client leaves. With no hub at all, `armIdle` is already a no-op — the
|
|
207
|
+
// guard lives there rather than being repeated here.
|
|
208
|
+
if (opts.sse === undefined || opts.sse.clientCount() === 0) armIdle();
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
const close = async (): Promise<void> => {
|
|
212
|
+
if (closed) return;
|
|
213
|
+
closed = true;
|
|
214
|
+
cancelIdle();
|
|
215
|
+
opts.sse?.close();
|
|
216
|
+
await opts.watcher?.close();
|
|
217
|
+
await new Promise<void>((resolve) => {
|
|
218
|
+
server.close(() => resolve());
|
|
219
|
+
// Without this, an open SSE stream keeps the server's handle alive and
|
|
220
|
+
// `close()` never resolves — the exact reason a pi session used to
|
|
221
|
+
// hang on shutdown.
|
|
222
|
+
server.closeAllConnections();
|
|
223
|
+
});
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
deps = {
|
|
227
|
+
cwd: opts.cwd,
|
|
228
|
+
vaultRoot,
|
|
229
|
+
session,
|
|
230
|
+
cache,
|
|
231
|
+
security,
|
|
232
|
+
bundlePath: opts.bundlePath ?? defaultBundlePath(),
|
|
233
|
+
...(opts.sse !== undefined ? { sse: opts.sse } : {}),
|
|
234
|
+
...(opts.openNote !== undefined ? { openNote: opts.openNote } : {}),
|
|
235
|
+
...(opts.readNote !== undefined ? { readNote: opts.readNote } : {}),
|
|
236
|
+
...(suppress === null ? {} : { suppress }),
|
|
237
|
+
onActivity: noteActivity,
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
await opts.watcher?.start();
|
|
241
|
+
|
|
242
|
+
// A workspace nobody has connected to yet is already idle: if the browser
|
|
243
|
+
// never opens, the port should still be reclaimed.
|
|
244
|
+
if (opts.sse !== undefined) armIdle();
|
|
245
|
+
|
|
246
|
+
return {
|
|
247
|
+
url: security.origin,
|
|
248
|
+
entryUrl: security.entryUrl,
|
|
249
|
+
port,
|
|
250
|
+
token: security.token,
|
|
251
|
+
security,
|
|
252
|
+
cache,
|
|
253
|
+
session,
|
|
254
|
+
noteActivity,
|
|
255
|
+
close,
|
|
256
|
+
};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Bind to an ephemeral loopback port, rejecting on a bind error. */
|
|
260
|
+
function listen(server: Server): Promise<void> {
|
|
261
|
+
return new Promise<void>((resolve, reject) => {
|
|
262
|
+
server.once("error", reject);
|
|
263
|
+
server.listen(0, "127.0.0.1", () => {
|
|
264
|
+
server.off("error", reject);
|
|
265
|
+
resolve();
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function boundPort(server: Server): number {
|
|
271
|
+
const address = server.address();
|
|
272
|
+
/* c8 ignore next -- `address()` is only a string for a unix socket, which we never bind. */
|
|
273
|
+
if (typeof address !== "object" || address === null) throw new Error("pi-weave: server did not bind a TCP port");
|
|
274
|
+
return address.port;
|
|
275
|
+
}
|