@jbarket/loomai 0.9.2 → 0.11.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.
@@ -9,7 +9,9 @@
9
9
  * loom tool. The CLI (`loom person`) calls the read functions here too.
10
10
  */
11
11
  import { resolveIdentityName } from '../config.js';
12
- import { openPeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
12
+ import { householdOf } from '../household.js';
13
+ import { openHouseholdPeople } from '../backends/people-household.js';
14
+ import { openPeopleStore, PeopleStore, personSlugError, PersonCardError, UnreadableCardError, } from '../backends/people.js';
13
15
  const json = (v) => JSON.stringify(v, null, 2);
14
16
  /** Slugs validate before anything else, store or no store (criterion 11). */
15
17
  function assertSlug(slug) {
@@ -22,9 +24,20 @@ function errorText(e) {
22
24
  return `Error: ${e.message}`;
23
25
  return `Error: ${e.message ?? String(e)}`;
24
26
  }
27
+ /**
28
+ * The people a context dir sees. In a household: the commons directory plus
29
+ * this agent's relationships (backends/people-household.ts). Otherwise the
30
+ * one store in the agent's memories.db, as before.
31
+ */
32
+ function openPeople(contextDir, opts = {}) {
33
+ const hh = householdOf(contextDir);
34
+ if (hh)
35
+ return openHouseholdPeople(hh.household, hh.agent, contextDir, opts);
36
+ return openPeopleStore(contextDir, opts);
37
+ }
25
38
  function withWritable(contextDir, fn) {
26
39
  try {
27
- const store = openPeopleStore(contextDir);
40
+ const store = openPeople(contextDir);
28
41
  try {
29
42
  return json(fn(store));
30
43
  }
@@ -50,12 +63,12 @@ export async function personRestore(contextDir, input) {
50
63
  return withWritable(contextDir, (s) => s.restore(input));
51
64
  }
52
65
  export async function personRevisionRestore(contextDir, input) {
53
- return withWritable(contextDir, (s) => s.revisionRestore(input));
66
+ return withWritable(contextDir, (s) => (s instanceof PeopleStore ? s.revisionRestore({ slug: input.slug, revision_id: input.revision_id }) : s.revisionRestore(input)));
54
67
  }
55
68
  /** The `person` read. Throws on an invalid slug or an unreadable row; the MCP wrapper turns that into text. */
56
69
  export function readPerson(contextDir, slug, includeArchived = false) {
57
70
  assertSlug(slug);
58
- const store = openPeopleStore(contextDir, { readonly: true });
71
+ const store = openPeople(contextDir, { readonly: true });
59
72
  if (!store)
60
73
  return { found: false };
61
74
  try {
@@ -66,7 +79,7 @@ export function readPerson(contextDir, slug, includeArchived = false) {
66
79
  }
67
80
  }
68
81
  export function listPeople(contextDir, includeArchived = false) {
69
- const store = openPeopleStore(contextDir, { readonly: true });
82
+ const store = openPeople(contextDir, { readonly: true });
70
83
  if (!store)
71
84
  return [];
72
85
  try {
@@ -95,7 +108,7 @@ export async function personList(contextDir, input) {
95
108
  export async function personHistory(contextDir, input) {
96
109
  try {
97
110
  assertSlug(input.slug);
98
- const store = openPeopleStore(contextDir, { readonly: true });
111
+ const store = openPeople(contextDir, { readonly: true });
99
112
  if (!store)
100
113
  return json([]);
101
114
  try {
@@ -1,4 +1,5 @@
1
1
  import type { McpServer } from '@modelcontextprotocol/server';
2
+ import { type Household } from '../household.js';
2
3
  export interface HttpServeOptions {
3
4
  contextDir: string;
4
5
  host: string;
@@ -34,6 +35,12 @@ export interface HttpServeOptions {
34
35
  }) => {
35
36
  server: McpServer;
36
37
  };
38
+ /**
39
+ * Household mode: tokens resolve to roles from household.home/tokens.json
40
+ * (plus `token`, if set, as an owner token). Mutually exclusive with
41
+ * `tokens` and `createServer`; `contextDir` should be the default agent's.
42
+ */
43
+ household?: Household;
37
44
  }
38
45
  export interface HttpServeHandle {
39
46
  /** The actually-bound port (resolves a 0 request to the OS-assigned port). */
@@ -23,6 +23,13 @@
23
23
  * bound to the session at open time (a later request on the same
24
24
  * session presenting a DIFFERENT identity's token is refused, not
25
25
  * silently re-attributed).
26
+ * - `household` (docs/specs/household.md) — one daemon for many agents.
27
+ * Each token in $LOOM_HOME/tokens.json has a role: `agent:<name>` gets a
28
+ * full server over that agent's context dir; `owner` gets the default
29
+ * agent's (or another's under `/agents/<name>/…`) and the page; `peer`
30
+ * gets the knowledge-only server. LOOM_BEARER_TOKEN, when set, is an
31
+ * owner token too. The role is resolved on every request and a session
32
+ * is pinned to the role and agent it opened with.
26
33
  */
27
34
  import { randomUUID } from 'node:crypto';
28
35
  import { createServer } from 'node:http';
@@ -32,7 +39,11 @@ import { EmptyResultSchema } from '@modelcontextprotocol/core';
32
39
  import { createLoomServer } from '../server.js';
33
40
  import { createBackend } from '../backends/index.js';
34
41
  import { handleCliBody } from './cli-endpoint.js';
35
- import { handleUi, isUiRequest } from '../ui/handler.js';
42
+ import { handleUi, isUiRequest, legacyUiAuth } from '../ui/handler.js';
43
+ import { createKnowledgeOnlyServer } from './knowledge-server.js';
44
+ import { hasUnsplitPeople } from '../backends/people-household.js';
45
+ import { agentDir, assertRealAgentDir, commonsProblem, roleString, TokenResolver } from '../household.js';
46
+ import { createHash, timingSafeEqual as cryptoTimingSafeEqual } from 'node:crypto';
36
47
  import { assertSafeBind, checkBearer, checkBearerMulti, checkPayloadSize, DEFAULT_MAX_BODY_BYTES, } from './guards.js';
37
48
  /**
38
49
  * SSE keep-alive: the SDK opens the standalone GET stream but sends no
@@ -119,6 +130,67 @@ export async function startHttpServer(opts) {
119
130
  if (opts.token && opts.tokens) {
120
131
  throw new Error('startHttpServer: pass either token or tokens, not both');
121
132
  }
133
+ if (opts.household && (opts.tokens || opts.createServer)) {
134
+ throw new Error('startHttpServer: household mode resolves roles itself; pass neither tokens nor createServer');
135
+ }
136
+ const household = opts.household;
137
+ if (household) {
138
+ // Each of these points every agent at one store or one name, which would
139
+ // quietly merge the household. Refuse rather than serve it that way.
140
+ for (const v of ['LOOM_SQLITE_DB_PATH', 'LOOM_KNOWLEDGE_DB_PATH', 'LOOM_IDENTITY']) {
141
+ if (process.env[v])
142
+ throw new Error(`startHttpServer: ${v} is set; a household gives each agent its own store and name, so unset it`);
143
+ }
144
+ for (const a of household.agents)
145
+ assertRealAgentDir(household, a);
146
+ const problem = commonsProblem(household);
147
+ if (problem)
148
+ throw new Error(`startHttpServer: ${problem}`);
149
+ for (const a of household.agents) {
150
+ if (hasUnsplitPeople(agentDir(household, a))) {
151
+ throw new Error(`startHttpServer: agents/${a}'s person cards haven't been split into the commons; with every loom daemon stopped, run: loom household commons`);
152
+ }
153
+ }
154
+ }
155
+ const resolver = household ? new TokenResolver(household) : undefined;
156
+ const ownerDigest = opts.token ? createHash('sha256').update(opts.token).digest() : undefined;
157
+ /** Resolve a household caller: 401 (no/unknown token), 403 (not allowed there) or 404 (no such agent). */
158
+ function callerFor(header, target) {
159
+ const presented = (header ?? '').replace(/^Bearer\s+/i, '').trim();
160
+ if (!presented)
161
+ return { status: 401, error: 'unauthorized: missing bearer token' };
162
+ // The env owner token first: a broken tokens.json must not lock it out.
163
+ let role = null;
164
+ if (ownerDigest && cryptoTimingSafeEqual(createHash('sha256').update(presented).digest(), ownerDigest))
165
+ role = { kind: 'owner' };
166
+ if (!role) {
167
+ try {
168
+ role = resolver.resolve(presented);
169
+ }
170
+ catch (err) {
171
+ // Fail closed, and keep the details (paths) out of the response.
172
+ process.stderr.write(`loom: household tokens unreadable: ${err.message}\n`);
173
+ return { status: 503, error: 'unavailable: the household token file is unreadable; see the daemon log' };
174
+ }
175
+ }
176
+ if (!role)
177
+ return { status: 401, error: 'unauthorized: bearer token not recognized' };
178
+ const h = household;
179
+ if (role.kind === 'peer') {
180
+ if (target)
181
+ return { status: 403, error: 'forbidden: a peer token reaches the commons only' };
182
+ return { role, agent: h.default, contextDir: agentDir(h, h.default), key: `${roleString(role)}>${h.default}` };
183
+ }
184
+ if (role.kind === 'agent') {
185
+ if (target && target !== role.agent)
186
+ return { status: 403, error: `forbidden: this token is ${role.agent}'s` };
187
+ return { role, agent: role.agent, contextDir: agentDir(h, role.agent), key: `${roleString(role)}>${role.agent}` };
188
+ }
189
+ const agent = target ?? h.default;
190
+ if (!h.agents.includes(agent))
191
+ return { status: 404, error: `not-found: no agent named ${agent}` };
192
+ return { role, agent, contextDir: agentDir(h, agent), key: `owner>${agent}` };
193
+ }
122
194
  // A scoped server factory (e.g. the knowledge-only service) always needs
123
195
  // to know WHO is calling for attribution — refuse to boot it open or on a
124
196
  // single shared secret with no identity behind it.
@@ -140,15 +212,52 @@ export async function startHttpServer(opts) {
140
212
  // a session three hops later. Scoped to the default server only — a scoped
141
213
  // createServer (e.g. the knowledge-only service) never touches this
142
214
  // backend, so forcing it there would just pay for an unused embedder load.
143
- if (!opts.createServer)
215
+ if (household)
216
+ for (const a of household.agents)
217
+ createBackend(agentDir(household, a));
218
+ else if (!opts.createServer)
144
219
  createBackend(opts.contextDir);
145
- const makeServer = opts.createServer ?? ((ctx) => createLoomServer(ctx));
220
+ // The page is the owner's: in a household, only owner tokens log in to it.
221
+ const uiAuth = household
222
+ ? {
223
+ enabled: true,
224
+ check: (t) => {
225
+ const d = createHash('sha256').update(t).digest();
226
+ if (ownerDigest && cryptoTimingSafeEqual(d, ownerDigest))
227
+ return d;
228
+ try {
229
+ return resolver.resolve(t)?.kind === 'owner' ? d : null;
230
+ }
231
+ catch {
232
+ return null;
233
+ }
234
+ },
235
+ live: (d) => {
236
+ if (ownerDigest !== undefined && cryptoTimingSafeEqual(d, ownerDigest))
237
+ return true;
238
+ try {
239
+ return resolver.ownerDigestLive(d.toString('hex'));
240
+ }
241
+ catch {
242
+ return false;
243
+ }
244
+ },
245
+ }
246
+ : legacyUiAuth(opts.token ? [opts.token] : Object.values(opts.tokens ?? {}));
247
+ const makeServer = opts.createServer ?? ((ctx) => createLoomServer({ ...ctx, remote: true }));
248
+ const serverFor = (c, identity) => {
249
+ if (!c)
250
+ return makeServer({ contextDir: opts.contextDir, gapsDir: opts.gapsDir, identity });
251
+ if (c.role.kind === 'peer')
252
+ return createKnowledgeOnlyServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, identity: c.role.name });
253
+ return createLoomServer({ contextDir: c.contextDir, gapsDir: opts.gapsDir, remote: true });
254
+ };
146
255
  // One transport (+ its connected server) per live MCP session, plus the
147
256
  // identity it was opened for — a multi-token session is pinned to that
148
257
  // identity for its lifetime (see the mismatch check in the request handler).
149
258
  const sessions = new Map();
150
- async function openSession(identity) {
151
- const { server } = makeServer({ contextDir: opts.contextDir, gapsDir: opts.gapsDir, identity });
259
+ async function openSession(identity, caller) {
260
+ const { server } = serverFor(caller, identity);
152
261
  // Fires AFTER the initialize handshake completes — getClientVersion() is
153
262
  // populated here (it is not yet at onsessioninitialized). Logging the peer
154
263
  // makes the resolved harness observable and reveals an unmapped client's
@@ -200,26 +309,53 @@ export async function startHttpServer(opts) {
200
309
  // loom's web page (src/ui), at the root. Its own auth (a session from
201
310
  // the bearer, or the bearer header), so it comes before the bearer gate.
202
311
  // Full scope only: a scoped server has no memory to show.
203
- const uiPath = new URL(req.url ?? '/', 'http://localhost').pathname;
204
- if (isUiRequest(req, uiPath)) {
312
+ const fullPath = new URL(req.url ?? '/', 'http://localhost').pathname;
313
+ if (isUiRequest(req, fullPath)) {
205
314
  if (opts.createServer)
206
315
  return sendError(res, 404, 'not-found: this server has no UI');
207
- return handleUi(req, res, uiPath, {
316
+ return handleUi(req, res, fullPath, {
208
317
  contextDir: opts.contextDir,
209
- secrets: opts.token ? [opts.token] : Object.values(opts.tokens ?? {}),
318
+ auth: uiAuth,
210
319
  env: process.env,
211
320
  });
212
321
  }
322
+ // Household: `/agents/<name>/…` names the agent (the owner's way to
323
+ // reach a non-default one; an agent's own token may use its own name).
324
+ const scoped = household ? /^\/agents\/([a-z][a-z0-9-]{0,39})(\/.*)?$/.exec(fullPath) : null;
325
+ // A path under /agents/ that doesn't name an agent cleanly is a mistake,
326
+ // not a request for the default agent.
327
+ if (household && !scoped && (fullPath === '/agents' || fullPath.startsWith('/agents/'))) {
328
+ return sendError(res, 404, 'not-found: /agents/<name>/ takes a lowercase agent name');
329
+ }
330
+ const target = scoped?.[1];
331
+ const path = scoped ? (scoped[2] ?? '/') : fullPath;
332
+ // In a household the only routes past the page are MCP and /cli: any
333
+ // other path (/AGENTS/mark/mcp, /x/agents/mark/mcp) is a 404, not the
334
+ // default agent.
335
+ if (household && path !== '/' && path !== '/mcp' && path !== '/cli') {
336
+ return sendError(res, 404, 'not-found: in a household, use /, /mcp or /cli, optionally under /agents/<name>/');
337
+ }
213
338
  // Auth gate (ac-lt-auth-gate): reject unauthenticated when a token (or
214
339
  // token map) is configured. Multi-token mode additionally resolves
215
340
  // WHICH identity made this call — the bearer itself is the identity
216
341
  // claim (t-677); there is no separate login step.
217
- const auth = opts.tokens
218
- ? checkBearerMulti(opts.tokens, req.headers['authorization'])
219
- : checkBearer(opts.token, req.headers['authorization']);
220
- if (!auth.ok)
221
- return sendError(res, 401, auth.error);
222
- const identity = auth.identity ?? 'art';
342
+ let caller;
343
+ let identity;
344
+ if (household) {
345
+ const c = callerFor(req.headers['authorization'], target);
346
+ if ('status' in c)
347
+ return sendError(res, c.status, c.error);
348
+ caller = c;
349
+ identity = c.key;
350
+ }
351
+ else {
352
+ const auth = opts.tokens
353
+ ? checkBearerMulti(opts.tokens, req.headers['authorization'])
354
+ : checkBearer(opts.token, req.headers['authorization']);
355
+ if (!auth.ok)
356
+ return sendError(res, 401, auth.error);
357
+ identity = auth.identity ?? 'art';
358
+ }
223
359
  // Oversized guard (ac-lt-oversized-guard): fast path on declared length.
224
360
  const declared = Number(req.headers['content-length'] ?? 0);
225
361
  if (Number.isFinite(declared) && declared > 0 && !checkPayloadSize(declared, maxBytes).ok) {
@@ -228,15 +364,15 @@ export async function startHttpServer(opts) {
228
364
  // POST /cli: the CLI's remote mode (cli-endpoint.ts). Same gate and cap
229
365
  // as /mcp above. Full server only: a scoped server (knowledge-only)
230
366
  // can't offer the memory verbs, so it has no /cli at all.
231
- if (new URL(req.url ?? '/', 'http://localhost').pathname === '/cli') {
232
- if (opts.createServer)
367
+ if (path === '/cli') {
368
+ if (opts.createServer || caller?.role.kind === 'peer')
233
369
  return sendError(res, 404, 'not-found: this server has no CLI endpoint');
234
370
  // Unlike /mcp (initialize handshake + mcp-session-id header), a bare
235
371
  // POST could be a browser's "simple" cross-site request. So /cli needs
236
372
  // a configured token, a JSON content type (forces a CORS preflight,
237
373
  // which this server never grants), and no Origin header at all: the
238
374
  // CLI never sends one, and a browser always does.
239
- if (!opts.token && !opts.tokens)
375
+ if (!opts.token && !opts.tokens && !household)
240
376
  return sendError(res, 403, 'forbidden: /cli requires LOOM_BEARER_TOKEN');
241
377
  if (req.headers['origin'] !== undefined)
242
378
  return sendError(res, 403, 'forbidden: /cli refuses browser requests');
@@ -255,7 +391,7 @@ export async function startHttpServer(opts) {
255
391
  catch (e) {
256
392
  return sendError(res, 400, `bad-input: malformed JSON (${e.message})`);
257
393
  }
258
- const outcome = await handleCliBody(opts.contextDir, parsed);
394
+ const outcome = await handleCliBody(caller?.contextDir ?? opts.contextDir, parsed);
259
395
  if (!outcome.ok)
260
396
  return sendError(res, 400, outcome.error);
261
397
  res.writeHead(200, { 'content-type': 'application/json' });
@@ -319,7 +455,7 @@ export async function startHttpServer(opts) {
319
455
  if (!isInitializeRequest(parsed)) {
320
456
  return sendError(res, 400, 'bad-input: no valid session; expected an initialize request');
321
457
  }
322
- transport = await openSession(identity);
458
+ transport = await openSession(identity, caller);
323
459
  }
324
460
  // Dispatch totality + typed envelopes (unknown tool / bad args -> JSON-RPC
325
461
  // error) are the SDK server's job from here — never a raw throw out.
@@ -1,12 +1,25 @@
1
1
  import type { IncomingMessage, ServerResponse } from 'node:http';
2
2
  export interface UiOptions {
3
3
  contextDir: string;
4
- /** Configured bearer secrets (the single token, or the token map's values). Empty = open. */
5
- secrets: string[];
4
+ /** Who may log in to the page. */
5
+ auth: UiAuth;
6
6
  /** Env for piece discovery and piece processes (PATH decides which pieces exist). */
7
7
  env: NodeJS.ProcessEnv;
8
8
  }
9
9
  /** Whether a request is for the page (see the header for how `/` is shared with MCP). */
10
10
  export declare function isUiRequest(req: IncomingMessage, pathname: string): boolean;
11
+ /**
12
+ * Who may log in. `check` answers a presented token with its digest when the
13
+ * token may use the page, `live` says whether a digest still may (so revoking
14
+ * or rotating the token ends its sessions). `enabled` false means no token is
15
+ * configured, and the page refuses to serve.
16
+ */
17
+ export interface UiAuth {
18
+ enabled: boolean;
19
+ check(token: string): Buffer | null;
20
+ live(digest: Buffer): boolean;
21
+ }
22
+ /** A fixed list of tokens (a single bearer, or a token map's values). */
23
+ export declare function legacyUiAuth(secrets: string[]): UiAuth;
11
24
  /** Handle a request for the page. The caller has already matched isUiRequest. */
12
25
  export declare function handleUi(req: IncomingMessage, res: ServerResponse, pathname: string, opts: UiOptions): Promise<void>;
@@ -33,7 +33,6 @@
33
33
  * message is kept in memory under a random id for a minute.
34
34
  */
35
35
  import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
36
- import { checkBearer } from '../transport/guards.js';
37
36
  import { agentName, coreAct, corePage, homePage, uiTimeZone } from './core.js';
38
37
  import { pieceAct, pieceManifests, piecePage } from './pieces.js';
39
38
  import { CORE, pieceSource, renderHome, renderLogin, renderPage } from './render.js';
@@ -72,9 +71,21 @@ export function isUiRequest(req, pathname) {
72
71
  const digest = (s) => createHash('sha256').update(s).digest();
73
72
  const sessions = new Map();
74
73
  const MAX_SESSIONS = 50;
75
- function newSession(secret) {
74
+ /** A fixed list of tokens (a single bearer, or a token map's values). */
75
+ export function legacyUiAuth(secrets) {
76
+ const digests = secrets.map((t) => digest(t));
77
+ return {
78
+ enabled: secrets.length > 0,
79
+ check: (t) => {
80
+ const d = digest(t);
81
+ return digests.some((x) => timingSafeEqual(x, d)) ? d : null;
82
+ },
83
+ live: (d) => digests.some((x) => timingSafeEqual(x, d)),
84
+ };
85
+ }
86
+ function newSession(tokenDigest) {
76
87
  const id = randomBytes(32).toString('hex');
77
- sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: digest(secret) });
88
+ sessions.set(id, { issuedAt: Math.floor(Date.now() / 1000), secret: tokenDigest });
78
89
  // Oldest first in a Map: drop the oldest past the cap.
79
90
  while (sessions.size > MAX_SESSIONS)
80
91
  sessions.delete(sessions.keys().next().value);
@@ -88,7 +99,7 @@ function csrfFor(id) {
88
99
  function safeEq(a, b) {
89
100
  return timingSafeEqual(digest(a), digest(b));
90
101
  }
91
- function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
102
+ function validSession(id, auth, nowS = Math.floor(Date.now() / 1000)) {
92
103
  if (!/^[0-9a-f]{64}$/.test(id))
93
104
  return false;
94
105
  const s = sessions.get(id);
@@ -99,7 +110,7 @@ function validSession(id, secrets, nowS = Math.floor(Date.now() / 1000)) {
99
110
  return false;
100
111
  }
101
112
  // A session dies with its token: rotate the token and old sessions stop.
102
- return secrets.some((t) => timingSafeEqual(digest(t), s.secret));
113
+ return auth.live(s.secret);
103
114
  }
104
115
  function readCookie(req, name) {
105
116
  for (const part of (req.headers.cookie ?? '').split(';')) {
@@ -110,13 +121,14 @@ function readCookie(req, name) {
110
121
  return undefined;
111
122
  }
112
123
  function whoIs(req, opts) {
113
- if (opts.secrets.length === 0)
124
+ if (!opts.auth.enabled)
114
125
  return null;
115
126
  const c = readCookie(req, COOKIE);
116
- if (c && validSession(c, opts.secrets))
127
+ if (c && validSession(c, opts.auth))
117
128
  return { kind: 'session', cookie: c };
118
129
  const header = req.headers['authorization'];
119
- if (header !== undefined && opts.secrets.some((s) => checkBearer(s, header).ok))
130
+ const presented = header?.replace(/^Bearer\s+/i, '').trim();
131
+ if (presented && opts.auth.check(presented))
120
132
  return { kind: 'bearer' };
121
133
  return null;
122
134
  }
@@ -269,7 +281,7 @@ function decode(s) {
269
281
  export async function handleUi(req, res, pathname, opts) {
270
282
  // No token, no page: an open /ui would be readable by any site that
271
283
  // DNS-rebinds a name to the loopback address (the same rule as /cli).
272
- if (opts.secrets.length === 0) {
284
+ if (!opts.auth.enabled) {
273
285
  return send(res, 403, 'text/plain', 'forbidden: loom\'s page requires LOOM_BEARER_TOKEN');
274
286
  }
275
287
  // The page used to live under /ui. Old links and bookmarks land on the same page.
@@ -294,7 +306,7 @@ export async function handleUi(req, res, pathname, opts) {
294
306
  if (!form)
295
307
  return send(res, 413, 'text/plain', 'too large');
296
308
  const token = form.get('token')?.trim() ?? '';
297
- const match = opts.secrets.find((s) => safeEq(token, s));
309
+ const match = token ? opts.auth.check(token) : null;
298
310
  if (!match)
299
311
  return send(res, 401, 'text/html; charset=utf-8', renderLogin('That token didn’t match.'));
300
312
  return redirect(res, '/', {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jbarket/loomai",
3
- "version": "0.9.2",
3
+ "version": "0.11.0",
4
4
  "description": "MCP server for persistent AI agent identity and memory. Single-file sqlite-vec + local ONNX embeddings; zero external services.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",