@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.
- package/package.json +1 -1
- package/packages/core/src/apiKeys.js +326 -0
- package/packages/core/src/collections/index.js +83 -13
- package/packages/core/src/index.js +18 -4
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/notifications/channel.js +68 -0
- package/packages/core/src/notifications/index.js +72 -43
- package/packages/core/src/notifications/webpush.js +110 -0
- package/packages/core/src/sqlite-d1.js +14 -1
- package/packages/core/src/sqlite-driver.js +73 -1
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +74 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +129 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/adapters/worker-tasks.js +14 -4
- package/packages/server/src/adapters/worker.js +7 -1
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +47 -5
- package/packages/server/src/engine/providers/core.js +109 -16
- package/packages/server/src/index.js +137 -12
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +156 -46
- package/packages/server/src/scope.js +2 -2
- package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
- package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
- package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +58 -9
- package/packages/web/src/bl/actions.js +112 -13
- package/packages/web/src/bl/activity.js +32 -0
- package/packages/web/src/bl/commands.js +41 -2
- package/packages/web/src/bl/index.js +9 -4
- package/packages/web/src/bl/services.js +78 -1
- package/packages/web/src/platform/api.js +57 -14
- package/packages/web/src/platform/pluginRpc.js +7 -4
- package/packages/web/src/styles.css +137 -0
- package/packages/web/src/ui/components/activityPanel.js +28 -1
- package/packages/web/src/ui/components/collectionGate.js +81 -0
- package/packages/web/src/ui/components/overlays.js +64 -34
- package/packages/web/src/ui/components/phoneChrome.js +2 -2
- package/packages/web/src/ui/components/settingsView.js +197 -1
- package/packages/web/src/ui/components/statusBar.js +19 -2
- package/packages/web/src/ui/compositions/workbench.js +9 -2
- package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
|
@@ -134,8 +134,22 @@ function assertPublicHost(hostname) {
|
|
|
134
134
|
|
|
135
135
|
// Which collection a request targets. There is no folder to infer one from any more,
|
|
136
136
|
// so it's named explicitly or it's the default.
|
|
137
|
-
|
|
138
|
-
|
|
137
|
+
/**
|
|
138
|
+
* The collection a request is scoped to, from the PATH.
|
|
139
|
+
*
|
|
140
|
+
* There is no fallback. A collection-scoped route names its collection in the URL —
|
|
141
|
+
* `/api/collections/:collection/items` — so a request that does not name one cannot
|
|
142
|
+
* reach the handler at all, and the router refuses it before this is called.
|
|
143
|
+
*
|
|
144
|
+
* The fallback that used to live here (`?collection=` or else `'default'`) was the same
|
|
145
|
+
* class of bug as everything else in this file's history: a missing value silently
|
|
146
|
+
* became a specific one, so an unscoped write went somewhere real and looked fine. On a
|
|
147
|
+
* multi-user drive that somewhere was a collection plenty of people cannot even read.
|
|
148
|
+
*/
|
|
149
|
+
function scopedCollection(ctx) {
|
|
150
|
+
const id = ctx.params?.collection;
|
|
151
|
+
if (!id) throw TroveError.invalid('This endpoint is scoped to a collection');
|
|
152
|
+
return id;
|
|
139
153
|
}
|
|
140
154
|
|
|
141
155
|
export function createRouter() {
|
|
@@ -163,8 +177,13 @@ export function createRouter() {
|
|
|
163
177
|
storage = await (await ctx.access.collection(query.collection, 'read')).storage();
|
|
164
178
|
}
|
|
165
179
|
return {
|
|
166
|
-
collection: query.collection ||
|
|
180
|
+
collection: query.collection || null,
|
|
167
181
|
storage: storage.capabilities,
|
|
182
|
+
// What kinds of backing store THIS deployment can build, with the fields each one
|
|
183
|
+
// needs. The collection form used to hardcode this list, which is how a Cloudflare
|
|
184
|
+
// Workers drive came to offer "Filesystem / NAS" — a choice its runtime cannot
|
|
185
|
+
// honour. Answered by the server now, so the form can only offer what exists.
|
|
186
|
+
storageDrivers: ctx.config.storageRegistry?.describe?.() || [],
|
|
168
187
|
indexers: vfs.indexers.list(),
|
|
169
188
|
partSize: vfs.uploads.partSize,
|
|
170
189
|
features: {
|
|
@@ -213,13 +232,11 @@ export function createRouter() {
|
|
|
213
232
|
|
|
214
233
|
r.get('/api/collections', ['collections'], async (ctx) => {
|
|
215
234
|
const { collections, principal } = ctx;
|
|
216
|
-
if (!collectionsEnabled(ctx)) return { collections: [{ id: 'default', name: 'My Drive', capabilities: ['read', 'write', 'delete', 'admin'] }] };
|
|
217
235
|
return { collections: await collections.list(principal), canCreate: collections.canCreate(principal) };
|
|
218
236
|
});
|
|
219
237
|
|
|
220
238
|
r.get('/api/collections/:id', ['collections'], async (ctx) => {
|
|
221
239
|
const { collections, principal, params } = ctx;
|
|
222
|
-
if (!collectionsEnabled(ctx)) return { collection: { id: 'default', name: 'My Drive' } };
|
|
223
240
|
const c = await collections.assert(principal, params.id, 'read');
|
|
224
241
|
return { collection: collections.describe(c, principal) };
|
|
225
242
|
});
|
|
@@ -259,6 +276,37 @@ export function createRouter() {
|
|
|
259
276
|
return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };
|
|
260
277
|
});
|
|
261
278
|
|
|
279
|
+
// --- API keys ---------------------------------------------------------------
|
|
280
|
+
//
|
|
281
|
+
// Managing keys requires a real admin PRINCIPAL, and a key can never be used to reach
|
|
282
|
+
// these routes — `requireHumanAdmin` refuses when the request arrived on a grant. That
|
|
283
|
+
// asymmetry is deliberate: a key that could mint keys is a key that can escalate
|
|
284
|
+
// itself, and revoking it would not revoke what it had already issued. The blast
|
|
285
|
+
// radius of a leaked key stops at the capabilities it was given.
|
|
286
|
+
|
|
287
|
+
r.get('/api/keys', ['apiKeys', 'collections'], async (ctx) => {
|
|
288
|
+
requireHumanAdmin(ctx, 'manage API keys');
|
|
289
|
+
return { keys: await ctx.apiKeys.list() };
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
r.post('/api/keys', ['apiKeys', 'collections'], async (ctx) => {
|
|
293
|
+
requireHumanAdmin(ctx, 'mint API keys');
|
|
294
|
+
const b = await body(ctx.req);
|
|
295
|
+
// The secret comes back exactly once, here, and is never retrievable again.
|
|
296
|
+
const { record, secret } = await ctx.apiKeys.mint({
|
|
297
|
+
name: b.name,
|
|
298
|
+
scopes: b.scopes,
|
|
299
|
+
expiresAt: b.expiresAt ?? null,
|
|
300
|
+
createdBy: ctx.principal?.id ?? null,
|
|
301
|
+
});
|
|
302
|
+
return { key: record, secret };
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
r.delete('/api/keys/:id', ['apiKeys', 'collections'], async (ctx) => {
|
|
306
|
+
requireHumanAdmin(ctx, 'revoke API keys');
|
|
307
|
+
return { key: await ctx.apiKeys.revoke(ctx.params.id) };
|
|
308
|
+
});
|
|
309
|
+
|
|
262
310
|
// --- browse ----------------------------------------------------------------
|
|
263
311
|
|
|
264
312
|
// --- items -----------------------------------------------------------------
|
|
@@ -269,9 +317,9 @@ export function createRouter() {
|
|
|
269
317
|
|
|
270
318
|
// Every item in a collection. There is nothing to descend into — a drive is browsed
|
|
271
319
|
// by search and by following links, and this is the "show me everything" fallback.
|
|
272
|
-
r.get('/api/items', ['vfs'], async (ctx) => {
|
|
320
|
+
r.get('/api/collections/:collection/items', ['vfs'], async (ctx) => {
|
|
273
321
|
const { vfs, query } = ctx;
|
|
274
|
-
const collectionId =
|
|
322
|
+
const collectionId = scopedCollection(ctx);
|
|
275
323
|
const collection = await ctx.access.collection(collectionId, 'read');
|
|
276
324
|
const { items, nextCursor } = await collection.list({
|
|
277
325
|
sort: query.sort, order: query.order,
|
|
@@ -290,12 +338,12 @@ export function createRouter() {
|
|
|
290
338
|
});
|
|
291
339
|
|
|
292
340
|
// Resolve an item: by id, by `?name=` within a collection, or by a `trove:` URI.
|
|
293
|
-
r.get('/api/items/resolve', [], async (ctx) => {
|
|
341
|
+
r.get('/api/collections/:collection/items/resolve', [], async (ctx) => {
|
|
294
342
|
const ref = ctx.query.id || ctx.query.uri || ctx.query.name;
|
|
295
343
|
if (!ref) throw TroveError.invalid('id, name or uri is required');
|
|
296
|
-
// A name is only unique within a collection,
|
|
297
|
-
//
|
|
298
|
-
const handle = await ctx.access.node(ref, 'read', { collectionId:
|
|
344
|
+
// A name is only unique within a collection, which is the reason this one is scoped
|
|
345
|
+
// by path at all: resolving `notes.md` is a different question in each collection.
|
|
346
|
+
const handle = await ctx.access.node(ref, 'read', { collectionId: scopedCollection(ctx) });
|
|
299
347
|
return { node: handle.node };
|
|
300
348
|
});
|
|
301
349
|
|
|
@@ -396,10 +444,10 @@ export function createRouter() {
|
|
|
396
444
|
|
|
397
445
|
// --- uploads ---------------------------------------------------------------
|
|
398
446
|
|
|
399
|
-
r.post('/api/uploads', [], async (ctx) => {
|
|
447
|
+
r.post('/api/collections/:collection/uploads', [], async (ctx) => {
|
|
400
448
|
const b = await body(ctx.req);
|
|
401
449
|
if (!b.name) throw TroveError.invalid('name is required');
|
|
402
|
-
const collection = await ctx.access.collection(
|
|
450
|
+
const collection = await ctx.access.collection(scopedCollection(ctx), 'write');
|
|
403
451
|
return uploadDescriptor(await collection.createUpload({
|
|
404
452
|
name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
|
|
405
453
|
overwrite: b.overwrite === true,
|
|
@@ -442,26 +490,44 @@ export function createRouter() {
|
|
|
442
490
|
|
|
443
491
|
// --- search & indexing -----------------------------------------------------
|
|
444
492
|
|
|
445
|
-
|
|
493
|
+
// Raw search: the query string is passed through as text, with NO transformer.
|
|
494
|
+
//
|
|
495
|
+
// Which means the `#tag` grammar does not apply here. `/api/capabilities` advertises a
|
|
496
|
+
// searchPrompt telling people `#tag` narrows by tag, and against this endpoint that
|
|
497
|
+
// degrades into a text search for the literal string "#tag" — no error, just quietly
|
|
498
|
+
// different results. POST /api/query is the one that runs the transformer and is what
|
|
499
|
+
// the workbench uses; this stays as the lower-level endpoint for callers that have
|
|
500
|
+
// already resolved their own query.
|
|
501
|
+
// Searching comes in two shapes, and which one you get is in the URL rather than in
|
|
502
|
+
// the presence of a parameter. The flat route searches every collection the caller can
|
|
503
|
+
// read; the scoped one searches exactly the collection it names.
|
|
504
|
+
//
|
|
505
|
+
// Two routes rather than `?collection=` because omitting a query parameter used to mean
|
|
506
|
+
// "the whole drive" — a missing value silently choosing the broadest possible scope,
|
|
507
|
+
// which is the same failure shape as the old `'default'` fallback pointing the other
|
|
508
|
+
// way. Neither is something to arrive at by accident.
|
|
509
|
+
const searchHandler = async (ctx) => {
|
|
446
510
|
const { vfs, query } = ctx;
|
|
447
511
|
if (!query.q) throw TroveError.invalid('q is required');
|
|
448
|
-
const collectionIds = await readableCollectionIds(ctx,
|
|
512
|
+
const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
|
|
449
513
|
const results = await vfs.searchQuery(query.q, {
|
|
450
514
|
mode: query.mode, limit: clampLimit(query.limit, 40),
|
|
451
515
|
indexers: query.indexers ? query.indexers.split(',') : undefined,
|
|
452
516
|
collectionIds,
|
|
453
517
|
});
|
|
454
518
|
return { query: query.q, results };
|
|
455
|
-
}
|
|
519
|
+
};
|
|
520
|
+
r.get('/api/search', ['collections', 'vfs'], searchHandler);
|
|
521
|
+
r.get('/api/collections/:collection/search', ['collections', 'vfs'], searchHandler);
|
|
456
522
|
|
|
457
523
|
// Unified query: a raw user string is run through the search transformer (default
|
|
458
524
|
// parses `#tag` syntax; a plugged-in one may use an LLM), then dispatched. Returns
|
|
459
525
|
// the results AND the `resolved` query (what was actually searched) so the client
|
|
460
526
|
// can honestly show it.
|
|
461
|
-
|
|
527
|
+
const queryHandler = async (ctx) => {
|
|
462
528
|
const b = await body(ctx.req);
|
|
463
529
|
if (typeof b.q !== 'string' || !b.q.trim()) throw TroveError.invalid('q is required');
|
|
464
|
-
const collectionIds = await readableCollectionIds(ctx,
|
|
530
|
+
const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
|
|
465
531
|
const { results, resolved } = await ctx.vfs.query(b.q, {
|
|
466
532
|
mode: b.mode, limit: clampLimit(b.limit, 40), collectionIds,
|
|
467
533
|
// Which views this client can draw with, so the transformer can suggest one of
|
|
@@ -470,18 +536,22 @@ export function createRouter() {
|
|
|
470
536
|
views: Array.isArray(b.views) ? b.views : undefined,
|
|
471
537
|
});
|
|
472
538
|
return { query: b.q, results, resolved };
|
|
473
|
-
}
|
|
539
|
+
};
|
|
540
|
+
r.post('/api/query', ['collections', 'vfs'], queryHandler);
|
|
541
|
+
r.post('/api/collections/:collection/query', ['collections', 'vfs'], queryHandler);
|
|
474
542
|
|
|
475
543
|
// Drive-wide tag/property filter (the launcher's `#tag` / `#key:op:value`).
|
|
476
|
-
|
|
544
|
+
const tagSearchHandler = async (ctx) => {
|
|
477
545
|
const b = await body(ctx.req);
|
|
478
546
|
const filters = Array.isArray(b.filters) ? b.filters : [];
|
|
479
|
-
const collectionIds = await readableCollectionIds(ctx,
|
|
547
|
+
const collectionIds = await readableCollectionIds(ctx, ctx.params?.collection);
|
|
480
548
|
const items = await ctx.vfs.findByTags(filters, {
|
|
481
549
|
q: b.q, collectionIds, limit: clampLimit(b.limit, 100),
|
|
482
550
|
});
|
|
483
551
|
return { items };
|
|
484
|
-
}
|
|
552
|
+
};
|
|
553
|
+
r.post('/api/tags/search', ['collections', 'vfs'], tagSearchHandler);
|
|
554
|
+
r.post('/api/collections/:collection/tags/search', ['collections', 'vfs'], tagSearchHandler);
|
|
485
555
|
|
|
486
556
|
r.get('/api/indexers', ['vfs'], ({ vfs }) => ({ indexers: vfs.indexers.list() }));
|
|
487
557
|
|
|
@@ -590,6 +660,19 @@ export function createRouter() {
|
|
|
590
660
|
return { ok: true };
|
|
591
661
|
});
|
|
592
662
|
|
|
663
|
+
// Check the backing stores on demand, and answer with what was found rather than only
|
|
664
|
+
// leaving issues behind — an admin who just pressed "Check storage" is owed the result
|
|
665
|
+
// of the check they asked for.
|
|
666
|
+
//
|
|
667
|
+
// Origin comes from THIS request, which is the whole reason the on-demand version
|
|
668
|
+
// exists alongside the scheduled one: a bucket policy may legitimately name a single
|
|
669
|
+
// origin, and the origin that matters is the one browsers are actually using to reach
|
|
670
|
+
// the drive. A cron firing can only fall back to a configured TROVE_PUBLIC_URL.
|
|
671
|
+
r.post('/api/diagnostics/storage', ['collections', 'issues', 'storageCheck'], async (ctx) => {
|
|
672
|
+
await requireWholeDrive(ctx, 'check the backing stores');
|
|
673
|
+
return ctx.storageCheck.run({ origin: publicOrigin(ctx.req, ctx.config) });
|
|
674
|
+
});
|
|
675
|
+
|
|
593
676
|
// Rebuild the search index on demand. Admin-only: it re-reads every object in the
|
|
594
677
|
// drive, so it is a real load, and it is drive-wide rather than scoped to anything
|
|
595
678
|
// the caller owns. Returns the task, which is how the caller watches it.
|
|
@@ -613,8 +696,8 @@ export function createRouter() {
|
|
|
613
696
|
// `delete` on the collection, the same capability the delete itself needed — seeing
|
|
614
697
|
// what you deleted, and undoing it, are not lesser rights than deleting.
|
|
615
698
|
|
|
616
|
-
r.get('/api/trash', [], async (ctx) => {
|
|
617
|
-
const collectionId =
|
|
699
|
+
r.get('/api/collections/:collection/trash', [], async (ctx) => {
|
|
700
|
+
const collectionId = scopedCollection(ctx);
|
|
618
701
|
const collection = await ctx.access.collection(collectionId, 'delete');
|
|
619
702
|
return { items: await collection.listTrash({ limit: clampLimit(ctx.query.limit, 200) }), collectionId };
|
|
620
703
|
});
|
|
@@ -629,14 +712,19 @@ export function createRouter() {
|
|
|
629
712
|
|
|
630
713
|
// Destroy for real. Separate from DELETE /api/items so that emptying the trash can
|
|
631
714
|
// never be something you reach by accident from the ordinary delete path.
|
|
715
|
+
// One item, by id. The node names its own collection, so this stays flat.
|
|
632
716
|
r.post('/api/trash/purge', [], async (ctx) => {
|
|
633
717
|
const b = await body(ctx.req);
|
|
634
|
-
if (b.id)
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
718
|
+
if (!b.id) throw TroveError.invalid('id is required — to empty a collection\u2019s trash, use /api/collections/:collection/trash/purge');
|
|
719
|
+
const node = await ctx.access.node(b.id, 'delete', { trashed: true });
|
|
720
|
+
await node.remove({ permanent: true });
|
|
721
|
+
return { purged: 1 };
|
|
722
|
+
});
|
|
723
|
+
|
|
724
|
+
// Empty a whole collection's trash. Scoped by path, because "everything in here" is
|
|
725
|
+
// exactly the request that must never be able to mean a collection you did not name.
|
|
726
|
+
r.post('/api/collections/:collection/trash/purge', [], async (ctx) => {
|
|
727
|
+
const collection = await ctx.access.collection(scopedCollection(ctx), 'delete');
|
|
640
728
|
return collection.purgeTrash({ limit: MAX_PAGE });
|
|
641
729
|
});
|
|
642
730
|
|
|
@@ -748,20 +836,10 @@ export function createRouter() {
|
|
|
748
836
|
return notifications.markRead(principal.id, b.ids);
|
|
749
837
|
});
|
|
750
838
|
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
requirePrincipal(principal);
|
|
756
|
-
const b = await body(req);
|
|
757
|
-
return notifications.subscribePush(principal.id, b.subscription);
|
|
758
|
-
});
|
|
759
|
-
r.delete('/api/push/subscribe', ['notifications'], async ({ notifications, principal, req }) => {
|
|
760
|
-
requireNotifications(notifications);
|
|
761
|
-
requirePrincipal(principal);
|
|
762
|
-
const b = await body(req);
|
|
763
|
-
return notifications.unsubscribePush(principal.id, b.endpoint);
|
|
764
|
-
});
|
|
839
|
+
// /api/push/* is not here. Registering with a delivery channel is the channel's own
|
|
840
|
+
// business — see WebPushChannel.routes() — so the drive's route table does not carry
|
|
841
|
+
// endpoints for a transport it may not have configured, and adding email or chat does
|
|
842
|
+
// not mean editing this file.
|
|
765
843
|
|
|
766
844
|
// --- plugins: domain verification proxy + per-plugin server storage --------
|
|
767
845
|
|
|
@@ -991,8 +1069,15 @@ async function assertContributorOwned(ctx, contributorId) {
|
|
|
991
1069
|
*/
|
|
992
1070
|
async function readableCollectionIds(ctx, narrowTo) {
|
|
993
1071
|
if (!collectionsEnabled(ctx)) return undefined;
|
|
994
|
-
|
|
995
|
-
|
|
1072
|
+
// A NAMED collection is asserted, not filtered. Filtering an unreadable id out of the
|
|
1073
|
+
// list answers "no results" for a collection the caller may not see — indistinguishable
|
|
1074
|
+
// from one that is simply empty, so a permissions problem reads as an indexing problem.
|
|
1075
|
+
// `access.collection` throws the 403 that says what actually happened.
|
|
1076
|
+
if (narrowTo) {
|
|
1077
|
+
await ctx.access.collection(narrowTo, 'read');
|
|
1078
|
+
return [narrowTo];
|
|
1079
|
+
}
|
|
1080
|
+
return (await ctx.collections.list(ctx.principal)).map((c) => c.id);
|
|
996
1081
|
}
|
|
997
1082
|
|
|
998
1083
|
/**
|
|
@@ -1058,9 +1143,34 @@ async function assertTaskAccess(ctx, task, what) {
|
|
|
1058
1143
|
if (task.collectionId == null) return requireWholeDrive(ctx, `${what} a drive-wide task`);
|
|
1059
1144
|
await assertCap(ctx, task.collectionId, 'write');
|
|
1060
1145
|
}
|
|
1146
|
+
/**
|
|
1147
|
+
* An admin, in person.
|
|
1148
|
+
*
|
|
1149
|
+
* Two refusals, not one. A grant is refused outright — an API key must never be able to
|
|
1150
|
+
* manage API keys, however broadly it was scoped, because a key that can mint keys can
|
|
1151
|
+
* outlive its own revocation. Then the ordinary admin check on the principal.
|
|
1152
|
+
*/
|
|
1153
|
+
function requireHumanAdmin(ctx, action) {
|
|
1154
|
+
if (ctx.grant) {
|
|
1155
|
+
throw TroveError.forbidden(`An API key cannot ${action} — sign in as an administrator`);
|
|
1156
|
+
}
|
|
1157
|
+
requirePrincipal(ctx.principal);
|
|
1158
|
+
const isAdmin = collectionsEnabled(ctx) ? ctx.collections.isAdmin(ctx.principal) : !!ctx.principal;
|
|
1159
|
+
if (!isAdmin) throw TroveError.forbidden(`You need to be an administrator to ${action}`);
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1061
1162
|
function requirePrincipal(principal) {
|
|
1062
1163
|
if (!principal) throw TroveError.unauthorized('Authentication required');
|
|
1063
1164
|
}
|
|
1064
1165
|
function requireNotifications(n) {
|
|
1065
1166
|
if (!n) throw TroveError.unsupported('Notifications are not enabled on this server');
|
|
1066
1167
|
}
|
|
1168
|
+
|
|
1169
|
+
/**
|
|
1170
|
+
* The request plumbing handed to routes contributed from outside this file.
|
|
1171
|
+
*
|
|
1172
|
+
* `body` is the capped JSON read — the cap is the reason to share it rather than let a
|
|
1173
|
+
* channel call `req.json()` and accept an unbounded body — and `requirePrincipal` is
|
|
1174
|
+
* the same 401 every route here throws.
|
|
1175
|
+
*/
|
|
1176
|
+
export const routeHelpers = { body, requirePrincipal };
|
|
@@ -15,12 +15,12 @@
|
|
|
15
15
|
* @param {object|null} principal who is asking
|
|
16
16
|
* @returns {{access: object|null, release: () => Promise<void>}}
|
|
17
17
|
*/
|
|
18
|
-
export function leaseScope(container, principal) {
|
|
18
|
+
export function leaseScope(container, principal, grant = null) {
|
|
19
19
|
const held = [];
|
|
20
20
|
if (!container) return { access: null, release: async () => {} };
|
|
21
21
|
|
|
22
22
|
const obtain = async (name, request) => {
|
|
23
|
-
const lease = await container.lease({ [name]: { principal, ...request } });
|
|
23
|
+
const lease = await container.lease({ [name]: { principal, grant, ...request } });
|
|
24
24
|
held.push(lease);
|
|
25
25
|
return lease.resources[name];
|
|
26
26
|
};
|