// Reads, checks and writes compositions and bindings. No DOM: main.js draws, the tests call this. import { Lexicons } from '@atproto/lexicon' import { bindParameters, loadComposition } from '@publicdomainrelay/atlibs/compose/index.js' import { sessionXrpc } from '@publicdomainrelay/atlibs/run-module/host-call.js' import { loadModule, modulePin, pdsOf } from '@publicdomainrelay/atlibs/run-module/index.js' export const COMPOSITION = 'com.fedfork.exec.composition' export const BINDING = 'com.fedfork.exec.binding' export const MODULE = 'com.fedfork.exec.wasm.module' const CLIENT = 'com.fedfork.exec.oauth.clientMetadata' const KNOTLESS_REPO = 'dev.knotless.repo' const ctx = { http: { fetch: (...args) => fetch(...args) } } const lexicons = new Lexicons( await Promise.all( ['com/fedfork/exec/composition', 'com/fedfork/exec/binding', 'com/atproto/repo/strongRef'].map((p) => fetch(new URL(`lexicons/${p}.json`, import.meta.url)).then((r) => r.json())), ), ) const memo = (fn) => { const cache = new Map() return (key) => { if (!cache.has(key)) cache.set(key, fn(key).catch((e) => (cache.delete(key), Promise.reject(e)))) return cache.get(key) } } // A DID's document ({} if none), fetched where pdsOf fetches it. const didDoc = memo(async (did) => { let doc = {} const capture = async (url) => { const r = await fetch(url) if (r.ok) doc = await r.clone().json() return r } await pdsOf({ http: { fetch: capture } }, did).catch(() => {}) return doc }) const claims = async (did, handle) => ((await didDoc(did)).alsoKnownAs ?? []).some((a) => a.toLowerCase() === `at://${handle.toLowerCase()}`) export const resolveDid = memo(async (handle) => { if (handle.startsWith('did:')) return handle const r = await fetch(`https://bsky.social/xrpc/com.atproto.identity.resolveHandle?${new URLSearchParams({ handle })}`) if (!r.ok) throw new Error(`cannot resolve ${handle}`) const { did } = await r.json() if (!(await claims(did, handle))) throw new Error(`${handle}: ${did} does not claim it`) return did }) // The DID's handle, only when verified both ways (alsoKnownAs, and resolveHandle back to the DID). const handleOf = memo(async (did) => { const handle = (await didDoc(did)).alsoKnownAs?.find((a) => a.startsWith('at://'))?.slice(5) return handle && (await resolveDid(handle).catch(() => null)) === did ? handle : null }) /** For display: `/` of an at:// URI (`/` without a verified handle); anything else as is. */ export async function label(uri) { const m = /^at:\/\/([^/]+)\/[^/]+\/([^/]+)$/.exec(uri) return m ? `${(await handleOf(m[1])) ?? m[1]}/${m[2]}` : uri } /** `/` or `at:////`, optionally `@`. */ export function parseRef(ref, collection) { const m = /^(at:\/\/)?@?([^/\s@]+)\/(?:([^/\s]+)\/)?([^/\s@]+)(?:@([a-z0-9]+))?$/.exec(ref.trim()) if (!m || (m[3] && !m[1])) throw new Error(`${ref}: expected / or at:///${collection}/`) if (m[3] && m[3] !== collection) throw new Error(`${ref} is not a ${collection}`) return { authority: m[2], collection, rkey: m[4], cid: m[5] } } export async function getRecord({ did, collection, rkey, cid }) { const pds = await pdsOf(ctx, did) const r = await fetch(`${pds}/xrpc/com.atproto.repo.getRecord?${new URLSearchParams({ repo: did, collection, rkey, ...(cid && { cid }) })}`) if (!r.ok) throw new Error(`${await label(`at://${did}/${collection}/${rkey}`)}${cid ? `@${cid}` : ''} not found`) const got = await r.json() if (cid && got.cid !== cid) throw new Error(`${await label(got.uri)} is at ${got.cid}, not ${cid}`) return got } /** A published composition: { did, rkey, uri, cid, value }. */ export async function openComposition(ref) { const { authority, rkey, cid } = parseRef(ref, COMPOSITION) const did = await resolveDid(authority) return { did, rkey, ...(await getRecord({ did, collection: COMPOSITION, rkey, cid })) } } const loadCached = memo((uri) => loadModule(ctx, uri)) /** A module by ref, for a new instance: { uri, pin }. */ export async function moduleRef(ref) { const { authority, rkey } = parseRef(ref, MODULE) const uri = `at://${await resolveDid(authority)}/${MODULE}/${rkey}` return { uri, pin: await modulePin(await loadCached(uri)) } } // A module key is a record of the composition's publisher (base). export function absolute(module, base) { if (module.startsWith('at://')) return module if (!base) throw new Error(`module ${module}: a key needs a publisher (open a published composition, or sign in), or use at://`) return `at://${base}/${MODULE}/${module}` } /** Throws the first problem: the lexicon's, then the load checks both hosts make (pins, hooks, parameters). */ export async function check(manifest, base) { lexicons.assertValidRecord(COMPOSITION, manifest) return loadComposition(ctx, manifest, { load: (module) => loadCached(absolute(module, base)) }) } const scopeOf = memo(async (key) => { const [did, session] = key.split(' ') return (await getRecord({ did, collection: CLIENT, rkey: session })).value.clientMetadata?.scope ?? '' }) /** What a loaded composition holds, as rows, and the union of its instances' session scopes. */ export async function summarize({ manifest, modules }, original) { const instances = await Promise.all( manifest.instances.map(async (inst) => { const mod = modules[inst.name] const session = inst.session ?? mod.session const did = /^at:\/\/([^/]+)\//.exec(mod.id ?? '')?.[1] const scope = session && did ? await scopeOf(`${did} ${session}`).catch((e) => `(${e.message})`) : '' return { name: inst.name, module: await label(mod.id ?? inst.module), pin: inst.pin ?? '', session: session ?? '', scope, added: !original.instances.some((x) => x.name === inst.name) } }), ) const edges = manifest.edges.map((e, i) => ({ from: e.from, nsid: e.nsid, to: e.handler ? `handler ${e.handler}` : `${e.to}#${modules[e.to].implements?.find((i) => i.nsid === e.nsid)?.entry}`, policies: [...(e.policies ?? []), ...(manifest.instances.find((x) => x.name === e.from)?.policies ?? [])].map((p) => `${p.instance}#${p.entry ?? 'evaluate'}`).join(' '), reason: e.reason, added: i >= (original.edges ?? []).length, })) const hooks = manifest.instances.flatMap((inst) => (modules[inst.name].hooks ?? []).map((h) => ({ instance: inst.name, nsid: h.nsid, required: h.required ? 'yes' : '', default: h.default ?? '', combine: h.combine ?? '', routes: manifest.edges.filter((e) => e.from === inst.name && e.nsid === h.nsid).length, })), ) const parameters = (manifest.parameters ?? []).map((p) => ({ key: p.key, type: p.type ?? 'string', required: p.required ? 'yes' : '', secret: p.secret ? 'yes' : '', default: JSON.stringify(p.values?.default) ?? '', enum: p.values?.enum ? JSON.stringify(p.values.enum) : '' })) const scope = [...new Set(instances.flatMap((i) => (i.scope.startsWith('(') ? [] : i.scope.split(/\s+/)).filter(Boolean)))].join(' ') return { instances, edges, hooks, parameters, exports: manifest.exports ?? [], scope } } /** A copy whose module keys are at:// URIs of `base`, for publishing someone else's composition as yours. */ export const withAbsoluteModules = (manifest, base) => ({ ...manifest, instances: manifest.instances.map((inst) => ({ ...inst, module: absolute(inst.module, base) })) }) /** putRecord as the session's account; swap: the CID the record must still be at. */ export async function putRecord(agent, collection, rkey, record, swap) { return sessionXrpc(agent)('com.atproto.repo.putRecord', { input: { repo: agent.did, collection, rkey, record, ...(swap && { swapRecord: swap }) } }) } export const deleteRecord = (agent, collection, rkey) => sessionXrpc(agent)('com.atproto.repo.deleteRecord', { input: { repo: agent.did, collection, rkey } }) /** The binding's rkey: `self`, or the knotless repository's key (its dev.knotless.repo record, by name). */ export async function repoKey(did, name) { if (name === 'self') return 'self' return (await getRecord({ did, collection: KNOTLESS_REPO, rkey: name })).value.key } /** A com.fedfork.exec.binding for a published composition ({ uri, cid, value }), checked as a host binds it. */ export function binding(composition, inputs = {}) { const params = composition.value.parameters ?? [] const secrets = params.filter((p) => p.secret === true).map((p) => p.key) const host = {} JSON.stringify(composition.value.instances, (k, v) => (k === '$host' && (host[v] = null), v)) // secrets come from the host at run time: check only the inputs here bindParameters({ ...composition.value, parameters: params.map((p) => (p.secret ? { ...p, required: false } : p)) }, { inputs, host }) const record = { $type: BINDING, composition: { uri: composition.uri, cid: composition.cid }, inputs, ...(secrets.length && { secrets }), createdAt: new Date().toISOString() } lexicons.assertValidRecord(BINDING, record) return record } /** Parameter defaults, for a binding's inputs to start from. */ export const defaults = (manifest) => Object.fromEntries((manifest.parameters ?? []).filter((p) => !p.secret && p.values && 'default' in p.values).map((p) => [p.key, p.values.default]))