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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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";
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in new issue
Block a user