~ knotless Search/
↑↓ move↵ open⇧↵ all results
0
0
main
177 lines · 9.0 KB Raw History
// 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: `<handle>/<rkey>` of an at:// URI (`<did>/<rkey>` 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
}

/** `<handle|did>/<rkey>` or `at://<handle|did>/<collection>/<rkey>`, optionally `@<cid>`. */
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 <handle>/<name> or at://<did>/${collection}/<name>`)
	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]))