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

+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.