/**
* Follow handlers — consume side of follower mode.
*
* RPCs:
* follow-repo({ payload | url }) → start a RepoFollower, persist
* unfollow-repo({ sourceServerKey, name })
* → stop a RepoFollower, persist removal
* (on-disk cores + repo bytes preserved)
* list-repo-follows() → snapshot of active follows
*
* Org variants come in Phase 2.
*
* Auth:
* follow-repo, unfollow-repo — UDS only (operator intent, write).
* list-repo-follows — UDS + swarm + HTTP read OK.
*/
import b4a from 'b4a'
import { decodeB64, parseUrl } from '../services/follow-payload.js'
function requireUds (ctx, rpc) {
if (!ctx || ctx.transport !== 'uds') {
throw Object.assign(new Error(rpc + ' requires UDS transport'), {
code: 'EAUTH', status: 403
})
}
}
export function followHandlers (svc) {
return [
{
name: 'follow-repo',
fn: async (ctx, params = {}) => {
requireUds(ctx, 'follow-repo')
if (!svc._follows || !svc._follows.startRepoFollow) {
throw new Error('follower mode not initialized on this daemon')
}
let payload
if (params.payload) {
payload = decodeB64(params.payload)
} else if (params.url) {
// URL form ships only (srvKey, kind, name) — we have to fetch
// the full payload from the source over swarm-RPC. The
// helper does the swarm-dial + call. If unimplemented (this
// daemon can't reach the source's RPC), surface a clean
// error directing the user to paste the binary payload form.
payload = await svc._follows.fetchPayloadFromUrl(params.url)
} else {
throw new Error('follow-repo requires { payload } or { url }')
}
if (!payload || payload.kind !== 'repo') {
throw new Error('follow-repo requires a repo-kind payload (got ' +
(payload && payload.kind) + ')')
}
// Conflict guards
// (a) If this daemon is a mirror of the same source, don't follow —
// mirror flow already replicates everything.
if (svc.originCfg && svc.originCfg.serverKey &&
String(svc.originCfg.serverKey).toLowerCase() === payload.sourceServerKey) {
return {
alreadyMirror: true,
sourceServerKey: payload.sourceServerKey,
name: payload.name,
message: 'daemon is already a mirror of this source — no follow needed'
}
}
const result = await svc._follows.startRepoFollow(payload)
return {
ok: true,
sourceServerKey: payload.sourceServerKey,
name: payload.name,
...result
}
}
},
{
name: 'unfollow-repo',
fn: async (ctx, params = {}) => {
requireUds(ctx, 'unfollow-repo')
if (!params.sourceServerKey || !params.name) {
throw new Error('unfollow-repo requires { sourceServerKey, name }')
}
if (!svc._follows) {
throw new Error('follower mode not initialized on this daemon')
}
const removed = await svc._follows.stopRepoFollow(
String(params.sourceServerKey).toLowerCase(),
params.name
)
return { ok: true, removed, sourceServerKey: params.sourceServerKey, name: params.name }
}
},
{
name: 'list-repo-follows',
fn: async () => {
if (!svc._follows) return { active: false, follows: [] }
return { active: true, follows: svc._follows.listRepoFollows() }
}
}
]
}
/** Helper: best-effort decode of a payload string from either form. */
export function decodeFollowToken (token) {
if (!token) throw new Error('decodeFollowToken requires a token')
// URL form is recognisable by the punch+follow:// prefix.
if (typeof token === 'string' && token.startsWith('punch+follow://')) {
return { kind: 'url', url: token, parsed: parseUrl(token) }
}
// Otherwise treat as a b64 payload (caller can decode it).
return { kind: 'payload', payload: token }
}