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,63 @@
|
||||
# Changelog
|
||||
|
||||
Notable changes to this repository. The package version and the wire schema
|
||||
version move independently: a breaking package release does not imply a new
|
||||
event schema, and a new schema does not require a major package bump unless the
|
||||
public API changes with it.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **Core decision model.** Four independent dimensions (`ranking`, `visibility`,
|
||||
`interaction`, `transaction`) with severity ladders and commutative
|
||||
max-merge composition.
|
||||
- **Stable reason identifiers** (`REASON_IDS`) as part of the public contract.
|
||||
Renaming or removing one is a breaking change.
|
||||
- **Structured evidence**, redacted by default and merged across an object's
|
||||
targets, so a multi-target object explains itself with everything that
|
||||
contributed.
|
||||
- **Administrative-state reduction** with capability filtering at merge time,
|
||||
community-source marking, and protected-actor stripping.
|
||||
- **Snapshot fingerprints** for cache invalidation and mismatch records.
|
||||
- **Application adapter contract** (`createApplicationAdapter`,
|
||||
`evaluateObject`, `collectTargets`).
|
||||
- **Codecs**: canonical v1 on `kind:30078`, NIP-56 reports, NIP-51 mute lists,
|
||||
legacy administrative lists, bech32/NIP-19, replaceable selection with a
|
||||
deterministic same-timestamp tiebreak, injected signature verification.
|
||||
- **Runtime**: six stores, transport orchestration, chunked report
|
||||
subscriptions, viewer lifecycle, diagnostics, idempotent teardown.
|
||||
- **Commands** with local capability checks, protected-target refusal, partial
|
||||
relay acceptance, and stable error codes.
|
||||
- **Characterization corpus**: 34 generated fixtures covering the behaviors the
|
||||
migration plan's Phase 0 requires, plus a conformance suite that evaluates
|
||||
them.
|
||||
- **Commerce proof-of-fit** (`examples/commerce`) demonstrating the engine
|
||||
outside a video application using only the public API.
|
||||
- GPL-3.0-or-later `LICENSE`, `README`, and an integration guide.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Per-package `types` fields pointed at paths the build never produced, so
|
||||
consumers received no type declarations at all. Declarations now emit per
|
||||
package and resolve.
|
||||
- `reduceAdminState` accepted malformed target identifiers, because
|
||||
`getTargetKey` formats anything it is given.
|
||||
- `community-user-deny` fired for any contributor rather than only for
|
||||
community-curated sources, making a direct moderator action indistinguishable
|
||||
from a federated one.
|
||||
- `composeDecisions` kept only the first decision's evidence, so an object
|
||||
governed by several targets lost the evidence that drove its verdict.
|
||||
- `OverrideStore.clear()` shadowed the inherited listener `clear()`, leaking
|
||||
subscribers on teardown. Now `clearOverrides()`.
|
||||
|
||||
### Changed
|
||||
|
||||
- The evaluator no longer carries application thresholds. The previous invented
|
||||
defaults (5/3/10/5) are gone; applications supply category thresholds through
|
||||
a policy definition. The characterized reference values live in
|
||||
`@nostr-governance/bitvid-compat` as one application's profile.
|
||||
- `createAuthorityState` takes an options object and always grants the root
|
||||
every capability and protection.
|
||||
- Evaluation takes a `GovernanceSnapshot` and a `ViewerState` rather than a
|
||||
flat state object, and reads an injected clock instead of wall time.
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
@@ -0,0 +1,158 @@
|
||||
# nostr-governance
|
||||
|
||||
A headless, application-agnostic governance engine for Nostr: moderation,
|
||||
access control, and trust-graph policy, with no opinion about what your
|
||||
application is.
|
||||
|
||||
The engine carries **no thresholds of its own**. Applications supply a policy
|
||||
definition; the engine supplies precedence, aggregation, composition, and
|
||||
explanation. That separation is what lets one engine serve a video app and a
|
||||
marketplace without either leaking into the other.
|
||||
|
||||
## Why it looks like this
|
||||
|
||||
**Four independent decision dimensions.** A governance decision is not a
|
||||
`hidden` boolean. It is four answers:
|
||||
|
||||
| Dimension | Values |
|
||||
| --- | --- |
|
||||
| `ranking` | `normal` → `downrank` (with weight) |
|
||||
| `visibility` | `allow` → `warn` → `restrict` → `hide` → `deny` |
|
||||
| `interaction` | `allow` → `require-explicit-action` → `deny` |
|
||||
| `transaction` | `allow` → `require-review` → `deny` |
|
||||
|
||||
A marketplace listing can be visible to its seller, downranked in discovery,
|
||||
inspectable behind a warning on its detail page, and blocked at checkout — all
|
||||
from one evaluation. Collapsing that into one boolean is what forces
|
||||
applications to reimplement policy.
|
||||
|
||||
**Composition is ladder-maximum per dimension**, which makes it commutative and
|
||||
associative: the order decisions arrive in cannot change the result.
|
||||
|
||||
**Capabilities resolve at merge time, not ingest time.** Revoking a moderator's
|
||||
role drops their contributions from effective state immediately, without
|
||||
replaying or rewriting anything.
|
||||
|
||||
**Trust seeds are a fallback, not an addition.** Once a viewer has real
|
||||
contacts, configured seeds stop contributing — following nobody must not be
|
||||
equivalent to following the operator's seed list.
|
||||
|
||||
## Packages
|
||||
|
||||
| Package | Purpose |
|
||||
| --- | --- |
|
||||
| `@nostr-governance/core` | Pure evaluation. No I/O, no browser globals, no dependencies. |
|
||||
| `@nostr-governance/nostr` | Codecs: canonical v1, NIP-56, NIP-51, legacy lists, bech32. |
|
||||
| `@nostr-governance/runtime` | Stores, transport orchestration, commands. |
|
||||
| `@nostr-governance/testing` | Conformance harness. |
|
||||
| `@nostr-governance/bitvid-compat` | Reference application profile for the characterization corpus. |
|
||||
|
||||
## Quick start
|
||||
|
||||
```js
|
||||
import { createPolicyDefinition, evaluateTarget } from "@nostr-governance/core";
|
||||
import { createGovernanceRuntime, createMemoryTransport } from "@nostr-governance/runtime";
|
||||
|
||||
const policy = createPolicyDefinition({
|
||||
id: "my-app",
|
||||
version: "1.0.0",
|
||||
defaultProfile: "feed",
|
||||
profiles: {
|
||||
feed: {
|
||||
name: "feed",
|
||||
administrativeDeny: { visibility: "hide", interaction: "deny" },
|
||||
reports: {
|
||||
spam: { downrank: 1, restrict: 3, hide: 8 },
|
||||
malware: { hide: 1, interactionDeny: 1 },
|
||||
},
|
||||
mutes: { default: { downrank: 1, hide: 20 } },
|
||||
muteWindowSeconds: 60 * 24 * 60 * 60,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const runtime = createGovernanceRuntime({
|
||||
applicationId: "my-app",
|
||||
namespace: "myapp",
|
||||
transport: myRelayAdapter,
|
||||
policy,
|
||||
now: () => Math.floor(Date.now() / 1000),
|
||||
});
|
||||
|
||||
runtime.setViewer(viewerPubkey);
|
||||
runtime.trust.setContacts(followList);
|
||||
await runtime.loadAdministrativeState();
|
||||
|
||||
const decision = runtime.evaluate({ type: "user", pubkey: authorPubkey }, { profile: "feed" });
|
||||
if (decision.visibility.effect === "hide") return null;
|
||||
```
|
||||
|
||||
## Extending to a new application
|
||||
|
||||
Write an adapter saying what your object is in governance terms, and a policy
|
||||
saying what the evidence means to you. Nothing else.
|
||||
|
||||
```js
|
||||
import { createApplicationAdapter, evaluateObject } from "@nostr-governance/core";
|
||||
|
||||
const productAdapter = createApplicationAdapter({
|
||||
applicationId: "my-shop",
|
||||
toTargets: (product) => [
|
||||
{ type: "user", pubkey: product.sellerPubkey },
|
||||
{ type: "event", id: product.id, author: product.sellerPubkey },
|
||||
{ type: "address", kind: "30078", pubkey: product.sellerPubkey, identifier: product.sku },
|
||||
],
|
||||
getPrimaryTargetKey: (product) => `address:30078:${product.sellerPubkey}:${product.sku}`,
|
||||
});
|
||||
|
||||
const decision = evaluateObject(product, productAdapter, (target) =>
|
||||
runtime.evaluate(target, { profile: "checkout" }),
|
||||
);
|
||||
```
|
||||
|
||||
A worked example lives in [`examples/commerce`](examples/commerce) — a
|
||||
marketplace built entirely on the public API, demonstrating seller allowlists,
|
||||
address denial that survives republication, malware-driven checkout blocking,
|
||||
capability-scoped moderator roles, community curator ingestion, and
|
||||
seller-facing explanations. See [`docs/integration-guide.md`](docs/integration-guide.md).
|
||||
|
||||
## Authority model
|
||||
|
||||
Root-authorized contributors: the root publishes the role roster, each actor
|
||||
publishes contributions under their own key, and a contribution is accepted only
|
||||
if the signing actor holds the matching capability.
|
||||
|
||||
```
|
||||
manage-roles manage-policy manage-community-sources
|
||||
contribute-user-allow contribute-user-deny contribute-event-deny
|
||||
contribute-address-deny contribute-trust-seed review-evidence
|
||||
```
|
||||
|
||||
Roles are convenience bundles over these; applications define their own
|
||||
(`listing_moderator`, `seller_moderator`, …). No moderator ever needs the root
|
||||
key. Protected actors cannot be denied by any contributor list, however many
|
||||
curators list them.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run lint # reference guard + typecheck
|
||||
npm test # unit + conformance
|
||||
npm run build:types # per-package .d.ts
|
||||
```
|
||||
|
||||
BitVid and BitRoad are **read-only reference repositories**, pinned by commit in
|
||||
[`docs/reference-map.md`](docs/reference-map.md). Behavior is copied via fixtures
|
||||
and characterization tests, never by importing their source;
|
||||
`npm run check:references` fails the build if anything does.
|
||||
|
||||
Regenerate the characterization corpus with:
|
||||
|
||||
```bash
|
||||
node scripts/build-characterization-corpus.mjs
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
GPL-3.0-or-later. See [`docs/license-and-provenance.md`](docs/license-and-provenance.md).
|
||||
@@ -0,0 +1,35 @@
|
||||
# ADR 0001 — Four independent decision dimensions
|
||||
|
||||
**Status:** accepted
|
||||
|
||||
## Context
|
||||
|
||||
The reference application collapsed moderation into a small number of booleans
|
||||
(`blurThumbnail`, `hideReason`, `blockAutoplay`), computed in two places that
|
||||
overlapped. A commerce consumer needs a state those booleans cannot express: a
|
||||
product that is visible, inspectable, downranked, and unbuyable at once.
|
||||
|
||||
## Decision
|
||||
|
||||
A decision carries four independent dimensions, each an ordered ladder:
|
||||
|
||||
- `ranking`: `normal` → `downrank` (with accumulated weight)
|
||||
- `visibility`: `allow` → `warn` → `restrict` → `hide` → `deny`
|
||||
- `interaction`: `allow` → `require-explicit-action` → `deny`
|
||||
- `transaction`: `allow` → `require-review` → `deny` (present only when a
|
||||
profile can affect it)
|
||||
|
||||
Composition takes the ladder maximum per dimension.
|
||||
|
||||
## Consequences
|
||||
|
||||
Composition is commutative and associative, so the order decisions arrive in
|
||||
cannot change the result — which is what lets an object be evaluated against
|
||||
several targets and merged without an ordering rule.
|
||||
|
||||
Consumers must map dimensions to their own UI rather than reading a boolean.
|
||||
That is more work at the call site and is the point: the mapping is application
|
||||
policy, and putting it in the engine is how the duplication started.
|
||||
|
||||
`transaction` is absent rather than `allow` when no profile gate can reach it,
|
||||
so a consumer can distinguish "explicitly permitted" from "not governed here".
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR 0002 — Capabilities resolve at merge time
|
||||
|
||||
**Status:** accepted
|
||||
|
||||
## Context
|
||||
|
||||
The reference application treated moderator status as a UI permission: an
|
||||
editor list gated which buttons appeared, but the resulting events carried no
|
||||
cryptographic authority. Anyone could publish the same event.
|
||||
|
||||
Revocation is the hard part. If a contribution's validity is decided when it is
|
||||
ingested, revoking a role later requires finding and undoing everything that
|
||||
actor ever contributed.
|
||||
|
||||
## Decision
|
||||
|
||||
Contributions are stored as-published, keyed by (actor, kind). Effective state
|
||||
is derived by `reduceAdminState`, which resolves each actor's capabilities
|
||||
against the *current* authority state every time it runs.
|
||||
|
||||
## Consequences
|
||||
|
||||
Revocation is immediate and requires no rewriting: drop the role, recompute,
|
||||
and that actor's entries are gone. A revoked moderator's stored contributions
|
||||
become inert rather than needing deletion.
|
||||
|
||||
Reduction cost is proportional to total contributions rather than to the change.
|
||||
For realistic list sizes this is far cheaper than maintaining incremental
|
||||
invalidation, and the store only recomputes when something actually changed.
|
||||
|
||||
An actor's contributions can also come *back* if their role is restored, which
|
||||
is the correct behavior for a mistaken revocation but must be understood when
|
||||
reasoning about "permanent" removal — permanence lives in the contribution
|
||||
being retracted, not in the role.
|
||||
@@ -0,0 +1,34 @@
|
||||
# ADR 0003 — The engine carries no thresholds
|
||||
|
||||
**Status:** accepted
|
||||
|
||||
## Context
|
||||
|
||||
The extraction started from one application's numbers: blur at 3 trusted
|
||||
reports, hide at 20 trusted mutes, a 60-day mute window. Those were nearly
|
||||
promoted into the core as defaults, which would have made every future consumer
|
||||
inherit a video application's editorial judgement.
|
||||
|
||||
## Decision
|
||||
|
||||
The core defines the *vocabulary* — target types, capabilities, effect ladders,
|
||||
reason identifiers, aggregation and precedence rules — and no thresholds.
|
||||
Applications supply a `PolicyDefinition` of named profiles with category
|
||||
thresholds. The characterized reference values live in
|
||||
`@nostr-governance/bitvid-compat` as one application's profile.
|
||||
|
||||
`NEUTRAL_POLICY` is the fallback: it enforces administrative denial and applies
|
||||
no trust thresholds, so the generic engine never hides content merely because
|
||||
one trusted account acted against it.
|
||||
|
||||
## Consequences
|
||||
|
||||
A new consumer writes a policy instead of arguing with a default. The commerce
|
||||
example needed no core change, which is the test this decision has to pass.
|
||||
|
||||
An application that supplies no policy gets very little enforcement. That is
|
||||
deliberate: silently applying someone else's moderation thresholds is worse than
|
||||
applying none, because the operator would not know it was happening.
|
||||
|
||||
Threshold `0` means "gate disabled" rather than "fires on everything", so
|
||||
zeroing a value is a safe way to switch a gate off.
|
||||
@@ -0,0 +1,26 @@
|
||||
# ADR 0004 — The core is hex-only
|
||||
|
||||
**Status:** accepted
|
||||
|
||||
## Context
|
||||
|
||||
Administrative lists in the wild contain both hex pubkeys and `npub` strings,
|
||||
and the reference implementation normalized them everywhere it touched them.
|
||||
Doing the same in the core would mean bech32 decoding in a package that is
|
||||
meant to have no dependencies and no encoding opinions.
|
||||
|
||||
## Decision
|
||||
|
||||
`@nostr-governance/core` accepts 64-character hex only. Decoding lives in
|
||||
`@nostr-governance/nostr` (`normalizePubkeyInput`, `normalizeEventIdInput`),
|
||||
and codecs normalize at ingest, so the core only ever sees hex.
|
||||
|
||||
## Consequences
|
||||
|
||||
The core stays dependency-free and runs unchanged in Node and the browser.
|
||||
|
||||
The trade-off is a real footgun: `normalizePubkey` returns `""` for a valid
|
||||
`npub` rather than throwing. Callers that hand user input straight to the core
|
||||
will silently get nothing. Anything accepting operator or user input must
|
||||
normalize through the codec package first — this is called out in the
|
||||
integration guide.
|
||||
+91
-45
@@ -1,63 +1,109 @@
|
||||
# Checkpoints
|
||||
|
||||
Scope note: this repository is the SDK. Migration-plan Phases 6–11 (BitVid
|
||||
shadow mode, cutovers, and duplicate removal) modify the consumer repositories
|
||||
and are tracked there, not here.
|
||||
|
||||
## Checkpoint 0 — Repo skeleton (completed)
|
||||
|
||||
Delivered:
|
||||
- npm workspaces monorepo
|
||||
- Node 22 + ESM
|
||||
- five empty packages under `packages/`
|
||||
- Vitest harness
|
||||
- TypeScript JSDoc typecheck
|
||||
- GitHub Actions CI
|
||||
- reference import guard
|
||||
- provenance docs
|
||||
Delivered: npm workspaces monorepo, Node 22 + ESM, Vitest, JSDoc typecheck,
|
||||
GitHub Actions CI, reference import guard, provenance docs.
|
||||
|
||||
Validation:
|
||||
```bash
|
||||
npm install
|
||||
npm run check:references
|
||||
npm run typecheck
|
||||
npm test
|
||||
npm run build:types
|
||||
Later additions: GPL-3.0-or-later `LICENSE`, `CHANGELOG.md`, architecture
|
||||
decision records under `docs/adr/`, and a working per-package type build. The
|
||||
`types` fields previously pointed at paths the build never produced, so
|
||||
consumers received no declarations; declarations now emit into each package.
|
||||
|
||||
## Checkpoint 1 — Characterization corpus (completed)
|
||||
|
||||
Delivered:
|
||||
- 34 fixtures under `fixtures/bitvid/cases/`, generated by
|
||||
`scripts/build-characterization-corpus.mjs`
|
||||
- conformance harness (`packages/testing/src/conformance.js`) that builds a
|
||||
snapshot, evaluates it, and diffs against a partial expectation
|
||||
- conformance suite (`tests/conformance/bitvid-fixtures.test.js`) asserting
|
||||
every required behavior, determinism, and input immutability
|
||||
|
||||
Characterized values, read from the pinned reference commit and cross-checked
|
||||
against the migration plan's baseline section:
|
||||
|
||||
```text
|
||||
Trusted report blur threshold: 3
|
||||
Trusted report autoplay threshold: 3
|
||||
Trusted mute hide threshold: 20
|
||||
Trusted spam-report hide threshold: 5
|
||||
Trusted mute validity window: 60 days
|
||||
```
|
||||
|
||||
Gate: do not add runtime or BitVid codecs until fixture corpus exists.
|
||||
These live in `@nostr-governance/bitvid-compat` as one application's profile,
|
||||
not as engine defaults.
|
||||
|
||||
## Checkpoint 1 — BitVid fixture and conformance corpus (completed)
|
||||
Provenance: fixtures are authored from the reference source and its documented
|
||||
configuration, not captured from a live relay. They pin the behavior the
|
||||
extraction must reproduce; they are not a recording of production traffic.
|
||||
|
||||
Delivered:
|
||||
- fixture folders for BitVid cases (admin-state, reports, trusted-mutes, overrides, expectations)
|
||||
- import script harness (`scripts/import-bitvid-fixtures.mjs`)
|
||||
- characterization cases list (`fixtures/bitvid/characterization-cases.json`)
|
||||
- conformance test framework (`tests/conformance/bitvid-fixtures.test.js`)
|
||||
- testing utilities (`packages/testing/src/conformance.js`)
|
||||
## Checkpoint 2 — Pure core (completed)
|
||||
|
||||
Validation:
|
||||
```bash
|
||||
npm run check:references # still passes
|
||||
npm test # now includes conformance tests
|
||||
```
|
||||
Delivered: identifiers, targets, authority, administrative-state reduction,
|
||||
reasons, evidence, policy engine, fingerprints, evaluator, adapter contract.
|
||||
|
||||
Status: Ready for real fixture extraction from BitVid.
|
||||
Acceptance:
|
||||
- identical snapshots produce identical decisions (asserted per fixture)
|
||||
- evaluation performs no I/O and reads an injected clock
|
||||
- evaluation mutates no input (asserted per fixture)
|
||||
- the core imports no browser globals and has no dependencies
|
||||
- the core contains no application-specific terms or thresholds
|
||||
|
||||
Gate: Do not implement evaluator until at least 3 real characterization cases are imported with expectations.
|
||||
## Checkpoint 3 — Nostr codecs (completed)
|
||||
|
||||
Documentation: See `docs/phase1-summary.md` for full details.
|
||||
Delivered: bech32/NIP-19 (verified against the NIP-19 test vector), canonical v1
|
||||
on `kind:30078`, NIP-56 reports, NIP-51 mute lists, legacy administrative lists
|
||||
covering all five migration stages, replaceable selection with a deterministic
|
||||
same-timestamp tiebreak, injected signature verification.
|
||||
|
||||
## Checkpoint 2 — Pure core implementation (in progress)
|
||||
## Checkpoint 4 — Runtime stores (completed)
|
||||
|
||||
Delivered:
|
||||
- identifiers module (`packages/core/src/identifiers.js`)
|
||||
- targets module (`packages/core/src/targets.js`)
|
||||
- authority module (`packages/core/src/authority.js`)
|
||||
- policy module (`packages/core/src/policy.js`)
|
||||
- target creation, validation, and hierarchy utilities
|
||||
- role and capability management
|
||||
- policy definitions, decision making, and effect composition
|
||||
- comprehensive test suites for all modules
|
||||
Delivered: `GovernanceAdminStore`, `TrustGraphStore`, `ReportStore`,
|
||||
`TrustedMuteStore`, `PolicyStore`, `OverrideStore`, `GovernanceRuntime`.
|
||||
|
||||
Gate: Complete core implementation before moving to Nostr codecs.
|
||||
Acceptance: injected transport, storage, and clock; change-only emission; viewer
|
||||
switching clears viewer-scoped state while keeping operator seeds; subscriptions
|
||||
close on destroy; report subscriptions are chunked (200 per filter) rather than
|
||||
opened per target; author evidence is queryable in batches; expired mute lists
|
||||
prune against the injected clock.
|
||||
|
||||
## Next checkpoint
|
||||
## Checkpoint 5 — Public commands (completed)
|
||||
|
||||
Checkpoint 3 — Nostr codecs implementation (event parsing, legacy support)
|
||||
Delivered: contribution, role, policy, community-source, report, and override
|
||||
commands with stable error codes.
|
||||
|
||||
Acceptance: every command checks capability locally before the signer is
|
||||
invoked, so an unauthorized actor cannot produce a signed mutation; revoked
|
||||
actors' contributions stop affecting effective state; protected actors are
|
||||
refused at the command boundary; first relay acceptance completes the command
|
||||
while remaining relay results stay observable.
|
||||
|
||||
## Checkpoint 6 — Reusability proof (completed)
|
||||
|
||||
Delivered: `examples/commerce`, a marketplace built entirely on the public API,
|
||||
covering the ten proof-of-fit behaviors the migration plan requires — including
|
||||
that no core change was needed. Plus `README.md`, `docs/integration-guide.md`,
|
||||
and `examples/commerce/README.md`.
|
||||
|
||||
## Status
|
||||
|
||||
CI gates green: reference guard, typecheck, 556 tests, per-package type build.
|
||||
|
||||
## Not done here
|
||||
|
||||
Migration-plan Phases 6–11 operate on BitVid and BitRoad:
|
||||
|
||||
- Phase 6 — BitVid compatibility facade and shadow mode
|
||||
- Phase 7 — cut over BitVid administrative state
|
||||
- Phase 8 — cut over BitVid reports and trust
|
||||
- Phase 9 — cut over BitVid policy
|
||||
- Phase 10 — validate through BitRoad itself
|
||||
- Phase 11 — remove duplicated BitVid logic
|
||||
|
||||
The commerce example stands in for Phase 10's API-shape validation without
|
||||
touching the BitRoad repository.
|
||||
@@ -0,0 +1,237 @@
|
||||
# Integration guide
|
||||
|
||||
How to put this engine into an application that is not a video app.
|
||||
|
||||
Everything application-specific lives in two places you write: an **adapter**
|
||||
(what is my object, in governance terms) and a **policy** (what does the
|
||||
evidence mean to me). Nothing else needs to change, and nothing in the core
|
||||
needs to know your application exists.
|
||||
|
||||
## 1. Decide what your objects are
|
||||
|
||||
Three target types cover the model:
|
||||
|
||||
| Target | Use for |
|
||||
| --- | --- |
|
||||
| `user` | Accounts: authors, sellers, reviewers, reporters, curators |
|
||||
| `event` | One exact, immutable event that should stay denied on its own |
|
||||
| `address` | A replaceable coordinate: a product across revisions, a storefront, an article |
|
||||
|
||||
The distinction between `event` and `address` matters more than it looks.
|
||||
Denying an **address** survives the author republishing under a new event id.
|
||||
Denying an **event** does not — which is exactly what you want when only one
|
||||
revision was bad. Most objects want both.
|
||||
|
||||
## 2. Write an adapter
|
||||
|
||||
```js
|
||||
import { createApplicationAdapter } from "@nostr-governance/core";
|
||||
|
||||
export const productAdapter = createApplicationAdapter({
|
||||
applicationId: "my-shop",
|
||||
|
||||
// Every target whose status should reach this object.
|
||||
toTargets: (product) => [
|
||||
{ type: "user", pubkey: product.sellerPubkey },
|
||||
{ type: "event", id: product.id, author: product.sellerPubkey, kind: 30078 },
|
||||
{ type: "address", kind: "30078", pubkey: product.sellerPubkey, identifier: product.sku },
|
||||
],
|
||||
|
||||
// The object's own identity, used to key its decision.
|
||||
getPrimaryTargetKey: (product) => `address:30078:${product.sellerPubkey}:${product.sku}`,
|
||||
|
||||
// Optional: map the decision back into your shape.
|
||||
applyDecision: (product, decision) => ({
|
||||
...product,
|
||||
hidden: decision.visibility.effect === "hide",
|
||||
rankPenalty: decision.ranking.weight,
|
||||
checkoutAllowed: (decision.transaction?.effect ?? "allow") === "allow",
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
Composition across an object's targets is ladder-maximum per dimension: the
|
||||
strictest verdict reaching the object wins. That is what makes "deny the seller"
|
||||
take their whole catalogue down without you writing any cascade logic.
|
||||
|
||||
Be deliberate about what you *don't* include. A review should answer to its
|
||||
reviewer and its own event — not to the product it reviews, or a bad product
|
||||
would silence honest reviews of it.
|
||||
|
||||
## 3. Write a policy
|
||||
|
||||
A policy is a set of named **profiles**, one per surface. Profiles read the same
|
||||
evidence and are free to reach different conclusions.
|
||||
|
||||
```js
|
||||
import { createPolicyDefinition } from "@nostr-governance/core";
|
||||
|
||||
export const policy = createPolicyDefinition({
|
||||
id: "my-shop",
|
||||
version: "1.0.0",
|
||||
defaultProfile: "browse",
|
||||
profiles: {
|
||||
browse: {
|
||||
name: "browse",
|
||||
administrativeDeny: { visibility: "hide", interaction: "deny", transaction: "deny" },
|
||||
requireAllowlist: true,
|
||||
allowlistMiss: { visibility: "hide", interaction: "deny" },
|
||||
allowViewerOverride: false,
|
||||
reports: {
|
||||
malware: { hide: 1, transactionDeny: 1 },
|
||||
scam: { downrank: 1, restrict: 3, transactionDeny: 3 },
|
||||
default: { downrank: 2, warn: 4 },
|
||||
},
|
||||
mutes: { default: { downrank: 1 } },
|
||||
muteWindowSeconds: 60 * 24 * 60 * 60,
|
||||
},
|
||||
|
||||
checkout: {
|
||||
name: "checkout",
|
||||
administrativeDeny: { visibility: "allow", transaction: "deny" },
|
||||
allowViewerOverride: false,
|
||||
reports: { malware: { transactionDeny: 1 }, scam: { transactionDeny: 3 } },
|
||||
mutes: {},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Profile options
|
||||
|
||||
| Option | Effect |
|
||||
| --- | --- |
|
||||
| `administrativeDeny` | Effects applied when the target is administratively denied |
|
||||
| `viewerBlock` | Effects applied when the viewer personally blocks |
|
||||
| `requireAllowlist` / `allowlistMiss` | Enforce an allowlist on this surface |
|
||||
| `allowViewerOverride` | Whether a viewer may soften a decision |
|
||||
| `exposeEvidence` | Whether contributor pubkeys reach the consumer |
|
||||
| `bypassHide` / `bypassHideCeiling` | Cap a hide on discovery surfaces |
|
||||
| `disabled` | Turn governance off for this surface, with a reason |
|
||||
| `reports` / `mutes` | Category thresholds |
|
||||
| `muteWindowSeconds` | Ignore mutes older than this |
|
||||
|
||||
### Threshold gates
|
||||
|
||||
A gate fires when `count >= threshold`. A threshold of `0` means **disabled**,
|
||||
not "always on" — so you can zero a gate out without it triggering on every
|
||||
target.
|
||||
|
||||
```
|
||||
downrank → ranking warn, restrict, hide, deny → visibility
|
||||
requireExplicitAction, interactionDeny → interaction
|
||||
transactionReview, transactionDeny → transaction
|
||||
```
|
||||
|
||||
## 4. Supply a transport
|
||||
|
||||
The runtime depends on no relay pool. Implement three methods:
|
||||
|
||||
```js
|
||||
const transport = {
|
||||
async list(filters, options) { /* → NostrEvent[] */ },
|
||||
subscribe(filters, handlers, options) { /* → { close() } */ },
|
||||
async publish(event, options) {
|
||||
return { ok: true, accepted: ["wss://..."], failed: [] };
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
`publish` should report **partial** acceptance honestly: `ok: true` with a
|
||||
non-empty `failed` list is a normal, successful outcome. The first acceptance
|
||||
means the event exists on the network; the remaining results are diagnostics.
|
||||
|
||||
A signer (`getPublicKey`, `signEvent`) and storage (`read`, `write`, `remove`)
|
||||
follow the same injection pattern. In-memory versions ship for testing:
|
||||
|
||||
```js
|
||||
import { createMemoryTransport, createMemoryStorage } from "@nostr-governance/runtime";
|
||||
```
|
||||
|
||||
## 5. Wire the pipeline
|
||||
|
||||
```js
|
||||
import { collectTargets, evaluateObject } from "@nostr-governance/core";
|
||||
|
||||
runtime.setViewer(viewerPubkey); // do this FIRST — it clears viewer state
|
||||
runtime.trust.setContacts(followList); // then seed trust
|
||||
await runtime.loadAdministrativeState();
|
||||
|
||||
runtime.setActiveTargets(collectTargets(products, productAdapter));
|
||||
runtime.subscribeToActiveTargetReports();
|
||||
|
||||
const governed = products.map((product) =>
|
||||
productAdapter.applyDecision(
|
||||
product,
|
||||
evaluateObject(product, productAdapter, (target) =>
|
||||
runtime.evaluate(target, { profile: "browse" }),
|
||||
),
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
Order matters in one place: `setViewer` clears viewer-scoped state by design, so
|
||||
seed contacts, blocks, and overrides *after* it, never before.
|
||||
|
||||
## 6. Define your roles
|
||||
|
||||
Roles are application-defined bundles over a fixed capability vocabulary:
|
||||
|
||||
```js
|
||||
import { createRoleDefinition } from "@nostr-governance/core";
|
||||
|
||||
const roles = {
|
||||
listing_moderator: createRoleDefinition("listing_moderator", [
|
||||
"contribute-event-deny",
|
||||
"contribute-address-deny",
|
||||
"review-evidence",
|
||||
]),
|
||||
seller_moderator: createRoleDefinition("seller_moderator", [
|
||||
"contribute-user-deny",
|
||||
"review-evidence",
|
||||
]),
|
||||
};
|
||||
```
|
||||
|
||||
If you pass a custom `roles` map to `setRoles`, it **replaces** the defaults —
|
||||
include `super_admin` yourself if you still want it. The root administrator
|
||||
always holds every capability regardless, so a misconfigured roster cannot lock
|
||||
you out of your own instance.
|
||||
|
||||
Every command checks capability locally before the signer is invoked. An
|
||||
unauthorized actor cannot produce a signed mutation at all, rather than
|
||||
publishing one that quietly fails to take effect.
|
||||
|
||||
## Common mistakes
|
||||
|
||||
**Recomputing policy in your view layer.** If your renderer is comparing report
|
||||
counts to thresholds, the extraction has failed. Consume `decision`.
|
||||
|
||||
**Treating `downrank` as a filter.** Downranking is a sort input. A downranked
|
||||
item still appears and can still be acted on; conflating the two is what makes
|
||||
moderation feel like censorship to the people affected.
|
||||
|
||||
**Skipping the checkout re-check.** Evaluate transaction-critical actions
|
||||
against their own profile at the moment of action. State moves between page
|
||||
render and button press.
|
||||
|
||||
**Exposing evidence everywhere.** `exposeEvidence` is off by default for a
|
||||
reason: public surfaces get counts, appeal surfaces get identities.
|
||||
|
||||
**Assuming an allowlist implies trust.** Being allowed to publish grants no
|
||||
trust, no moderator authority, and no immunity from personal blocks. The two
|
||||
systems are deliberately separate.
|
||||
|
||||
## Testing your integration
|
||||
|
||||
The conformance harness takes JSON fixtures and asserts decisions:
|
||||
|
||||
```js
|
||||
import { runConformanceCase } from "@nostr-governance/testing/conformance";
|
||||
|
||||
const { passed, mismatches } = runConformanceCase(fixture, policy);
|
||||
```
|
||||
|
||||
Expectations are partial — assert the dimensions your case is about and stay
|
||||
silent about the rest. See `fixtures/bitvid/cases/` for the shape and
|
||||
`examples/commerce/test/proof-of-fit.test.js` for a full application suite.
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
Generated
+14
-1
@@ -8,7 +8,8 @@
|
||||
"name": "nostr-governance",
|
||||
"version": "0.1.0",
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"examples/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
@@ -19,6 +20,14 @@
|
||||
"node": ">=22"
|
||||
}
|
||||
},
|
||||
"examples/commerce": {
|
||||
"name": "@nostr-governance/example-commerce",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@nostr-governance/core": "*",
|
||||
"@nostr-governance/runtime": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
|
||||
@@ -438,6 +447,10 @@
|
||||
"resolved": "packages/core",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nostr-governance/example-commerce": {
|
||||
"resolved": "examples/commerce",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@nostr-governance/nostr": {
|
||||
"resolved": "packages/nostr",
|
||||
"link": true
|
||||
|
||||
+2
-1
@@ -18,7 +18,8 @@
|
||||
"node": ">=22"
|
||||
},
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
"packages/*",
|
||||
"examples/*"
|
||||
],
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.10.0",
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
// Application adapter contract.
|
||||
//
|
||||
// An adapter is how an application says "here is what my object is, in
|
||||
// governance terms" and "here is what a decision means to me". Everything
|
||||
// application-specific lives in the adapter; nothing application-specific lives
|
||||
// in the engine. This is the seam that lets one engine serve a video app and a
|
||||
// marketplace without either leaking into the other.
|
||||
|
||||
import { getTargetKey } from "./identifiers.js";
|
||||
import { isValidTarget } from "./targets.js";
|
||||
import { composeDecisions } from "./policy.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('./identifiers.js').GovernanceTarget} GovernanceTarget
|
||||
* @typedef {import('./policy.js').GovernanceDecision} GovernanceDecision
|
||||
*/
|
||||
|
||||
/**
|
||||
* @template TObject
|
||||
* @typedef {Object} GovernanceApplicationAdapter
|
||||
* @property {string} applicationId
|
||||
* @property {(object: TObject) => GovernanceTarget[]} toTargets - Every target that governs this object
|
||||
* @property {(object: TObject) => string} getPrimaryTargetKey - The object's own identity
|
||||
* @property {(object: TObject, decision: GovernanceDecision) => TObject} [applyDecision] - Map a decision back into application shape
|
||||
*/
|
||||
|
||||
/**
|
||||
* Define an adapter, validating its required members.
|
||||
* @template TObject
|
||||
* @param {GovernanceApplicationAdapter<TObject>} adapter
|
||||
* @returns {GovernanceApplicationAdapter<TObject>}
|
||||
*/
|
||||
export function createApplicationAdapter(adapter) {
|
||||
if (!adapter || typeof adapter !== "object") {
|
||||
throw new Error("Adapter must be an object");
|
||||
}
|
||||
if (typeof adapter.applicationId !== "string" || !adapter.applicationId.trim()) {
|
||||
throw new Error("Adapter requires an applicationId");
|
||||
}
|
||||
if (typeof adapter.toTargets !== "function") {
|
||||
throw new Error("Adapter requires toTargets()");
|
||||
}
|
||||
if (typeof adapter.getPrimaryTargetKey !== "function") {
|
||||
throw new Error("Adapter requires getPrimaryTargetKey()");
|
||||
}
|
||||
return adapter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate every target an object maps to and compose one decision.
|
||||
*
|
||||
* Composition is the ladder maximum per dimension, so an object is governed by
|
||||
* the strictest verdict reaching it: denying a seller takes their listings down
|
||||
* even though each listing's own address is clean. Applications that want a
|
||||
* different rule can compose the per-target decisions themselves.
|
||||
*
|
||||
* @template TObject
|
||||
* @param {TObject} object
|
||||
* @param {GovernanceApplicationAdapter<TObject>} adapter
|
||||
* @param {(target: GovernanceTarget) => GovernanceDecision} evaluate
|
||||
* @returns {GovernanceDecision}
|
||||
*/
|
||||
export function evaluateObject(object, adapter, evaluate) {
|
||||
const targets = adapter.toTargets(object).filter((target) => isValidTarget(target));
|
||||
if (targets.length === 0) {
|
||||
throw new Error(`Adapter produced no valid targets for ${adapter.applicationId}`);
|
||||
}
|
||||
|
||||
const decisions = targets.map((target) => evaluate(target));
|
||||
const composed = composeDecisions(decisions);
|
||||
|
||||
// The composed decision is reported against the object's own identity, not
|
||||
// against whichever contributing target happened to be most severe.
|
||||
composed.key = adapter.getPrimaryTargetKey(object);
|
||||
return composed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Evaluate many objects, returning decisions keyed by primary target.
|
||||
* @template TObject
|
||||
* @param {TObject[]} objects
|
||||
* @param {GovernanceApplicationAdapter<TObject>} adapter
|
||||
* @param {(target: GovernanceTarget) => GovernanceDecision} evaluate
|
||||
* @returns {Map<string, GovernanceDecision>}
|
||||
*/
|
||||
export function evaluateObjects(objects, adapter, evaluate) {
|
||||
/** @type {Map<string, GovernanceDecision>} */
|
||||
const results = new Map();
|
||||
for (const object of objects ?? []) {
|
||||
results.set(adapter.getPrimaryTargetKey(object), evaluateObject(object, adapter, evaluate));
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect the deduplicated targets a batch of objects depends on.
|
||||
*
|
||||
* Used to tell the runtime which targets to keep evidence fresh for, without
|
||||
* subscribing once per object.
|
||||
*
|
||||
* @template TObject
|
||||
* @param {TObject[]} objects
|
||||
* @param {GovernanceApplicationAdapter<TObject>} adapter
|
||||
* @returns {GovernanceTarget[]}
|
||||
*/
|
||||
export function collectTargets(objects, adapter) {
|
||||
/** @type {Map<string, GovernanceTarget>} */
|
||||
const targets = new Map();
|
||||
for (const object of objects ?? []) {
|
||||
for (const target of adapter.toTargets(object)) {
|
||||
if (isValidTarget(target)) {
|
||||
targets.set(getTargetKey(target), target);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Array.from(targets.values());
|
||||
}
|
||||
@@ -86,6 +86,67 @@ export function freezeEvidence(evidence) {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge evidence from several decisions about the same object.
|
||||
*
|
||||
* An object may answer to more than one target — a product to its seller, its
|
||||
* address, and its exact event — and the evidence that explains the composed
|
||||
* decision is the union of what each target carried. Counts are summed per
|
||||
* category and contributor lists are unioned, so a seller-level report and an
|
||||
* address-level report both show up in one explanation.
|
||||
*
|
||||
* @param {GovernanceEvidence[]} records
|
||||
* @returns {GovernanceEvidence}
|
||||
*/
|
||||
export function mergeEvidence(records) {
|
||||
const merged = createEmptyEvidence();
|
||||
|
||||
/** @type {Set<string>} */
|
||||
const reporters = new Set();
|
||||
/** @type {Set<string>} */
|
||||
const muters = new Set();
|
||||
|
||||
for (const record of records ?? []) {
|
||||
if (!record) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const [category, count] of Object.entries(record.trustedReportsByCategory ?? {})) {
|
||||
merged.trustedReportsByCategory[category] =
|
||||
(merged.trustedReportsByCategory[category] ?? 0) + count;
|
||||
}
|
||||
for (const [category, count] of Object.entries(record.trustedMutesByCategory ?? {})) {
|
||||
merged.trustedMutesByCategory[category] =
|
||||
(merged.trustedMutesByCategory[category] ?? 0) + count;
|
||||
}
|
||||
|
||||
merged.trustedReportTotal += record.trustedReportTotal ?? 0;
|
||||
merged.trustedMuteTotal += record.trustedMuteTotal ?? 0;
|
||||
|
||||
for (const pubkey of record.trustedReporterPubkeys ?? []) {
|
||||
reporters.add(pubkey);
|
||||
}
|
||||
for (const pubkey of record.trustedMuterPubkeys ?? []) {
|
||||
muters.add(pubkey);
|
||||
}
|
||||
|
||||
merged.personalBlock ||= record.personalBlock === true;
|
||||
merged.personalMute ||= record.personalMute === true;
|
||||
merged.userDenied ||= record.userDenied === true;
|
||||
merged.eventDenied ||= record.eventDenied === true;
|
||||
merged.addressDenied ||= record.addressDenied === true;
|
||||
merged.userAllowed ||= record.userAllowed === true;
|
||||
merged.protectedTarget ||= record.protectedTarget === true;
|
||||
|
||||
merged.thresholds = { ...merged.thresholds, ...record.thresholds };
|
||||
}
|
||||
|
||||
merged.trustedReporterPubkeys = Array.from(reporters);
|
||||
merged.trustedMuterPubkeys = Array.from(muters);
|
||||
|
||||
return freezeEvidence(merged);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip evidence down to counts, dropping the pubkey lists.
|
||||
*
|
||||
|
||||
@@ -53,6 +53,7 @@ export {
|
||||
export {
|
||||
createEmptyEvidence,
|
||||
freezeEvidence,
|
||||
mergeEvidence,
|
||||
redactEvidence,
|
||||
} from "./evidence.js";
|
||||
|
||||
@@ -77,6 +78,13 @@ export {
|
||||
composeDecisions,
|
||||
} from "./policy.js";
|
||||
|
||||
export {
|
||||
createApplicationAdapter,
|
||||
evaluateObject,
|
||||
evaluateObjects,
|
||||
collectTargets,
|
||||
} from "./adapter.js";
|
||||
|
||||
export {
|
||||
canonicalStringify,
|
||||
hashString,
|
||||
@@ -115,4 +123,5 @@ export {
|
||||
* @typedef {import('./evaluator.js').ViewerState} ViewerState
|
||||
* @typedef {import('./evaluator.js').ReportRecord} ReportRecord
|
||||
* @typedef {import('./evaluator.js').MuteRecord} MuteRecord
|
||||
* @typedef {import('./adapter.js').GovernanceApplicationAdapter<any>} GovernanceApplicationAdapter
|
||||
*/
|
||||
@@ -9,6 +9,7 @@
|
||||
// collapsing those states into a single "hidden" boolean.
|
||||
|
||||
import { createReason, dedupeReasons } from "./reasons.js";
|
||||
import { mergeEvidence } from "./evidence.js";
|
||||
|
||||
/**
|
||||
* @typedef {import('./reasons.js').GovernanceReason} GovernanceReason
|
||||
@@ -548,6 +549,15 @@ export function composeDecisions(decisions) {
|
||||
}
|
||||
|
||||
composed.reasons = dedupeReasons(composed.reasons);
|
||||
|
||||
// Evidence is unioned rather than taken from the first decision: an object
|
||||
// governed by several targets must explain itself with everything that
|
||||
// contributed, not just whichever target happened to be evaluated first.
|
||||
const evidence = decisions.map((decision) => decision.evidence).filter(Boolean);
|
||||
if (evidence.length) {
|
||||
composed.evidence = mergeEvidence(/** @type {any} */ (evidence));
|
||||
}
|
||||
|
||||
return composed;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
collectTargets,
|
||||
createApplicationAdapter,
|
||||
evaluateObject,
|
||||
evaluateObjects,
|
||||
} from "../src/adapter.js";
|
||||
import { createNeutralDecision } from "../src/policy.js";
|
||||
|
||||
const SELLER = "d4".repeat(32);
|
||||
const EVENT_ID = "1b".repeat(32);
|
||||
|
||||
/** @type {import('../src/adapter.js').GovernanceApplicationAdapter<any>} */
|
||||
const adapter = createApplicationAdapter({
|
||||
applicationId: "test-app",
|
||||
toTargets: (object) => [
|
||||
{ type: "user", pubkey: object.author },
|
||||
{ type: "event", id: object.id, author: object.author },
|
||||
],
|
||||
getPrimaryTargetKey: (object) => `event:${object.id}`,
|
||||
});
|
||||
|
||||
const object = { id: EVENT_ID, author: SELLER };
|
||||
|
||||
/** Build an evaluator that returns a fixed visibility per target type. */
|
||||
const evaluatorFor = (byType) => (target) => {
|
||||
const decision = createNeutralDecision({ key: `${target.type}` });
|
||||
const effect = byType[target.type];
|
||||
if (effect) {
|
||||
decision.visibility.effect = effect;
|
||||
}
|
||||
return decision;
|
||||
};
|
||||
|
||||
describe("createApplicationAdapter", () => {
|
||||
it("accepts a complete adapter", () => {
|
||||
expect(adapter.applicationId).toBe("test-app");
|
||||
});
|
||||
|
||||
it("rejects a missing applicationId", () => {
|
||||
expect(() =>
|
||||
createApplicationAdapter(/** @type {any} */ ({ toTargets: () => [], getPrimaryTargetKey: () => "" })),
|
||||
).toThrow(/applicationId/);
|
||||
});
|
||||
|
||||
it("rejects a missing toTargets", () => {
|
||||
expect(() =>
|
||||
createApplicationAdapter(/** @type {any} */ ({ applicationId: "a", getPrimaryTargetKey: () => "" })),
|
||||
).toThrow(/toTargets/);
|
||||
});
|
||||
|
||||
it("rejects a missing getPrimaryTargetKey", () => {
|
||||
expect(() =>
|
||||
createApplicationAdapter(/** @type {any} */ ({ applicationId: "a", toTargets: () => [] })),
|
||||
).toThrow(/getPrimaryTargetKey/);
|
||||
});
|
||||
|
||||
it("rejects a non-object", () => {
|
||||
expect(() => createApplicationAdapter(/** @type {any} */ (null))).toThrow(/must be an object/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateObject", () => {
|
||||
it("keeps the strictest verdict reaching the object", () => {
|
||||
const decision = evaluateObject(object, adapter, evaluatorFor({ user: "hide" }));
|
||||
expect(decision.visibility.effect).toBe("hide");
|
||||
});
|
||||
|
||||
it("is unaffected by which target was most severe when reporting identity", () => {
|
||||
const decision = evaluateObject(object, adapter, evaluatorFor({ user: "hide" }));
|
||||
expect(decision.key).toBe(`event:${EVENT_ID}`);
|
||||
});
|
||||
|
||||
it("allows an object when no target objects", () => {
|
||||
expect(evaluateObject(object, adapter, evaluatorFor({})).visibility.effect).toBe("allow");
|
||||
});
|
||||
|
||||
it("escalates from any contributing target", () => {
|
||||
expect(
|
||||
evaluateObject(object, adapter, evaluatorFor({ event: "deny" })).visibility.effect,
|
||||
).toBe("deny");
|
||||
});
|
||||
|
||||
it("filters out invalid targets the adapter emits", () => {
|
||||
const loose = createApplicationAdapter({
|
||||
applicationId: "a",
|
||||
toTargets: () => [
|
||||
/** @type {any} */ ({ type: "user", pubkey: "junk" }),
|
||||
{ type: "user", pubkey: SELLER },
|
||||
],
|
||||
getPrimaryTargetKey: () => "user:x",
|
||||
});
|
||||
expect(() => evaluateObject(object, loose, evaluatorFor({}))).not.toThrow();
|
||||
});
|
||||
|
||||
it("throws when an adapter produces no usable targets", () => {
|
||||
const broken = createApplicationAdapter({
|
||||
applicationId: "broken",
|
||||
toTargets: () => [],
|
||||
getPrimaryTargetKey: () => "x",
|
||||
});
|
||||
expect(() => evaluateObject(object, broken, evaluatorFor({}))).toThrow(/no valid targets/);
|
||||
});
|
||||
});
|
||||
|
||||
describe("evaluateObjects", () => {
|
||||
it("keys results by primary target", () => {
|
||||
const results = evaluateObjects([object], adapter, evaluatorFor({}));
|
||||
expect(results.has(`event:${EVENT_ID}`)).toBe(true);
|
||||
});
|
||||
|
||||
it("handles an empty list", () => {
|
||||
expect(evaluateObjects([], adapter, evaluatorFor({})).size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("collectTargets", () => {
|
||||
it("deduplicates shared targets across objects", () => {
|
||||
const targets = collectTargets(
|
||||
[object, { id: "2c".repeat(32), author: SELLER }],
|
||||
adapter,
|
||||
);
|
||||
// Two events plus one shared author.
|
||||
expect(targets).toHaveLength(3);
|
||||
});
|
||||
|
||||
it("returns nothing for an empty list", () => {
|
||||
expect(collectTargets([], adapter)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { createEmptyEvidence, freezeEvidence, redactEvidence } from "../src/evidence.js";
|
||||
import {
|
||||
createEmptyEvidence,
|
||||
freezeEvidence,
|
||||
mergeEvidence,
|
||||
redactEvidence,
|
||||
} from "../src/evidence.js";
|
||||
|
||||
const populated = () => ({
|
||||
...createEmptyEvidence(),
|
||||
@@ -71,3 +76,34 @@ describe("redactEvidence", () => {
|
||||
expect(redacted.trustedReportsByCategory).toEqual({ nudity: 1, spam: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe("mergeEvidence", () => {
|
||||
it("sums counts across targets", () => {
|
||||
const merged = mergeEvidence([populated(), populated()]);
|
||||
expect(merged.trustedReportTotal).toBe(6);
|
||||
expect(merged.trustedReportsByCategory.spam).toBe(4);
|
||||
});
|
||||
|
||||
it("unions contributor lists without duplicating", () => {
|
||||
const merged = mergeEvidence([populated(), populated()]);
|
||||
expect(merged.trustedReporterPubkeys).toEqual(["aa", "bb", "cc"]);
|
||||
});
|
||||
|
||||
it("ors the boolean flags", () => {
|
||||
const denied = { ...createEmptyEvidence(), userDenied: true };
|
||||
const blocked = { ...createEmptyEvidence(), personalBlock: true };
|
||||
const merged = mergeEvidence([denied, blocked]);
|
||||
expect(merged.userDenied).toBe(true);
|
||||
expect(merged.personalBlock).toBe(true);
|
||||
expect(merged.eventDenied).toBe(false);
|
||||
});
|
||||
|
||||
it("tolerates empty and malformed input", () => {
|
||||
expect(mergeEvidence([]).trustedReportTotal).toBe(0);
|
||||
expect(mergeEvidence(/** @type {any} */ ([null])).trustedReportTotal).toBe(0);
|
||||
});
|
||||
|
||||
it("returns sorted, stable output", () => {
|
||||
expect(mergeEvidence([populated()])).toEqual(mergeEvidence([populated()]));
|
||||
});
|
||||
});
|
||||
@@ -4,6 +4,7 @@ export default defineConfig({
|
||||
test: {
|
||||
include: [
|
||||
"packages/**/test/**/*.test.js",
|
||||
"examples/**/test/**/*.test.js",
|
||||
"tests/**/*.test.js",
|
||||
],
|
||||
environment: "node",
|
||||
|
||||
Reference in new issue
Block a user