core: add the allowlist policy preset

The engine already honored requireAllowlist + allowlistMiss, and the
commerce example configured a seller allowlist by hand -- but there was no
reusable, named "only show allowlisted publishers" preset, the symmetric
twin of the denylist. Integrators (Tessera) punted on allowlist mode for
lack of one.

Add ALLOWLIST_POLICY (registry name "allowlist"): a publisher is shown only
when an authorized actor has contributed a user-allow for them, and every
unlisted publisher misses and is hidden across all four dimensions.
Administrative denials still apply on top, and protected actors (root,
moderators) are never gated -- the people who curate the list can always
see. Populate userAllow exactly as the denylist: signed contributions
resolved from relays (e.g. NIP-32 allow labels).

Exported from @bitgate/core, registered in POLICY_PRESETS, documented in the
README, and covered by presets.test.js (unlisted hidden, allowlisted shown,
protected bypasses).

Addresses the "allowlist / whitelist mode" item of the Tessera integration
feedback (docs/TODO.md).

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 20:30:52 -04:00
1 parent 8674d6b33e
commit af2818fbce
5 files changed
+143 -4

No files matched your search

+4 -3
View File
@@ -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
+54
View File
@@ -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
`<bitgate-provider>`/`<bitgate-veil>` 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.
+1
View File
@@ -89,6 +89,7 @@ export {
SOCIAL_POLICY,
COMMERCE_POLICY,
ADMIN_ONLY_POLICY,
ALLOWLIST_POLICY,
POLICY_PRESETS,
getPolicyPreset,
} from "./presets.js";
+36
View File
@@ -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<string, import('./policy.js').PolicyDefinition>}
@@ -255,6 +290,7 @@ export const POLICY_PRESETS = {
social: SOCIAL_POLICY,
commerce: COMMERCE_POLICY,
"admin-only": ADMIN_ONLY_POLICY,
allowlist: ALLOWLIST_POLICY,
};
/**
+48 -1
View File
@@ -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.