1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
// 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]))