@3sln/trove 0.0.2
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 +1227 -0
- package/package.json +75 -0
- package/packages/core/src/collections/index.js +249 -0
- package/packages/core/src/errors.js +186 -0
- package/packages/core/src/identity/discovery.js +210 -0
- package/packages/core/src/identity/index.js +188 -0
- package/packages/core/src/identity/jwt.js +199 -0
- package/packages/core/src/index.js +104 -0
- package/packages/core/src/indexers/contribution.js +115 -0
- package/packages/core/src/indexers/registry.js +162 -0
- package/packages/core/src/indexing.js +340 -0
- package/packages/core/src/issues.js +150 -0
- package/packages/core/src/kv.js +0 -0
- package/packages/core/src/links.js +141 -0
- package/packages/core/src/metadata/cursor.js +73 -0
- package/packages/core/src/metadata/interface.js +244 -0
- package/packages/core/src/metadata/memory.js +270 -0
- package/packages/core/src/metadata/sqlite.js +412 -0
- package/packages/core/src/notifications/index.js +139 -0
- package/packages/core/src/notifications/webpush.js +217 -0
- package/packages/core/src/plugins/contributions.js +177 -0
- package/packages/core/src/plugins/identity.js +98 -0
- package/packages/core/src/plugins/index.js +225 -0
- package/packages/core/src/plugins/indexers.js +142 -0
- package/packages/core/src/plugins/installStore.js +134 -0
- package/packages/core/src/plugins/package.js +102 -0
- package/packages/core/src/plugins/packageStore.js +61 -0
- package/packages/core/src/plugins/runtime.js +101 -0
- package/packages/core/src/plugins/sql.js +52 -0
- package/packages/core/src/retry.js +74 -0
- package/packages/core/src/scan.js +302 -0
- package/packages/core/src/search/embeddings.js +128 -0
- package/packages/core/src/search/index.js +200 -0
- package/packages/core/src/search/keywordStore.js +107 -0
- package/packages/core/src/search/sqliteStores.js +455 -0
- package/packages/core/src/search/tagMatch.js +59 -0
- package/packages/core/src/search/transformer.js +195 -0
- package/packages/core/src/search/vectorStore.js +274 -0
- package/packages/core/src/search/vectorize.js +249 -0
- package/packages/core/src/sidecar/document.js +213 -0
- package/packages/core/src/sidecar/index.js +174 -0
- package/packages/core/src/sidecar/manager.js +239 -0
- package/packages/core/src/sidecar/store.js +46 -0
- package/packages/core/src/signedUrls.js +170 -0
- package/packages/core/src/sqlite-d1.js +162 -0
- package/packages/core/src/sqlite-driver.js +42 -0
- package/packages/core/src/sqlite.js +162 -0
- package/packages/core/src/storage/filesystem.js +283 -0
- package/packages/core/src/storage/interface.js +222 -0
- package/packages/core/src/storage/memory.js +113 -0
- package/packages/core/src/storage/prefixed.js +75 -0
- package/packages/core/src/storage/s3.js +316 -0
- package/packages/core/src/storage/s3sigv4.js +185 -0
- package/packages/core/src/tasks.js +228 -0
- package/packages/core/src/uploads.js +386 -0
- package/packages/core/src/util.js +125 -0
- package/packages/core/src/vfs.js +666 -0
- package/packages/plugin-sdk/src/browser.js +316 -0
- package/packages/plugin-sdk/src/index.js +32 -0
- package/packages/plugin-sdk/src/protocol.js +59 -0
- package/packages/plugin-sdk/src/rpc.js +95 -0
- package/packages/server/src/adapters/bun.js +78 -0
- package/packages/server/src/adapters/node.js +115 -0
- package/packages/server/src/adapters/staticAssets.js +123 -0
- package/packages/server/src/adapters/webDist.js +70 -0
- package/packages/server/src/adapters/worker-tasks.js +206 -0
- package/packages/server/src/adapters/worker.js +159 -0
- package/packages/server/src/cachePolicy.js +34 -0
- package/packages/server/src/engine/README.md +88 -0
- package/packages/server/src/engine/actions/scanCollection.js +114 -0
- package/packages/server/src/engine/index.js +95 -0
- package/packages/server/src/engine/lazy.js +25 -0
- package/packages/server/src/engine/providers/access.js +363 -0
- package/packages/server/src/engine/providers/core.js +405 -0
- package/packages/server/src/engine/providers/scan.js +67 -0
- package/packages/server/src/index.js +698 -0
- package/packages/server/src/manifest.js +98 -0
- package/packages/server/src/mcp/auth.js +40 -0
- package/packages/server/src/mcp/index.js +213 -0
- package/packages/server/src/mcp/protocol.js +181 -0
- package/packages/server/src/mcp/tools.js +351 -0
- package/packages/server/src/router.js +229 -0
- package/packages/server/src/routes.js +1066 -0
- package/packages/server/src/scope.js +43 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js +5 -0
- package/packages/web/dist/assets/chunk-4xqbzebh.js.map +9 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js +5 -0
- package/packages/web/dist/assets/chunk-h05bxfbs.js.map +10 -0
- package/packages/web/dist/assets/main-4cxs7prw.js +356 -0
- package/packages/web/dist/assets/main-4cxs7prw.js.map +103 -0
- package/packages/web/dist/assets/styles-kcx1x337.css +1 -0
- package/packages/web/dist/icon.svg +11 -0
- package/packages/web/dist/index.html +16 -0
- package/packages/web/dist/sql-wasm.wasm +0 -0
- package/packages/web/dist/sw.js +186 -0
- package/packages/web/src/bl/actions.js +410 -0
- package/packages/web/src/bl/activity.js +306 -0
- package/packages/web/src/bl/commands.js +274 -0
- package/packages/web/src/bl/fileType.js +49 -0
- package/packages/web/src/bl/index.js +70 -0
- package/packages/web/src/bl/links.js +54 -0
- package/packages/web/src/bl/offline.js +268 -0
- package/packages/web/src/bl/openers.js +71 -0
- package/packages/web/src/bl/pluginInstall.js +59 -0
- package/packages/web/src/bl/services.js +143 -0
- package/packages/web/src/bl/social.js +234 -0
- package/packages/web/src/bl/tagQuery.js +44 -0
- package/packages/web/src/main.js +10 -0
- package/packages/web/src/platform/api.js +529 -0
- package/packages/web/src/platform/commands.js +89 -0
- package/packages/web/src/platform/context.js +77 -0
- package/packages/web/src/platform/contributions.js +156 -0
- package/packages/web/src/platform/index.js +150 -0
- package/packages/web/src/platform/keybindings.js +199 -0
- package/packages/web/src/platform/mediaUrls.js +137 -0
- package/packages/web/src/platform/navigation.js +131 -0
- package/packages/web/src/platform/notifications.js +50 -0
- package/packages/web/src/platform/overlay.js +81 -0
- package/packages/web/src/platform/pluginClientDb.js +132 -0
- package/packages/web/src/platform/pluginDock.js +141 -0
- package/packages/web/src/platform/pluginFrames.js +194 -0
- package/packages/web/src/platform/pluginHost.js +648 -0
- package/packages/web/src/platform/pluginMedia.js +62 -0
- package/packages/web/src/platform/pluginModules.js +90 -0
- package/packages/web/src/platform/pluginNet.js +71 -0
- package/packages/web/src/platform/pluginPackage.js +247 -0
- package/packages/web/src/platform/pluginRpc.js +377 -0
- package/packages/web/src/platform/pluginSigning.js +168 -0
- package/packages/web/src/platform/pluginStore.js +67 -0
- package/packages/web/src/platform/settings.js +101 -0
- package/packages/web/src/platform/spatialNav.js +286 -0
- package/packages/web/src/platform/viewport.js +123 -0
- package/packages/web/src/platform/voice.js +133 -0
- package/packages/web/src/platform/voiceSearch.js +155 -0
- package/packages/web/src/platform/whenclause.js +162 -0
- package/packages/web/src/platform/workbench.js +156 -0
- package/packages/web/src/runtime.js +73 -0
- package/packages/web/src/styles.css +1382 -0
- package/packages/web/src/ui/components/activityBar.js +35 -0
- package/packages/web/src/ui/components/activityPanel.js +132 -0
- package/packages/web/src/ui/components/commandPalette.js +154 -0
- package/packages/web/src/ui/components/editorArea.js +75 -0
- package/packages/web/src/ui/components/launcher.js +392 -0
- package/packages/web/src/ui/components/openers/index.js +212 -0
- package/packages/web/src/ui/components/openers/markdown.js +222 -0
- package/packages/web/src/ui/components/overlays.js +255 -0
- package/packages/web/src/ui/components/phoneChrome.js +188 -0
- package/packages/web/src/ui/components/pluginReview.js +151 -0
- package/packages/web/src/ui/components/pluginsView.js +120 -0
- package/packages/web/src/ui/components/settingsView.js +258 -0
- package/packages/web/src/ui/components/social.js +290 -0
- package/packages/web/src/ui/components/statusBar.js +198 -0
- package/packages/web/src/ui/components/views/grid.js +115 -0
- package/packages/web/src/ui/components/views/index.js +155 -0
- package/packages/web/src/ui/components/views/list.js +50 -0
- package/packages/web/src/ui/components/views/parts.js +58 -0
- package/packages/web/src/ui/compositions/workbench.js +125 -0
- package/packages/web/src/ui/format.js +33 -0
- package/packages/web/src/ui/icon.js +81 -0
- package/packages/web/src/ui/media.js +114 -0
- package/packages/web/src/ui/sanitize.js +86 -0
- package/packages/web/src/workbench.js +205 -0
package/package.json
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@3sln/trove",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"type": "module",
|
|
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
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "https://github.com/3sln/trove.git"
|
|
9
|
+
},
|
|
10
|
+
"license": "MIT",
|
|
11
|
+
"author": "Ray Stubbs",
|
|
12
|
+
"keywords": [
|
|
13
|
+
"drive",
|
|
14
|
+
"file storage",
|
|
15
|
+
"semantic search",
|
|
16
|
+
"self-hosted",
|
|
17
|
+
"plugins",
|
|
18
|
+
"s3"
|
|
19
|
+
],
|
|
20
|
+
"publishConfig": {
|
|
21
|
+
"access": "public"
|
|
22
|
+
},
|
|
23
|
+
"exports": {
|
|
24
|
+
".": "./packages/server/src/index.js",
|
|
25
|
+
"./server": "./packages/server/src/index.js",
|
|
26
|
+
"./server/*": "./packages/server/src/*",
|
|
27
|
+
"./core": "./packages/core/src/index.js",
|
|
28
|
+
"./core/*": "./packages/core/src/*",
|
|
29
|
+
"./plugin-sdk": "./packages/plugin-sdk/src/index.js",
|
|
30
|
+
"./plugin-sdk/*": "./packages/plugin-sdk/src/*",
|
|
31
|
+
"./web/*": "./packages/web/src/*",
|
|
32
|
+
"./package.json": "./package.json"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"packages/core/src",
|
|
36
|
+
"packages/server/src",
|
|
37
|
+
"packages/plugin-sdk/src",
|
|
38
|
+
"packages/web/src",
|
|
39
|
+
"packages/web/dist"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"dev": "web-dev-server --config packages/web/web-dev-server.config.mjs",
|
|
43
|
+
"build:web": "cd packages/web && bun build.mjs",
|
|
44
|
+
"serve": "bun packages/server/src/adapters/bun.js",
|
|
45
|
+
"serve:node": "node packages/server/src/adapters/node.js",
|
|
46
|
+
"test": "bun test",
|
|
47
|
+
"test:browser": "web-test-runner --config packages/web/web-test-runner.config.mjs",
|
|
48
|
+
"backup": "bun scripts/backup.mjs",
|
|
49
|
+
"prepack": "bun run build:web",
|
|
50
|
+
"sync-version": "node scripts/sync-version.mjs",
|
|
51
|
+
"version": "npm run sync-version && git add packages/create-trove/package.json"
|
|
52
|
+
},
|
|
53
|
+
"engines": {
|
|
54
|
+
"node": ">=20",
|
|
55
|
+
"bun": ">=1.1.0"
|
|
56
|
+
},
|
|
57
|
+
"dependencies": {
|
|
58
|
+
"@3sln/ngin": "^0.0.4",
|
|
59
|
+
"fflate": "^0.8.3"
|
|
60
|
+
},
|
|
61
|
+
"optionalDependencies": {
|
|
62
|
+
"sqlite-vec": "^0.1.9"
|
|
63
|
+
},
|
|
64
|
+
"devDependencies": {
|
|
65
|
+
"@3sln/dodo": "^0.0.10",
|
|
66
|
+
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
67
|
+
"@web/dev-server": "^1.0.0",
|
|
68
|
+
"@web/test-runner": "^1.0.0",
|
|
69
|
+
"aws4": "^1.13.2",
|
|
70
|
+
"es-module-lexer": "^1.7.0",
|
|
71
|
+
"playwright-core": "^1.61.1",
|
|
72
|
+
"s3rver": "^3.7.1",
|
|
73
|
+
"sql.js": "^1.14.1"
|
|
74
|
+
}
|
|
75
|
+
}
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Collections — the top-level ownership + permission boundary. Every file and
|
|
2
|
+
// folder belongs to exactly one collection, and (almost) all authorization is at
|
|
3
|
+
// the collection level: a principal holds some subset of { read, write, delete,
|
|
4
|
+
// admin } capabilities on a collection, granted via its ACL.
|
|
5
|
+
//
|
|
6
|
+
// A collection is also a STORE CONFIG: it names a backing store (S3 bucket+prefix,
|
|
7
|
+
// a filesystem path, memory…). Different collections can live on entirely
|
|
8
|
+
// different backends, or share one bucket via distinct prefixes. Users who hold
|
|
9
|
+
// the global "create collections" capability can spin up new collections by
|
|
10
|
+
// supplying a store config — Trove instantiates the backend on demand.
|
|
11
|
+
//
|
|
12
|
+
// Records persist in the pluggable KeyValueStore. Store configs may contain
|
|
13
|
+
// secrets (access keys); treat that store as sensitive.
|
|
14
|
+
|
|
15
|
+
import { TroveError } from '../errors.js';
|
|
16
|
+
import { PrefixedStorage } from '../storage/prefixed.js';
|
|
17
|
+
import { newId } from '../util.js';
|
|
18
|
+
|
|
19
|
+
export const CAPABILITIES = ['read', 'write', 'delete', 'admin'];
|
|
20
|
+
const NS = 'collections';
|
|
21
|
+
|
|
22
|
+
// admin implies everything; a simple implication table keeps checks declarative.
|
|
23
|
+
function expand(caps) {
|
|
24
|
+
const set = new Set(caps);
|
|
25
|
+
if (set.has('admin')) for (const c of CAPABILITIES) set.add(c);
|
|
26
|
+
return set;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export class CollectionService {
|
|
30
|
+
/**
|
|
31
|
+
* @param {object} deps
|
|
32
|
+
* @param {import('../kv.js').KeyValueStore} deps.kv
|
|
33
|
+
* @param {(storeConfig: object) => import('../storage/interface.js').StorageBackend} deps.storageFactory
|
|
34
|
+
* @param {string[]} [deps.admins] global admin principal ids (can do anything, create collections)
|
|
35
|
+
* @param {string[]} [deps.creatorRoles] roles allowed to create collections
|
|
36
|
+
* @param {object} [deps.defaultStore] store config for the auto-created 'default' collection
|
|
37
|
+
* @param {boolean} [deps.defaultOpen] if true (default), 'default' grants everyone all caps (single-user friendly)
|
|
38
|
+
*/
|
|
39
|
+
constructor({ kv, storageFactory, admins = [], creatorRoles = [], defaultStore, defaultOpen = true, storageOverrides }) {
|
|
40
|
+
if (!kv) throw TroveError.invalid('CollectionService requires a kv store');
|
|
41
|
+
if (!storageFactory) throw TroveError.invalid('CollectionService requires a storageFactory');
|
|
42
|
+
this.kv = kv;
|
|
43
|
+
this.storageFactory = storageFactory;
|
|
44
|
+
this.admins = new Set(admins);
|
|
45
|
+
this.creatorRoles = new Set(creatorRoles);
|
|
46
|
+
this.defaultStore = defaultStore || { driver: 'memory' };
|
|
47
|
+
this.defaultOpen = defaultOpen;
|
|
48
|
+
// Pre-built backends to reuse an existing instance (e.g. the server's primary
|
|
49
|
+
// storage for the 'default' collection, so data isn't split across two Maps).
|
|
50
|
+
this._storage = new Map(Object.entries(storageOverrides || {}));
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async init() {
|
|
54
|
+
const existing = await this.kv.get(NS, 'default');
|
|
55
|
+
if (!existing) {
|
|
56
|
+
await this.kv.set(NS, 'default', {
|
|
57
|
+
id: 'default', name: 'My Drive', description: 'Default collection',
|
|
58
|
+
store: this.defaultStore,
|
|
59
|
+
acl: { grants: this.defaultOpen ? [{ type: 'anyone', capabilities: ['read', 'write', 'delete', 'admin'] }] : [] },
|
|
60
|
+
createdAt: Date.now(), createdBy: 'system', system: true,
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async list(principal) {
|
|
66
|
+
const rows = await this.kv.list(NS);
|
|
67
|
+
return rows
|
|
68
|
+
.map((r) => r.value)
|
|
69
|
+
.filter((c) => this.can(principal, c, 'read'))
|
|
70
|
+
.map((c) => this.describe(c, principal))
|
|
71
|
+
.sort((a, b) => (a.name || '').localeCompare(b.name || ''));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async get(id) {
|
|
75
|
+
const c = await this.kv.get(NS, id);
|
|
76
|
+
if (!c) throw TroveError.notFound('Collection');
|
|
77
|
+
return c;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** A safe, principal-scoped view (no secrets). */
|
|
81
|
+
describe(c, principal) {
|
|
82
|
+
const caps = [...this.capabilities(principal, c)];
|
|
83
|
+
return {
|
|
84
|
+
id: c.id, name: c.name, description: c.description || '',
|
|
85
|
+
driver: c.store?.driver, system: !!c.system,
|
|
86
|
+
capabilities: caps, createdAt: c.createdAt,
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Build (and cache) the StorageBackend for a collection from its store config. */
|
|
91
|
+
async storageFor(collectionId) {
|
|
92
|
+
if (this._storage.has(collectionId)) return this._storage.get(collectionId);
|
|
93
|
+
const c = await this.get(collectionId);
|
|
94
|
+
let backend = this.storageFactory(c.store);
|
|
95
|
+
if (c.store?.prefix) backend = new PrefixedStorage(backend, c.store.prefix);
|
|
96
|
+
this._storage.set(collectionId, backend);
|
|
97
|
+
return backend;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// --- permissions -----------------------------------------------------------
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Does `subject` — a string an operator or an ACL wrote down — name this principal?
|
|
104
|
+
*
|
|
105
|
+
* By id, or by email. The id is whatever the IdP put in `sub`, and for Cloudflare
|
|
106
|
+
* Access that is an internal user UUID, not an address: `TROVE_ADMINS=you@example.com`
|
|
107
|
+
* matched nothing and there was no way to discover the UUID short of decoding a JWT.
|
|
108
|
+
* A drive that looks administered and isn't is the worst version of this to ship, so
|
|
109
|
+
* the address people actually think of as their identity works too.
|
|
110
|
+
*
|
|
111
|
+
* Email compares case-insensitively, which is how mailboxes behave and how every IdP
|
|
112
|
+
* hands them over.
|
|
113
|
+
*/
|
|
114
|
+
#names(subject, principal) {
|
|
115
|
+
if (!subject || !principal) return false;
|
|
116
|
+
if (subject === principal.id) return true;
|
|
117
|
+
const email = principal.email;
|
|
118
|
+
return !!email && String(subject).toLowerCase() === String(email).toLowerCase();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Is this principal on the global admin list (by id or by email)? */
|
|
122
|
+
#isNamedAdmin(principal) {
|
|
123
|
+
if (!principal) return false;
|
|
124
|
+
if (this.admins.has(principal.id)) return true;
|
|
125
|
+
for (const a of this.admins) if (this.#names(a, principal)) return true;
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The set of capabilities `principal` holds on a collection record/id. */
|
|
130
|
+
capabilities(principal, collectionOrId) {
|
|
131
|
+
const c = typeof collectionOrId === 'string' ? null : collectionOrId;
|
|
132
|
+
if (!c) return new Set(); // callers pass the record; unknown → none
|
|
133
|
+
if (this.#isNamedAdmin(principal)) return expand(['admin']);
|
|
134
|
+
const roles = new Set(principal?.roles || []);
|
|
135
|
+
// Note: a creator role grants the ability to CREATE collections, not blanket
|
|
136
|
+
// access to every collection — per-collection access comes only from the ACL below.
|
|
137
|
+
const granted = new Set();
|
|
138
|
+
for (const g of c.acl?.grants || []) {
|
|
139
|
+
const match =
|
|
140
|
+
g.type === 'anyone' ||
|
|
141
|
+
// Same for a per-collection grant: sharing with someone by the address you know
|
|
142
|
+
// them by has to work, or an ACL written by a human never matches.
|
|
143
|
+
(g.type === 'user' && this.#names(g.subject, principal)) ||
|
|
144
|
+
(g.type === 'role' && roles.has(g.subject));
|
|
145
|
+
if (match) for (const cap of g.capabilities || []) granted.add(cap);
|
|
146
|
+
}
|
|
147
|
+
return expand([...granted]);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
can(principal, collectionOrId, capability) {
|
|
151
|
+
return this.capabilities(principal, collectionOrId).has(capability);
|
|
152
|
+
}
|
|
153
|
+
async assert(principal, collectionId, capability) {
|
|
154
|
+
const c = await this.get(collectionId);
|
|
155
|
+
if (!this.can(principal, c, capability)) {
|
|
156
|
+
throw TroveError.forbidden(`You lack "${capability}" on collection "${c.name}"`);
|
|
157
|
+
}
|
|
158
|
+
return c;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* May this principal act on the drive AS A WHOLE?
|
|
163
|
+
*
|
|
164
|
+
* True for a named admin, and also for anyone who can already read and write every
|
|
165
|
+
* collection that exists — which is not a new grant, it is naming what is already
|
|
166
|
+
* true. It matters because the default zero-config self-host is exactly that shape:
|
|
167
|
+
* one anonymous user with full access to an open default collection. Gating
|
|
168
|
+
* drive-wide maintenance (rebuilding the search index) behind a TROVE_ADMINS list
|
|
169
|
+
* nobody is on would make it unreachable in the configuration most people run first,
|
|
170
|
+
* while a locked-down multi-tenant drive still restricts it to real admins.
|
|
171
|
+
*
|
|
172
|
+
* Deliberately NOT used for anything that grants new power — installing a server
|
|
173
|
+
* indexer runs code, and stays `isAdmin` only.
|
|
174
|
+
*/
|
|
175
|
+
async hasWholeDrive(principal) {
|
|
176
|
+
if (this.isAdmin(principal)) return true;
|
|
177
|
+
if (!principal) return false;
|
|
178
|
+
const all = (await this.kv.list(NS)).map((r) => r.value).filter(Boolean);
|
|
179
|
+
if (!all.length) return false;
|
|
180
|
+
return all.every((c) => this.can(principal, c, 'read') && this.can(principal, c, 'write'));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Global admin (can do anything, incl. grant admin-only plugin capabilities). */
|
|
184
|
+
isAdmin(principal) {
|
|
185
|
+
return this.#isNamedAdmin(principal);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Global capability to create new collections. */
|
|
189
|
+
canCreate(principal) {
|
|
190
|
+
if (!principal) return false;
|
|
191
|
+
if (this.#isNamedAdmin(principal)) return true;
|
|
192
|
+
return (principal.roles || []).some((r) => this.creatorRoles.has(r));
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// --- CRUD ------------------------------------------------------------------
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Create a collection from a store config. Requires the create capability.
|
|
199
|
+
* The creator is granted admin on the new collection.
|
|
200
|
+
*/
|
|
201
|
+
async create({ name, description, store, acl }, principal) {
|
|
202
|
+
if (!this.canCreate(principal)) throw TroveError.forbidden('You cannot create collections');
|
|
203
|
+
if (!name?.trim()) throw TroveError.invalid('Collection name is required');
|
|
204
|
+
if (!store?.driver) throw TroveError.invalid('A backing store (driver + config) is required');
|
|
205
|
+
// Validate the store config actually builds.
|
|
206
|
+
try {
|
|
207
|
+
this.storageFactory(store);
|
|
208
|
+
} catch (err) {
|
|
209
|
+
throw TroveError.invalid(`Invalid store config: ${err.message}`, { cause: err });
|
|
210
|
+
}
|
|
211
|
+
const id = newId('col');
|
|
212
|
+
const grants = acl?.grants ? [...acl.grants] : [];
|
|
213
|
+
grants.push({ type: 'user', subject: principal.id, capabilities: ['admin'] });
|
|
214
|
+
const record = { id, name: name.trim(), description: description || '', store, acl: { grants }, createdAt: Date.now(), createdBy: principal.id };
|
|
215
|
+
await this.kv.set(NS, id, record);
|
|
216
|
+
return this.describe(record, principal);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
async update(id, patch, principal) {
|
|
220
|
+
const c = await this.assert(principal, id, 'admin');
|
|
221
|
+
const next = { ...c };
|
|
222
|
+
if (patch.name != null) next.name = patch.name;
|
|
223
|
+
if (patch.description != null) next.description = patch.description;
|
|
224
|
+
if (patch.acl) next.acl = patch.acl; // full ACL replace (admin-only)
|
|
225
|
+
if (patch.store) {
|
|
226
|
+
next.store = patch.store;
|
|
227
|
+
this._storage.delete(id); // rebuild backend next use
|
|
228
|
+
}
|
|
229
|
+
await this.kv.set(NS, id, next);
|
|
230
|
+
return this.describe(next, principal);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Remove the collection record. Caller is responsible for its nodes/objects. */
|
|
234
|
+
async remove(id, principal) {
|
|
235
|
+
if (id === 'default') throw TroveError.invalid('The default collection cannot be deleted');
|
|
236
|
+
await this.assert(principal, id, 'admin');
|
|
237
|
+
await this.kv.delete(NS, id);
|
|
238
|
+
this._storage.delete(id);
|
|
239
|
+
return { ok: true };
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Full ACL grant management (admin-only). */
|
|
243
|
+
async setGrant(id, grant, principal) {
|
|
244
|
+
const c = await this.assert(principal, id, 'admin');
|
|
245
|
+
const grants = (c.acl?.grants || []).filter((g) => !(g.type === grant.type && g.subject === grant.subject));
|
|
246
|
+
if (grant.capabilities?.length) grants.push(grant);
|
|
247
|
+
return this.update(id, { acl: { grants } }, principal);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
// A small, deliberate error taxonomy. Everything that can fail in Trove throws
|
|
2
|
+
// (or rejects with) a TroveError subclass so callers — and ultimately the user —
|
|
3
|
+
// get a stable `code`, a human `message`, and a `retryable` signal that the
|
|
4
|
+
// retry helper and the HTTP layer both key off of. Wrapping a lower-level cause
|
|
5
|
+
// (an S3 SDK error, a fetch TypeError) preserves it under `.cause` for logs
|
|
6
|
+
// while presenting a clean surface upward.
|
|
7
|
+
|
|
8
|
+
/** Stable, machine-readable codes. Mirrored by the HTTP layer → status codes. */
|
|
9
|
+
export const ErrorCode = Object.freeze({
|
|
10
|
+
NOT_FOUND: 'not_found',
|
|
11
|
+
ALREADY_EXISTS: 'already_exists',
|
|
12
|
+
INVALID: 'invalid', // bad request / validation
|
|
13
|
+
CONFLICT: 'conflict', // version/etag mismatch, concurrent edit
|
|
14
|
+
UNAUTHORIZED: 'unauthorized',
|
|
15
|
+
FORBIDDEN: 'forbidden',
|
|
16
|
+
UNSUPPORTED: 'unsupported', // backend can't do this (e.g. presign on fs)
|
|
17
|
+
QUOTA: 'quota', // out of space (507) / rate limited by capacity (429) — see below
|
|
18
|
+
TOO_LARGE: 'too_large', // this request is bigger than a configured limit allows
|
|
19
|
+
BAD_RANGE: 'bad_range', // the requested byte range doesn't exist in this object (416)
|
|
20
|
+
TRANSIENT: 'transient', // network blip, 5xx, throttle — safe to retry
|
|
21
|
+
TIMEOUT: 'timeout',
|
|
22
|
+
ABORTED: 'aborted', // caller cancelled (AbortSignal)
|
|
23
|
+
INTERNAL: 'internal', // unexpected; a bug
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// Which codes are, by default, safe to retry with backoff.
|
|
27
|
+
const RETRYABLE = new Set([ErrorCode.TRANSIENT, ErrorCode.TIMEOUT, ErrorCode.QUOTA]);
|
|
28
|
+
|
|
29
|
+
// HTTP status for each code, so the server layer stays declarative.
|
|
30
|
+
const HTTP_STATUS = {
|
|
31
|
+
[ErrorCode.NOT_FOUND]: 404,
|
|
32
|
+
[ErrorCode.ALREADY_EXISTS]: 409,
|
|
33
|
+
[ErrorCode.INVALID]: 400,
|
|
34
|
+
[ErrorCode.CONFLICT]: 412,
|
|
35
|
+
[ErrorCode.UNAUTHORIZED]: 401,
|
|
36
|
+
[ErrorCode.FORBIDDEN]: 403,
|
|
37
|
+
[ErrorCode.UNSUPPORTED]: 501,
|
|
38
|
+
[ErrorCode.QUOTA]: 429,
|
|
39
|
+
[ErrorCode.TOO_LARGE]: 413,
|
|
40
|
+
[ErrorCode.BAD_RANGE]: 416,
|
|
41
|
+
[ErrorCode.TRANSIENT]: 503,
|
|
42
|
+
[ErrorCode.TIMEOUT]: 504,
|
|
43
|
+
[ErrorCode.ABORTED]: 499,
|
|
44
|
+
[ErrorCode.INTERNAL]: 500,
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export class TroveError extends Error {
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} code one of ErrorCode
|
|
50
|
+
* @param {string} message human-readable, safe to surface to the user
|
|
51
|
+
* @param {{cause?: unknown, retryable?: boolean, details?: object}} [opts]
|
|
52
|
+
*/
|
|
53
|
+
constructor(code, message, opts = {}) {
|
|
54
|
+
super(message, opts.cause !== undefined ? { cause: opts.cause } : undefined);
|
|
55
|
+
this.name = 'TroveError';
|
|
56
|
+
this.code = code || ErrorCode.INTERNAL;
|
|
57
|
+
// Explicit override wins; otherwise derive from the code.
|
|
58
|
+
this.retryable = opts.retryable ?? RETRYABLE.has(this.code);
|
|
59
|
+
this.details = opts.details ?? null;
|
|
60
|
+
this.status = HTTP_STATUS[this.code] ?? 500;
|
|
61
|
+
// QUOTA covers two failures that deserve different statuses. A rate limit is 429 —
|
|
62
|
+
// back off and try again. Being out of DISK is 507: retrying changes nothing, and
|
|
63
|
+
// telling a client to retry sends it into a loop against a condition only a human
|
|
64
|
+
// can clear. `retryable` is what tells them apart.
|
|
65
|
+
//
|
|
66
|
+
// "Your file is bigger than we allow" is neither, and has its own code (TOO_LARGE,
|
|
67
|
+
// 413): the store is not full, and nothing about waiting or freeing space helps.
|
|
68
|
+
if (this.code === ErrorCode.QUOTA && !this.retryable) this.status = 507;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Shape sent over the wire and logged. Never leaks the raw cause. */
|
|
72
|
+
toJSON() {
|
|
73
|
+
return {
|
|
74
|
+
error: {
|
|
75
|
+
code: this.code,
|
|
76
|
+
message: this.message,
|
|
77
|
+
retryable: this.retryable,
|
|
78
|
+
...(this.details ? { details: this.details } : {}),
|
|
79
|
+
},
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Ergonomic constructors — read like prose at the call site.
|
|
84
|
+
static notFound(what, opts) {
|
|
85
|
+
return new TroveError(ErrorCode.NOT_FOUND, what ? `${what} not found` : 'Not found', opts);
|
|
86
|
+
}
|
|
87
|
+
static alreadyExists(what, opts) {
|
|
88
|
+
return new TroveError(ErrorCode.ALREADY_EXISTS, `${what} already exists`, opts);
|
|
89
|
+
}
|
|
90
|
+
static invalid(message, opts) {
|
|
91
|
+
return new TroveError(ErrorCode.INVALID, message, opts);
|
|
92
|
+
}
|
|
93
|
+
static conflict(message, opts) {
|
|
94
|
+
return new TroveError(ErrorCode.CONFLICT, message, opts);
|
|
95
|
+
}
|
|
96
|
+
static unsupported(message, opts) {
|
|
97
|
+
return new TroveError(ErrorCode.UNSUPPORTED, message, opts);
|
|
98
|
+
}
|
|
99
|
+
static transient(message, opts) {
|
|
100
|
+
return new TroveError(ErrorCode.TRANSIENT, message, { retryable: true, ...opts });
|
|
101
|
+
}
|
|
102
|
+
static timeout(message, opts) {
|
|
103
|
+
return new TroveError(ErrorCode.TIMEOUT, message, { retryable: true, ...opts });
|
|
104
|
+
}
|
|
105
|
+
static aborted(message = 'Operation aborted', opts) {
|
|
106
|
+
return new TroveError(ErrorCode.ABORTED, message, { retryable: false, ...opts });
|
|
107
|
+
}
|
|
108
|
+
static unauthorized(message = 'Unauthorized', opts) {
|
|
109
|
+
return new TroveError(ErrorCode.UNAUTHORIZED, message, opts);
|
|
110
|
+
}
|
|
111
|
+
static tooLarge(message = 'Too large', opts) {
|
|
112
|
+
return new TroveError(ErrorCode.TOO_LARGE, message, { retryable: false, ...opts });
|
|
113
|
+
}
|
|
114
|
+
/** The requested byte range doesn't exist in this object — 416, not 400 or 500. */
|
|
115
|
+
static badRange(message = 'Range not satisfiable', opts) {
|
|
116
|
+
return new TroveError(ErrorCode.BAD_RANGE, message, { retryable: false, ...opts });
|
|
117
|
+
}
|
|
118
|
+
static forbidden(message = 'Forbidden', opts) {
|
|
119
|
+
return new TroveError(ErrorCode.FORBIDDEN, message, opts);
|
|
120
|
+
}
|
|
121
|
+
static internal(message = 'Internal error', opts) {
|
|
122
|
+
return new TroveError(ErrorCode.INTERNAL, message, opts);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Normalize any thrown value into a TroveError. Recognises AbortError, common
|
|
128
|
+
* fetch/network failures, and Node fs errno codes so lower layers can just
|
|
129
|
+
* `throw wrapError(e)` and get sane classification for free.
|
|
130
|
+
*/
|
|
131
|
+
export function wrapError(err, fallbackMessage = 'Unexpected error') {
|
|
132
|
+
if (err instanceof TroveError) return err;
|
|
133
|
+
|
|
134
|
+
// Cancellation.
|
|
135
|
+
if (err?.name === 'AbortError' || err?.code === 'ABORT_ERR') {
|
|
136
|
+
return TroveError.aborted(err.message || 'Operation aborted', { cause: err });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Node/browser network failures are transient by nature.
|
|
140
|
+
const netCodes = new Set([
|
|
141
|
+
'ECONNRESET', 'ECONNREFUSED', 'ETIMEDOUT', 'EAI_AGAIN', 'EPIPE', 'ENOTFOUND', 'UND_ERR_SOCKET',
|
|
142
|
+
]);
|
|
143
|
+
if (netCodes.has(err?.code) || (err instanceof TypeError && /fetch|network/i.test(err.message || ''))) {
|
|
144
|
+
return TroveError.transient(err.message || 'Network error', { cause: err });
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Node fs errnos.
|
|
148
|
+
switch (err?.code) {
|
|
149
|
+
case 'ENOENT':
|
|
150
|
+
return TroveError.notFound(null, { cause: err });
|
|
151
|
+
case 'EEXIST':
|
|
152
|
+
return TroveError.alreadyExists('Path', { cause: err });
|
|
153
|
+
case 'EACCES':
|
|
154
|
+
case 'EPERM':
|
|
155
|
+
return new TroveError(ErrorCode.FORBIDDEN, 'Permission denied', { cause: err });
|
|
156
|
+
// Out of room. NOT retryable, unlike the rate-limit sense of QUOTA that shares this
|
|
157
|
+
// code — retrying a full disk just burns the user's time to reach the same answer.
|
|
158
|
+
// The message is what someone can act on, rather than the kernel's phrasing.
|
|
159
|
+
case 'ENOSPC':
|
|
160
|
+
return new TroveError(ErrorCode.QUOTA, 'The storage volume is full — free some space and try again', { cause: err, retryable: false });
|
|
161
|
+
case 'EDQUOT':
|
|
162
|
+
return new TroveError(ErrorCode.QUOTA, 'The storage quota for this volume has been reached', { cause: err, retryable: false });
|
|
163
|
+
case 'EFBIG':
|
|
164
|
+
return new TroveError(ErrorCode.QUOTA, 'That file is larger than this filesystem can store', { cause: err, retryable: false });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return TroveError.internal(err?.message || fallbackMessage, { cause: err });
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Did this fail because the store is out of room?
|
|
172
|
+
*
|
|
173
|
+
* QUOTA covers two different things — "no space left" and "you are being rate limited"
|
|
174
|
+
* — and only the first is a standing condition someone has to go and fix. Callers that
|
|
175
|
+
* want to raise a persistent, actionable problem need to tell them apart, and the
|
|
176
|
+
* distinguishing fact is that a full disk is not retryable.
|
|
177
|
+
*/
|
|
178
|
+
export function isOutOfSpace(err) {
|
|
179
|
+
return err instanceof TroveError && err.code === ErrorCode.QUOTA && err.retryable === false;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** True if the thrown value should be retried. */
|
|
183
|
+
export function isRetryable(err) {
|
|
184
|
+
if (err instanceof TroveError) return err.retryable;
|
|
185
|
+
return wrapError(err).retryable;
|
|
186
|
+
}
|