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