@3sln/trove 0.0.7 → 0.0.9
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/collections/index.js +146 -1
- package/packages/core/src/encryption/envelope.js +483 -0
- package/packages/core/src/encryption/exposure.js +101 -0
- package/packages/core/src/encryption/keys.js +88 -0
- package/packages/core/src/encryption/policy.js +112 -0
- package/packages/core/src/encryption/rotation.js +432 -0
- package/packages/core/src/index.js +17 -1
- package/packages/core/src/links.js +85 -0
- package/packages/core/src/metadata/memory.js +3 -1
- package/packages/core/src/metadata/sqlite.js +27 -7
- package/packages/core/src/scan.js +35 -1
- package/packages/core/src/storage/cost.js +228 -0
- package/packages/core/src/uploads.js +302 -19
- package/packages/core/src/vfs.js +161 -10
- package/packages/server/src/adapters/staticAssets.js +14 -5
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/core.js +21 -2
- package/packages/server/src/index.js +45 -2
- package/packages/server/src/routes.js +62 -1
- package/packages/web/dist/assets/main-jg5vmp8f.js +356 -0
- package/packages/web/dist/assets/main-jg5vmp8f.js.map +118 -0
- package/packages/web/dist/assets/styles-e5gk19rn.css +1 -0
- package/packages/web/dist/index.html +9 -3
- package/packages/web/dist/sw.js +1 -1
- package/packages/web/src/bl/actions.js +1471 -138
- package/packages/web/src/bl/commands.js +103 -284
- package/packages/web/src/bl/context.js +69 -0
- package/packages/web/src/bl/index.js +136 -40
- package/packages/web/src/bl/intern.js +129 -0
- package/packages/web/src/bl/launcher.js +251 -0
- package/packages/web/src/bl/links.js +1 -1
- package/packages/web/src/bl/match.js +71 -0
- package/packages/web/src/bl/mentions.js +39 -0
- package/packages/web/src/bl/offline.js +0 -4
- package/packages/web/src/bl/openers.js +65 -33
- package/packages/web/src/bl/pluginInstall.js +42 -21
- package/packages/web/src/bl/queries.js +669 -0
- package/packages/web/src/bl/services.js +141 -145
- package/packages/web/src/bl/social.js +3 -1
- package/packages/web/src/bl/state.js +144 -0
- package/packages/web/src/bl/status.js +96 -0
- package/packages/web/src/bl/trust.js +109 -0
- package/packages/web/src/bl/viewState.js +60 -0
- package/packages/web/src/bl/views.js +64 -0
- package/packages/web/src/bl/watchQuery.js +73 -0
- package/packages/web/src/platform/api.js +63 -11
- package/packages/web/src/platform/commands.js +49 -9
- package/packages/web/src/platform/context.js +146 -51
- package/packages/web/src/platform/index.js +27 -20
- package/packages/web/src/platform/navigation.js +34 -4
- package/packages/web/src/platform/pickers.js +46 -0
- package/packages/web/src/platform/pluginHost.js +43 -10
- package/packages/web/src/platform/pluginRpc.js +6 -2
- package/packages/web/src/platform/spatialNav.js +0 -2
- package/packages/web/src/platform/viewport.js +1 -6
- package/packages/web/src/platform/voiceSearch.js +1 -3
- package/packages/web/src/platform/whenclause.js +22 -3
- package/packages/web/src/styles.css +77 -4
- package/packages/web/src/ui/activate.js +22 -0
- package/packages/web/src/ui/components/activityBar.js +7 -3
- package/packages/web/src/ui/components/activityPanel.js +10 -11
- package/packages/web/src/ui/components/adminView.js +191 -0
- package/packages/web/src/ui/components/collectionGate.js +4 -3
- package/packages/web/src/ui/components/commandPalette.js +24 -55
- package/packages/web/src/ui/components/editorArea.js +16 -13
- package/packages/web/src/ui/components/launcher.js +39 -225
- package/packages/web/src/ui/components/openers/index.js +7 -4
- package/packages/web/src/ui/components/openers/markdown.js +5 -3
- package/packages/web/src/ui/components/overlays.js +169 -43
- package/packages/web/src/ui/components/phoneChrome.js +35 -33
- package/packages/web/src/ui/components/pluginReview.js +62 -23
- package/packages/web/src/ui/components/pluginsView.js +30 -28
- package/packages/web/src/ui/components/settingsView.js +151 -58
- package/packages/web/src/ui/components/social.js +42 -62
- package/packages/web/src/ui/components/statusBar.js +30 -74
- package/packages/web/src/ui/components/views/grid.js +3 -2
- package/packages/web/src/ui/components/views/index.js +7 -62
- package/packages/web/src/ui/components/views/list.js +3 -2
- package/packages/web/src/ui/components/views/parts.js +15 -4
- package/packages/web/src/ui/compositions/workbench.js +166 -58
- package/packages/web/src/ui/region.js +53 -0
- package/packages/web/src/workbench.js +17 -15
- package/packages/web/dist/assets/main-f0f2tfhp.js +0 -356
- package/packages/web/dist/assets/main-f0f2tfhp.js.map +0 -104
- package/packages/web/dist/assets/styles-d3cyysgp.css +0 -1
- package/packages/web/src/platform/overlay.js +0 -81
- package/packages/web/src/platform/workbench.js +0 -156
|
@@ -33,6 +33,7 @@ import path from 'node:path';
|
|
|
33
33
|
// The policy lives in its own module because index.js needs it too and has to stay
|
|
34
34
|
// loadable on Workers, where `node:path` is not there to be imported.
|
|
35
35
|
import { IMMUTABLE_PREFIX, cacheControlFor } from '../cachePolicy.js';
|
|
36
|
+
import { SHARE_PATH } from '@3sln/trove/core/links.js';
|
|
36
37
|
|
|
37
38
|
export { IMMUTABLE_PREFIX, cacheControlFor };
|
|
38
39
|
|
|
@@ -46,10 +47,16 @@ export const MIME = {
|
|
|
46
47
|
/**
|
|
47
48
|
* Should a miss here fall back to the SPA entry point?
|
|
48
49
|
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
50
|
+
* Most of the app lives at `/` — `navigation.js` pushes state with no URL — so for those
|
|
51
|
+
* views the fallback is only robustness, a refresh landing somewhere unexpected.
|
|
52
|
+
*
|
|
53
|
+
* Share links are the exception, and they break the rule this used to rest on. A share
|
|
54
|
+
* link is `/c/<collection>/i/<item>`, and an item name ends in a filename, so
|
|
55
|
+
* "anything with an extension is asking for a file" refused exactly the deep link the
|
|
56
|
+
* scheme exists to provide: the server 404'd before the app could load, and the
|
|
57
|
+
* client-side routing that would have resolved it never ran. A share path is a route
|
|
58
|
+
* whatever it ends with, so it is recognised before the extension rule rather than
|
|
59
|
+
* fighting it.
|
|
53
60
|
*
|
|
54
61
|
* @param {string} pathname
|
|
55
62
|
*/
|
|
@@ -58,7 +65,9 @@ export function shouldFallBack(pathname) {
|
|
|
58
65
|
// Content-addressed: a miss is a stale reference to a build that no longer exists.
|
|
59
66
|
// Answering it with HTML is what poisons a service worker cache.
|
|
60
67
|
if (pathname.startsWith(IMMUTABLE_PREFIX)) return false;
|
|
61
|
-
//
|
|
68
|
+
// A route the app owns, named by the same module that produces the links.
|
|
69
|
+
if (pathname === SHARE_PATH || pathname.startsWith(`${SHARE_PATH}/`)) return true;
|
|
70
|
+
// Anything else with an extension is asking for a file, not a view.
|
|
62
71
|
if (path.extname(pathname)) return false;
|
|
63
72
|
return true;
|
|
64
73
|
}
|
|
@@ -49,7 +49,7 @@ export function createDriveEngine(config = {}, lifecycleState = { closing: false
|
|
|
49
49
|
export const BACKBONE = [
|
|
50
50
|
'storage', 'sqlite', 'metadata', 'kv', 'tasks', 'issues', 'notifications',
|
|
51
51
|
'sidecar', 'collections', 'identity', 'auth', 'search', 'vfs', 'plugins',
|
|
52
|
-
'apiKeys', 'capabilities', 'lifecycle',
|
|
52
|
+
'apiKeys', 'capabilities', 'rotation', 'lifecycle',
|
|
53
53
|
];
|
|
54
54
|
|
|
55
55
|
/** The shape `beginScan` has always returned, so no caller has to change. */
|
|
@@ -32,6 +32,8 @@ import {
|
|
|
32
32
|
IdentityProvider, JwtIdentityProvider, HeaderIdentityProvider, AnonymousIdentityProvider,
|
|
33
33
|
cloudflareAccess,
|
|
34
34
|
KeyValueStore, MemoryKV, SqliteKV,
|
|
35
|
+
KvSessionStore,
|
|
36
|
+
RotationService,
|
|
35
37
|
SqliteProvider, LocalSqliteProvider,
|
|
36
38
|
SidecarService, NotificationCenter, WebPushService, WebPushChannel, NotificationChannel,
|
|
37
39
|
ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
|
|
@@ -329,6 +331,17 @@ export function coreProviders(config, lifecycleState) {
|
|
|
329
331
|
// and the task succeeding clears the issue.
|
|
330
332
|
tasks: Provider.fromLazySingleton(() => resolve(config.tasks, TaskRegistry, () => new TaskRegistry())),
|
|
331
333
|
|
|
334
|
+
// Moving a collection onto a new key, incrementally. Depends on vfs rather than the
|
|
335
|
+
// other way round, so it sits after it in the graph and the container orders itself.
|
|
336
|
+
rotation: Provider.fromLazySingleton(
|
|
337
|
+
async (deps) => {
|
|
338
|
+
const { kv, vfs, collections } = await need(deps, ['kv', 'vfs', 'collections']);
|
|
339
|
+
return new RotationService({ kv, vfs, collections });
|
|
340
|
+
},
|
|
341
|
+
null,
|
|
342
|
+
{ deps: ['kv', 'vfs', 'collections'] },
|
|
343
|
+
),
|
|
344
|
+
|
|
332
345
|
issues: Provider.fromLazySingleton(
|
|
333
346
|
async (deps) => {
|
|
334
347
|
const { kv } = await need(deps, ['kv']);
|
|
@@ -461,11 +474,17 @@ export function coreProviders(config, lifecycleState) {
|
|
|
461
474
|
async (deps) => {
|
|
462
475
|
const r = await need(deps, [
|
|
463
476
|
'storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections',
|
|
464
|
-
'searchTransformer', 'issues', 'signedUrls',
|
|
477
|
+
'searchTransformer', 'issues', 'signedUrls', 'kv',
|
|
465
478
|
]);
|
|
466
479
|
const vfs = new Vfs({
|
|
467
480
|
...r,
|
|
468
481
|
maxUploadBytes: config.maxUploadBytes ?? null,
|
|
482
|
+
// Upload sessions in the KeyValueStore rather than this process's memory. An
|
|
483
|
+
// upload spans several requests and the session is the only thing joining them,
|
|
484
|
+
// so on a runtime that can serve those requests from different isolates an
|
|
485
|
+
// in-memory Map means "upload session not found" on a drive where nothing is
|
|
486
|
+
// wrong. See KvSessionStore.
|
|
487
|
+
uploadSessions: new KvSessionStore({ kv: r.kv }),
|
|
469
488
|
// Where this server can be reached from outside, for the URLs that leave the
|
|
470
489
|
// browser (an indexer hands one to an external API). Absent, those are refused
|
|
471
490
|
// rather than handed out as links to nowhere.
|
|
@@ -475,7 +494,7 @@ export function coreProviders(config, lifecycleState) {
|
|
|
475
494
|
return vfs;
|
|
476
495
|
},
|
|
477
496
|
null,
|
|
478
|
-
{ deps: ['storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections', 'searchTransformer', 'issues', 'signedUrls'] },
|
|
497
|
+
{ deps: ['storage', 'metadata', 'search', 'indexers', 'sidecar', 'collections', 'searchTransformer', 'issues', 'signedUrls', 'kv'] },
|
|
479
498
|
),
|
|
480
499
|
|
|
481
500
|
// Bulk plugin package blobs. Defaults to the primary storage under a prefix;
|
|
@@ -66,7 +66,7 @@ export async function createServer(config = {}) {
|
|
|
66
66
|
const backbone = await engine.container.lease(BACKBONE);
|
|
67
67
|
const {
|
|
68
68
|
storage, sqlite: sqliteProvider, metadata, kv, tasks, issues, notifications,
|
|
69
|
-
sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities,
|
|
69
|
+
sidecar, collections, identity, auth, search, vfs, plugins, apiKeys, capabilities, rotation,
|
|
70
70
|
} = backbone.resources;
|
|
71
71
|
|
|
72
72
|
// Aliased so the rest of this function reads as it did; the container's
|
|
@@ -232,6 +232,36 @@ export async function createServer(config = {}) {
|
|
|
232
232
|
? null
|
|
233
233
|
: rebuildIndexIfLost(vfs, search, startReindex);
|
|
234
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Advance every rotation that is part-way through, and say what moved.
|
|
237
|
+
*
|
|
238
|
+
* Extracted because there are TWO schedulers — the interval below, for a process that
|
|
239
|
+
* lives, and `runMaintenance`, for a Worker whose timers do not survive the request —
|
|
240
|
+
* and only the second one used to step rotations. On Bun and Node, which is where this
|
|
241
|
+
* is self-hosted, `POST /rotate` therefore minted the new key, reported "running", and
|
|
242
|
+
* then moved nothing, ever: `rotation.step` had no caller, and there is no route that
|
|
243
|
+
* steps one either. The old key stayed in the ring indefinitely, which is the exact
|
|
244
|
+
* failure a rotation exists to end.
|
|
245
|
+
*
|
|
246
|
+
* One function, both callers, so the next thing added to periodic work cannot land in
|
|
247
|
+
* one scheduler and not the other.
|
|
248
|
+
*/
|
|
249
|
+
async function stepRotations(targets, budgetEach) {
|
|
250
|
+
const out = [];
|
|
251
|
+
for (const c of targets) {
|
|
252
|
+
const state = await rotation.state(c.id).catch(() => null);
|
|
253
|
+
if (!state || state.status !== 'running') continue;
|
|
254
|
+
// The slice claims the collection, so a firing that overlaps a manual run does
|
|
255
|
+
// nothing rather than racing it.
|
|
256
|
+
const next = await rotation.step(c.id, { budgetMs: budgetEach }).catch((e) => {
|
|
257
|
+
console.error(`[trove] rotation slice for ${c.id} failed`, e);
|
|
258
|
+
return null;
|
|
259
|
+
});
|
|
260
|
+
if (next) out.push({ collectionId: c.id, moved: next.moved, failed: next.failed, status: next.status });
|
|
261
|
+
}
|
|
262
|
+
return out;
|
|
263
|
+
}
|
|
264
|
+
|
|
235
265
|
// Periodic maintenance. Both of these caches are otherwise unbounded: abandoned
|
|
236
266
|
// upload sessions (a client that starts an upload and never finishes) accumulate in
|
|
237
267
|
// the session store forever, and sidecar documents stay resident after their last
|
|
@@ -251,6 +281,12 @@ export async function createServer(config = {}) {
|
|
|
251
281
|
.then(() => sidecar.sweep())
|
|
252
282
|
.then(() => (trashMs > 0 ? vfs.purgeTrash({ before: Date.now() - trashMs }) : null))
|
|
253
283
|
.then((r) => { if (r?.purged) console.log(`[trove] purged ${r.purged} item(s) from the trash after ${config.trashRetentionDays ?? 30} days`); })
|
|
284
|
+
// A rotation started through the API finishes on its own here. Unlike a scan this
|
|
285
|
+
// is NOT opt-in: it only touches collections someone has explicitly put into
|
|
286
|
+
// rotation, and leaving one half-moved is worse than the work of finishing it.
|
|
287
|
+
.then(() => (collections ? collections.all().catch(() => []) : []))
|
|
288
|
+
.then((targets) => stepRotations(targets, Math.max(1000, Math.floor(everyMs / 4))))
|
|
289
|
+
.then((moved) => { for (const m of moved) if (m.moved) console.log(`[trove] rotation ${m.collectionId}: ${m.moved} moved, ${m.status}`); })
|
|
254
290
|
.catch((e) => console.error('maintenance sweep failed', e));
|
|
255
291
|
}, everyMs);
|
|
256
292
|
maintenance.unref?.();
|
|
@@ -471,6 +507,13 @@ export async function createServer(config = {}) {
|
|
|
471
507
|
const r = await startScan(c.id, { reason: 'Scheduled', deadlineMs: each }).catch((e) => ({ error: e.message }));
|
|
472
508
|
out.scans.push({ collectionId: c.id, ...r });
|
|
473
509
|
}
|
|
510
|
+
|
|
511
|
+
// A rotation that has been started finishes on its own — see `stepRotations`, which
|
|
512
|
+
// the interval scheduler shares so the two cannot drift apart.
|
|
513
|
+
//
|
|
514
|
+
// Last, and out of what the scans left, because a rotation is elective and a scan is
|
|
515
|
+
// how the drive notices files that changed underneath it.
|
|
516
|
+
out.rotated = await stepRotations(targets, each);
|
|
474
517
|
return out;
|
|
475
518
|
}
|
|
476
519
|
|
|
@@ -482,7 +525,7 @@ export async function createServer(config = {}) {
|
|
|
482
525
|
// inside a Durable Object want. `begin*` goes wherever `config.background` says,
|
|
483
526
|
// which for a front-line Worker isolate is the object rather than itself.
|
|
484
527
|
startScan, startReindex, beginScan: routeBeginScan, beginReindex: routeBeginReindex,
|
|
485
|
-
runMaintenance, checkStorage, mcp, auth, close };
|
|
528
|
+
runMaintenance, checkStorage, rotation, mcp, auth, close };
|
|
486
529
|
}
|
|
487
530
|
|
|
488
531
|
/**
|
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
import { Router, json, parseRange } from './router.js';
|
|
6
6
|
import {
|
|
7
7
|
TroveError, assertSafePluginSql, concatBytes, metadataUrl, publicOrigin,
|
|
8
|
+
shouldEncrypt, estimateRotationCost,
|
|
8
9
|
} from '@3sln/trove/core';
|
|
9
10
|
import { parseContribUri, CORE_DOMAIN } from '@3sln/trove/core/plugins/identity.js';
|
|
10
11
|
|
|
@@ -444,10 +445,20 @@ export function createRouter() {
|
|
|
444
445
|
|
|
445
446
|
// --- uploads ---------------------------------------------------------------
|
|
446
447
|
|
|
447
|
-
r.post('/api/collections/:collection/uploads', [], async (ctx) => {
|
|
448
|
+
r.post('/api/collections/:collection/uploads', ['collections', 'vfs'], async (ctx) => {
|
|
448
449
|
const b = await body(ctx.req);
|
|
449
450
|
if (!b.name) throw TroveError.invalid('name is required');
|
|
450
451
|
const collection = await ctx.access.collection(scopedCollection(ctx), 'write');
|
|
452
|
+
// `write` is enough again, including on an encrypted collection.
|
|
453
|
+
//
|
|
454
|
+
// This used to demand `read` as well, for a good reason that has since gone away: the
|
|
455
|
+
// plan handed the client the collection's data key so it could seal locally, and that
|
|
456
|
+
// key decrypts EVERYTHING in the collection — a read capability however it arrives. So
|
|
457
|
+
// a write-only ingest credential, which the key model explicitly supports, could not
|
|
458
|
+
// upload to an encrypted collection at all.
|
|
459
|
+
//
|
|
460
|
+
// The drive seals now and the key never leaves it, so a plan carries nothing readable
|
|
461
|
+
// and there is nothing left to protect. A write-only credential works everywhere.
|
|
451
462
|
return uploadDescriptor(await collection.createUpload({
|
|
452
463
|
name: b.name, size: Number(b.size ?? 0), contentType: b.contentType,
|
|
453
464
|
overwrite: b.overwrite === true,
|
|
@@ -673,6 +684,55 @@ export function createRouter() {
|
|
|
673
684
|
return ctx.storageCheck.run({ origin: publicOrigin(ctx.req, ctx.config) });
|
|
674
685
|
});
|
|
675
686
|
|
|
687
|
+
// --- key rotation ----------------------------------------------------------
|
|
688
|
+
//
|
|
689
|
+
// Moving a collection onto a new key is admin work in the strict sense: it rewrites every
|
|
690
|
+
// object in the collection and costs real money on a metered store. `requireHumanAdmin`
|
|
691
|
+
// refuses a request arriving on an API key — a credential that could re-key a collection
|
|
692
|
+
// could also make its contents unreadable to everyone else holding the old one.
|
|
693
|
+
|
|
694
|
+
/** What a rotation would cost, before anyone starts one. */
|
|
695
|
+
r.get('/api/collections/:collection/rotate/estimate', ['collections', 'vfs'], async (ctx) => {
|
|
696
|
+
const collectionId = scopedCollection(ctx);
|
|
697
|
+
await ctx.access.collection(collectionId, 'admin');
|
|
698
|
+
requireHumanAdmin(ctx, 'estimate a key rotation');
|
|
699
|
+
const record = await ctx.collections.get(collectionId);
|
|
700
|
+
const stats = await ctx.vfs.metadata.collectionStats?.(collectionId).catch(() => null);
|
|
701
|
+
return estimateRotationCost(
|
|
702
|
+
{ driver: record.store?.driver, endpoint: record.store?.endpoint || record.store?.s3?.endpoint },
|
|
703
|
+
{ objects: stats?.items ?? 0, bytes: stats?.bytes ?? 0 },
|
|
704
|
+
);
|
|
705
|
+
});
|
|
706
|
+
|
|
707
|
+
r.get('/api/collections/:collection/rotate', ['collections', 'rotation'], async (ctx) => {
|
|
708
|
+
const collectionId = scopedCollection(ctx);
|
|
709
|
+
await ctx.access.collection(collectionId, 'admin');
|
|
710
|
+
requireHumanAdmin(ctx, 'read key rotation state');
|
|
711
|
+
// Null rather than 404: "this collection has never been rotated" is an answer, and a
|
|
712
|
+
// client polling for progress should not have to treat it as an error.
|
|
713
|
+
return { rotation: (await ctx.rotation.state(collectionId)) || null };
|
|
714
|
+
});
|
|
715
|
+
|
|
716
|
+
r.post('/api/collections/:collection/rotate', ['collections', 'rotation'], async (ctx) => {
|
|
717
|
+
const collectionId = scopedCollection(ctx);
|
|
718
|
+
await ctx.access.collection(collectionId, 'admin');
|
|
719
|
+
requireHumanAdmin(ctx, 'rotate a key');
|
|
720
|
+
// Begin only mints the key and makes it current; the objects move in slices, from the
|
|
721
|
+
// cron or from further calls. Returning immediately is the point — the walk can take
|
|
722
|
+
// hours and holding the request open for it would just time out.
|
|
723
|
+
const state = await ctx.rotation.begin(collectionId, ctx.principal);
|
|
724
|
+
return { rotation: state };
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
r.delete('/api/collections/:collection/rotate', ['collections', 'rotation'], async (ctx) => {
|
|
728
|
+
const collectionId = scopedCollection(ctx);
|
|
729
|
+
await ctx.access.collection(collectionId, 'admin');
|
|
730
|
+
requireHumanAdmin(ctx, 'cancel a key rotation');
|
|
731
|
+
// Stops the walk. What has already moved stays moved, and both keys stay in the ring,
|
|
732
|
+
// so nothing becomes unreadable — an abandoned rotation is untidy, not destructive.
|
|
733
|
+
return { rotation: await ctx.rotation.cancel(collectionId) };
|
|
734
|
+
});
|
|
735
|
+
|
|
676
736
|
// Rebuild the search index on demand. Admin-only: it re-reads every object in the
|
|
677
737
|
// drive, so it is a real load, and it is drive-wide rather than scoped to anything
|
|
678
738
|
// the caller owns. Returns the task, which is how the caller watches it.
|
|
@@ -1150,6 +1210,7 @@ async function assertTaskAccess(ctx, task, what) {
|
|
|
1150
1210
|
* manage API keys, however broadly it was scoped, because a key that can mint keys can
|
|
1151
1211
|
* outlive its own revocation. Then the ordinary admin check on the principal.
|
|
1152
1212
|
*/
|
|
1213
|
+
|
|
1153
1214
|
function requireHumanAdmin(ctx, action) {
|
|
1154
1215
|
if (ctx.grant) {
|
|
1155
1216
|
throw TroveError.forbidden(`An API key cannot ${action} — sign in as an administrator`);
|