@jbarket/loomai 0.8.0 → 0.9.0

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
+ }
@@ -1,26 +1,44 @@
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 /ui Home: the tape, tiles, "Your call"
5
+ * GET /ui/mind, /ui/people[/<slug>], /ui/memory/<ref> loom's own pages
6
+ * GET /ui/p/<piece>[/<path>] a piece's page
7
+ * GET /ui/pieces.json the installed pieces' manifests, for scripts
8
+ * POST /ui/act/<source> an action (source = loom or a piece)
9
+ * GET/POST /ui/login, POST /ui/logout
4
10
  *
5
11
  * Auth follows the daemon's bearer token, and the UI needs one: a daemon with
6
12
  * no token serves no /ui (like /cli), because an open page would be readable
7
13
  * 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.
14
+ * in once by pasting the token and gets a session: a random id held in this
15
+ * process's memory. It isn't the token and isn't derivable from it, so it
16
+ * can't be replayed as a bearer or used to guess the token. It lasts 30 days,
17
+ * until logout (which revokes it), until the token changes, or until the
18
+ * daemon restarts. Scripts can send the bearer header instead.
19
+ *
20
+ * A GET from another site or another port is served only as a top-level
21
+ * navigation, never to an <img> or a fetch (fetch metadata).
22
+ *
23
+ * Every POST from a browser carries an anti-forgery token derived from its
24
+ * cookie, and a cross-site POST is refused outright (Sec-Fetch-Site / Origin),
25
+ * on top of the SameSite=Strict cookie. The page runs no script and is served
26
+ * under a CSP that forbids script and framing. Action fields go to the piece
27
+ * on stdin and are never logged, stored, or put in a URL; the one-time result
28
+ * message is kept in memory under a random id for a minute.
15
29
  */
16
30
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
17
31
  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';
32
+ import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
33
+ import { pieceAct, pieceManifests, piecePage } from './pieces.js';
34
+ import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
35
+ import { ID, LIMITS, QUERY_KEY, splitPage, validPath } from './vocab.js';
21
36
  const COOKIE = 'loom_ui';
22
37
  const LOGIN_MAX_BYTES = 4096;
38
+ const ACT_MAX_BYTES = 64 * 1024;
23
39
  const COOKIE_MAX_AGE_S = 30 * 24 * 3600;
40
+ const FLASH_TTL_MS = 60_000;
41
+ const FLASH_MAX = 200;
24
42
  /** This process's cookie key. Never written anywhere: a restart means logging in again. */
25
43
  const COOKIE_KEY = randomBytes(32);
26
44
  const SECURITY_HEADERS = {
@@ -33,24 +51,36 @@ export function isUiPath(pathname) {
33
51
  return pathname === '/ui' || pathname.startsWith('/ui/');
34
52
  }
35
53
  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');
54
+ const sessions = new Map();
55
+ const MAX_SESSIONS = 50;
56
+ function newSession(secret) {
57
+ const id = randomBytes(32).toString('hex');
58
+ sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: digest(secret) });
59
+ // Oldest first in a Map: drop the oldest past the cap.
60
+ while (sessions.size > MAX_SESSIONS)
61
+ sessions.delete(sessions.keys().next().value);
62
+ return id;
38
63
  }
39
- function cookieValue(secret, issuedAt = Math.floor(Date.now() / 1000)) {
40
- return `${issuedAt}.${mac(issuedAt, secret)}`;
64
+ /** The anti-forgery token for a session: bound to its id, useless without it. */
65
+ function csrfFor(id) {
66
+ return createHmac('sha256', COOKIE_KEY).update(`loom-ui-csrf|${id}`).digest('hex');
41
67
  }
42
68
  /** Equal-length digests, so neither the content nor the length of a guess leaks. */
43
69
  function safeEq(a, b) {
44
70
  return timingSafeEqual(digest(a), digest(b));
45
71
  }
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)
72
+ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
73
+ if (!/^[0-9a-f]{64}$/.test(id))
49
74
  return false;
50
- const issuedAt = Number(m[1]);
51
- if (issuedAt > nowS + 60 || nowS - issuedAt > COOKIE_MAX_AGE_S)
75
+ const s = sessions.get(id);
76
+ if (!s)
52
77
  return false;
53
- return secrets.some((s) => safeEq(m[2], mac(issuedAt, s)));
78
+ if (nowS - s.issuedAt > COOKIE_MAX_AGE_S) {
79
+ sessions.delete(id);
80
+ return false;
81
+ }
82
+ // A session dies with its token: rotate the token and old sessions stop.
83
+ return secrets.some((t) => timingSafeEqual(digest(t), s.secret));
54
84
  }
55
85
  function readCookie(req, name) {
56
86
  for (const part of (req.headers.cookie ?? '').split(';')) {
@@ -60,14 +90,51 @@ function readCookie(req, name) {
60
90
  }
61
91
  return undefined;
62
92
  }
63
- function authorized(req, opts) {
93
+ function whoIs(req, opts) {
64
94
  if (opts.secrets.length === 0)
65
- return false;
95
+ return null;
66
96
  const c = readCookie(req, COOKIE);
67
- if (c && validCookie(c, opts.secrets))
68
- return true;
97
+ if (c && validSession(c, opts.secrets))
98
+ return { kind: 'session', cookie: c };
69
99
  const header = req.headers['authorization'];
70
- return header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok);
100
+ if (header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok))
101
+ return { kind: 'bearer' };
102
+ return null;
103
+ }
104
+ /**
105
+ * A GET from somewhere else is fine as a top-level navigation (a link in a
106
+ * chat) and refused otherwise: an <img> or fetch from another site, or from
107
+ * another port on this host (same-site, so the cookie rides along), must not
108
+ * make loom run a piece.
109
+ */
110
+ function allowedGet(req) {
111
+ const site = req.headers['sec-fetch-site'];
112
+ if (site === undefined || site === 'same-origin' || site === 'none')
113
+ return true;
114
+ return req.headers['sec-fetch-mode'] === 'navigate' && req.headers['sec-fetch-dest'] === 'document';
115
+ }
116
+ /**
117
+ * A browser POST must come from loom's own page. Sec-Fetch-Site is set by
118
+ * every current browser and can't be forged by a page; Origin, when sent,
119
+ * must name this host. Either one saying "elsewhere" is a refusal.
120
+ */
121
+ function sameOrigin(req) {
122
+ const site = req.headers['sec-fetch-site'];
123
+ if (site !== undefined && site !== 'same-origin' && site !== 'none')
124
+ return false;
125
+ const origin = req.headers.origin;
126
+ if (origin !== undefined) {
127
+ if (origin === 'null')
128
+ return false;
129
+ try {
130
+ if (new URL(origin).host !== req.headers.host)
131
+ return false;
132
+ }
133
+ catch {
134
+ return false;
135
+ }
136
+ }
137
+ return true;
71
138
  }
72
139
  function send(res, status, type, body, extra = {}) {
73
140
  res.writeHead(status, { 'content-type': type, ...SECURITY_HEADERS, ...extra });
@@ -77,9 +144,10 @@ function redirect(res, to, extra = {}) {
77
144
  res.writeHead(303, { location: to, ...SECURITY_HEADERS, ...extra });
78
145
  res.end();
79
146
  }
80
- function readForm(req) {
147
+ function readForm(req, max) {
81
148
  return new Promise((resolve) => {
82
- let body = '';
149
+ const chunks = [];
150
+ let bytes = 0;
83
151
  let done = false;
84
152
  const finish = (v) => { if (!done) {
85
153
  done = true;
@@ -88,21 +156,95 @@ function readForm(req) {
88
156
  req.on('data', (d) => {
89
157
  if (done)
90
158
  return;
91
- body += d.toString('utf-8');
92
- if (body.length > LOGIN_MAX_BYTES) {
159
+ bytes += d.length;
160
+ if (bytes > max) {
93
161
  finish(null);
94
162
  req.destroy();
163
+ return;
95
164
  }
165
+ chunks.push(d);
96
166
  });
97
- req.on('end', () => finish(new URLSearchParams(body)));
167
+ req.on('end', () => finish(new URLSearchParams(Buffer.concat(chunks).toString('utf-8'))));
98
168
  req.on('error', () => finish(null));
99
169
  req.on('close', () => finish(null));
100
170
  });
101
171
  }
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 };
172
+ /** The page's own query: valid keys only, loom's `_` keys and oversize values dropped. */
173
+ function pageQuery(url) {
174
+ const q = Object.create(null);
175
+ let n = 0;
176
+ for (const [k, v] of url.searchParams) {
177
+ if (!QUERY_KEY.test(k) || v.length > LIMITS.queryValue || Object.hasOwn(q, k))
178
+ continue;
179
+ if (++n > LIMITS.query)
180
+ break;
181
+ q[k] = v;
182
+ }
183
+ return q;
184
+ }
185
+ const flashes = new Map();
186
+ function putFlash(flash) {
187
+ const now = Date.now();
188
+ for (const [k, v] of flashes)
189
+ if (now - v.at > FLASH_TTL_MS || flashes.size > FLASH_MAX)
190
+ flashes.delete(k);
191
+ const id = randomBytes(12).toString('hex');
192
+ flashes.set(id, { at: now, flash });
193
+ return id;
194
+ }
195
+ function takeFlash(id) {
196
+ if (!id)
197
+ return undefined;
198
+ const f = flashes.get(id);
199
+ flashes.delete(id);
200
+ return f && Date.now() - f.at <= FLASH_TTL_MS ? f.flash : undefined;
201
+ }
202
+ // ─── Assembling a page ───────────────────────────────────────────────────────
203
+ function clock() {
204
+ return new Intl.DateTimeFormat('en-US', { timeZone: uiTimeZone(), weekday: 'short', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' }).format(new Date());
205
+ }
206
+ function hasPages(p) {
207
+ return Boolean(p.manifest.tab || p.manifest.legacy?.length);
208
+ }
209
+ function chrome(opts, pieces, skipped, current, csrf, flash) {
210
+ const tabs = pieces.filter(hasPages)
211
+ .map((p) => ({ label: p.manifest.tab?.label ?? p.piece, href: `/ui/p/${p.piece}`, current: current === `p/${p.piece}`, piece: true }))
212
+ .sort((a, b) => a.label.localeCompare(b.label));
213
+ return {
214
+ agent: agentName(opts.contextDir),
215
+ nav: [
216
+ { label: 'Home', href: '/ui', current: current === '', piece: false },
217
+ { label: 'Mind', href: '/ui/mind', current: current === 'mind', piece: false },
218
+ { label: 'People', href: '/ui/people', current: current === 'people', piece: false },
219
+ ...tabs,
220
+ ],
221
+ clock: clock(),
222
+ csrf,
223
+ flash,
224
+ skipped,
225
+ };
226
+ }
227
+ /** Where to land after an action: the page the piece names, or back where the form was. */
228
+ function landing(source, result, back) {
229
+ const s = (result.page !== undefined ? splitPage(result.page) : null) ?? splitPage(back);
230
+ const core = s?.core || source.id === 'loom';
231
+ const path = s?.path ?? '';
232
+ const q = s ? new URLSearchParams(s.query).toString() : '';
233
+ const base = core ? `/ui${path ? `/${path}` : ''}` : `/ui/p/${source.id}${path ? `/${path}` : ''}`;
234
+ return q ? `${base}?${q}` : base;
235
+ }
236
+ function withFlash(to, id) {
237
+ return `${to}${to.includes('?') ? '&' : '?'}_flash=${id}`;
238
+ }
239
+ const notFound = { title: 'Not found', sections: [] };
240
+ /** decodeURIComponent, or null for a malformed escape (a 404, never a 500). */
241
+ function decode(s) {
242
+ try {
243
+ return decodeURIComponent(s);
244
+ }
245
+ catch {
246
+ return null;
247
+ }
106
248
  }
107
249
  /** Handle a /ui request. The caller has already matched isUiPath. */
108
250
  export async function handleUi(req, res, pathname, opts) {
@@ -118,7 +260,9 @@ export async function handleUi(req, res, pathname, opts) {
118
260
  return send(res, 200, 'text/html; charset=utf-8', renderLogin());
119
261
  if (req.method !== 'POST')
120
262
  return send(res, 405, 'text/plain', 'method not allowed');
121
- const form = await readForm(req);
263
+ if (!sameOrigin(req))
264
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
265
+ const form = await readForm(req, LOGIN_MAX_BYTES);
122
266
  if (!form)
123
267
  return send(res, 413, 'text/plain', 'too large');
124
268
  const token = form.get('token')?.trim() ?? '';
@@ -126,34 +270,130 @@ export async function handleUi(req, res, pathname, opts) {
126
270
  if (!match)
127
271
  return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
128
272
  return redirect(res, '/ui', {
129
- 'set-cookie': `${COOKIE}=${cookieValue(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
273
+ 'set-cookie': `${COOKIE}=${newSession(match)}; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=${COOKIE_MAX_AGE_S}${secure ? '; Secure' : ''}`,
130
274
  });
131
275
  }
276
+ const who = whoIs(req, opts);
277
+ const csrf = who?.kind === 'session' ? csrfFor(who.cookie) : '';
132
278
  if (pathname === '/ui/logout') {
133
279
  if (req.method !== 'POST')
134
280
  return send(res, 405, 'text/plain', 'method not allowed');
281
+ if (!sameOrigin(req))
282
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
283
+ // Revoke it server-side, so a copy of the cookie stops working too.
284
+ const c = readCookie(req, COOKIE);
285
+ if (c)
286
+ sessions.delete(c);
135
287
  return redirect(res, '/ui/login', { 'set-cookie': `${COOKIE}=; Path=/ui; HttpOnly; SameSite=Strict; Max-Age=0` });
136
288
  }
137
- const agent = agentName(opts.contextDir);
289
+ if (!who) {
290
+ if (pathname.endsWith('.json'))
291
+ return send(res, 401, 'application/json', JSON.stringify({ error: 'unauthorized' }));
292
+ if (req.method === 'GET')
293
+ return redirect(res, '/ui/login');
294
+ return send(res, 401, 'text/plain', 'unauthorized');
295
+ }
296
+ const url = new URL(req.url ?? pathname, 'http://loom.invalid');
297
+ if (pathname.startsWith('/ui/act/')) {
298
+ if (req.method !== 'POST')
299
+ return send(res, 405, 'text/plain', 'method not allowed');
300
+ if (!sameOrigin(req))
301
+ return send(res, 403, 'text/plain', 'forbidden: cross-site request');
302
+ const sourceId = pathname.slice('/ui/act/'.length);
303
+ if (sourceId !== 'loom' && !ID.test(sourceId))
304
+ return send(res, 404, 'text/plain', 'not found');
305
+ const form = await readForm(req, ACT_MAX_BYTES);
306
+ if (!form)
307
+ return send(res, 413, 'text/plain', 'too large');
308
+ // A browser session must prove the form came from loom's page. A bearer
309
+ // script has no ambient credential a forger could ride, so it needs none.
310
+ if (who.kind === 'session' && !safeEq(form.get('_csrf') ?? '', csrf)) {
311
+ return send(res, 403, 'text/plain', 'forbidden: this form is stale or not from loom\'s page; reload and try again');
312
+ }
313
+ const action = form.get('_action') ?? '';
314
+ const back = form.get('_path') ?? '';
315
+ if (!/^[a-z][a-z0-9_-]{0,39}$/.test(action))
316
+ return send(res, 400, 'text/plain', 'bad action');
317
+ const backLink = splitPage(back) ? back : '';
318
+ // A form has up to LIMITS.fields fields plus LIMITS.hidden hidden values.
319
+ // More than that is refused whole, never cut short: a truncated form
320
+ // could drop the very field (a secret) the piece needs.
321
+ const fields = Object.create(null);
322
+ for (const [k, v] of form) {
323
+ if (k.startsWith('_') || !/^[a-z][a-z0-9_-]{0,39}$/.test(k) || Object.hasOwn(fields, k))
324
+ continue;
325
+ fields[k] = v;
326
+ }
327
+ if (Object.keys(fields).length > LIMITS.fields + LIMITS.hidden)
328
+ return send(res, 400, 'text/plain', 'too many fields');
329
+ let source;
330
+ let result;
331
+ if (sourceId === 'loom') {
332
+ source = CORE;
333
+ result = await coreAct(opts.contextDir, action, fields);
334
+ }
335
+ else {
336
+ const { pieces } = await pieceManifests(opts.env);
337
+ const info = pieces.find((p) => p.piece === sourceId);
338
+ if (!info)
339
+ return send(res, 404, 'text/plain', 'not found');
340
+ source = pieceSource(info.piece);
341
+ const path = splitPage(backLink)?.path ?? '';
342
+ result = await pieceAct(info, { action, path, fields }, opts.env);
343
+ }
344
+ const to = landing(source, result, backLink);
345
+ // A piece's message is labeled with the piece, so it can't pass as loom's.
346
+ const from = source.id === 'loom' ? undefined : source.id;
347
+ return redirect(res, withFlash(to, putFlash({ ok: result.ok, message: result.message, from })));
348
+ }
138
349
  if (req.method !== 'GET')
139
350
  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 }));
351
+ if (!allowedGet(req))
352
+ return send(res, 403, 'text/plain', 'forbidden: loom\'s page can only be opened, not embedded or fetched from another site');
353
+ if (pathname === '/ui/pieces.json') {
354
+ const m = await pieceManifests(opts.env);
355
+ return send(res, 200, 'application/json', JSON.stringify({
356
+ agent: agentName(opts.contextDir),
357
+ pieces: m.pieces.map((p) => ({ piece: p.piece, manifest: p.manifest })),
358
+ skipped: m.skipped,
359
+ }));
145
360
  }
361
+ const query = pageQuery(url);
362
+ const flash = takeFlash(url.searchParams.get('_flash'));
363
+ const { pieces, skipped } = await pieceManifests(opts.env);
364
+ const skippedNames = skipped.map((s) => s.piece);
365
+ const html = (body, status = 200) => send(res, status, 'text/html; charset=utf-8', body);
146
366
  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' }),
156
- }));
367
+ const home = homePage(opts.contextDir, query);
368
+ const tiles = [
369
+ { ...home.tile, source: CORE },
370
+ ...pieces.filter((p) => p.manifest.tile).map((p) => ({ ...p.manifest.tile, source: pieceSource(p.piece) })),
371
+ ];
372
+ const asks = pieces.flatMap((p) => p.manifest.asks.map((a) => ({ ...a, source: pieceSource(p.piece) })));
373
+ return html(renderHome(home.page, { source: CORE, path: '', query, csrf }, chrome(opts, pieces, skippedNames, '', csrf, flash), tiles, asks));
374
+ }
375
+ if (pathname.startsWith('/ui/p/')) {
376
+ const rest = pathname.slice('/ui/p/'.length);
377
+ const slash = rest.indexOf('/');
378
+ const piece = slash === -1 ? rest : rest.slice(0, slash);
379
+ const path = slash === -1 ? '' : decode(rest.slice(slash + 1));
380
+ const info = pieces.find((p) => p.piece === piece && hasPages(p));
381
+ const ch = chrome(opts, pieces, skippedNames, `p/${piece}`, csrf, flash);
382
+ if (!info || path === null || !validPath(path))
383
+ return html(renderPage(notFound, { source: pieceSource(piece), path: '', query: {}, csrf }, ch), 404);
384
+ const page = await piecePage(info, path, query, opts.env);
385
+ const ctx = { source: pieceSource(piece), path, query, csrf };
386
+ if (typeof page === 'string') {
387
+ 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);
388
+ }
389
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
157
390
  }
158
- return send(res, 404, 'text/plain', 'not found');
391
+ const path = decode(pathname.slice('/ui/'.length)) ?? '\0';
392
+ const top = path.split('/')[0];
393
+ const ch = chrome(opts, pieces, skippedNames, top, csrf, flash);
394
+ const page = validPath(path) ? corePage(opts.contextDir, path, query) : null;
395
+ const ctx = { source: CORE, path: validPath(path) ? path : '', query, csrf };
396
+ if (!page)
397
+ return html(renderPage(notFound, ctx, ch), 404);
398
+ return html(renderPage(page, ctx, ch), page.title === 'Not found' && page.sections.length === 0 ? 404 : 200);
159
399
  }
@@ -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>;