@jbarket/loomai 0.8.0 → 0.9.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.
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Read-only queries behind loom's own pages: memories with their heat
3
+ * explained, one memory by ref, the memories about a person. Plain SQL over
4
+ * memories.db opened read-only, like the digest; nothing here writes.
5
+ */
6
+ import { existsSync } from 'node:fs';
7
+ import BetterSqlite3 from 'better-sqlite3';
8
+ import { resolveSqliteDbPath } from '../config.js';
9
+ import { DEFAULT_HALF_LIFE_DAYS, HALF_LIVES, decayedRecallHeat, heatOf, pickForDigest, temperature, tierOf } from '../backends/salience.js';
10
+ const EPISODE = 'episode';
11
+ const COLUMNS = 'ref, title, category, project, content, sourcing, scope, created, updated, last_accessed, expires_at, times_seen, metadata, salience, recall_heat, recall_heat_at';
12
+ function open(contextDir) {
13
+ const path = resolveSqliteDbPath(contextDir);
14
+ if (!existsSync(path))
15
+ return null;
16
+ const db = new BetterSqlite3(path, { readonly: true });
17
+ db.pragma('busy_timeout = 5000');
18
+ return db;
19
+ }
20
+ function hasColumns(db) {
21
+ return new Set(db.pragma('table_info(memories)').map((c) => c.name));
22
+ }
23
+ /** Older stores lack some columns; select what exists and default the rest. */
24
+ function selectList(db) {
25
+ const have = hasColumns(db);
26
+ return COLUMNS.split(', ').map((c) => (have.has(c) ? c : `NULL AS ${c}`)).join(', ');
27
+ }
28
+ function personOf(metadata) {
29
+ try {
30
+ const m = JSON.parse(metadata || '{}');
31
+ return typeof m.person === 'string' && m.person ? m.person : null;
32
+ }
33
+ catch {
34
+ return null;
35
+ }
36
+ }
37
+ function view(r, nowMs, inDigest = false) {
38
+ const heat = heatOf(r, nowMs);
39
+ const writtenHeat = temperature(r.updated ?? r.created, r.category, nowMs);
40
+ const recallHeat = decayedRecallHeat(r.recall_heat, r.recall_heat_at, r.category, nowMs);
41
+ return {
42
+ ref: r.ref, title: r.title, category: r.category, project: r.project, content: r.content,
43
+ sourcing: r.sourcing, scope: r.scope, created: r.created, updated: r.updated,
44
+ lastAccessed: r.last_accessed, expiresAt: r.expires_at, timesSeen: r.times_seen ?? 1,
45
+ person: personOf(r.metadata), stored: r.salience ?? heat, heat, writtenHeat, recallHeat, tier: tierOf(heat),
46
+ halfLifeDays: HALF_LIVES[r.category] ?? DEFAULT_HALF_LIFE_DAYS, inDigest,
47
+ };
48
+ }
49
+ /** Live memories (not episodes) hottest first, each marked with whether the digest carries it. */
50
+ export function heatList(contextDir, opts = {}) {
51
+ const db = open(contextDir);
52
+ if (!db)
53
+ return { rows: [], total: 0, inDigest: 0 };
54
+ try {
55
+ const nowMs = opts.nowMs ?? Date.now();
56
+ const all = db.prepare(`SELECT ${selectList(db)} FROM memories WHERE archived = 0 AND category != ?`).all(EPISODE)
57
+ .map((r) => view(r, nowMs))
58
+ .sort((a, b) => b.heat - a.heat);
59
+ // The same selection the boot digest makes, so "in the digest" is exact.
60
+ const picked = new Set(pickForDigest(all.map((m) => ({ ...m, salience: m.heat }))).map((m) => m.ref));
61
+ for (const m of all)
62
+ m.inDigest = picked.has(m.ref);
63
+ const filtered = opts.category ? all.filter((m) => m.category === opts.category) : all;
64
+ return { rows: filtered.slice(0, opts.limit ?? 40), total: all.length, inDigest: picked.size };
65
+ }
66
+ finally {
67
+ db.close();
68
+ }
69
+ }
70
+ export function memoryByRef(contextDir, ref, nowMs = Date.now()) {
71
+ const db = open(contextDir);
72
+ if (!db)
73
+ return null;
74
+ try {
75
+ const r = db.prepare(`SELECT ${selectList(db)} FROM memories WHERE ref = ? AND archived = 0`).get(ref);
76
+ if (!r)
77
+ return null;
78
+ const m = view(r, nowMs);
79
+ if (m.category !== EPISODE) {
80
+ m.inDigest = heatList(contextDir, { limit: 400, nowMs }).rows.some((x) => x.ref === ref && x.inDigest);
81
+ }
82
+ return m;
83
+ }
84
+ finally {
85
+ db.close();
86
+ }
87
+ }
88
+ /** User memories tagged to a person, newest first. */
89
+ export function memoriesAbout(contextDir, slug, opts = {}) {
90
+ const db = open(contextDir);
91
+ if (!db)
92
+ return [];
93
+ try {
94
+ const nowMs = opts.nowMs ?? Date.now();
95
+ const rows = db.prepare(`SELECT ${selectList(db)} FROM memories
96
+ WHERE category = 'user' AND archived = 0 AND json_extract(metadata, '$.person') = ?
97
+ AND (expires_at IS NULL OR expires_at > ?) AND (? IS NULL OR created > ?)
98
+ ORDER BY created DESC LIMIT ?`).all(slug, new Date(nowMs).toISOString(), opts.since ?? null, opts.since ?? null, opts.limit ?? 20);
99
+ return rows.map((r) => view(r, nowMs));
100
+ }
101
+ finally {
102
+ db.close();
103
+ }
104
+ }
105
+ /** Episodes in the last `hours`, newest first, with where each body was. */
106
+ export function tapeRows(contextDir, hours, nowMs = Date.now()) {
107
+ const db = open(contextDir);
108
+ if (!db)
109
+ return [];
110
+ try {
111
+ const since = new Date(nowMs - hours * 3_600_000).toISOString();
112
+ const rows = db.prepare(`SELECT ref, title, created, project, metadata FROM memories
113
+ WHERE category = ? AND archived = 0 AND created >= ? AND (expires_at IS NULL OR expires_at > ?)
114
+ ORDER BY created DESC`).all(EPISODE, since, new Date(nowMs).toISOString());
115
+ return rows.map((r) => {
116
+ let where = r.project?.trim() || '?';
117
+ try {
118
+ const m = JSON.parse(r.metadata || '{}');
119
+ if (typeof m.where === 'string' && m.where.trim())
120
+ where = m.where.trim();
121
+ }
122
+ catch { /* keep the fallback */ }
123
+ return { ref: r.ref, title: r.title, created: r.created, where };
124
+ });
125
+ }
126
+ finally {
127
+ db.close();
128
+ }
129
+ }
@@ -6,6 +6,7 @@ export interface UiOptions {
6
6
  /** Env for piece discovery and piece processes (PATH decides which pieces exist). */
7
7
  env: NodeJS.ProcessEnv;
8
8
  }
9
- export declare function isUiPath(pathname: string): boolean;
10
- /** Handle a /ui request. The caller has already matched isUiPath. */
9
+ /** Whether a request is for the page (see the header for how `/` is shared with MCP). */
10
+ export declare function isUiRequest(req: IncomingMessage, pathname: string): boolean;
11
+ /** Handle a request for the page. The caller has already matched isUiRequest. */
11
12
  export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
@@ -1,26 +1,49 @@
1
1
  /**
2
- * loom's web UI on the HTTP daemon (full scope only): GET /ui, the page;
3
- * GET /ui/sections.json, the same sections as data; /ui/login and /ui/logout.
2
+ * loom's web UI on the HTTP daemon (full scope only).
3
+ *
4
+ * GET / Home: the tape, tiles, "Your call"
5
+ * GET /mind, /people[/<slug>], /memory/<ref> loom's own pages
6
+ * GET /p/<piece>[/<path>] a piece's page
7
+ * GET /pieces.json the installed pieces' manifests, for scripts
8
+ * POST /act/<source> an action (source = loom or a piece)
9
+ * GET/POST /login, POST /logout
10
+ * /ui/… the old address: redirects to the same page at the root
11
+ *
12
+ * The page shares the daemon with MCP, which answers on any other path. `/`
13
+ * is the page only for a plain browser GET: a POST, a request with an MCP
14
+ * session, or one asking for an event stream is MCP's.
4
15
  *
5
16
  * Auth follows the daemon's bearer token, and the UI needs one: a daemon with
6
17
  * no token serves no /ui (like /cli), because an open page would be readable
7
18
  * by any site that DNS-rebinds a name to the loopback address. A browser logs
8
- * in once by pasting the token. The cookie is `<issued-at>.<mac>`, a MAC under
9
- * a key this process made at start, over the issued-at time and a digest of
10
- * the token. It isn't the token and isn't derivable from it, so it can't be
11
- * replayed as a bearer or used to guess the token offline. It lasts 30 days,
12
- * or until the daemon restarts (log in again). Scripts can send the bearer
13
- * header instead. The page is read-only, has no script, and is served under a
14
- * CSP that forbids script and framing.
19
+ * in once by pasting the token and gets a session: a random id held in this
20
+ * process's memory. It isn't the token and isn't derivable from it, so it
21
+ * can't be replayed as a bearer or used to guess the token. It lasts 30 days,
22
+ * until logout (which revokes it), until the token changes, or until the
23
+ * daemon restarts. Scripts can send the bearer header instead.
24
+ *
25
+ * A GET from another site or another port is served only as a top-level
26
+ * navigation, never to an <img> or a fetch (fetch metadata).
27
+ *
28
+ * Every POST from a browser carries an anti-forgery token derived from its
29
+ * cookie, and a cross-site POST is refused outright (Sec-Fetch-Site / Origin),
30
+ * on top of the SameSite=Strict cookie. The page runs no script and is served
31
+ * under a CSP that forbids script and framing. Action fields go to the piece
32
+ * on stdin and are never logged, stored, or put in a URL; the one-time result
33
+ * message is kept in memory under a random id for a minute.
15
34
  */
16
35
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
36
  import { checkBearer } from '../transport/guards.js';
18
- import { agentName, coreSections, uiTimeZone } from './core.js';
19
- import { pieceSections } from './piece-sections.js';
20
- import { renderLogin, renderPage } from './render.js';
37
+ import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
38
+ import { pieceAct, pieceManifests, piecePage } from './pieces.js';
39
+ import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
40
+ import { ID, LIMITS, QUERY_KEY, splitPage, validPath } from './vocab.js';
21
41
  const COOKIE = 'loom_ui';
22
42
  const LOGIN_MAX_BYTES = 4096;
43
+ const ACT_MAX_BYTES = 64 * 1024;
23
44
  const COOKIE_MAX_AGE_S = 30 * 24 * 3600;
45
+ const FLASH_TTL_MS = 60_000;
46
+ const FLASH_MAX = 200;
24
47
  /** This process's cookie key. Never written anywhere: a restart means logging in again. */
25
48
  const COOKIE_KEY = randomBytes(32);
26
49
  const SECURITY_HEADERS = {
@@ -29,28 +52,51 @@ const SECURITY_HEADERS = {
29
52
  'referrer-policy': 'no-referrer',
30
53
  'cache-control': 'no-store',
31
54
  };
32
- export function isUiPath(pathname) {
33
- return pathname === '/ui' || pathname.startsWith('/ui/');
55
+ const PAGE_ROOTS = ['/login', '/logout', '/pieces.json', '/mind', '/people', '/memory', '/p', '/act'];
56
+ /** Whether a request is for the page (see the header for how `/` is shared with MCP). */
57
+ export function isUiRequest(req, pathname) {
58
+ if (pathname === '/ui' || pathname.startsWith('/ui/'))
59
+ return true;
60
+ if (pathname === '/') {
61
+ if (req.method !== 'GET' && req.method !== 'HEAD')
62
+ return false;
63
+ if (req.headers['mcp-session-id'] !== undefined)
64
+ return false;
65
+ return !/text\/event-stream/i.test(req.headers.accept ?? '');
66
+ }
67
+ return PAGE_ROOTS.some((r) => pathname === r || pathname.startsWith(`${r}/`));
34
68
  }
35
69
  const digest = (s) => createHash('sha256').update(s).digest();
36
- function mac(issuedAt, secret) {
37
- return createHmac('sha256', COOKIE_KEY).update(`loom-ui-v2|${issuedAt}|`).update(digest(secret)).digest('hex');
70
+ const sessions = new Map();
71
+ const MAX_SESSIONS = 50;
72
+ function newSession(secret) {
73
+ const id = randomBytes(32).toString('hex');
74
+ sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: digest(secret) });
75
+ // Oldest first in a Map: drop the oldest past the cap.
76
+ while (sessions.size > MAX_SESSIONS)
77
+ sessions.delete(sessions.keys().next().value);
78
+ return id;
38
79
  }
39
- function cookieValue(secret, issuedAt = Math.floor(Date.now() / 1000)) {
40
- return `${issuedAt}.${mac(issuedAt, secret)}`;
80
+ /** The anti-forgery token for a session: bound to its id, useless without it. */
81
+ function csrfFor(id) {
82
+ return createHmac('sha256', COOKIE_KEY).update(`loom-ui-csrf|${id}`).digest('hex');
41
83
  }
42
84
  /** Equal-length digests, so neither the content nor the length of a guess leaks. */
43
85
  function safeEq(a, b) {
44
86
  return timingSafeEqual(digest(a), digest(b));
45
87
  }
46
- function validCookie(value, secrets, nowS = Math.floor(Date.now() / 1000)) {
47
- const m = /^(\d{1,12})\.([0-9a-f]{64})$/.exec(value);
48
- if (!m)
88
+ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
89
+ if (!/^[0-9a-f]{64}$/.test(id))
49
90
  return false;
50
- const issuedAt = Number(m[1]);
51
- if (issuedAt > nowS + 60 || nowS - issuedAt > COOKIE_MAX_AGE_S)
91
+ const s = sessions.get(id);
92
+ if (!s)
52
93
  return false;
53
- return secrets.some((s) => safeEq(m[2], mac(issuedAt, s)));
94
+ if (nowS - s.issuedAt > COOKIE_MAX_AGE_S) {
95
+ sessions.delete(id);
96
+ return false;
97
+ }
98
+ // A session dies with its token: rotate the token and old sessions stop.
99
+ return secrets.some((t) => timingSafeEqual(digest(t), s.secret));
54
100
  }
55
101
  function readCookie(req, name) {
56
102
  for (const part of (req.headers.cookie ?? '').split(';')) {
@@ -60,14 +106,51 @@ function readCookie(req, name) {
60
106
  }
61
107
  return undefined;
62
108
  }
63
- function authorized(req, opts) {
109
+ function whoIs(req, opts) {
64
110
  if (opts.secrets.length === 0)
65
- return false;
111
+ return null;
66
112
  const c = readCookie(req, COOKIE);
67
- if (c && validCookie(c, opts.secrets))
68
- return true;
113
+ if (c && validSession(c, opts.secrets))
114
+ return { kind: 'session', cookie: c };
69
115
  const header = req.headers['authorization'];
70
- return header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok);
116
+ if (header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok))
117
+ return { kind: 'bearer' };
118
+ return null;
119
+ }
120
+ /**
121
+ * A GET from somewhere else is fine as a top-level navigation (a link in a
122
+ * chat) and refused otherwise: an <img> or fetch from another site, or from
123
+ * another port on this host (same-site, so the cookie rides along), must not
124
+ * make loom run a piece.
125
+ */
126
+ function allowedGet(req) {
127
+ const site = req.headers['sec-fetch-site'];
128
+ if (site === undefined || site === 'same-origin' || site === 'none')
129
+ return true;
130
+ return req.headers['sec-fetch-mode'] === 'navigate' && req.headers['sec-fetch-dest'] === 'document';
131
+ }
132
+ /**
133
+ * A browser POST must come from loom's own page. Sec-Fetch-Site is set by
134
+ * every current browser and can't be forged by a page; Origin, when sent,
135
+ * must name this host. Either one saying "elsewhere" is a refusal.
136
+ */
137
+ function sameOrigin(req) {
138
+ const site = req.headers['sec-fetch-site'];
139
+ if (site !== undefined && site !== 'same-origin' && site !== 'none')
140
+ return false;
141
+ const origin = req.headers.origin;
142
+ if (origin !== undefined) {
143
+ if (origin === 'null')
144
+ return false;
145
+ try {
146
+ if (new URL(origin).host !== req.headers.host)
147
+ return false;
148
+ }
149
+ catch {
150
+ return false;
151
+ }
152
+ }
153
+ return true;
71
154
  }
72
155
  function send(res, status, type, body, extra = {}) {
73
156
  res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
@@ -77,9 +160,10 @@ function redirect(res, to, extra = {}) {
77
160
  res.writeHead(303, { location: to, ...SECURITY_HEADERS, ...extra });
78
161
  res.end();
79
162
  }
80
- function readForm(req) {
163
+ function readForm(req, max) {
81
164
  return new Promise((resolve) => {
82
- let body = '';
165
+ const chunks = [];
166
+ let bytes = 0;
83
167
  let done = false;
84
168
  const finish = (v) => { if (!done) {
85
169
  done = true;
@@ -88,72 +172,253 @@ function readForm(req) {
88
172
  req.on('data', (d) => {
89
173
  if (done)
90
174
  return;
91
- body += d.toString('utf-8');
92
- if (body.length > LOGIN_MAX_BYTES) {
175
+ bytes += d.length;
176
+ if (bytes > max) {
93
177
  finish(null);
94
178
  req.destroy();
179
+ return;
95
180
  }
181
+ chunks.push(d);
96
182
  });
97
- req.on('end', () => finish(new URLSearchParams(body)));
183
+ req.on('end', () => finish(new URLSearchParams(Buffer.concat(chunks).toString('utf-8'))));
98
184
  req.on('error', () => finish(null));
99
185
  req.on('close', () => finish(null));
100
186
  });
101
187
  }
102
- async function sections(opts) {
103
- const core = coreSections(opts.contextDir);
104
- const { contributed, skipped } = await pieceSections(opts.env);
105
- return { core, pieces: contributed, skipped };
188
+ /** The page's own query: valid keys only, loom's `_` keys and oversize values dropped. */
189
+ function pageQuery(url) {
190
+ const q = Object.create(null);
191
+ let n = 0;
192
+ for (const [k, v] of url.searchParams) {
193
+ if (!QUERY_KEY.test(k) || v.length > LIMITS.queryValue || Object.hasOwn(q, k))
194
+ continue;
195
+ if (++n > LIMITS.query)
196
+ break;
197
+ q[k] = v;
198
+ }
199
+ return q;
200
+ }
201
+ const flashes = new Map();
202
+ function putFlash(flash) {
203
+ const now = Date.now();
204
+ for (const [k, v] of flashes)
205
+ if (now - v.at > FLASH_TTL_MS || flashes.size > FLASH_MAX)
206
+ flashes.delete(k);
207
+ const id = randomBytes(12).toString('hex');
208
+ flashes.set(id, { at: now, flash });
209
+ return id;
106
210
  }
107
- /** Handle a /ui request. The caller has already matched isUiPath. */
211
+ function takeFlash(id) {
212
+ if (!id)
213
+ return undefined;
214
+ const f = flashes.get(id);
215
+ flashes.delete(id);
216
+ return f && Date.now() - f.at <= FLASH_TTL_MS ? f.flash : undefined;
217
+ }
218
+ // ─── Assembling a page ───────────────────────────────────────────────────────
219
+ function clock() {
220
+ return new Intl.DateTimeFormat('en-US', { timeZone: uiTimeZone(), weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date());
221
+ }
222
+ function hasPages(p) {
223
+ return Boolean(p.manifest.tab || p.manifest.legacy?.length);
224
+ }
225
+ function chrome(opts, pieces, skipped, current, csrf, flash) {
226
+ const tabs = pieces.filter(hasPages)
227
+ .map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
228
+ .sort((a, b) => a.label.localeCompare(b.label));
229
+ return {
230
+ agent: agentName(opts.contextDir),
231
+ nav: [
232
+ { label: 'Home', href: '/', current: current === '', piece: false },
233
+ { label: 'Mind', href: '/mind', current: current === 'mind', piece: false },
234
+ { label: 'People', href: '/people', current: current === 'people', piece: false },
235
+ ...tabs,
236
+ ],
237
+ clock: clock(),
238
+ csrf,
239
+ flash,
240
+ skipped,
241
+ };
242
+ }
243
+ /** Where to land after an action: the page the piece names, or back where the form was. */
244
+ function landing(source, result, back) {
245
+ const s = (result.page !== undefined ? splitPage(result.page) : null) ?? splitPage(back);
246
+ const core = s?.core || source.id === 'loom';
247
+ const path = s?.path ?? '';
248
+ const q = s ? new URLSearchParams(s.query).toString() : '';
249
+ const base = core ? `/${path}` : `/p/${source.id}${path ? `/${path}` : ''}`;
250
+ return q ? `${base}?${q}` : base;
251
+ }
252
+ function withFlash(to, id) {
253
+ return `${to}${to.includes('?') ? '&' : '?'}_flash=${id}`;
254
+ }
255
+ const notFound = { title: 'Not found', sections: [] };
256
+ /** decodeURIComponent, or null for a malformed escape (a 404, never a 500). */
257
+ function decode(s) {
258
+ try {
259
+ return decodeURIComponent(s);
260
+ }
261
+ catch {
262
+ return null;
263
+ }
264
+ }
265
+ /** Handle a request for the page. The caller has already matched isUiRequest. */
108
266
  export async function handleUi(req, res, pathname, opts) {
109
267
  // No token, no page: an open /ui would be readable by any site that
110
268
  // DNS-rebinds a name to the loopback address (the same rule as /cli).
111
269
  if (opts.secrets.length === 0) {
112
270
  return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
113
271
  }
272
+ // The page used to live under /ui. Old links and bookmarks land on the same page.
273
+ if (pathname === '/ui' || pathname.startsWith('/ui/')) {
274
+ const to = new URL(req.url ?? pathname, 'http://loom.invalid');
275
+ // One leading slash, never two: `/ui//evil.example` must not become a
276
+ // protocol-relative `//evil.example`.
277
+ const rest = '/' + pathname.slice(3).replace(/^[/\\]+/, '');
278
+ res.writeHead(308, { location: rest + to.search, ...SECURITY_HEADERS });
279
+ return void res.end();
280
+ }
114
281
  const secure = req.headers['x-forwarded-proto'] === 'https';
115
- if (pathname === '/ui/login') {
282
+ if (pathname === '/login') {
116
283
  // Unauthenticated: the login page names no agent.
117
284
  if (req.method === 'GET')
118
285
  return send(res, 200, 'text/html; charset=utf-8', renderLogin());
119
286
  if (req.method !== 'POST')
120
287
  return send(res, 405, 'text/plain', 'method not allowed');
121
- const form = await readForm(req);
288
+ if (!sameOrigin(req))
289
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
290
+ const form = await readForm(req, LOGIN_MAX_BYTES);
122
291
  if (!form)
123
292
  return send(res, 413, 'text/plain', 'too large');
124
293
  const token = form.get('token')?.trim() ?? '';
125
294
  const match = opts.secrets.find((s) => safeEq(token, s));
126
295
  if (!match)
127
296
  return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
128
- return redirect(res, '/ui', {
129
- 'set-cookie': `${COOKIE}=${cookieValue(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
297
+ return redirect(res, '/', {
298
+ 'set-cookie': `${COOKIE}=${newSession(match)}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
130
299
  });
131
300
  }
132
- if (pathname === '/ui/logout') {
301
+ const who = whoIs(req, opts);
302
+ const csrf = who?.kind === 'session' ? csrfFor(who.cookie) : '';
303
+ if (pathname === '/logout') {
133
304
  if (req.method !== 'POST')
134
305
  return send(res, 405, 'text/plain', 'method not allowed');
135
- return redirect(res, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
306
+ if (!sameOrigin(req))
307
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
308
+ // Revoke it server-side, so a copy of the cookie stops working too.
309
+ const c = readCookie(req, COOKIE);
310
+ if (c)
311
+ sessions.delete(c);
312
+ return redirect(res, '/login', { 'set-cookie': `${COOKIE}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0` });
313
+ }
314
+ if (!who) {
315
+ if (pathname.endsWith('.json'))
316
+ return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
317
+ if (req.method === 'GET')
318
+ return redirect(res, '/login');
319
+ return send(res, 401, 'text/plain', 'unauthorized');
320
+ }
321
+ const url = new URL(req.url ?? pathname, 'http://loom.invalid');
322
+ if (pathname.startsWith('/act/')) {
323
+ if (req.method !== 'POST')
324
+ return send(res, 405, 'text/plain', 'method not allowed');
325
+ if (!sameOrigin(req))
326
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
327
+ const sourceId = pathname.slice('/act/'.length);
328
+ if (sourceId !== 'loom' && !ID.test(sourceId))
329
+ return send(res, 404, 'text/plain', 'not found');
330
+ const form = await readForm(req, ACT_MAX_BYTES);
331
+ if (!form)
332
+ return send(res, 413, 'text/plain', 'too large');
333
+ // A browser session must prove the form came from loom's page. A bearer
334
+ // script has no ambient credential a forger could ride, so it needs none.
335
+ if (who.kind === 'session' && !safeEq(form.get('_csrf') ?? '', csrf)) {
336
+ return send(res, 403, 'text/plain', 'forbidden: this form is stale or not from loom\'s page; reload and try again');
337
+ }
338
+ const action = form.get('_action') ?? '';
339
+ const back = form.get('_path') ?? '';
340
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(action))
341
+ return send(res, 400, 'text/plain', 'bad action');
342
+ const backLink = splitPage(back) ? back : '';
343
+ // A form has up to LIMITS.fields fields plus LIMITS.hidden hidden values.
344
+ // More than that is refused whole, never cut short: a truncated form
345
+ // could drop the very field (a secret) the piece needs.
346
+ const fields = Object.create(null);
347
+ for (const [k, v] of form) {
348
+ if (k.startsWith('_') || !/^[a-z][a-z0-9_-]{0,39}$/.test(k) || Object.hasOwn(fields, k))
349
+ continue;
350
+ fields[k] = v;
351
+ }
352
+ if (Object.keys(fields).length > LIMITS.fields + LIMITS.hidden)
353
+ return send(res, 400, 'text/plain', 'too many fields');
354
+ let source;
355
+ let result;
356
+ if (sourceId === 'loom') {
357
+ source = CORE;
358
+ result = await coreAct(opts.contextDir, action, fields);
359
+ }
360
+ else {
361
+ const { pieces } = await pieceManifests(opts.env);
362
+ const info = pieces.find((p) => p.piece === sourceId);
363
+ if (!info)
364
+ return send(res, 404, 'text/plain', 'not found');
365
+ source = pieceSource(info.piece);
366
+ const path = splitPage(backLink)?.path ?? '';
367
+ result = await pieceAct(info, { action, path, fields }, opts.env);
368
+ }
369
+ const to = landing(source, result, backLink);
370
+ // A piece's message is labeled with the piece, so it can't pass as loom's.
371
+ const from = source.id === 'loom' ? undefined : source.id;
372
+ return redirect(res, withFlash(to, putFlash({ ok: result.ok, message: result.message, from })));
136
373
  }
137
- const agent = agentName(opts.contextDir);
138
374
  if (req.method !== 'GET')
139
375
  return send(res, 405, 'text/plain', 'method not allowed');
140
- if (pathname === '/ui/sections.json') {
141
- if (!authorized(req, opts))
142
- return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
143
- const s = await sections(opts);
144
- return send(res, 200, 'application/json', JSON.stringify({ agent, ...s }));
145
- }
146
- if (pathname === '/ui' || pathname === '/ui/') {
147
- if (!authorized(req, opts))
148
- return redirect(res, '/ui/login');
149
- const s = await sections(opts);
150
- return send(res, 200, 'text/html; charset=utf-8', renderPage({
151
- agent,
152
- core: s.core,
153
- pieces: s.pieces,
154
- skipped: s.skipped,
155
- generatedAt: new Date().toLocaleTimeString('en-US', { timeZone: uiTimeZone(), hour: '2-digit', minute: '2-digit' }),
376
+ if (!allowedGet(req))
377
+ return send(res, 403, 'text/plain', 'forbidden: loom\'s page can only be opened, not embedded or fetched from another site');
378
+ if (pathname === '/pieces.json') {
379
+ const m = await pieceManifests(opts.env);
380
+ return send(res, 200, 'application/json', JSON.stringify({
381
+ agent: agentName(opts.contextDir),
382
+ pieces: m.pieces.map((p) => ({ piece: p.piece, manifest: p.manifest })),
383
+ skipped: m.skipped,
156
384
  }));
157
385
  }
158
- return send(res, 404, 'text/plain', 'not found');
386
+ const query = pageQuery(url);
387
+ const flash = takeFlash(url.searchParams.get('_flash'));
388
+ const { pieces, skipped } = await pieceManifests(opts.env);
389
+ const skippedNames = skipped.map((s) => s.piece);
390
+ const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
391
+ if (pathname === '/') {
392
+ const home = homePage(opts.contextDir, query);
393
+ const tiles = [
394
+ { ...home.tile, source: CORE },
395
+ ...pieces.filter((p) => p.manifest.tile).map((p) => ({ ...p.manifest.tile, source: pieceSource(p.piece) })),
396
+ ];
397
+ const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
398
+ return html(renderHome(home.page, { source: CORE, path: '', query, csrf }, chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
399
+ }
400
+ if (pathname.startsWith('/p/')) {
401
+ const rest = pathname.slice('/p/'.length);
402
+ const slash = rest.indexOf('/');
403
+ const piece = slash === -1 ? rest : rest.slice(0, slash);
404
+ const path = slash === -1 ? '' : decode(rest.slice(slash + 1));
405
+ const info = pieces.find((p) => p.piece === piece && hasPages(p));
406
+ const ch = chrome(opts, pieces, skippedNames, `p/${piece}`, csrf, flash);
407
+ if (!info || path === null || !validPath(path))
408
+ return html(renderPage(notFound, { source: pieceSource(piece), path: '', query: {}, csrf }, ch), 404);
409
+ const page = await piecePage(info, path, query, opts.env);
410
+ const ctx = { source: pieceSource(piece), path, query, csrf };
411
+ if (typeof page === 'string') {
412
+ return html(renderPage({ title: info.manifest.tab?.label ?? piece, sections: [{ id: 'error', blocks: [{ type: 'callout', tone: 'bad', text: `${piece} couldn't show this page (${page}).` }] }] }, ctx, ch), 502);
413
+ }
414
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
415
+ }
416
+ const path = decode(pathname.slice(1)) ?? '\0';
417
+ const top = path.split('/')[0];
418
+ const ch = chrome(opts, pieces, skippedNames, top, csrf, flash);
419
+ const page = validPath(path) ? corePage(opts.contextDir, path, query) : null;
420
+ const ctx = { source: CORE, path: validPath(path) ? path : '', query, csrf };
421
+ if (!page)
422
+ return html(renderPage(notFound, ctx, ch), 404);
423
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
159
424
  }
@@ -0,0 +1,37 @@
1
+ import { type ActResult, type Manifest, type Page } from './vocab.js';
2
+ export declare const PIECE_TIMEOUT_MS = 3000;
3
+ export declare const ACT_TIMEOUT_MS = 15000;
4
+ export declare const PIECE_MAX_BYTES: number;
5
+ /** Longer than the page's 30s refresh, so an open tab doesn't re-run every piece each time. */
6
+ export declare const CACHE_MS = 45000;
7
+ export interface PieceInfo {
8
+ piece: string;
9
+ path: string;
10
+ manifest: Manifest;
11
+ }
12
+ /** Why a piece contributed nothing, for the page footer (never shown as an error). */
13
+ export interface PieceSkip {
14
+ piece: string;
15
+ reason: string;
16
+ }
17
+ export declare function clearPieceCache(path?: string): void;
18
+ export declare function pieceEnv(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv;
19
+ /**
20
+ * At most this many piece processes at once, across every page load: a burst
21
+ * of requests queues instead of forking a process each. The time limit starts
22
+ * when a piece actually runs.
23
+ */
24
+ export declare const MAX_RUNNING = 6;
25
+ /** Every installed piece's manifest, plus the ones that had nothing to show and why. */
26
+ export declare function pieceManifests(env: NodeJS.ProcessEnv, nowMs?: number): Promise<{
27
+ pieces: PieceInfo[];
28
+ skipped: PieceSkip[];
29
+ }>;
30
+ /** One page from a piece, or why it couldn't be had. */
31
+ export declare function piecePage(info: PieceInfo, path: string, query: Record<string, string>, env: NodeJS.ProcessEnv): Promise<Page | string>;
32
+ /** Run an action on a piece. Field values go on stdin only. */
33
+ export declare function pieceAct(info: PieceInfo, request: {
34
+ action: string;
35
+ path: string;
36
+ fields: Record<string, string>;
37
+ }, env: NodeJS.ProcessEnv): Promise<ActResult>;