mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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:
1 parent
1abba23df3
commit
cecd70c55f
5 files changed
+214
-7
No files matched your search
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in new issue
Block a user