@3sln/trove 0.0.4 → 0.0.7
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/README.md +6 -0
- 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 +14 -2
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +83 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +162 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- 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 +105 -11
- package/packages/server/src/index.js +123 -11
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +135 -32
- 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
|
@@ -269,6 +269,38 @@ export class ActivityService {
|
|
|
269
269
|
}
|
|
270
270
|
}
|
|
271
271
|
|
|
272
|
+
/**
|
|
273
|
+
* Ask the server whether the backing stores are actually usable from a browser.
|
|
274
|
+
*
|
|
275
|
+
* Unlike a scan or a reindex this finishes in one round trip, so it reports its own
|
|
276
|
+
* result rather than handing back a task: the answer to "did I fix the bucket?" should
|
|
277
|
+
* be available immediately, and the issue list is refreshed so a problem that is now
|
|
278
|
+
* fixed visibly disappears rather than sitting there until the next poll.
|
|
279
|
+
*/
|
|
280
|
+
async checkStorage() {
|
|
281
|
+
try {
|
|
282
|
+
const res = await this.api.checkStorage();
|
|
283
|
+
await this.refresh();
|
|
284
|
+
this.togglePanel(true);
|
|
285
|
+
const problems = (res.results || []).reduce((n, r) => n + (r.findings?.length || 0), 0);
|
|
286
|
+
if (problems) {
|
|
287
|
+
this.platform.notifications?.warn?.(`Found ${problems} storage problem${problems === 1 ? '' : 's'} — see Activity`);
|
|
288
|
+
} else if (!res.checked) {
|
|
289
|
+
this.platform.notifications?.info?.('There are no collections to check yet');
|
|
290
|
+
} else if (!res.corsChecked) {
|
|
291
|
+
// Honest about what was not checked. Reporting "all good" having skipped the check
|
|
292
|
+
// that matters is how a diagnostic stops being believed.
|
|
293
|
+
this.platform.notifications?.info?.('Stores are reachable. Browser access was not checked — this drive has no public URL configured.');
|
|
294
|
+
} else {
|
|
295
|
+
this.platform.notifications?.success?.('Stores are reachable and allow browser access');
|
|
296
|
+
}
|
|
297
|
+
return res;
|
|
298
|
+
} catch (err) {
|
|
299
|
+
this.platform.notifications?.error?.(`Couldn't check the stores: ${err.message}`);
|
|
300
|
+
throw err;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
272
304
|
togglePanel(open) {
|
|
273
305
|
this.#set({ open: open ?? !this.state.open });
|
|
274
306
|
if (this.state.open) this.refresh();
|
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
NavigateAction, RefreshAction, DeleteAction, RenameAction,
|
|
8
8
|
UploadFilesAction, OpenFileAction, CreateCollectionAction, LoadMoreAction, TrashAction,
|
|
9
9
|
SearchAction,
|
|
10
|
+
LoadApiKeysAction, MintApiKeyAction, RevokeApiKeyAction,
|
|
10
11
|
} from './actions.js';
|
|
11
12
|
import { beginInstallFromFile, beginInstallFromUrl } from './pluginInstall.js';
|
|
12
13
|
import { troveUri } from '@3sln/trove/core/links.js';
|
|
@@ -56,8 +57,20 @@ export function registerCommands(app) {
|
|
|
56
57
|
// Trove is not the only thing that can write to the bucket. This is how files added,
|
|
57
58
|
// replaced, or removed by something else get picked up.
|
|
58
59
|
cmd('workbench.scanCollection', 'Scan Collection for Outside Changes',
|
|
59
|
-
() =>
|
|
60
|
+
() => {
|
|
61
|
+
// No collection means no scan. This used to fall back to one called 'default',
|
|
62
|
+
// which on a drive that has none scanned a collection that does not exist and
|
|
63
|
+
// reported the failure as a scan error.
|
|
64
|
+
const id = explorer.state.collectionId;
|
|
65
|
+
if (!id) return platform.notifications.info('Open a collection first — a scan is per collection.');
|
|
66
|
+
return app.activity.scanCollection(id).catch(() => {});
|
|
67
|
+
},
|
|
60
68
|
{ category: 'Explorer', icon: 'refresh' });
|
|
69
|
+
// Whether the backing stores are usable from a browser at all. Separate from a scan:
|
|
70
|
+
// a scan asks what the store HOLDS, this asks whether the store can be READ from here,
|
|
71
|
+
// which is the failure that makes every file open to a spinner.
|
|
72
|
+
cmd('workbench.checkStorage', 'Check Storage Configuration',
|
|
73
|
+
() => app.activity.checkStorage().catch(() => {}), { category: 'View', icon: 'plug' });
|
|
61
74
|
|
|
62
75
|
// --- explorer --------------------------------------------------------------
|
|
63
76
|
cmd('explorer.refresh', 'Refresh', () => go(new RefreshAction()), { category: 'Explorer', icon: 'refresh' });
|
|
@@ -131,6 +144,29 @@ export function registerCommands(app) {
|
|
|
131
144
|
go(new TrashAction('list'));
|
|
132
145
|
workbench.showHome();
|
|
133
146
|
}, { category: 'Explorer', icon: 'trash' });
|
|
147
|
+
// --- API keys (admin) ---------------------------------------------------------
|
|
148
|
+
// All `palette: false`: these are the settings screen's own buttons, not verbs anyone
|
|
149
|
+
// would go looking for in the palette. Revoking a credential by fuzzy-searching for it
|
|
150
|
+
// is not a thing to make easy.
|
|
151
|
+
cmd('keys.load', 'Load API Keys', () => go(new LoadApiKeysAction()), { palette: false });
|
|
152
|
+
cmd('keys.new', 'New API Key', () => app.apiKeys.startDraft(), { palette: false });
|
|
153
|
+
cmd('keys.cancel', 'Cancel API Key', () => app.apiKeys.cancelDraft(), { palette: false });
|
|
154
|
+
cmd('keys.dismissMinted', 'Dismiss API Key', () => app.apiKeys.clearMinted(), { palette: false });
|
|
155
|
+
cmd('keys.revoke', 'Revoke API Key', (id) => id && go(new RevokeApiKeyAction(id)), { palette: false });
|
|
156
|
+
cmd('keys.mint', 'Create API Key', () => {
|
|
157
|
+
const draft = app.apiKeys.state.draft;
|
|
158
|
+
const scopes = app.apiKeys.draftScopes();
|
|
159
|
+
if (!draft?.name?.trim() || !scopes) return;
|
|
160
|
+
// Days in the form, an absolute instant on the wire: the server compares against its
|
|
161
|
+
// own clock, and "30 days from whenever this arrives" is not what was chosen.
|
|
162
|
+
const days = Number(draft.expiresInDays);
|
|
163
|
+
const expiresAt = draft.expiresInDays !== '' && Number.isFinite(days) && days > 0
|
|
164
|
+
? Date.now() + days * 86400_000
|
|
165
|
+
: null;
|
|
166
|
+
app.apiKeys.cancelDraft();
|
|
167
|
+
return go(new MintApiKeyAction({ name: draft.name.trim(), scopes, expiresAt }));
|
|
168
|
+
}, { palette: false });
|
|
169
|
+
|
|
134
170
|
cmd('explorer.hideTrash', 'Hide Trash', () => go(new TrashAction('hide')), { palette: false });
|
|
135
171
|
cmd('explorer.restore', 'Restore from Trash', (id) => id && go(new TrashAction('restore', id)), { palette: false });
|
|
136
172
|
cmd('explorer.purgeOne', 'Delete Forever', (id) => id && go(new TrashAction('purge', id)), { palette: false });
|
|
@@ -172,7 +208,10 @@ export function registerCommands(app) {
|
|
|
172
208
|
// The menu of "where else could I be". Shared by the palette command and the status
|
|
173
209
|
// bar's collection segment, so both offer the same list.
|
|
174
210
|
const collectionMenu = () => {
|
|
175
|
-
|
|
211
|
+
// No fallback: this only decides which row gets a tick, and with nothing open the
|
|
212
|
+
// answer is that none of them do. `|| 'default'` ticked a collection the user had
|
|
213
|
+
// not chosen, and on a drive with one actually called "default", the wrong one.
|
|
214
|
+
const current = explorer.state.collectionId;
|
|
176
215
|
const items = (explorer.state.collections || []).map((c) => ({
|
|
177
216
|
label: c.name || c.id,
|
|
178
217
|
icon: c.id === current ? 'check' : 'files',
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { Engine, Provider } from '@3sln/ngin';
|
|
8
8
|
import { effect } from '../runtime.js';
|
|
9
|
-
import { ExplorerService, SearchClientService, TransfersService } from './services.js';
|
|
9
|
+
import { ExplorerService, SearchClientService, TransfersService, ApiKeysService } from './services.js';
|
|
10
10
|
import { SocialService } from './social.js';
|
|
11
11
|
import { OfflineService } from './offline.js';
|
|
12
12
|
import { ActivityService } from './activity.js';
|
|
@@ -16,6 +16,7 @@ import { NavigateAction, LoadCollectionsAction, OpenInitialCollectionAction } fr
|
|
|
16
16
|
export function createApp(platform) {
|
|
17
17
|
const explorer = new ExplorerService(platform.settings);
|
|
18
18
|
const search = new SearchClientService();
|
|
19
|
+
const apiKeys = new ApiKeysService();
|
|
19
20
|
// One place for "what's running" and "what's stuck", covering both sides of the wire.
|
|
20
21
|
const activity = new ActivityService(platform);
|
|
21
22
|
const transfers = new TransfersService(activity);
|
|
@@ -23,7 +24,7 @@ export function createApp(platform) {
|
|
|
23
24
|
const offline = new OfflineService(platform);
|
|
24
25
|
social.offline = offline; // social queues sidecar ops through offline when disconnected
|
|
25
26
|
|
|
26
|
-
const app = { platform, explorer, search, transfers, social, offline, activity, engine: null };
|
|
27
|
+
const app = { platform, explorer, search, transfers, social, offline, activity, apiKeys, engine: null };
|
|
27
28
|
|
|
28
29
|
const engine = new Engine({
|
|
29
30
|
providers: { app: Provider.fromSingleton(app) },
|
|
@@ -39,10 +40,14 @@ export function createApp(platform) {
|
|
|
39
40
|
// (e.g. the Delete keybinding needs `explorer.hasSelection`), and previously only
|
|
40
41
|
// NavigateAction set them — so selecting a file never flipped hasSelection true and
|
|
41
42
|
// Delete silently did nothing. Deriving them from the observable keeps them honest.
|
|
42
|
-
|
|
43
|
+
//
|
|
44
|
+
// `null` when no collection is open, never the string 'default'. A when-clause reading
|
|
45
|
+
// this is asking which collection is open, and answering with the name of one that may
|
|
46
|
+
// not exist made every such clause true before the user had chosen anything.
|
|
47
|
+
platform.context.setMany({ 'explorer.collectionId': null, 'explorer.hasSelection': false });
|
|
43
48
|
effect(explorer.observe(), (ex) => {
|
|
44
49
|
platform.context.setMany({
|
|
45
|
-
'explorer.collectionId': ex.collectionId
|
|
50
|
+
'explorer.collectionId': ex.collectionId ?? null,
|
|
46
51
|
'explorer.hasSelection': (ex.selection?.length || 0) > 0,
|
|
47
52
|
});
|
|
48
53
|
});
|
|
@@ -12,7 +12,10 @@ export class ExplorerService {
|
|
|
12
12
|
this.state = {
|
|
13
13
|
items: [], loading: false, error: null,
|
|
14
14
|
selection: [], sort: settings.get('explorer.sort'), order: settings.get('explorer.sortOrder'),
|
|
15
|
-
|
|
15
|
+
// No collection until one is chosen or created. `gate` is 'create' | 'choose' | null
|
|
16
|
+
// — when set, it is the ONLY thing the workbench shows, because every request needs
|
|
17
|
+
// a collection and there is nothing sensible to render without one.
|
|
18
|
+
collectionId: null, collections: [], canCreateCollection: false, gate: null,
|
|
16
19
|
// `stats` is the whole collection; `items` is the page on screen. Keeping both
|
|
17
20
|
// is what lets the UI say "500 of 3,006" instead of quietly claiming 500.
|
|
18
21
|
stats: null, usage: null, nextCursor: null, loadingMore: false, trash: null,
|
|
@@ -59,6 +62,80 @@ export class ExplorerService {
|
|
|
59
62
|
}
|
|
60
63
|
}
|
|
61
64
|
|
|
65
|
+
/**
|
|
66
|
+
* API keys, for the admin screen that manages them.
|
|
67
|
+
*
|
|
68
|
+
* Its own service rather than a corner of ExplorerService because it is a different
|
|
69
|
+
* lifetime: keys are read when someone opens Settings and never again, so they should not
|
|
70
|
+
* be part of the state every render of the file list walks over.
|
|
71
|
+
*
|
|
72
|
+
* `minted` holds the one secret a mint returns. It lives here, in memory, and is dropped
|
|
73
|
+
* the moment the admin dismisses it — the server cannot show it again, so the UI is the
|
|
74
|
+
* only place it ever exists, and it should not persist anywhere that outlives the tab.
|
|
75
|
+
*/
|
|
76
|
+
export class ApiKeysService {
|
|
77
|
+
constructor() {
|
|
78
|
+
this.state = {
|
|
79
|
+
keys: [], loading: false, loaded: false, error: null, minted: null, busy: null,
|
|
80
|
+
// The mint form. Held here rather than in the DOM so the section stays a pure
|
|
81
|
+
// function of state — and so a half-filled form survives a re-render caused by
|
|
82
|
+
// something else on the settings screen.
|
|
83
|
+
draft: null,
|
|
84
|
+
};
|
|
85
|
+
this.cell = cell(this.state);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Open the mint form, empty. */
|
|
89
|
+
startDraft() {
|
|
90
|
+
this.set({ draft: { name: '', expiresInDays: '', caps: {} }, error: null });
|
|
91
|
+
}
|
|
92
|
+
cancelDraft() {
|
|
93
|
+
this.set({ draft: null });
|
|
94
|
+
}
|
|
95
|
+
patchDraft(patch) {
|
|
96
|
+
if (!this.state.draft) return;
|
|
97
|
+
this.set({ draft: { ...this.state.draft, ...patch } });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Toggle one capability on one collection in the draft.
|
|
102
|
+
*
|
|
103
|
+
* `admin` is not treated specially here — it is offered as itself and the server
|
|
104
|
+
* expands it. Pre-ticking read/write/delete when admin is chosen would suggest they
|
|
105
|
+
* are separable afterwards, and they are not.
|
|
106
|
+
*/
|
|
107
|
+
toggleCap(collectionId, capability) {
|
|
108
|
+
if (!this.state.draft) return;
|
|
109
|
+
const caps = { ...this.state.draft.caps };
|
|
110
|
+
const held = new Set(caps[collectionId] || []);
|
|
111
|
+
if (held.has(capability)) held.delete(capability);
|
|
112
|
+
else held.add(capability);
|
|
113
|
+
if (held.size) caps[collectionId] = [...held];
|
|
114
|
+
else delete caps[collectionId];
|
|
115
|
+
this.patchDraft({ caps });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The draft as the API wants it, or null when it would grant nothing. */
|
|
119
|
+
draftScopes() {
|
|
120
|
+
const caps = this.state.draft?.caps || {};
|
|
121
|
+
const scopes = Object.entries(caps)
|
|
122
|
+
.filter(([, list]) => list.length)
|
|
123
|
+
.map(([collectionId, capabilities]) => ({ collectionId, capabilities }));
|
|
124
|
+
return scopes.length ? scopes : null;
|
|
125
|
+
}
|
|
126
|
+
observe() {
|
|
127
|
+
return this.cell;
|
|
128
|
+
}
|
|
129
|
+
set(patch) {
|
|
130
|
+
this.state = { ...this.state, ...patch };
|
|
131
|
+
this.cell.setValue(this.state);
|
|
132
|
+
}
|
|
133
|
+
/** Forget the freshly minted secret. Called on dismiss, and after a copy. */
|
|
134
|
+
clearMinted() {
|
|
135
|
+
if (this.state.minted) this.set({ minted: null });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
62
139
|
export class SearchClientService {
|
|
63
140
|
constructor() {
|
|
64
141
|
this.state = { query: '', mode: 'hybrid', results: [], loading: false, error: null, ran: false, paletteFiles: [], paletteQuery: '', paletteLoading: false, paletteError: null };
|
|
@@ -91,14 +91,24 @@ export class TroveApiClient {
|
|
|
91
91
|
return res.ok;
|
|
92
92
|
} catch { return false; }
|
|
93
93
|
}
|
|
94
|
+
// Collection-scoped calls name their collection in the PATH. There is no default and
|
|
95
|
+
// no `?collection=`: the server refuses a request that does not say which collection it
|
|
96
|
+
// means, so a caller that forgets gets an error here rather than silently reaching one.
|
|
94
97
|
/** Every item in a collection. */
|
|
95
|
-
list(opts = {}) {
|
|
96
|
-
return this.request('GET',
|
|
98
|
+
list(collection, opts = {}) {
|
|
99
|
+
return this.request('GET', `${this.#scope(collection)}/items`, { query: opts });
|
|
97
100
|
}
|
|
98
101
|
/** Resolve an item by id, by `trove:` URI, or by name within a collection. */
|
|
99
|
-
stat(ref, opts = {}) {
|
|
102
|
+
stat(ref, { collection, ...opts } = {}) {
|
|
100
103
|
const key = String(ref).startsWith('trove:') ? 'uri' : 'id';
|
|
101
|
-
|
|
104
|
+
// A name is only unique inside a collection, so resolving one needs the scope. An id
|
|
105
|
+
// or a trove: URI names itself, and the flat resolve is fine for those.
|
|
106
|
+
const base = collection ? `${this.#scope(collection)}/items/resolve` : '/api/items/resolve';
|
|
107
|
+
return this.request('GET', base, { query: { [key]: ref, ...opts } });
|
|
108
|
+
}
|
|
109
|
+
#scope(collection) {
|
|
110
|
+
if (!collection) throw new Error('This call is scoped to a collection — pass one');
|
|
111
|
+
return `/api/collections/${encodeURIComponent(collection)}`;
|
|
102
112
|
}
|
|
103
113
|
/** What links to this item. */
|
|
104
114
|
backlinks(id, opts = {}) {
|
|
@@ -112,26 +122,34 @@ export class TroveApiClient {
|
|
|
112
122
|
}
|
|
113
123
|
/** What's been deleted but not yet destroyed. */
|
|
114
124
|
trash(collection) {
|
|
115
|
-
return this.request('GET',
|
|
125
|
+
return this.request('GET', `${this.#scope(collection)}/trash`);
|
|
116
126
|
}
|
|
117
127
|
restore(id) {
|
|
118
128
|
return this.request('POST', '/api/trash/restore', { body: { id } });
|
|
119
129
|
}
|
|
120
130
|
/** Destroy for real — one item, or everything in a collection's trash. */
|
|
121
131
|
purgeTrash({ id, collection } = {}) {
|
|
122
|
-
|
|
132
|
+
// One item names itself; emptying a whole collection has to name the collection, and
|
|
133
|
+
// says so in the URL — "everything in here" is the request that must never be able
|
|
134
|
+
// to mean somewhere you did not point at.
|
|
135
|
+
return id
|
|
136
|
+
? this.request('POST', '/api/trash/purge', { body: { id } })
|
|
137
|
+
: this.request('POST', `${this.#scope(collection)}/trash/purge`);
|
|
123
138
|
}
|
|
124
|
-
search(q, opts = {}) {
|
|
125
|
-
|
|
139
|
+
search(q, { collection, ...opts } = {}) {
|
|
140
|
+
const base = collection ? `${this.#scope(collection)}/search` : '/api/search';
|
|
141
|
+
return this.request('GET', base, { query: { q, ...opts } });
|
|
126
142
|
}
|
|
127
143
|
// Unified query: server transforms the raw string (parse/LLM) → runs it → returns
|
|
128
144
|
// { query, results, resolved }. `resolved` is what was actually searched.
|
|
129
|
-
query(q, opts = {}) {
|
|
130
|
-
|
|
145
|
+
query(q, { collection, ...opts } = {}) {
|
|
146
|
+
const base = collection ? `${this.#scope(collection)}/query` : '/api/query';
|
|
147
|
+
return this.request('POST', base, { body: { q, ...opts } });
|
|
131
148
|
}
|
|
132
149
|
// Drive-wide tag/property filter (launcher #tag / #key:op:value).
|
|
133
|
-
tagSearch(filters, q, opts = {}) {
|
|
134
|
-
|
|
150
|
+
tagSearch(filters, q, { collection, ...opts } = {}) {
|
|
151
|
+
const base = collection ? `${this.#scope(collection)}/tags/search` : '/api/tags/search';
|
|
152
|
+
return this.request('POST', base, { body: { filters, q, ...opts } });
|
|
135
153
|
}
|
|
136
154
|
indexers() {
|
|
137
155
|
return this.request('GET', '/api/indexers');
|
|
@@ -167,6 +185,18 @@ export class TroveApiClient {
|
|
|
167
185
|
return this.request('POST', `/api/collections/${encodeURIComponent(id)}/scan`);
|
|
168
186
|
}
|
|
169
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Check every collection's backing store for problems a browser would hit — chiefly a
|
|
190
|
+
* missing CORS policy, which leaves the file list and search working while every file
|
|
191
|
+
* fails to open.
|
|
192
|
+
*
|
|
193
|
+
* Worth asking from the browser rather than only on a timer: the server checks against
|
|
194
|
+
* the origin of THIS request, and a bucket policy may name exactly one origin.
|
|
195
|
+
*/
|
|
196
|
+
checkStorage() {
|
|
197
|
+
return this.request('POST', '/api/diagnostics/storage');
|
|
198
|
+
}
|
|
199
|
+
|
|
170
200
|
// --- server plugin installs (account-scoped, synced across devices) ---------
|
|
171
201
|
/** Upload a package zip for account install; returns the server install record. */
|
|
172
202
|
async installPlugin(bytes, grants) {
|
|
@@ -241,6 +271,19 @@ export class TroveApiClient {
|
|
|
241
271
|
vapidKey() {
|
|
242
272
|
return this.request('GET', '/api/push/vapid');
|
|
243
273
|
}
|
|
274
|
+
|
|
275
|
+
// --- API keys (admin) -------------------------------------------------------
|
|
276
|
+
// Capability-only credentials; see core/apiKeys.js. `mintApiKey` is the one call in
|
|
277
|
+
// this client whose response contains a secret, and it is the only chance to see it.
|
|
278
|
+
apiKeys() {
|
|
279
|
+
return this.request('GET', '/api/keys');
|
|
280
|
+
}
|
|
281
|
+
mintApiKey({ name, scopes, expiresAt = null }) {
|
|
282
|
+
return this.request('POST', '/api/keys', { body: { name, scopes, expiresAt } });
|
|
283
|
+
}
|
|
284
|
+
revokeApiKey(id) {
|
|
285
|
+
return this.request('DELETE', `/api/keys/${encodeURIComponent(id)}`);
|
|
286
|
+
}
|
|
244
287
|
subscribePush(subscription) {
|
|
245
288
|
return this.request('POST', '/api/push/subscribe', { body: { subscription } });
|
|
246
289
|
}
|
|
@@ -344,8 +387,8 @@ export class TroveApiClient {
|
|
|
344
387
|
async upload(file, opts) {
|
|
345
388
|
const name = opts.name || file.name || 'untitled';
|
|
346
389
|
const size = file.size;
|
|
347
|
-
const plan = await this.request('POST',
|
|
348
|
-
body: {
|
|
390
|
+
const plan = await this.request('POST', `${this.#scope(opts.collection)}/uploads`, {
|
|
391
|
+
body: { name, size, contentType: file.type || undefined },
|
|
349
392
|
signal: opts.signal,
|
|
350
393
|
});
|
|
351
394
|
// Hand the caller the server upload id so a cancel/failure can abort the session
|
|
@@ -131,10 +131,13 @@ export class PluginRpcRouter {
|
|
|
131
131
|
// ONE options object — `api.list(opts)`. Called as `list(pathOrId, params)` the
|
|
132
132
|
// collection became the query string's key and everything else was dropped, so a
|
|
133
133
|
// plugin asking for `photos` silently got the default collection unsorted.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
134
|
+
// A plugin has to say which collection it means, like everything else. The host
|
|
135
|
+
// does not pick one for it — a plugin reaching into whatever collection happened to
|
|
136
|
+
// be the default is exactly the ambient authority the capability list exists to stop.
|
|
137
|
+
case 'files:list': return cap('files'), this.platform.api.list(
|
|
138
|
+
params.collection || params.pathOrId,
|
|
139
|
+
{ sort: params.sort, order: params.order, limit: params.limit, cursor: params.cursor },
|
|
140
|
+
);
|
|
138
141
|
case 'files:stat': return cap('files'), this.platform.api.stat(params.id);
|
|
139
142
|
case 'files:downloadUrl': return cap('files'), { url: this.platform.api.downloadUrl(params.id) };
|
|
140
143
|
case 'files:index': {
|
|
@@ -1022,6 +1022,14 @@ kbd {
|
|
|
1022
1022
|
.act-detail, .act-meta { font-size: 11.5px; color: var(--text-dim); margin-top: 2px; overflow-wrap: anywhere; }
|
|
1023
1023
|
.act-meta { color: var(--text-faint); }
|
|
1024
1024
|
.act-error { font-size: 11.5px; color: var(--danger); margin-top: 2px; overflow-wrap: anywhere; }
|
|
1025
|
+
/* A remedy is a policy or a command, so it keeps its own whitespace and scrolls rather
|
|
1026
|
+
than reflowing — a JSON document rewrapped to the panel width is not pasteable. */
|
|
1027
|
+
.act-remedy {
|
|
1028
|
+
font-family: var(--font-mono, ui-monospace, monospace); font-size: 10.5px; line-height: 1.5;
|
|
1029
|
+
color: var(--text-dim); background: var(--bg-inset, var(--bg-hover)); border: 1px solid var(--border);
|
|
1030
|
+
border-radius: 6px; padding: 8px 10px; margin: 6px 0 2px; max-height: 220px;
|
|
1031
|
+
overflow: auto; white-space: pre; tab-size: 2;
|
|
1032
|
+
}
|
|
1025
1033
|
.act-amount { font-size: 11px; color: var(--text-faint); margin-top: 3px; }
|
|
1026
1034
|
.act-action { background: none; border: none; color: var(--text-faint); cursor: pointer; padding: 2px; border-radius: 4px; }
|
|
1027
1035
|
.act-action:hover { background: var(--bg-hover); color: var(--text); }
|
|
@@ -1380,3 +1388,132 @@ kbd {
|
|
|
1380
1388
|
.launch-h .lh-title { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
|
|
1381
1389
|
.launch-h .lh-actions { display: flex; align-items: center; gap: 2px; flex: none; }
|
|
1382
1390
|
.launch-h .lh-verbatim { text-transform: none; font-weight: 500; color: var(--text-dim); letter-spacing: 0; }
|
|
1391
|
+
|
|
1392
|
+
/* ---- API keys (settings) --------------------------------------------------
|
|
1393
|
+
Built from the same tokens as the rest of Settings, so a key list reads as part
|
|
1394
|
+
of the screen rather than a bolted-on admin console. */
|
|
1395
|
+
.keys-actions { display: flex; gap: 8px; align-items: center; padding: 12px 0 2px; }
|
|
1396
|
+
.keys-empty { color: var(--text-faint); font-size: 12.5px; padding: 10px 0; }
|
|
1397
|
+
|
|
1398
|
+
.keys-list { display: flex; flex-direction: column; gap: 8px; margin-top: 12px; }
|
|
1399
|
+
.key-row {
|
|
1400
|
+
display: flex; align-items: flex-start; gap: 12px; padding: 12px 13px;
|
|
1401
|
+
border: 1px solid var(--border); border-radius: 10px; background: var(--bg-elevated);
|
|
1402
|
+
}
|
|
1403
|
+
.key-row-main { flex: 1; min-width: 0; }
|
|
1404
|
+
.key-row-name { display: flex; align-items: center; gap: 8px; }
|
|
1405
|
+
.key-row-name .t { font-weight: 550; }
|
|
1406
|
+
/* Revoked and expired keys stay in the list — "why did this stop working" is the
|
|
1407
|
+
question they answer — so they are dimmed rather than removed. */
|
|
1408
|
+
.key-row.dead { opacity: .55; }
|
|
1409
|
+
.key-row.dead .key-row-name .t { text-decoration: line-through; }
|
|
1410
|
+
.key-tag {
|
|
1411
|
+
font-size: 10.5px; text-transform: uppercase; letter-spacing: .5px;
|
|
1412
|
+
padding: 2px 7px; border-radius: 6px; background: var(--bg-hover); color: var(--text-dim);
|
|
1413
|
+
}
|
|
1414
|
+
.key-row-scopes { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
|
|
1415
|
+
.key-chip {
|
|
1416
|
+
display: inline-flex; align-items: center; gap: 6px; font-size: 11px;
|
|
1417
|
+
border: 1px solid var(--border); border-radius: 6px; overflow: hidden;
|
|
1418
|
+
}
|
|
1419
|
+
.key-chip .where { padding: 2px 7px; background: var(--bg-hover); color: var(--text-dim); }
|
|
1420
|
+
.key-chip .what { padding: 2px 7px; color: var(--accent); font-family: var(--font-mono); font-size: 10.5px; }
|
|
1421
|
+
.key-row-meta { color: var(--text-faint); font-size: 11.5px; margin-top: 7px; }
|
|
1422
|
+
|
|
1423
|
+
/* ---- the mint form ---- */
|
|
1424
|
+
.key-form {
|
|
1425
|
+
border: 1px solid var(--border); border-radius: 11px; background: var(--bg-elevated);
|
|
1426
|
+
padding: 4px 14px 14px; margin-top: 12px;
|
|
1427
|
+
}
|
|
1428
|
+
.key-form .setting:last-of-type { border-bottom: 0; }
|
|
1429
|
+
.key-scopes { padding: 12px 0 4px; }
|
|
1430
|
+
.key-scopes-head .t { font-weight: 550; }
|
|
1431
|
+
.key-scopes-head .d { color: var(--text-faint); font-size: 12px; margin-top: 2px; line-height: 1.4; }
|
|
1432
|
+
.key-scope {
|
|
1433
|
+
display: flex; align-items: center; gap: 12px; padding: 9px 0;
|
|
1434
|
+
border-bottom: 1px solid var(--border);
|
|
1435
|
+
}
|
|
1436
|
+
.key-scope:last-child { border-bottom: 0; }
|
|
1437
|
+
/* "All collections" is the one row where a tick is a much bigger decision than it
|
|
1438
|
+
looks, so it is set apart rather than sitting flush with the named collections. */
|
|
1439
|
+
.key-scope.wildcard {
|
|
1440
|
+
margin-bottom: 4px; padding: 10px 11px; border-bottom: 0; border-radius: 9px;
|
|
1441
|
+
background: color-mix(in srgb, var(--warn) 8%, transparent);
|
|
1442
|
+
border: 1px solid color-mix(in srgb, var(--warn) 26%, transparent);
|
|
1443
|
+
}
|
|
1444
|
+
.key-scope-name { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; font-size: 13px; }
|
|
1445
|
+
.key-scope-name span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1446
|
+
.key-scope-name svg { flex: none; color: var(--text-dim); }
|
|
1447
|
+
.key-scope.wildcard .key-scope-name svg { color: var(--warn); }
|
|
1448
|
+
.key-scope-caps { display: flex; gap: 6px; flex: none; }
|
|
1449
|
+
.key-scope-caps .cap {
|
|
1450
|
+
display: inline-flex; align-items: center; gap: 5px; cursor: pointer; user-select: none;
|
|
1451
|
+
font-size: 11.5px; padding: 4px 9px; border-radius: 7px;
|
|
1452
|
+
border: 1px solid var(--border); color: var(--text-dim); background: transparent;
|
|
1453
|
+
}
|
|
1454
|
+
.key-scope-caps .cap:hover { background: var(--bg-hover); }
|
|
1455
|
+
.key-scope-caps .cap.on {
|
|
1456
|
+
color: var(--accent); border-color: color-mix(in srgb, var(--accent) 45%, var(--border));
|
|
1457
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
1458
|
+
}
|
|
1459
|
+
.key-scope-caps .cap input { margin: 0; accent-color: var(--accent); }
|
|
1460
|
+
|
|
1461
|
+
/* ---- the one-time secret ----
|
|
1462
|
+
Loud on purpose. The server kept only a hash, so this is the only moment the value
|
|
1463
|
+
exists anywhere, and a quiet row would be dismissed without being copied. */
|
|
1464
|
+
.key-minted {
|
|
1465
|
+
margin: 12px 0 4px; padding: 13px 14px; border-radius: 11px;
|
|
1466
|
+
background: color-mix(in srgb, var(--warn) 9%, transparent);
|
|
1467
|
+
border: 1px solid color-mix(in srgb, var(--warn) 32%, transparent);
|
|
1468
|
+
}
|
|
1469
|
+
.key-minted-head { display: flex; align-items: center; gap: 9px; font-weight: 550; color: var(--warn); }
|
|
1470
|
+
.key-minted-head svg { flex: none; }
|
|
1471
|
+
.key-minted-note { color: var(--text-dim); font-size: 12.5px; line-height: 1.55; margin: 8px 0 11px; }
|
|
1472
|
+
.key-secret { display: flex; align-items: center; gap: 8px; min-width: 0; }
|
|
1473
|
+
.key-secret code {
|
|
1474
|
+
flex: 1; min-width: 0; font-family: var(--font-mono); font-size: 12.5px; color: var(--text);
|
|
1475
|
+
background: var(--bg-input); border: 1px solid var(--border); border-radius: 6px;
|
|
1476
|
+
padding: 7px 10px; overflow-x: auto; white-space: nowrap;
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
/* A capability row is four chips wide; on a phone that beats the collection name into a
|
|
1480
|
+
gutter, so the row stacks and the chips wrap under it. */
|
|
1481
|
+
@media (max-width: 560px) {
|
|
1482
|
+
.key-scope { flex-direction: column; align-items: stretch; gap: 8px; }
|
|
1483
|
+
.key-scope-caps { flex-wrap: wrap; }
|
|
1484
|
+
.key-row { flex-direction: column; }
|
|
1485
|
+
}
|
|
1486
|
+
|
|
1487
|
+
/* ---- Collection gate -------------------------------------------------------
|
|
1488
|
+
The whole screen until a collection is chosen. Centred and narrow on purpose:
|
|
1489
|
+
there is exactly one decision to make here, and a full-width layout would imply
|
|
1490
|
+
there is something else to look at. */
|
|
1491
|
+
.gate { display: grid; place-items: center; min-height: 100%; padding: 40px 20px; }
|
|
1492
|
+
.gate-card { width: min(520px, 100%); text-align: center; }
|
|
1493
|
+
.gate-icon {
|
|
1494
|
+
width: 56px; height: 56px; margin: 0 auto 16px; border-radius: 15px;
|
|
1495
|
+
display: grid; place-items: center; color: var(--accent);
|
|
1496
|
+
background: color-mix(in srgb, var(--accent) 13%, transparent);
|
|
1497
|
+
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
|
|
1498
|
+
}
|
|
1499
|
+
.gate-card h2 { margin: 0 0 8px; font-size: 19px; }
|
|
1500
|
+
.gate-sub { color: var(--text-dim); font-size: 13px; line-height: 1.6; margin: 0 0 20px; }
|
|
1501
|
+
.gate-actions { display: flex; gap: 8px; justify-content: center; margin-top: 18px; }
|
|
1502
|
+
|
|
1503
|
+
.gate-list { display: flex; flex-direction: column; gap: 8px; text-align: left; }
|
|
1504
|
+
.gate-choice {
|
|
1505
|
+
display: flex; align-items: center; gap: 12px; width: 100%; cursor: pointer;
|
|
1506
|
+
padding: 12px 14px; border-radius: 10px; font: inherit; color: inherit;
|
|
1507
|
+
border: 1px solid var(--border); background: var(--bg-elevated);
|
|
1508
|
+
}
|
|
1509
|
+
.gate-choice:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: var(--bg-hover); }
|
|
1510
|
+
.gate-choice:focus-visible { outline: none; box-shadow: 0 0 0 3px var(--focus-halo); }
|
|
1511
|
+
.gate-choice-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
|
|
1512
|
+
.gate-choice-main .n { font-weight: 550; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1513
|
+
.gate-choice-main .d { color: var(--text-faint); font-size: 11.5px; }
|
|
1514
|
+
/* The capability list is the quiet half of the row: useful before you commit, not
|
|
1515
|
+
something to read first. */
|
|
1516
|
+
.gate-caps {
|
|
1517
|
+
flex: none; font-family: var(--font-mono); font-size: 10.5px; color: var(--text-dim);
|
|
1518
|
+
padding: 2px 8px; border-radius: 6px; background: var(--bg-hover);
|
|
1519
|
+
}
|
|
@@ -12,10 +12,23 @@ import { dd } from '../../runtime.js';
|
|
|
12
12
|
import { icon } from '../icon.js';
|
|
13
13
|
import { bytes } from '../format.js';
|
|
14
14
|
|
|
15
|
-
const { div, button, span, h3, p } = dd;
|
|
15
|
+
const { div, button, span, h3, p, pre } = dd;
|
|
16
16
|
|
|
17
17
|
const STATUS_ICON = { running: 'refresh', done: 'check', failed: 'close', cancelled: 'close' };
|
|
18
18
|
|
|
19
|
+
/**
|
|
20
|
+
* Clipboard, with the value shown if the write is refused.
|
|
21
|
+
*
|
|
22
|
+
* Clipboard permission is denied often enough that a button which silently does nothing
|
|
23
|
+
* reads as broken — and the remedy is the one thing on this panel the user has to get out
|
|
24
|
+
* of the app and into a terminal.
|
|
25
|
+
*/
|
|
26
|
+
function copyText(text, ui) {
|
|
27
|
+
navigator.clipboard?.writeText(text)
|
|
28
|
+
.then(() => ui.platform.notifications.success('Copied'))
|
|
29
|
+
.catch(() => ui.platform.notifications.info(text, { sticky: true }));
|
|
30
|
+
}
|
|
31
|
+
|
|
19
32
|
function amount(task) {
|
|
20
33
|
if (task.total == null) return null;
|
|
21
34
|
return task.unit === 'bytes'
|
|
@@ -61,10 +74,18 @@ function issueRow(issue, ui) {
|
|
|
61
74
|
div({ className: 'act-body' },
|
|
62
75
|
div({ className: 'act-title' }, issue.title),
|
|
63
76
|
issue.detail ? div({ className: 'act-detail' }, issue.detail) : null,
|
|
77
|
+
// The fix, kept apart from the description and rendered as-is. A remedy is often
|
|
78
|
+
// a policy document or a shell command, and a problem report that describes the
|
|
79
|
+
// fix in prose leaves the reader to retype it.
|
|
80
|
+
issue.remedy ? pre({ className: 'act-remedy' }, issue.remedy) : null,
|
|
64
81
|
div({ className: 'act-meta' }, issue.count > 1 ? `${issue.count} times, since ${since}` : `since ${since}`),
|
|
65
82
|
),
|
|
66
83
|
),
|
|
67
84
|
div({ className: 'act-actions' },
|
|
85
|
+
issue.remedy
|
|
86
|
+
? button({ className: 'btn small ghost' }, 'Copy fix')
|
|
87
|
+
.on({ click: () => copyText(issue.remedy, ui) })
|
|
88
|
+
: null,
|
|
68
89
|
// Retry only when the server said pressing it will actually do something.
|
|
69
90
|
issue.retryable
|
|
70
91
|
? button({ className: 'btn small act-retry' }, icon('refresh', { size: 12 }), span('Retry'))
|
|
@@ -127,6 +148,12 @@ export default function activityPanel(state, ui) {
|
|
|
127
148
|
.on({ click: () => ui.exec('workbench.rebuildIndex') }),
|
|
128
149
|
button({ className: 'btn small ghost act-scan' }, icon('refresh', { size: 12 }), span('Scan for outside changes'))
|
|
129
150
|
.on({ click: () => ui.exec('workbench.scanCollection') }),
|
|
151
|
+
// On demand as well as on a schedule, because the moment an admin wants to know
|
|
152
|
+
// whether they fixed the bucket is right after they changed it — not up to five
|
|
153
|
+
// minutes later. It also checks against THIS browser's origin, which the scheduled
|
|
154
|
+
// run can only do if the deployment configured one.
|
|
155
|
+
button({ className: 'btn small ghost act-storage' }, icon('plug', { size: 12 }), span('Check storage'))
|
|
156
|
+
.on({ click: () => ui.exec('workbench.checkStorage') }),
|
|
130
157
|
),
|
|
131
158
|
);
|
|
132
159
|
}
|