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
This commit is contained in:
thePR0M3TH3ANandClaude Opus 4.8 committed 2026-08-11 22:59:50 -04:00
1 parent 1abba23df3
commit cecd70c55f
5 files changed
+214 -7

No files matched your search

+17
View File
@@ -150,6 +150,23 @@ const decision = evaluateTarget(target, snapshot, { policy, surface: "registry"
if (!decision.transaction || decision.transaction.effect === "allow") install(); // gate a non-visual action if (!decision.transaction || decision.transaction.effect === "allow") install(); // gate a non-visual action
``` ```
Don't hand-build `honoredModerators`. "Who is a moderator" is data that should
change without a redeploy, so let the root publish it as a signed kind-30000 set
and resolve it at runtime with `resolveAuthorityRoster` — it honors only
root-signed sets (and, with `signer`, one-role-delegates-another chains), and
its output spreads straight into `createAuthorityState`:
```js
import { resolveAuthorityRoster } from "@bitgate/nostr";
const authority = createAuthorityState(resolveAuthorityRoster(roleSetEvents, {
root: ROOT,
rosters: [
{ identifier: "bitgate:moderators", role: "moderator" }, // root names moderators
{ identifier: "bitgate:curators", role: "curator", signer: "moderator" }, // moderators name curators
],
}));
```
A decision has four dimensions, so `transaction` can block an install or A decision has four dimensions, so `transaction` can block an install or
checkout while `visibility` still shows the listing with a warning — something a checkout while `visibility` still shows the listing with a warning — something a
`hidden` boolean can't express. [`examples/headless-quickstart.mjs`](examples/headless-quickstart.mjs) `hidden` boolean can't express. [`examples/headless-quickstart.mjs`](examples/headless-quickstart.mjs)
+9 -7
View File
@@ -253,13 +253,15 @@ adoption the obvious choice.
stays **visible (warn)** but **not installable (transaction deny)** — where a stays **visible (warn)** but **not installable (transaction deny)** — where a
`hidden` boolean can't reach. Pinned by `tests/headless-quickstart.test.js` `hidden` boolean can't reach. Pinned by `tests/headless-quickstart.test.js`
(incl. that an out-of-set labeller's deny is ignored). (incl. that an out-of-set labeller's deny is ignored).
- **[PARTIAL 2026-08-11] Moderator-set as data.** The primitive exists - **[DONE 2026-08-11] Moderator-set as data.** Shipped `resolveAuthorityRoster`
(`createAuthorityState({ root, actors })` + capability-gated (`@bitgate/nostr`): resolves the honored actor→roles map from signed kind-30000
`reduceAdminState`), and the headless example demonstrates resolving the "role set" events and spreads straight into `createAuthorityState`. It honors
honored set as data and ignoring an unauthorized labeller. Still open as a **only** root-signed sets, plus role-based delegation (`signer: "moderator"`
first-class helper: resolving that actor map from a *signed, root-controlled lets a resolved moderator publish a further set, one ordered pass), keeps the
kind-30000 set* at runtime (with delegation) rather than the app assembling latest set per publisher (replaceable), and ignores anyone unauthorized — so
the `actors` object itself. Left as a focused follow-up. moderators change by publishing a signed set, no redeploy. Covered by
`roster.test.js` (root-only, unauthorized-ignored, delegation, replaceable,
and feeds `createAuthorityState`); README "headless" section shows it.
- **[DONE 2026-08-11] Allowlist / whitelist mode.** The engine already honored - **[DONE 2026-08-11] Allowlist / whitelist mode.** The engine already honored
`requireAllowlist` + `allowlistMiss` (evaluator.js), and the commerce example `requireAllowlist` + `allowlistMiss` (evaluator.js), and the commerce example
configured a seller allowlist by hand — but there was no reusable named configured a seller allowlist by hand — but there was no reusable named
+2
View File
@@ -91,6 +91,8 @@ export {
decodeMixedContributions, decodeMixedContributions,
} from "./legacy.js"; } from "./legacy.js";
export { ROLE_SET_KIND, resolveAuthorityRoster } from "./roster.js";
/** /**
* @typedef {import('./replaceable.js').NostrEvent} NostrEvent * @typedef {import('./replaceable.js').NostrEvent} NostrEvent
* @typedef {import('./replaceable.js').SignatureVerifier} SignatureVerifier * @typedef {import('./replaceable.js').SignatureVerifier} SignatureVerifier
+103
View File
@@ -0,0 +1,103 @@
// 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 };
}
+83
View File
@@ -0,0 +1,83 @@
import { describe, expect, it } from "vitest";
import { createAuthorityState, getActorRoles } from "@bitgate/core";
import { ROLE_SET_KIND, resolveAuthorityRoster } from "../src/roster.js";
const ROOT = "a1".repeat(32);
const MOD_A = "b2".repeat(32);
const MOD_B = "c3".repeat(32);
const COUNCIL = "d4".repeat(32);
const CURATOR = "e5".repeat(32);
const OUTSIDER = "f6".repeat(32);
const setEvent = (pubkey, identifier, members, createdAt = 1000, id = "0".repeat(64)) => ({
kind: ROLE_SET_KIND,
pubkey,
created_at: createdAt,
id,
content: "",
tags: [["d", identifier], ...members.map((m) => ["p", m])],
});
describe("resolveAuthorityRoster", () => {
it("grants the root its role and members from a root-signed set", () => {
const { root, actors } = resolveAuthorityRoster([setEvent(ROOT, "bitgate:moderators", [MOD_A, MOD_B])], {
root: ROOT,
rosters: [{ identifier: "bitgate:moderators", role: "moderator" }],
});
expect(root).toBe(ROOT);
expect(actors[ROOT]).toEqual(["super_admin"]);
expect(actors[MOD_A]).toEqual(["moderator"]);
expect(actors[MOD_B]).toEqual(["moderator"]);
});
it("ignores a set signed by anyone but the root (the whole security property)", () => {
const { actors } = resolveAuthorityRoster([setEvent(OUTSIDER, "bitgate:moderators", [MOD_A])], {
root: ROOT,
rosters: [{ identifier: "bitgate:moderators", role: "moderator" }],
});
expect(actors[MOD_A]).toBeUndefined();
});
it("supports delegation: a granted role may publish a further set", () => {
const events = [
setEvent(ROOT, "bitgate:council", [COUNCIL]), // root names the council
setEvent(COUNCIL, "bitgate:curators", [CURATOR]), // a council member names curators
setEvent(OUTSIDER, "bitgate:curators", [OUTSIDER]), // outsider's set ignored
];
const { actors } = resolveAuthorityRoster(events, {
root: ROOT,
rosters: [
{ identifier: "bitgate:council", role: "council" },
{ identifier: "bitgate:curators", role: "curator", signer: "council" },
],
});
expect(actors[COUNCIL]).toEqual(["council"]);
expect(actors[CURATOR]).toEqual(["curator"]);
expect(actors[OUTSIDER]).toBeUndefined();
});
it("keeps the latest set per publisher (kind 30000 is replaceable)", () => {
const events = [
setEvent(ROOT, "bitgate:moderators", [MOD_A], 1000, "1".repeat(64)),
setEvent(ROOT, "bitgate:moderators", [MOD_B], 2000, "2".repeat(64)), // newer: replaces
];
const { actors } = resolveAuthorityRoster(events, {
root: ROOT,
rosters: [{ identifier: "bitgate:moderators", role: "moderator" }],
});
expect(actors[MOD_B]).toEqual(["moderator"]);
expect(actors[MOD_A]).toBeUndefined();
});
it("output feeds createAuthorityState directly", () => {
const roster = resolveAuthorityRoster([setEvent(ROOT, "bitgate:moderators", [MOD_A])], {
root: ROOT,
rosters: [{ identifier: "bitgate:moderators", role: "moderator" }],
});
const authority = createAuthorityState(roster);
expect(getActorRoles(MOD_A, authority)).toContain("moderator");
expect(getActorRoles(ROOT, authority)).toContain("super_admin");
});
});