@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
@@ -0,0 +1,234 @@
1
+ // Is the backing store actually usable from a browser?
2
+ //
3
+ // This exists because of a failure that cost a day. A drive was serving its file list
4
+ // fine, and every file opened to a spinner that never resolved. The server was healthy,
5
+ // the storage was healthy, the download endpoint returned 200 in 300ms — and the browser
6
+ // still could not read a single byte, because the R2 bucket had no CORS policy. Nothing
7
+ // in the system knew: CORS is enforced in the browser, so the server's own request to
8
+ // the same bucket succeeded, and the only evidence was a console message in one tab.
9
+ //
10
+ // So the check has to be the browser's check. A CORS preflight is an ordinary OPTIONS
11
+ // request that anyone can send, including us — and a preflight never touches the object,
12
+ // so it works against a key that does not exist and costs nothing. Sending it against a
13
+ // presigned URL and reading the response headers is exactly what a browser does before
14
+ // it will hand a response to a page. If it fails for us it fails for them.
15
+ //
16
+ // The point is not detection for its own sake. Each finding carries the command that
17
+ // fixes it: a diagnostic that says "CORS is misconfigured" to someone who did not know
18
+ // buckets had a CORS policy has told them nothing they can act on.
19
+
20
+ /** A key that need not exist — a preflight is answered without looking one up. */
21
+ const PROBE_KEY = '.trove-cors-probe';
22
+
23
+ /**
24
+ * Every code this module can produce.
25
+ *
26
+ * Exported because the caller that raises these as issues also has to CLEAR the ones a
27
+ * later check no longer reports — that is what makes fixing the bucket make the warning
28
+ * go away. Deriving the clear-set from the same list the checks use means a new finding
29
+ * cannot be added without becoming clearable.
30
+ */
31
+ export const STORAGE_ISSUE_CODES = [
32
+ 'storage-unreachable',
33
+ 'cors-missing',
34
+ 'cors-origin',
35
+ 'cors-headers',
36
+ 'cors-expose',
37
+ 'cors-unknown',
38
+ ];
39
+
40
+ /** Headers the client sends on a download, so a policy that omits them breaks reads. */
41
+ const NEEDED_REQUEST_HEADERS = ['range'];
42
+
43
+ /**
44
+ * Headers the client must be able to READ off the response.
45
+ *
46
+ * Cross-origin responses expose almost nothing by default, and these are not cosmetic:
47
+ * without `content-range` the text viewer cannot tell a truncated file from a whole one,
48
+ * and without `accept-ranges` seeking in audio and video is not offered at all.
49
+ *
50
+ * Checked against a real GET rather than the preflight — see where this is used.
51
+ */
52
+ const NEEDED_EXPOSED_HEADERS = ['content-range', 'content-length'];
53
+
54
+ const csv = (value) => String(value || '').toLowerCase().split(',').map((s) => s.trim()).filter(Boolean);
55
+
56
+ /**
57
+ * Diagnose one collection's backing store.
58
+ *
59
+ * Ordered: an unreachable store short-circuits, because "CORS is not configured" is a
60
+ * misleading thing to say about a bucket whose credentials are wrong.
61
+ *
62
+ * @param {object} deps
63
+ * @param {import('./interface.js').StorageBackend} deps.storage
64
+ * @param {string} [deps.origin] the browser origin to check against — the drive's own
65
+ * public URL. Omitted means the CORS check is skipped rather than guessed at: a policy
66
+ * is allowed to be origin-specific, so checking the wrong origin invents a problem.
67
+ * @param {string} [deps.driver] store driver key, to word the remedy for it
68
+ * @param {typeof fetch} [deps.fetchImpl]
69
+ * @returns {Promise<Array<{code: string, severity: string, title: string, detail: string, remedy?: string}>>}
70
+ */
71
+ export async function diagnoseStorage({ storage, origin = null, driver = null, fetchImpl = null } = {}) {
72
+ const findings = [];
73
+ if (!storage) return findings;
74
+ const doFetch = fetchImpl || (typeof fetch === 'function' ? fetch : null);
75
+
76
+ // --- can we talk to it at all? ---------------------------------------------
77
+ try {
78
+ await storage.list({ limit: 1 });
79
+ } catch (err) {
80
+ findings.push({
81
+ code: 'storage-unreachable',
82
+ severity: 'error',
83
+ title: 'The backing store could not be reached',
84
+ detail: err?.message || String(err),
85
+ remedy:
86
+ 'Check the collection’s store settings: the bucket or directory must exist, and '
87
+ + 'for an S3-compatible store the endpoint, region and credentials must all be for '
88
+ + 'that bucket. A wrong endpoint and a wrong key look identical from here.',
89
+ });
90
+ return findings;
91
+ }
92
+
93
+ // --- does the browser get to read it? --------------------------------------
94
+ // Only when downloads go straight from the browser to the store. A drive that proxies
95
+ // its bytes through the server is same-origin all the way, and CORS never applies —
96
+ // reporting a bucket policy as missing there would be a problem the admin cannot have.
97
+ const caps = storage.capabilities || {};
98
+ if (!caps.presignDownload) return findings;
99
+ if (!origin || !doFetch) return findings;
100
+
101
+ let url;
102
+ try {
103
+ url = await storage.presignGet(PROBE_KEY, { expiresIn: 60 });
104
+ } catch {
105
+ // A store that claims presignDownload and cannot presign is a bug, not a
106
+ // configuration problem, and it will surface far more loudly elsewhere.
107
+ return findings;
108
+ }
109
+
110
+ let res;
111
+ try {
112
+ res = await doFetch(url, {
113
+ method: 'OPTIONS',
114
+ headers: {
115
+ origin,
116
+ 'access-control-request-method': 'GET',
117
+ 'access-control-request-headers': NEEDED_REQUEST_HEADERS.join(','),
118
+ },
119
+ });
120
+ } catch (err) {
121
+ findings.push({
122
+ code: 'cors-unknown',
123
+ severity: 'warning',
124
+ title: 'Could not check whether the store allows browser access',
125
+ detail: `The preflight request to the store failed: ${err?.message || err}`,
126
+ remedy: 'This is usually a network or endpoint problem rather than a CORS one — '
127
+ + 'confirm the store’s endpoint is reachable from the server.',
128
+ });
129
+ return findings;
130
+ }
131
+
132
+ const allowOrigin = res.headers.get('access-control-allow-origin');
133
+ if (!allowOrigin) {
134
+ findings.push({
135
+ code: 'cors-missing',
136
+ severity: 'error',
137
+ title: 'The store does not allow browser access, so files will not open',
138
+ detail:
139
+ `A CORS preflight from ${origin} was answered without an `
140
+ + `Access-Control-Allow-Origin header (HTTP ${res.status}). Browsers will refuse `
141
+ + 'every download, including previews and thumbnails, while the file list and '
142
+ + 'search keep working normally.',
143
+ remedy: corsRemedy(origin, driver),
144
+ });
145
+ return findings;
146
+ }
147
+
148
+ if (allowOrigin !== '*' && allowOrigin.toLowerCase() !== origin.toLowerCase()) {
149
+ findings.push({
150
+ code: 'cors-origin',
151
+ severity: 'error',
152
+ title: 'The store allows a different origin than this drive',
153
+ detail: `It allows "${allowOrigin}", but this drive is served from "${origin}". `
154
+ + 'A policy naming the wrong origin is refused exactly like no policy at all.',
155
+ remedy: corsRemedy(origin, driver),
156
+ });
157
+ return findings;
158
+ }
159
+
160
+ // Allowed, but possibly not for everything we send or need back. These are warnings:
161
+ // opening a small file will work, so the drive is usable — it is seeking in a video
162
+ // and reading the head of a large file that break.
163
+ const allowHeaders = csv(res.headers.get('access-control-allow-headers'));
164
+ const missingRequest = allowHeaders.includes('*')
165
+ ? []
166
+ : NEEDED_REQUEST_HEADERS.filter((h) => !allowHeaders.includes(h));
167
+ if (missingRequest.length) {
168
+ findings.push({
169
+ code: 'cors-headers',
170
+ severity: 'warning',
171
+ title: 'The store’s CORS policy blocks ranged reads',
172
+ detail: `It does not allow the ${missingRequest.join(', ')} request header, so seeking `
173
+ + 'in audio and video, and previewing the start of a large file, will fail. Whole-file '
174
+ + 'downloads are unaffected.',
175
+ remedy: corsRemedy(origin, driver),
176
+ });
177
+ }
178
+
179
+ // Exposed headers are read off the ACTUAL response, not the preflight — that is where
180
+ // the spec puts them and where the browser looks. Some stores echo them on a preflight
181
+ // and some do not, so checking the OPTIONS response reports a correctly configured
182
+ // bucket as broken. A GET for a key that does not exist is answered 404 WITH the CORS
183
+ // headers when a policy matches, which is all this needs.
184
+ let actual;
185
+ try {
186
+ actual = await doFetch(url, { method: 'GET', headers: { origin } });
187
+ } catch {
188
+ // The preflight already passed, so the policy is in place; failing to complete this
189
+ // second request says nothing more about it. Warning here would be guessing.
190
+ return findings;
191
+ }
192
+
193
+ const exposed = csv(actual.headers.get('access-control-expose-headers'));
194
+ const missingExposed = exposed.includes('*')
195
+ ? []
196
+ : NEEDED_EXPOSED_HEADERS.filter((h) => !exposed.includes(h));
197
+ if (missingExposed.length) {
198
+ findings.push({
199
+ code: 'cors-expose',
200
+ severity: 'warning',
201
+ title: 'The store hides response headers the viewer needs',
202
+ detail: `${missingExposed.join(', ')} are not in exposeHeaders, so the browser cannot read `
203
+ + 'them. Trove cannot then tell a truncated preview from a complete file, and will '
204
+ + 'not offer seeking.',
205
+ remedy: corsRemedy(origin, driver),
206
+ });
207
+ }
208
+
209
+ return findings;
210
+ }
211
+
212
+ /** The policy this drive needs, as something that can be pasted. */
213
+ export function corsPolicy(origin) {
214
+ return [{
215
+ AllowedOrigins: [origin],
216
+ AllowedMethods: ['GET', 'PUT', 'HEAD'],
217
+ AllowedHeaders: ['content-type', 'range'],
218
+ ExposeHeaders: ['ETag', 'Content-Length', 'Content-Type', 'Content-Range', 'Accept-Ranges'],
219
+ MaxAgeSeconds: 3600,
220
+ }];
221
+ }
222
+
223
+ function corsRemedy(origin, driver) {
224
+ const json = JSON.stringify(corsPolicy(origin), null, 2);
225
+ // R2 is the common case for a Workers deployment and its tooling is not the AWS CLI,
226
+ // so name it explicitly rather than leaving the admin to translate.
227
+ const r2 = driver === 's3'
228
+ ? '\n\nOn Cloudflare R2, save the JSON above as cors.json and run:\n'
229
+ + ' wrangler r2 bucket cors put <bucket> --file cors.json\n\n'
230
+ + 'On AWS S3:\n'
231
+ + ' aws s3api put-bucket-cors --bucket <bucket> --cors-configuration file://cors.json'
232
+ : '';
233
+ return `Allow this origin on the bucket. The policy Trove needs:\n\n${json}${r2}`;
234
+ }
@@ -0,0 +1,83 @@
1
+ // The drivers this package ships, as registrable descriptors.
2
+ //
3
+ // Split by what a runtime can actually run, and that split is the point:
4
+ //
5
+ // `portableDrivers()` — memory and S3. Both are fetch-and-arithmetic, so they work
6
+ // identically on Node, Bun, Deno and Cloudflare Workers.
7
+ //
8
+ // `filesystemDriver()` — lives in filesystem.js and is imported from there, NOT from
9
+ // this module or the package barrel. That import is what pulls in node:fs, so a Workers
10
+ // entry point that never mentions it never gets it: the driver is absent from the form
11
+ // AND absent from the bundle. Re-exporting it here would defeat both.
12
+ //
13
+ // A deployment's driver set is therefore decided by its entry point, which is the only
14
+ // place that knows what it is running on.
15
+
16
+ import { MemoryStorage } from './memory.js';
17
+ import { S3Storage } from './s3.js';
18
+
19
+ /** Anything with a `fetch` can run these. */
20
+ export function portableDrivers() {
21
+ return [
22
+ {
23
+ key: 's3',
24
+ label: 'S3-compatible',
25
+ description: 'AWS S3, Cloudflare R2, MinIO, Backblaze B2 — anything speaking the S3 API.',
26
+ fields: [
27
+ { name: 'bucket', label: 'Bucket', required: true, placeholder: 'my-bucket' },
28
+ {
29
+ name: 'region',
30
+ label: 'Region',
31
+ placeholder: 'auto',
32
+ help: 'R2 uses "auto". AWS wants the bucket’s real region.',
33
+ },
34
+ {
35
+ name: 'endpoint',
36
+ label: 'Endpoint',
37
+ placeholder: 'https://<account>.r2.cloudflarestorage.com',
38
+ help: 'Leave blank for AWS S3.',
39
+ },
40
+ { name: 'prefix', label: 'Prefix', help: 'Share one bucket between collections.' },
41
+ // Marked secret so they are never read back out of a collection record: the
42
+ // record lives in the KV store and is otherwise safe to show an admin.
43
+ { name: 'accessKeyId', label: 'Access key id', required: true, secret: true },
44
+ { name: 'secretAccessKey', label: 'Secret access key', type: 'password', required: true, secret: true },
45
+ {
46
+ name: 'forcePathStyle',
47
+ label: 'Path-style addressing',
48
+ type: 'boolean',
49
+ help: 'MinIO and most self-hosted endpoints need this.',
50
+ },
51
+ ],
52
+ // Two config shapes reach here and both have to work.
53
+ //
54
+ // Flat — `{ driver: 's3', bucket: … }` — is what `fields` above describes and what
55
+ // the collection form posts. Nested under `s3` is what `configFromEnv` produces and
56
+ // what every collection record written before this driver existed holds, because the
57
+ // switch this replaced read `cfg.s3` and nothing else.
58
+ //
59
+ // Normalising rather than only handling it in `create` is the point: validation runs
60
+ // against the normalised shape, so a nested config is no longer refused for a missing
61
+ // top-level `bucket` that was about to be spread in anyway. That refusal broke every
62
+ // environment-configured S3 deployment at startup, which is as loud as a bug gets and
63
+ // still took a local run to see, because no test used the env shape.
64
+ normalize: (cfg) => ({ ...cfg, ...(cfg.s3 || {}) }),
65
+ create: (cfg) => new S3Storage({
66
+ bucket: cfg.bucket,
67
+ region: cfg.region || 'auto',
68
+ endpoint: cfg.endpoint || undefined,
69
+ accessKeyId: cfg.accessKeyId,
70
+ secretAccessKey: cfg.secretAccessKey,
71
+ sessionToken: cfg.sessionToken,
72
+ forcePathStyle: cfg.forcePathStyle === true || cfg.forcePathStyle === 'true',
73
+ }),
74
+ },
75
+ {
76
+ key: 'memory',
77
+ label: 'Memory',
78
+ description: 'Nothing is kept. For demos and tests — a restart empties it.',
79
+ fields: [],
80
+ create: () => new MemoryStorage(),
81
+ },
82
+ ];
83
+ }
@@ -281,3 +281,25 @@ export class FilesystemStorage extends StorageBackend {
281
281
  function etagOfStat(stat) {
282
282
  return `"${stat.size.toString(16)}-${Math.floor(stat.mtimeMs).toString(16)}"`;
283
283
  }
284
+
285
+ /**
286
+ * This driver as a registrable descriptor.
287
+ *
288
+ * Deliberately exported from HERE and not from the package barrel or drivers.js. Importing
289
+ * it is what pulls node:fs into a bundle, so a Workers entry point that never mentions it
290
+ * gets neither the form option nor the module — which is also why `core/index.js` no
291
+ * longer re-exports FilesystemStorage. A Workers build previously needed nodejs_compat
292
+ * purely because the barrel dragged this file in whether or not it could ever be used.
293
+ */
294
+ export function filesystemDriver() {
295
+ return {
296
+ key: 'filesystem',
297
+ label: 'Filesystem / NAS',
298
+ description: 'A directory on this machine, or a mounted network share.',
299
+ fields: [
300
+ { name: 'root', label: 'Root directory', required: true, placeholder: './data/team' },
301
+ { name: 'prefix', label: 'Prefix', help: 'Share one directory between collections.' },
302
+ ],
303
+ create: (cfg) => new FilesystemStorage({ root: cfg.root }),
304
+ };
305
+ }
@@ -0,0 +1,162 @@
1
+ // Which kinds of backing store this deployment can have.
2
+ //
3
+ // A collection IS a store config — `{ driver: 's3', bucket: … }` — and something has to
4
+ // turn that into a StorageBackend. That something used to be a three-case `switch` in the
5
+ // server, which had two problems beyond being closed:
6
+ //
7
+ // Its `default:` arm returned MemoryStorage. So `driver: 'flesystem'` — a typo — built a
8
+ // store that accepted writes and lost them on restart. A misconfiguration that looks
9
+ // like it worked is worse than one that refuses to start.
10
+ //
11
+ // The UI could not know what the server supported, so it hardcoded its own list and
12
+ // offered Filesystem / NAS on Cloudflare Workers, where there is no filesystem to
13
+ // point at. A form offering a choice the runtime cannot honour is a form that produces
14
+ // a broken collection.
15
+ //
16
+ // A registry fixes both by making the set of drivers DATA. Each one declares a globally
17
+ // unique key, a label, and the fields it needs — so the server answers "what can I be
18
+ // configured with" and the client renders that answer instead of guessing. Availability
19
+ // is not a flag on a driver, it is whether the driver was registered at all: an entry
20
+ // point registers what its runtime can actually run, so Filesystem is absent on Workers
21
+ // rather than present-and-refused, and the module is not in the bundle either.
22
+ //
23
+ // The config lives in the collection record; the implementation does not. A driver can be
24
+ // written and registered entirely outside this package.
25
+
26
+ import { TroveError } from '../errors.js';
27
+ import { StorageBackend } from './interface.js';
28
+
29
+ /**
30
+ * One field a driver needs in order to be configured.
31
+ *
32
+ * This is what the UI renders and what a deploy script validates against, so it says how
33
+ * to ASK rather than how to store: `secret` is the interesting one, because a store config
34
+ * lives in the KV store and a field marked secret should never be echoed back to a client
35
+ * once written.
36
+ *
37
+ * @typedef {object} DriverField
38
+ * @property {string} name key in the store config
39
+ * @property {string} label what to call it in a form
40
+ * @property {'text'|'password'|'number'|'boolean'} [type]
41
+ * @property {boolean} [required]
42
+ * @property {boolean} [secret] never returned once stored
43
+ * @property {string} [placeholder]
44
+ * @property {string} [help] one line under the field
45
+ */
46
+
47
+ export class StorageDriverRegistry {
48
+ constructor(drivers = []) {
49
+ this._drivers = new Map();
50
+ for (const d of drivers) this.register(d);
51
+ }
52
+
53
+ /**
54
+ * @param {object} driver
55
+ * @param {string} driver.key globally unique, and what goes in `store.driver`
56
+ * @param {string} driver.label for a form
57
+ * @param {string} [driver.description] one line about what it is
58
+ * @param {DriverField[]} [driver.fields]
59
+ * @param {(config: object) => object} [driver.normalize] accept an older or alternative
60
+ * config shape, returning the one `fields` describes. Runs before validation, so the
61
+ * shape checked is the shape built from.
62
+ * @param {(config: object) => StorageBackend} driver.create
63
+ */
64
+ register(driver) {
65
+ const key = String(driver?.key ?? '').trim();
66
+ if (!key) throw TroveError.invalid('A storage driver needs a key');
67
+ if (typeof driver.create !== 'function') {
68
+ throw TroveError.invalid(`Storage driver "${key}" needs a create(config) function`);
69
+ }
70
+ // Refused rather than overwritten. Two drivers claiming one key means one of them is
71
+ // silently not the one being used, and which one depends on registration order.
72
+ if (this._drivers.has(key)) {
73
+ throw TroveError.invalid(`A storage driver is already registered as "${key}"`);
74
+ }
75
+ this._drivers.set(key, {
76
+ key,
77
+ label: driver.label || key,
78
+ description: driver.description || '',
79
+ fields: (driver.fields || []).map((f) => ({
80
+ name: f.name,
81
+ label: f.label || f.name,
82
+ type: f.type || 'text',
83
+ required: !!f.required,
84
+ secret: !!f.secret,
85
+ placeholder: f.placeholder || '',
86
+ help: f.help || '',
87
+ })),
88
+ normalize: driver.normalize || null,
89
+ create: driver.create,
90
+ });
91
+ return this;
92
+ }
93
+
94
+ has(key) {
95
+ return this._drivers.has(key);
96
+ }
97
+
98
+ keys() {
99
+ return [...this._drivers.keys()];
100
+ }
101
+
102
+ /**
103
+ * One registered driver, `create` included — unlike `describe()`, which deliberately
104
+ * strips it because it answers a client.
105
+ *
106
+ * For copying a driver into another registry, which is how a deployment narrows the set
107
+ * it offers: a registry has no `unregister`, so narrowing rebuilds from what survived
108
+ * rather than removing from what did not. Keeping removal out of this class is the point
109
+ * — a driver disappearing from a live registry is a store that stops being buildable
110
+ * while collections still reference it.
111
+ */
112
+ driver(key) {
113
+ return this._drivers.get(key);
114
+ }
115
+
116
+ /**
117
+ * What a client needs to render a form.
118
+ *
119
+ * Neither `create` nor `normalize`: both are behaviour, not data. JSON.stringify would
120
+ * drop them anyway, which is exactly why they are removed here instead — a describe()
121
+ * whose result is only serialisable by accident is one that leaks the next function
122
+ * somebody adds into every in-process consumer.
123
+ */
124
+ describe() {
125
+ return [...this._drivers.values()].map(({ create, normalize, ...rest }) => rest);
126
+ }
127
+
128
+ /**
129
+ * Build the backend a store config names.
130
+ *
131
+ * An unknown driver throws, and says what IS available. This is the arm that used to
132
+ * return an in-memory store.
133
+ *
134
+ * `normalize` runs FIRST, so a driver that accepts more than one config shape validates
135
+ * the shape it will actually build from. Without it, required-field checks are performed
136
+ * against a config the driver was about to rewrite — which is precisely how S3 broke:
137
+ * `configFromEnv` nests its settings under `s3`, `create` spread that back out, and the
138
+ * check in between looked for a top-level `bucket` that was never going to be there and
139
+ * refused every environment-configured S3 deployment at startup.
140
+ */
141
+ build(config) {
142
+ const key = config?.driver;
143
+ if (!key) throw TroveError.invalid('A store config needs a driver');
144
+ const driver = this._drivers.get(key);
145
+ if (!driver) {
146
+ throw TroveError.invalid(
147
+ `Unknown storage driver "${key}" — this deployment has: ${this.keys().join(', ') || 'none'}`,
148
+ );
149
+ }
150
+ const cfg = driver.normalize ? driver.normalize(config) : config;
151
+ for (const f of driver.fields) {
152
+ if (f.required && (cfg[f.name] == null || cfg[f.name] === '')) {
153
+ throw TroveError.invalid(`Storage driver "${key}" requires "${f.name}"`);
154
+ }
155
+ }
156
+ const backend = driver.create(cfg);
157
+ if (!(backend instanceof StorageBackend)) {
158
+ throw TroveError.invalid(`Storage driver "${key}" did not return a StorageBackend`);
159
+ }
160
+ return backend;
161
+ }
162
+ }
@@ -10,6 +10,11 @@
10
10
 
11
11
  import { readFileSync } from 'node:fs';
12
12
  import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
13
+ // This runtime HAS a filesystem, so it registers the filesystem driver. Imported from
14
+ // storage/filesystem.js rather than the package barrel: that import is what pulls in
15
+ // node:fs, and the Workers adapter deliberately never makes it — so there, Filesystem is
16
+ // absent from the collection form and absent from the bundle.
17
+ import { filesystemDriver } from '@3sln/trove/core/storage/filesystem.js';
13
18
  import { findWebDist } from './webDist.js';
14
19
  import { createStaticAssets } from './staticAssets.js';
15
20
 
@@ -47,6 +52,7 @@ const envConfig = configFromEnv();
47
52
  warnOnOpenAccess(envConfig);
48
53
  const { handle, close } = await createServer({
49
54
  ...envConfig,
55
+ storageDrivers: [filesystemDriver()],
50
56
  assets: hasWeb ? staticAssets : undefined,
51
57
  });
52
58
 
@@ -12,6 +12,11 @@ import fs, { readFileSync } from 'node:fs';
12
12
  import fsp from 'node:fs/promises';
13
13
  import { Readable } from 'node:stream';
14
14
  import { createServer, configFromEnv, warnOnOpenAccess } from '../index.js';
15
+ // This runtime HAS a filesystem, so it registers the filesystem driver. Imported from
16
+ // storage/filesystem.js rather than the package barrel: that import is what pulls in
17
+ // node:fs, and the Workers adapter deliberately never makes it — so there, Filesystem is
18
+ // absent from the collection form and absent from the bundle.
19
+ import { filesystemDriver } from '@3sln/trove/core/storage/filesystem.js';
15
20
  import { findWebDist } from './webDist.js';
16
21
  import { createStaticAssets } from './staticAssets.js';
17
22
 
@@ -82,6 +87,7 @@ const envConfig = configFromEnv();
82
87
  warnOnOpenAccess(envConfig);
83
88
  const { handle, close } = await createServer({
84
89
  ...envConfig,
90
+ storageDrivers: [filesystemDriver()],
85
91
  assets: hasWeb ? staticAssets : undefined,
86
92
  });
87
93
 
@@ -49,7 +49,7 @@ export function createDriveEngine(config = {}, lifecycleState = { closing: false
49
49
  export const BACKBONE = [
50
50
  'storage', 'sqlite', 'metadata', 'kv', 'tasks', 'issues', 'notifications',
51
51
  'sidecar', 'collections', 'identity', 'auth', 'search', 'vfs', 'plugins',
52
- 'lifecycle',
52
+ 'apiKeys', 'capabilities', 'lifecycle',
53
53
  ];
54
54
 
55
55
  /** The shape `beginScan` has always returned, so no caller has to change. */
@@ -76,6 +76,27 @@ const intersect = (held, asked) => new Set([...asked].filter((c) => held.has(c))
76
76
  * @param {object} node already resolved by `stat`
77
77
  * @param {string|symbol} granted the capability that was asserted
78
78
  */
79
+ /**
80
+ * Authorize from an API key grant rather than from a principal.
81
+ *
82
+ * The same shape as the signature path below it, and for the same reason: a grant is
83
+ * authority that arrived with the request, so there is nobody to look up in an ACL.
84
+ *
85
+ * REFUSED, not narrowed. If the route asked for `write` and the key holds `read`, this
86
+ * throws — because handing back a read handle to a route that asked for write means the
87
+ * route believes it has write and acts accordingly. The signature path learned this the
88
+ * hard way (see its comment); the rule is the same here.
89
+ */
90
+ function grantedCapabilities(grant, collectionId, capability) {
91
+ const held = grant.capabilitiesFor(collectionId);
92
+ if (!held.has(capability)) {
93
+ throw TroveError.forbidden(
94
+ `This API key does not hold "${capability}" on this collection`,
95
+ );
96
+ }
97
+ return intersect(held, requested(capability));
98
+ }
99
+
79
100
  function nodeHandle(vfs, sidecar, node, held) {
80
101
  const permits = (capability) => held.has(capability);
81
102
  const granted = held === ALL ? 'system' : [...held].sort().join(',');
@@ -204,7 +225,7 @@ export class NodeAccessProvider extends Provider {
204
225
  // cannot see — restoring and permanently deleting are the only operations on items
205
226
  // that are no longer part of the drive. It widens WHAT IS VISIBLE, never what is
206
227
  // permitted: the capability is asserted exactly the same way afterwards.
207
- async obtain({ principal, id, collectionId, trashed = false, capability = 'read', signature = null } = {}) {
228
+ async obtain({ principal, grant = null, id, collectionId, trashed = false, capability = 'read', signature = null } = {}) {
208
229
  if (!id) throw TroveError.invalid('A node id is required');
209
230
  assertCapability(capability);
210
231
  const vfs = await this.vfs.obtain();
@@ -238,6 +259,15 @@ export class NodeAccessProvider extends Provider {
238
259
  const config = await this.config.obtain();
239
260
  if (!enforcing(config)) return nodeHandle(vfs, sidecar, node, requested(capability));
240
261
 
262
+ // A key IS the grant, scoped to the node's own collection. Checked before the ACL
263
+ // and never alongside it: a request bearing a key is the key's request, and falling
264
+ // back to whatever principal happens to be attached would let a weak key borrow a
265
+ // strong session — the confused deputy, arrived at by being helpful.
266
+ if (grant) {
267
+ return nodeHandle(vfs, sidecar, node,
268
+ grantedCapabilities(grant, node.collectionId, capability));
269
+ }
270
+
241
271
  // `assert` throws when the capability is not held. Nothing here decides from
242
272
  // presence: if the service is missing this raises, it does not allow.
243
273
  const collections = await this.collections.obtain();
@@ -269,12 +299,18 @@ export class CollectionAccessProvider extends Provider {
269
299
  this.config = config;
270
300
  }
271
301
 
272
- async obtain({ principal, id = 'default', capability = 'read' } = {}) {
302
+ async obtain({ principal, grant = null, id = 'default', capability = 'read' } = {}) {
273
303
  assertCapability(capability);
274
304
  const vfs = await this.vfs.obtain();
275
305
  const config = await this.config.obtain();
276
306
  if (!enforcing(config)) return collectionHandle(vfs, id, requested(capability));
277
307
 
308
+ // Same rule as the node path: a key's grant decides, alone, and refuses rather than
309
+ // narrowing. This is the check that keeps a key scoped to `photos` out of `invoices`.
310
+ if (grant) {
311
+ return collectionHandle(vfs, id, grantedCapabilities(grant, id, capability));
312
+ }
313
+
278
314
  const collections = await this.collections.obtain();
279
315
  const collection = await collections.assert(principal, id, capability);
280
316
  return collectionHandle(vfs, id,
@@ -306,7 +342,7 @@ export class UploadAccessProvider extends Provider {
306
342
  this.config = config;
307
343
  }
308
344
 
309
- async obtain({ principal, id } = {}) {
345
+ async obtain({ principal, grant = null, id } = {}) {
310
346
  if (!id) throw TroveError.invalid('An upload id is required');
311
347
  const vfs = await this.vfs.obtain();
312
348
  // Resolving first is what makes the check possible at all: only the session knows
@@ -314,8 +350,14 @@ export class UploadAccessProvider extends Provider {
314
350
  const session = await vfs.uploadStatus(id);
315
351
  const config = await this.config.obtain();
316
352
  if (enforcing(config)) {
317
- const collections = await this.collections.obtain();
318
- await collections.assert(principal, session.collectionId, 'write');
353
+ // Re-checked on EVERY request of the upload, keys included — the point of this
354
+ // provider. A key revoked between `POST /api/uploads` and `complete` stops the
355
+ // upload, which it would not if the grant were only checked when it began.
356
+ if (grant) grantedCapabilities(grant, session.collectionId, 'write');
357
+ else {
358
+ const collections = await this.collections.obtain();
359
+ await collections.assert(principal, session.collectionId, 'write');
360
+ }
319
361
  }
320
362
  return {
321
363
  id,