diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e332e84 --- /dev/null +++ b/CHANGELOG.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f288702 --- /dev/null +++ b/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + 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. + + + Copyright (C) + + 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 . + +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: + + Copyright (C) + 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 +. + + 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 +. diff --git a/README.md b/README.md new file mode 100644 index 0000000..095026d --- /dev/null +++ b/README.md @@ -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). diff --git a/docs/adr/0001-four-decision-dimensions.md b/docs/adr/0001-four-decision-dimensions.md new file mode 100644 index 0000000..2961ffc --- /dev/null +++ b/docs/adr/0001-four-decision-dimensions.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". diff --git a/docs/adr/0002-capabilities-resolve-at-merge-time.md b/docs/adr/0002-capabilities-resolve-at-merge-time.md new file mode 100644 index 0000000..af90944 --- /dev/null +++ b/docs/adr/0002-capabilities-resolve-at-merge-time.md @@ -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. diff --git a/docs/adr/0003-generic-engine-application-policy.md b/docs/adr/0003-generic-engine-application-policy.md new file mode 100644 index 0000000..99f34cc --- /dev/null +++ b/docs/adr/0003-generic-engine-application-policy.md @@ -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. diff --git a/docs/adr/0004-hex-only-core.md b/docs/adr/0004-hex-only-core.md new file mode 100644 index 0000000..9395646 --- /dev/null +++ b/docs/adr/0004-hex-only-core.md @@ -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. diff --git a/docs/checkpoints.md b/docs/checkpoints.md index 80b2dc4..6d29b7a 100644 --- a/docs/checkpoints.md +++ b/docs/checkpoints.md @@ -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) \ No newline at end of file +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. diff --git a/docs/integration-guide.md b/docs/integration-guide.md new file mode 100644 index 0000000..8182d1f --- /dev/null +++ b/docs/integration-guide.md @@ -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. diff --git a/examples/commerce/README.md b/examples/commerce/README.md new file mode 100644 index 0000000..939e2ce --- /dev/null +++ b/examples/commerce/README.md @@ -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 +``` diff --git a/examples/commerce/package.json b/examples/commerce/package.json new file mode 100644 index 0000000..ebaddaf --- /dev/null +++ b/examples/commerce/package.json @@ -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": "*" + } +} diff --git a/examples/commerce/src/adapters.js b/examples/commerce/src/adapters.js new file mode 100644 index 0000000..e55c420 --- /dev/null +++ b/examples/commerce/src/adapters.js @@ -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} + */ +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} + */ +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} + */ +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}`; + }, +}); diff --git a/examples/commerce/src/marketplace.js b/examples/commerce/src/marketplace.js new file mode 100644 index 0000000..a38c33e --- /dev/null +++ b/examples/commerce/src/marketplace.js @@ -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} + */ +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} + */ +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, + }; +} diff --git a/examples/commerce/src/policy.js b/examples/commerce/src/policy.js new file mode 100644 index 0000000..7d5d232 --- /dev/null +++ b/examples/commerce/src/policy.js @@ -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} + */ +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} + */ +export const COMMERCE_ROLE_CAPABILITIES = Object.fromEntries( + Object.values(COMMERCE_ROLES).map((role) => [role.name, role.capabilities]), +); diff --git a/examples/commerce/test/proof-of-fit.test.js b/examples/commerce/test/proof-of-fit.test.js new file mode 100644 index 0000000..4b474a3 --- /dev/null +++ b/examples/commerce/test/proof-of-fit.test.js @@ -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); + }); +}); diff --git a/package-lock.json b/package-lock.json index e55c673..108e891 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 diff --git a/package.json b/package.json index eab468c..1b3d92f 100644 --- a/package.json +++ b/package.json @@ -18,7 +18,8 @@ "node": ">=22" }, "workspaces": [ - "packages/*" + "packages/*", + "examples/*" ], "devDependencies": { "@types/node": "^22.10.0", diff --git a/packages/core/src/adapter.js b/packages/core/src/adapter.js new file mode 100644 index 0000000..f5b290c --- /dev/null +++ b/packages/core/src/adapter.js @@ -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} adapter + * @returns {GovernanceApplicationAdapter} + */ +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} 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} adapter + * @param {(target: GovernanceTarget) => GovernanceDecision} evaluate + * @returns {Map} + */ +export function evaluateObjects(objects, adapter, evaluate) { + /** @type {Map} */ + 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} adapter + * @returns {GovernanceTarget[]} + */ +export function collectTargets(objects, adapter) { + /** @type {Map} */ + 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()); +} diff --git a/packages/core/src/evidence.js b/packages/core/src/evidence.js index c4ed598..65a7b4d 100644 --- a/packages/core/src/evidence.js +++ b/packages/core/src/evidence.js @@ -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} */ + const reporters = new Set(); + /** @type {Set} */ + 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. * diff --git a/packages/core/src/index.js b/packages/core/src/index.js index b52794d..28258fa 100644 --- a/packages/core/src/index.js +++ b/packages/core/src/index.js @@ -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} GovernanceApplicationAdapter */ diff --git a/packages/core/src/policy.js b/packages/core/src/policy.js index 06fda70..acc571e 100644 --- a/packages/core/src/policy.js +++ b/packages/core/src/policy.js @@ -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; } diff --git a/packages/core/test/adapter.test.js b/packages/core/test/adapter.test.js new file mode 100644 index 0000000..e085b94 --- /dev/null +++ b/packages/core/test/adapter.test.js @@ -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} */ +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([]); + }); +}); diff --git a/packages/core/test/evidence.test.js b/packages/core/test/evidence.test.js index ec0e665..ff1cb9e 100644 --- a/packages/core/test/evidence.test.js +++ b/packages/core/test/evidence.test.js @@ -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()])); + }); +}); diff --git a/vitest.config.js b/vitest.config.js index 9c10986..a8f6a86 100644 --- a/vitest.config.js +++ b/vitest.config.js @@ -4,6 +4,7 @@ export default defineConfig({ test: { include: [ "packages/**/test/**/*.test.js", + "examples/**/test/**/*.test.js", "tests/**/*.test.js", ], environment: "node",