@3sln/trove 0.0.3 → 0.0.5
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/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 +18 -4
- package/packages/core/src/issues.js +4 -0
- package/packages/core/src/notifications/channel.js +68 -0
- package/packages/core/src/notifications/index.js +72 -43
- package/packages/core/src/notifications/webpush.js +110 -0
- package/packages/core/src/sqlite-d1.js +14 -1
- package/packages/core/src/sqlite-driver.js +73 -1
- package/packages/core/src/storage/diagnose.js +234 -0
- package/packages/core/src/storage/drivers.js +74 -0
- package/packages/core/src/storage/filesystem.js +22 -0
- package/packages/core/src/storage/registry.js +129 -0
- package/packages/server/src/adapters/bun.js +6 -0
- package/packages/server/src/adapters/node.js +6 -0
- package/packages/server/src/adapters/worker-tasks.js +14 -4
- package/packages/server/src/adapters/worker.js +7 -1
- 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 +109 -16
- package/packages/server/src/index.js +137 -12
- package/packages/server/src/mcp/tools.js +40 -8
- package/packages/server/src/router.js +1 -1
- package/packages/server/src/routes.js +156 -46
- 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,74 @@
|
|
|
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
|
+
// S3Storage takes the config flat; the old switch passed `cfg.s3`, which meant a
|
|
53
|
+
// collection record had to nest its own settings one level deeper than every
|
|
54
|
+
// other driver for no reason a user could see.
|
|
55
|
+
create: (cfg) => new S3Storage({
|
|
56
|
+
bucket: cfg.bucket,
|
|
57
|
+
region: cfg.region || 'auto',
|
|
58
|
+
endpoint: cfg.endpoint || undefined,
|
|
59
|
+
accessKeyId: cfg.accessKeyId,
|
|
60
|
+
secretAccessKey: cfg.secretAccessKey,
|
|
61
|
+
sessionToken: cfg.sessionToken,
|
|
62
|
+
forcePathStyle: cfg.forcePathStyle === true || cfg.forcePathStyle === 'true',
|
|
63
|
+
...(cfg.s3 || {}),
|
|
64
|
+
}),
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
key: 'memory',
|
|
68
|
+
label: 'Memory',
|
|
69
|
+
description: 'Nothing is kept. For demos and tests — a restart empties it.',
|
|
70
|
+
fields: [],
|
|
71
|
+
create: () => new MemoryStorage(),
|
|
72
|
+
},
|
|
73
|
+
];
|
|
74
|
+
}
|
|
@@ -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,129 @@
|
|
|
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) => StorageBackend} driver.create
|
|
60
|
+
*/
|
|
61
|
+
register(driver) {
|
|
62
|
+
const key = String(driver?.key ?? '').trim();
|
|
63
|
+
if (!key) throw TroveError.invalid('A storage driver needs a key');
|
|
64
|
+
if (typeof driver.create !== 'function') {
|
|
65
|
+
throw TroveError.invalid(`Storage driver "${key}" needs a create(config) function`);
|
|
66
|
+
}
|
|
67
|
+
// Refused rather than overwritten. Two drivers claiming one key means one of them is
|
|
68
|
+
// silently not the one being used, and which one depends on registration order.
|
|
69
|
+
if (this._drivers.has(key)) {
|
|
70
|
+
throw TroveError.invalid(`A storage driver is already registered as "${key}"`);
|
|
71
|
+
}
|
|
72
|
+
this._drivers.set(key, {
|
|
73
|
+
key,
|
|
74
|
+
label: driver.label || key,
|
|
75
|
+
description: driver.description || '',
|
|
76
|
+
fields: (driver.fields || []).map((f) => ({
|
|
77
|
+
name: f.name,
|
|
78
|
+
label: f.label || f.name,
|
|
79
|
+
type: f.type || 'text',
|
|
80
|
+
required: !!f.required,
|
|
81
|
+
secret: !!f.secret,
|
|
82
|
+
placeholder: f.placeholder || '',
|
|
83
|
+
help: f.help || '',
|
|
84
|
+
})),
|
|
85
|
+
create: driver.create,
|
|
86
|
+
});
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
has(key) {
|
|
91
|
+
return this._drivers.has(key);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
keys() {
|
|
95
|
+
return [...this._drivers.keys()];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What a client needs to render a form. No `create`, because that is not data. */
|
|
99
|
+
describe() {
|
|
100
|
+
return [...this._drivers.values()].map(({ create, ...rest }) => rest);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Build the backend a store config names.
|
|
105
|
+
*
|
|
106
|
+
* An unknown driver throws, and says what IS available. This is the arm that used to
|
|
107
|
+
* return an in-memory store.
|
|
108
|
+
*/
|
|
109
|
+
build(config) {
|
|
110
|
+
const key = config?.driver;
|
|
111
|
+
if (!key) throw TroveError.invalid('A store config needs a driver');
|
|
112
|
+
const driver = this._drivers.get(key);
|
|
113
|
+
if (!driver) {
|
|
114
|
+
throw TroveError.invalid(
|
|
115
|
+
`Unknown storage driver "${key}" — this deployment has: ${this.keys().join(', ') || 'none'}`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
for (const f of driver.fields) {
|
|
119
|
+
if (f.required && (config[f.name] == null || config[f.name] === '')) {
|
|
120
|
+
throw TroveError.invalid(`Storage driver "${key}" requires "${f.name}"`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
const backend = driver.create(config);
|
|
124
|
+
if (!(backend instanceof StorageBackend)) {
|
|
125
|
+
throw TroveError.invalid(`Storage driver "${key}" did not return a StorageBackend`);
|
|
126
|
+
}
|
|
127
|
+
return backend;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
@@ -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
|
|
|
@@ -149,12 +149,14 @@ export function createTaskHost(getServer) {
|
|
|
149
149
|
* gets the truth without knowing where it lives.
|
|
150
150
|
*/
|
|
151
151
|
export class RemoteTasks extends TaskRegistry {
|
|
152
|
+
/** @param {() => object} stub resolves the Durable Object stub — see remoteBackground
|
|
153
|
+
* for why this is a function and not the stub itself. */
|
|
152
154
|
constructor(stub) {
|
|
153
155
|
super();
|
|
154
156
|
this.stub = stub;
|
|
155
157
|
}
|
|
156
158
|
async #rpc(path, payload) {
|
|
157
|
-
const res = await this.stub.fetch(`https://trove.tasks${path}`, {
|
|
159
|
+
const res = await this.stub().fetch(`https://trove.tasks${path}`, {
|
|
158
160
|
method: 'POST',
|
|
159
161
|
headers: { 'content-type': 'application/json' },
|
|
160
162
|
body: JSON.stringify(payload || {}),
|
|
@@ -181,8 +183,16 @@ export function remoteBackground(namespace) {
|
|
|
181
183
|
// One instance for the whole drive, by name. Tasks are few and long, so there is no
|
|
182
184
|
// throughput argument for sharding — and one instance is what makes GET /api/tasks a
|
|
183
185
|
// complete answer rather than a per-shard sample.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
+
//
|
|
187
|
+
// Resolved per call, never held. A stub belongs to the I/O context of the request that
|
|
188
|
+
// created it, and the server that owns this one is cached at module scope for the life
|
|
189
|
+
// of the isolate — so the stub outlived its request and every later use threw. The
|
|
190
|
+
// first request worked and the second did not, which reads like a fluke and is not:
|
|
191
|
+
// GET /api/tasks was broken for the entire life of every isolate after its first
|
|
192
|
+
// request. `idFromName` is a pure hash, so re-deriving it costs nothing and always
|
|
193
|
+
// names the same object.
|
|
194
|
+
const stub = () => namespace.get(namespace.idFromName('trove-tasks'));
|
|
195
|
+
const begin = (payload) => stub()
|
|
186
196
|
.fetch('https://trove.tasks/begin', {
|
|
187
197
|
method: 'POST',
|
|
188
198
|
headers: { 'content-type': 'application/json' },
|
|
@@ -195,7 +205,7 @@ export function remoteBackground(namespace) {
|
|
|
195
205
|
beginScan: (collectionId, { reason } = {}) => begin({ kind: 'scan', collectionId, reason }),
|
|
196
206
|
beginReindex: ({ reason } = {}) => begin({ kind: 'index', reason }),
|
|
197
207
|
},
|
|
198
|
-
maintain: (budgetMs) => stub
|
|
208
|
+
maintain: (budgetMs) => stub()
|
|
199
209
|
.fetch('https://trove.tasks/maintain', {
|
|
200
210
|
method: 'POST',
|
|
201
211
|
headers: { 'content-type': 'application/json' },
|
|
@@ -53,7 +53,13 @@ async function getServer(env, buildVfs, { delegate = true } = {}) {
|
|
|
53
53
|
config.metadata = { driver: 'sqlite' };
|
|
54
54
|
}
|
|
55
55
|
// Cloudflare Vectorize binding → first-class vector store (no REST creds needed).
|
|
56
|
-
|
|
56
|
+
//
|
|
57
|
+
// An explicit TROVE_VECTOR wins. The binding used to be taken unconditionally, which
|
|
58
|
+
// left no way to opt out: `wrangler dev` against a real Vectorize index is the only
|
|
59
|
+
// shape a local run could have, and setting TROVE_VECTOR=memory in .dev.vars did
|
|
60
|
+
// nothing. Naming a store and being given a different one is the kind of override that
|
|
61
|
+
// should never be silent.
|
|
62
|
+
if (env.VECTORIZE && (!env.TROVE_VECTOR || env.TROVE_VECTOR === 'vectorize')) {
|
|
57
63
|
config.vectorStore = { driver: 'vectorize', binding: env.VECTORIZE };
|
|
58
64
|
}
|
|
59
65
|
// Cloudflare Workers AI binding → LLM-assisted search transformer (human text →
|
|
@@ -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,
|
|
@@ -21,7 +21,8 @@
|
|
|
21
21
|
// means the same thing it always did.
|
|
22
22
|
|
|
23
23
|
import {
|
|
24
|
-
StorageBackend, MemoryStorage,
|
|
24
|
+
StorageBackend, MemoryStorage, S3Storage,
|
|
25
|
+
StorageDriverRegistry, portableDrivers,
|
|
25
26
|
MetadataStore, MemoryStore, SqliteStore,
|
|
26
27
|
SearchService, EmbeddingProvider, LocalHashEmbedding, HttpEmbedding,
|
|
27
28
|
SearchTransformer, ParsingSearchTransformer, WorkersAiSearchTransformer,
|
|
@@ -32,7 +33,8 @@ import {
|
|
|
32
33
|
cloudflareAccess,
|
|
33
34
|
KeyValueStore, MemoryKV, SqliteKV,
|
|
34
35
|
SqliteProvider, LocalSqliteProvider,
|
|
35
|
-
SidecarService, NotificationCenter, WebPushService,
|
|
36
|
+
SidecarService, NotificationCenter, WebPushService, WebPushChannel, NotificationChannel,
|
|
37
|
+
ApiKeyService, CapabilityProvider, ApiKeyCapabilityProvider,
|
|
36
38
|
CollectionService,
|
|
37
39
|
PluginService, PackageStore, StoragePackageStore, SqlitePluginInstallStore,
|
|
38
40
|
IndexerRuntime, InProcessIndexerRuntime, PluginIndexers,
|
|
@@ -49,12 +51,37 @@ import { need } from '../lazy.js';
|
|
|
49
51
|
const resolve = (value, BaseClass, build) =>
|
|
50
52
|
(value instanceof BaseClass ? value : build(value || {}));
|
|
51
53
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
54
|
+
/**
|
|
55
|
+
* The drivers this deployment can build, as a registry.
|
|
56
|
+
*
|
|
57
|
+
* `config.storageDrivers` either IS a registry or is a list of drivers to add to the
|
|
58
|
+
* portable ones — so a deployment adds Filesystem (Node/Bun), or a driver written
|
|
59
|
+
* entirely outside this package, by naming it at the entry point. What is not registered
|
|
60
|
+
* is not offered and cannot be built.
|
|
61
|
+
*/
|
|
62
|
+
export function storageRegistry(config = {}) {
|
|
63
|
+
if (config.storageRegistry instanceof StorageDriverRegistry) return config.storageRegistry;
|
|
64
|
+
if (config.storageDrivers instanceof StorageDriverRegistry) return config.storageDrivers;
|
|
65
|
+
const registry = new StorageDriverRegistry(portableDrivers());
|
|
66
|
+
for (const d of config.storageDrivers || []) registry.register(d);
|
|
67
|
+
return registry;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build a backend from a store config.
|
|
72
|
+
*
|
|
73
|
+
* The `default:` arm this replaces returned MemoryStorage, so a typo'd driver produced a
|
|
74
|
+
* store that took writes and lost them at the next restart. Unknown drivers now throw and
|
|
75
|
+
* say what is available.
|
|
76
|
+
*/
|
|
77
|
+
export function buildStorage(cfg, config = {}) {
|
|
78
|
+
// ABSENT is not the same as WRONG, and conflating them is what the old `default:` arm
|
|
79
|
+
// did. No storage configured at all is the zero-config path — `createServer()` with
|
|
80
|
+
// nothing, which is ephemeral by definition — so it gets memory. A driver that was
|
|
81
|
+
// NAMED and is not registered is a mistake, and throws: that is the case where a typo
|
|
82
|
+
// used to buy you a store that took writes and lost them.
|
|
83
|
+
if (!cfg?.driver) return new MemoryStorage();
|
|
84
|
+
return storageRegistry(config).build(cfg);
|
|
58
85
|
}
|
|
59
86
|
|
|
60
87
|
function buildIdentity(cfg) {
|
|
@@ -141,8 +168,15 @@ export function coreProviders(config, lifecycleState) {
|
|
|
141
168
|
beginReindex: (opts) => lifecycleState.background.beginReindex(opts),
|
|
142
169
|
}),
|
|
143
170
|
|
|
171
|
+
// The storage self-check, late-bound for the same reason: it needs `collections` and
|
|
172
|
+
// `issues` from this container, so it is assembled in createServer and reached back
|
|
173
|
+
// into rather than built here.
|
|
174
|
+
storageCheck: Provider.fromSingleton({
|
|
175
|
+
run: (opts) => lifecycleState.storageCheck(opts),
|
|
176
|
+
}),
|
|
177
|
+
|
|
144
178
|
storage: Provider.fromLazySingleton(
|
|
145
|
-
() => resolve(config.storage ?? config.vfs?.storage, StorageBackend, buildStorage),
|
|
179
|
+
() => resolve(config.storage ?? config.vfs?.storage, StorageBackend, (cfg) => buildStorage(cfg, config)),
|
|
146
180
|
),
|
|
147
181
|
|
|
148
182
|
// One shared SQLite provider (a keyed pool) for metadata, kv, and per-plugin
|
|
@@ -285,17 +319,68 @@ export function coreProviders(config, lifecycleState) {
|
|
|
285
319
|
})
|
|
286
320
|
: null))),
|
|
287
321
|
|
|
288
|
-
notifications
|
|
322
|
+
// How notifications actually reach people. A list, because there is no reason for
|
|
323
|
+
// it to be one: a drive can push to browsers and mail a digest and post into a chat
|
|
324
|
+
// workspace, and none of those knows about the others. Web push is the default and
|
|
325
|
+
// only when VAPID is configured, so a drive that sets nothing gets an inbox and no
|
|
326
|
+
// delivery — which is what it got before.
|
|
327
|
+
//
|
|
328
|
+
// `config.notificationChannels` replaces the list wholesale rather than adding to
|
|
329
|
+
// it, so a caller who wants email INSTEAD of push says so by saying so.
|
|
330
|
+
notificationChannels: Provider.fromLazySingleton(
|
|
289
331
|
async (deps) => {
|
|
290
332
|
const { kv, push } = await need(deps, ['kv', 'push']);
|
|
291
|
-
|
|
333
|
+
if (config.notificationChannels) {
|
|
334
|
+
return config.notificationChannels.filter(Boolean).map((c) => {
|
|
335
|
+
if (!(c instanceof NotificationChannel)) {
|
|
336
|
+
throw TroveError.invalid('Every notificationChannels entry must be a NotificationChannel');
|
|
337
|
+
}
|
|
338
|
+
return c;
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return push ? [new WebPushChannel({ kv, service: push })] : [];
|
|
342
|
+
},
|
|
343
|
+
null,
|
|
344
|
+
{ deps: ['kv', 'push'] },
|
|
345
|
+
),
|
|
346
|
+
|
|
347
|
+
// The API key store. Keys grant capabilities and no identity — see core/apiKeys.js.
|
|
348
|
+
apiKeys: Provider.fromLazySingleton(
|
|
349
|
+
async (deps) => {
|
|
350
|
+
const { kv } = await need(deps, ['kv']);
|
|
351
|
+
return resolve(config.apiKeys, ApiKeyService, () => new ApiKeyService({ kv }));
|
|
352
|
+
},
|
|
353
|
+
null,
|
|
354
|
+
{ deps: ['kv'] },
|
|
355
|
+
),
|
|
356
|
+
|
|
357
|
+
// How a credential becomes a capability grant. The counterpart to `identity`, and
|
|
358
|
+
// separate from it on purpose: some credentials answer "what may this do" without
|
|
359
|
+
// answering "who is this". Swap it to authorize from something else — a client
|
|
360
|
+
// certificate, a signed webhook, a service mesh header.
|
|
361
|
+
capabilities: Provider.fromLazySingleton(
|
|
362
|
+
async (deps) => {
|
|
363
|
+
const { apiKeys } = await need(deps, ['apiKeys']);
|
|
364
|
+
return resolve(config.capabilities, CapabilityProvider,
|
|
365
|
+
() => new ApiKeyCapabilityProvider({ apiKeys }));
|
|
366
|
+
},
|
|
367
|
+
null,
|
|
368
|
+
{ deps: ['apiKeys'] },
|
|
369
|
+
),
|
|
370
|
+
|
|
371
|
+
notifications: Provider.fromLazySingleton(
|
|
372
|
+
async (deps) => {
|
|
373
|
+
const { kv, notificationChannels } = await need(deps, ['kv', 'notificationChannels']);
|
|
374
|
+
const center = new NotificationCenter({
|
|
375
|
+
kv, channels: notificationChannels, flushIntervalMs: config.mentionFlushMs ?? 30_000,
|
|
376
|
+
});
|
|
292
377
|
if (config.startFlusher !== false) center.start();
|
|
293
378
|
return center;
|
|
294
379
|
},
|
|
295
380
|
// Stopping the flusher used to be a line in close() that had to remember this
|
|
296
381
|
// existed. Now it is attached to the thing it stops.
|
|
297
382
|
(center) => center.stop(),
|
|
298
|
-
{ deps: ['kv', '
|
|
383
|
+
{ deps: ['kv', 'notificationChannels'] },
|
|
299
384
|
),
|
|
300
385
|
|
|
301
386
|
sidecar: Provider.fromLazySingleton(
|
|
@@ -313,15 +398,23 @@ export function coreProviders(config, lifecycleState) {
|
|
|
313
398
|
),
|
|
314
399
|
|
|
315
400
|
// The ownership + permission boundary; each collection is a store config.
|
|
316
|
-
//
|
|
317
|
-
//
|
|
401
|
+
// There is no "off" any more. Every collection-scoped endpoint names its collection
|
|
402
|
+
// in the path, so a drive with no collection layer has nothing to answer with — and
|
|
403
|
+
// the ACL check standing down because the service is absent was the failure mode this
|
|
404
|
+
// graph was rebuilt to make impossible. Refused here as well as in configFromEnv, so
|
|
405
|
+
// there is one answer whichever way the config arrived.
|
|
318
406
|
collections: Provider.fromLazySingleton(
|
|
319
407
|
async (deps) => {
|
|
320
|
-
if (config.collections === false)
|
|
408
|
+
if (config.collections === false) {
|
|
409
|
+
throw TroveError.invalid(
|
|
410
|
+
'collections: false is no longer supported — endpoints are scoped to a named '
|
|
411
|
+
+ 'collection. Create one collection and use it.',
|
|
412
|
+
);
|
|
413
|
+
}
|
|
321
414
|
const { kv, storage } = await need(deps, ['kv', 'storage']);
|
|
322
415
|
return resolve(config.collections, CollectionService, () => new CollectionService({
|
|
323
416
|
kv,
|
|
324
|
-
storageFactory: (storeConfig) => buildStorage(storeConfig),
|
|
417
|
+
storageFactory: (storeConfig) => buildStorage(storeConfig, config),
|
|
325
418
|
admins: config.admins || [],
|
|
326
419
|
creatorRoles: config.creatorRoles || [],
|
|
327
420
|
defaultOpen: config.defaultOpen !== false,
|