// @pdsjs/sites - server-side app installs // // Fulfills core's SiteInstallerPort: the account page's install sheet and // install button run through this. The reads mirror the CLI's; the writes // go straight to the ports instead of over XRPC, and every copied blob is // hashed here, so integrity does not rest on a remote server's answer. import { cborDecode, cidToString, createBlobCid, createTid, findBlobRefs, toDisplayValue, } from '@pdsjs/core/repo'; import { isValidSiteName } from './hostname.js'; import { planInstall, settingsSchema } from './install.js'; import { QUERY_COLLECTION, SITE_COLLECTION } from './lexicons.js'; const INSTALL_COLLECTION = 'dev.pdsjs.app.install'; /** Upper bound on one installed file, matching the deploy CLI's default. */ const BLOB_LIMIT = 5 * 1024 * 1024; /** * @typedef {Object} SiteInstallerContext * @property {import('@pdsjs/core/ports').ActorStoragePort} actorStorage * @property {import('@pdsjs/core/ports').BlobPort} blobs * @property {() => Promise} getDid * @property {(did: string, ops: Array<{action: string, uri: string, cid?: string}>) => Promise} createCommit * @property {(cid: string, uri: string, time?: number) => Promise} linkBlob * @property {(record: Record) => Promise<{cid: string, bytes: Uint8Array}>} encodeRecord - DAG-CBOR encode plus CID, injected from core's repo helpers by the platform * @property {typeof fetch} [fetchImpl] */ /** * Resolve a manifest at-uri to the developer's DID, PDS, and record key. * @param {string} uri * @param {typeof fetch} fetchImpl */ async function resolveManifestUri(uri, fetchImpl) { const match = uri.match( /^at:\/\/([^/]+)\/dev\.pdsjs\.app\.manifest\/([^/]+)$/, ); if (!match) { throw new Error( 'Give the app as at:///dev.pdsjs.app.manifest/', ); } let did = match[1]; if (!did.startsWith('did:')) { const resolved = await fetchImpl( `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(did)}`, ); if (!resolved.ok) throw new Error(`Could not resolve handle ${did}.`); did = (await resolved.json()).did; } const doc = await fetchImpl(`https://plc.directory/${did}`); if (!doc.ok) throw new Error(`Could not resolve ${did}.`); const services = (await doc.json()).service || []; const pds = services.find( (/** @type {{id?: string, type?: string}} */ s) => s.id === '#atproto_pds' || s.type === 'AtprotoPersonalDataServer', )?.serviceEndpoint; if (!pds) throw new Error(`${did} names no PDS in its document.`); return { did, pds: pds.replace(/\/$/, ''), rkey: match[2] }; } /** * Build the installer. * @param {SiteInstallerContext} ctx * @returns {import('./ports.js').SiteInstallerPort} */ export function createSiteInstaller(ctx) { const { actorStorage, blobs, getDid, createCommit, linkBlob, encodeRecord } = ctx; for (const [name, value] of Object.entries({ actorStorage, blobs, getDid, createCommit, linkBlob, encodeRecord, })) { if (!value) throw new Error(`createSiteInstaller requires ${name}`); } const fetchImpl = ctx.fetchImpl || fetch; /** @param {string} pds @param {string} path */ const remote = async (pds, path) => { const response = await fetchImpl(`${pds}/xrpc/${path}`); if (!response.ok) { throw new Error( `The developer's PDS answered ${response.status} for ${path.split('?')[0]}.`, ); } return response; }; /** @param {string} uri */ async function fetchManifestAndSite(uri) { const dev = await resolveManifestUri(uri, fetchImpl); const manifest = await ( await remote( dev.pds, `com.atproto.repo.getRecord?repo=${encodeURIComponent(dev.did)}&collection=dev.pdsjs.app.manifest&rkey=${encodeURIComponent(dev.rkey)}`, ) ).json(); const siteUri = manifest.value?.site?.uri; if (typeof siteUri !== 'string') { throw new Error('The manifest names no site.'); } const siteRkey = siteUri.split('/').pop() || ''; const site = await ( await remote( dev.pds, `com.atproto.repo.getRecord?repo=${encodeURIComponent(dev.did)}&collection=${SITE_COLLECTION}&rkey=${encodeURIComponent(siteRkey)}`, ) ).json(); return { dev, manifest, site }; } /** * The current install record pinning this manifest, if one exists. * @param {string} _did @param {string} manifestUri */ async function existingInstall(_did, manifestUri) { const page = await actorStorage.listRecords(INSTALL_COLLECTION, null, 100); for (const record of page.records) { const value = /** @type {{manifest?: {uri?: string}, config?: Record, site?: string}} */ ( toDisplayValue( cborDecode( record.value instanceof Uint8Array ? record.value : new Uint8Array(record.value), ), ) ); if (value.manifest?.uri === manifestUri) { return { uri: record.uri, config: value.config, site: typeof value.site === 'string' ? value.site : undefined, }; } } return null; } return { async preview(uri) { const { dev, manifest, site } = await fetchManifestAndSite(uri); const files = Array.isArray(site.value?.files) ? site.value.files : []; let totalSize = 0; for (const file of files) { if (typeof file?.blob?.size === 'number') totalSize += file.blob.size; } let handle = dev.did; try { const described = await ( await remote( dev.pds, `com.atproto.repo.describeRepo?repo=${encodeURIComponent(dev.did)}`, ) ).json(); if (typeof described.handle === 'string') handle = described.handle; } catch {} const iconCid = manifest.value?.icon?.ref?.$link; return { uri: manifest.uri, cid: manifest.cid, name: typeof manifest.value?.name === 'string' ? manifest.value.name : '', description: typeof manifest.value?.description === 'string' ? manifest.value.description : '', author: { did: dev.did, handle }, external: manifest.value?.external === true, scopes: Array.isArray(manifest.value?.scopes) ? manifest.value.scopes : [], settings: settingsSchema(manifest.value?.settings), queries: Array.isArray(manifest.value?.queries) ? manifest.value.queries.length : 0, files: { count: files.length, totalSize }, defaultSite: typeof manifest.value?.suggestedSite === 'string' && isValidSiteName(manifest.value.suggestedSite) ? manifest.value.suggestedSite : site.uri.split('/').pop() || '', iconUrl: iconCid ? `${dev.pds}/xrpc/com.atproto.sync.getBlob?did=${encodeURIComponent(dev.did)}&cid=${iconCid}` : null, pinDrifted: manifest.value?.site?.cid !== site.cid, }; }, async install(uri, options = {}) { const did = await getDid(); if (!did) throw new Error('This PDS is not initialized.'); const { dev, manifest, site } = await fetchManifestAndSite(uri); if ( Array.isArray(manifest.value?.scopes) && manifest.value.scopes.length ) { throw new Error( 'This app requests scopes, which installs cannot grant yet.', ); } const existing = await existingInstall(did, manifest.uri); // An update stays where the user put it: the existing install's name // outranks the manifest's suggestion. const plan = planInstall({ manifest, site, localName: options.site || existing?.site, overrides: options.config, existingConfig: existing?.config, }); // Copy every blob first, hashing each one here: the site record only // lands once all the bytes it names verifiably exist. const toCopy = [...plan.copies]; if (plan.icon) { toCopy.push({ path: 'icon', cid: plan.icon.cid, contentType: plan.icon.contentType, }); } for (const copy of toCopy) { const bytes = new Uint8Array( await ( await remote( dev.pds, `com.atproto.sync.getBlob?did=${encodeURIComponent(dev.did)}&cid=${copy.cid}`, ) ).arrayBuffer(), ); if (bytes.length > BLOB_LIMIT) { throw new Error(`${copy.path} is over the ${BLOB_LIMIT} byte limit.`); } const hashed = cidToString(await createBlobCid(bytes)); if (hashed !== copy.cid) { throw new Error( `${copy.path} hashed to ${hashed}, not the ${copy.cid} the record names. Not installing.`, ); } await actorStorage.putBlob(copy.cid, copy.contentType, bytes.length); await blobs.put(did, copy.cid, bytes, copy.contentType); } /** @type {Array<{action: string, uri: string, cid: string}>} */ const ops = []; /** @param {string} collection @param {string} rkey @param {Record} value @param {string[]} blobCids */ const write = async (collection, rkey, value, blobCids) => { const recordUri = `at://${did}/${collection}/${rkey}`; const { cid, bytes } = await encodeRecord(value); const had = await actorStorage.getRecord(recordUri); if (blobCids.length) { await actorStorage.unlinkBlobsFromRecord(recordUri); } await actorStorage.putBlock(cid, bytes); await actorStorage.putRecord(recordUri, cid, collection, rkey, bytes); for (const blobCid of blobCids) { await linkBlob(blobCid, recordUri, Date.now()); } ops.push({ action: had ? 'update' : 'create', uri: recordUri, cid }); }; await write( SITE_COLLECTION, plan.localName, plan.siteRecord, plan.copies.map((copy) => copy.cid), ); for (const def of plan.queryDefs) { await write(QUERY_COLLECTION, def.rkey, def.record, []); } const installRkey = existing ? existing.uri.split('/').pop() || createTid() : createTid(); await write( INSTALL_COLLECTION, installRkey, plan.installRecord, plan.icon ? [plan.icon.cid] : [], ); await createCommit(did, ops); return { site: plan.localName, name: manifest.value?.name || '' }; }, async rename(installUri, newName) { const did = await getDid(); if (!did) throw new Error('This PDS is not initialized.'); if (!isValidSiteName(newName)) { throw new Error( `"${newName}" cannot name a site: names are lowercase DNS labels.`, ); } const stored = await actorStorage.getRecord(installUri); if (!stored) throw new Error('No such install.'); const installValue = /** @type {Record} */ ( toDisplayValue( cborDecode( stored.value instanceof Uint8Array ? stored.value : new Uint8Array(stored.value), ), ) ); const oldName = installValue.site; if (typeof oldName !== 'string') { throw new Error('This install names no site.'); } if (oldName === newName) return { site: newName }; const oldUri = `at://${did}/${SITE_COLLECTION}/${oldName}`; const newUri = `at://${did}/${SITE_COLLECTION}/${newName}`; if (await actorStorage.getRecord(newUri)) { throw new Error(`A site named "${newName}" already exists.`); } const siteStored = await actorStorage.getRecord(oldUri); if (!siteStored) throw new Error('The installed site record is gone.'); const siteBytes = siteStored.value instanceof Uint8Array ? siteStored.value : new Uint8Array(siteStored.value); // The record moves byte for byte: same CID under the new key, so the // manifest pin and the update check are untouched by a rename. const siteValue = cborDecode(siteBytes); await actorStorage.putBlock(siteStored.cid, siteBytes); await actorStorage.putRecord( newUri, siteStored.cid, SITE_COLLECTION, newName, siteBytes, ); for (const ref of findBlobRefs(siteValue)) { await linkBlob(ref, newUri, Date.now()); } await actorStorage.unlinkBlobsFromRecord(oldUri); await actorStorage.deleteRecord(oldUri); const updatedInstall = { ...installValue, site: newName }; const { cid, bytes } = await encodeRecord(updatedInstall); await actorStorage.putBlock(cid, bytes); await actorStorage.putRecord( installUri, cid, INSTALL_COLLECTION, installUri.split('/').pop() || '', bytes, ); await createCommit(did, [ { action: 'create', uri: newUri, cid: siteStored.cid }, { action: 'delete', uri: oldUri }, { action: 'update', uri: installUri, cid }, ]); return { site: newName }; }, async checkUpdate(uri, pinnedCid) { const dev = await resolveManifestUri(uri, fetchImpl); const manifest = await ( await remote( dev.pds, `com.atproto.repo.getRecord?repo=${encodeURIComponent(dev.did)}&collection=dev.pdsjs.app.manifest&rkey=${encodeURIComponent(dev.rkey)}`, ) ).json(); return { currentCid: manifest.cid, changed: manifest.cid !== pinnedCid, }; }, }; }