@3sln/trove 0.0.3 → 0.0.5

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.
Files changed (47) hide show
  1. package/package.json +1 -1
  2. package/packages/core/src/apiKeys.js +326 -0
  3. package/packages/core/src/collections/index.js +83 -13
  4. package/packages/core/src/index.js +18 -4
  5. package/packages/core/src/issues.js +4 -0
  6. package/packages/core/src/notifications/channel.js +68 -0
  7. package/packages/core/src/notifications/index.js +72 -43
  8. package/packages/core/src/notifications/webpush.js +110 -0
  9. package/packages/core/src/sqlite-d1.js +14 -1
  10. package/packages/core/src/sqlite-driver.js +73 -1
  11. package/packages/core/src/storage/diagnose.js +234 -0
  12. package/packages/core/src/storage/drivers.js +74 -0
  13. package/packages/core/src/storage/filesystem.js +22 -0
  14. package/packages/core/src/storage/registry.js +129 -0
  15. package/packages/server/src/adapters/bun.js +6 -0
  16. package/packages/server/src/adapters/node.js +6 -0
  17. package/packages/server/src/adapters/worker-tasks.js +14 -4
  18. package/packages/server/src/adapters/worker.js +7 -1
  19. package/packages/server/src/engine/index.js +1 -1
  20. package/packages/server/src/engine/providers/access.js +47 -5
  21. package/packages/server/src/engine/providers/core.js +109 -16
  22. package/packages/server/src/index.js +137 -12
  23. package/packages/server/src/mcp/tools.js +40 -8
  24. package/packages/server/src/router.js +1 -1
  25. package/packages/server/src/routes.js +156 -46
  26. package/packages/server/src/scope.js +2 -2
  27. package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
  28. package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
  29. package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
  30. package/packages/web/dist/index.html +2 -2
  31. package/packages/web/dist/sw.js +58 -9
  32. package/packages/web/src/bl/actions.js +112 -13
  33. package/packages/web/src/bl/activity.js +32 -0
  34. package/packages/web/src/bl/commands.js +41 -2
  35. package/packages/web/src/bl/index.js +9 -4
  36. package/packages/web/src/bl/services.js +78 -1
  37. package/packages/web/src/platform/api.js +57 -14
  38. package/packages/web/src/platform/pluginRpc.js +7 -4
  39. package/packages/web/src/styles.css +137 -0
  40. package/packages/web/src/ui/components/activityPanel.js +28 -1
  41. package/packages/web/src/ui/components/collectionGate.js +81 -0
  42. package/packages/web/src/ui/components/overlays.js +64 -34
  43. package/packages/web/src/ui/components/phoneChrome.js +2 -2
  44. package/packages/web/src/ui/components/settingsView.js +197 -1
  45. package/packages/web/src/ui/components/statusBar.js +19 -2
  46. package/packages/web/src/ui/compositions/workbench.js +9 -2
  47. package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
@@ -15,9 +15,11 @@ import {
15
15
  VectorStore, KeywordStore, IndexerRegistry,
16
16
  accessHost, TroveError,
17
17
  protectedResourceMetadata, challengeHeaders, publicOrigin,
18
+ diagnoseStorage, STORAGE_ISSUE_CODES,
18
19
  } from '@3sln/trove/core';
19
- import { createRouter } from './routes.js';
20
+ import { createRouter, routeHelpers } from './routes.js';
20
21
  import { createDriveEngine, scanStarter, BACKBONE } from './engine/index.js';
22
+ import { storageRegistry } from './engine/providers/core.js';
21
23
  import { createMcpHandler } from './mcp/index.js';
22
24
  import { cacheControlFor } from './cachePolicy.js';
23
25
  import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
@@ -51,6 +53,10 @@ export async function createServer(config = {}) {
51
53
  // 200 lines of statements whose ORDER was the graph is a declaration the
52
54
  // container walks, which is also what makes `close()` stop being a hand-kept
53
55
  // list that had to agree with it.
56
+ // The driver registry is decided once, here, and shared: the providers build backends
57
+ // from it and /api/capabilities describes it, so the form a user sees and the set of
58
+ // things the server can actually construct cannot drift apart.
59
+ config = { ...config, storageRegistry: storageRegistry(config) };
54
60
  const lifecycleState = { closing: false, background: null };
55
61
  const engine = createDriveEngine(config, lifecycleState);
56
62
 
@@ -60,7 +66,7 @@ export async function createServer(config = {}) {
60
66
  const backbone = await engine.container.lease(BACKBONE);
61
67
  const {
62
68
  storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
63
- sidecar, collections, identity, auth, search, vfs, plugins,
69
+ sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities,
64
70
  } = backbone.resources;
65
71
 
66
72
  // Aliased so the rest of this function reads as it did; the container's
@@ -153,6 +159,68 @@ export async function createServer(config = {}) {
153
159
  const routeBeginReindex = lifecycleState.background.beginReindex;
154
160
  issues.handle('scan-collection', (issue) => startScan(issue.retry.collectionId, { reason: 'Retrying after a failed scan' }));
155
161
 
162
+ // --- storage self-check ----------------------------------------------------
163
+ // The failure this exists for: a bucket with no CORS policy serves the server fine and
164
+ // serves the browser nothing, so the drive looks healthy and every file opens to a
165
+ // spinner. See core/storage/diagnose.js for why the check has to be a real preflight.
166
+ //
167
+ // `origin` is the browser origin to check the policy against, and there is no guessing
168
+ // it: a policy may legitimately name one origin, so checking the wrong one would invent
169
+ // a problem. A request supplies its own; a cron firing has only `config.publicUrl`, and
170
+ // without either the CORS half is skipped rather than assumed.
171
+ const STORAGE_ISSUE_KIND = 'storage';
172
+ async function checkStorage({ origin = null } = {}) {
173
+ // `all()`, not `list(null)`: this has no user, and asking what the anonymous principal
174
+ // may read means checking nothing at all on a drive that is not public.
175
+ const list = collections ? await collections.all().catch(() => []) : [];
176
+ const results = [];
177
+ for (const c of list) {
178
+ let findings;
179
+ try {
180
+ const storage = await collections.storageFor(c.id);
181
+ findings = await diagnoseStorage({
182
+ storage, origin, driver: c.store?.driver || null, fetchImpl: config.fetch,
183
+ });
184
+ } catch (err) {
185
+ // Failing to BUILD the store is itself the most severe version of unreachable —
186
+ // an unknown driver or a config missing a required field never gets far enough
187
+ // to be asked whether it can be read.
188
+ findings = [{
189
+ code: 'storage-unreachable',
190
+ severity: 'error',
191
+ title: 'This collection’s store could not be opened',
192
+ detail: err?.message || String(err),
193
+ }];
194
+ }
195
+ const found = new Set(findings.map((f) => f.code));
196
+ for (const f of findings) {
197
+ await issues.raise({
198
+ kind: STORAGE_ISSUE_KIND,
199
+ subject: `${c.id}:${f.code}`,
200
+ title: `${c.name || c.id}: ${f.title}`,
201
+ detail: f.detail,
202
+ remedy: f.remedy || null,
203
+ severity: f.severity,
204
+ collectionId: c.id,
205
+ // Re-running the check IS the fix verification, so Retry rechecks against the
206
+ // same origin the finding was made for. Checking a different one would report
207
+ // a pass for a policy the affected browser still cannot use.
208
+ retry: { op: 'storage-check', origin },
209
+ });
210
+ }
211
+ // Whatever is no longer true stops being listed. Without this, fixing the bucket
212
+ // leaves the warning up, and a problem list that outlives its problems is one
213
+ // people learn to scroll past.
214
+ for (const code of STORAGE_ISSUE_CODES) {
215
+ if (!found.has(code)) await issues.clear(STORAGE_ISSUE_KIND, `${c.id}:${code}`);
216
+ }
217
+ results.push({ collectionId: c.id, name: c.name || c.id, findings });
218
+ }
219
+ return { checked: results.length, corsChecked: !!origin, results };
220
+ }
221
+ issues.handle('storage-check', (issue) => checkStorage({ origin: issue.retry?.origin || config.publicUrl || null }));
222
+ lifecycleState.storageCheck = checkStorage;
223
+
156
224
  issues.handle('reindex-node', (issue) => tasks.run(
157
225
  // Carries the issue's collection, so the person who can see the file can also see
158
226
  // the task fixing it — a task nobody is allowed to watch is not a task worth having.
@@ -197,9 +265,9 @@ export async function createServer(config = {}) {
197
265
  if (config.startFlusher !== false && config.scanIntervalMs) {
198
266
  scanTimer = setInterval(() => {
199
267
  if (tasks.list().some((t) => t.kind === 'scan' && t.status === 'running')) return; // still going
200
- Promise.resolve(collections ? collections.list(null).catch(() => []) : [{ id: 'default' }])
268
+ Promise.resolve(collections ? collections.list(null).catch(() => []) : [])
201
269
  .then(async (list) => {
202
- for (const c of list.length ? list : [{ id: 'default' }]) {
270
+ for (const c of list) {
203
271
  await startScan(c.id, { reason: 'Scheduled' }).catch(() => {});
204
272
  }
205
273
  })
@@ -210,6 +278,17 @@ export async function createServer(config = {}) {
210
278
 
211
279
  const router = createRouter();
212
280
 
281
+ // Routes contributed by delivery channels — the endpoints a client uses to REGISTER
282
+ // with one, of which a VAPID key and a push subscription are the obvious example.
283
+ // Mounted here rather than declared in routes.js so the drive's API reflects what is
284
+ // actually configured: no web push, no /api/push/*. Added after the core table, so a
285
+ // channel cannot shadow a built-in route by claiming its path.
286
+ for (const channel of notifications?.channels || []) {
287
+ for (const route of channel.routes?.(routeHelpers) || []) {
288
+ router.add(route.method, route.path, route.deps || [], route.handler);
289
+ }
290
+ }
291
+
213
292
  // Said at boot, because that is when someone is looking and can still fix it. The
214
293
  // alternative is discovering it from a client that can't sign in and a 401 that
215
294
  // doesn't say why.
@@ -270,9 +349,20 @@ export async function createServer(config = {}) {
270
349
  if (url.pathname.startsWith('/api/')) {
271
350
  // Authenticate every API request; a bad token is a clean 401, missing is
272
351
  // anonymous-or-401 per the provider's policy.
352
+ //
353
+ // A capability grant is resolved FIRST, and when one is found the identity step is
354
+ // skipped entirely. That ordering is the design: an API key answers "what may this
355
+ // request do" and deliberately does not answer "who is this", so there is no
356
+ // principal to attach and nothing downstream can mistake a key for a person.
357
+ //
358
+ // Resolving both would be worse than useless. A request bearing a weak key and a
359
+ // strong session would get the union of the two, which is the confused deputy
360
+ // reached by being accommodating — so it is one or the other, never both.
273
361
  let principal = null;
362
+ let grant = null;
274
363
  try {
275
- principal = await identity.authenticate(req);
364
+ grant = await capabilities.resolve(req);
365
+ if (!grant) principal = await identity.authenticate(req);
276
366
  } catch (err) {
277
367
  const e = err instanceof TroveError ? err : TroveError.unauthorized('Authentication failed');
278
368
  return withChallenge(new Response(JSON.stringify(e.toJSON()), { status: e.status, headers: { 'content-type': 'application/json', 'x-content-type-options': 'nosniff' } }), req);
@@ -284,7 +374,7 @@ export async function createServer(config = {}) {
284
374
  // locator — nothing recorded what a route used, so nothing stopped it
285
375
  // reaching for more.
286
376
  container: engine.container,
287
- config, principal, auth, mcp,
377
+ config, principal, grant, auth, mcp,
288
378
  });
289
379
  // A route can refuse on its own (a token that verified but names nobody we know,
290
380
  // a session that expired between calls). Whatever refused, the answer to "so
@@ -341,15 +431,40 @@ export async function createServer(config = {}) {
341
431
  * does, so it does as much as it can and stores where it got to.
342
432
  */
343
433
  async function runMaintenance({ budgetMs = 20_000, scan = true } = {}) {
344
- const out = { swept: false, purged: 0, scans: [] };
434
+ const out = { swept: false, purged: 0, scans: [], notified: 0, storage: 0 };
345
435
  await vfs.uploads.sweepExpired(Date.now());
346
436
  await sidecar.sweep();
437
+ // Mentions are batched and drained on an interval — a timer, and a timer registered
438
+ // during a request does not outlive it on Workers, where the adapter switches the
439
+ // flusher off for exactly that reason. Nothing else called flush, so on that runtime
440
+ // mentions piled up in the pending store and were never delivered at all: no inbox
441
+ // entry, no push, no error. Maintenance runs from a cron there, which is the one
442
+ // thing that does fire. Harmless where the timer works — concurrent drains collapse.
443
+ out.notified = await notifications.flush().catch((e) => {
444
+ console.error('[trove] mention flush failed', e);
445
+ return 0;
446
+ });
447
+ // Cheap (one preflight per collection) and the only thing that will ever notice a
448
+ // bucket policy that was fine yesterday, so it runs on every firing rather than
449
+ // waiting for someone to open the Activity panel and press a button.
450
+ out.storage = await checkStorage({ origin: config.publicUrl || null })
451
+ .then((r) => r.checked)
452
+ .catch((e) => {
453
+ console.error('[trove] storage check failed', e);
454
+ return 0;
455
+ });
347
456
  const trashMs = (config.trashRetentionDays ?? 30) * 86400_000;
348
457
  if (trashMs > 0) out.purged = (await vfs.purgeTrash({ before: Date.now() - trashMs }))?.purged || 0;
349
458
  out.swept = true;
350
459
  if (!scan) return out;
351
- const list = collections ? await collections.list(null).catch(() => []) : [];
352
- const targets = list.length ? list : [{ id: 'default' }];
460
+ // `all()` rather than `list(null)`. Maintenance has no user, and `list(null)` answers
461
+ // "what may the anonymous principal read" which on any drive that is not open to the
462
+ // public is nothing, so the scheduled scan silently scanned no collection whatsoever.
463
+ // No collections means nothing to scan. It used to mean "scan the one called
464
+ // default", which on a drive that has none is a scan of a collection that does not
465
+ // exist — work that fails every cron firing and reports it as a scan error.
466
+ const targets = collections ? await collections.all().catch(() => []) : [];
467
+ if (!targets.length) return out;
353
468
  // Share the budget across collections so one huge bucket can't starve the rest.
354
469
  const each = Math.max(1000, Math.floor(budgetMs / targets.length));
355
470
  for (const c of targets) {
@@ -359,7 +474,7 @@ export async function createServer(config = {}) {
359
474
  return out;
360
475
  }
361
476
 
362
- return { vfs, handle, router, sidecar, notifications, identity, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
477
+ return { vfs, handle, router, sidecar, notifications, identity, apiKeys, capabilities, kv, collections, plugins, sqlite: sqliteProvider, tasks, issues, indexRebuild,
363
478
  // The graph itself, so an action or query can be dispatched directly — by a
364
479
  // test, by MCP, by anything that is not an HTTP route.
365
480
  engine, engineContainer: engine.container,
@@ -367,7 +482,7 @@ export async function createServer(config = {}) {
367
482
  // inside a Durable Object want. `begin*` goes wherever `config.background` says,
368
483
  // which for a front-line Worker isolate is the object rather than itself.
369
484
  startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
370
- runMaintenance, mcp, auth, close };
485
+ runMaintenance, checkStorage, mcp, auth, close };
371
486
  }
372
487
 
373
488
  /**
@@ -619,7 +734,17 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
619
734
  config.kv = { driver: env.TROVE_KV || (config.metadata.driver === 'sqlite' ? 'sqlite' : 'memory'), path: config.metadata.path };
620
735
 
621
736
  // Collections: on by default. Admins (global) + roles that can create collections.
622
- if (env.TROVE_COLLECTIONS === 'false') config.collections = false;
737
+ // TROVE_COLLECTIONS=false used to mean "one implicit unnamed store, no ACLs". That is
738
+ // precisely the fallback this drive no longer has: every collection-scoped endpoint
739
+ // names its collection in the path, so there is nothing for an unnamed store to answer.
740
+ // Refused loudly rather than ignored — a drive that quietly kept enforcing after being
741
+ // told not to would be a surprise in the wrong direction.
742
+ if (env.TROVE_COLLECTIONS === 'false') {
743
+ throw TroveError.invalid(
744
+ 'TROVE_COLLECTIONS=false is no longer supported — endpoints are scoped to a named '
745
+ + 'collection. Remove the setting; create one collection and use it.',
746
+ );
747
+ }
623
748
  config.admins = (env.TROVE_ADMINS || '').split(',').map((s) => s.trim()).filter(Boolean);
624
749
  config.creatorRoles = (env.TROVE_COLLECTION_CREATOR_ROLES || '').split(',').map((s) => s.trim()).filter(Boolean);
625
750
  // 'default' collection grants everyone all caps unless locked down.
@@ -98,6 +98,33 @@ async function readText(handle) {
98
98
  * `ctx` at call time carries { vfs, collections, principal } — the same objects the HTTP
99
99
  * routes get, so the two surfaces cannot drift apart on what a given user may do.
100
100
  */
101
+ /**
102
+ * An agent has to say which collection it means.
103
+ *
104
+ * There used to be a `'default'` here, which made every tool call work on a fresh drive
105
+ * and quietly work on the WRONG collection on a real one — an agent asked to file
106
+ * something would put it wherever the default happened to be, which on a multi-user drive
107
+ * is a collection its user may not even be able to read. Naming it is one extra call to
108
+ * list_collections and removes a whole class of silently-misplaced writes.
109
+ */
110
+ function requireCollection(collection) {
111
+ if (!collection) {
112
+ throw TroveError.invalid('A collection is required — call list_collections to see which ones you can use');
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The same, but only when the reference needs it. A file id or a `trove:` URI already
118
+ * names its collection; a bare NAME is only unique within one.
119
+ */
120
+ function requireCollectionForName(file, collection) {
121
+ const ref = String(file || '');
122
+ const selfNaming = ref.startsWith('trove:') || /^itm_/.test(ref);
123
+ if (!selfNaming && !collection) {
124
+ throw TroveError.invalid(`"${ref}" is a name, so it needs a collection — or pass a file id or trove: URI`);
125
+ }
126
+ }
127
+
101
128
  export function registerTroveTools(server) {
102
129
  server.instructions = INSTRUCTIONS;
103
130
 
@@ -152,12 +179,13 @@ export function registerTroveTools(server) {
152
179
  inputSchema: {
153
180
  type: 'object',
154
181
  properties: {
155
- collection: { type: 'string', description: 'Which collection (default: "default").' },
182
+ collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
156
183
  cursor: { type: 'string', description: 'Continue from a previous call.' },
157
184
  limit: { type: 'integer', description: `Maximum items (default 25, max ${MAX_RESULTS}).` },
158
185
  },
159
186
  },
160
- async run({ collection = 'default', cursor, limit }, ctx) {
187
+ async run({ collection, cursor, limit }, ctx) {
188
+ requireCollection(collection);
161
189
  // The description promises recency and `list` defaults to alphabetical, so it
162
190
  // has to be asked for. An agent answering "what did I add recently?" off the top
163
191
  // of an alphabetical list is confidently wrong in a way nothing surfaces.
@@ -184,11 +212,12 @@ export function registerTroveTools(server) {
184
212
  type: 'object',
185
213
  properties: {
186
214
  file: { type: 'string', description: 'A file id, a name, or a trove: URI.' },
187
- collection: { type: 'string', description: 'Which collection to look in when given a name (default: "default").' },
215
+ collection: { type: 'string', description: 'Which collection to look in. Required when `file` is a name; a file id or trove: URI names its own.' },
188
216
  },
189
217
  required: ['file'],
190
218
  },
191
- async run({ file, collection = 'default' }, ctx) {
219
+ async run({ file, collection }, ctx) {
220
+ requireCollectionForName(file, collection);
192
221
  const handle = await fileHandle(ctx, file, collection, 'read');
193
222
  const node = handle.node;
194
223
  if (!textLike(node.contentType)) {
@@ -216,12 +245,13 @@ export function registerTroveTools(server) {
216
245
  properties: {
217
246
  name: { type: 'string', description: 'The file name, including its extension.' },
218
247
  content: { type: 'string', description: 'The text to write.' },
219
- collection: { type: 'string', description: 'Which collection (default: "default").' },
248
+ collection: { type: 'string', description: 'Which collection to act in. Required — call list_collections first.' },
220
249
  contentType: { type: 'string', description: 'Override the type guessed from the name.' },
221
250
  },
222
251
  required: ['name', 'content'],
223
252
  },
224
- async run({ name, content, collection = 'default', contentType }, ctx) {
253
+ async run({ name, content, collection, contentType }, ctx) {
254
+ requireCollection(collection);
225
255
  if (!name?.trim()) throw TroveError.invalid('name is required');
226
256
  const into = await ctx.access.collection(collection, 'write');
227
257
  // No contentType falls through to the vfs's own guess from the name.
@@ -245,7 +275,8 @@ export function registerTroveTools(server) {
245
275
  },
246
276
  required: ['file'],
247
277
  },
248
- async run({ file, collection = 'default' }, ctx) {
278
+ async run({ file, collection }, ctx) {
279
+ requireCollectionForName(file, collection);
249
280
  const handle = await fileHandle(ctx, file, collection, 'delete');
250
281
  await handle.remove();
251
282
  return toolText(`Moved "${handle.name}" to the trash. It can be restored from the drive's trash.`);
@@ -284,7 +315,8 @@ export function registerTroveTools(server) {
284
315
  },
285
316
  required: ['file'],
286
317
  },
287
- async run({ file, collection = 'default' }, ctx) {
318
+ async run({ file, collection }, ctx) {
319
+ requireCollectionForName(file, collection);
288
320
  const handle = await fileHandle(ctx, file, collection, 'read');
289
321
  const node = handle.node;
290
322
  // Scoped, exactly like the HTTP route. Backlinks reach ACROSS collections by
@@ -139,7 +139,7 @@ export class Router {
139
139
  // handler asks for an AUTHORIZED view of a node, collection or upload: the
140
140
  // grant is carried by the object it hands back, so there is no unrestricted
141
141
  // service and no raw id left over to use with one.
142
- const scope = leaseScope(ctx.container, ctx.principal);
142
+ const scope = leaseScope(ctx.container, ctx.principal, ctx.grant);
143
143
  const access = scope.access;
144
144
  try {
145
145
  lease = ctx.container ? await ctx.container.lease(found.route.deps) : null;