diff --git a/README.md b/README.md index 361a201..7c8d996 100644 --- a/README.md +++ b/README.md @@ -81,9 +81,10 @@ await provider.ready; await provider.useSigner(window.nostr); // BitLogin's NIP-07 provider ``` -Policy presets: `social`, `commerce`, `admin-only`. Every number in them is a -starting point — see the [integration guide](docs/integration-guide.md) to -write your own. +Policy presets: `social`, `commerce`, `admin-only`, and `allowlist` (only +allowlisted publishers are shown — the symmetric twin of a denylist, for +invite-only or curated surfaces). Every number in them is a starting point — +see the [integration guide](docs/integration-guide.md) to write your own. ## Quick start — JavaScript diff --git a/docs/TODO.md b/docs/TODO.md index 9758bef..4f089cf 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -218,3 +218,57 @@ A developer or AI agent can take `bitlogin.js`, `bitgate.js`, `bitfeed.js`, and `bitblocks.js` and build a functional static Nostr application with almost no infrastructure or integration boilerplate, while each individual library remains usable without BitBlocks. + +## Feedback from Tessera building a moderation layer (2026-08-11) + +Tessera needed exactly what BitGate provides (moderation as policy over public +Nostr events) but ended up building a **client-side stand-in** — a kind-33604 +"moderation decision" event carrying the four dimensions, ladder-max +composition, a signed root-controlled moderator set, and a browser console — +rather than integrating `@bitgate/*`. Notes on why, and what would have made +adoption the obvious choice. + +### Why we didn't reach for BitGate first + +- **No obvious "just the engine + events over Nostr" path for a static site.** + We wanted: moderators publish signed decisions to relays; every client + resolves the honored set and composes decisions ladder-max; enforce in the UI. + That is precisely BitGate's model, but the on-ramp we found was the + ``/`` widgets, which assume you're veiling + DOM content, not making install/transaction decisions in app logic. A + documented recipe for "headless `@bitgate/core` + your own decision events + + your own enforcement" would have won us over. +- **The decision event format is ours, not yours.** We invented a 33604 shape + because we didn't find a canonical BitGate "decision/label event" to emit and + consume over relays. If BitGate defines (or blesses a NIP-32-based) wire + format for a signed decision across the four dimensions, other clients — + including ours — could interoperate instead of each inventing one. + +### High-value asks + +- **Headless quickstart**: `@bitgate/core` composing decisions from an array of + events + a viewer + a policy, with zero DOM. Show the `transaction` dimension + gating a non-visual action (install/checkout), which is where the four- + dimension model really shines and where a `hidden` boolean can't reach. +- **Moderator-set as data.** Tessera made "who is a moderator" a signed, + root-controlled kind-30000 set resolved at runtime, so moderators can be + added in-app without a redeploy. A first-class BitGate primitive for the + honored-moderator/authority set (with delegation) would be broadly useful. +- **[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 + preset. **Shipped** `ALLOWLIST_POLICY` (registry name `allowlist`): only + publishers with a `user-allow` contribution are shown, every unlisted one is + hidden across all four dimensions, administrative denials still apply on top, + and protected actors (root/mods) are never gated. Symmetric to the denylist — + populate `userAllow` the same way (signed contributions / NIP-32 allow + labels). Covered by presets.test.js (unlisted hidden, allowlisted shown, + protected bypasses); surfaced in the README preset list. + +### Medium + +- Ship `@bitgate/core` as a standalone ESM single-file (like BitFeed's + `standalone`) so a no-build static site can vendor it the way we vendor + `@tessera/core`. Dependency-free + no build step was a hard requirement for us. +- A worked example of BitGate feeding a feed ranking (the `ranking` dimension → + a score multiplier) would have saved us reinventing it in the feed recipe. diff --git a/packages/core/src/index.js b/packages/core/src/index.js index cea1a8d..a146467 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -89,6 +89,7 @@ export { SOCIAL_POLICY, COMMERCE_POLICY, ADMIN_ONLY_POLICY, + ALLOWLIST_POLICY, POLICY_PRESETS, getPolicyPreset, } from "./presets.js"; diff --git a/packages/core/src/presets.js b/packages/core/src/presets.js index 73ebb9b..46cd12e 100644 --- a/packages/core/src/presets.js +++ b/packages/core/src/presets.js @@ -247,6 +247,41 @@ export const ADMIN_ONLY_POLICY = createPolicyDefinition({ }, }); +/** + * Allowlist-only — the symmetric twin of the denylist. + * + * A denylist names who is OUT; this names who is IN. A publisher is shown only + * when an authorized actor has contributed a `user-allow` for them (from a + * NIP-32 allow label, or an admin allowlist), and every unlisted publisher + * misses the allowlist and is hidden across all four dimensions. It composes + * with everything else: administrative denials still apply on top of an + * allowlisted publisher, and protected actors (root, moderators) are never + * gated by the allowlist — the people who curate the list can always see. + * + * Use it for invite-only or curated surfaces where "unknown until vouched for" + * is the correct default. Populate `userAllow` exactly as you populate the + * denylist: signed contributions resolved from relays. + */ +export const ALLOWLIST_POLICY = createPolicyDefinition({ + id: "bitgate-allowlist", + name: "Allowlist only", + description: "Only allowlisted publishers are shown; every unlisted one is hidden.", + version: "1.0.0", + defaultProfile: "default", + profiles: { + default: { + name: "default", + requireAllowlist: true, + allowlistMiss: { visibility: "hide", interaction: "deny", transaction: "deny" }, + administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" }, + viewerBlock: { visibility: "hide", interaction: "deny" }, + allowViewerOverride: true, + reports: {}, + mutes: {}, + }, + }, +}); + /** * Presets addressable by name, for attribute-driven setup. * @type {Record} @@ -255,6 +290,7 @@ export const POLICY_PRESETS = { social: SOCIAL_POLICY, commerce: COMMERCE_POLICY, "admin-only": ADMIN_ONLY_POLICY, + allowlist: ALLOWLIST_POLICY, }; /** diff --git a/packages/core/test/presets.test.js b/packages/core/test/presets.test.js index d00407e..12014c6 100644 --- a/packages/core/test/presets.test.js +++ b/packages/core/test/presets.test.js @@ -5,6 +5,7 @@ import { reduceAdminState } from "../src/adminState.js"; import { createSnapshot, evaluateTarget } from "../src/evaluator.js"; import { ADMIN_ONLY_POLICY, + ALLOWLIST_POLICY, COMMERCE_POLICY, POLICY_PRESETS, SOCIAL_POLICY, @@ -65,7 +66,16 @@ describe("preset registry", () => { }); it("exposes every preset in the registry", () => { - expect(Object.keys(POLICY_PRESETS).sort()).toEqual(["admin-only", "commerce", "social"]); + expect(Object.keys(POLICY_PRESETS).sort()).toEqual([ + "admin-only", + "allowlist", + "commerce", + "social", + ]); + }); + + it("resolves the allowlist preset by name", () => { + expect(getPolicyPreset("allowlist")).toBe(ALLOWLIST_POLICY); }); }); @@ -198,6 +208,43 @@ describe("admin-only preset", () => { }); }); +describe("allowlist preset", () => { + const evaluateUser = (pubkey, contributions = []) => { + const auth = authority(); + const snapshot = createSnapshot({ authority: auth, admin: reduceAdminState(contributions, auth) }); + return evaluateTarget({ type: "user", pubkey }, snapshot, { + surface: "default", + policyProfile: "default", + policy: ALLOWLIST_POLICY, + now: NOW, + }); + }; + + it("hides an unlisted publisher across every dimension", () => { + const decision = evaluateUser(CREATOR); + expect(decision.visibility.effect).toBe("hide"); + expect(decision.interaction.effect).toBe("deny"); + expect(decision.transaction?.effect).toBe("deny"); + expect(decision.reasons.map((reason) => reason.id)).toContain("allowlist-miss"); + }); + + it("shows a publisher once an authorized actor allowlists them", () => { + const allow = [ + { actor: ROOT, kind: "user-allow", targets: [{ type: "user", pubkey: CREATOR }] }, + ]; + const decision = evaluateUser(CREATOR, /** @type {any} */ (allow)); + expect(decision.visibility.effect).toBe("allow"); + expect(decision.transaction?.effect ?? "allow").toBe("allow"); + expect(decision.evidence?.userAllowed).toBe(true); + }); + + it("never gates a protected actor by the allowlist", () => { + // ROOT is protected, so it shows even though nobody allowlisted it — the + // people who curate the list can always see. + expect(evaluateUser(ROOT).visibility.effect).toBe("allow"); + }); +}); + describe("category vocabulary", () => { // NIP-56 defines exactly these. A threshold for anything else can only fire // if a bespoke client emits it.