@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
package/README.md CHANGED
@@ -163,6 +163,12 @@ TROVE_S3_ACCESS_KEY_ID=… # or AWS_ACCESS_KEY_ID
163
163
  TROVE_S3_SECRET_ACCESS_KEY=… # or AWS_SECRET_ACCESS_KEY
164
164
  TROVE_S3_PATH_STYLE=true # MinIO / custom endpoints
165
165
 
166
+ # Which store types a COLLECTION may be created on. Defaults to everything this
167
+ # runtime registered; naming a subset takes the rest off the collection form and
168
+ # refuses them. Worth setting on Workers, where `memory` is offered because it is
169
+ # portable but produces a collection that loses its uploads on isolate recycle.
170
+ TROVE_STORAGE_DRIVERS=s3 # subset of: memory | filesystem | s3
171
+
166
172
  # Metadata (file tree + facets)
167
173
  TROVE_METADATA=sqlite # memory | sqlite
168
174
  TROVE_DB_PATH=./data/trove.db
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.4",
3
+ "version": "0.0.7",
4
4
  "type": "module",
5
5
  "description": "Trove — a self-hostable, plugin-extensible Google Drive. Semantic search, pluggable storage (S3 / filesystem / NAS), and a VS Code-style contribution system with sandboxed plugins.",
6
6
  "repository": {
@@ -0,0 +1,326 @@
1
+ // API keys: a credential that carries CAPABILITIES and no identity.
2
+ //
3
+ // This is deliberately not "log in as a robot user". An API key does not answer "who is
4
+ // this", it answers "what may this request do" — and those are different questions that
5
+ // the rest of the drive already keeps apart: IdentityProvider says who, the collection
6
+ // ACL says what. A key skips the first question entirely.
7
+ //
8
+ // The pattern is not new here. Signed URLs (signedUrls.js) already work this way: a valid
9
+ // signature is served WITHOUT a principal because it was minted by someone who held the
10
+ // capability it grants. A key is that idea with a longer life, a broader scope, and a
11
+ // record you can revoke. Both are grants; neither is a login.
12
+ //
13
+ // Consequences worth being deliberate about:
14
+ //
15
+ // Attribution is to the KEY, not a person. `createdBy` records who minted it, so an
16
+ // audit trail exists, but a write made with a key is the key's write. If you need
17
+ // per-person attribution, that is what identity is for — do not hand one key to five
18
+ // people and expect the log to tell them apart.
19
+ //
20
+ // Scope is per collection and explicit. A key names the collections it may touch and
21
+ // the capabilities it holds on each. Drive-wide access exists but has to be asked for
22
+ // ('*'), because the difference between "this one bucket" and "everything" should never
23
+ // be a thing you get by leaving a field blank.
24
+ //
25
+ // The secret is never stored. Only a SHA-256 of it, so a dump of the KV store yields
26
+ // nothing replayable. It is shown to the minter exactly once.
27
+
28
+ import { TroveError } from './errors.js';
29
+ import { CAPABILITIES, expand } from './collections/index.js';
30
+
31
+ const NS = 'api-keys';
32
+
33
+ /** Every collection, rather than a named one. Spelled out so it cannot be a typo. */
34
+ export const ANY_COLLECTION = '*';
35
+
36
+ // `trv_<id>_<secret>`. The id travels in the credential so verification is a single
37
+ // keyed read rather than a scan-and-compare over every key in the store — which is both
38
+ // slower and a timing oracle over how many keys exist.
39
+ const PREFIX = 'trv';
40
+ const ID_PREFIX = 'key';
41
+ const SECRET_BYTES = 32;
42
+
43
+ const enc = new TextEncoder();
44
+
45
+ function b64url(bytes) {
46
+ let s = '';
47
+ for (const b of bytes) s += String.fromCharCode(b);
48
+ return btoa(s).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
49
+ }
50
+
51
+ function randomB64(bytes) {
52
+ const buf = new Uint8Array(bytes);
53
+ crypto.getRandomValues(buf);
54
+ return b64url(buf);
55
+ }
56
+
57
+ /**
58
+ * Hex, for the id half only.
59
+ *
60
+ * base64url would be shorter but its alphabet includes `_`, which is the separator —
61
+ * so an id could contain one and the credential would no longer parse into id and
62
+ * secret. Hex has no such overlap, and the id is not the part carrying the entropy.
63
+ */
64
+ function randomHex(bytes) {
65
+ const buf = new Uint8Array(bytes);
66
+ crypto.getRandomValues(buf);
67
+ return [...buf].map((b) => b.toString(16).padStart(2, '0')).join('');
68
+ }
69
+
70
+ async function sha256(text) {
71
+ const digest = await crypto.subtle.digest('SHA-256', enc.encode(text));
72
+ return b64url(new Uint8Array(digest));
73
+ }
74
+
75
+ /**
76
+ * Compare without leaking where two strings diverge.
77
+ *
78
+ * The hash of a presented secret against the stored hash. A `===` here would return
79
+ * faster the earlier it finds a difference, which over enough attempts is a way to learn
80
+ * a prefix — the classic reason credential comparison is not string equality.
81
+ */
82
+ function timingSafeEqual(a, b) {
83
+ if (typeof a !== 'string' || typeof b !== 'string' || a.length !== b.length) return false;
84
+ let diff = 0;
85
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
86
+ return diff === 0;
87
+ }
88
+
89
+ /** Normalise and validate one `{ collectionId, capabilities }` entry. */
90
+ function normalizeScope(scope) {
91
+ const collectionId = String(scope?.collectionId ?? '').trim();
92
+ if (!collectionId) throw TroveError.invalid('Every scope needs a collectionId (or "*")');
93
+ const caps = [...new Set(scope?.capabilities ?? [])];
94
+ if (!caps.length) throw TroveError.invalid(`Scope for "${collectionId}" grants no capabilities`);
95
+ for (const c of caps) {
96
+ if (!CAPABILITIES.includes(c)) {
97
+ throw TroveError.invalid(`Unknown capability "${c}" — expected one of: ${CAPABILITIES.join(', ')}`);
98
+ }
99
+ }
100
+ return { collectionId, capabilities: caps };
101
+ }
102
+
103
+ /**
104
+ * A resolved key, as the authorization layer sees it.
105
+ *
106
+ * Shaped to be the answer to one question — "what may this request do to this
107
+ * collection" — so the access layer never has to know it came from a key rather than
108
+ * from a signature or an ACL.
109
+ */
110
+ export class ApiKeyGrant {
111
+ constructor({ keyId, name, scopes }) {
112
+ this.kind = 'api-key';
113
+ this.keyId = keyId;
114
+ this.name = name;
115
+ this.scopes = scopes;
116
+ }
117
+
118
+ /**
119
+ * The capabilities this key holds on one collection, `admin` expanded to everything
120
+ * it implies (the same expansion the collection ACL uses, so a key and a grant mean
121
+ * the same thing by the word "admin").
122
+ *
123
+ * @returns {Set<string>}
124
+ */
125
+ capabilitiesFor(collectionId) {
126
+ const held = new Set();
127
+ for (const scope of this.scopes) {
128
+ if (scope.collectionId !== ANY_COLLECTION && scope.collectionId !== collectionId) continue;
129
+ for (const c of scope.capabilities) held.add(c);
130
+ }
131
+ return expand(held);
132
+ }
133
+
134
+ /** Whether this key may do `capability` to `collectionId` at all. */
135
+ can(collectionId, capability) {
136
+ return this.capabilitiesFor(collectionId).has(capability);
137
+ }
138
+ }
139
+
140
+ /**
141
+ * Mint, verify and revoke API keys.
142
+ *
143
+ * Records live in the pluggable KV store, so this works on every backend the drive runs
144
+ * on and survives restarts without a migration.
145
+ */
146
+ export class ApiKeyService {
147
+ /**
148
+ * @param {object} deps
149
+ * @param {import('./kv.js').KeyValueStore} deps.kv
150
+ * @param {() => number} [deps.now] injected clock, for tests
151
+ */
152
+ constructor({ kv, now = () => Date.now() } = {}) {
153
+ if (!kv) throw TroveError.invalid('ApiKeyService requires a kv store');
154
+ this.kv = kv;
155
+ this.now = now;
156
+ }
157
+
158
+ /**
159
+ * Create a key. The secret is returned ONCE and never stored.
160
+ *
161
+ * @param {object} spec
162
+ * @param {string} spec.name what it is for, so a list of keys is readable
163
+ * @param {Array<{collectionId: string, capabilities: string[]}>} spec.scopes
164
+ * @param {number|null} [spec.expiresAt] epoch ms; null never expires
165
+ * @param {string|null} [spec.createdBy] principal id of the minter, for the audit trail
166
+ * @returns {Promise<{record: object, secret: string}>}
167
+ */
168
+ async mint({ name, scopes, expiresAt = null, createdBy = null } = {}) {
169
+ const label = String(name ?? '').trim();
170
+ if (!label) throw TroveError.invalid('An API key needs a name');
171
+ if (!Array.isArray(scopes) || !scopes.length) {
172
+ throw TroveError.invalid('An API key needs at least one scope — a key that grants nothing is not useful');
173
+ }
174
+ const normalized = scopes.map(normalizeScope);
175
+ if (expiresAt != null && (!Number.isFinite(expiresAt) || expiresAt <= this.now())) {
176
+ throw TroveError.invalid('expiresAt must be a future timestamp');
177
+ }
178
+
179
+ const id = `${ID_PREFIX}_${randomHex(8)}`;
180
+ const secret = `${PREFIX}_${id}_${randomB64(SECRET_BYTES)}`;
181
+ const record = {
182
+ id,
183
+ name: label,
184
+ // The hash of the WHOLE credential, so a stolen record cannot be turned back into
185
+ // something presentable even by someone who knows the id.
186
+ hash: await sha256(secret),
187
+ scopes: normalized,
188
+ createdAt: this.now(),
189
+ createdBy,
190
+ expiresAt,
191
+ lastUsedAt: null,
192
+ revokedAt: null,
193
+ };
194
+ await this.kv.set(NS, id, record);
195
+ return { record: redact(record), secret };
196
+ }
197
+
198
+ /**
199
+ * Resolve a presented secret into a grant, or null.
200
+ *
201
+ * Null for every failure — unknown, revoked, expired, malformed, wrong secret. The
202
+ * caller turns that into one 401; distinguishing them in the response would tell an
203
+ * attacker which key ids exist.
204
+ *
205
+ * @returns {Promise<ApiKeyGrant|null>}
206
+ */
207
+ async verify(secret) {
208
+ if (typeof secret !== 'string') return null;
209
+ // `trv_key_<hex id>_<secret>`. Parsed by prefix rather than by splitting on `_`,
210
+ // because the secret half is base64url and may contain underscores of its own —
211
+ // only the id is guaranteed not to.
212
+ const head = `${PREFIX}_${ID_PREFIX}_`;
213
+ if (!secret.startsWith(head)) return null;
214
+ const rest = secret.slice(head.length);
215
+ const boundary = rest.indexOf('_');
216
+ if (boundary < 1 || boundary === rest.length - 1) return null;
217
+ const id = `${ID_PREFIX}_${rest.slice(0, boundary)}`;
218
+
219
+ const record = await this.kv.get(NS, id);
220
+ if (!record || record.revokedAt) return null;
221
+ if (record.expiresAt != null && record.expiresAt <= this.now()) return null;
222
+ if (!timingSafeEqual(await sha256(secret), record.hash)) return null;
223
+
224
+ return new ApiKeyGrant({ keyId: record.id, name: record.name, scopes: record.scopes });
225
+ }
226
+
227
+ /** Every key, without hashes. Newest first. */
228
+ async list() {
229
+ const rows = await this.kv.list(NS);
230
+ return rows
231
+ .map((r) => r.value)
232
+ .filter(Boolean)
233
+ .map(redact)
234
+ .sort((a, b) => b.createdAt - a.createdAt);
235
+ }
236
+
237
+ async get(id) {
238
+ const record = await this.kv.get(NS, id);
239
+ return record ? redact(record) : null;
240
+ }
241
+
242
+ /**
243
+ * Revoke by marking, not deleting.
244
+ *
245
+ * The record is what explains a key that used to work, and "why did this stop" is a
246
+ * question worth being able to answer. It also keeps the id from being reissued.
247
+ */
248
+ async revoke(id) {
249
+ const record = await this.kv.get(NS, id);
250
+ if (!record) throw TroveError.notFound('API key');
251
+ if (record.revokedAt) return redact(record);
252
+ record.revokedAt = this.now();
253
+ await this.kv.set(NS, id, record);
254
+ return redact(record);
255
+ }
256
+
257
+ /**
258
+ * Record that a key was used. Best-effort and never on the request path's critical
259
+ * section: a failure to write "last used" must not fail the request it describes.
260
+ */
261
+ async touch(id) {
262
+ try {
263
+ const record = await this.kv.get(NS, id);
264
+ if (!record || record.revokedAt) return;
265
+ record.lastUsedAt = this.now();
266
+ await this.kv.set(NS, id, record);
267
+ } catch { /* the timestamp is a convenience, not a control */ }
268
+ }
269
+ }
270
+
271
+ /** A record safe to send to a client: everything except the hash. */
272
+ function redact(record) {
273
+ const { hash, ...rest } = record;
274
+ return rest;
275
+ }
276
+
277
+ // --- the injection point -------------------------------------------------------
278
+
279
+ /**
280
+ * How a request becomes a capability grant.
281
+ *
282
+ * The counterpart to IdentityProvider, and the reason it is separate: identity and
283
+ * authority are different questions, and some credentials only answer the second.
284
+ * Implement this to authorize from something other than a key — a mutual-TLS
285
+ * certificate, a signed webhook, a service mesh header.
286
+ */
287
+ export class CapabilityProvider {
288
+ /**
289
+ * @param {Request} request
290
+ * @returns {Promise<ApiKeyGrant|null>} a grant, or null to abstain
291
+ */
292
+ async resolve(request) { // eslint-disable-line no-unused-vars
293
+ return null;
294
+ }
295
+ }
296
+
297
+ /** `Authorization: Bearer trv_key_…`, verified against the key store. */
298
+ export class ApiKeyCapabilityProvider extends CapabilityProvider {
299
+ constructor({ apiKeys } = {}) {
300
+ super();
301
+ if (!apiKeys) throw TroveError.invalid('ApiKeyCapabilityProvider requires an ApiKeyService');
302
+ this.apiKeys = apiKeys;
303
+ }
304
+
305
+ async resolve(request) {
306
+ const header = request?.headers?.get?.('authorization') || '';
307
+ const match = /^Bearer\s+(\S+)$/i.exec(header);
308
+ // Only OUR prefix. A bearer token that is someone's OIDC access token must fall
309
+ // through to the identity provider untouched, not be spent as a failed key lookup.
310
+ if (!match || !match[1].startsWith(`${PREFIX}_`)) return null;
311
+
312
+ const grant = await this.apiKeys.verify(match[1]);
313
+ // Presented one of ours and it did not verify. That is a 401, not a fall-through to
314
+ // anonymous: a caller who sent a credential is telling us they expect to be
315
+ // authorized by it, and quietly serving them as the public would turn a revoked key
316
+ // into "works, but with less access" — which is how a revocation goes unnoticed.
317
+ if (!grant) throw TroveError.unauthorized('This API key is not valid');
318
+
319
+ // Not awaited. "Last used" is what tells an admin which key nobody needs any more,
320
+ // so it is worth recording — but it is a convenience, and a slow or failing KV write
321
+ // must not add latency to, or fail, the request it is describing. `touch` swallows
322
+ // its own errors for the same reason.
323
+ this.apiKeys.touch(grant.keyId);
324
+ return grant;
325
+ }
326
+ }
@@ -20,7 +20,9 @@ export const CAPABILITIES = ['read', 'write', 'delete', 'admin'];
20
20
  const NS = 'collections';
21
21
 
22
22
  // admin implies everything; a simple implication table keeps checks declarative.
23
- function expand(caps) {
23
+ // Exported because API keys grant capabilities too, and "admin" has to mean the same
24
+ // thing in a key as it does in an ACL — two implication tables would eventually diverge.
25
+ export function expand(caps) {
24
26
  const set = new Set(caps);
25
27
  if (set.has('admin')) for (const c of CAPABILITIES) set.add(c);
26
28
  return set;
@@ -50,17 +52,21 @@ export class CollectionService {
50
52
  this._storage = new Map(Object.entries(storageOverrides || {}));
51
53
  }
52
54
 
53
- async init() {
54
- const existing = await this.kv.get(NS, 'default');
55
- if (!existing) {
56
- await this.kv.set(NS, 'default', {
57
- id: 'default', name: 'My Drive', description: 'Default collection',
58
- store: this.defaultStore,
59
- acl: { grants: this.defaultOpen ? [{ type: 'anyone', capabilities: ['read', 'write', 'delete', 'admin'] }] : [] },
60
- createdAt: Date.now(), createdBy: 'system', system: true,
61
- });
62
- }
63
- }
55
+ /**
56
+ * Nothing is created here any more.
57
+ *
58
+ * There used to be a `default` collection, minted on first boot and named by every
59
+ * unscoped request. It made a fresh drive feel ready, and it cost more than it was
60
+ * worth: `'default'` became a hardcoded assumption in routing, in maintenance, in the
61
+ * metadata schema and in four core signatures, and on a multi-user drive it was a
62
+ * collection most people could not read — so the fallback pointed new users at a
63
+ * permission error and called it their drive.
64
+ *
65
+ * A drive with no collections now says so, and the client asks for one to be created.
66
+ * That is a real first-run step rather than a magic id, and every request names the
67
+ * collection it means.
68
+ */
69
+ async init() {}
64
70
 
65
71
  async list(principal) {
66
72
  const rows = await this.kv.list(NS);
@@ -71,6 +77,22 @@ export class CollectionService {
71
77
  .sort((a, b) => (a.name || '').localeCompare(b.name || ''));
72
78
  }
73
79
 
80
+ /**
81
+ * Every collection record, with no principal and no ACL filtering.
82
+ *
83
+ * For system work — a scheduled scan, a storage self-check — which has no user and must
84
+ * not pretend to have one. `list(null)` is the wrong tool for that, and quietly so: it
85
+ * asks what the ANONYMOUS principal may read, which on a drive that is not open to the
86
+ * public is nothing. Maintenance that called it looked like it ran and scanned no
87
+ * collection at all.
88
+ *
89
+ * Never hand the result to a request — these records carry store configuration,
90
+ * credentials included. `list(principal)` is the answer to "what may you see".
91
+ */
92
+ async all() {
93
+ return (await this.kv.list(NS)).map((r) => r.value).filter(Boolean);
94
+ }
95
+
74
96
  async get(id) {
75
97
  const c = await this.kv.get(NS, id);
76
98
  if (!c) throw TroveError.notFound('Collection');
@@ -198,6 +220,55 @@ export class CollectionService {
198
220
  * Create a collection from a store config. Requires the create capability.
199
221
  * The creator is granted admin on the new collection.
200
222
  */
223
+ /**
224
+ * Create a collection if it is absent; leave it alone if it is present.
225
+ *
226
+ * Idempotent, and that is the point: this is the shape a deploy script, a boot hook or
227
+ * a test setup wants — "make sure this exists" — where `create` throws the second time
228
+ * and forces every caller to write the same try/ignore around it.
229
+ *
230
+ * Unlike `create` this takes an explicit `id` and no principal. It is a server-side
231
+ * operation, the way `init()` used to be, so anything exposing it over HTTP does its
232
+ * own authorization first. `store` defaults to the service's configured store, so the
233
+ * common case — one collection on the drive's own storage — is a name and nothing else.
234
+ *
235
+ * @param {object} spec
236
+ * @param {string} spec.id
237
+ * @param {string} [spec.name]
238
+ * @param {object} [spec.store] defaults to the configured defaultStore
239
+ * @param {object} [spec.acl] defaults to open when the service was built with defaultOpen
240
+ * @returns {Promise<{record: object, created: boolean}>}
241
+ */
242
+ async ensure({ id, name, description, store, acl, system = false } = {}) {
243
+ if (!id) throw TroveError.invalid('ensure needs a collection id');
244
+ const existing = await this.kv.get(NS, id);
245
+ if (existing) return { record: existing, created: false };
246
+
247
+ const config = store || this.defaultStore;
248
+ if (!config?.driver) throw TroveError.invalid('A backing store (driver + config) is required');
249
+ try {
250
+ this.storageFactory(config);
251
+ } catch (err) {
252
+ throw TroveError.invalid(`Invalid store config: ${err.message}`, { cause: err });
253
+ }
254
+ const record = {
255
+ id,
256
+ name: (name || id).trim(),
257
+ description: description || '',
258
+ store: config,
259
+ acl: acl || {
260
+ grants: this.defaultOpen
261
+ ? [{ type: 'anyone', capabilities: [...CAPABILITIES] }]
262
+ : [],
263
+ },
264
+ createdAt: Date.now(),
265
+ createdBy: 'system',
266
+ system,
267
+ };
268
+ await this.kv.set(NS, id, record);
269
+ return { record, created: true };
270
+ }
271
+
201
272
  async create({ name, description, store, acl }, principal) {
202
273
  if (!this.canCreate(principal)) throw TroveError.forbidden('You cannot create collections');
203
274
  if (!name?.trim()) throw TroveError.invalid('Collection name is required');
@@ -232,7 +303,6 @@ export class CollectionService {
232
303
 
233
304
  /** Remove the collection record. Caller is responsible for its nodes/objects. */
234
305
  async remove(id, principal) {
235
- if (id === 'default') throw TroveError.invalid('The default collection cannot be deleted');
236
306
  await this.assert(principal, id, 'admin');
237
307
  await this.kv.delete(NS, id);
238
308
  this._storage.delete(id);
@@ -8,7 +8,14 @@ export * from './links.js';
8
8
 
9
9
  export { StorageBackend } from './storage/interface.js';
10
10
  export { MemoryStorage } from './storage/memory.js';
11
- export { FilesystemStorage } from './storage/filesystem.js';
11
+ // FilesystemStorage is NOT exported here on purpose. It imports node:fs at the top level,
12
+ // so re-exporting it from the barrel put node:fs into every bundle that touched core —
13
+ // including Cloudflare Workers, which is the only reason a Workers build needed
14
+ // nodejs_compat. Import it (and `filesystemDriver`) from
15
+ // '@3sln/trove/core/storage/filesystem.js' in an entry point that has a filesystem.
16
+ export { StorageDriverRegistry } from './storage/registry.js';
17
+ export { portableDrivers } from './storage/drivers.js';
18
+ export { diagnoseStorage, corsPolicy, STORAGE_ISSUE_CODES } from './storage/diagnose.js';
12
19
  export { S3Storage } from './storage/s3.js';
13
20
  export { PrefixedStorage } from './storage/prefixed.js';
14
21
 
@@ -16,7 +23,7 @@ export { MetadataStore } from './metadata/interface.js';
16
23
  export { MemoryStore } from './metadata/memory.js';
17
24
  export { SqliteStore } from './metadata/sqlite.js';
18
25
 
19
- export { CollectionService, CAPABILITIES } from './collections/index.js';
26
+ export { CollectionService, CAPABILITIES, expand as expandCapabilities } from './collections/index.js';
20
27
 
21
28
  export { SearchService } from './search/index.js';
22
29
  export { SearchTransformer, ParsingSearchTransformer, WorkersAiSearchTransformer, parseTagFilters, matchTagFilters } from './search/transformer.js';
@@ -45,6 +52,11 @@ export { VectorizeVectorStore } from './search/vectorize.js';
45
52
  // Server-side key/value store (subscriptions, inboxes, profiles).
46
53
  export { KeyValueStore, MemoryKV, SqliteKV } from './kv.js';
47
54
  export { SignedUrls, resolveUrlSecret, URL_PURPOSES } from './signedUrls.js';
55
+ // API keys: capability without identity. CapabilityProvider is the injection point —
56
+ // the counterpart to IdentityProvider, for credentials that say what rather than who.
57
+ export {
58
+ ApiKeyService, ApiKeyGrant, ApiKeyCapabilityProvider, CapabilityProvider, ANY_COLLECTION,
59
+ } from './apiKeys.js';
48
60
  export { SqliteDatabase, SqliteProvider, LocalSqliteProvider, assertSafePluginSql, stripSqlLiterals } from './sqlite.js';
49
61
  // SQLite on Cloudflare D1, so a Worker deployment has a metadata store that exists.
50
62
  export { D1SqliteProvider } from './sqlite-d1.js';
@@ -67,6 +67,9 @@ export class IssueRegistry {
67
67
  * @param {string} [spec.subject] what it is about (a node id); omit for drive-wide
68
68
  * @param {string} spec.title one line, in the user's terms
69
69
  * @param {string} [spec.detail] the underlying error, for someone who wants it
70
+ * @param {string} [spec.remedy] what to DO about it, kept separate from `detail`
71
+ * because it is the part a client should render as something copyable — a remedy is
72
+ * often a policy or a command, and folded into prose it stops being pasteable.
70
73
  * @param {string} [spec.severity] 'error' (default) | 'warning'
71
74
  * @param {string} [spec.collectionId] scopes visibility; null = drive-wide (admin)
72
75
  * @param {{op: string}} [spec.retry] declarative retry, executed by a registered handler
@@ -82,6 +85,7 @@ export class IssueRegistry {
82
85
  subject: spec.subject ?? null,
83
86
  title: spec.title,
84
87
  detail: spec.detail ?? null,
88
+ remedy: spec.remedy ?? null,
85
89
  severity: spec.severity || 'error',
86
90
  collectionId: spec.collectionId ?? null,
87
91
  retry: spec.retry ?? null,