@3sln/trove 0.0.17 → 0.0.19

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.
@@ -373,6 +373,29 @@ export function createRouter() {
373
373
  return { node: handle.node };
374
374
  });
375
375
 
376
+ // The same question with no collection in the path, for a caller that has an id.
377
+ //
378
+ // `api.stat(ref)` builds this URL whenever it is not given a collection, which is the
379
+ // normal case — an id and a `trove:` URI each name themselves, so there is nothing to
380
+ // scope. The route did not exist, so every such call answered "No such route": that is
381
+ // what broke `ctx.files.blob()` for plugins, and with it every viewer that reads its own
382
+ // file. The audiobook player showed it twice over — no cover art, and no sample tables
383
+ // to stream from — and on the drive it simply said "No such route" and stopped.
384
+ //
385
+ // A NAME is refused here rather than guessed at. A name is only unique inside a
386
+ // collection, so resolving one without saying which collection is a question with more
387
+ // than one right answer.
388
+ r.get('/api/items/resolve', [], async (ctx) => {
389
+ const ref = ctx.query.id || ctx.query.uri;
390
+ if (!ref) {
391
+ throw TroveError.invalid(ctx.query.name
392
+ ? 'a name is only unique within a collection — resolve it at /api/collections/:collection/items/resolve'
393
+ : 'id or uri is required');
394
+ }
395
+ const handle = await ctx.access.node(ref, 'read');
396
+ return { node: handle.node };
397
+ });
398
+
376
399
  // What links to this item — the inverse of the links its own content declares, and
377
400
  // what replaces "which folder is it in?".
378
401
  r.get('/api/items/backlinks', ['collections'], async (ctx) => {
@@ -854,6 +877,24 @@ export function createRouter() {
854
877
  admin: ctx.collections.isAdmin(ctx.principal),
855
878
  }));
856
879
 
880
+ /**
881
+ * Which plugin scope this caller may write, from `?scope=`.
882
+ *
883
+ * A contribution URI names a plugin, and the caller must have it installed — otherwise
884
+ * "scoped per plugin" is a naming convention rather than a boundary, and one plugin
885
+ * could read or overwrite another's state on every item in the drive.
886
+ */
887
+ async function requireScope(ctx) {
888
+ const scope = String(ctx.query.scope || '').trim();
889
+ if (!scope) throw TroveError.invalid('scope is required');
890
+ const pluginId = parseContribUri(scope)?.pluginId ?? (scope.startsWith('trove+plugin:') ? scope.slice('trove+plugin:'.length) : null);
891
+ if (!pluginId) throw TroveError.invalid('scope must be a trove+plugin: URI');
892
+ requirePlugins(ctx.plugins);
893
+ const installed = await ctx.plugins.get(ctx.principal, pluginId);
894
+ if (!installed) throw TroveError.forbidden(`"${pluginId}" is not installed for this account`);
895
+ return `trove+plugin:${pluginId}`;
896
+ }
897
+
857
898
  // --- conversations, tags, sidecar (per file) -------------------------------
858
899
  // The :id is a file node id; the sidecar is that file's CRDT document.
859
900
 
@@ -863,6 +904,39 @@ export function createRouter() {
863
904
  return (await ctx.access.node(ctx.params.id, 'read')).view();
864
905
  });
865
906
 
907
+ // A PLUGIN's own key/value data for this item — a listening position, a last page.
908
+ //
909
+ // The scope is `?scope=` and the route does NOT trust it on its own: `requireScope`
910
+ // checks the caller owns that plugin id, the same gate `/api/index/:indexerId` applies
911
+ // to contributions. Without it any caller could write into any plugin's namespace, and
912
+ // "scoped" would be a naming convention rather than a boundary.
913
+ //
914
+ // `read` to read and `write` to write, on the ITEM — this is data about someone's use of
915
+ // a file, so it follows the file's permissions.
916
+ r.get('/api/items/:id/data', ['sidecar'], async (ctx) => {
917
+ const scope = await requireScope(ctx);
918
+ await ctx.access.node(ctx.params.id, 'read');
919
+ return { scope, data: await ctx.sidecar.data(ctx.params.id, scope) };
920
+ });
921
+
922
+ r.post('/api/items/:id/data', ['sidecar'], async (ctx) => {
923
+ const scope = await requireScope(ctx);
924
+ requirePrincipal(ctx.principal);
925
+ await ctx.access.node(ctx.params.id, 'write');
926
+ const b = await body(ctx.req);
927
+ // A BATCH, because the client flushes what it queued while offline and one request
928
+ // per key would make a reconnect a thundering herd against one sidecar document.
929
+ const entries = Array.isArray(b.entries) ? b.entries : [{ key: b.key, value: b.value, remove: b.remove }];
930
+ for (const e of entries) {
931
+ if (!e?.key) continue;
932
+ if (e.remove) await ctx.sidecar.removeData(ctx.params.id, scope, e.key, ctx.principal);
933
+ else await ctx.sidecar.setData(ctx.params.id, scope, e.key, e.value, ctx.principal);
934
+ }
935
+ // The merged view back, so a client that was offline learns what won without a
936
+ // second round trip — which is the whole shape of "write, then reconcile".
937
+ return { scope, data: await ctx.sidecar.data(ctx.params.id, scope) };
938
+ });
939
+
866
940
  r.post('/api/items/:id/comments', [], async (ctx) => {
867
941
  requirePrincipal(ctx.principal);
868
942
  const node = await ctx.access.node(ctx.params.id, 'write');