Files
bitgate/packages/nostr/src/roster.js
T
thePR0M3TH3ANandClaude Opus 4.8 cecd70c55f nostr: resolveAuthorityRoster — moderator set as signed data
"Who is a moderator" should change without a redeploy, but apps had to
hand-assemble the createAuthorityState actors map. Add resolveAuthorityRoster
(@bitgate/nostr): turn signed kind-30000 "role set" events into the
actor->roles map, ready to spread into createAuthorityState.

It honors ONLY sets signed by an authorized key -- the root, or (with
`signer: <role>`) an actor already granted that role, giving one-role-
delegates-another chains in a single ordered pass. It keeps the latest set
per publisher (kind 30000 is replaceable) and ignores anyone unauthorized,
which is the security property: the roster is only as trustworthy as the key
that signed it. Signature verification stays the caller's job (inject
@bitgate/verify upstream).

Covered by roster.test.js: root-only grants, unauthorized-ignored,
delegation, replaceable-latest-wins, and that the output feeds
createAuthorityState. README headless section shows the pattern.

Addresses the "moderator-set as data" item of the Tessera feedback
(docs/TODO.md) -- previously marked PARTIAL.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB
2026-08-11 22:59:50 -04:00

104 lines
4.5 KiB
JavaScript

// Resolve an authority actor->roles map from signed kind-30000 "role set"
// events, ready to feed createAuthorityState.
//
// createAuthorityState({ root, actors }) needs to know who holds which role, but
// "who is a moderator" is data that should change without a redeploy: the root
// publishes a signed kind-30000 set naming them, clients resolve it at runtime.
// This turns those signed sets into the actors map, honoring ONLY sets published
// by an authorized signer -- the root, or (with delegation) an actor the root
// already granted a naming role. An unsigned or unauthorized set changes
// nothing, which is the whole security property: the roster is only as
// trustworthy as the key that signed it.
//
// A "role set" is a kind-30000 event whose `d` tag is the set identifier and
// whose `p` tags name the members. Signature verification is the caller's job
// (inject @bitgate/verify upstream): this reads already-trusted events.
import { getTagValue, getTags } from "./replaceable.js";
import { normalizePubkeyInput } from "./nip19.js";
/** NIP-51 people-set kind, reused here as the role-set carrier. */
export const ROLE_SET_KIND = 30000;
/**
* @typedef {Object} RosterSpec
* @property {string} identifier The set's `d` tag, e.g. "bitgate:moderators".
* @property {string} role Role granted to each `p`-tagged member.
* @property {string} [signer] Who may publish this set: "root" (default), or
* a role name -- the delegation hook. When a role name, any actor who ALREADY
* holds that role may publish this set. List a delegated roster AFTER the set
* that grants its signer role, since resolution is a single ordered pass.
*/
/**
* Resolve the honored actor->roles map.
*
* @param {import('./replaceable.js').NostrEvent[]} events Candidate kind-30000
* events off your relays (verify their signatures first).
* @param {Object} [options]
* @param {string} [options.root] Root pubkey (hex) -- the source of authority.
* With no root, nothing is authorized and the roster is empty.
* @param {string} [options.rootRole] Role for the root. Default "super_admin".
* @param {RosterSpec[]} [options.rosters] Sets to resolve, in dependency order.
* @returns {{ root: string, actors: Record<string, string[]> }} Spread into
* createAuthorityState(...).
*/
export function resolveAuthorityRoster(events, { root, rootRole = "super_admin", rosters = [] } = {}) {
const rootPk = normalizePubkeyInput(root ?? "") ?? "";
/** @type {Record<string, Set<string>>} pubkey -> roles */
const actors = Object.create(null);
const grant = (pubkey, role) => {
const pk = normalizePubkeyInput(pubkey);
if (!pk || !role) return;
(actors[pk] ??= new Set()).add(role);
};
if (rootPk) grant(rootPk, rootRole);
const roleSets = (Array.isArray(events) ? events : []).filter(
(event) => event && event.kind === ROLE_SET_KIND,
);
for (const roster of Array.isArray(rosters) ? rosters : []) {
if (!roster || typeof roster.identifier !== "string" || typeof roster.role !== "string") {
continue;
}
const signerRole = roster.signer && roster.signer !== "root" ? roster.signer : null;
// Authorization is evaluated against the roles resolved SO FAR, which is why
// a delegated roster must be listed after the roster granting its signer.
const authorized = (pubkey) => {
const pk = normalizePubkeyInput(pubkey);
if (!pk) return false;
if (pk === rootPk) return true; // root may publish any set
return signerRole ? (actors[pk]?.has(signerRole) ?? false) : false;
};
// Keep the latest set per authorized publisher for this identifier (kind
// 30000 is replaceable; relays may still hand back older copies).
/** @type {Map<string, import('./replaceable.js').NostrEvent>} */
const latestByPublisher = new Map();
for (const event of roleSets) {
if (getTagValue(event, "d") !== roster.identifier || !authorized(event.pubkey)) continue;
const prev = latestByPublisher.get(event.pubkey);
if (
!prev ||
event.created_at > prev.created_at ||
(event.created_at === prev.created_at && event.id < prev.id)
) {
latestByPublisher.set(event.pubkey, event);
}
}
for (const event of latestByPublisher.values()) {
for (const tag of getTags(event, "p")) {
grant(tag[1], roster.role);
}
}
}
/** @type {Record<string, string[]>} */
const out = Object.create(null);
for (const [pk, roles] of Object.entries(actors)) out[pk] = [...roles];
return { root: rootPk, actors: out };
}