@3sln/trove 0.0.4 → 0.0.7
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -0
- package/package.json +1 -1
- package/packages/core/src/apiKeys.js +326 -0
- package/packages/core/src/collections/index.js +83 -13
- package/packages/core/src/index.js +14 -2
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +83 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +162 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/engine/index.js +1 -1
- package/packages/server/src/engine/providers/access.js +47 -5
- package/packages/server/src/engine/providers/core.js +105 -11
- package/packages/server/src/index.js +123 -11
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +135 -32
- package/packages/server/src/scope.js +2 -2
- package/packages/web/dist/assets/main-f0f2tfhp.js +356 -0
- package/packages/web/dist/assets/{main-4cxs7prw.js.map → main-f0f2tfhp.js.map} +17 -16
- package/packages/web/dist/assets/{styles-kcx1x337.css → styles-d3cyysgp.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/dist/sw.js +58 -9
- package/packages/web/src/bl/actions.js +112 -13
- package/packages/web/src/bl/activity.js +32 -0
- package/packages/web/src/bl/commands.js +41 -2
- package/packages/web/src/bl/index.js +9 -4
- package/packages/web/src/bl/services.js +78 -1
- package/packages/web/src/platform/api.js +57 -14
- package/packages/web/src/platform/pluginRpc.js +7 -4
- package/packages/web/src/styles.css +137 -0
- package/packages/web/src/ui/components/activityPanel.js +28 -1
- package/packages/web/src/ui/components/collectionGate.js +81 -0
- package/packages/web/src/ui/components/overlays.js +64 -34
- package/packages/web/src/ui/components/phoneChrome.js +2 -2
- package/packages/web/src/ui/components/settingsView.js +197 -1
- package/packages/web/src/ui/components/statusBar.js +19 -2
- package/packages/web/src/ui/compositions/workbench.js +9 -2
- package/packages/web/dist/assets/main-4cxs7prw.js +0 -356
|
@@ -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
|
-
|
|
318
|
-
|
|
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,
|