chadtmiller.com/pds.js
An atproto PDS in JavaScript, for Cloudflare Workers and Node
0
0
// 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<import('./ports.js').ActorStoragePort>} */
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<import('./ports.js').SpaceStoragePort>} */
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<T>} guarded
* @returns {T}
*/
function passThrough(target, guarded) {
return new Proxy(target, {
get(object, property, receiver) {
if (property in guarded) {
return /** @type {Record<PropertyKey, unknown>} */ (guarded)[property];
}
const value = Reflect.get(object, property, receiver);
return typeof value === 'function' ? value.bind(object) : value;
},
});
}