// The storage views an extension receives on its host. Each is the server's // own port behind a check on the record collection: an extension declares // the collections it owns, and a call that names any other collection is // refused with a rejected promise. Methods that name no collection (blocks, // blobs, the event log, the account's own fields) pass through unchanged. /** * The collection an AT-URI names: the segment before the record key, which * holds for a public record (`at://did/collection/rkey`) and for a record * in a space (`at://did/space/type/skey/did/collection/rkey`) alike. * @param {string} uri * @returns {string} */ export function collectionOfUri(uri) { const segments = uri.replace(/^at:\/\//, '').split('/'); return segments.length >= 3 ? segments[segments.length - 2] : ''; } /** * @typedef {Object} CollectionScope * @property {(collection: string) => boolean} allows * @property {(collection: string) => void} assert - throws when not allowed * @property {() => void} assertAll - throws unless every collection is allowed */ /** * A scope over the collections an extension declares. The list is read on * every check, so a factory may run before its extension exists. * @param {() => string} name - the extension's name, for the error * @param {() => string[]|undefined} declared - `['*']` allows every collection; absent allows none * @returns {CollectionScope} */ export function createCollectionScope(name, declared) { const allows = (/** @type {string} */ collection) => { const list = declared() ?? []; return list.includes('*') || list.includes(collection); }; const refuse = (/** @type {string} */ what) => { const list = declared(); const holds = list?.length ? `declares ${list.join(', ')}` : 'declares no collections'; throw new Error(`Extension ${name()} may not touch ${what}: it ${holds}`); }; return { allows, assert(collection) { if (!allows(collection)) refuse(collection); }, assertAll() { if (!allows('*')) refuse('every collection'); }, }; } /** * The actor storage port behind the scope. Record reads and writes check * the collection they name; everything else passes through. * @param {import('./ports.js').ActorStoragePort} storage * @param {CollectionScope} scope * @returns {import('./ports.js').ActorStoragePort} */ export function scopeActorStorage(storage, scope) { /** @type {Partial} */ const guarded = { getRecord: async (uri) => { scope.assert(collectionOfUri(uri)); return storage.getRecord(uri); }, listRecords: async (collection, cursor, limit, reverse) => { scope.assert(collection); return storage.listRecords(collection, cursor, limit, reverse); }, listAllRecords: async () => { scope.assertAll(); return storage.listAllRecords(); }, putRecord: async (uri, cid, collection, rkey, value) => { scope.assert(collection); return storage.putRecord(uri, cid, collection, rkey, value); }, deleteRecord: async (uri) => { scope.assert(collectionOfUri(uri)); return storage.deleteRecord(uri); }, linkBlobToRecord: async (cid, uri, time) => { scope.assert(collectionOfUri(uri)); return storage.linkBlobToRecord(cid, uri, time); }, unlinkBlobsFromRecord: async (uri) => { scope.assert(collectionOfUri(uri)); return storage.unlinkBlobsFromRecord(uri); }, }; return passThrough(storage, guarded); } /** * The space storage port behind the scope: record reads, listings and * commits check their collection; space rows, members and repo state pass * through. * @param {import('./ports.js').SpaceStoragePort} storage * @param {CollectionScope} scope * @returns {import('./ports.js').SpaceStoragePort} */ export function scopeSpaceStorage(storage, scope) { /** @type {Partial} */ const guarded = { getSpaceRecord: async (space, collection, rkey) => { scope.assert(collection); return storage.getSpaceRecord(space, collection, rkey); }, getSpaceRecordCid: async (space, collection, rkey) => { scope.assert(collection); return storage.getSpaceRecordCid(space, collection, rkey); }, listSpaceRecords: async (space, opts) => { if (opts.collection) scope.assert(opts.collection); else scope.assertAll(); return storage.listSpaceRecords(space, opts); }, listAllSpaceRecords: async (space) => { scope.assertAll(); return storage.listAllSpaceRecords(space); }, commitSpaceWrite: async (space, commit) => { for (const write of commit.writes) scope.assert(write.collection); return storage.commitSpaceWrite(space, commit); }, }; return passThrough(storage, guarded); } /** * The space browser behind the scope: a collection listing checks its * collection, the whole-account listing needs every collection, and the * space and member views pass through. * @param {import('./ports.js').SpaceBrowserPort} browser * @param {CollectionScope} scope * @returns {import('./ports.js').SpaceBrowserPort} */ export function scopeSpaceBrowser(browser, scope) { return { listSpaces: () => browser.listSpaces(), countRecords: (uri) => browser.countRecords(uri), countMembers: (uri) => browser.countMembers(uri), listMembers: (uri) => browser.listMembers(uri), listRecords: async (uri, collection, limit) => { scope.assert(collection); return browser.listRecords(uri, collection, limit); }, listAllRecords: async () => { scope.assertAll(); return browser.listAllRecords(); }, }; } /** Headers that carry a caller's credentials. */ const CREDENTIAL_HEADERS = ['authorization', 'cookie', 'dpop']; /** * The request without its credentials, so a self-addressed call an * extension makes is anonymous whatever request it was built from. * @param {Request} request * @returns {Request} */ export function anonymousRequest(request) { if (!CREDENTIAL_HEADERS.some((name) => request.headers.has(name))) { return request; } const headers = new Headers(request.headers); for (const name of CREDENTIAL_HEADERS) headers.delete(name); return new Request(request, { headers }); } /** * The commit function behind the scope: every op must name a declared * collection, the same check a record write passes. * @param {import('./ports.js').ExtensionHost['createCommit']} createCommit * @param {CollectionScope} scope * @returns {import('./ports.js').ExtensionHost['createCommit']} */ export function scopeCommits(createCommit, scope) { return async (did, ops) => { for (const op of ops) scope.assert(collectionOfUri(op.uri)); return createCommit(did, ops); }; } /** * `guarded` in front of `target`: a guarded method answers, any other * member reads from the target with methods bound to it. * @template {object} T * @param {T} target * @param {Partial} guarded * @returns {T} */ function passThrough(target, guarded) { return new Proxy(target, { get(object, property, receiver) { if (property in guarded) { return /** @type {Record} */ (guarded)[property]; } const value = Reflect.get(object, property, receiver); return typeof value === 'function' ? value.bind(object) : value; }, }); }