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 await provider.useSigner(window.nostr); // BitLogin's NIP-07 provider
``` ```
Policy presets: `social`, `commerce`, `admin-only`. Every number in them is a Policy presets: `social`, `commerce`, `admin-only`, and `allowlist` (only
starting point — see the [integration guide](docs/integration-guide.md) to allowlisted publishers are shown — the symmetric twin of a denylist, for
write your own. 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 ## 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 `bitblocks.js` and build a functional static Nostr application with almost no
infrastructure or integration boilerplate, while each individual library infrastructure or integration boilerplate, while each individual library
remains usable without BitBlocks. 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, SOCIAL_POLICY,
COMMERCE_POLICY, COMMERCE_POLICY,
ADMIN_ONLY_POLICY, ADMIN_ONLY_POLICY,
ALLOWLIST_POLICY,
POLICY_PRESETS, POLICY_PRESETS,
getPolicyPreset, getPolicyPreset,
} from "./presets.js"; } 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. * Presets addressable by name, for attribute-driven setup.
* @type {Record<string, import('./policy.js').PolicyDefinition>} * @type {Record<string, import('./policy.js').PolicyDefinition>}
@@ -255,6 +290,7 @@ export const POLICY_PRESETS = {
social: SOCIAL_POLICY, social: SOCIAL_POLICY,
commerce: COMMERCE_POLICY, commerce: COMMERCE_POLICY,
"admin-only": ADMIN_ONLY_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 { createSnapshot, evaluateTarget } from "../src/evaluator.js";
import { import {
ADMIN_ONLY_POLICY, ADMIN_ONLY_POLICY,
ALLOWLIST_POLICY,
COMMERCE_POLICY, COMMERCE_POLICY,
POLICY_PRESETS, POLICY_PRESETS,
SOCIAL_POLICY, SOCIAL_POLICY,
@@ -65,7 +66,16 @@ describe("preset registry", () => {
}); });
it("exposes every preset in the 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", () => { describe("category vocabulary", () => {
// NIP-56 defines exactly these. A threshold for anything else can only fire // NIP-56 defines exactly these. A threshold for anything else can only fire
// if a bespoke client emits it. // if a bespoke client emits it.