@3sln/trove 0.0.9 → 0.0.11

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@3sln/trove",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
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": {
@@ -26,6 +26,7 @@
26
26
  // nothing replayable. It is shown to the minter exactly once.
27
27
 
28
28
  import { TroveError } from './errors.js';
29
+ import { timingSafeEqual } from './timingSafe.js';
29
30
  import { CAPABILITIES, expand } from './collections/index.js';
30
31
 
31
32
  const NS = 'api-keys';
@@ -72,19 +73,6 @@ async function sha256(text) {
72
73
  return b64url(new Uint8Array(digest));
73
74
  }
74
75
 
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
76
 
89
77
  /** Normalise and validate one `{ collectionId, capabilities }` entry. */
90
78
  function normalizeScope(scope) {
@@ -324,6 +324,30 @@ export class CollectionService {
324
324
  return all.every((c) => this.can(principal, c, 'read') && this.can(principal, c, 'write'));
325
325
  }
326
326
 
327
+ /**
328
+ * What this principal may reach across the whole drive, and whether that is anything.
329
+ *
330
+ * The ACL as a DECISION rather than as a record, so something outside this library can
331
+ * ask it — an edge policy deciding whether an email gets through the front door at all,
332
+ * for instance. Everything it needs is already here; what was missing was a way to ask
333
+ * without reimplementing `can()` against the grant shape, which is exactly how two
334
+ * copies of an authorization rule start.
335
+ *
336
+ * "Allowed" means: a named admin, or read on at least one collection. Read on nothing is
337
+ * the honest definition of someone with no business here — note that on a `defaultOpen`
338
+ * drive the `anyone` grant makes that true for everybody, which is correct, because the
339
+ * ACL is what says so.
340
+ */
341
+ async accessFor(principal) {
342
+ const admin = this.isAdmin(principal);
343
+ const collections = await this.list(principal);
344
+ return {
345
+ allowed: admin || collections.length > 0,
346
+ admin,
347
+ collections: collections.map((c) => ({ id: c.id, name: c.name, capabilities: c.capabilities })),
348
+ };
349
+ }
350
+
327
351
  /** Global admin (can do anything, incl. grant admin-only plugin capabilities). */
328
352
  isAdmin(principal) {
329
353
  return this.#isNamedAdmin(principal);
@@ -141,6 +141,54 @@ async function importVerifyKey(alg, key) {
141
141
  * @param {number} [opts.clockToleranceSec]
142
142
  * @param {number|null} [opts.now] ms epoch; pass null to say there is no clock
143
143
  */
144
+ /** base64url WITHOUT padding, which is what a JWT wants everywhere. */
145
+ function bytesToBase64url(bytes) {
146
+ let bin = '';
147
+ for (const b of bytes) bin += String.fromCharCode(b);
148
+ return btoa(bin).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
149
+ }
150
+
151
+ /**
152
+ * Sign a JWT with a private JWK.
153
+ *
154
+ * The mirror of `verifyJwt`, and it exists for one caller: answering an external policy
155
+ * evaluation, where the asker fetches OUR public key to check the answer. That is why it is
156
+ * asymmetric where `signedUrls.js` is happy with HMAC — a shared secret would mean handing
157
+ * the verifier the ability to mint answers.
158
+ *
159
+ * RS256 by default, and that is not a preference. Cloudflare Access verifies these, its
160
+ * documentation says an RSA key pair and that "other key formats are not supported", and
161
+ * its own reference implementation signs with RSASSA-PKCS1-v1_5 over SHA-256. ES256 is a
162
+ * better curve and would have been silently rejected by the only thing that reads these.
163
+ */
164
+ export async function signJwt(payload, { privateJwk, kid, alg = 'RS256', expiresInSec = 60, now = Date.now() } = {}) {
165
+ if (!privateJwk) throw TroveError.invalid('Signing a JWT needs a private JWK');
166
+ const spec = ALGS[alg];
167
+ if (!spec || alg === 'HS256') throw TroveError.unsupported(`Cannot sign a JWT with alg ${alg}`);
168
+ const iat = Math.floor(now / 1000);
169
+ const body = { iat, exp: iat + expiresInSec, ...payload };
170
+ const header = { alg, typ: 'JWT', ...(kid ? { kid } : {}) };
171
+ const signingInput = `${bytesToBase64url(enc.encode(JSON.stringify(header)))}.`
172
+ + `${bytesToBase64url(enc.encode(JSON.stringify(body)))}`;
173
+ const key = await crypto.subtle.importKey('jwk', { ...privateJwk, alg: undefined, key_ops: undefined }, spec.import, false, ['sign']);
174
+ const sig = new Uint8Array(await crypto.subtle.sign(spec.verify, key, enc.encode(signingInput)));
175
+ return `${signingInput}.${bytesToBase64url(sig)}`;
176
+ }
177
+
178
+ /**
179
+ * The public half of a private JWK, as a JWKS entry.
180
+ *
181
+ * Strips `d` — the private scalar — and everything else a signer needs and a verifier must
182
+ * not have. Written as a subtraction rather than a copy of the public fields so a JWK that
183
+ * grows a field cannot silently start publishing it.
184
+ */
185
+ export function publicJwkOf(privateJwk, { kid, alg = 'RS256' } = {}) {
186
+ const { d, p, q, dp, dq, qi, ...pub } = privateJwk || {};
187
+ // `alg` is published because Cloudflare's own keys endpoint publishes it, and a verifier
188
+ // that trusts the JWK's alg over the token header's is the safer of the two.
189
+ return { ...pub, alg, key_ops: ['verify'], use: 'sig', ...(kid ? { kid } : {}) };
190
+ }
191
+
144
192
  export async function verifyJwt(token, opts = {}) {
145
193
  const { header, payload, parts } = decodeJwt(token);
146
194
  const alg = header.alg;
@@ -83,7 +83,8 @@ export {
83
83
  AnonymousIdentityProvider, principalFromClaims,
84
84
  cloudflareAccess, accessHost,
85
85
  } from './identity/index.js';
86
- export { verifyJwt, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
86
+ export { verifyJwt, signJwt, publicJwkOf, decodeJwt, JwksClient, StaticJwks } from './identity/jwt.js';
87
+ export { timingSafeEqual } from './timingSafe.js';
87
88
  // Where an unauthenticated client is told to go — one answer for the whole drive.
88
89
  export {
89
90
  protectedResourceMetadata, challengeHeaders, metadataUrl, publicOrigin,
@@ -18,6 +18,7 @@
18
18
  // may see must not be editable into one for a file you may not.
19
19
 
20
20
  import { TroveError } from './errors.js';
21
+ import { timingSafeEqual } from './timingSafe.js';
21
22
 
22
23
  const MINUTES = 60;
23
24
  const HOURS = 60 * MINUTES;
@@ -132,19 +133,6 @@ export class SignedUrls {
132
133
  }
133
134
  }
134
135
 
135
- /**
136
- * Constant-time string compare.
137
- *
138
- * `a === b` on a signature leaks where the first differing byte is, which over enough
139
- * requests is a forgery oracle. The cost of not caring is small and the cost of caring is
140
- * nothing.
141
- */
142
- function timingSafeEqual(a, b) {
143
- if (a.length !== b.length) return false;
144
- let diff = 0;
145
- for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
146
- return diff === 0;
147
- }
148
136
 
149
137
  /**
150
138
  * A secret that survives a restart and is shared between instances.
@@ -0,0 +1,30 @@
1
+ // Comparing a credential without saying how nearly you got it right.
2
+ //
3
+ // `a === b` returns as soon as it finds a difference, so it is fast for a wrong first byte
4
+ // and slower for a wrong last one. Over enough attempts that difference is measurable, and
5
+ // measuring it recovers the secret one byte at a time — which is why credential comparison
6
+ // is never string equality.
7
+ //
8
+ // One copy, and this file exists because there were two: `signedUrls.js` and `apiKeys.js`
9
+ // each grew their own, and the two had already drifted — one guarded its argument types and
10
+ // the other trusted the call site. That is the cheapest possible version of the bug where
11
+ // two copies of a security rule disagree, and it is worth not having.
12
+
13
+ /**
14
+ * Do these two strings match, in time that does not depend on where they differ?
15
+ *
16
+ * Length is NOT hidden: returning early on a length mismatch leaks how long the secret is,
17
+ * which for a fixed-width hash or signature is public anyway. Hiding it would mean hashing
18
+ * both sides first, and every caller here compares values that are already digests.
19
+ *
20
+ * A non-string is false rather than a throw. The inputs come off the wire, `undefined` is a
21
+ * perfectly ordinary thing for a missing header to be, and a comparison that throws on it
22
+ * turns a failed auth attempt into a 500.
23
+ */
24
+ export function timingSafeEqual(a, b) {
25
+ if (typeof a !== 'string' || typeof b !== 'string') return false;
26
+ if (a.length !== b.length) return false;
27
+ let diff = 0;
28
+ for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
29
+ return diff === 0;
30
+ }
@@ -12,8 +12,12 @@
12
12
 
13
13
  import { TroveError, isOutOfSpace } from './errors.js';
14
14
  import { UploadManager } from './uploads.js';
15
- import { toHex } from './encryption/keys.js';
16
- import { decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES } from './encryption/envelope.js';
15
+ import { fromHex, toHex } from './encryption/keys.js';
16
+ import {
17
+ encrypt, decryptStream, decodeHeader, cipherRangeFor, cipherSize, HEADER_BYTES,
18
+ DEFAULT_CHUNK_SIZE,
19
+ } from './encryption/envelope.js';
20
+ import { shouldEncrypt } from './encryption/policy.js';
17
21
  import { IndexerRegistry } from './indexers/registry.js';
18
22
  import { ParsingSearchTransformer, matchTagFilters } from './search/transformer.js';
19
23
  import { extname } from './util.js';
@@ -56,6 +60,24 @@ async function bytesOf(stream) {
56
60
  return out;
57
61
  }
58
62
 
63
+ /**
64
+ * Whatever `writeFile` was handed, as bytes.
65
+ *
66
+ * Sealing needs the plaintext size before it can write the envelope header, and this path
67
+ * is the convenience write for things already resident, so buffering costs nothing that was
68
+ * not already paid.
69
+ */
70
+ async function bytesOfBody(body) {
71
+ if (body == null) return new Uint8Array(0);
72
+ if (typeof body === 'string') return new TextEncoder().encode(body);
73
+ if (body instanceof Uint8Array) return body;
74
+ if (ArrayBuffer.isView(body)) return new Uint8Array(body.buffer, body.byteOffset, body.byteLength);
75
+ if (body instanceof ArrayBuffer) return new Uint8Array(body);
76
+ if (typeof body.arrayBuffer === 'function') return new Uint8Array(await body.arrayBuffer());
77
+ if (typeof body.getReader === 'function') return bytesOf(body);
78
+ return new Uint8Array(body);
79
+ }
80
+
59
81
  /**
60
82
  * Emit only the bytes between `start` and `end` of a stream.
61
83
  *
@@ -132,6 +154,32 @@ export class Vfs {
132
154
  if (this.collections) await this.collections.init();
133
155
  }
134
156
 
157
+ /**
158
+ * What this collection wants sealed, and the key for it — or null.
159
+ *
160
+ * The same question `UploadManager` asks through its injected `encryptionFor`, including
161
+ * `shouldEncrypt`, so a collection with per-item rules answers identically whichever way
162
+ * the bytes arrive.
163
+ */
164
+ async #sealingFor(collectionId, name, contentType) {
165
+ if (!this.collections?.encryptionFor) return null;
166
+ // A collection record that is not there cannot ask for anything. `writeFile` defaults
167
+ // to 'default', which on a zero-config drive is a storage backend rather than a
168
+ // collection anyone created, and asking about it throws rather than answering "no".
169
+ let encryption;
170
+ try {
171
+ encryption = await this.collections.encryptionFor(collectionId);
172
+ } catch (err) {
173
+ if (err?.code === 'not_found') return null;
174
+ throw err;
175
+ }
176
+ if (!encryption?.enabled) return null;
177
+ if (!shouldEncrypt(encryption, { name, contentType })) return null;
178
+ const key = await this.collections.dataKeyFor(collectionId);
179
+ if (!key) throw TroveError.internal('This collection is encrypted but its key is unavailable');
180
+ return { key, fingerprint: fromHex(encryption.fingerprint), chunkSize: encryption.chunkSize || DEFAULT_CHUNK_SIZE };
181
+ }
182
+
135
183
  /** Resolve the storage backend for a collection. */
136
184
  async storageFor(collectionId = 'default') {
137
185
  if (this.collections) return this.collections.storageFor(collectionId);
@@ -256,9 +304,31 @@ export class Vfs {
256
304
  const storageKey = `obj_${cryptoId()}`;
257
305
  const ct = contentType || this.guessContentType(name);
258
306
  const storage = await this.storageFor(collectionId);
307
+ // Sealed here too, if the collection says so.
308
+ //
309
+ // This path wrote straight to the bucket and recorded the item with no `encryption`,
310
+ // never asking — so a server-side write put a READABLE file in a collection someone had
311
+ // set up to be encrypted, and stamped it as unencrypted so the read path served it back
312
+ // happily and nothing ever said otherwise. Once the drive started sealing uploads, this
313
+ // was the only remaining way to get plaintext into an encrypted bucket.
314
+ const sealing = await this.#sealingFor(collectionId, name, ct);
315
+ let toStore = body;
316
+ let encryption = null;
317
+ let plaintextSize = null;
318
+ if (sealing) {
319
+ // Buffered, unlike the upload path, which streams. This is the convenience write for
320
+ // things already resident — a sidecar, a test fixture, an in-process import — and the
321
+ // envelope needs the plaintext size before it can write its header.
322
+ const plain = await bytesOfBody(body);
323
+ plaintextSize = plain.length;
324
+ toStore = await encrypt(sealing.key, plain, {
325
+ fingerprint: sealing.fingerprint, chunkSize: sealing.chunkSize,
326
+ });
327
+ encryption = { fingerprint: toHex(sealing.fingerprint), chunkSize: sealing.chunkSize };
328
+ }
259
329
  let info;
260
330
  try {
261
- info = await storage.put(storageKey, body, { contentType: ct, signal });
331
+ info = await storage.put(storageKey, toStore, { contentType: ct, signal });
262
332
  } catch (err) {
263
333
  // A write that failed for lack of room is a standing condition, not one bad
264
334
  // request: the next upload will fail the same way. Record it so it is visible
@@ -266,7 +336,11 @@ export class Vfs {
266
336
  if (isOutOfSpace(err)) await this.storageUsage(collectionId).catch(() => {});
267
337
  throw err;
268
338
  }
269
- const node = await this.#upsertItem({ collectionId, name, storageKey, size: info.size, contentType: ct, etag: info.etag });
339
+ // The size the user sees is the FILE's, not the envelope's.
340
+ const node = await this.#upsertItem({
341
+ collectionId, name, storageKey, size: plaintextSize ?? info.size, contentType: ct,
342
+ etag: info.etag, encryption,
343
+ });
270
344
  // Small server-side writes index synchronously (search is ready on return);
271
345
  // large client uploads (completeUpload) index in the background instead.
272
346
  await this.indexing.indexNode(node).catch((e) => console.error('index error', e));
@@ -0,0 +1,152 @@
1
+ // External policy evaluation — the drive's ACL, answering an identity provider.
2
+ //
3
+ // WHAT THIS IS FOR
4
+ //
5
+ // Cloudflare Access can call out to a service mid-login and ask "should this person be let
6
+ // in?". Point it here and the answer comes from the collection ACLs: whoever has read on
7
+ // at least one collection gets through the front door, and nobody else reaches the drive at
8
+ // all. Access stays the single place you edit — the administration screen — and it now
9
+ // governs two things instead of one:
10
+ //
11
+ // the EDGE who gets past Cloudflare, decided here
12
+ // the BUCKET what they may do once inside, decided by the same ACL, as it always was
13
+ //
14
+ // The second half is unchanged and still authoritative. This does not replace an internal
15
+ // check anywhere; letting someone through the door does not grant them a collection. If
16
+ // this component is switched off, nothing about the drive's own guarding changes.
17
+ //
18
+ // OPTIONAL, and off unless configured. Mounted exactly like a notification channel — the
19
+ // component contributes routes and `createServer` adds them — so a drive that has not
20
+ // configured it has no `/api/access/*` at all rather than endpoints that answer "no".
21
+ //
22
+ // SETTING IT UP
23
+ //
24
+ // 1. Make a key pair. It is asymmetric because Cloudflare must be able to CHECK our
25
+ // answer without being able to MINT one — and it is RSA because Access says so:
26
+ // "other key formats are not supported", and its reference implementation signs
27
+ // RS256. An EC key here is silently rejected by the only thing that reads these.
28
+ //
29
+ // node -e "crypto.subtle.generateKey({name:'RSASSA-PKCS1-v1_5',modulusLength:2048,
30
+ // publicExponent:new Uint8Array([1,0,1]),hash:'SHA-256'},true,['sign','verify'])
31
+ // .then(k=>crypto.subtle.exportKey('jwk',k.privateKey)).then(j=>console.log(JSON.stringify(j)))"
32
+ //
33
+ // 2. TROVE_ACCESS_EVAL_KEY=<that JSON> (and TROVE_CF_ACCESS_TEAM if not already set)
34
+ // 3. In the Access policy, add an External Evaluation rule:
35
+ // Evaluate URL https://<your drive>/api/access/evaluate
36
+ // Keys URL https://<your drive>/api/access/keys
37
+ //
38
+ // WHY IT REFUSES TO RUN UNVERIFIED
39
+ //
40
+ // This endpoint answers "does this email have access to this drive". That is a question
41
+ // worth lying to strangers about, so it will not answer one it cannot attribute: the
42
+ // caller's assertion must verify against the configured team's JWKS. Without a team
43
+ // configured the component declines to mount rather than mounting an open oracle that
44
+ // enumerates your users to anyone who can reach it.
45
+ //
46
+ // THE TRANSPORT, CHECKED AGAINST CLOUDFLARE (Aug 2026)
47
+ //
48
+ // Verified against the External Evaluation docs and their reference Worker:
49
+ //
50
+ // in POST, JSON body `{ token }`, the JWT signed by the Access account key
51
+ // out JSON body `{ token }`, a JWT of `{ success, iat, exp, nonce }`, RS256
52
+ // keys `{ keys: [ <public JWK, with kid> ] }`
53
+ //
54
+ // The identity lands at `claims.identity.email` in their example rather than at the top
55
+ // level, which is why `principalOf` looks in both places. `parseAssertion` stays generous
56
+ // about where it finds the incoming JWT: the token is self-authenticating, so accepting it
57
+ // from more than one place costs nothing and survives a small change on their side.
58
+
59
+ import { TroveError, verifyJwt, signJwt, publicJwkOf, JwksClient } from '@3sln/trove/core';
60
+
61
+ /** Where Cloudflare publishes the keys for a team's own assertions. */
62
+ const teamJwksUrl = (team) => `https://${team}.cloudflareaccess.com/cdn-cgi/access/certs`;
63
+
64
+ /**
65
+ * Find the assertion in whatever shape it arrived.
66
+ *
67
+ * Generous on purpose — see the header. The JWT is the only thing that matters and it is
68
+ * self-authenticating, so accepting it from several places costs nothing: a forgery is
69
+ * still a forgery wherever it was found.
70
+ */
71
+ export async function parseAssertion(req) {
72
+ const header = req.headers?.get?.('cf-access-jwt-assertion');
73
+ if (header) return header;
74
+ const text = await req.text();
75
+ if (!text) return null;
76
+ try {
77
+ const body = JSON.parse(text);
78
+ return body.token || body.jwt || body.assertion || null;
79
+ } catch {
80
+ // Not JSON: some callers post the bare token.
81
+ return text.trim() || null;
82
+ }
83
+ }
84
+
85
+ /** The identity Cloudflare is asking about, as a principal this drive understands. */
86
+ export function principalOf(claims) {
87
+ const email = claims?.email || claims?.identity?.email || claims?.sub || null;
88
+ if (!email) return null;
89
+ return { id: email, email, name: claims?.name || email, roles: claims?.groups || claims?.roles || [] };
90
+ }
91
+
92
+ /**
93
+ * An external-evaluation component, or null when this drive has not configured one.
94
+ *
95
+ * @param {object} cfg
96
+ * @param {object} cfg.privateJwk EC P-256 private key, the one whose public half we publish
97
+ * @param {string} cfg.team Cloudflare Access team, for verifying the caller
98
+ * @param {string} [cfg.kid] key id, so the key can be rotated without an outage
99
+ */
100
+ export function externalEvaluation({ privateJwk, team, kid = 'trove-access', jwks, now = Date.now } = {}) {
101
+ if (!privateJwk) return null;
102
+ // Refusing rather than warning: see the header. An oracle nobody authenticated is worse
103
+ // than no oracle.
104
+ if (!team && !jwks) {
105
+ throw TroveError.invalid(
106
+ 'External evaluation needs a Cloudflare Access team to verify callers against — set TROVE_CF_ACCESS_TEAM',
107
+ );
108
+ }
109
+ const keys = jwks || new JwksClient(teamJwksUrl(team));
110
+
111
+ return {
112
+ name: 'cloudflare-external-evaluation',
113
+ routes() {
114
+ return [
115
+ {
116
+ method: 'POST',
117
+ path: '/api/access/evaluate',
118
+ deps: ['collections'],
119
+ async handler(ctx) {
120
+ const token = await parseAssertion(ctx.req);
121
+ if (!token) throw TroveError.invalid('No access assertion in the request');
122
+ // Verified before it is read. Everything below trusts these claims, so this
123
+ // line is the whole security of the component.
124
+ const claims = await verifyJwt(token, { jwks: keys, now: now() });
125
+ const principal = principalOf(claims);
126
+
127
+ // No identity is a "no", not an error: Access is asking about somebody and we
128
+ // cannot say yes about somebody we cannot name.
129
+ const decision = principal && ctx.collections
130
+ ? await ctx.collections.accessFor(principal)
131
+ : { allowed: false, admin: false, collections: [] };
132
+
133
+ const answer = await signJwt(
134
+ { success: !!decision.allowed, nonce: claims.nonce, email: principal?.email || null },
135
+ { privateJwk, kid, now: now() },
136
+ );
137
+ return { token: answer };
138
+ },
139
+ },
140
+ {
141
+ method: 'GET',
142
+ path: '/api/access/keys',
143
+ deps: [],
144
+ // Public by design: it is a public key, and Cloudflare fetches it unauthenticated.
145
+ handler() {
146
+ return { keys: [publicJwkOf(privateJwk, { kid })] };
147
+ },
148
+ },
149
+ ];
150
+ },
151
+ };
152
+ }
@@ -23,6 +23,7 @@ import { storageRegistry } from './engine/providers/core.js';
23
23
  import { createMcpHandler } from './mcp/index.js';
24
24
  import { cacheControlFor } from './cachePolicy.js';
25
25
  import { MANIFEST_PATH, webManifest, manifestFromEnv } from './manifest.js';
26
+ import { externalEvaluation } from './access/externalEvaluation.js';
26
27
 
27
28
  // Every backend is pluggable. Each field of `config` accepts EITHER a ready
28
29
  // provider instance (pass your own class) OR a `{ driver, ... }` config object
@@ -325,6 +326,17 @@ export async function createServer(config = {}) {
325
326
  }
326
327
  }
327
328
 
329
+ // The external policy component, on the same terms: it contributes routes or it does not
330
+ // exist. A drive that has not configured one has no `/api/access/*` at all, rather than
331
+ // endpoints that exist to answer "no" — which is the difference between a feature that is
332
+ // off and a feature that is broken.
333
+ const accessPolicy = config.accessEvaluation
334
+ ? externalEvaluation({ ...config.accessEvaluation, team: config.accessEvaluation.team || config.identity?.access?.team })
335
+ : null;
336
+ for (const route of accessPolicy?.routes?.(routeHelpers) || []) {
337
+ router.add(route.method, route.path, route.deps || [], route.handler);
338
+ }
339
+
328
340
  // Said at boot, because that is when someone is looking and can still fix it. The
329
341
  // alternative is discovering it from a client that can't sign in and a 401 that
330
342
  // doesn't say why.
@@ -850,6 +862,22 @@ export function configFromEnv(env = (typeof process !== 'undefined' ? process.en
850
862
  }
851
863
 
852
864
  // Cross-origin API access is off unless an origin (or '*') is configured.
865
+ // External policy evaluation: present only when a signing key is. See
866
+ // access/externalEvaluation.js for how to make one.
867
+ if (env.TROVE_ACCESS_EVAL_KEY) {
868
+ let privateJwk;
869
+ try {
870
+ privateJwk = JSON.parse(env.TROVE_ACCESS_EVAL_KEY);
871
+ } catch {
872
+ throw TroveError.invalid('TROVE_ACCESS_EVAL_KEY must be a private JWK as JSON');
873
+ }
874
+ config.accessEvaluation = {
875
+ privateJwk,
876
+ team: env.TROVE_CF_ACCESS_TEAM || null,
877
+ kid: env.TROVE_ACCESS_EVAL_KID || 'trove-access',
878
+ };
879
+ }
880
+
853
881
  config.corsOrigin = env.TROVE_CORS_ORIGIN || null;
854
882
  // App-shell CSP is opt-in (see SAMPLE_CSP) — provide a full policy string to
855
883
  // enable it. Off by default because sandboxed plugin iframes can't satisfy one.
@@ -272,6 +272,27 @@ export function createRouter() {
272
272
  return collections.remove(params.id, principal);
273
273
  });
274
274
 
275
+ /**
276
+ * Who has access to this collection.
277
+ *
278
+ * A separate route rather than a field on `describe()`, which is documented as "a safe,
279
+ * principal-scoped view (no secrets)" and is returned to everyone who can see the
280
+ * collection at all. The ACL is not a secret exactly, but it names people, and every
281
+ * listing would carry it. Asking for it is an explicitly admin-gated act.
282
+ */
283
+ r.get('/api/collections/:id/grants', ['collections'], async (ctx) => {
284
+ requireCollections(ctx);
285
+ const c = await ctx.collections.assert(ctx.principal, ctx.params.id, 'admin');
286
+ // Drive administrators come from the DEPLOYMENT (TROVE_ADMINS), not from this ACL, and
287
+ // returning them alongside rather than inside `grants` is the honest shape: they hold
288
+ // admin on every collection, `setGrant` cannot touch them, and a UI that listed them as
289
+ // grants would offer a revoke button that silently does nothing.
290
+ return {
291
+ grants: c.acl?.grants || [],
292
+ admins: [...(ctx.collections.admins || [])],
293
+ };
294
+ });
295
+
275
296
  r.post('/api/collections/:id/grants', ['collections'], async (ctx) => {
276
297
  requireCollections(ctx);
277
298
  return { collection: await ctx.collections.setGrant(ctx.params.id, await body(ctx.req), ctx.principal) };