@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.
Files changed (41) hide show
  1. package/README.md +6 -0
  2. package/package.json +1 -1
  3. package/packages/core/src/apiKeys.js +326 -0
  4. package/packages/core/src/collections/index.js +83 -13
  5. package/packages/core/src/index.js +14 -2
  6. package/packages/core/src/issues.js +4 -0
  7. package/packages/core/src/storage/diagnose.js +234 -0
  8. package/packages/core/src/storage/drivers.js +83 -0
  9. package/packages/core/src/storage/filesystem.js +22 -0
  10. package/packages/core/src/storage/registry.js +162 -0
  11. package/packages/server/src/adapters/bun.js +6 -0
  12. package/packages/server/src/adapters/node.js +6 -0
  13. package/packages/server/src/engine/index.js +1 -1
  14. package/packages/server/src/engine/providers/access.js +47 -5
  15. package/packages/server/src/engine/providers/core.js +105 -11
  16. package/packages/server/src/index.js +123 -11
  17. package/packages/server/src/mcp/tools.js +40 -8
  18. package/packages/server/src/router.js +1 -1
  19. package/packages/server/src/routes.js +135 -32
  20. package/packages/server/src/scope.js +2 -2
  21. package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
  22. package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
  23. package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
  24. package/packages/web/dist/index.html +2 -2
  25. package/packages/web/dist/sw.js +58 -9
  26. package/packages/web/src/bl/actions.js +112 -13
  27. package/packages/web/src/bl/activity.js +32 -0
  28. package/packages/web/src/bl/commands.js +41 -2
  29. package/packages/web/src/bl/index.js +9 -4
  30. package/packages/web/src/bl/services.js +78 -1
  31. package/packages/web/src/platform/api.js +57 -14
  32. package/packages/web/src/platform/pluginRpc.js +7 -4
  33. package/packages/web/src/styles.css +137 -0
  34. package/packages/web/src/ui/components/activityPanel.js +28 -1
  35. package/packages/web/src/ui/components/collectionGate.js +81 -0
  36. package/packages/web/src/ui/components/overlays.js +64 -34
  37. package/packages/web/src/ui/components/phoneChrome.js +2 -2
  38. package/packages/web/src/ui/components/settingsView.js +197 -1
  39. package/packages/web/src/ui/components/statusBar.js +19 -2
  40. package/packages/web/src/ui/compositions/workbench.js +9 -2
  41. 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
- () => app.activity.scanCollection(explorer.state.collectionId || 'default').catch(() => {}),
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
- const current = explorer.state.collectionId || 'default';
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
- platform.context.setMany({ 'explorer.collectionId': 'default', 'explorer.hasSelection': false });
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 || 'default',
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
- collectionId: 'default', collections: [], canCreateCollection: false,
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', '/api/items', { query: opts });
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
- return this.request('GET', '/api/items/resolve', { query: { [key]: ref, ...opts } });
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', '/api/trash', { query: collection ? { collection } : {} });
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
- return this.request('POST', '/api/trash/purge', { body: id ? { id } : { collection } });
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
- return this.request('GET', '/api/search', { query: { q, ...opts } });
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
- return this.request('POST', '/api/query', { body: { q, ...opts } });
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
- return this.request('POST', '/api/tags/search', { body: { filters, q, ...opts } });
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', '/api/uploads', {
348
- body: { collection: opts.collection, name, size, contentType: file.type || undefined },
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
- case 'files:list': return cap('files'), this.platform.api.list({
135
- collection: params.collection || params.pathOrId || undefined,
136
- sort: params.sort, order: params.order, limit: params.limit, cursor: params.cursor,
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
  }