// Self-certifying DHT name pointer + name-binding row schema (naming Phase N0).
//
// Mirrors backend/relay-record.js: a name's owner publishes `name → target`
// as a hyperdht MUTABLE record keyed by the name's (rotatable, NOT seed-derived)
// sub-pubkey. dht.mutableGet VERIFIES the Ed25519 signature against that key, so
// a resolved pointer is self-certifying — a malicious DHT node can serve stale
// data, never forge. The monotonic seq `s` lets the owner re-point the name
// (highest seq wins), surviving key rotation.
//
// Node-safe (b4a only, no Bare modules) → unit-testable outside Bare, like
// relay-record.js. See docs/research/naming.md.
const b4a = require('b4a')
const {
driveKeyFromHyperLink,
normalizeCatalogLink,
normalizeDriveKey
} = require('./catalog-safety.cjs')
const NAME_RECORD_VERSION = 1
const MAX_LINK = 300
function normalizeRecordLink (raw) {
const link = normalizeCatalogLink(raw)
return link && link.length <= MAX_LINK ? link : null
}
function normalizeRecordTarget ({ driveKey, link, target } = {}) {
const normalizedLink = normalizeRecordLink(target) || normalizeRecordLink(link)
const key = normalizeDriveKey(driveKey) ||
(!normalizedLink ? normalizeDriveKey(target) : '') ||
(normalizedLink ? driveKeyFromHyperLink(normalizedLink) : '')
if (!key && !normalizedLink) return null
return {
driveKey: key || null,
link: normalizedLink || null,
target: normalizedLink || key
}
}
// Wire shape: { v, n, k?, s, l? } — version, name, optional drive key
// (64-hex), monotonic seq, and optional browsable Hyper link. Old key-only
// records still decode; link-only records must be Hyper content. Unknown shapes
// decode to null.
function decodeNameRecord (buf) {
if (buf == null) return null
let rec
try { rec = JSON.parse(b4a.isBuffer(buf) ? b4a.toString(buf, 'utf8') : String(buf)) } catch { return null }
if (!rec || typeof rec !== 'object' || rec.v !== NAME_RECORD_VERSION) return null
if (typeof rec.n !== 'string' || rec.n.length === 0 || rec.n.length > 253) return null
if (!Number.isInteger(rec.s) || rec.s < 0) return null
const target = normalizeRecordTarget({ driveKey: rec.k, link: rec.l })
if (!target) return null
return { name: rec.n, driveKey: target.driveKey, seq: rec.s, link: target.link, target: target.target }
}
function encodeNameRecord ({ name, driveKey, seq, link, target } = {}) {
if (typeof name !== 'string' || !name) throw new Error('name required')
if (!Number.isInteger(seq) || seq < 0) throw new Error('seq must be a non-negative integer')
const resolved = normalizeRecordTarget({ driveKey, link, target })
if (!resolved) throw new Error('target must be a Hyperdrive key or hyper:// link')
const rec = { v: NAME_RECORD_VERSION, n: name, s: seq }
if (resolved.driveKey) rec.k = resolved.driveKey
if (resolved.link) rec.l = resolved.link
return b4a.from(JSON.stringify(rec), 'utf8')
}
/**
* Resolve a name's current pointer by its sub-pubkey over a hyperdht instance.
* mutableGet verifies the signature, so the result is trustworthy without a
* registrar. @param {object} dht @param {Buffer|string} nameSubPubkey 32B/64-hex
*/
async function resolveNameRecord (dht, nameSubPubkey) {
if (!dht || typeof dht.mutableGet !== 'function') return null
let key
try { key = typeof nameSubPubkey === 'string' ? b4a.from(nameSubPubkey, 'hex') : nameSubPubkey } catch { return null }
if (!key || key.length !== 32) return null
let res
try { res = await dht.mutableGet(key) } catch { return null }
if (!res || res.value == null) return null
return decodeNameRecord(res.value)
}
// name-binding row schema for a `name-directory` index room — mirrors APPS_SCHEMA
// in backend/sheets-catalog.js. A signed `name → key` claim that the directory
// ingest re-verifies and DROPS on failure ("index, not authority"), ranked by
// the follow graph. binderPubkey is the claimer's name sub-pubkey; bindingSig is
// the Ed25519 sig over (normalized name ‖ driveKey), checked with
// identity-binding.cjs verifyAppSig — i.e. this CONSUMES the Lighthouse
// substrate rather than re-deriving a verifier.
const NAME_BINDING_SCHEMA = {
type: 'object',
properties: {
name: { type: 'string', maxLength: 253 },
driveKey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
link: { type: 'string', maxLength: 300, pattern: '^hyper://.+' },
binderPubkey: { type: 'string', pattern: '^[0-9a-f]{64}$' },
bindingSig: { type: 'string', pattern: '^[0-9a-f]{128}$' },
seq: { type: 'integer' },
verification: { enum: ['unverified', 'relay-listed', 'author-signed'] }
},
required: ['name', 'binderPubkey', 'bindingSig'],
anyOf: [{ required: ['driveKey'] }, { required: ['link'] }],
additionalProperties: false
}
module.exports = {
NAME_RECORD_VERSION, decodeNameRecord, encodeNameRecord, resolveNameRecord,
NAME_BINDING_SCHEMA,
}