From cecd70c55f6b71edffffb1a1d781c8cf27c85a6b Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN <53631862+PR0M3TH3AN@users.noreply.github.com> Date: Tue, 11 Aug 2026 22:59:50 -0400 Subject: [PATCH] =?UTF-8?q?nostr:=20resolveAuthorityRoster=20=E2=80=94=20m?= =?UTF-8?q?oderator=20set=20as=20signed=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "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: `) 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 Claude-Session: https://claude.ai/code/session_01BXQCvJPA6sVSQ5j8uBd9bB --- README.md | 17 +++++ docs/TODO.md | 16 +++-- packages/nostr/src/index.js | 2 + packages/nostr/src/roster.js | 103 +++++++++++++++++++++++++++++ packages/nostr/test/roster.test.js | 83 +++++++++++++++++++++++ 5 files changed, 214 insertions(+), 7 deletions(-) create mode 100644 packages/nostr/src/roster.js create mode 100644 packages/nostr/test/roster.test.js diff --git a/README.md b/README.md index 2cd701f..0559312 100644 --- a/README.md +++ b/README.md @@ -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 ``` +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 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) diff --git a/docs/TODO.md b/docs/TODO.md index 4ba0c99..6b627d6 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -253,13 +253,15 @@ adoption the obvious choice. stays **visible (warn)** but **not installable (transaction deny)** — where a `hidden` boolean can't reach. Pinned by `tests/headless-quickstart.test.js` (incl. that an out-of-set labeller's deny is ignored). -- **[PARTIAL 2026-08-11] Moderator-set as data.** The primitive exists - (`createAuthorityState({ root, actors })` + capability-gated - `reduceAdminState`), and the headless example demonstrates resolving the - honored set as data and ignoring an unauthorized labeller. Still open as a - first-class helper: resolving that actor map from a *signed, root-controlled - kind-30000 set* at runtime (with delegation) rather than the app assembling - the `actors` object itself. Left as a focused follow-up. +- **[DONE 2026-08-11] Moderator-set as data.** Shipped `resolveAuthorityRoster` + (`@bitgate/nostr`): resolves the honored actor→roles map from signed kind-30000 + "role set" events and spreads straight into `createAuthorityState`. It honors + **only** root-signed sets, plus role-based delegation (`signer: "moderator"` + lets a resolved moderator publish a further set, one ordered pass), keeps the + latest set per publisher (replaceable), and ignores anyone unauthorized — so + 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 `requireAllowlist` + `allowlistMiss` (evaluator.js), and the commerce example configured a seller allowlist by hand — but there was no reusable named diff --git a/packages/nostr/src/index.js b/packages/nostr/src/index.js index d9260bd..bc514c0 100644 --- a/packages/nostr/src/index.js +++ b/packages/nostr/src/index.js @@ -91,6 +91,8 @@ export { decodeMixedContributions, } from "./legacy.js"; +export { ROLE_SET_KIND, resolveAuthorityRoster } from "./roster.js"; + /** * @typedef {import('./replaceable.js').NostrEvent} NostrEvent * @typedef {import('./replaceable.js').SignatureVerifier} SignatureVerifier diff --git a/packages/nostr/src/roster.js b/packages/nostr/src/roster.js new file mode 100644 index 0000000..51d46c9 --- /dev/null +++ b/packages/nostr/src/roster.js @@ -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 }} Spread into + * createAuthorityState(...). + */ +export function resolveAuthorityRoster(events, { root, rootRole = "super_admin", rosters = [] } = {}) { + const rootPk = normalizePubkeyInput(root ?? "") ?? ""; + + /** @type {Record>} 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} */ + 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} */ + const out = Object.create(null); + for (const [pk, roles] of Object.entries(actors)) out[pk] = [...roles]; + return { root: rootPk, actors: out }; +} diff --git a/packages/nostr/test/roster.test.js b/packages/nostr/test/roster.test.js new file mode 100644 index 0000000..e844930 --- /dev/null +++ b/packages/nostr/test/roster.test.js @@ -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"); + }); +});