// The daemon's RPC surface — newline-delimited JSON over a unix socket.
//
// Deliberately the smallest thing that works. hyper-svc is the ecosystem answer and brings
// transports, auth contexts and a documented half-open socket leak; none of that earns its place
// while the only client is a CLI on the same machine. The socket is owner-gated by the filesystem,
// which is the same trust boundary pgv3's UDS listener relies on.
//
// One frame per line: request `{id, cmd, args}` → response `{id, ok, value}` or `{id, ok:false, error}`.
import net from '#node:net'
import b4a from 'b4a'
import fs from '#node:fs'
import { join } from '#node:path'
import { Peer, LIKE_VALUE } from './peer.js'
import { linkMentions } from './mentions.js'
import { graphOf } from './graph.js'
import { recordName } from './name.js'
// ⛔ ONE ladder, owned by the record vocabulary that defines it (record-vocabulary §3.5).
import { makePointer, AUTHOR, selectLevel, planLevels, derivedBy } from 'favela-markdown/lib/levels.js'
import { available as ffmpegAvailable, probe, encodeLevel } from './transcode.js'
/**
* Serve `dispatch` over a unix socket.
*
* ⛔ `call` IS THE INTERFACE, and it is supplied rather than assumed. `dispatch(node, cmd, a)` is
* what this daemon can do; a COMPOSITION that holds this node alongside something else can do more,
* and it must be able to offer the same set here as it offers over any other adapter. The Android
* worklet already composes exactly that way and has no socket to serve it on (SELinux refuses an
* unprivileged app a `sock_file`); a desktop host wants the same composition WITH a socket, for
* `favela-bridge` and the CLI.
*
* ⚠ This teaches favela-peer nothing about what those extra verbs are, or that any exist. The
* default is unchanged, so every existing caller behaves exactly as before.
*/
export function createServer (node, sockPath, { onError = () => {}, call = null } = {}) {
const handle = call || ((cmd, args) => dispatch(node, cmd, args))
// A stale socket from a crashed daemon would make bind fail with EADDRINUSE and look like
// "already running". Removing it is safe ONLY because the caller has already established that no
// daemon holds it — see index.js, which connects first and refuses to start if one answers.
try { fs.unlinkSync(sockPath) } catch {}
const server = net.createServer((socket) => {
let buf = ''
socket.on('error', onError)
socket.on('data', async (chunk) => {
buf += chunk.toString()
let i
while ((i = buf.indexOf('\n')) !== -1) {
const line = buf.slice(0, i)
buf = buf.slice(i + 1)
if (!line.trim()) continue
let req
try { req = JSON.parse(line) } catch { continue }
let res
try {
res = { id: req.id, ok: true, value: await handle(req.cmd, req.args || {}) }
} catch (err) {
res = { id: req.id, ok: false, error: (err && err.message) || String(err) }
}
try { socket.write(JSON.stringify(res) + '\n') } catch {}
}
})
})
server.listen(sockPath)
return server
}
/**
* Resolve bare `@handle` into real mentions before a body is written.
*
* ⛔ Applied on the WRITE path, for every client. A surface doing it would leave the CLI and any
* other writer without it, and "you can @ someone in this one app" is not the feature.
*
* ⚠ It changes what the author typed, and that is the point — a bare @name is plain text for ever
* otherwise. It is conservative about it: confirmed handles only, prose only, never inside code and
* never inside an existing link.
*/
const mentioned = (node, body) =>
linkMentions(String(body || ''), (token) => node.resolveMention(token))
// Every command is a thin pass-through. Business rules live in Peer and apply — a handler that
// enforced something would be a second place to look for the rule.
// ⛔ A write from an unadmitted device is DROPPED BY APPLY, deterministically and silently. The op
// appends, the CLI gets a name back, and the record never appears anywhere — which is exactly the
// "it returned, so it worked" confusion this codebase refuses at every other layer.
//
// So the socket refuses the write instead. The check is here rather than in Peer because Peer's job
// is to describe the feed, and apply's job is to be the authority; this is the door.
const MUTATIONS = new Set(['post', 'post-delete', 'follow', 'unfollow', 'like', 'unlike', 'reply',
'knock', 'admit', 'dismiss', 'unlink', 'react', 'unreact', 'repost', 'unrepost', 'block', 'unblock',
// ⚠ The namespace ops that SIGN. `namespace-join` is not here: pointing this node at another
// registry needs no credential, and a node that cannot yet sign must still be able to look.
'namespace-request', 'namespace-admit', 'namespace-grant', 'namespace-revoke',
'namespace-invite', 'namespace-uninvite',
// Room ops act through this device's writer core (ROOM-CONTRACT v2): an unlinked device has none.
'room-create', 'room-post', 'room-config', 'room-role', 'room-remove', 'room-status', 'room-invite',
'room-uninvite', 'room-join-invite', 'room-accept', 'room-reject', 'room-add-device', 'room-delete',
'room-flag', 'room-list', 'room-delist', 'dm',
// ⛔ Changing WHERE this identity publishes is an identity act, not a display preference.
// `set-profile` is not here because a self-label asserts nothing; turning on a network puts words
// under this identity somewhere that does not forget, so it requires an ATTESTED device — the
// same gate as posting. A browser cannot do it without one, which is the whole point of the
// browser being a controller rather than a device (see browsers.js).
'set-reach',
// ⛔ Asking a bridge to carry this identity puts a name for it on somebody's domain, which is the
// same class of act as turning a network on — so it needs an ATTESTED device, not merely a
// signed-in browser.
'bridge-claim', 'bridge-release', 'bridge-front'])
/**
* The room, or a refusal that says which of the two possible things is wrong.
*
* ⚠ "not in that room" and "that room does not exist" look identical from outside and are fixed by
* completely different things — one by a key, the other by nothing.
*/
// ⛔ UNDER THE NODE'S OWN STORAGE, never `~/.hyperident`. A phone has no home directory, and a
// daemon that guesses one is the same defect as a binary assumed to be on PATH. The node already
// knows where its state lives, so the vault goes beside it and travels with it.
const vaultPathFor = (node) => join(node.storage, 'identity', 'vault')
const b4aOf = (hex) => b4a.from(hex, 'hex')
function requireRoom (node, key) {
const r = node.room(key)
if (!r) throw new Error('this node is not in room ' + String(key).slice(0, 16) + '… — join it first')
return r
}
async function requireLinked (peer) {
const devices = await peer.feed.devices()
if (devices.includes(peer.feed.deviceKey)) return
throw new Error('this device is not admitted to its own feed, so every write would be silently ' +
'dropped by apply. Run `favela link` to attest it.')
}
export async function dispatch (node, cmd, a) {
const peer = node.peer
if (MUTATIONS.has(cmd)) await requireLinked(peer)
switch (cmd) {
case 'whoami':
return {
feed: node.key.toString('hex'),
device: peer.feed.deviceKey,
writable: peer.feed.writable,
// ⛔ Reported, because "not writable" and "not linked" look identical from outside and are
// fixed by completely different things.
linked: (await peer.feed.devices()).includes(peer.feed.deviceKey)
}
// Admit this device with a chain proof for its writer key. The CLI mints it; the daemon never
// opens the vault itself.
// ⛔ MINT — create this identity, or restore it from a recovery phrase. The counterpart of
// `pair-accept`: pairing needs another device AWAKE, this needs nobody, and until it existed the
// origin of every favela identity sat outside the product in a shell a handset does not have.
//
// ⛔ THE VAULT LIVES UNDER THE NODE'S STORAGE, not `~/.hyperident`. A phone has no home directory,
// and defaulting to one is the same class of bug as assuming a binary is on PATH — the daemon
// already knows where its own state goes, so it says.
//
// ⚠ THE MNEMONIC COMES BACK ONCE AND IS NEVER STORED IN THE CLEAR when a passphrase is given.
// The caller shows it and forgets it; nothing here writes it anywhere but the sealed vault.
// Re-asking is impossible by design, which is what makes "a stolen device yields a locked vault"
// true rather than aspirational.
case 'mint-identity': {
const { mintIdentity } = await import('./mint.js')
const r = await mintIdentity({
vaultPath: vaultPathFor(node),
passphrase: a.passphrase || null,
mnemonic: a.mnemonic || null
})
// Linked immediately: a minted identity that is not attested to this device would leave the node
// holding a vault it cannot write with, which reads as a half-finished ceremony with no way back.
await node.link({
keyPair: {
publicKey: b4aOf(r.credential.publicKey),
secretKey: b4aOf(r.credential.secretKey)
},
proof: r.credential.proof
})
return { mnemonic: r.mnemonic, device: r.credential.publicKey, restored: !!a.mnemonic }
}
case 'link': {
if (!a.credential || !a.credential.publicKey || !a.credential.secretKey || !a.credential.proof) {
throw new Error('link needs { credential: { publicKey, secretKey, proof } }')
}
const b4a = await import('b4a')
return node.link({
keyPair: {
publicKey: b4a.default.from(a.credential.publicKey, 'hex'),
secretKey: b4a.default.from(a.credential.secretKey, 'hex')
},
proof: a.credential.proof
})
}
case 'devices': return {
// { writer, device } pairs — `unlink` takes the DEVICE key, so the listing has to show it.
roster: await peer.feed.roster(),
revoked: await peer.feed.revokedDevices(),
founder: await peer.feed.founder()
}
case 'unlink': { await peer.feed.removeDevice(a.device); await peer.update(); return { revoked: a.device } }
case 'post': {
// ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
// a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
const name = recordName()
await peer.feed.put('post', name, {
frontmatter: { createdAt: new Date().toISOString() },
body: await mentioned(node, a.body)
})
await peer.update()
return { name }
}
// ⛔ DELETE MY OWN post or reply — a TOMBSTONE, like every other undo here and like keet's message
// delete, so every peer and both bridges can tell "deleted" from "not replicated yet". The name is
// looked up in MY feed only: there is no way to name somebody else's record here.
case 'post-delete': {
const name = String(a.name || '')
for (const collection of ['post', 'reply']) {
const r = await peer.feed.resolve(collection, name)
if (r.status === 'deleted') return { deleted: name, collection, already: true }
if (r.pointer) {
await peer.feed.del(collection, name)
await peer.update()
return { deleted: name, collection, already: false }
}
}
throw new Error('no post or reply of yours is named ' + JSON.stringify(name))
}
// My positively-deleted posts and replies — what the bsky projection retracts on (favela-bridge).
case 'deleted-own': {
await peer.update()
return { post: await peer.feed.tombstones('post'), reply: await peer.feed.tombstones('reply') }
}
case 'timeline':
await peer.update()
return withLikes(peer, await peer.timeline({ limit: a.limit || 50 }))
// One author's posts. ⛔ Not a filtered timeline: the timeline excludes peeked feeds, so
// filtering it could never render the page of somebody you had only peeked.
// ⛔ MY OWN edges, with their targets intact — what a bridge needs to project a like or a reply.
// `reactions`/`likes` answer "who reacted to THIS", which is the page's question; this answers
// "what have I reacted TO", which is the projector's.
case 'edges-by': {
await peer.update()
return peer.ownEdges(String(a.collection || ''))
}
case 'posts-by': {
await peer.update()
return withLikes(peer, await peer.postsBy(a.feed))
}
// Do I hold this feed, and is it a follow or a peek? What a person page needs to know which
// buttons to offer.
case 'holding': return node.holding(String(a.feed || ''))
case 'follow': return { feed: await node.follow(a.feed) }
// ⛔ Read someone without following them. Not in MUTATIONS: it writes nothing anywhere, so a
// device that cannot yet write to its own feed can still read a stranger's page.
case 'peek': return node.peek(a.feed)
// ⛔ Stop RENDERING a feed. It writes nothing to theirs and cannot stop them doing anything —
// see peer.js. The surface must say that, or the button promises more than it does.
case 'block': { await peer.block(a.feed); await peer.update(); return { blocked: a.feed } }
case 'unblock': { await peer.unblock(a.feed); await peer.update(); return { unblocked: a.feed } }
case 'blocked': return { blocked: [...await peer.blocked()] }
case 'unfollow': { await node.unfollow(a.feed); return { feed: a.feed } }
case 'following': return peer.following()
case 'missing': return peer.missing()
case 'like': { await peer.like(a.target); await peer.update(); return { ok: true } }
case 'unlike': { await peer.unlike(a.target); await peer.update(); return { ok: true } }
// ⛔ `like` stays its own command rather than becoming `react value=like`. It is the one value
// both target networks can carry, so a caller that means exactly that should be able to say so.
case 'react': { await peer.react(a.target, a.value); await peer.update(); return { ok: true } }
case 'unreact': { await peer.unreact(a.target, a.value); await peer.update(); return { ok: true } }
// Everyone's reactions on ONE target — a scan across held feeds, so it takes a target.
case 'repost': { await peer.repost(a.target); await peer.update(); return { ok: true } }
case 'unrepost': { await peer.unrepost(a.target); await peer.update(); return { ok: true } }
// Posts naming me. ⚠ A scan of what this node holds — see peer.js. It takes no target because
// the only root this node can prove is its own.
// ⛔ A projection, not an inbox. Nothing was delivered to produce this — see peer.js.
case 'notifications': {
await peer.update()
return { notifications: await peer.notifications({ limit: a.limit || 100 }) }
}
// ⛔ A projection of the SUBSTRATE. Its value is diagnostic: four states that currently look
// identical from outside — see graph.js.
case 'graph': { await peer.update(); return graphOf(peer) }
case 'mentions': {
await peer.update()
return withLikes(peer, await peer.myMentions())
}
case 'reposts': {
await peer.update()
return { reposts: (await peer.repostsOf(a.target)).map((r) => ({ author: r.author })) }
}
case 'reactions': {
await peer.update()
const rows = await peer.reactionsOn(a.target)
return { reactions: rows.map((r) => ({ author: r.author, value: r.value })) }
}
case 'likes': return (await peer.likesOn(a.target)).map((r) => view(r))
// Have I liked it? Asked by a page that renders ONE post and has no timeline to stamp.
case 'liked': return { liked: await peer.likedByMe(a.target) }
case 'reply': {
// ⛔ MINTED HERE, NEVER TAKEN FROM THE CALLER. This was `a.name || String(Date.now())`:
// a millisecond clock collides, and accepting `a.name` made a collision CHOOSABLE. See lib/name.js.
const name = recordName()
await peer.reply(a.target, await mentioned(node, a.body), name)
await peer.update()
return { name }
}
case 'replies': return withLikes(peer, await peer.repliesTo(a.target))
case 'search': {
await peer.update()
const r = await peer.search(a.query, { collection: a.collection || null, limit: a.limit || 25 })
// People and posts are different answers to one query, so they are returned apart rather than
// interleaved — a reader searching "stortron" may mean either, and the UI can show both.
return { results: await withLikes(peer, r.results), unreadable: r.unreadable, people: await peer.searchProfiles(a.query) }
}
case 'knocks': return peer.knocks()
case 'knock': { await peer.feed.knock(a.feed, peer._binder()); return { ok: true } }
case 'admit': { await peer.admitKnock(a.feed, await node.hold(a.feed)); await peer.update(); return { ok: true } }
case 'dismiss': { await peer.dismissKnock(a.feed); await peer.update(); return { ok: true } }
case 'directory': return peer.directory()
// ── rooms ───────────────────────────────────────────────────────────────────────────────
//
// ⛔ A room is the unit of scope, audience, governance and history at once (SOCIAL-MODEL §3).
// A feed is the degenerate case of one — which is why these are not "group feeds".
case 'room-create': return node.createRoom()
case 'room-join': return node.joinRoom(a.key)
case 'room-leave': return node.leaveRoom(a.key)
case 'rooms': return { rooms: await node.rooms() }
case 'room': {
const r = requireRoom(node, a.key)
await r.update()
const me = node.rootHex()
const role = me ? await r.roleOf(me) : null
return {
key: a.key,
config: await r.settings(),
members: await r.members(),
// ⚠ Reported, because "I am in this room" and "I am a MEMBER of it" are different facts and
// only the second lets you write. Holding the key gets you the first.
role,
// ⛔ The identity being a member is not THIS device being able to write: a member's second
// device has the role and no bound core until it runs room-add-device.
bound: (await r.devices()).some((d) => d.writer === r.writer),
// Join requests and invitations are what moderators act on; others see none.
requests: role === 'moderator' || role === 'admin' ? await r.requests() : [],
invites: role === 'moderator' || role === 'admin' ? await r.invites() : [],
listings: await r.listings(),
messages: await r.messages()
}
}
// ── ROOM-CONTRACT v2: every op is carried by THIS node's writer core, which is the credential ──
case 'room-post': { const r = requireRoom(node, a.key); const name = await r.post(a.body, node.cred); await r.update(); return { name } }
case 'room-config': { const r = requireRoom(node, a.key); await r.setSetting(a.field, a.value); await r.update(); return { ok: true } }
case 'room-role': { const r = requireRoom(node, a.key); await r.setRole(a.member, a.role); await r.update(); return { ok: true } }
// Removing yourself is leaving (reversible by invitation); a moderator removing you is permanent.
case 'room-remove': { const r = requireRoom(node, a.key); await r.removeMember(a.member || node.rootHex()); await r.update(); return { ok: true } }
case 'room-status': { const r = requireRoom(node, a.key); await r.setStatus(a.member, { muted: a.muted, banned: a.banned }); await r.update(); return { ok: true } }
// ⛔ Joining is by INVITATION (v2). The token carries the invitation's signing secret: hand it over
// a private channel; anyone holding it can join until it is revoked.
case 'room-invite': { const r = requireRoom(node, a.key); const inv = await r.invite({ role: a.role || 'peer', requiresConfirmation: !!a.requiresConfirmation }); await r.update(); return inv }
case 'room-uninvite': { const r = requireRoom(node, a.key); await r.uninvite(a.id); await r.update(); return { ok: true } }
case 'room-join-invite': {
// Opens the room if this node does not hold it yet, then joins on THIS node's own core.
await node.joinRoom(a.key).catch(() => {})
const r = requireRoom(node, a.key)
await r.join(a.token, node.cred)
await r.update()
return { key: a.key, writer: r.writer }
}
case 'room-accept': { const r = requireRoom(node, a.key); await r.acceptJoin(a.writer); await r.update(); return { accepted: a.writer } }
case 'room-reject': { const r = requireRoom(node, a.key); await r.rejectJoin(a.writer); await r.update(); return { rejected: a.writer } }
// A member's other device: binds THIS node's core to the identity already in the room.
case 'room-add-device': { const r = requireRoom(node, a.key); await r.addDevice(node.cred); await r.update(); return { writer: r.writer } }
case 'room-delete': { const r = requireRoom(node, a.key); await r.deleteMessage(a.name); await r.update(); return { ok: true } }
case 'room-flag': { const r = requireRoom(node, a.key); await r.flag(a.target, a.reason); await r.update(); return { ok: true } }
// ⛔ v1 verbs, gone with authority-by-signature. Said, not silently absent.
case 'room-add': case 'room-request': case 'room-admit':
throw new Error(cmd + ' is gone: rooms admit by invitation now (ROOM-CONTRACT v2) — use room-invite, then room-join-invite on the joining node')
// ⛔ A community is a ROOM WHOSE LISTINGS ARE ROOMS — not a type.
case 'room-list': { const r = requireRoom(node, a.key); await r.listRoom(a.room, a.note); await r.update(); return { listed: a.room } }
case 'room-delist': { const r = requireRoom(node, a.key); await r.delistRoom(a.room); await r.update(); return { delisted: a.room } }
// ⛔ A DM is a room with two members. SOCIAL-MODEL §4: a two-member room IS a direct message,
// and there is deliberately no separate DM type — one primitive, used.
case 'dm': {
// ⛔ Founded with both people (Keet `internal.js:57-64`); the other person's node binds its own core
// with room-add-device. No invitation needed: they are a member from the start.
const { key } = await node.createRoom({ initialMembers: [{ root: String(a.member).toLowerCase(), role: 'peer' }] })
const r = node.room(key)
await r.setSetting('title', 'DM')
await r.update()
return { key, member: a.member }
}
// ── browser identities ──────────────────────────────────────────────────────────────────
//
// ⛔ These are reachable over the daemon's UDS, which is owner-gated by the filesystem. Minting
// an invite from here is therefore already "a human at this machine", which is exactly the gate
// a one-time invite is supposed to be.
case 'invite': return node.browsers.invite(a.ttlMs !== undefined ? { ttlMs: a.ttlMs } : {})
case 'browser-challenge': return node.browsers.challenge()
case 'browser-redeem': return node.browsers.redeem(a)
case 'browser-claim': return node.browsers.claim(a)
case 'browser-unclaimed': return { unclaimed: await node.browsers.unclaimed() }
// ── profile ─────────────────────────────────────────────────────────────────────────────
// ⛔ The PROFILE lives in the feed, not in the local registry — a registry does not replicate,
// so a profile kept only there is invisible to everyone who reads your feed.
case 'profile': {
await peer.update()
return a.feed ? (await peer.profileOf(a.feed)) || { name: null, avatar: null } : peer.profile()
}
case 'profiles': {
await peer.update()
await node.names.update()
const profiles = await peer.profiles()
// ⛔ The HANDLE is attached here so a byline can prefer it over the self-label. Resolution
// order is handle -> self-label -> key: one is claimed in a shared log, one is what a feed
// says about itself, and the third is the only unforgeable thing on the row.
//
// ⛔ CONFIRMED claims only. A claim names a feed; it does not prove the feed agrees, and this
// loop used to attach any claim that pointed at one — so claiming a name at someone else's
// feed key renamed them in every byline on the page. That is the attack arriving through the
// display layer rather than the log, and `confirmedHandles` is the one place it is refused.
for (const [feed, handle] of Object.entries(await node.confirmedHandles())) {
if (profiles[feed]) profiles[feed].handle = handle
else profiles[feed] = { name: null, avatar: null, handle }
}
return profiles
}
case 'set-profile': { const r = await peer.setProfile(a); await peer.update(); return r }
// Which networks this identity publishes to. ⚠ A preference, not a bridge — see peer.reach().
// ── bridges ────────────────────────────────────────────────────────────────────────────────
//
// ⛔ THIS DAEMON LEARNS NOTHING ABOUT THE NETWORK. It asks a bridge for a NAME and hands back the
// answer; what the name means is the bridge's business and the caller's. The same three verbs
// serve any bridge, which is the point — one ceremony rather than one per network.
//
// ⚠ The RESULT IS NOT RECORDED HERE. `set-reach` is a separate act because designating where this
// identity publishes is a separate decision from holding a name — a person may hold one and not
// publish under it.
case 'bridge-policy': {
const { connectBridge } = await import('./bridge.js')
const { rpc } = await connectBridge(node.swarm, a.bridge)
return rpc.request('policy', {})
}
case 'bridge-claim': {
const { connectBridge, buildClaim } = await import('./bridge.js')
const { rpc } = await connectBridge(node.swarm, a.bridge)
// ⛔ A FRESH CHALLENGE PER ATTEMPT. It is single-use at the bridge, so reusing one across a
// retry fails as "unknown or expired challenge" — which reads like a hostile caller.
const { nonce } = await rpc.request('challenge', {})
const req = buildClaim({
nonce,
username: a.name,
feedKey: node.key.toString('hex'),
root: node.rootHex(),
cred: node.cred,
invite: a.invite || null
})
const r = await rpc.request('claim', req)
// The bridge answers in HTTP's vocabulary on purpose — it is already the contract a peer reads.
if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
// ⛔ Remember WHICH bridge. Releasing needs the key again and `name@host` does not contain it;
// asking a person to paste it a second time, for the one irreversible act here, is how the
// wrong bridge gets asked.
const { rememberBridge, acceptObserver } = await import('./bridge.js')
rememberBridge(node.storage, r.body && r.body.acct, a.bridge)
// ⛔ AND THE OBSERVATION FEED THE BRIDGE VOUCHED FOR (hyper-social #82, I1) — kept only if the bridge we just
// claimed with signed it, then held right away so outside interactions start arriving without a restart.
const o = acceptObserver(node.storage, a.bridge, r.body)
if (o && typeof node.holdObserver === 'function') node.holdObserver(o).catch(() => {})
// ⛔ AND RECORD WHICH BRIDGE CARRIES THE NAME IN THE IDENTITY, not only on this device: reach replicates, so
// every device of this identity can ask that bridge for its observation feed at boot (hyper-social #82, I1).
if (r.body && r.body.acct && node.peer) {
try {
await node.peer.setReach({ fediverse: r.body.acct, fediverseBridge: String(a.bridge).replace(/^bridge:/, '').toLowerCase() })
} catch { /* an unattested device cannot write reach; the claim itself still stands */ }
}
return { ...r.body, pending: r.status === 202 ? (r.body && r.body.pending) || true : false }
}
// ⛔ CARRIED, not named. A bridge that fronts an identity serves what that identity SIGNED — it
// holds no key for it — so this asks for availability, not for authority.
case 'bridge-front': {
const { connectBridge, buildFront } = await import('./bridge.js')
const { rpc } = await connectBridge(node.swarm, a.bridge)
const { nonce } = await rpc.request('challenge', {})
const r = await rpc.request('front', buildFront({ nonce, id: a.id, root: node.rootHex(), cred: node.cred }))
if (r.status !== 200 && r.status !== 202) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
return r.body
}
case 'bridge-release': {
const { connectBridge, buildRelease, bridgeFor, forgetBridge, forgetObserver } = await import('./bridge.js')
// ⚠ The remembered key wins over one supplied by the caller: the node knows which bridge it
// actually claimed on, and a release sent to the wrong bridge is a request to burn a name
// somewhere it was never held.
const key = bridgeFor(node.storage, a.acct) || a.bridge
const { rpc } = await connectBridge(node.swarm, key)
const { nonce } = await rpc.request('challenge', {})
const r = await rpc.request('release', buildRelease({ nonce, username: a.name, root: node.rootHex(), cred: node.cred }))
if (r.status !== 200) throw new Error((r.body && r.body.error) || ('bridge refused: ' + r.status))
forgetBridge(node.storage, a.acct)
// The bridge's observation feed goes with it: its founder is no longer vouched for, so it renders nothing.
const gone = (await import('./bridge.js')).observers(node.storage).find((o) => o.acct === a.acct)
forgetObserver(node.storage, a.acct)
if (gone && node.peer && node.peer.observers) node.peer.observers.delete(gone.observationFeed)
// The identity no longer names a bridge for this name.
try {
const cur = node.peer ? await node.peer.reach() : null
if (cur && cur.fediverse === a.acct) await node.peer.setReach({ fediverseBridge: null })
} catch {}
return r.body
}
case 'bridge-which': {
const { bridgeFor } = await import('./bridge.js')
return { bridge: bridgeFor(node.storage, a.acct) }
}
case 'reach': return peer.reach()
case 'set-reach': { const r = await peer.setReach(a); await peer.update(); return r }
// Bytes cross the socket as base64 — it is a local UNIX socket carrying a small image, and a
// second transport for one field would be more moving parts than the field is worth.
// ── files ───────────────────────────────────────────────────────────────────────────────
//
// ⛔ PATHS cross this socket, never bytes. Both ends are on one machine — it is a unix socket —
// and `registerFile` takes a path, so sending a file through here as base64 meant encoding it
// to hand it to a process that wrote it straight back to disk. The bytes themselves move
// between PEERS over hyper-overlay, chunked and content-addressed, and always did.
case 'avatar-put': {
const r = await node.avatars.put(a.path)
// The oid goes into the PUBLISHED profile, so the picture travels with the feed.
await peer.setProfile({ avatar: r.oid })
await peer.update()
return r
}
// ⛔ A SECOND PICTURE, stored by the SAME machinery. `avatars.put` types from magic bytes and
// registers in the overlay — none of which is avatar-specific — so a banner needs a verb, not a
// mechanism. It is a separate verb rather than `avatar-put {field}` because the two are
// different acts and a mistyped field name would silently replace the wrong picture.
case 'banner-put': {
// ⛔ ITS OWN CAP. `avatars.put` defaults to the AVATAR's 512 KiB, which is a thumbnail policy —
// a banner is a wide header and does not fit in it, so every banner upload threw and the
// profile kept the avatar written moments earlier in the same submission.
const { BANNER_MAX_BYTES } = await import('./avatars.js')
const r = await node.avatars.put(a.path, { maxBytes: BANNER_MAX_BYTES, kind: 'banner' })
await peer.setProfile({ banner: r.oid })
await peer.update()
return r
}
case 'avatar-get': {
const r = await node.avatars.get(a.oid)
return r || { found: false }
}
// A file embedded in a record: ``. No cap — the overlay chunks.
case 'file-put': return node.files.put(a.path)
/**
* ⛔ THE PYRAMID, stored and pointed at in one act (SOCIAL-MODEL §6.6.1).
*
* The caller has already encoded the levels — that is authoring-time work and happens where the
* encoder is: `<canvas>` in the browser for images, system ffmpeg for video. This stores each
* whole file in the overlay exactly as a single blob is stored, then publishes ONE pointer
* naming them. Bridges then SELECT a level rather than re-encoding per delivery.
*
* ⚠ The BASE oid is the key and is what a record references, so ``
* is unchanged and every reader that knows nothing of levels still resolves the author's own
* bytes. Levels are additive; nothing already written moves.
*/
case 'file-put-levels': {
const input = Array.isArray(a.levels) ? a.levels : []
if (!input.length) throw new Error('file-put-levels: at least one level is required')
const stored = []
for (const l of input) {
if (!l || !l.path) throw new Error('file-put-levels: every level needs a path')
const r = await node.files.put(l.path)
stored.push({ oid: r.oid, w: Number(l.w) || 0, h: Number(l.h) || 0, size: r.size, by: l.by || AUTHOR })
}
// Largest first — makePointer sorts — so the base is level 0 and is the author's own bytes.
// The type comes from the BASE level's bytes — files.put already sniffed it, and every level
// of one asset is the same kind of thing.
const baseInfo = await node.files.get(stored[0].oid)
const pointer = makePointer({ mimeType: (baseInfo && baseInfo.mimeType) || '', size: stored[0].size, levels: stored })
const base = pointer.levels[0]
await peer.putBlobPointer(base.oid, pointer)
await peer.update()
return { oid: base.oid, pointer }
}
/**
* ⛔ DERIVE the missing rungs of a video's pyramid, on a node that HAS an encoder.
*
* §6.6.4: the pyramid is allowed to be PARTIAL — a handset posts one level and a well-resourced
* peer fills in the rest later. That is why this exists as a verb rather than happening on
* write: the node that stored the bytes is not necessarily the node that can encode.
*
* ⛔ EVERY LEVEL IT MAKES IS `derived:`, NEVER the author's. Deriving is not authoring — a peer
* adding a rung has produced a derived artifact and says so, or an author's bytes and somebody
* else's re-encode become indistinguishable. §8's rule cost two of wj62's posts.
*
* ⚠ Says plainly when it cannot: a node without ffmpeg is an ordinary node, not a broken one.
*/
case 'blob-derive': {
const info = await node.files.get(a.oid)
if (!info) return { ok: false, reason: 'no such blob' }
if (!/^video\//.test(info.mimeType || '')) return { ok: false, reason: 'only video is derived here — images are encoded at authoring' }
if (!await ffmpegAvailable()) return { ok: false, reason: 'no ffmpeg on this node — a level can be derived on one that has it' }
const dims = await probe(info.path)
if (!dims || !dims.height) return { ok: false, reason: 'could not probe the video' }
const existing = await peer.blobPointer(a.oid)
const have = new Set(((existing && existing.levels) || []).map((l) => l.h))
const want = planLevels({ mimeType: info.mimeType, width: dims.width, height: dims.height })
.filter((l) => !have.has(l.edge))
if (!want.length) return { ok: true, added: 0, reason: 'every level this ladder defines already exists' }
const me = b4a.toString(peer.feed.key, 'hex').slice(0, 8)
const levels = ((existing && existing.levels) || [])
if (!levels.length) levels.push({ oid: a.oid, w: dims.width, h: dims.height, size: info.size, by: AUTHOR })
let added = 0
for (const l of want) {
const enc = await encodeLevel(info.path, l)
if (!enc) continue
// ⛔ A RUNG BIGGER THAN THE SOURCE IS NOT A RUNG. A fixed bitrate applied to already-small
// or highly-compressible video produces a "smaller" level that costs more bytes and buys
// nothing — measured on a synthetic 720p clip whose 480p rung came out larger. The browser
// path guards the same way for the same reason.
if (enc.size >= info.size) { try { fs.unlinkSync(enc.path) } catch {}; continue }
const stored = await node.files.put(enc.path)
try { fs.unlinkSync(enc.path) } catch {}
levels.push({ oid: stored.oid, w: 0, h: l.edge, size: stored.size, by: derivedBy(me) })
added++
}
if (!added) return { ok: false, reason: 'ffmpeg produced no usable level' }
const pointer = makePointer({ mimeType: info.mimeType, size: info.size, levels })
await peer.putBlobPointer(a.oid, pointer)
await peer.update()
return { ok: true, added, pointer }
}
/** The pointer for a blob — null when it has only the one level it was written with. */
case 'blob-pointer': return (a.feed ? peer.blobPointerOf(a.feed, a.oid) : peer.blobPointer(a.oid)) || null
/**
* The level nearest a caller's ceiling, for a blob of any feed this node holds.
*
* ⛔ THE BRIDGE SUPPLIES THE CEILING; the choice is still the bridge's. Doing the arithmetic
* here rather than in each bridge keeps ONE ladder — the alternative is the same table written
* three times, which is how this monorepo got six copies of a signing format.
*
* ⚠ A blob with no pointer answers with its own single level, because that is what it has
* (record-vocabulary §3.5: today's avatar is the single-level special case). Absence of a
* pointer is never an error.
*/
case 'blob-level': {
const pointer = a.feed ? await peer.blobPointerOf(a.feed, a.oid) : await peer.blobPointer(a.oid)
if (!pointer) {
const info = await node.files.get(a.oid)
if (!info) return { level: null, reason: 'no such blob' }
const fits = (info.size || 0) <= (a.maxBytes || Infinity)
return fits
? { level: { oid: a.oid, w: 0, h: 0, size: info.size, by: AUTHOR }, reason: 'single level' }
: { level: null, reason: 'the only level is ' + info.size + ' bytes against a ceiling of ' + a.maxBytes }
}
return selectLevel(pointer, { maxBytes: a.maxBytes || Infinity, maxEdge: a.maxEdge || Infinity })
}
case 'file-get': {
const r = await node.files.get(a.oid)
return r || { found: false }
}
case 'browser-auth': return node.browsers.authenticate(a)
case 'browsers': return { browsers: await node.browsers.list() }
case 'browser-revoke': return node.browsers.revoke(a.publicKey)
case 'browser-status': return node.browsers.status(a.publicKey)
// ── names ───────────────────────────────────────────────────────────────────────────────
//
// ⛔ Every claim gets a number appended, always. There is no bare `alice` — the first claimant
// is `alice0`, the next `alice1`. So a name is never scarce, nothing can be squatted, there is
// no race to win, and no handle is privileged over another by being older.
case 'namespace': {
await node.names.update()
const founder = await node.names.founder()
return {
key: node.names.key.toString('hex'),
claims: (await node.names.list()).length,
// ⛔ Reported, because "I resolve handles here" and "I carry this log" are different facts
// and are fixed by completely different things. A node with no weight is not broken — it is
// a reader — but nobody can tell from a key alone.
founded: !!founder,
founder: founder ? founder.root : null,
admins: await node.names.admins(),
writers: await node.names.writers(),
requests: await node.names.requests(),
carrying: node.names.base.isIndexer,
writable: node.names.base.writable
}
}
// Resolve handles in a different namespace from now on. The one we leave stays on disk.
// ⛔ EVERY namespace this node holds, so "you can come back to it" is a door rather than a
// promise. `namespaceSettings` has always said the one you leave stays on disk; without this
// there was no way to learn what it was, and a 64-hex key someone has to have written down is
// not a way back.
case 'namespaces': {
const { loadNamespaces } = await import('./device.js')
const cur = node.names ? node.names.key.toString('hex') : null
return {
current: cur,
namespaces: loadNamespaces(node.storage).map((n) => ({ ...n, current: n.key === cur }))
}
}
// ⛔ FORGETS THE INDEX ENTRY, NEVER THE LOG. The slot stays on disk with every claim in it —
// this removes it from the list, and joining by key again finds the same slot through the index
// it was just dropped from... which it cannot. Stated plainly rather than discovered: forgetting
// the CURRENT namespace is refused, and forgetting a founded one loses the only record of its
// legacy slot, so that is refused too.
case 'namespace-forget': {
const { loadNamespaces, saveNamespaces } = await import('./device.js')
const key = String(a.key || '').toLowerCase()
const cur = node.names ? node.names.key.toString('hex') : null
if (key === cur) throw new Error('that is the namespace this node is in — join another first')
const known = loadNamespaces(node.storage)
const entry = known.find((n) => n.key === key)
if (!entry) throw new Error('this node does not hold that namespace')
if (entry.slot === 'names') throw new Error('that is the namespace this node founded — forgetting it would lose the only record of where its claims live')
saveNamespaces(node.storage, known.filter((n) => n.key !== key))
return { forgotten: key, note: 'the log stays on disk; join by key to return to it' }
}
case 'namespace-join': return node.joinNamespace(a.key)
// Ask to carry this namespace's log. Grants nothing; an admin has to answer.
case 'namespace-request': { await node.names.request(node.cred, a.token); await node.names.update(); return { asked: node.names.base.local.key.toString('hex'), token: a.token ? 'redeemed' : null } }
// ⛔ Minted over the daemon's UDS, which the filesystem already gates to the owner — so this is
// already "a human at this machine", which is the gate the token stands in for elsewhere.
case 'namespace-invite': return node.names.invite(node.cred)
case 'namespace-uninvite': { await node.names.withdrawInvite(a.hash, node.cred); await node.names.update(); return { withdrawn: a.hash } }
case 'namespace-invites': { await node.names.update(); return { invites: await node.names.invites() } }
// ⛔ Answer an ask. Refuses loudly when this node carries no weight, because a grant riding a
// weightless block is capped to zero SILENTLY (see namespace.js).
case 'namespace-admit': { await node.names.admitWriter(a.writer, node.cred); await node.names.update(); return { admitted: a.writer } }
case 'namespace-grant': { await node.names.grantAdmin(a.root, node.cred); await node.names.update(); return { admin: a.root } }
case 'namespace-revoke': { await node.names.revokeDevice(a.device, node.cred); await node.names.update(); return { revoked: a.device } }
case 'claim-name': {
await node.names.claim(a.name, node.key, node.cred)
await node.names.update()
return { handle: await node.names.handleFor(node.key) }
}
case 'release-name': { await node.names.release(a.handle, node.cred); await node.names.update(); return { released: a.handle } }
case 'rename': {
await node.names.rename(a.handle, a.name, node.key, node.cred)
await node.names.update()
return { handle: await node.names.handleFor(node.key) }
}
case 'handles': return { handles: await node.handles() }
// ⛔ Goes through the NODE, not the namespace: resolving a handle means checking the claim's
// anchor against the feed's own founder, and only the node holds feeds. `names.resolve` alone
// reports who CLAIMED a name, never whether the claim is true.
case 'resolve': return (await node.resolveHandle(a.handle)) || { found: false }
case 'search-names': return { handles: await node.handles(a.query) }
// ── device pairing ──────────────────────────────────────────────────────────────────────
case 'pair-offer': return node.pairOffer(a)
case 'pair-status': return node.pairStatus()
case 'pair-cancel': return node.pairCancel()
case 'pair-accept': return node.pairAccept(a.invite)
// ⚠ Reported, never folded into a listing: a record we hold a pointer for and cannot read is
// not an absent record, and the CLI must be able to say which.
case 'unresolved': return peer.unresolved()
// ⛔ WHICH TRIGGER IS ACTUALLY FIRING. Built because three fixes in a row shipped a mirror
// trigger that never fired, and nothing on a running node could say so — each time the only
// evidence was how LONG a record took to appear, which is inference, not measurement. If
// `append` and `connection` stay at zero while `stale` climbs, the events are dead. That is now
// a fact you can read.
case 'mirror-stats': return node._mirror ? node._mirror.stats() : { mirror: 'not running' }
default:
throw new Error('unknown command: ' + cmd)
}
}
const view = (r, mine = null) => ({
author: r.author,
collection: r.collection,
name: r.name,
createdAt: r.record && r.record.frontmatter && r.record.frontmatter.createdAt,
body: r.record && r.record.body,
hits: r.hits,
// ⛔ What *I* have already put on this row. Without it a surface renders the same button whether
// or not I reacted, and pressing it twice looks the same as pressing it once. Computed from ONE
// listing of my own feed for the whole page, never a lookup per row.
//
// ⚠ MINE only. Everyone else's reactions need a scan across held feeds, which is per-target work
// (`reactions`) and belongs on a single-post view, not on every row of a timeline.
...(mine === null
? {}
: (() => {
const values = mine.get(Peer.targetId({ feed: r.author, collection: r.collection, name: r.name }))
return { reactions: values ? [...values] : [], liked: !!values && values.has(LIKE_VALUE) }
})())
})
// Stamp a page of rows with my own reactions AND reposts. One listing of each, for the page —
// never a lookup per row.
const withLikes = async (peer, rows) => {
const mine = await peer.myReactions()
const reposted = await peer.myReposts()
const me = Peer.nameFor(peer.feed.key)
return rows.map((r) => {
const v = view(r, mine)
v.reposted = reposted.has(Peer.targetId({ feed: r.author, collection: r.collection, name: r.name }))
// ⛔ Whether I WROTE it — what lets a page offer delete on my own posts and replies only.
v.own = r.author === me
return v
})
}