/**
* follow-payload — Discovery payload for non-mirror follow.
*
* Compact-encoded blob (b64url-wrapped for CLI/URL transport) that
* gives a non-mirror daemon everything it needs to read-only replicate
* one remote resource (a repo or an org).
*
* See spec/modules/follow-payload.md for the schema + auth model.
*
* Nullable buffer fields use the same 1-byte presence prefix + 32-byte
* payload pattern as repo-event-log-messages.js.
*/
import c from 'compact-encoding'
import b4a from 'b4a'
export const VERSION = 1
export const KIND_REPO = 1
export const KIND_ORG = 2
const DEFAULT_TOPIC_SALT = 'pear-git-v3'
// ── Helpers ──────────────────────────────────────────────────────
function toBuf32 (v, field) {
if (v == null) return null
// A 32-byte value is exactly 64 hex chars. Validate the string shape BEFORE
// parsing: bare's b4a.from throws "Invalid input" on malformed hex (node is
// lenient and returns a short buffer), so without this guard the intended
// length error is masked by a low-level parse error under bare.
if (typeof v === 'string' && !/^[0-9a-fA-F]{64}$/.test(v)) {
const err = new Error(`${field} must be 32 bytes (got ${Math.floor(v.length / 2)})`)
err.code = 'EBADFIELD'
throw err
}
const buf = typeof v === 'string' ? b4a.from(v, 'hex') : v
if (buf.length !== 32) {
const err = new Error(`${field} must be 32 bytes (got ${buf.length})`)
err.code = 'EBADFIELD'
throw err
}
return buf
}
function require32 (v, field) {
const buf = toBuf32(v, field)
if (!buf) {
const err = new Error(`${field} is required`)
err.code = 'EREQUIRED'
throw err
}
return buf
}
function preencodeNullableBuf (state, buf) {
c.uint8.preencode(state, buf ? 1 : 0)
if (buf) state.end += 32
}
function encodeNullableBuf (state, buf) {
c.uint8.encode(state, buf ? 1 : 0)
if (buf) {
buf.copy(state.buffer, state.start, 0, 32)
state.start += 32
}
}
function decodeNullableBuf (state) {
const present = c.uint8.decode(state)
if (!present) return null
const out = b4a.from(state.buffer.subarray(state.start, state.start + 32))
state.start += 32
return out
}
// ── Codec ───────────────────────────────────────────────────────
function preencodePayload (state, m) {
c.uint8.preencode(state, m.version)
c.uint8.preencode(state, m.kindNum)
state.end += 32 // sourceServerKey
c.string.preencode(state, m.topicSalt)
c.string.preencode(state, m.name)
if (m.kindNum === KIND_REPO) {
preencodeNullableBuf(state, m.issueBootstrapKey)
preencodeNullableBuf(state, m.repoEventLogKey)
} else {
state.end += 32 // orgsBootstrapKey
state.end += 32 // orgEventLogKey
preencodeNullableBuf(state, m.orgIssueBootstrapKey)
preencodeNullableBuf(state, m.repoEventLogKey)
}
}
function encodePayload (state, m) {
c.uint8.encode(state, m.version)
c.uint8.encode(state, m.kindNum)
m.sourceServerKey.copy(state.buffer, state.start, 0, 32); state.start += 32
c.string.encode(state, m.topicSalt)
c.string.encode(state, m.name)
if (m.kindNum === KIND_REPO) {
encodeNullableBuf(state, m.issueBootstrapKey)
encodeNullableBuf(state, m.repoEventLogKey)
} else {
m.orgsBootstrapKey.copy(state.buffer, state.start, 0, 32); state.start += 32
m.orgEventLogKey.copy(state.buffer, state.start, 0, 32); state.start += 32
encodeNullableBuf(state, m.orgIssueBootstrapKey)
encodeNullableBuf(state, m.repoEventLogKey)
}
}
function decodePayload (state) {
const version = c.uint8.decode(state)
if (version !== VERSION) {
const err = new Error(`unknown follow-payload version ${version}`)
err.code = 'EBADVERSION'
throw err
}
const kindNum = c.uint8.decode(state)
if (kindNum !== KIND_REPO && kindNum !== KIND_ORG) {
const err = new Error(`unknown follow-payload kind ${kindNum}`)
err.code = 'EBADKIND'
throw err
}
const sourceServerKey = b4a.from(state.buffer.subarray(state.start, state.start + 32))
state.start += 32
const topicSalt = c.string.decode(state)
const name = c.string.decode(state)
const out = {
version,
kind: kindNum === KIND_REPO ? 'repo' : 'org',
sourceServerKey: b4a.toString(sourceServerKey, 'hex'),
topicSalt,
name
}
if (kindNum === KIND_REPO) {
const ibk = decodeNullableBuf(state)
const relk = decodeNullableBuf(state)
out.issueBootstrapKey = ibk && b4a.toString(ibk, 'hex')
out.repoEventLogKey = relk && b4a.toString(relk, 'hex')
} else {
const obk = b4a.from(state.buffer.subarray(state.start, state.start + 32))
state.start += 32
const oelk = b4a.from(state.buffer.subarray(state.start, state.start + 32))
state.start += 32
const oibk = decodeNullableBuf(state)
const relk = decodeNullableBuf(state)
out.orgsBootstrapKey = b4a.toString(obk, 'hex')
out.orgEventLogKey = b4a.toString(oelk, 'hex')
out.orgIssueBootstrapKey = oibk && b4a.toString(oibk, 'hex')
out.repoEventLogKey = relk && b4a.toString(relk, 'hex')
}
return out
}
// ── Public API ──────────────────────────────────────────────────
export function encode (input) {
if (!input || (input.kind !== 'repo' && input.kind !== 'org')) {
const err = new Error('kind must be "repo" or "org"')
err.code = 'EBADKIND'
throw err
}
if (!input.name || typeof input.name !== 'string') {
const err = new Error('name is required')
err.code = 'EREQUIRED'
throw err
}
const m = {
version: VERSION,
kindNum: input.kind === 'repo' ? KIND_REPO : KIND_ORG,
sourceServerKey: require32(input.sourceServerKey, 'sourceServerKey'),
topicSalt: input.topicSalt || DEFAULT_TOPIC_SALT,
name: input.name
}
if (input.kind === 'repo') {
m.issueBootstrapKey = toBuf32(input.issueBootstrapKey, 'issueBootstrapKey')
m.repoEventLogKey = toBuf32(input.repoEventLogKey, 'repoEventLogKey')
} else {
m.orgsBootstrapKey = require32(input.orgsBootstrapKey, 'orgsBootstrapKey')
m.orgEventLogKey = require32(input.orgEventLogKey, 'orgEventLogKey')
m.orgIssueBootstrapKey = toBuf32(input.orgIssueBootstrapKey, 'orgIssueBootstrapKey')
m.repoEventLogKey = toBuf32(input.repoEventLogKey, 'repoEventLogKey')
}
const state = { start: 0, end: 0, buffer: null }
preencodePayload(state, m)
state.buffer = b4a.allocUnsafe(state.end)
state.start = 0
encodePayload(state, m)
return state.buffer
}
export function decode (buf) {
const state = { start: 0, end: buf.length, buffer: buf }
return decodePayload(state)
}
export function encodeB64 (input) {
return b4a.toString(encode(input), 'base64url')
}
export function decodeB64 (s) {
return decode(b4a.from(s, 'base64url'))
}
// ── URL form ─────────────────────────────────────────────────────
const URL_RE = /^punch\+follow:\/\/([0-9a-f]{64})\/(repo|org)\/(.+)$/i
export function formatUrl (payloadOrFields) {
const kind = payloadOrFields.kind
const srv = payloadOrFields.sourceServerKey
const name = payloadOrFields.name
if (!kind || !srv || !name) {
const err = new Error('formatUrl needs { kind, sourceServerKey, name }')
err.code = 'EREQUIRED'
throw err
}
if (kind !== 'repo' && kind !== 'org') {
const err = new Error('kind must be "repo" or "org"')
err.code = 'EBADKIND'
throw err
}
return `punch+follow://${srv}/${kind}/${encodeURIComponent(name)}`
}
export function parseUrl (url) {
const m = URL_RE.exec(String(url || ''))
if (!m) {
const err = new Error('not a punch+follow:// URL')
err.code = 'EBADURL'
throw err
}
return {
kind: m[2].toLowerCase(),
sourceServerKey: m[1].toLowerCase(),
name: decodeURIComponent(m[3])
}
}