mirror of
https://github.com/PR0M3TH3AN/bitgate.git
synced 2026-10-27 08:31:00 +00:00
feat: adapter contract, commerce proof-of-fit, and reusability docs
Proves the engine did not stay video-specific during extraction. - application adapter contract in core (createApplicationAdapter, evaluateObject, collectTargets): an object names the targets that govern it and the strictest verdict reaching any of them wins, so denying a seller takes their whole catalogue down with no cascade logic in the application - examples/commerce: a marketplace on the public API covering all ten proof-of-fit behaviors the plan requires, including that no core change was needed. One product, one snapshot, four surfaces, four different verdicts. - README, integration guide, per-example README, CHANGELOG, four ADRs, and GPL-3.0-or-later LICENSE (the license was listed in Phase 1 but missing) - checkpoints rewritten to reflect actual state and to say plainly which migration-plan phases belong to the consumer repos rather than here Fixes a real gap found by the proof-of-fit: composeDecisions kept only the first decision's evidence, so an object governed by several targets lost the evidence that drove its verdict. Evidence now merges — counts summed, contributor lists unioned, flags or-ed. 556 tests; lint, typecheck, and per-package type build clean.
This commit is contained in:
1 parent
4d460c12fe
commit
2beb7a7bb5
24 files changed
+2732
-48
No files matched your search
@@ -0,0 +1,68 @@
|
||||
# Commerce proof-of-fit
|
||||
|
||||
A marketplace built on `nostr-governance`, using only the published API.
|
||||
|
||||
The point is not to ship marketplace moderation. It is to prove the engine did
|
||||
not accidentally stay video-specific during extraction: if a commerce consumer
|
||||
needs a change to `@nostr-governance/core`, the abstraction is wrong.
|
||||
|
||||
## What it demonstrates
|
||||
|
||||
The migration plan lists ten behaviors a commerce consumer should show before
|
||||
`1.0.0`. Each is a numbered block in
|
||||
[`test/proof-of-fit.test.js`](test/proof-of-fit.test.js):
|
||||
|
||||
1. **Seller allowlist mode** — unlisted sellers are hidden from discovery, but
|
||||
the allowlist is not enforced at checkout
|
||||
2. **Administrative seller denial** — takes the seller's whole catalogue down
|
||||
while leaving them their own dashboard view
|
||||
3. **Product-address denial** — survives the seller republishing under a new
|
||||
event id, and does not touch their other listings
|
||||
4. **Exact-event denial** — affects only the named revision
|
||||
5. **Trusted scam-report downranking** — one report reorders, three block
|
||||
checkout; nothing is hidden on a single signal
|
||||
6. **Malware checkout denial** — one trusted malware report blocks the sale,
|
||||
while three "misleading" reports do not
|
||||
7. **Moderator capability checks** — a listing moderator cannot deny a seller;
|
||||
a seller moderator cannot deny a listing address
|
||||
8. **Community curator ingestion** — curated denials are honored, curator
|
||||
contributions beyond their capability are dropped, and the root cannot be
|
||||
denied through a curated list
|
||||
9. **Seller-dashboard explanation** — the seller sees who reported and why;
|
||||
public surfaces see only counts
|
||||
10. **No core changes required** — four different verdicts for one product from
|
||||
one snapshot, using only exported functions
|
||||
|
||||
## Files
|
||||
|
||||
| File | Contents |
|
||||
| --- | --- |
|
||||
| `src/policy.js` | Marketplace thresholds and roles. None of these numbers exist in the core. |
|
||||
| `src/adapters.js` | Products, storefronts, reviews, and sellers as governance targets. |
|
||||
| `src/marketplace.js` | Feed pipeline, grid ordering, checkout, seller explanation. |
|
||||
|
||||
## The shape worth copying
|
||||
|
||||
A product answers to three targets — its seller, its exact event, and its
|
||||
address coordinate — and the composed decision is the strictest verdict reaching
|
||||
it. That is what makes "deny the seller" remove every listing without a line of
|
||||
cascade logic in the application.
|
||||
|
||||
One product, one snapshot, four surfaces:
|
||||
|
||||
| Surface | Visibility | Transaction |
|
||||
| --- | --- | --- |
|
||||
| `public-marketplace` | `restrict` | `deny` |
|
||||
| `product-detail` | `restrict`, overridable | `deny` |
|
||||
| `checkout` | `allow` | `deny` |
|
||||
| `seller-dashboard` | `restrict`, with evidence | `deny` |
|
||||
|
||||
Reviews deliberately answer to their reviewer and their own event — never to the
|
||||
product they review. Otherwise denying a bad product would silence the reviews
|
||||
warning people about it.
|
||||
|
||||
## Run it
|
||||
|
||||
```bash
|
||||
npm test -- examples/commerce
|
||||
```
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@nostr-governance/example-commerce",
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Proof-of-fit: the governance engine driving a marketplace, using only the public API.",
|
||||
"main": "src/marketplace.js",
|
||||
"scripts": {
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nostr-governance/core": "*",
|
||||
"@nostr-governance/runtime": "*"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
// Commerce application adapters.
|
||||
//
|
||||
// Each adapter answers two questions: which governance targets stand behind
|
||||
// this object, and what does a decision mean for it. Note what is absent —
|
||||
// no thresholds, no precedence rules, no policy logic. Those live in the
|
||||
// policy definition and the engine respectively.
|
||||
|
||||
import { createApplicationAdapter } from "@nostr-governance/core";
|
||||
|
||||
/** Parameterized-replaceable kind used for commerce documents in this example. */
|
||||
export const PRODUCT_KIND = 30078;
|
||||
|
||||
/**
|
||||
* @typedef {Object} Product
|
||||
* @property {string} id - Event id of the current revision
|
||||
* @property {string} sellerPubkey
|
||||
* @property {string} identifier - d-tag, stable across revisions
|
||||
* @property {string} title
|
||||
* @property {number} priceSats
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Storefront
|
||||
* @property {string} id
|
||||
* @property {string} sellerPubkey
|
||||
* @property {string} identifier
|
||||
* @property {string} name
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} Review
|
||||
* @property {string} id
|
||||
* @property {string} reviewerPubkey
|
||||
* @property {string} productIdentifier
|
||||
* @property {number} rating
|
||||
*/
|
||||
|
||||
/**
|
||||
* Products answer to three targets.
|
||||
*
|
||||
* The address is what persists across revisions: denying it survives the seller
|
||||
* republishing the listing under a new event id, which denying the exact event
|
||||
* would not. Both are kept, because an exact-event denial must also stay
|
||||
* possible for the case where only one revision was bad.
|
||||
*
|
||||
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Product>}
|
||||
*/
|
||||
export const productAdapter = createApplicationAdapter({
|
||||
applicationId: "commerce-example",
|
||||
|
||||
toTargets(product) {
|
||||
return [
|
||||
{ type: "user", pubkey: product.sellerPubkey },
|
||||
{ type: "event", id: product.id, author: product.sellerPubkey, kind: PRODUCT_KIND },
|
||||
{
|
||||
type: "address",
|
||||
kind: String(PRODUCT_KIND),
|
||||
pubkey: product.sellerPubkey,
|
||||
identifier: product.identifier,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
getPrimaryTargetKey(product) {
|
||||
return `address:${PRODUCT_KIND}:${product.sellerPubkey}:${product.identifier}`;
|
||||
},
|
||||
|
||||
applyDecision(product, decision) {
|
||||
return {
|
||||
...product,
|
||||
moderation: {
|
||||
visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
|
||||
warn: decision.visibility.effect === "warn",
|
||||
blurred: decision.visibility.effect === "restrict",
|
||||
downranked: decision.ranking.effect === "downrank",
|
||||
rankPenalty: decision.ranking.weight,
|
||||
checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
|
||||
checkoutNeedsReview: decision.transaction?.effect === "require-review",
|
||||
reasons: decision.reasons.map((reason) => reason.id),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Storefronts answer to the seller, the storefront address, and the exact event.
|
||||
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Storefront>}
|
||||
*/
|
||||
export const storefrontAdapter = createApplicationAdapter({
|
||||
applicationId: "commerce-example",
|
||||
|
||||
toTargets(storefront) {
|
||||
return [
|
||||
{ type: "user", pubkey: storefront.sellerPubkey },
|
||||
{ type: "event", id: storefront.id, author: storefront.sellerPubkey, kind: PRODUCT_KIND },
|
||||
{
|
||||
type: "address",
|
||||
kind: String(PRODUCT_KIND),
|
||||
pubkey: storefront.sellerPubkey,
|
||||
identifier: storefront.identifier,
|
||||
},
|
||||
];
|
||||
},
|
||||
|
||||
getPrimaryTargetKey(storefront) {
|
||||
return `address:${PRODUCT_KIND}:${storefront.sellerPubkey}:${storefront.identifier}`;
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Reviews answer to the reviewer and the exact review event.
|
||||
*
|
||||
* Deliberately not to the reviewed product: a bad product should not silence
|
||||
* honest reviews of it, which is exactly the outcome an over-broad target list
|
||||
* would produce.
|
||||
*
|
||||
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<Review>}
|
||||
*/
|
||||
export const reviewAdapter = createApplicationAdapter({
|
||||
applicationId: "commerce-example",
|
||||
|
||||
toTargets(review) {
|
||||
return [
|
||||
{ type: "user", pubkey: review.reviewerPubkey },
|
||||
{ type: "event", id: review.id, author: review.reviewerPubkey, kind: PRODUCT_KIND },
|
||||
];
|
||||
},
|
||||
|
||||
getPrimaryTargetKey(review) {
|
||||
return `event:${review.id}`;
|
||||
},
|
||||
|
||||
applyDecision(review, decision) {
|
||||
return {
|
||||
...review,
|
||||
suppressed: decision.visibility.effect === "hide" || decision.visibility.effect === "deny",
|
||||
downranked: decision.ranking.effect === "downrank",
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Sellers answer only to their own user target.
|
||||
* @type {import('@nostr-governance/core').GovernanceApplicationAdapter<{ pubkey: string }>}
|
||||
*/
|
||||
export const sellerAdapter = createApplicationAdapter({
|
||||
applicationId: "commerce-example",
|
||||
|
||||
toTargets(seller) {
|
||||
return [{ type: "user", pubkey: seller.pubkey }];
|
||||
},
|
||||
|
||||
getPrimaryTargetKey(seller) {
|
||||
return `user:${seller.pubkey}`;
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
// Marketplace pipeline.
|
||||
//
|
||||
// The application's job is reduced to: collect targets, ask the engine, apply
|
||||
// the answer. It computes no thresholds and reimplements no precedence — which
|
||||
// is the property the extraction is meant to guarantee.
|
||||
|
||||
import { collectTargets, evaluateObject } from "@nostr-governance/core";
|
||||
|
||||
import { productAdapter } from "./adapters.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('./adapters.js').Product} Product
|
||||
*/
|
||||
|
||||
/**
|
||||
* Govern a product feed for a surface.
|
||||
*
|
||||
* Targets are collected first and handed to the runtime in one call, so a
|
||||
* hundred-item page opens a bounded number of subscriptions rather than one per
|
||||
* listing.
|
||||
*
|
||||
* @param {Product[]} products
|
||||
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
|
||||
* @param {string} profile
|
||||
* @returns {Array<Product & { moderation: any }>}
|
||||
*/
|
||||
export function governProductFeed(products, runtime, profile) {
|
||||
runtime.setActiveTargets(collectTargets(products, productAdapter));
|
||||
|
||||
return products.map((product) => {
|
||||
const decision = evaluateObject(product, productAdapter, (target) =>
|
||||
runtime.evaluate(target, { profile }),
|
||||
);
|
||||
return /** @type {any} */ (productAdapter.applyDecision?.(product, decision) ?? product);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Render a marketplace grid: drop hidden listings, order downranked ones last.
|
||||
*
|
||||
* Downranking is a sort input, not a filter. A downranked listing still appears
|
||||
* and can still be bought — conflating the two is what makes moderation feel
|
||||
* like censorship to sellers.
|
||||
*
|
||||
* @param {Product[]} products
|
||||
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
|
||||
* @returns {Array<Product & { moderation: any }>}
|
||||
*/
|
||||
export function buildMarketplaceGrid(products, runtime) {
|
||||
return governProductFeed(products, runtime, "public-marketplace")
|
||||
.filter((product) => product.moderation.visible)
|
||||
.sort((a, b) => a.moderation.rankPenalty - b.moderation.rankPenalty);
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {Object} CheckoutVerdict
|
||||
* @property {boolean} allowed
|
||||
* @property {boolean} needsReview
|
||||
* @property {string[]} reasons
|
||||
*/
|
||||
|
||||
/**
|
||||
* Check a product at checkout.
|
||||
*
|
||||
* Evaluated freshly against the checkout profile rather than reusing the
|
||||
* listing page's decision: state may have changed since the page rendered, and
|
||||
* checkout is the point where being wrong actually costs someone money.
|
||||
*
|
||||
* @param {Product} product
|
||||
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
|
||||
* @returns {CheckoutVerdict}
|
||||
*/
|
||||
export function checkoutVerdict(product, runtime) {
|
||||
const decision = evaluateObject(product, productAdapter, (target) =>
|
||||
runtime.evaluate(target, { profile: "checkout" }),
|
||||
);
|
||||
|
||||
const effect = decision.transaction?.effect ?? "allow";
|
||||
return {
|
||||
allowed: effect === "allow",
|
||||
needsReview: effect === "require-review",
|
||||
reasons: decision.reasons.map((reason) => reason.id),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Explain a product's status to its seller.
|
||||
*
|
||||
* The seller-dashboard profile exposes evidence, so this is the one surface
|
||||
* that can answer "how many people reported me, and for what".
|
||||
*
|
||||
* @param {Product} product
|
||||
* @param {import('@nostr-governance/runtime').GovernanceRuntime} runtime
|
||||
* @returns {{ visible: boolean, sellable: boolean, reasons: string[], evidence: any }}
|
||||
*/
|
||||
export function explainToSeller(product, runtime) {
|
||||
const decision = evaluateObject(product, productAdapter, (target) =>
|
||||
runtime.evaluate(target, { profile: "seller-dashboard" }),
|
||||
);
|
||||
|
||||
return {
|
||||
visible: decision.visibility.effect !== "hide" && decision.visibility.effect !== "deny",
|
||||
sellable: (decision.transaction?.effect ?? "allow") === "allow",
|
||||
reasons: decision.reasons.map((reason) => reason.id),
|
||||
evidence: decision.evidence,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
// Commerce policy profiles.
|
||||
//
|
||||
// This example exists to prove the engine is not video-specific. Nothing here
|
||||
// touches @nostr-governance/core internals: it is built entirely from the
|
||||
// public API, and the core carries none of these numbers.
|
||||
//
|
||||
// The interesting property is that one product can simultaneously be visible to
|
||||
// its seller, downranked in discovery, inspectable on its detail page, and
|
||||
// blocked from checkout — four different answers from one decision, which a
|
||||
// single `hidden` boolean could not express.
|
||||
|
||||
import { createPolicyDefinition, createRoleDefinition } from "@nostr-governance/core";
|
||||
|
||||
/**
|
||||
* Category thresholds for a marketplace.
|
||||
*
|
||||
* Malware is treated far more harshly than misleading copy: one trusted report
|
||||
* blocks checkout, because the cost of a false negative is a compromised buyer,
|
||||
* while an over-eager `misleading` threshold would just suppress lawful listings.
|
||||
*
|
||||
* @type {Record<string, import('@nostr-governance/core').CategoryThresholds>}
|
||||
*/
|
||||
export const MARKETPLACE_REPORT_THRESHOLDS = {
|
||||
scam: {
|
||||
downrank: 1,
|
||||
warn: 2,
|
||||
restrict: 3,
|
||||
hide: 5,
|
||||
transactionReview: 2,
|
||||
transactionDeny: 3,
|
||||
},
|
||||
malware: {
|
||||
warn: 1,
|
||||
restrict: 1,
|
||||
hide: 2,
|
||||
transactionDeny: 1,
|
||||
},
|
||||
misleading: {
|
||||
downrank: 2,
|
||||
warn: 3,
|
||||
restrict: 5,
|
||||
hide: 8,
|
||||
},
|
||||
"not-as-described": {
|
||||
downrank: 2,
|
||||
warn: 4,
|
||||
transactionReview: 5,
|
||||
},
|
||||
default: {
|
||||
downrank: 2,
|
||||
warn: 4,
|
||||
},
|
||||
};
|
||||
|
||||
/** Trusted mutes downrank a seller but never block a sale on their own. */
|
||||
const MARKETPLACE_MUTE_THRESHOLDS = {
|
||||
default: { downrank: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* The marketplace policy.
|
||||
*
|
||||
* Every surface reads the same evidence and reaches a different conclusion,
|
||||
* which is the whole point of profiles.
|
||||
*/
|
||||
export const COMMERCE_POLICY = createPolicyDefinition({
|
||||
id: "commerce-example",
|
||||
name: "Commerce example",
|
||||
description: "Marketplace policy demonstrating the engine outside a video application.",
|
||||
version: "1.0.0",
|
||||
defaultProfile: "public-marketplace",
|
||||
profiles: {
|
||||
// Discovery: denied sellers disappear entirely and cannot transact.
|
||||
"public-marketplace": {
|
||||
name: "public-marketplace",
|
||||
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
|
||||
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||
allowViewerOverride: false,
|
||||
requireAllowlist: true,
|
||||
allowlistMiss: { visibility: "hide", interaction: "deny", transaction: "deny" },
|
||||
reports: MARKETPLACE_REPORT_THRESHOLDS,
|
||||
mutes: MARKETPLACE_MUTE_THRESHOLDS,
|
||||
},
|
||||
|
||||
// Detail page: a shopper who followed a direct link may still inspect the
|
||||
// listing behind a warning, but cannot buy it.
|
||||
"product-detail": {
|
||||
name: "product-detail",
|
||||
administrativeDeny: { visibility: "restrict", interaction: "require-explicit-action", transaction: "deny" },
|
||||
viewerBlock: { visibility: "hide", interaction: "deny" },
|
||||
allowViewerOverride: true,
|
||||
reports: MARKETPLACE_REPORT_THRESHOLDS,
|
||||
mutes: MARKETPLACE_MUTE_THRESHOLDS,
|
||||
},
|
||||
|
||||
// Checkout: visibility is irrelevant; only the transaction verdict matters,
|
||||
// and it is re-checked here rather than trusted from the listing page.
|
||||
checkout: {
|
||||
name: "checkout",
|
||||
administrativeDeny: { visibility: "allow", transaction: "deny" },
|
||||
allowViewerOverride: false,
|
||||
reports: MARKETPLACE_REPORT_THRESHOLDS,
|
||||
mutes: {},
|
||||
},
|
||||
|
||||
// Seller dashboard: the seller can always see their own listing and is told
|
||||
// why it is restricted. An appeals screen that hides the evidence is useless.
|
||||
"seller-dashboard": {
|
||||
name: "seller-dashboard",
|
||||
administrativeDeny: { visibility: "warn", interaction: "allow", transaction: "deny" },
|
||||
allowViewerOverride: false,
|
||||
exposeEvidence: true,
|
||||
reports: MARKETPLACE_REPORT_THRESHOLDS,
|
||||
mutes: MARKETPLACE_MUTE_THRESHOLDS,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Marketplace-specific roles, built from the generic capability vocabulary.
|
||||
*
|
||||
* These roles do not exist in the core; an application invents the roles it
|
||||
* needs and the engine enforces them.
|
||||
*/
|
||||
export const COMMERCE_ROLES = {
|
||||
listing_moderator: createRoleDefinition("listing_moderator", [
|
||||
"contribute-event-deny",
|
||||
"contribute-address-deny",
|
||||
"review-evidence",
|
||||
]),
|
||||
seller_moderator: createRoleDefinition("seller_moderator", [
|
||||
"contribute-user-deny",
|
||||
"review-evidence",
|
||||
]),
|
||||
community_curator: createRoleDefinition("community_curator", ["contribute-user-deny"]),
|
||||
};
|
||||
|
||||
/**
|
||||
* Role capability map in the shape createAuthorityState expects.
|
||||
* @type {Record<string, import('@nostr-governance/core').GovernanceCapability[]>}
|
||||
*/
|
||||
export const COMMERCE_ROLE_CAPABILITIES = Object.fromEntries(
|
||||
Object.values(COMMERCE_ROLES).map((role) => [role.name, role.capabilities]),
|
||||
);
|
||||
@@ -0,0 +1,508 @@
|
||||
// Commerce proof-of-fit.
|
||||
//
|
||||
// The plan requires a commerce consumer to demonstrate ten behaviors before the
|
||||
// package reaches 1.0.0, the tenth being that none of it needs a change to the
|
||||
// generic core. Each numbered case below is one of those requirements.
|
||||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createCommands, createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
|
||||
|
||||
import { COMMERCE_POLICY, COMMERCE_ROLE_CAPABILITIES } from "../src/policy.js";
|
||||
import { productAdapter, reviewAdapter, sellerAdapter } from "../src/adapters.js";
|
||||
import {
|
||||
buildMarketplaceGrid,
|
||||
checkoutVerdict,
|
||||
explainToSeller,
|
||||
governProductFeed,
|
||||
} from "../src/marketplace.js";
|
||||
|
||||
const ROOT = "a1".repeat(32);
|
||||
const LISTING_MOD = "b2".repeat(32);
|
||||
const SELLER_MOD = "c3".repeat(32);
|
||||
const CURATOR = "e5".repeat(32);
|
||||
const SELLER = "d4".repeat(32);
|
||||
const OTHER_SELLER = "f6".repeat(32);
|
||||
const BUYER = "ab".repeat(32);
|
||||
const NOW = 1_750_000_000;
|
||||
|
||||
const trusted = (index) => index.toString(16).padStart(2, "0").repeat(32);
|
||||
const TRUSTED = Array.from({ length: 8 }, (_, index) => trusted(index + 1));
|
||||
|
||||
/** @type {import('../src/adapters.js').Product} */
|
||||
const PRODUCT = {
|
||||
id: "1b".repeat(32),
|
||||
sellerPubkey: SELLER,
|
||||
identifier: "sku-001",
|
||||
title: "Widget",
|
||||
priceSats: 1000,
|
||||
};
|
||||
|
||||
/** @type {import('../src/adapters.js').Product} */
|
||||
const OTHER_PRODUCT = {
|
||||
id: "2c".repeat(32),
|
||||
sellerPubkey: SELLER,
|
||||
identifier: "sku-002",
|
||||
title: "Gadget",
|
||||
priceSats: 2000,
|
||||
};
|
||||
|
||||
/** @type {import('../src/adapters.js').Product} */
|
||||
const RIVAL_PRODUCT = {
|
||||
id: "3d".repeat(32),
|
||||
sellerPubkey: OTHER_SELLER,
|
||||
identifier: "sku-100",
|
||||
title: "Rival widget",
|
||||
priceSats: 900,
|
||||
};
|
||||
|
||||
function setup({ allowlisted = [SELLER, OTHER_SELLER] } = {}) {
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "commerce-example",
|
||||
namespace: "commerce",
|
||||
transport: createMemoryTransport(),
|
||||
policy: COMMERCE_POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
|
||||
runtime.admin.setRoles({
|
||||
root: ROOT,
|
||||
roles: COMMERCE_ROLE_CAPABILITIES,
|
||||
actors: {
|
||||
[ROOT]: ["super_admin"],
|
||||
[LISTING_MOD]: ["listing_moderator"],
|
||||
[SELLER_MOD]: ["seller_moderator"],
|
||||
[CURATOR]: ["community_curator"],
|
||||
},
|
||||
});
|
||||
|
||||
// Roles here are application-defined, so super_admin needs its default bundle
|
||||
// restored alongside them.
|
||||
runtime.admin.setRoles({
|
||||
root: ROOT,
|
||||
roles: {
|
||||
...COMMERCE_ROLE_CAPABILITIES,
|
||||
super_admin: [
|
||||
"manage-roles",
|
||||
"manage-policy",
|
||||
"manage-community-sources",
|
||||
"contribute-user-allow",
|
||||
"contribute-user-deny",
|
||||
"contribute-event-deny",
|
||||
"contribute-address-deny",
|
||||
"contribute-trust-seed",
|
||||
"review-evidence",
|
||||
],
|
||||
},
|
||||
actors: {
|
||||
[ROOT]: ["super_admin"],
|
||||
[LISTING_MOD]: ["listing_moderator"],
|
||||
[SELLER_MOD]: ["seller_moderator"],
|
||||
[CURATOR]: ["community_curator"],
|
||||
},
|
||||
});
|
||||
|
||||
if (allowlisted.length) {
|
||||
runtime.admin.upsertContribution({
|
||||
actor: ROOT,
|
||||
kind: "user-allow",
|
||||
targets: allowlisted.map((pubkey) => ({ type: "user", pubkey })),
|
||||
createdAt: NOW,
|
||||
});
|
||||
}
|
||||
|
||||
// Set the viewer first: switching viewers clears viewer-scoped trust state,
|
||||
// so seeding contacts beforehand would be wiped.
|
||||
runtime.setViewer(BUYER);
|
||||
runtime.trust.setContacts(TRUSTED);
|
||||
|
||||
return { runtime, commands: createCommands(runtime) };
|
||||
}
|
||||
|
||||
/** Report a target from `count` distinct trusted accounts. */
|
||||
function reportFrom(runtime, targetKey, category, count) {
|
||||
for (const reporter of TRUSTED.slice(0, count)) {
|
||||
runtime.reports.ingest({ reporter, category, createdAt: NOW - 100 }, targetKey);
|
||||
}
|
||||
}
|
||||
|
||||
const productAddressKey = (product) =>
|
||||
`address:30078:${product.sellerPubkey}:${product.identifier}`;
|
||||
|
||||
describe("1. seller allowlist mode", () => {
|
||||
it("hides a seller who is not allowlisted", () => {
|
||||
const { runtime } = setup({ allowlisted: [] });
|
||||
const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(governed.moderation.visible).toBe(false);
|
||||
expect(governed.moderation.reasons).toContain("allowlist-miss");
|
||||
});
|
||||
|
||||
it("shows an allowlisted seller", () => {
|
||||
const { runtime } = setup();
|
||||
const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(governed.moderation.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("does not enforce the allowlist on checkout", () => {
|
||||
const { runtime } = setup({ allowlisted: [] });
|
||||
expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("2. administrative seller denial", () => {
|
||||
it("removes every product from a denied seller", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: SELLER_MOD,
|
||||
kind: "user-deny",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const grid = buildMarketplaceGrid([PRODUCT, OTHER_PRODUCT, RIVAL_PRODUCT], runtime);
|
||||
expect(grid.map((product) => product.identifier)).toEqual(["sku-100"]);
|
||||
});
|
||||
|
||||
it("blocks checkout for the denied seller", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: SELLER_MOD,
|
||||
kind: "user-deny",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("leaves the seller their own dashboard view", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: SELLER_MOD,
|
||||
kind: "user-deny",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const explanation = explainToSeller(PRODUCT, runtime);
|
||||
expect(explanation.visible).toBe(true);
|
||||
expect(explanation.sellable).toBe(false);
|
||||
expect(explanation.reasons).toContain("admin-user-deny");
|
||||
});
|
||||
});
|
||||
|
||||
describe("3. product-address denial", () => {
|
||||
it("denies one listing without touching the seller's others", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: LISTING_MOD,
|
||||
kind: "address-deny",
|
||||
targets: [
|
||||
{ type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
|
||||
],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const grid = buildMarketplaceGrid([PRODUCT, OTHER_PRODUCT], runtime);
|
||||
expect(grid.map((product) => product.identifier)).toEqual(["sku-002"]);
|
||||
});
|
||||
|
||||
it("persists across a revision that changes the event id", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: LISTING_MOD,
|
||||
kind: "address-deny",
|
||||
targets: [
|
||||
{ type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
|
||||
],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const republished = { ...PRODUCT, id: "99".repeat(32), title: "Widget v2" };
|
||||
const [governed] = governProductFeed([republished], runtime, "public-marketplace");
|
||||
expect(governed.moderation.visible).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("4. exact-event denial", () => {
|
||||
it("affects only the named revision", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: LISTING_MOD,
|
||||
kind: "event-deny",
|
||||
targets: [{ type: "event", id: PRODUCT.id }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const [denied] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(denied.moderation.visible).toBe(false);
|
||||
|
||||
const republished = { ...PRODUCT, id: "99".repeat(32) };
|
||||
const [fresh] = governProductFeed([republished], runtime, "public-marketplace");
|
||||
expect(fresh.moderation.visible).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("5. trusted scam-report downranking", () => {
|
||||
it("downranks on a single trusted scam report without hiding", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 1);
|
||||
|
||||
const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(governed.moderation.downranked).toBe(true);
|
||||
expect(governed.moderation.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("orders downranked listings after clean ones", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 1);
|
||||
|
||||
const grid = buildMarketplaceGrid([PRODUCT, RIVAL_PRODUCT], runtime);
|
||||
expect(grid.map((product) => product.identifier)).toEqual(["sku-100", "sku-001"]);
|
||||
});
|
||||
|
||||
it("escalates to review then denial as reports accumulate", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 2);
|
||||
expect(checkoutVerdict(PRODUCT, runtime).needsReview).toBe(true);
|
||||
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
|
||||
expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("6. malware-based checkout denial", () => {
|
||||
it("blocks checkout on a single trusted malware report", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "malware", 1);
|
||||
expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(false);
|
||||
});
|
||||
|
||||
it("treats malware far more harshly than misleading copy", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "misleading", 3);
|
||||
expect(checkoutVerdict(PRODUCT, runtime).allowed).toBe(true);
|
||||
|
||||
reportFrom(runtime, productAddressKey(RIVAL_PRODUCT), "malware", 1);
|
||||
expect(checkoutVerdict(RIVAL_PRODUCT, runtime).allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("7. moderator capability checks", () => {
|
||||
const signerFor = (pubkey) => ({
|
||||
async getPublicKey() {
|
||||
return pubkey;
|
||||
},
|
||||
async signEvent(template) {
|
||||
return { ...template, id: "ff".repeat(32), pubkey, sig: "00".repeat(64) };
|
||||
},
|
||||
});
|
||||
|
||||
function commandsAs(pubkey) {
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "commerce-example",
|
||||
namespace: "commerce",
|
||||
transport: createMemoryTransport(),
|
||||
signer: signerFor(pubkey),
|
||||
policy: COMMERCE_POLICY,
|
||||
now: () => NOW,
|
||||
});
|
||||
runtime.admin.setRoles({
|
||||
root: ROOT,
|
||||
roles: COMMERCE_ROLE_CAPABILITIES,
|
||||
actors: {
|
||||
[LISTING_MOD]: ["listing_moderator"],
|
||||
[SELLER_MOD]: ["seller_moderator"],
|
||||
[CURATOR]: ["community_curator"],
|
||||
},
|
||||
});
|
||||
return createCommands(runtime);
|
||||
}
|
||||
|
||||
it("lets a listing moderator deny an address", async () => {
|
||||
expect((await commandsAs(LISTING_MOD).denyAddress("30078", SELLER, "sku-001")).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("stops a listing moderator denying a seller", async () => {
|
||||
expect((await commandsAs(LISTING_MOD).denyUser(SELLER)).code).toBe("not-authorized");
|
||||
});
|
||||
|
||||
it("lets a seller moderator deny a seller", async () => {
|
||||
expect((await commandsAs(SELLER_MOD).denyUser(SELLER)).ok).toBe(true);
|
||||
});
|
||||
|
||||
it("stops a seller moderator denying a listing address", async () => {
|
||||
expect((await commandsAs(SELLER_MOD).denyAddress("30078", SELLER, "sku-001")).code).toBe(
|
||||
"not-authorized",
|
||||
);
|
||||
});
|
||||
|
||||
it("stops a curator denying an exact event", async () => {
|
||||
expect((await commandsAs(CURATOR).denyEvent("1b".repeat(32))).code).toBe("not-authorized");
|
||||
});
|
||||
|
||||
it("stops any moderator changing roles", async () => {
|
||||
expect((await commandsAs(SELLER_MOD).setRoles({ actors: {} })).code).toBe("not-authorized");
|
||||
});
|
||||
});
|
||||
|
||||
describe("8. community curator ingestion", () => {
|
||||
it("honors a curator's seller denial", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: CURATOR,
|
||||
kind: "user-deny",
|
||||
source: "curated-scam-list",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(governed.moderation.visible).toBe(false);
|
||||
expect(governed.moderation.reasons).toContain("community-user-deny");
|
||||
});
|
||||
|
||||
it("ignores a curator contribution beyond their capability", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: CURATOR,
|
||||
kind: "address-deny",
|
||||
source: "curated-scam-list",
|
||||
targets: [
|
||||
{ type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
|
||||
],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const [governed] = governProductFeed([PRODUCT], runtime, "public-marketplace");
|
||||
expect(governed.moderation.visible).toBe(true);
|
||||
});
|
||||
|
||||
it("cannot deny the root through a curated list", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: CURATOR,
|
||||
kind: "user-deny",
|
||||
source: "hostile-list",
|
||||
targets: [{ type: "user", pubkey: ROOT }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
expect(runtime.admin.state.userDeny.has(`user:${ROOT}`)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("9. seller-dashboard explanation", () => {
|
||||
it("exposes evidence counts to the seller", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
|
||||
|
||||
const explanation = explainToSeller(PRODUCT, runtime);
|
||||
expect(explanation.evidence.trustedReportTotal).toBe(3);
|
||||
expect(explanation.evidence.trustedReportsByCategory.scam).toBe(3);
|
||||
expect(explanation.evidence.trustedReporterPubkeys).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("withholds reporter identities from public surfaces", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
|
||||
|
||||
const decision = runtime.evaluate(
|
||||
{ type: "address", kind: "30078", pubkey: SELLER, identifier: PRODUCT.identifier },
|
||||
{ profile: "public-marketplace" },
|
||||
);
|
||||
expect(decision.evidence?.trustedReportTotal).toBe(3);
|
||||
expect(decision.evidence?.trustedReporterPubkeys).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("10. no core changes required", () => {
|
||||
it("expresses four different verdicts for one product from one snapshot", () => {
|
||||
const { runtime } = setup();
|
||||
reportFrom(runtime, productAddressKey(PRODUCT), "scam", 3);
|
||||
|
||||
const target = {
|
||||
type: "address",
|
||||
kind: "30078",
|
||||
pubkey: SELLER,
|
||||
identifier: PRODUCT.identifier,
|
||||
};
|
||||
|
||||
expect(runtime.evaluate(target, { profile: "public-marketplace" }).visibility.effect).toBe(
|
||||
"restrict",
|
||||
);
|
||||
expect(runtime.evaluate(target, { profile: "seller-dashboard" }).visibility.effect).toBe(
|
||||
"restrict",
|
||||
);
|
||||
expect(runtime.evaluate(target, { profile: "checkout" }).transaction?.effect).toBe("deny");
|
||||
expect(runtime.evaluate(target, { profile: "product-detail" }).visibility.overridable).toBe(true);
|
||||
});
|
||||
|
||||
it("governs reviews without letting a bad product silence them", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: SELLER_MOD,
|
||||
kind: "user-deny",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const review = {
|
||||
id: "7f".repeat(32),
|
||||
reviewerPubkey: BUYER,
|
||||
productIdentifier: PRODUCT.identifier,
|
||||
rating: 1,
|
||||
};
|
||||
|
||||
const decision = runtime.evaluate(reviewAdapter.toTargets(review)[0], {
|
||||
profile: "product-detail",
|
||||
});
|
||||
expect(decision.visibility.effect).not.toBe("hide");
|
||||
});
|
||||
|
||||
it("uses only the published core API", async () => {
|
||||
// Every import in this example resolves through a package entry point; if
|
||||
// one reached into a package's internals, the reference guard and this
|
||||
// assertion are what would catch it.
|
||||
const core = await import("@nostr-governance/core");
|
||||
for (const name of [
|
||||
"createApplicationAdapter",
|
||||
"evaluateObject",
|
||||
"collectTargets",
|
||||
"createPolicyDefinition",
|
||||
"createRoleDefinition",
|
||||
]) {
|
||||
expect(typeof core[name]).toBe("function");
|
||||
}
|
||||
});
|
||||
|
||||
it("governs a seller through the same engine as a product", () => {
|
||||
const { runtime } = setup();
|
||||
runtime.admin.upsertContribution({
|
||||
actor: SELLER_MOD,
|
||||
kind: "user-deny",
|
||||
targets: [{ type: "user", pubkey: SELLER }],
|
||||
createdAt: NOW,
|
||||
});
|
||||
|
||||
const decision = runtime.evaluate(sellerAdapter.toTargets({ pubkey: SELLER })[0], {
|
||||
profile: "public-marketplace",
|
||||
});
|
||||
expect(decision.visibility.effect).toBe("hide");
|
||||
});
|
||||
});
|
||||
|
||||
describe("adapter composition", () => {
|
||||
it("reports decisions against the product's own identity", () => {
|
||||
const { runtime } = setup();
|
||||
const decision = runtime.evaluate(productAdapter.toTargets(PRODUCT)[2], {
|
||||
profile: "public-marketplace",
|
||||
});
|
||||
expect(decision.key).toBe(productAddressKey(PRODUCT));
|
||||
});
|
||||
|
||||
it("collects deduplicated targets across a feed", () => {
|
||||
const { runtime } = setup();
|
||||
governProductFeed([PRODUCT, OTHER_PRODUCT], runtime, "public-marketplace");
|
||||
// Two products from one seller: 2 events + 2 addresses + 1 shared seller.
|
||||
expect(runtime.activeTargets.size).toBe(5);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user