mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
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:
1 parent
8674d6b33e
commit
af2818fbce
5 files changed
+143
-4
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -89,6 +89,7 @@ export {
|
||||
SOCIAL_POLICY,
|
||||
COMMERCE_POLICY,
|
||||
ADMIN_ONLY_POLICY,
|
||||
ALLOWLIST_POLICY,
|
||||
POLICY_PRESETS,
|
||||
getPolicyPreset,
|
||||
} from "./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<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,
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in new issue
Block a user