openvibers/openvibe.meme Commits
OpenVibe.Meme v1: the open meme database, entries cited to the moment, meme of the week, the caption generator
Changes
nnew file mode 100644@@@ -0,0 +1,60 @@++# OpenVibe.Meme — environment (production: /etc/openvibe/meme.env). Names only; never commit values.++++NODE_ENV=production++PORT=5120++HOST=127.0.0.1++BASE_URL=https://openvibe.meme++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,++# the owner on a direct connection (migrations), and Valkey (shared limit counters).++# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.++DATABASE_URL=++DATABASE_DIRECT_URL=++VALKEY_URL=++VALKEY_PREFIX=ov:meme:++# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).++#MEME_LIMITS_MINUTE=120++#MEME_LIMITS_HOUR=3000++#MEME_API_RATE_LIMIT_PER_MIN=240++#MEME_RATE_LIMIT_PER_MIN=300++++# OpenVibe.Network: SSO (OAuth client `meme`, PKCE S256) and its JWKS; app tokens for audience openvibe.meme.++OV_NETWORK_URL=https://openvibe.network++OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000++# OV_NETWORK_ISSUER=https://openvibe.network++OV_OAUTH_CLIENT_ID=meme++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.meme/auth/callback++# MEME_AUDIENCE=openvibe.meme++# OV_SESSION_AUDIENCE=openvibe.network++# MEME_PGLITE_DIR=./data/pglite++++# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery++# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).++MEME_EVENTS_SECRET=++MEME_EVENTS_URL=++#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events++#MEME_EVENTS_INTERVAL_MS=5000++++# The public APIs the importer and the citation re-verifier read. No service grant is used in v1.++OV_CHAT_URL=https://openvibe.chat++OV_LIVE_URL=https://openvibe.live++OV_VIDEO_URL=https://openvibe.video++#MEME_UPSTREAM_TIMEOUT_MS=8000++++# The candidate importer: every 30 minutes, hidden candidates only, off under MEME_DRILL.++#MEME_IMPORTER_INTERVAL_MS=1800000++#MEME_IMPORTER_MIN_USES=2++#MEME_IMPORTER_MIN_DAYS=2++#MEME_IMPORTER_MAX_TOPICS=12++#MEME_IMPORTER_MAX_VODS=12++#MEME_IMPORTER_MAX_CANDIDATES=10++++# Citation re-verification: daily, off under MEME_DRILL.++#MEME_REVERIFY_INTERVAL_MS=86400000++++# A restore drill starts no jobs and creates no subscriptions.++#MEME_DRILL=0++++# optional: defaults to true in production++# COOKIE_SECURE=truennew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test scripts++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,11 @@++node_modules/++data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.key++!server/data/nnew file mode 100644@@@ -0,0 +1,37 @@++# Changelog++++What changed in OpenVibe.Meme, newest first.++++## 0.1.0 — 2026-10-28++++The first version: the open meme database, rebuilt with AI.++++- **Entries and citations.** `meme_entries` with kind, AI summary (`noLooks`; a look-naming summary becomes the++ title), status and `meme_status_events`, first/last seen, visibility and a revision that goes up on every change.++ `meme_citations` carries a verbatim quote, its sha256, the canonical VOD second and URL, and++ `live|hidden|gone`. VOD 5709 and clip 483 are a fixed deny list: never cited, kept or rendered.++- **The importer.** Every 30 minutes (off under `MEME_DRILL`) it reads Chat's public history and timeline, Live's++ Rundown and VOD transcripts, finds a 3–6 word phrase in at least two uses on two different days, follows++ OpenVibe.Video's joined-VOD redirect, and inserts one hidden candidate with an origin and its uses. It never++ publishes. A second run enriches the same slug, never duplicates it.++- **Re-verification.** Daily, every citation URL is read again: 404/410 → `gone`, 401/403 → `hidden`, a redirect++ updates the canonical VOD second. An entry whose origin is gone is hidden and tombstoned. A transient 5xx changes++ nothing.++- **The API.** Public cursor-paged `GET /entries`, one entry with its citations and timeline,++ `GET /entries/:slug/series?days=30` zero-filled per UTC day, `GET /trending`, `GET /week`, `POST /week/vote` (one++ ballot per week and person), `GET/POST /templates` and `/captions` (1–4 lines × 120 characters, blocked words,++ text only), `GET /captions/:id`, `POST /entries/:slug/report`, and staff `publish|hide|PATCH` behind Network role++ `global_mod`+ plus `GET /reports`.++- **The week and the generator.** ISO `YYYY-Www` weeks, candidate counts and the reader's own vote; a caption++ preview is server-rendered HTML text over a template image and a saved caption is its jsonb lines.++- **Search.** Each public entry is one `search.index-document@1` (owner `meme`, type `entry`, `/m/<slug>`, revision++ from the entry, provenance per citation) queued in `meme_outbox` in the same transaction; hiding writes a++ tombstone. The relay waits for the `search.document.write` grant.++- **The service.** Network sign-in as OAuth client `meme` (PKCE S256, `meme_at`/`meme_rt`/`meme_oauth`), truthful++ readiness, Prometheus metrics on loopback, discovery, deploy files, and server-rendered pages with stable `mm-`++ ids. `public/css/meme.css` and `public/js/meme.js` are one-comment placeholders for the main session.++- **The first proof.** `scripts/seed-proof.js` creates `cozmo-offline` hidden from Chat message 86957 and VOD 6025++ @ 1541 s (the canonical target of `/v/6026?t=864`), with its two citations and a noLooks-cleaned summary.++- **Tests.** Migration, importer, re-verification, deny list, noLooks, votes, captions, staff, Search document,++ SSO, pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock++ Network and a Chat/Live/Video fixture server.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are 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.++++ 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.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ 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 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 work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero 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 Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ 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 AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,199 @@++# OpenVibe.Meme++++> The open meme database, rebuilt with AI.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `openvibe.meme` · **Port:** 5120 · **Service id:** `meme` · **Env prefix:** `MEME`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Meme turns OpenVibe's own running jokes into entries with a citation to the moment each one happened —++a chat line, a VOD second, a Rundown segment — a timeline of later uses, a status (`rising`, `peaked`, `classic`,++`dead`), a weekly vote and a caption generator on templates. Every public entry is also a Search Fabric document.++++## Purpose++++- **Entries, cited.** Every entry has one origin citation and any number of later uses; the quote is verbatim,++ the URL is canonical (OpenVibe.Video's `?t=` redirect is followed, e.g. `/v/6026?t=864` → `/v/6025?t=1541`),++ and `quote_hash` is its sha256. A summary is AI-written and passes `noLooks`; if it names looks it is replaced by++ the title. Verbatim quotes are never rewritten.++- **Candidates stay hidden.** `server/importer.js` reads public Chat/Live APIs only, finds a 3–6 word phrase used at++ least twice on two different days, attaches citations and inserts the entry `hidden`. It never publishes. A staff++ person (Network role `global_mod` or above) publishes it with `POST /api/v1/entries/:slug/publish`.++- **The citation is checked again.** A daily job reads every public citation URL; a 404/410 marks it `gone`, a++ 401/403 marks it `hidden`, and an entry whose origin is gone is hidden (revision +1, Search tombstone).++- **Meme of the week.** People vote once per ISO week; a second vote changes the choice, never adds a ballot.++- **The generator.** Pick a template, type 1–4 lines of at most 120 characters each and preview them as HTML text++ over the image; a caption is stored as text lines, never as a rendered image.++++## Owns++++| Table | Holds | Migration |++|---|---|---|++| `meme_entries` | `mem_` + ULID, unique slug (`/m/<slug>`), title, kind `joke\|catchphrase\|bit\|meme\|saga`, AI summary, status + `status_since`, first/last seen, visibility `public\|unlisted\|hidden`, `revision` (+1 on every change), timestamps | `migrations/0001_initial.sql` |++| `meme_citations` | entry, role `origin\|use\|example`, source service/type/id, stream/VOD/clip/message ids, canonical VOD `offset_seconds`, verbatim `quote_text`, `quote_hash`, canonical `url`, `observed_at`, `source_status` `live\|hidden\|gone` | `0001_initial.sql` |++| `meme_status_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |++| `meme_votes` | (`week` `YYYY-Www`, `subject`) → entry; one vote a week | `0001_initial.sql` |++| `meme_templates` | id, name, `image_url` (openvibe.media or a local data-free placeholder), `box_count`, source, license; three placeholders seeded | `0001_initial.sql` |++| `meme_captions` | `mcp_` + ULID, subject, template, `lines` jsonb (1–4 plain-text lines), visibility, created | `0001_initial.sql` |++| `meme_reports` | `mrp_` + ULID, entry, subject, kind `takedown\|correction`, reason, note, status | `0001_initial.sql` |++| `meme_outbox` | Today's outbox shape: fixed `evt_` id, topic, JSON payload, attempts, last error, claim/backoff lease — the Search document queue | `0001_initial.sql` |++| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |++| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |++++## Does not own++++- **Chat, Live and the VODs** — OpenVibe.Chat and OpenVibe.Live. Meme reads their public history, Rundown and++ transcripts. It never reads a private VOD, never uses a service grant in v1, and never cites VOD **5709** or clip++ **483** (the fixed deny list).++- **Identity, roles and accounts** — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staff++ route asks for `global_mod` or above.++- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox; the Search++ document writer is not wired yet because `meme` has no `search.document.write` grant.++- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.++++## Depends on++++Environment variable names are in [.env.example](.env.example); no value is committed.++++- **OpenVibe.Network** — sign-in (OAuth client `meme`, PKCE S256, cookies `meme_at`/`meme_rt`/`meme_oauth`), JWKS,++ token exchange/refresh/revoke, account export/deletion, staff roles: `OV_NETWORK_URL`,++ `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`,++ `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `MEME_AUDIENCE`.++- **OpenVibe.Chat / Live / Video** — the public reads: `OV_CHAT_URL`, `OV_LIVE_URL`, `OV_VIDEO_URL`,++ `MEME_UPSTREAM_TIMEOUT_MS`.++- **OpenVibe.Events** — account deliveries: `MEME_EVENTS_SECRET`, `MEME_EVENTS_URL`, `MEME_EVENTS_ENDPOINT`.++- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in++ `MEME_PGLITE_DIR` for development.++- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).++- **Packages** — `openvibe-contracts` v0.229.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same++ tags OpenVibe.Today pins.++++## API++++Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `meme_at`++session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write++made with the session cookie must come from `https://openvibe.meme` (403 `meme.cross_site`). A signed-in account++also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`).++++| Route | What it does |++|---|---|++| `GET /entries?limit=&cursor=&kind=&status=` | public entries, newest first; opaque keyset cursor; `Cache-Control: public, max-age=60` |++| `GET /entries/:slug` | one entry with `origin`, every `citation`, the use `timeline` and `examples`; a hidden one is 404 unless staff |++| `GET /entries/:slug/series?days=30` | uses per UTC day, zero-filled, 1–365 days |++| `GET /trending?limit=` | public entries, `rising` first, then `peaked`, `classic`, `dead`; last use newest first |++| `GET /week` | this ISO week's candidates (public entries with a live use that week) and their vote counts |++| `POST /week/vote` | signed in, same-origin; `{ entry_id }`; upsert per (`week`, subject) — one vote a week |++| `GET /templates` | the caption templates with their caption counts |++| `POST /captions` | signed in; `{ template_id, lines, visibility? }`, 1–4 lines × 120 chars, blocked words refused |++| `GET /captions/:id` | one caption; a public read never returns the author's subject |++| `POST /entries/:slug/report` | signed in; `{ kind: "takedown"\|"correction", reason, note? }`, stored for staff |++| `GET /reports` | staff: the open reports |++| `POST /entries/:slug/publish` | staff: hidden → public; `409 meme.origin_gone` when the origin is not live |++| `POST /entries/:slug/hide` | staff: public → hidden; Search tombstone |++| `PATCH /entries/:slug` | staff: `{ title, kind, status, reason? }`; a status change writes `meme_status_events` |++++Validation refusals are `400 { code: "meme.invalid", message }`; a blocked caption is++`400 { code: "meme.blocked_words" }`. Other codes: `meme.sign_in_required`, `meme.staff_required`,++`meme.cross_site`, `meme.not_found`, `meme.template_not_found`, `meme.origin_gone`, `meme.rate_limited`.++++## The importer++++`server/importer.js` runs every 30 minutes (`MEME_IMPORTER_INTERVAL_MS`), off under `MEME_DRILL`; tests call++`runOnce()`. It reads:++++1. **Chat global history** — verbatim lines with ids and timestamps, each a usable citation.++2. **Chat global timeline** — a model paraphrase with no message id, so it is a discovery hint only; it never++ becomes a citation.++3. **Rundown topics and segments** — every quote is verbatim speech with `vod_id` and a VOD second, with `said_at`.++4. **VOD transcripts** — a segment is used only when the public VOD row gives a date; source part ids are++ canonicalised through OpenVibe.Video before a citation is stored.++++A 3–6 word phrase that starts and ends with a content word, appears in at least `MEME_IMPORTER_MIN_USES` uses on at++least `MEME_IMPORTER_MIN_DAYS` different days, and is not a substring of a stronger phrase becomes one candidate++(at most `MEME_IMPORTER_MAX_CANDIDATES` per run). Its earliest use is `origin`, the rest are `use`, and the entry++is inserted `hidden`. Re-running enriches the existing slug with new uses and an extended first/last range; it++never creates a duplicate and never publishes. Uses naming the deny list are dropped before anything is written.++++## Citations and re-verification++++`server/reverify.js` runs daily (`MEME_REVERIFY_INTERVAL_MS`), off under `MEME_DRILL`. Each citation is read again:++++- a Chat line is looked up in `GET /api/chat/global/history?before=<timestamp+1m>&limit=500`; a missing id (or a++ deleted message) is `gone`/`hidden`;++- every other citation's canonical URL is fetched; 404/410 → `gone`, 401/403 → `hidden`, 2xx/3xx → `live`;++- a redirect to a joined VOD updates `vod_id`, `offset_seconds` and `url`;++- an entry whose origin is no longer `live` is hidden (`revision` +1) and a Search tombstone is queued.++++A transient 5xx is recorded and changes nothing: a hiccup must not erase a real citation.++++## Search++++`server/search.js` builds one `search.index-document@1` for each public entry: owner `meme`, type `entry`, id++`mem_…`, revision = the entry's `revision`, canonical page `${BASE_URL}/m/<slug>`, title/summary/body (the++verbatim quotes as plain text), facets `kind`/`status`/`source`, authorship `ai_assisted`, and one provenance++reference per live citation. Publishing or editing queues `meme.index_document.upserted`; hiding, deleting or++losing the origin queues `meme.index_document.deleted` (a tombstone wins over an older revision). Rows live in++`meme_outbox` in the same transaction as the entry change and wait for the `search.document.write` grant; the relay++is deliberately not part of v1. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from one++row, a sequencer revision in the row, a tombstone when the page is no longer public.++++## Pages++++All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with semantic++HTML and stable `mm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a++signed-in person is `private, no-store`.++++| Page | What it is |++|---|---|++| `/` | trending, meme of the week, the generator teaser |++| `/m/:slug` | one entry: origin with its citation link, the use timeline, examples, and a report form when signed in |++| `/week` | the ballot (`POST /week/vote`) |++| `/make` | the generator form: template, 1–4 lines, a server-rendered preview (`GET /make`), save (`POST /make` → 303 `/c/:id`) |++| `/c/:id` | one saved caption, the lines over the template image |++| `/developers` | the API, the Search document, the importer's sources |++| 404 | the not-found page |++++`public/css/meme.css` and `public/js/meme.js` are one-comment placeholder files: the pages are complete without++them, and the main session designs them. The templates' images are local data-free SVG placeholders (or an++`openvibe.media` object when staff add a real one). `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list++the public pages and every public entry.++++## The first proof++++`scripts/seed-proof.js` reads the live public APIs and creates **one hidden entry**:++++- slug `cozmo-offline`, title `Cozmo is offline`, kind `joke`, status `classic`, visibility `hidden`;++- origin: Chat message **86957** (2026-10-28 00:11:53, “your Cozmo ain’t online yet”);++- use: VOD **6025** at **1541 s** — the canonical target of `/v/6026?t=864` (source part 6026 at 864.08 s), quote++ “but yeah and Cosmo is broken now…”;++- summary AI-written and `noLooks`-cleaned; `first_seen_at`/`last_seen_at` from the two citations.++++It never publishes. A re-run leaves the existing entry and adds only a citation that is not already there. VOD++5709 and clip 483 are refused before anything is written.++++## Run and test++++```sh++npm install++npm test # PGlite; every test file in its own process, no network needed++npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)++OV_ALLOW_SUITE=1 npm test++npm start # PORT 5120, loopback; DATABASE_URL or an embedded PGlite in development++node scripts/seed-proof.js # the first proof, hidden, from the live public APIs++```++++The tests boot the app with a mock Network, a Chat/Live/Video fixture server, and a temporary database: the++migration, the importer's candidate and its canonical redirect, the deny list, `noLooks`, the one-vote-a-week rule,++the caption limits, staff publish/patch/hide, the Search document (validated against++`search.index-document@1`), the citation 404 → gone → hidden path, sign-in, the pages and cache headers, and the++account export/deletion over votes, captions and reports.++++## Deploy++++`deploy/systemd/openvibe-meme.service` (127.0.0.1:5120, `/etc/openvibe/meme.env`) and++`deploy/nginx/openvibe.meme.conf` (TLS, www → apex, `/auth/me` on the API zone, `/internal` and `/metrics` 404, a++forms zone for the POST routes). Not deployed by this change.++++## License++++AGPL-3.0-only. See [LICENSE](LICENSE).nnew file mode 100644@@@ -0,0 +1,38 @@++# Security policy++++OpenVibe is run in the open, and we want to hear about security problems before anyone else does.++++## Reporting a vulnerability++++Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,++repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for++a vulnerability, and do not test against other people's accounts, data or streams.++++We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.++++## Scope++++Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.meme, openvibe.today, openvibe.tools,++openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under++github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.++++## What OpenVibe.Meme holds++++- Public entries: the title, kind, AI-written summary, status, times and revision. An entry is the network's record,++ not a person's row, so it is not exported or erased with an account.++- Public citations: a verbatim quote from public speech, its sha256, the canonical public URL and the source ids++ (chat message id, VOD/clip id, Rundown segment id). No private VOD bytes, thumbnails or metadata are kept.++- A person's weekly votes, saved caption lines and reports. These are exported and erased with an account.++- Network session tokens only in httpOnly cookies (`meme_at`, `meme_rt`); no token is stored in the database,++ rendered into a page or logged.++++VOD 5709 and clip 483 are a fixed deny list: no title, thumbnail or quote from either is ever stored, and a++citation naming them is dropped before it is written. A citation URL is re-read daily; a 404 marks it gone.++++A caption is plain text lines on a template; v1 never accepts, renders or stores an image uploaded by a person.++The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for++`/internal` and `/metrics`.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,64 @@++{++ "repository": "OpenVibers/OpenVibe.Meme",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Meme: the open meme database rebuilt with AI — entries built from OpenVibe's own running jokes, each cited to the moment it happened, a timeline of later uses, a status, meme of the week and a caption generator on templates",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5120",++ "domain": "openvibe.meme",++ "contracts": "openvibe-contracts v0.229.0",++ "sdk": "openvibe-sdk v0.70.0",++ "shared": "openvibe-shared v4.31.0",++ "packages": [++ "openvibe-contracts v0.229.0",++ "openvibe-sdk v0.70.0",++ "openvibe-shared v4.31.0"++ ],++ "summary": "OpenVibe.Meme is the open meme database, rebuilt with AI. The importer reads only public Chat and Live APIs and proposes a candidate when a recurring phrase appears in at least two uses on two different days; the candidate is born hidden with a verbatim citation for every use and stays hidden until a staff person publishes it. Every citation is re-verified daily, a 404 marks it gone and an entry whose origin is gone is hidden. People vote once a week for meme of the week, and a caption is plain text over a template, never a rendered image. Every public entry is one Search Fabric document queued in the service's outbox.",++ "works": [++ "Entries with a unique slug, kind, AI summary passing noLooks, status and status history, first/last seen, visibility and a revision that goes up on every change",++ "Citations with role origin/use/example, verbatim quote and sha256, canonical OpenVibe.Video URL, VOD seconds, observed time and source_status live/hidden/gone",++ "The public importer: Chat history/timeline, Rundown topics and segments, VOD transcripts; a recurring phrase across ≥2 uses on ≥2 days; hidden candidates only, never a publish",++ "Daily citation re-verification: 404/410 → gone, 401/403 → hidden, the origin gone hides the entry, a transient 5xx changes nothing",++ "The fixed deny list: VOD 5709 and clip 483 are never cited, cached or rendered",++ "Meme of the week: ISO weeks, one ballot per week and person, a second vote changes the choice",++ "The caption generator: a template, 1–4 lines × 120 characters, a blocked-word list, server-rendered HTML preview, text lines stored in jsonb",++ "The API: public cursor-paged entries, an entry with citations and timeline, a zero-filled use series, trending, the week, templates, captions, reports, and staff publish/hide/patch",++ "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden",++ "Sign-in with OpenVibe.Network (PKCE S256, meme_at/meme_rt/meme_oauth), staff role global_mod+",++ "Account export and deletion over votes, captions and reports",++ "Server-rendered pages with stable mm- ids/classes, cache headers, discovery artifacts and deploy files"++ ],++ "notYet": [++ "The Search document relay: documents wait in meme_outbox until Network grants meme search.document.write",++ "A per-message Chat permalink API; a chat citation stores the message id and the public history is re-read to verify it",++ "Automatic status transitions by the proposed rising/peaked/classic/dead rules; the importer sets a first status and staff can change it",++ "Real template artwork: v1 ships three data-free local SVG placeholders; a real template is an openvibe.media object",++ "A staff moderation screen beyond GET /api/v1/reports; reports are stored and read through the API",++ "A cron/startup backfill of the proof beyond scripts/seed-proof.js"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "entries and citations",++ "hidden candidate importer",++ "citation re-verification and deny list",++ "meme of the week vote",++ "caption generator",++ "templates",++ "reports",++ "staff publish/hide/patch",++ "Search Fabric outbox",++ "account export and deletion",++ "Network sign-in",++ "server-rendered pages",++ "discovery",++ "deploy files"++ ],++ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-meme.service and deploy/nginx/openvibe.meme.conf",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.meme — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.meme.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5120, systemd unit openvibe-meme) renders every page and serves++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses++## text.++##++## No proxy_cache: the app decides cacheability per response (public for the public pages and entries; private,++## no-store for anything drawn for a signed-in person). Never log request bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=meme_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=meme_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=meme_forms:10m rate=1r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.meme www.openvibe.meme;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.meme$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.meme;++++ ssl_certificate /etc/letsencrypt/live/openvibe.meme/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.meme/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.meme$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.meme;++++ ssl_certificate /etc/letsencrypt/live/openvibe.meme/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.meme/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.meme.access.log;++ error_log /var/log/nginx/openvibe.meme.error.log;++++ # The API's JSON body cap is 64 KB; leave room for the headers.++ client_max_body_size 128k;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints ───────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ limit_req zone=meme_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5120;++ }++++ location /auth/ {++ limit_req zone=meme_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5120;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=meme_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5120;++ }++++ # ── Forms and other writes: the vote, the caption generator, the report ──++ location ~ ^/(week/vote|make|m/[a-z0-9-]+/report)$ {++ limit_req zone=meme_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5120;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5120/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5120/internal/events directly. Never proxied from outside (ADR-033).++ location ~* ^/internal(/|$) { return 404; }++++ # ── Pages, docs, robots.txt, sitemap, static assets ─────++ location / {++ proxy_pass http://127.0.0.1:5120;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe.Meme — the open meme database, rebuilt with AI.++# Network (sign-in, JWKS, account events) should be up first, but Meme degrades honestly without it: the public++# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).++After=network.target postgresql.service pgbouncer.service valkey-server.service++Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service++++[Service]++Type=simple++User=ubuntu++Group=ubuntu++WorkingDirectory=/opt/openvibe.meme++EnvironmentFile=/etc/openvibe/meme.env++Environment=NODE_ENV=production++Environment=PORT=5120++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.meme/server/index.js++Restart=always++RestartSec=5++KillSignal=SIGTERM++# In-flight requests get the 10 s drain.++TimeoutStopSec=15++NoNewPrivileges=true++PrivateTmp=true++ProtectSystem=strict++ProtectHome=true++# OpenVibe.Meme's own database is PostgreSQL on the host's data role; the code tree stays read-only.++++[Install]++WantedBy=multi-user.targetnnew file mode 100644@@@ -0,0 +1,207 @@++-- phase: expand++-- OpenVibe.Meme: the open meme database, rebuilt with AI. Every entry is built from OpenVibe's own running++-- jokes and cites the moment it happened (a chat line, a VOD second, a Rundown segment). A candidate is born++-- hidden and only a staff person publishes it. Nothing written here is a credential: a citation stores the++-- public URL and a hash of the verbatim quote, never private bytes or a person's token.++++CREATE TABLE meme_entries (++ id text COLLATE "C" PRIMARY KEY, -- mem_ + 26 Crockford base32 characters (ULID)++ slug text COLLATE "C" NOT NULL UNIQUE, -- /m/<slug>++ title text NOT NULL,++ kind text COLLATE "C" NOT NULL, -- joke | catchphrase | bit | meme | saga++ summary text, -- AI-written, noLooks-cleaned; replaced by title when it names looks++ status text COLLATE "C" NOT NULL DEFAULT 'rising',-- rising | peaked | classic | dead++ status_since timestamptz NOT NULL DEFAULT now(),++ first_seen_at timestamptz NOT NULL,++ last_seen_at timestamptz NOT NULL,++ visibility text COLLATE "C" NOT NULL DEFAULT 'hidden',-- public | unlisted | hidden++ revision bigint NOT NULL DEFAULT 1, -- +1 on every change; the Search revision++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT meme_entries_id CHECK (id ~ '^mem_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT meme_entries_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),++ CONSTRAINT meme_entries_title CHECK (length(title) BETWEEN 1 AND 160),++ CONSTRAINT meme_entries_kind CHECK (kind IN ('joke', 'catchphrase', 'bit', 'meme', 'saga')),++ CONSTRAINT meme_entries_status CHECK (status IN ('rising', 'peaked', 'classic', 'dead')),++ CONSTRAINT meme_entries_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden')),++ CONSTRAINT meme_entries_revision CHECK (revision > 0),++ CONSTRAINT meme_entries_seen CHECK (first_seen_at <= last_seen_at)++);++CREATE INDEX meme_entries_public ON meme_entries (visibility, status, last_seen_at DESC);++CREATE INDEX meme_entries_by_status ON meme_entries (status, last_seen_at DESC);++CREATE INDEX meme_entries_by_first_seen ON meme_entries (first_seen_at DESC, id DESC);++++-- Where a use happened. role origin is the first moment (one per entry in practice), use is a later use, example++-- is an illustrative citation. quote_text is verbatim human speech, never rewritten; quote_hash is sha256 of it,++-- so a re-verification can tell whether the source still says the same thing. source_status: live | hidden | gone.++CREATE TABLE meme_citations (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,++ role text COLLATE "C" NOT NULL, -- origin | use | example++ source_service text COLLATE "C" NOT NULL, -- live | chat | community++ source_type text COLLATE "C" NOT NULL, -- chat_message | vod | clip | rundown_segment | ai_paste | stream_memory++ source_id text COLLATE "C" NOT NULL, -- message id, VOD id, clip id, segment id, paste slug++ stream_id bigint,++ vod_id bigint,++ clip_id bigint,++ chat_message_id bigint,++ offset_seconds numeric, -- canonical VOD seconds (never stream seconds)++ quote_text text NOT NULL,++ quote_hash text COLLATE "C" NOT NULL,++ url text NOT NULL, -- canonical public URL, e.g. https://openvibe.video/v/6025?t=1541++ observed_at timestamptz NOT NULL, -- when the use happened++ source_status text COLLATE "C" NOT NULL DEFAULT 'live',++ captured_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT meme_citations_role CHECK (role IN ('origin', 'use', 'example')),++ CONSTRAINT meme_citations_source_service CHECK (source_service IN ('live', 'chat', 'community')),++ CONSTRAINT meme_citations_source_type CHECK (source_type IN ('chat_message', 'vod', 'clip', 'rundown_segment', 'ai_paste', 'stream_memory')),++ CONSTRAINT meme_citations_quote CHECK (length(quote_text) >= 1),++ CONSTRAINT meme_citations_hash CHECK (quote_hash ~ '^[0-9a-f]{64}$'),++ CONSTRAINT meme_citations_url CHECK (url ~ '^https?://'),++ CONSTRAINT meme_citations_offset CHECK (offset_seconds IS NULL OR offset_seconds >= 0),++ CONSTRAINT meme_citations_status CHECK (source_status IN ('live', 'hidden', 'gone'))++);++CREATE UNIQUE INDEX meme_citations_unique ON meme_citations++ (entry_id, source_service, source_type, source_id, COALESCE(offset_seconds, -1));++CREATE INDEX meme_citations_by_entry ON meme_citations (entry_id, role, observed_at);++CREATE INDEX meme_citations_for_reverify ON meme_citations (source_status, captured_at);++++CREATE TABLE meme_status_events (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,++ from_status text COLLATE "C",++ to_status text COLLATE "C" NOT NULL,++ reason text,++ changed_by text COLLATE "C", -- usr_… or service:meme++ changed_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT meme_status_events_from CHECK (from_status IS NULL OR from_status IN ('rising', 'peaked', 'classic', 'dead')),++ CONSTRAINT meme_status_events_to CHECK (to_status IN ('rising', 'peaked', 'classic', 'dead'))++);++CREATE INDEX meme_status_events_by_entry ON meme_status_events (entry_id, changed_at DESC);++++-- One vote a week: (week, subject) is the key, so a second vote in the same ISO week changes the choice, never++-- adds a second ballot. week is YYYY-Www (ISO-8601, UTC).++CREATE TABLE meme_votes (++ week text COLLATE "C" NOT NULL,++ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL, -- usr_…++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (week, subject),++ CONSTRAINT meme_votes_week CHECK (week ~ '^[0-9]{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$'),++ CONSTRAINT meme_votes_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')++);++CREATE INDEX meme_votes_by_entry ON meme_votes (week, entry_id);++++CREATE TABLE meme_templates (++ id text COLLATE "C" PRIMARY KEY,++ name text NOT NULL,++ image_url text NOT NULL, -- openvibe.media object, or a local data-free placeholder++ box_count integer NOT NULL,++ source text NOT NULL,++ license text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT meme_templates_box_count CHECK (box_count BETWEEN 1 AND 8),++ CONSTRAINT meme_templates_image CHECK (image_url ~ '^(https://openvibe\.media/|/img/)'),++ CONSTRAINT meme_templates_name CHECK (length(name) BETWEEN 1 AND 80)++);++++CREATE TABLE meme_captions (++ id text COLLATE "C" PRIMARY KEY, -- mcp_ + ULID++ subject text COLLATE "C" NOT NULL, -- usr_…++ template_id text COLLATE "C" NOT NULL REFERENCES meme_templates (id),++ lines jsonb NOT NULL, -- array of 1–4 plain-text lines++ created_at timestamptz NOT NULL DEFAULT now(),++ visibility text COLLATE "C" NOT NULL DEFAULT 'public', -- public | unlisted | hidden++ CONSTRAINT meme_captions_id CHECK (id ~ '^mcp_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT meme_captions_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT meme_captions_lines CHECK (jsonb_typeof(lines) = 'array' AND jsonb_array_length(lines) BETWEEN 1 AND 4),++ CONSTRAINT meme_captions_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden'))++);++CREATE INDEX meme_captions_by_subject ON meme_captions (subject, created_at DESC);++CREATE INDEX meme_captions_by_template ON meme_captions (template_id, created_at DESC);++++CREATE TABLE meme_reports (++ id text COLLATE "C" PRIMARY KEY, -- mrp_ + ULID++ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,++ subject text COLLATE "C" NOT NULL,++ kind text COLLATE "C" NOT NULL, -- takedown | correction++ reason text NOT NULL,++ note text,++ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | handled | dismissed++ created_at timestamptz NOT NULL DEFAULT now(),++ handled_at timestamptz,++ handled_by text COLLATE "C",++ CONSTRAINT meme_reports_id CHECK (id ~ '^mrp_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT meme_reports_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT meme_reports_kind CHECK (kind IN ('takedown', 'correction')),++ CONSTRAINT meme_reports_reason CHECK (length(reason) BETWEEN 3 AND 2000),++ CONSTRAINT meme_reports_note CHECK (note IS NULL OR length(note) <= 4000),++ CONSTRAINT meme_reports_status CHECK (status IN ('open', 'handled', 'dismissed'))++);++CREATE INDEX meme_reports_by_entry ON meme_reports (entry_id, created_at DESC);++CREATE INDEX meme_reports_open ON meme_reports (status, created_at);++++-- The Search Fabric outbox (Today's shape). A document for a public entry is written with this fixed evt_ id in++-- the same transaction as the entry change; a hidden entry writes a tombstone. No service grant is needed to++-- queue it, and the rows wait until OpenVibe.Search's document writer is configured.++CREATE TABLE meme_outbox (++ event_id text COLLATE "C" PRIMARY KEY,++ topic text COLLATE "C" NOT NULL,++ payload jsonb NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ published_at timestamptz,++ attempts integer NOT NULL DEFAULT 0,++ last_error text,++ next_attempt_at timestamptz NOT NULL DEFAULT now(),++ claimed_at timestamptz,++ claimed_by text++);++CREATE INDEX meme_outbox_pending ON meme_outbox (next_attempt_at) WHERE published_at IS NULL;++++-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes++-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.++CREATE TABLE account_data_events (++ id text PRIMARY KEY,++ kind text NOT NULL,++ subject text NOT NULL,++ outcome jsonb,++ applied_at timestamptz NOT NULL DEFAULT now(),++ sent_at timestamptz++);++++-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.++CREATE TABLE telemetry_samples (++ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,++ at timestamptz NOT NULL,++ service text NOT NULL,++ project text,++ subject text,++ resource text,++ provider text,++ node text,++ cell text,++ region text,++ operation text NOT NULL,++ latency_ms double precision,++ queue_delay_ms double precision,++ ttfb_ms double precision,++ throughput_per_second double precision,++ bytes bigint,++ status text,++ cache_status text,++ cost_estimate double precision,++ route_epoch bigint,++ trace_id text,++ extra jsonb NOT NULL DEFAULT '{}'::jsonb++);++CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);++CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);++++-- v1 ships three data-free placeholders; a real template is an openvibe.media object added by staff. The++-- generator preview is HTML text over the image, and no caption is ever stored as an image.++INSERT INTO meme_templates (id, name, image_url, box_count, source, license) VALUES++ ('tpl_classic', 'Classic two-line', '/img/template-classic.svg', 2, 'OpenVibe', 'CC0-1.0'),++ ('tpl_reaction', 'Reaction', '/img/template-reaction.svg', 1, 'OpenVibe', 'CC0-1.0'),++ ('tpl_triptych', 'Three panels', '/img/template-triptych.svg', 3, 'OpenVibe', 'CC0-1.0')++ON CONFLICT (id) DO NOTHING;nnew file mode 100644@@@ -0,0 +1,1407 @@++{++ "name": "openvibe-meme",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-meme",++ "version": "0.1.0",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ },++ "engines": {++ "node": ">=22.0.0"++ }++ },++ "node_modules/@electric-sql/pglite": {++ "version": "0.5.13",++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",++ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",++ "devOptional": true,++ "license": "Apache-2.0"++ },++ "node_modules/@iovalkey/commands": {++ "version": "0.1.0",++ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",++ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",++ "license": "MIT"++ },++ "node_modules/accepts": {++ "version": "1.3.8",++ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",++ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",++ "license": "MIT",++ "dependencies": {++ "mime-types": "~2.1.34",++ "negotiator": "0.6.3"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ajv": {++ "version": "8.20.0",++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",++ "license": "MIT",++ "dependencies": {++ "fast-deep-equal": "^3.1.3",++ "fast-uri": "^3.0.1",++ "json-schema-traverse": "^1.0.0",++ "require-from-string": "^2.0.2"++ },++ "funding": {++ "type": "github",++ "url": "https://github.com/sponsors/epoberezkin"++ }++ },++ "node_modules/ajv-formats": {++ "version": "3.0.1",++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",++ "license": "MIT",++ "dependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependencies": {++ "ajv": "^8.0.0"++ },++ "peerDependenciesMeta": {++ "ajv": {++ "optional": true++ }++ }++ },++ "node_modules/array-flatten": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",++ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",++ "license": "MIT"++ },++ "node_modules/body-parser": {++ "version": "1.20.9",++ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",++ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "content-type": "~1.0.5",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "~1.2.0",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "on-finished": "~2.4.1",++ "qs": "~6.16.0",++ "raw-body": "~2.5.3",++ "type-is": "~1.6.18",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/buffer-equal-constant-time": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",++ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",++ "license": "BSD-3-Clause"++ },++ "node_modules/bytes": {++ "version": "3.1.2",++ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",++ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/call-bind-apply-helpers": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",++ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/call-bound": {++ "version": "1.0.4",++ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",++ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "get-intrinsic": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/cluster-key-slot": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",++ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/content-disposition": {++ "version": "0.5.4",++ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",++ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",++ "license": "MIT",++ "dependencies": {++ "safe-buffer": "5.2.1"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/content-type": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",++ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie": {++ "version": "0.7.2",++ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",++ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/cookie-parser": {++ "version": "1.4.7",++ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",++ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",++ "license": "MIT",++ "dependencies": {++ "cookie": "0.7.2",++ "cookie-signature": "1.0.6"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/cookie-signature": {++ "version": "1.0.6",++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",++ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",++ "license": "MIT"++ },++ "node_modules/debug": {++ "version": "2.6.9",++ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",++ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",++ "license": "MIT",++ "dependencies": {++ "ms": "2.0.0"++ }++ },++ "node_modules/denque": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",++ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",++ "license": "Apache-2.0",++ "engines": {++ "node": ">=0.10"++ }++ },++ "node_modules/depd": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",++ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/destroy": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",++ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8",++ "npm": "1.2.8000 || >= 1.4.16"++ }++ },++ "node_modules/dotenv": {++ "version": "16.6.1",++ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",++ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",++ "license": "BSD-2-Clause",++ "engines": {++ "node": ">=12"++ },++ "funding": {++ "url": "https://dotenvx.com"++ }++ },++ "node_modules/dunder-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",++ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.1",++ "es-errors": "^1.3.0",++ "gopd": "^1.2.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/ecdsa-sig-formatter": {++ "version": "1.0.11",++ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",++ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",++ "license": "Apache-2.0",++ "dependencies": {++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/ee-first": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",++ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",++ "license": "MIT"++ },++ "node_modules/encodeurl": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",++ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/es-define-property": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",++ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-errors": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",++ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/es-object-atoms": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",++ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/escape-html": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",++ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",++ "license": "MIT"++ },++ "node_modules/etag": {++ "version": "1.8.1",++ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",++ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/express": {++ "version": "4.22.3",++ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",++ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",++ "license": "MIT",++ "dependencies": {++ "accepts": "~1.3.8",++ "array-flatten": "1.1.1",++ "body-parser": "~1.20.5",++ "content-disposition": "~0.5.4",++ "content-type": "~1.0.4",++ "cookie": "~0.7.1",++ "cookie-signature": "~1.0.6",++ "debug": "2.6.9",++ "depd": "2.0.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "finalhandler": "~1.3.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.0",++ "merge-descriptors": "1.0.3",++ "methods": "~1.1.2",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "path-to-regexp": "~0.1.13",++ "proxy-addr": "~2.0.7",++ "qs": "~6.16.0",++ "range-parser": "~1.2.1",++ "safe-buffer": "5.2.1",++ "send": "~0.19.0",++ "serve-static": "~1.16.2",++ "setprototypeof": "1.2.0",++ "statuses": "~2.0.1",++ "type-is": "~1.6.18",++ "utils-merge": "1.0.1",++ "vary": "~1.1.2"++ },++ "engines": {++ "node": ">= 0.10.0"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/express-rate-limit": {++ "version": "7.5.1",++ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",++ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",++ "license": "MIT",++ "engines": {++ "node": ">= 16"++ },++ "funding": {++ "url": "https://github.com/sponsors/express-rate-limit"++ },++ "peerDependencies": {++ "express": ">= 4.11"++ }++ },++ "node_modules/fast-deep-equal": {++ "version": "3.1.3",++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",++ "license": "MIT"++ },++ "node_modules/fast-uri": {++ "version": "3.1.9",++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",++ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/fastify"++ },++ {++ "type": "opencollective",++ "url": "https://opencollective.com/fastify"++ }++ ],++ "license": "BSD-3-Clause"++ },++ "node_modules/finalhandler": {++ "version": "1.3.2",++ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",++ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "on-finished": "~2.4.1",++ "parseurl": "~1.3.3",++ "statuses": "~2.0.2",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/forwarded": {++ "version": "0.2.0",++ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",++ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/fresh": {++ "version": "0.5.2",++ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",++ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/function-bind": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",++ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-intrinsic": {++ "version": "1.3.0",++ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",++ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",++ "license": "MIT",++ "dependencies": {++ "call-bind-apply-helpers": "^1.0.2",++ "es-define-property": "^1.0.1",++ "es-errors": "^1.3.0",++ "es-object-atoms": "^1.1.1",++ "function-bind": "^1.1.2",++ "get-proto": "^1.0.1",++ "gopd": "^1.2.0",++ "has-symbols": "^1.1.0",++ "hasown": "^2.0.2",++ "math-intrinsics": "^1.1.0"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/get-proto": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",++ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",++ "license": "MIT",++ "dependencies": {++ "dunder-proto": "^1.0.1",++ "es-object-atoms": "^1.0.0"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/gopd": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",++ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/has-symbols": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",++ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/hasown": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",++ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",++ "license": "MIT",++ "dependencies": {++ "function-bind": "^1.1.2"++ },++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/helmet": {++ "version": "8.3.0",++ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",++ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",++ "license": "MIT",++ "engines": {++ "node": ">=18.0.0"++ },++ "funding": {++ "url": "https://github.com/sponsors/EvanHahn"++ }++ },++ "node_modules/http-errors": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",++ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",++ "license": "MIT",++ "dependencies": {++ "depd": "~2.0.0",++ "inherits": "~2.0.4",++ "setprototypeof": "~1.2.0",++ "statuses": "~2.0.2",++ "toidentifier": "~1.0.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/iconv-lite": {++ "version": "0.4.24",++ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",++ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",++ "license": "MIT",++ "dependencies": {++ "safer-buffer": ">= 2.1.2 < 3"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/inherits": {++ "version": "2.0.4",++ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",++ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",++ "license": "ISC"++ },++ "node_modules/iovalkey": {++ "version": "0.4.0",++ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",++ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",++ "license": "MIT",++ "dependencies": {++ "@iovalkey/commands": "^0.1.0",++ "cluster-key-slot": "^1.1.0",++ "debug": "^4.3.4",++ "denque": "^2.1.0",++ "lodash.defaults": "^4.2.0",++ "lodash.isarguments": "^3.1.0",++ "redis-errors": "^1.2.0",++ "redis-parser": "^3.0.0",++ "standard-as-callback": "^2.1.0"++ },++ "engines": {++ "node": ">=18.12.0"++ }++ },++ "node_modules/iovalkey/node_modules/debug": {++ "version": "4.4.3",++ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",++ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",++ "license": "MIT",++ "dependencies": {++ "ms": "^2.1.3"++ },++ "engines": {++ "node": ">=6.0"++ },++ "peerDependenciesMeta": {++ "supports-color": {++ "optional": true++ }++ }++ },++ "node_modules/iovalkey/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/ipaddr.js": {++ "version": "1.9.1",++ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",++ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.10"++ }++ },++ "node_modules/json-schema-traverse": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",++ "license": "MIT"++ },++ "node_modules/jsonwebtoken": {++ "version": "9.0.3",++ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",++ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",++ "license": "MIT",++ "dependencies": {++ "jws": "^4.0.1",++ "lodash.includes": "^4.3.0",++ "lodash.isboolean": "^3.0.3",++ "lodash.isinteger": "^4.0.4",++ "lodash.isnumber": "^3.0.3",++ "lodash.isplainobject": "^4.0.6",++ "lodash.isstring": "^4.0.1",++ "lodash.once": "^4.0.0",++ "ms": "^2.1.1",++ "semver": "^7.5.4"++ },++ "engines": {++ "node": ">=12",++ "npm": ">=6"++ }++ },++ "node_modules/jsonwebtoken/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/jwa": {++ "version": "2.0.1",++ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",++ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",++ "license": "MIT",++ "dependencies": {++ "buffer-equal-constant-time": "^1.0.1",++ "ecdsa-sig-formatter": "1.0.11",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/jws": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",++ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",++ "license": "MIT",++ "dependencies": {++ "jwa": "^2.0.1",++ "safe-buffer": "^5.0.1"++ }++ },++ "node_modules/lodash.defaults": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",++ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",++ "license": "MIT"++ },++ "node_modules/lodash.includes": {++ "version": "4.3.0",++ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",++ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",++ "license": "MIT"++ },++ "node_modules/lodash.isarguments": {++ "version": "3.1.0",++ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",++ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",++ "license": "MIT"++ },++ "node_modules/lodash.isboolean": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",++ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",++ "license": "MIT"++ },++ "node_modules/lodash.isinteger": {++ "version": "4.0.4",++ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",++ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",++ "license": "MIT"++ },++ "node_modules/lodash.isnumber": {++ "version": "3.0.3",++ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",++ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",++ "license": "MIT"++ },++ "node_modules/lodash.isplainobject": {++ "version": "4.0.6",++ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",++ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",++ "license": "MIT"++ },++ "node_modules/lodash.isstring": {++ "version": "4.0.1",++ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",++ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",++ "license": "MIT"++ },++ "node_modules/lodash.once": {++ "version": "4.1.1",++ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",++ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",++ "license": "MIT"++ },++ "node_modules/math-intrinsics": {++ "version": "1.1.0",++ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",++ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ }++ },++ "node_modules/media-typer": {++ "version": "0.3.0",++ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",++ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/merge-descriptors": {++ "version": "1.0.3",++ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",++ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",++ "license": "MIT",++ "funding": {++ "url": "https://github.com/sponsors/sindresorhus"++ }++ },++ "node_modules/methods": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",++ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime": {++ "version": "1.6.0",++ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",++ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",++ "license": "MIT",++ "bin": {++ "mime": "cli.js"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/mime-db": {++ "version": "1.52.0",++ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",++ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/mime-types": {++ "version": "2.1.35",++ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",++ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",++ "license": "MIT",++ "dependencies": {++ "mime-db": "1.52.0"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/ms": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",++ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",++ "license": "MIT"++ },++ "node_modules/negotiator": {++ "version": "0.6.3",++ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",++ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/object-inspect": {++ "version": "1.13.4",++ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",++ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/on-finished": {++ "version": "2.4.2",++ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",++ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",++ "license": "MIT",++ "dependencies": {++ "ee-first": "1.1.1"++ },++ "engines": {++ "node": ">= 0.8"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/openvibe-contracts": {++ "version": "0.234.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",++ "integrity": "sha512-qCv+UuC1Q/megqkkLu69f5VBivuZao6jIztYmcj9e4KBT2cBe8zNykkOaQJS/Uu6weU+gy6NVHZ4w1K3SKM7FA==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.70.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.31.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-meme",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Meme — the open meme database, rebuilt with AI.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "seed:proof": "node scripts/seed-proof.js",++ "test": "node test/run.js",++ "test:pg": "MEME_TEST_STORE=pg node test/run.js"++ },++ "keywords": [++ "openvibe"++ ],++ "author": "OpenVibers",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "cookie-parser": "^1.4.7",++ "dotenv": "^16.4.7",++ "express": "^4.21.2",++ "express-rate-limit": "^7.5.0",++ "helmet": "^8.1.0",++ "iovalkey": "^0.4.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "pg": "^8.23.0"++ },++ "engines": {++ "node": ">=22.0.0"++ },++ "devDependencies": {++ "@electric-sql/pglite": "^0.5.8"++ }++}nnew file mode 100644@@@ -0,0 +1,160 @@++/* OpenVibe.Meme: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent++ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone++ first; nothing scrolls sideways. A caption is text over a template, drawn here in the classic meme style. */++*, *::before, *::after { box-sizing: border-box; }++html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }++.skip { position: absolute; left: -9999px; }++.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }++.mm { display: block; }++.mm a { color: var(--room-strong); }++.mm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.mm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.mm-narrow { max-width: 620px; }++.mm-page-pad { padding-top: clamp(28px, 6vw, 64px); }++.mm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.mm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.mm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }++.mm-page-h h1, .mm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }++.mm-lede, .mm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.mm-fine, .mm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.mm-section { margin-top: clamp(28px, 4vw, 44px); }++.mm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.mm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }++.mm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }++.mm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }++.mm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }++.mm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }++.mm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }++.mm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }++.mm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.mm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }++.mm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }++.mm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }++.mm-empty p { margin: 0; line-height: 1.5; }++.mm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++++/* Status: rising (the room's gold), peaked (warm), classic (calm), dead (muted). */++.mm-status { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .76rem; font-weight: 800; letter-spacing: .01em; }++.mm-status-rising { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 18%, transparent); }++.mm-status-peaked { color: var(--warning-text, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 14%, transparent); }++.mm-status-classic { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }++.mm-status-dead { color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 16%, transparent); }++++/* The family-tree motif in the hero */++.mm-tree { position: relative; width: min(100%, 440px); aspect-ratio: 400 / 280; margin: 0 auto; }++.mm-tree-lines { position: absolute; inset: 0; width: 100%; height: 100%; }++.mm-tree-lines path { fill: none; stroke: var(--room-accent); stroke-width: 2.5; stroke-linecap: round; opacity: .55; stroke-dasharray: 260; stroke-dashoffset: 0; }++html.ov-motion .mm-tree-lines path { animation: mm-grow 1.6s cubic-bezier(.2,.7,.2,1) both; }++html.ov-motion .mm-tree-lines path:nth-child(n+4) { animation-delay: .5s; }++@keyframes mm-grow { from { stroke-dashoffset: 260; } }++.mm-node { position: absolute; translate: -50% -50%; display: grid; gap: 2px; padding: 7px 12px; border-radius: 12px; white-space: nowrap; font-size: .8rem; font-weight: 700;++ background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); box-shadow: 0 8px 22px rgba(0, 0, 0, .22); }++.mm-node small { font-weight: 600; color: var(--text-muted, #7386a3); font-size: .7rem; }++.mm-node-root { left: 50%; top: 22%; padding: 10px 16px; font-size: .95rem; background: var(--room-fill); color: var(--room-on); border-color: transparent; }++.mm-node-root small { color: inherit; opacity: .75; }++.mm-n1 { left: 23%; top: 67%; } .mm-n2 { left: 50%; top: 67%; } .mm-n3 { left: 77%; top: 67%; }++.mm-sticker { position: absolute; padding: 4px 10px; border-radius: 8px; font: 900 .78rem/1 Impact, 'Arial Black', sans-serif; letter-spacing: .04em; text-transform: uppercase; color: #fff;++ background: color-mix(in srgb, var(--room-accent) 70%, #000); rotate: -8deg; box-shadow: 0 6px 14px rgba(0, 0, 0, .25); }++.mm-s1 { left: 6%; top: 88%; } .mm-s2 { left: 31%; top: 90%; rotate: 7deg; } .mm-s3 { left: 82%; top: 89%; rotate: -4deg; }++html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) both; animation-delay: calc(.9s + var(--d, 0s)); }++.mm-s2 { --d: .12s; } .mm-s3 { --d: .24s; }++@keyframes mm-fan { from { opacity: 0; translate: 0 12px; scale: .6; } }++++/* Entry cards */++.mm-entries { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }++.mm-entry-link { display: grid; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important;++ background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }++.mm-entry-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }++.mm-entry-title { font-size: 1.05rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }++.mm-entry-meta, .mm-entry-when { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary, #96a7c2); font-size: .84rem; }++.mm-entry-when { color: var(--text-muted, #7386a3); font-size: .8rem; }++++/* Citations: the quote is the thing */++.mm-cite { margin: 0; padding: 16px 18px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-left: 4px solid var(--room-accent); }++.mm-quote { margin: 0; font-size: 1.12rem; line-height: 1.45; font-weight: 600; overflow-wrap: anywhere; }++.mm-quote::before { content: '“'; color: var(--room-strong); } .mm-quote::after { content: '”'; color: var(--room-strong); }++.mm-cite-foot { margin-top: 8px; color: var(--text-muted, #7386a3); font-size: .84rem; }++.mm-cite-source { font-weight: 800; color: var(--text-secondary, #96a7c2); }++.mm-cite-link { font-weight: 700; }++.mm-cite-state { margin-left: 8px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; font-weight: 800; color: var(--error-text, #f87171); background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); }++++/* An entry */++.mm-entry-h { padding-top: clamp(20px, 4vw, 40px); }++.mm-entry-h .mm-kicker { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.mm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }++.mm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.mm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.mm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }++.mm-origin, .mm-timeline-sec, .mm-examples { margin-top: 30px; }++.mm-origin h2, .mm-timeline-sec h2, .mm-examples h2 { margin-bottom: 12px; }++.mm-origin .mm-cite { border-left-width: 6px; padding: 20px 22px; }++.mm-origin .mm-quote { font-size: clamp(1.2rem, 3vw, 1.6rem); }++.mm-timeline { list-style: none; margin: 0; padding: 0 0 0 18px; display: grid; gap: 12px; border-left: 2px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); }++.mm-timeline-item { position: relative; display: grid; gap: 6px; }++.mm-timeline-item::before { content: ''; position: absolute; left: -25px; top: 6px; width: 12px; height: 12px; border-radius: 50%; background: var(--room-fill); box-shadow: 0 0 0 3px var(--bg-primary, #0a0f1c); }++.mm-timeline-when { font-size: .8rem; font-weight: 800; color: var(--text-muted, #7386a3); }++.mm-examples .mm-cite + .mm-cite { margin-top: 10px; }++.mm-entry-foot { margin-top: 32px; }++.mm-report summary { cursor: pointer; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.mm-staff { color: var(--warning-text, #f59e0b); }++++/* The ballot */++.mm-candidates { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.mm-candidate { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 18px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.mm-candidate.is-mine { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--border, #1f2d47)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 14%, transparent); }++.mm-candidate-body { display: grid; gap: 4px; min-width: 0; }++.mm-candidate-title { font-size: 1.05rem; font-weight: 800; text-decoration: none; overflow-wrap: anywhere; }++.mm-candidate-meta { display: flex; align-items: center; gap: 6px; font-size: .84rem; color: var(--text-secondary, #96a7c2); }++.mm-candidate-vote { display: flex; align-items: center; gap: 8px; color: var(--text-muted, #7386a3); font-size: .84rem; }++.mm-vote-count { font-size: 1.4rem; font-weight: 900; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }++.mm-inline { margin: 0; }++++/* The caption generator: forms, templates, the classic meme text */++.mm-make-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 22px; align-items: start; }++.mm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.mm-form { display: grid; gap: 14px; }++.mm-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }++.mm-field input, .mm-field select, .mm-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }++.mm-field input:focus, .mm-field select:focus, .mm-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }++.mm-templates { margin: 0; padding: 0; border: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }++.mm-templates legend { margin-bottom: 8px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }++.mm-template { position: relative; display: grid; gap: 6px; padding: 8px; border-radius: 14px; border: 2px solid var(--border, #1f2d47); cursor: pointer; background: var(--bg-primary, #0a0f1c); }++.mm-template input { position: absolute; opacity: 0; pointer-events: none; }++.mm-template img { width: 100%; height: auto; aspect-ratio: 11 / 7; object-fit: cover; border-radius: 9px; display: block; }++.mm-template span { display: grid; gap: 1px; font-size: .84rem; }++.mm-template small { color: var(--text-muted, #7386a3); font-size: .74rem; }++.mm-template:has(input:checked), .mm-template.is-chosen { border-color: var(--room-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 18%, transparent); }++.mm-template:has(input:focus-visible) { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.mm-preview { position: sticky; top: 76px; }++.mm-preview h2 { margin-bottom: 12px; }++.mm-caption-card { position: relative; margin: 0; overflow: hidden; border-radius: 16px; background: #111; container-type: inline-size; box-shadow: 0 14px 34px rgba(0, 0, 0, .3); }++.mm-caption-card img { display: block; width: 100%; height: auto; aspect-ratio: 11 / 7; object-fit: cover; }++.mm-caption-card figcaption { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 4cqw 4cqw 5cqw; pointer-events: none; }++.mm-caption-card figcaption span { display: block; text-align: center; color: #fff; text-transform: uppercase; overflow-wrap: anywhere;++ font: 900 clamp(1rem, 7.2cqw, 3.2rem)/1.05 Impact, Haettenschweiler, 'Arial Narrow Bold', 'Arial Black', sans-serif; letter-spacing: .01em;++ text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }++.mm-caption-card figcaption span:empty { display: none; }++.mm-caption-large { max-width: 760px; }++++/* Scenes' demo pieces */++.mm-demo-cite { max-width: 460px; }++.mm-demo-rise { display: grid; gap: 10px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); justify-items: start; }++.mm-demo-bars { display: flex; align-items: flex-end; gap: 6px; height: 120px; width: 100%; }++.mm-demo-bars i { flex: 1; height: calc(var(--h) * 7%); border-radius: 4px 4px 0 0; background: linear-gradient(to top, color-mix(in srgb, var(--room-accent) 45%, transparent), var(--room-accent)); }++html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1) both; animation-delay: calc(var(--h) * 30ms); transform-origin: bottom; }++@keyframes mm-bar { from { transform: scaleY(0); } }++.mm-demo-rise small { color: var(--text-muted, #7386a3); }++.mm-demo-caption { max-width: 460px; }++++/* Developers */++.mm-eps { display: grid; gap: 10px; margin: 12px 0 0; }++.mm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.mm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }++.mm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++#mm-developers section { margin-top: 26px; }++++/* On a phone the room's picture is a faint corner mark behind the title: the branches only, no words to read through. */++@media (max-width: 759px) { .sc-room-pic .mm-node, .sc-room-pic .mm-sticker { display: none; } }++@media (max-width: 860px) { .mm-make-grid { grid-template-columns: minmax(0, 1fr); } .mm-preview { position: static; order: -1; } }++@media (prefers-reduced-motion: reduce) { .mm-tree-lines path, .mm-sticker, .mm-demo-bars i { animation: none !important; } }++html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .mm-sticker, html[data-ov-motion=reduced] .mm-demo-bars i { animation: none !important; }nnew file mode 100644@@@ -0,0 +1,7 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="Two empty caption bars on a plain background">++<rect width="440" height="280" fill="#20242c"/>++<rect x="28" y="28" width="384" height="176" rx="14" fill="#2b313c"/>++<circle cx="92" cy="86" r="26" fill="#c3ac00"/>++<path d="M72 146h244M72 172h176" stroke="#4b5566" stroke-width="10" stroke-linecap="round"/>++<rect x="28" y="220" width="384" height="32" rx="8" fill="#171a20"/>++</svg>nnew file mode 100644@@@ -0,0 +1,7 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="One empty caption bar under a reaction face outline">++<rect width="440" height="280" fill="#171a20"/>++<rect x="36" y="32" width="368" height="148" rx="20" fill="#2b313c"/>++<circle cx="220" cy="106" r="48" fill="none" stroke="#c3ac00" stroke-width="10"/>++<path d="M196 96h8M236 96h8M198 126c14 14 30 14 44 0" stroke="#c3ac00" stroke-width="8" stroke-linecap="round" fill="none"/>++<rect x="60" y="204" width="320" height="34" rx="8" fill="#0f1115"/>++</svg>nnew file mode 100644@@@ -0,0 +1,8 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="Three empty caption bars in a row">++<rect width="440" height="280" fill="#20242c"/>++<rect x="24" y="40" width="120" height="150" rx="12" fill="#2b313c"/>++<rect x="160" y="40" width="120" height="150" rx="12" fill="#2b313c"/>++<rect x="296" y="40" width="120" height="150" rx="12" fill="#2b313c"/>++<path d="M40 160h88M176 160h88M312 160h88" stroke="#4b5566" stroke-width="8" stroke-linecap="round"/>++<rect x="40" y="214" width="360" height="28" rx="7" fill="#171a20"/>++</svg>nnew file mode 100644@@@ -0,0 +1,37 @@++/* OpenVibe.Meme: what the pages are complete without. On /make the preview follows the template and the lines as you++ * type (the Preview button still works without JavaScript); a template card shows which one is chosen. */++(function () {++ 'use strict';++ function maker() {++ var form = document.getElementById('mm-caption-form');++ var aside = document.getElementById('mm-preview');++ if (!form || !aside || form.dataset.ready) return;++ form.dataset.ready = '1';++ var fig = aside.querySelector('.mm-caption-card');++ if (!fig) {++ aside.querySelectorAll('.mm-muted').forEach(function (p) { p.remove(); });++ fig = document.createElement('figure'); fig.className = 'mm-caption-card';++ fig.appendChild(document.createElement('img')); fig.appendChild(document.createElement('figcaption'));++ aside.appendChild(fig);++ }++ var img = fig.querySelector('img'), cap = fig.querySelector('figcaption');++ img.alt = '';++ function draw() {++ var chosen = form.querySelector('input[name=template_id]:checked');++ form.querySelectorAll('.mm-template').forEach(function (l) { l.classList.toggle('is-chosen', !!l.querySelector('input:checked')); });++ if (chosen) { var t = chosen.closest('.mm-template').querySelector('img'); if (t && img.src !== t.src) img.src = t.src; }++ cap.replaceChildren();++ for (var i = 1; i <= 4; i++) {++ var input = form.querySelector('[name=line' + i + ']');++ var span = document.createElement('span');++ span.textContent = input ? input.value : '';++ cap.appendChild(span);++ }++ }++ form.addEventListener('input', draw);++ form.addEventListener('change', draw);++ draw();++ }++ function run() { maker(); }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();++})();nnew file mode 100644@@@ -0,0 +1,139 @@++#!/usr/bin/env node++'use strict';++++/**++ * The first proof entry: "Cozmo is offline" (research §6.3), seeded from the live public APIs and left hidden for++ * a staff person to publish.++ *++ * node scripts/seed-proof.js++ *++ * It reads three public things: the Chat global history around the known line (message 86957, 2026-10-28++ * 00:11:53), VOD 6026's transcript segment at 864.08 s, and OpenVibe.Video's own redirect for++ * `/v/6026?t=864` → `/v/6025?t=1541` so the stored citation is the canonical one. Nothing is invented: the quote++ * is verbatim, the hash is its sha256, and the entry is created `hidden`.++ *++ * Re-running is safe: an existing `cozmo-offline` entry is left as it is (a second run adds only a citation that is++ * not already there). The deny list (VOD 5709, clip 483) is enforced before anything is written.++ */++const { load } = require('../server/config');++const { openStore } = require('../server/db');++const { createUpstreams, parseApiTime } = require('../server/upstreams');++const { createStore } = require('../server/store');++const { cleanSummary } = require('../server/text/no-looks');++const { isDenied } = require('../server/deny-list');++++const CHAT_MESSAGE_ID = 86957;++const SOURCE_VOD = 6026;++const SOURCE_SEC = 864.08;++const TITLE = 'Cozmo is offline';++const SLUG = 'cozmo-offline';++++function short(s, n = 100) { return String(s || '').replace(/\s+/g, ' ').trim().slice(0, n); }++++async function findChatLine(upstreams) {++ // The known before-page: one minute after the line, so the page ends just past it.++ for (const before of ['2026-10-28 00:12:00', null]) {++ const page = await upstreams.chatHistory({ before, limit: 200 });++ const byId = page.messages.find((m) => Number(m.id) === CHAT_MESSAGE_ID);++ if (byId && Number(byId.is_deleted) !== 1) return byId;++ const byText = page.messages.find((m) => Number(m.is_deleted) !== 1 && /\bcozmo\b/i.test(String(m.message)) && /\b(online|offline)\b/i.test(String(m.message)));++ if (byText) return byText;++ }++ return null;++}++++async function findSegment(upstreams) {++ const all = await upstreams.transcripts([SOURCE_VOD]);++ const t = all[String(SOURCE_VOD)];++ const segments = t && Array.isArray(t.segments) ? t.segments : [];++ let best = null;++ for (const seg of segments) {++ const d = Math.abs(Number(seg.start) - SOURCE_SEC);++ if (!best || d < best.d) best = { seg, d };++ }++ if (!best || best.d > 5 || !/\b(cosmo|cozmo)\b/i.test(String(best.seg.text))) return null;++ return best.seg;++}++++async function runSeed({ config, s, upstreams, log = console } = {}) {++ if (!config) config = load();++ if (!upstreams) upstreams = createUpstreams({ config, log });++ if (!s) s = await openStore(config, { log });++ const search = { sync: async () => null }; // a hidden entry writes no Search document++ const entries = createStore(s, { search });++++ const msg = await findChatLine(upstreams);++ if (!msg) throw new Error('the public Chat history did not contain message 86957 (or another Cozmo line); nothing was written');++ const seg = await findSegment(upstreams);++ if (!seg) throw new Error(`VOD ${SOURCE_VOD}'s public transcript did not contain the Cozmo segment near ${SOURCE_SEC}s; nothing was written`);++++ const canon = await upstreams.canonicalVideo(SOURCE_VOD, SOURCE_SEC);++ if (canon.denied || canon.gone || canon.hidden || !canon.url) {++ throw new Error(`OpenVibe.Video would not resolve VOD ${SOURCE_VOD}@${SOURCE_SEC} to a public URL (${canon.gone ? '404/gone' : canon.hidden ? 'hidden' : 'denied'}); nothing was written`);++ }++ if (isDenied({ vod_id: canon.vod_id })) throw new Error('the resolved VOD is on the fixed deny list; nothing was written');++++ const chatAt = parseApiTime(msg.timestamp);++ let vodAt = null;++ try { const row = await upstreams.vod(SOURCE_VOD); vodAt = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null; } catch { /* the chat time is enough */ }++ if (!vodAt) {++ try { const row = await upstreams.vod(canon.vod_id); vodAt = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null; } catch { /* still fine */ }++ }++ const observedVod = vodAt || chatAt;++++ const citations = [++ {++ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(msg.id),++ chat_message_id: Number(msg.id), quote_text: String(msg.message), url: `${upstreams.base(config.upstreams.chat)}/`,++ observed_at: chatAt,++ },++ {++ role: 'use', source_service: 'live', source_type: 'vod', source_id: String(canon.vod_id),++ vod_id: canon.vod_id, offset_seconds: canon.offset_seconds, quote_text: String(seg.text), url: canon.url,++ observed_at: observedVod,++ },++ ].filter((c) => !isDenied(c));++ if (citations.length !== 2) throw new Error('a citation landed on the fixed deny list; nothing was written');++++ const first = citations.reduce((m, c) => (Date.parse(c.observed_at) < Date.parse(m) ? c.observed_at : m), citations[0].observed_at);++ const last = citations.reduce((m, c) => (Date.parse(c.observed_at) > Date.parse(m) ? c.observed_at : m), citations[0].observed_at);++ const summary = cleanSummary(TITLE, 'Cozmo, the robot, is not online: a chat line and a stream moment about its Wi-Fi. The robot is offline, again.');++++ const existing = await entries.bySlug(SLUG);++ if (existing) {++ const enriched = await entries.enrich(existing.id, { first_seen_at: first, last_seen_at: last, cites: citations });++ const added = enriched && enriched.added ? enriched.added : 0;++ log.log(`[seed-proof] ${SLUG} already exists (${existing.id}, ${existing.visibility}); ${added} new citation(s) added — left as it is`);++ return { created: false, entry: existing, citations, added };++ }++++ const out = await entries.createCandidate({++ slug: SLUG, title: TITLE, kind: 'joke', summary, status: 'classic',++ first_seen_at: first, last_seen_at: last, visibility: 'hidden', citations,++ });++ log.log(`[seed-proof] created ${out.entry.id} /${SLUG} (${out.entry.visibility}, ${out.entry.status})`);++ log.log(`[seed-proof] origin: chat message ${msg.id} at ${msg.timestamp} — "${short(msg.message, 90)}"`);++ log.log(`[seed-proof] use: VOD ${canon.vod_id} @${canon.offset_seconds}s (source part ${SOURCE_VOD} @${SOURCE_SEC}s) — "${short(seg.text, 90)}"`);++ log.log('[seed-proof] publish it with: POST /api/v1/entries/cozmo-offline/publish (Network role staff+)');++ return { created: true, entry: out.entry, citations: out.citations };++}++++async function main() {++ const config = load();++ const s = await openStore(config, { log: console });++ try {++ const out = await runSeed({ config, s });++ process.exitCode = out && out.entry ? 0 : 1;++ } finally {++ await s.close();++ }++}++++if (require.main === module) {++ main().catch((err) => {++ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);++ process.exitCode = 1;++ });++}++++module.exports = { runSeed, findChatLine, findSegment, TITLE, SLUG, CHAT_MESSAGE_ID, SOURCE_VOD, SOURCE_SEC };nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++++/**++ * OpenVibe.Meme — Express app factory; server/index.js listens, tests build their own instance with a temp++ * database and a mock Network.++ *++ * /, /m/:slug, /week, /make, /c/:id, /developers the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /internal/events account export/deletion (loopback only)++ *++ * The importer and the citation re-verifier are made here but started by server/index.js (and never under++ * MEME_DRILL); tests drive their runOnce().++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { assetVersion, send } = require('./render/layout');++const { raw } = require('./render/html');++++const accountDataLib = require('./identity/account-data');++const { createStore } = require('./store');++const { createSearchIndex } = require('./search');++const { createUpstreams } = require('./upstreams');++const { createImporter } = require('./importer');++const { createReverifier } = require('./reverify');++const { createNetworkSender } = require('openvibe-sdk/account-data');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++const { createHttpTelemetry } = require('openvibe-sdk/service');++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');++++/**++ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a++ * stand-in for Network's internal routes; tests only), telemetrySink.++ */++async function createApp(opts = {}) {++ const config = opts.config || configLib.load();++ const log = opts.log || console;++ const fetchImpl = opts.fetchImpl || globalThis.fetch;++ const s = opts.store || await openStore(config, { now: opts.now, log });++++ const keys = createKeyStore({ config, fetchImpl, log });++ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });++ const principal = createPrincipal({ config, keys });++++ const search = createSearchIndex({ config, s });++ const entries = createStore(s, { search });++ const upstreams = createUpstreams({ config, fetchImpl, log });++ const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });++ const reverifier = createReverifier({ config, s, entries, upstreams, log });++++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own++ // client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });++++ const ctx = {++ config, s, keys, sso, principal, search, entries, upstreams, importer, reverifier, accountData, accountSend, log,++ };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'meme', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'meme', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'meme', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });++ app.use(ctx.telemetry.middleware());++ app.locals.ctx = ctx;++++ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);++ ctx.valkey = valkey;++++ app.use(contracts.http.middleware());++ app.use(helmet({++ contentSecurityPolicy: {++ directives: {++ defaultSrc: ["'self'"],++ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],++ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],++ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],++ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],++ frameSrc: ["'self'", 'https://openvibe.network'],++ frameAncestors: ["'none'"],++ objectSrc: ["'none'"],++ baseUri: ["'self'"],++ formAction: ["'self'", 'https://openvibe.network'],++ },++ },++ frameguard: { action: 'deny' },++ crossOriginEmbedderPolicy: false,++ crossOriginResourcePolicy: { policy: 'same-site' },++ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },++ }));++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-meme', name: 'OpenVibe.Meme', version: VERSION }));++ release.mount(app, { registry: metrics.registry });++ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });++ app.get('/api/ready', readiness.handler);++++ // ── Who is asking (verified offline; refreshed when expired) ──++ app.use(sso.middleware());++++ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──++ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));++ app.use('/auth', sso.routes());++ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'meme', service: 'meme', host: 'openvibe.meme', name: 'OpenVibe.Meme', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only) ──────++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.++ // nginx answers 404 for /internal/.++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ app.use('/api/v1', createApi(ctx));++ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));++++ // ── Pages ───────────────────────────────────────────────++ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));++ app.use(createPageRoutes(ctx));++ app.use((req, res) => send(res, 404, {++ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',++ body: raw(require('./views').notFound()),++ }));++++ // eslint-disable-next-line no-unused-vars++ app.use((err, req, res, _next) => {++ log.error('[OpenVibe.Meme]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's Bearer token (the API).++ *++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves++ * this module: token.no_key answers 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Meme] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }++ }++ async function ensure() { await refreshMirror(); }++ function get() { return mirror ? mirror.pem : null; }++ function loaded() { return Boolean(mirror); }++++ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));++++ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Meme] user token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };++ }++ }++++ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {++ try {++ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });++ await refreshMirror();++ return { ok: true, claims };++ } catch (err) {++ await refreshMirror();++ const code = err && err.code;++ log.warn(`[OpenVibe.Meme] app token rejected (${code || 'error'}):`, (err && err.message) || err);++ return { ok: false, reason: publicReason(err), code };++ }++ }++++ async function pemForToken(token) {++ let kid = null;++ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }++ try {++ const ks = await client.keysForKid(kid);++ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];++ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;++ } catch { return null; }++ }++++ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };++}++++module.exports = { createKeyStore, jwksUrl };nnew file mode 100644@@@ -0,0 +1,260 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `meme`.++ *++ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256++ * (?silent=1 adds prompt=none; ?next= a same-site path)++ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)++ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)++ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest++ *++ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives++ * in an httpOnly cookie (meme_at) no script can read; the refresh token (meme_rt) is httpOnly too. Neither is ever++ * stored server-side, rendered or logged.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'meme_at';++const REFRESH_COOKIE = 'meme_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'meme_oauth';++++function pkcePair() {++ const verifier = crypto.randomBytes(32).toString('base64url');++ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');++ return { verifier, challenge };++}++++function ownOrigins(config) {++ const out = new Set();++ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {++ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }++ }++ return out;++}++++/** Same-site relative paths only (never protocol-relative, never another origin). */++function sanitizeNext(next, config) {++ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';++ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {++ let u = null;++ try { u = new URL(next); } catch { return '/'; }++ if (!ownOrigins(config).has(u.origin)) return '/';++ next = `${u.pathname}${u.search}${u.hash}`;++ }++ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';++ return next;++}++++function authorizeUrl(config, { state, challenge, silent = false }) {++ const q = new URLSearchParams({++ response_type: 'code',++ client_id: config.oauth.clientId,++ redirect_uri: config.oauth.redirectUri,++ scope: config.oauth.scope,++ state,++ code_challenge: challenge,++ code_challenge_method: 'S256',++ });++ if (silent) q.set('prompt', 'none');++ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;++}++++function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });++++ function setSession(res, data) {++ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });++ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });++ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ }++ function clearSession(res) {++ res.clearCookie(ACCESS_COOKIE, cookieBase());++ res.clearCookie(REFRESH_COOKIE, cookieBase());++ }++++ async function tokenGrant(fields) {++ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });++ let res;++ try {++ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },++ body, signal: AbortSignal.timeout(10_000),++ });++ } catch (err) {++ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.access_token) {++ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),++ { status: res.status, error: (data && data.error) || 'invalid_grant' });++ }++ return data;++ }++++ /**++ * A Meme session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ res.set('Clear-Site-Data', '"cache"');++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Meme] sign-in exchange failed:', err.status || '', err.error || '');++ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')++ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');++ }++ });++++ const sameOriginRequest = (req) => {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');++ const origin = req.get('origin');++ if (origin) return origin === `${req.protocol}://${req.get('host')}`;++ return req.method === 'GET';++ };++ r.get('/logout', (req, res, next) => {++ if (sameOriginRequest(req)) return next();++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';++ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`++ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`++ + `<p>Sign out of OpenVibe.Meme?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);++ });++ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));++ r.all('/logout', async (req, res) => {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (rt) {++ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {++ method: 'POST', headers: { 'Content-Type': 'application/json' },++ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),++ signal: AbortSignal.timeout(3000),++ }).catch(() => {});++ }++ clearSession(res);++ res.set('Clear-Site-Data', '"cache"');++ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });++ res.redirect(303, sanitizeNext(req.query.next, config));++ });++++ r.get('/me', (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const c = req.cookies || {};++ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });++ const v = req.viewer;++ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });++ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });++ });++ return r;++ }++++ return { middleware, routes, asViewer, tokenGrant, pkcePair };++}++++module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };nnew file mode 100644@@@ -0,0 +1,88 @@++'use strict';++++/**++ * OpenVibe.Meme configuration. Every value comes from the environment (production: /etc/openvibe/meme.env,++ * see .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.++ *++ * load(env) is pure so tests can build a config without touching process.env.++ */++require('dotenv').config();++const trim = (s) => String(s || '').replace(/\/+$/, '');++const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++++function load(env = process.env) {++ const nodeEnv = env.NODE_ENV || 'development';++ const isProduction = nodeEnv === 'production';++ const port = int(env.PORT, 5120);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.meme' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'meme',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.MEME_DRILL === '1' || env.MEME_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.MEME_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.MEME_LIMITS_HOUR, 3000)),++ },++++ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In++ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (MEME_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEME_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:meme:' },++++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ audience: env.MEME_AUDIENCE || 'openvibe.meme',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'meme',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).++ events: {++ secrets: String(env.MEME_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.MEME_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.MEME_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.MEME_EVENTS_INTERVAL_MS, 5000)),++ scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',++ },++++ // The public APIs the importer reads. No service grant is used in v1: these are the anonymous reads a++ // person can make too (OpenVibe.Live's Rundown and transcripts, OpenVibe.Chat's history and timeline).++ upstreams: {++ chat: trim(env.OV_CHAT_URL || env.OV_CHAT_INTERNAL_URL || 'https://openvibe.chat'),++ live: trim(env.OV_LIVE_URL || env.OV_LIVE_INTERNAL_URL || 'https://openvibe.live'),++ video: trim(env.OV_VIDEO_URL || 'https://openvibe.video'),++ timeoutMs: Math.max(500, int(env.MEME_UPSTREAM_TIMEOUT_MS, 8000)),++ },++++ // The candidate importer: every 30 minutes, off under MEME_DRILL (tests call runOnce()).++ importer: {++ intervalMs: Math.max(60_000, int(env.MEME_IMPORTER_INTERVAL_MS, 30 * 60_000)),++ minUses: Math.max(2, int(env.MEME_IMPORTER_MIN_USES, 2)),++ minDays: Math.max(2, int(env.MEME_IMPORTER_MIN_DAYS, 2)),++ maxTopics: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_TOPICS, 12))),++ maxVods: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_VODS, 12))),++ maxCandidates: Math.min(30, Math.max(1, int(env.MEME_IMPORTER_MAX_CANDIDATES, 10))),++ },++++ // Every citation is re-verified daily, off under MEME_DRILL.++ reverify: { intervalMs: Math.max(60_000, int(env.MEME_REVERIFY_INTERVAL_MS, 24 * 60 * 60_000)) },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Meme's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret. A citation stores a public URL and a sha256 of the verbatim quote;++ * the quote itself is public speech already, and a person's vote, caption or report is theirs to export/erase.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Meme] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'meme', registry, log });++ await db.migrate({ dir: MIGRATIONS, log });++ return db;++ }++ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');++ const owner = createDb({ url: config.db.directUrl, service: 'meme-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'meme', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx((t) => fn(t)),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++++/**++ * The fixed deny list: VOD 5709 and clip 483 are never cited, kept, cached or rendered by OpenVibe.Meme. Both answer++ * 404 anonymously; clip 483 is Live's AI auto-clip of sexual content (server/ai/explicit.js), and VOD 5709 has no++ * public row at all. A citation that would carry either id is dropped before it is written, and the seed script and++ * the importer both call this filter. No title, thumbnail or quote from either ever enters the database.++ */++const DENIED_VOD_IDS = Object.freeze(new Set([5709]));++const DENIED_CLIP_IDS = Object.freeze(new Set([483]));++++function num(v) {++ if (v == null || v === '') return null;++ const n = Number(v);++ return Number.isFinite(n) ? n : null;++}++++function isDeniedVod(vodId) {++ const n = num(vodId);++ return n != null && DENIED_VOD_IDS.has(n);++}++++function isDeniedClip(clipId) {++ const n = num(clipId);++ return n != null && DENIED_CLIP_IDS.has(n);++}++++/** True when a use names a denied VOD or clip. Accepts both wire and database column names. */++function isDenied({ vod_id, vodId, clip_id, clipId } = {}) {++ return isDeniedVod(vod_id != null ? vod_id : vodId) || isDeniedClip(clip_id != null ? clip_id : clipId);++}++++/** The uses that may be cited: everything except the fixed deny list. */++function allowed(uses) {++ return (Array.isArray(uses) ? uses : []).filter((u) => !isDenied(u));++}++++module.exports = { DENIED_VOD_IDS, DENIED_CLIP_IDS, isDenied, isDeniedVod, isDeniedClip, allowed };nnew file mode 100644@@@ -0,0 +1,232 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Meme's JSON API.++ *++ * GET /entries?limit=&cursor=&kind=&status= public entries, newest first, cursor-paged++ * GET /entries/:slug one entry with citations and the use timeline++ * GET /entries/:slug/series?days=30 uses per UTC day, zero-filled++ * GET /trending rising first++ * GET /week this ISO week's candidates and vote counts++ * POST /week/vote one vote a week (signed in, same-origin)++ * GET /templates caption templates++ * POST /captions save a caption (signed in; text only)++ * GET /captions/:id one saved caption++ * POST /entries/:slug/report a takedown/correction report (signed in)++ * GET /reports open reports (staff)++ * POST /entries/:slug/publish|hide staff++ * PATCH /entries/:slug staff: title, kind, status++ *++ * Errors are application/problem+json with a `code`. A person is the meme_at session cookie or a Network user++ * token as a Bearer; a session write must come from openvibe.meme itself (403 meme.cross_site).++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { isStaff } = require('../identity/staff');++const { currentWeek } = require('../week');++const { KINDS, STATUSES } = require('../store');++++function createApi(ctx) {++ const { config, s, principal, entries } = ctx;++ const r = asyncRouter();++++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and++ // hour budget from config, and an app or anonymous caller is left to the address limiter. A second process++ // has its own counters; VALKEY_URL would make them shared (the readiness check says so).++ const buckets = new Map();++ function windowCount(key, windowMs, limit) {++ const now = Date.now();++ let b = buckets.get(key);++ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }++ b.n += 1;++ return b.n <= limit;++ }++ r.use((req, res, next) => {++ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {++ const who = `user:${req.principal.subject}`;++ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);++ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);++ if (!okMinute || !okHour) return problem(res, 429, 'meme.rate_limited', 'too many requests from this account; retry shortly');++ if (buckets.size > 20_000) {++ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);++ }++ }++ return next();++ });++++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });++ const invalid = (res, detail) => problem(res, 400, 'meme.invalid', detail);++++ function person(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') { problem(res, 401, 'meme.sign_in_required', 'Sign in at openvibe.meme, or send a person\'s Network token as a Bearer.'); return null; }++ if (p.kind !== 'user') { problem(res, 403, 'meme.person_required', 'This route is for a person, not an app.'); return null; }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ problem(res, 403, 'meme.cross_site', 'A signed-in request that changes something must come from openvibe.meme itself.');++ return null;++ }++ return p;++ }++ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);++ const requireStaff = (req, res, next) => {++ if (!person(req, res)) return undefined;++ if (!isStaff(req.principal)) return problem(res, 403, 'meme.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');++ return next();++ };++++ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));++++ // ── Public reads ────────────────────────────────────────++ r.get('/entries', async (req, res) => {++ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');++ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;++ if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);++ const status = typeof req.query.status === 'string' && req.query.status ? req.query.status : null;++ if (status && !STATUSES.includes(status)) return invalid(res, `status must be one of ${STATUSES.join(', ')}`);++ const out = await entries.list({++ limit, kind, status,++ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,++ });++ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out');++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ count: out.entries.length, entries: out.entries, next_cursor: out.next_cursor });++ });++++ r.get('/entries/:slug/series', async (req, res) => {++ const row = await entries.bySlug(String(req.params.slug || ''));++ if (!row || (row.visibility === 'hidden' && !isStaff(req.principal))) return problem(res, 404, 'meme.not_found', 'No such meme.');++ const days = req.query.days == null || req.query.days === '' ? 30 : Number.parseInt(req.query.days, 10);++ if (!Number.isFinite(days) || days < 1 || days > 365) return invalid(res, 'days must be a whole number from 1 to 365');++ const out = await entries.series(row.id, { days });++ res.set('Cache-Control', row.visibility === 'public' ? 'public, max-age=60' : 'private, max-age=60');++ return res.json({ slug: row.slug, ...out });++ });++++ r.get('/entries/:slug', async (req, res) => {++ const row = await entries.bySlug(String(req.params.slug || ''));++ const staff = isStaff(req.principal);++ if (!row || (row.visibility === 'hidden' && !staff)) return problem(res, 404, 'meme.not_found', 'No such meme.');++ const citations = await entries.citations(row.id);++ if (row.visibility === 'public') res.set('Cache-Control', 'public, max-age=60');++ else if (row.visibility === 'unlisted') res.set('Cache-Control', 'private, max-age=60');++ return res.json({++ ...entries.wireEntry(row),++ origin: citations.find((c) => c.role === 'origin') || citations[0] || null,++ citations,++ timeline: citations.filter((c) => c.role !== 'example'),++ examples: citations.filter((c) => c.role === 'example'),++ });++ });++++ r.get('/trending', async (req, res) => {++ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');++ const rows = await entries.trending({ limit });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ count: rows.length, entries: rows });++ });++++ r.get('/week', async (req, res) => {++ const { week } = currentWeek(s.now());++ const subject = req.principal.kind === 'user' ? req.principal.subject : null;++ const out = await entries.week({ week, subject });++ res.set('Cache-Control', subject ? 'private, no-store' : 'public, max-age=60');++ return res.json(out);++ });++++ r.get('/templates', async (_req, res) => {++ const list = await entries.templates();++ const counted = await Promise.all(list.map(async (t) => ({ ...t, captions: await entries.captionCountForTemplate(t.id) })));++ res.set('Cache-Control', 'public, max-age=300');++ return res.json({ count: counted.length, templates: counted });++ });++++ r.get('/captions/:id', async (req, res) => {++ const row = await entries.captionById(String(req.params.id || ''));++ if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');++ const mine = req.principal.kind === 'user' && req.principal.subject === row.subject;++ if (row.visibility === 'hidden' && !mine && !isStaff(req.principal)) return problem(res, 404, 'meme.not_found', 'No such caption.');++ const template = await entries.templateById(row.template_id);++ const staff = isStaff(req.principal);++ // A response that names the owner (or is the owner's own) must never sit in a shared cache.++ res.set('Cache-Control', row.visibility === 'public' && !mine && !staff ? 'public, max-age=60' : 'private, no-store');++ const out = { ...row, template, mine };++ if (!mine && !staff) delete out.subject;++ return res.json(out);++ });++++ // ── Signed-in writes ────────────────────────────────────++ r.post('/week/vote', requirePerson, async (req, res) => {++ const entryId = req.body && typeof req.body.entry_id === 'string' ? req.body.entry_id.trim() : '';++ if (!entryId || entryId.length > 40) return invalid(res, 'entry_id is required');++ const { week } = currentWeek(s.now());++ const out = await entries.castVote({ subject: req.principal.subject, entryId, week });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid vote');++ return res.json({ week, ...out });++ });++++ r.post('/captions', requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const out = await entries.createCaption({++ subject: req.principal.subject,++ templateId: b.template_id,++ lines: b.lines,++ visibility: b.visibility == null ? 'public' : String(b.visibility),++ });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid caption');++ res.set('Location', `/api/v1/captions/${out.caption.id}`);++ return res.status(201).json(out.caption);++ });++++ r.post('/entries/:slug/report', requirePerson, async (req, res) => {++ const row = await entries.bySlug(String(req.params.slug || ''));++ if (!row || row.visibility !== 'public') return problem(res, 404, 'meme.not_found', 'No such public meme.');++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const kind = b.kind === 'takedown' || b.kind === 'correction' ? b.kind : null;++ if (!kind) return invalid(res, 'kind must be takedown or correction');++ const reason = String(b.reason == null ? '' : b.reason).replace(/\s+/g, ' ').trim();++ if (reason.length < 3 || reason.length > 2000) return invalid(res, 'reason must be 3 to 2000 characters');++ const note = b.note == null || b.note === '' ? null : String(b.note).trim().slice(0, 4000);++ const out = await entries.createReport({ entryId: row.id, subject: req.principal.subject, kind, reason, note });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid report');++ res.set('Location', `/api/v1/reports`);++ return res.status(201).json({ id: out.report.id, status: out.report.status, created_at: out.report.created_at });++ });++++ // ── Staff ───────────────────────────────────────────────++ r.post('/entries/:slug/publish', requireStaff, async (req, res) => {++ const out = await entries.publish(String(req.params.slug || ''), { changedBy: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'cannot publish');++ return res.json(out.entry);++ });++++ r.post('/entries/:slug/hide', requireStaff, async (req, res) => {++ const out = await entries.hide(String(req.params.slug || ''), { changedBy: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'cannot hide');++ return res.json(out.entry);++ });++++ r.patch('/entries/:slug', requireStaff, async (req, res) => {++ const out = await entries.patch(String(req.params.slug || ''), { body: req.body || {}, changedBy: req.principal.subject });++ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid change');++ return res.json(out.entry);++ });++++ r.get('/reports', requireStaff, async (req, res) => {++ const reports = await entries.listOpenReports({ limit: asInt(req.query.limit, 100) });++ return res.json({ count: reports.length, reports });++ });++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,114 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.meme: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public entries are for++ * search engines and AI crawlers; sign-in, the API, the internal route and a hidden entry are not. A sitemap also++ * lists every public entry page (/m/<slug>).++ */++const fs = require('fs');++const path = require('path');++const seo = require('openvibe-shared/seo');++const cache = require('openvibe-shared/cache-policy');++const { asyncRouter } = require('./router');++++const SITE_NAME = 'OpenVibe.Meme';++const DESCRIPTION = 'OpenVibe.Meme is the open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and is voted meme of the week.';++const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics'];++++function dayOf(ts) {++ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++}++function siteUpdated() {++ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }++}++++function homeJsonLd(config) {++ const site = String(config.baseUrl).replace(/\/+$/, '');++ return [++ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),++ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'EntertainmentApplication', keywords: 'openvibe meme, meme database, running jokes, meme of the week, caption generator' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function publicPages() {++ return [++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Meme home', text: 'The open meme database: trending entries, meme of the week and the caption generator.' },++ { path: '/week', changefreq: 'daily', priority: 0.9, title: 'Meme of the week', text: 'This week’s candidates and the people’s vote.' },++ { path: '/make', changefreq: 'weekly', priority: 0.7, title: 'The caption generator', text: 'Pick a template, type up to four lines and preview them over the image.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Meme API', text: 'The JSON API, the Search document and the importer’s public sources.' },++ ];++}++++function createDiscoveryRoutes(ctx) {++ const { config, s } = ctx;++ const r = asyncRouter();++ const site = String(config.baseUrl).replace(/\/+$/, '');++ const abs = (p) => `${site}${p}`;++ const TEXT = cache.htmlHeaders({ maxAge: 3600 });++ const pages = publicPages();++++ r.get('/robots.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(++ '# openvibe.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'++ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));++ });++++ r.get('/llms.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({++ name: SITE_NAME,++ summary: 'OpenVibe.Meme is the open meme database, rebuilt with AI. Every public entry cites the moment a running joke happened (a chat line, a VOD second, a Rundown segment), follows its later uses on a timeline, has a status (rising, peaked, classic, dead), and people vote for meme of the week. A caption generator puts plain text lines over a template image.',++ details: 'GET /api/v1/entries lists public entries; GET /api/v1/entries/:slug adds every citation and the use timeline; GET /api/v1/entries/:slug/series?days=30 is uses per UTC day. GET /api/v1/week lists this ISO week’s candidates and vote counts. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Meme', url: abs('/'), note: 'trending and meme of the week' },++ { title: 'The ballot', url: abs('/week') },++ { title: 'The caption generator', url: abs('/make') },++ { title: 'The API', url: abs('/developers') },++ ] },++ { title: 'Machine-readable', links: [++ { title: 'Public entries (JSON)', url: abs('/api/v1/entries') },++ { title: 'Sitemap', url: abs('/sitemap.xml') },++ { title: 'Full text for language models', url: abs('/llms-full.txt') },++ { title: 'Release metadata (JSON)', url: abs('/release.json') },++ ] },++ { title: 'Elsewhere', links: [++ { title: 'OpenVibe.Chat', url: 'https://openvibe.chat', note: 'where a running joke is often typed first' },++ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'the Rundown and the VOD transcripts' },++ { title: 'OpenVibe.Video', url: 'https://openvibe.video', note: 'the canonical ?t= moment' },++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and staff roles' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', async (_req, res) => {++ const rows = await s.db.many("SELECT slug, title, summary, kind, status FROM meme_entries WHERE visibility = 'public' ORDER BY last_seen_at DESC LIMIT 2000");++ const entries = rows.map((e) => ({ url: `/m/${e.slug}`, title: e.title, text: [e.kind, e.status, e.summary].filter(Boolean).join(' · ') }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Meme: the home, the weekly ballot, the generator, the API and every public entry.',++ base: site,++ maxBytes: 512 * 1024,++ sections: [++ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },++ { title: 'Entries', pages: entries },++ ],++ }));++ });++++ r.get('/sitemap.xml', async (_req, res) => {++ const lastmod = siteUpdated();++ const rows = await s.db.many("SELECT slug, updated_at FROM meme_entries WHERE visibility = 'public' ORDER BY slug LIMIT 5000");++ const urls = [++ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),++ ...rows.map((e) => ({ loc: abs(`/m/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),++ ];++ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));++ });++++ return r;++}++++module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };nnew file mode 100644@@@ -0,0 +1,187 @@++'use strict';++++/**++ * Meme's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real link.++ * Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is++ * `private, no-store`.++ *++ * / trending, meme of the week, the generator teaser++ * /m/:slug an entry: origin with its citation link, the use timeline, examples, the report form++ * /week this week's candidates and the vote form (POST /week/vote)++ * /make the caption generator form (POST /make) with a server-rendered preview (GET /make)++ * /c/:id one saved caption, text over the template image++ * /developers the API++ * 404 the not-found page++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { sameOrigin } = require('./principal');++const { html, raw, notice } = require('../render/html');++const { send, SITE_NAME } = require('../render/layout');++const { isStaff } = require('../identity/staff');++const { currentWeek } = require('../week');++const views = require('../views');++++const PUBLIC_CACHE = 'public, max-age=60';++++function createPageRoutes(ctx) {++ const { config, s, entries } = ctx;++ const r = asyncRouter();++ r.use(express.urlencoded({ extended: false, limit: '64kb' }));++ const base = () => String(config.baseUrl).replace(/\/+$/, '');++ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });++ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ const staffViewer = (req) => isStaff(req.viewer);++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;++ const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty"><h1>That came from somewhere else</h1><p class="mm-muted">Use the form on <a href="/">OpenVibe.Meme</a>.</p></div></div>`) }, 403);++++ async function counts() {++ const rows = await s.db.many("SELECT COUNT(*)::int AS entries, (SELECT COUNT(*)::int FROM meme_citations c JOIN meme_entries e ON e.id = c.entry_id WHERE e.visibility = 'public') AS citations FROM meme_entries WHERE visibility = 'public'");++ return rows[0] || { entries: 0, citations: 0 };++ }++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', async (req, res) => {++ const { week } = currentWeek(s.now());++ const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: null,++ description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ trending, week: weekOut, counts: c })),++ });++ });++++ // ── One entry ────────────────────────────────────────────++ async function entryPage(req, res, { problem = null } = {}) {++ const slug = String(req.params.slug || '');++ const row = await entries.bySlug(slug);++ const staff = staffViewer(req);++ if (!row || (row.visibility === 'hidden' && !staff)) return notFound(req, res);++ const citations = await entries.citations(row.id);++ const e = entries.wireEntry(row);++ const jsonLd = seo.jsonLd.webPage({++ name: e.title,++ url: `${base()}/m/${e.slug}`,++ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),++ siteUrl: base(),++ });++ return page(req, res, {++ index: e.visibility === 'public',++ cache: e.visibility === 'public' ? PUBLIC_CACHE : null,++ title: e.title,++ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),++ jsonLd: [jsonLd],++ body: raw(views.entry({ e, citations, signedIn: Boolean(signedIn(req)), staff, reported: req.query.reported === '1', problem })),++ });++ }++ r.get('/m/:slug', (req, res) => entryPage(req, res));++++ r.post('/m/:slug/report', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/m/${req.params.slug}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const row = await entries.bySlug(String(req.params.slug || ''));++ if (!row || row.visibility !== 'public') return notFound(req, res);++ const b = req.body || {};++ const kind = b.kind === 'takedown' || b.kind === 'correction' ? b.kind : null;++ if (!kind) return entryPage(req, res, { problem: 'Choose a takedown or a correction.' });++ const reason = String(b.reason == null ? '' : b.reason).replace(/\s+/g, ' ').trim();++ if (reason.length < 3 || reason.length > 2000) return entryPage(req, res, { problem: 'The reason must be 3 to 2000 characters.' });++ const note = b.note == null || b.note === '' ? null : String(b.note).trim().slice(0, 4000);++ const out = await entries.createReport({ entryId: row.id, subject: req.viewer.subject, kind, reason, note });++ if (!out.ok) return entryPage(req, res, { problem: out.message || 'That report could not be saved.' });++ return res.redirect(303, `/m/${encodeURIComponent(row.slug)}?reported=1`);++ });++++ // ── Meme of the week ────────────────────────────────────++ r.get('/week', async (req, res) => {++ const { week } = currentWeek(s.now());++ const subject = signedIn(req) ? req.viewer.subject : null;++ const out = await entries.week({ week, subject });++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Meme of the week',++ description: 'This ISO week’s candidates for meme of the week and the people’s vote.',++ body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),++ });++ });++++ r.post('/week/vote', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fweek');++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const entryId = req.body && typeof req.body.entry_id === 'string' ? req.body.entry_id.trim() : '';++ if (!entryId) return res.redirect(303, '/week?error=1');++ const { week } = currentWeek(s.now());++ const out = await entries.castVote({ subject: req.viewer.subject, entryId, week });++ return res.redirect(303, out.ok ? '/week?voted=1' : '/week?error=1');++ });++++ // ── The caption generator ───────────────────────────────++ const LINES = [1, 2, 3, 4];++ function makeValues(src = {}) {++ const v = { template_id: src.template_id, visibility: src.visibility || 'public' };++ for (const i of LINES) v[`line${i}`] = String(src[`line${i}`] == null ? '' : src[`line${i}`]).slice(0, 120);++ return v;++ }++ async function renderMake(req, res, { values = {}, problem = null, status = 200 } = {}) {++ const templates = await entries.templates();++ const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;++ const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);++ const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'Make a caption',++ description: 'Pick a template, type up to four lines and preview them over the image before saving.',++ body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),++ }, status);++ }++ r.get('/make', async (req, res) => renderMake(req, res, { values: makeValues(req.query) }));++ r.post('/make', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Make a caption', body: raw(views.signIn({ title: 'Make a caption', text: 'A saved caption belongs to your OpenVibe account, so you can find it later.', next: '/make' })) }, 401);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const values = makeValues(req.body || {});++ const lines = LINES.map((i) => values[`line${i}`]).filter((l) => String(l).trim());++ const out = await entries.createCaption({ subject: req.viewer.subject, templateId: values.template_id, lines, visibility: values.visibility });++ if (!out.ok) return renderMake(req, res, { values, problem: out.message || 'That caption could not be saved.', status: out.status || 400 });++ return res.redirect(303, `/c/${encodeURIComponent(out.caption.id)}`);++ });++++ // ── One saved caption ───────────────────────────────────++ r.get('/c/:id', async (req, res) => {++ const c = await entries.captionById(String(req.params.id || ''));++ if (!c) return notFound(req, res);++ const mine = Boolean(signedIn(req) && req.viewer.subject === c.subject);++ if (c.visibility === 'hidden' && !mine && !staffViewer(req)) return notFound(req, res);++ const template = await entries.templateById(c.template_id);++ return page(req, res, {++ index: c.visibility === 'public',++ cache: c.visibility === 'public' ? PUBLIC_CACHE : null,++ title: template ? `A caption on ${template.name}` : 'A saved caption',++ description: c.lines.join(' / ').slice(0, 160),++ body: raw(views.caption({ caption: c, template, mine })),++ });++ });++++ // ── Developers ──────────────────────────────────────────++ r.get('/developers', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'The OpenVibe.Meme API',++ description: 'The JSON API, the Search document and the public sources the importer reads.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],++ body: raw(views.developers()),++ }));++++ // ── Discovery ───────────────────────────────────────────++ r.use(createDiscoveryRoutes(ctx));++++ function notFound(req, res) {++ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);++ }++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * Who is calling /api/v1 — req.principal:++ *++ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }++ * a person: their Network user token as a Bearer, or this site's session cookie (meme_at).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience openvibe.meme. No Meme route needs one; the shape is++ * kept so a future capability route can be added in one place.++ * { kind: 'anonymous' }++ *++ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the++ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site++ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, caption or report.++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++// Meme's routes are a person acting for themself, a public read, and staff acting on an entry. No capability.++const CAPABILITIES = [];++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function roleClaim(claims) {++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';++}++function usernameClaim(claims) {++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++}++++function requesterOfSub(sub) {++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;++ return sub;++}++++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++ return false;++}++++function createPrincipal({ config, keys }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };++ }++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };++ }++ const viewer = req.viewer;++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };++ }++ return { principal: { kind: 'anonymous' } };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ function require_(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Meme's capabilities`);++ return function guard(req, res, next) {++ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.meme, or send a person\'s Network token as a Bearer.', ctx: req.ov });++ if (req.principal.kind === 'user') {++ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'meme.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.meme itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'meme.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });++ };++ }++++ return { middleware, resolve, requireCapability: require_ };++}++++module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };nnew file mode 100644@@@ -0,0 +1,23 @@++'use strict';++++/**++ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)++ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.++ */++const express = require('express');++++function asyncRouter() {++ const r = express.Router();++ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {++ const orig = r[method].bind(r);++ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {++ try {++ const out = h(req, res, next);++ if (out && typeof out.catch === 'function') out.catch(next);++ } catch (err) { next(err); }++ })));++ }++ return r;++}++++module.exports = { asyncRouter };nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++++/**++ * Account export and deletion → Meme (ADR-033; openvibe-sdk/account-data). The rows a person owns are their weekly++ * votes, their saved captions and the reports they filed. An entry is the network's, not a person's, so it is not++ * exported or erased with an account; a caption or report is deleted outright.++ *++ * The database stores bare usr_ ids in the subject columns. A vote is one row per week; a caption is text lines on a++ * template; a report is a takedown/correction request and is never a secret.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'meme_votes', subject: 'subject', value: (usr) => usr, file: 'votes.json', erase: 'delete' },++ { table: 'meme_captions', subject: 'subject', value: (usr) => usr, file: 'captions.json', erase: 'delete' },++ { table: 'meme_reports', subject: 'subject', value: (usr) => usr, file: 'reports.json', erase: 'delete' },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'meme',++ tables: TABLES,++ note: 'Entries are the network\'s meme record; only your votes, captions and reports are here.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,22 @@++'use strict';++++/**++ * Who is staff at OpenVibe.Meme: the Network role claim on the person's session (server/auth/sso.js puts `role`++ * on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's++ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.++ *++ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.++ */++const { staff } = require('openvibe-contracts');++++const STAFF_MIN = 'global_mod';++const STAFF_ROLES = ['global_mod', 'admin', 'owner'];++++/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */++function isStaff(who) {++ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;++ if (typeof who.role !== 'string' || !who.role) return false;++ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }++}++++module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };nnew file mode 100644@@@ -0,0 +1,287 @@++'use strict';++++/**++ * The candidate importer (v1). It reads only public APIs — OpenVibe.Live's Rundown and VOD transcripts, and++ * OpenVibe.Chat's global timeline and history — and proposes a candidate entry when the same 3–6 word phrase++ * appears in at least two uses on at least two different days. A candidate is inserted hidden, with one citation++ * per use; it never publishes. Staff publish it through POST /api/v1/entries/:slug/publish.++ *++ * The job runs every 30 minutes (MEME_IMPORTER_INTERVAL_MS) and is off under MEME_DRILL; tests call runOnce().++ * A failing upstream is logged and skipped: Chat down still lets the Rundown produce candidates, and vice versa.++ * A phrase already in the table is enriched (new citations, an extended first/last range) rather than copied.++ */++const { parseApiTime } = require('./upstreams');++const { isDenied } = require('./deny-list');++const { cleanSummary } = require('./text/no-looks');++const { slugify } = require('./store');++++const MIN_WORDS = 3;++const MAX_WORDS = 6;++const MAX_USES_PER_PHRASE = 25;++const STOP = new Set(('a about above actually after again against all almost alone along already also although always am among an and another any anyone anything are around as at back be became because become been before being below between both but by came can cannot cant could did didnt do does doesnt doing don dont done down during each either else enough even ever every few first for found from get gets getting give go going gone gonna got gotta great group guy guys had has have having he her here hers him himself his hi how however i id if im in into is isnt it its itself ive just kind kinda know last least less let like likely little lot made make many may maybe me mean might mine more most much must my myself near need neither never new next no nobody none nor not nothing now of off often on once one only or other others otherwise our ours out over own part people perhaps place point put quite rather really right said same saw say saying see seen several she should since small so some somebody something still such sure take tell than that thats the their them then there these they thing think this those though thought three through time to today together too took toward try turn two under until up upon us use used using very via want was way we well went were what whatever when where whether which while who why will with within without would year years yes yet you your yours yourself').split(' '));++++const DAY = 86_400_000;++const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));++const displayTitle = (phrase) => phrase.charAt(0).toUpperCase() + phrase.slice(1);++++function normalize(text) {++ return String(text || '')++ .toLowerCase()++ .replace(/https?:\/\/\S+/g, ' ')++ .replace(/[^a-z0-9']+/g, ' ')++ .replace(/'+/g, '')++ .replace(/\s+/g, ' ')++ .trim();++}++function words(text) { return normalize(text) ? normalize(text).split(' ') : []; }++function contentCount(tokens) { return tokens.filter((w) => !STOP.has(w) && w.length > 1).length; }++function utcDay(iso) { return String(iso || '').slice(0, 10); }++function useKey(u) { return `${u.source_type}:${u.source_id}:${u.offset_seconds == null ? '' : u.offset_seconds}`; }++++/**++ * Every 3–6 word window of every use, filtered to phrases that start and end with a content word and contain at++ * least two content words. Returns [{ phrase, uses }] with ≥ minUses uses on ≥ minDays days.++ */++function recurringPhrases(uses, { minUses = 2, minDays = 2 } = {}) {++ const map = new Map();++ for (const use of uses) {++ const toks = words(use.quote_text);++ if (toks.length < MIN_WORDS) continue;++ for (let n = MIN_WORDS; n <= MAX_WORDS; n++) {++ for (let i = 0; i + n <= toks.length; i++) {++ const win = toks.slice(i, i + n);++ if (STOP.has(win[0]) || STOP.has(win[n - 1])) continue;++ if (contentCount(win) < 2) continue;++ const phrase = win.join(' ');++ if (phrase.length < 12) continue;++ const list = map.get(phrase);++ if (list) { if (!list.some((u) => useKey(u) === useKey(use))) list.push(use); }++ else map.set(phrase, [use]);++ }++ }++ }++ const out = [];++ for (const [phrase, list] of map) {++ if (list.length < minUses) continue;++ const days = new Set(list.map((u) => utcDay(u.observed_at)));++ if (days.size < minDays) continue;++ if (new Set(list.map(useKey)).size < minUses) continue;++ out.push({ phrase, uses: list });++ }++ return out;++}++++function detectCandidates(uses, { minUses = 2, minDays = 2, hints = '', max = 10 } = {}) {++ const found = recurringPhrases(uses, { minUses, minDays });++ for (const c of found) {++ const days = new Set(c.uses.map((u) => utcDay(u.observed_at))).size;++ c.score = c.uses.length * 10 + days * 5 + (hints.includes(c.phrase) ? 4 : 0) + Math.min(c.phrase.length, 40) / 10;++ c.days = days;++ }++ found.sort((a, b) => b.score - a.score || b.phrase.length - a.phrase.length || (a.phrase < b.phrase ? -1 : 1));++ const kept = [];++ for (const c of found) {++ if (kept.some((k) => k.phrase.includes(c.phrase) || c.phrase.includes(k.phrase))) continue;++ kept.push(c);++ if (kept.length >= max) break;++ }++ return kept;++}++++function statusFor(firstSeen, lastSeen, now) {++ const first = Date.parse(firstSeen);++ const last = Date.parse(lastSeen);++ if (!Number.isFinite(first) || !Number.isFinite(last)) return 'rising';++ if (last < now - 90 * DAY) return 'dead';++ if (first < now - 60 * DAY && last > now - 30 * DAY) return 'classic';++ if (first > now - 14 * DAY) return 'rising';++ return 'peaked';++}++++function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.fetch, log = console }) {++ let timers = [];++ const stats = { runs: 0, uses: 0, candidates: 0, created: 0, enriched: 0, errors: 0, last_run_at: null, last_error: null };++++ /** One openvibe.video call per (vod, sec) is enough; a 404 removes the use. */++ function resolver() {++ const cache = new Map();++ return async (vodId, sec) => {++ const key = `${vodId}:${Math.floor(Number(sec) || 0)}`;++ if (!cache.has(key)) cache.set(key, upstreams.canonicalVideo(vodId, sec).catch((err) => ({ error: err })));++ return await cache.get(key);++ };++ }++++ async function collect() {++ const errors = [];++ const uses = [];++ const hints = [];++ const canon = resolver();++++ // Chat: the history is verbatim; the timeline is a model paraphrase, so it is a discovery hint only and++ // never becomes a citation (there is no message id in it — research §2.1).++ try {++ const history = await upstreams.chatHistory({ limit: 200 });++ for (const m of history.messages) {++ if (Number(m.is_deleted) === 1) continue;++ if (m.message_type && m.message_type !== 'chat') continue;++ const observed = parseApiTime(m.timestamp);++ if (!observed || !normalize(m.message)) continue;++ uses.push({++ source_service: 'chat', source_type: 'chat_message', source_id: String(m.id),++ chat_message_id: Number(m.id), quote_text: String(m.message), observed_at: observed,++ url: `${upstreams.base(config.upstreams.chat)}/`,++ });++ }++ } catch (err) { errors.push(`chat history: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }++++ try {++ const timeline = await upstreams.chatTimeline({ limit: 60 });++ for (const e of timeline.events) hints.push(normalize(`${e.label || ''} ${e.detail || ''}`));++ } catch (err) { errors.push(`chat timeline: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }++++ // Rundown: every quote is verbatim speech with a VOD second already converted to VOD time.++ const vods = new Set();++ try {++ const topics = await upstreams.rundownTopics({ limit: config.importer.maxTopics });++ for (const topic of topics) {++ let detail = null;++ try { detail = await upstreams.rundownTopic(topic.slug); } catch (err) { errors.push(`rundown ${topic.slug}: ${err.message}`); continue; }++ if (!detail) continue;++ for (const take of detail.takes) {++ const said = parseApiTime(take.said_at) || parseApiTime(topic.last_seen_at);++ const vodId = nOrNull(take.vod_id);++ const streamId = nOrNull(take.stream_id);++ if (vodId && !isDenied({ vod_id: vodId })) vods.add(vodId);++ for (const quote of Array.isArray(take.quotes) ? take.quotes : []) {++ const sec = nOrNull(quote.sec) != null ? nOrNull(quote.sec) : nOrNull(take.sec);++ if (!said || !normalize(quote.text) || vodId == null || sec == null) continue;++ if (isDenied({ vod_id: vodId })) continue;++ uses.push({++ source_service: 'live', source_type: 'rundown_segment', source_id: String(take.id),++ stream_id: streamId, vod_id: vodId, offset_seconds: sec, quote_text: String(quote.text),++ observed_at: said, url: `${upstreams.base(config.upstreams.video)}/v/${vodId}?t=${Math.floor(sec)}`,++ });++ }++ }++ }++ } catch (err) { errors.push(`rundown: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }++++ // VOD transcripts: a segment has no date of its own, so it is used only when the VOD row gives one.++ try {++ const ids = [...vods].slice(0, config.importer.maxVods);++ const rows = {};++ for (const id of ids) {++ try { const row = await upstreams.vod(id); if (row) rows[id] = row; } catch (err) { errors.push(`vod ${id}: ${err.message}`); }++ }++ const tr = await upstreams.transcripts(ids);++ for (const [id, data] of Object.entries(tr)) {++ const row = rows[id];++ const observed = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null;++ if (!observed) continue;++ for (const seg of Array.isArray(data.segments) ? data.segments : []) {++ if (!normalize(seg.text) || seg.text.length < 8) continue;++ const sec = nOrNull(seg.start);++ if (sec == null) continue;++ uses.push({++ source_service: 'live', source_type: 'vod', source_id: String(id), vod_id: Number(id), offset_seconds: sec,++ quote_text: String(seg.text), observed_at: observed,++ url: `${upstreams.base(config.upstreams.video)}/v/${id}?t=${Math.floor(sec)}`,++ });++ }++ }++ } catch (err) { errors.push(`transcripts: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }++++ return { uses, hints: hints.join(' '), errors };++ }++++ /** Canonicalise a candidate's citations (the joined-VOD redirect) and drop what is gone/denied. */++ async function materialize(candidate, canon) {++ const picked = [...candidate.uses]++ .sort((a, b) => Date.parse(a.observed_at) - Date.parse(b.observed_at) || (a.source_type < b.source_type ? -1 : 1))++ .slice(0, MAX_USES_PER_PHRASE);++ const out = [];++ for (const u of picked) {++ if (isDenied(u)) continue;++ const c = { ...u };++ if (u.vod_id != null && u.offset_seconds != null) {++ const resolved = await canon(u.vod_id, u.offset_seconds);++ if (resolved.denied || resolved.gone || resolved.hidden) continue;++ if (!resolved.error && resolved.url) {++ c.vod_id = resolved.vod_id;++ c.offset_seconds = resolved.offset_seconds;++ c.url = resolved.url;++ }++ }++ if (isDenied(c)) continue;++ out.push({ ...c, role: out.length === 0 ? 'origin' : 'use' });++ }++ return out;++ }++++ async function runOnce() {++ const runAt = new Date(s.now()).toISOString();++ const { uses, hints, errors } = await collect();++ const canon = resolver();++ const found = detectCandidates(uses, {++ minUses: config.importer.minUses, minDays: config.importer.minDays, hints,++ max: Math.max(1, Number(config.importer.maxCandidates) || 10),++ });++ let created = 0;++ let enriched = 0;++ for (const candidate of found) {++ try {++ const cites = await materialize(candidate, canon);++ if (!cites.length) continue;++ const firstSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) < Date.parse(m) ? c.observed_at : m), cites[0].observed_at);++ const lastSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) > Date.parse(m) ? c.observed_at : m), cites[0].observed_at);++ const title = displayTitle(candidate.phrase);++ const summary = cleanSummary(title, `"${title}" recurs in ${cites.length} moments across ${new Set(cites.map((c) => utcDay(c.observed_at))).size} days on OpenVibe, from ${firstSeen.slice(0, 10)} to ${lastSeen.slice(0, 10)}.${hints.includes(candidate.phrase) ? ' Chat\'s timeline names it too.' : ''}`);++ const existing = await entries.bySlug(slugify(title));++ if (existing) {++ const r = await entries.enrich(existing.id, { first_seen_at: firstSeen, last_seen_at: lastSeen, cites });++ if (r && r.changed) enriched++;++ } else {++ await entries.createCandidate({++ slug: slugify(title), title, kind: candidate.phrase.split(' ').length <= 4 ? 'catchphrase' : 'meme',++ summary, status: statusFor(firstSeen, lastSeen, s.now()), first_seen_at: firstSeen, last_seen_at: lastSeen,++ visibility: 'hidden', citations: cites,++ });++ created++;++ }++ } catch (err) {++ errors.push(`candidate ${candidate.phrase}: ${err.message}`);++ log.warn(`[OpenVibe.Meme] importer candidate failed: ${err.message}`);++ }++ }++ stats.runs++;++ stats.uses = uses.length;++ stats.candidates = found.length;++ stats.created += created;++ stats.enriched += enriched;++ stats.errors += errors.length;++ stats.last_run_at = runAt;++ stats.last_error = errors[0] || null;++ const out = { ok: errors.length === 0, uses: uses.length, candidates: found.length, created, enriched, errors };++ log.log(`[OpenVibe.Meme] importer: ${uses.length} uses → ${found.length} candidates, ${created} created, ${enriched} enriched${errors.length ? `, ${errors.length} errors` : ''}`);++ return out;++ }++++ function start() {++ if (config.drill || timers.length) return false;++ const quiet = () => runOnce().catch((err) => log.warn(`[OpenVibe.Meme] importer tick failed: ${err.message}`));++ const kick = setTimeout(quiet, 30_000);++ const tick = setInterval(quiet, config.importer.intervalMs);++ if (kick.unref) kick.unref();++ if (tick.unref) tick.unref();++ timers = [kick, tick];++ return true;++ }++ function stop() {++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }++ }++++ return { runOnce, start, stop, stats: () => ({ ...stats, running: timers.length > 0 }) };++}++++module.exports = { createImporter, detectCandidates, recurringPhrases, statusFor, normalize, STOP };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * OpenVibe.Meme — process entry. `node server/index.js`++ * Listens on PORT (5120) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the++ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Meme', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.keys.client.stop(),++ () => ctx.importer.stop(),++ () => ctx.reverifier.stop(),++ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },++ () => ctx.s.close(),++ ...extra,++ ],++ });++}++++async function start() {++ const { app, ctx } = await createApp();++ const { config } = ctx;++++ const server = app.listen(config.port, config.host, () => {++ console.log(`[OpenVibe.Meme] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);++ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded++ // only because the first JWKS fetch had not happened.++ ctx.keys.ensure().catch(() => {});++ });++ server.keepAliveTimeout = 65_000;++ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });++ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);++ ctx.telemetryTick.unref();++ ctx.keys.client.start();++++ // Jobs. Each refuses to start under MEME_DRILL; tests call their runOnce().++ ctx.importer.start();++ ctx.reverifier.start();++++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the++ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.++ const subs = config.drill ? null : startSubscriptions({++ eventsUrl: config.events.url,++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,++ secret: config.events.secrets[0] || '',++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ log: ctx.log || console,++ });++ const extra = [() => { if (subs) subs.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Meme] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,39 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,++ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has++ * signed in to yet is not degraded only because the first fetch had not happened.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'meme',++ release,++ checks: [++ {++ name: 'db', required: true,++ check: async () => {++ const r = await s.db.ready();++ return r.ok ? { ok: true, detail: r.detail } : r.error;++ },++ },++ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },++ {++ name: 'network_jwks', required: false,++ check: () => {++ const statuses = jwksStatus();++ if (!statuses.length) return 'no JWKS clients (misconfigured)';++ const first = statuses[0];++ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';++ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };++ },++ },++ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * This is Today's layout with Meme's name, navigation, room accent and assets.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++const { render, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Meme';++const TAGLINE = 'The open meme database, rebuilt with AI.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'This week', href: '/week' },++ { label: 'Make a caption', href: '/make' },++ { label: 'Developers', href: '/developers' },++];++++const hashes = new Map();++function assetVersion(rel) {++ if (hashes.has(rel)) return hashes.get(rel);++ let v = 'dev';++ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }++ hashes.set(rel, v);++ return v;++}++const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;++++let RELEASE = 'dev';++function setRelease(id) { if (id) RELEASE = String(id); }++++/**++ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles++ */++function renderPage(o) {++ const viewer = o.viewer || { kind: 'anonymous' };++ const signedIn = viewer.kind === 'user';++ const pathNow = o.path || '/';++ const loginNext = encodeURIComponent(pathNow);++ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;++ const nav = {++ service: 'meme',++ apiBase: NETWORK_URL,++ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),++ history: { type: 'page', title: o.title || SITE_NAME },++ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,++ sessionUrl: '/auth/me',++ loginUrl: '/auth/login?next={path}',++ logoutUrl: '/auth/logout?next={path}',++ notificationsRealtime: true,++ };++ const footer = { service: 'meme', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'meme' })}++<link rel="stylesheet" href="${ovServe.url('kit.css')}">++<link rel="stylesheet" href="${ovServe.url('room.css')}">++<link rel="stylesheet" href="${ovServe.url('showcase.css')}">++<link rel="stylesheet" href="${asset('css/meme.css')}">++<script src="${ovServe.url('motion.js')}" defer></script>++<script src="${asset('js/meme.js')}" defer></script>++${render(scripts)}++<meta name="ov-boost" content="meme@${require('./html').esc(RELEASE)}">++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>++<main id="main" class="mm ov-room" style="${rooms.style('meme')}">++${crumbs}++${render(o.body)}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };nnew file mode 100644@@@ -0,0 +1,136 @@++'use strict';++++/**++ * Citation re-verification, once a day (MEME_REVERIFY_INTERVAL_MS; off under MEME_DRILL). Every citation's public++ * URL is read again: a 404/410 (a private VOD, a deleted clip) marks it gone; a 401/403 marks it hidden. A Chat++ * citation is re-read from the public history page because v1 has no per-message permalink API. An entry whose++ * origin citation is no longer live is hidden (revision +1, Search tombstone) — the plan's rule.++ *++ * The job is small and sequential on purpose: a handful of reads per citation, and a failing upstream leaves that++ * citation as it was (a transient 503 must not erase a real citation).++ */++const { parseApiTime } = require('./upstreams');++const { isDenied } = require('./deny-list');++++function createReverifier({ config, s, entries, upstreams, log = console }) {++ let timers = [];++ const stats = { runs: 0, checked: 0, gone: 0, hidden: 0, entries_hidden: 0, errors: 0, last_run_at: null, last_error: null };++++ async function verifyOne(citation) {++ if (isDenied({ vod_id: citation.vod_id, clip_id: citation.clip_id })) return { status: 'gone', denied: true };++ if (citation.source_type === 'chat_message') {++ const r = await upstreams.chatMessage(citation);++ return { status: r.status, text: r.text || null };++ }++ const r = await upstreams.citationUrl(citation.url);++ const out = { status: r.status };++ if (r.finalUrl && r.finalUrl !== citation.url) {++ try {++ const u = new URL(r.finalUrl);++ const m = u.pathname.match(/\/v\/(\d+)\/?$/);++ if (m) {++ out.vod_id = Number(m[1]);++ const t = u.searchParams.get('t');++ if (t != null && t !== '') out.offset_seconds = Number(t);++ }++ out.url = u.toString();++ } catch { /* keep the stored URL */ }++ }++ return out;++ }++++ async function runOnce({ limit = 500 } = {}) {++ const runAt = new Date(s.now()).toISOString();++ const rows = await s.db.many(++ "SELECT * FROM meme_citations WHERE source_status <> 'gone' ORDER BY captured_at ASC, id ASC LIMIT $1",++ [Math.min(5000, Math.max(1, Number(limit) || 500))],++ );++ let checked = 0;++ let gone = 0;++ let hidden = 0;++ const toHide = new Set();++ const toReindex = new Set();++ const errors = [];++ for (const row of rows) {++ checked++;++ try {++ const r = await verifyOne(row);++ const status = r.status === 'live' ? 'live' : r.status === 'hidden' ? 'hidden' : 'gone';++ if (status === 'gone') gone++;++ if (status === 'hidden') hidden++;++ if (status !== row.source_status || r.url || r.vod_id != null || r.offset_seconds != null) {++ if (status !== 'live') toHide.add(row.entry_id);++ else toReindex.add(row.entry_id);++ await s.db.exec(++ `UPDATE meme_citations SET source_status = $2,++ url = COALESCE($3, url),++ vod_id = COALESCE($4, vod_id),++ offset_seconds = COALESCE($5, offset_seconds),++ captured_at = now()++ WHERE id = $1`,++ [row.id, status, r.url || null, r.vod_id == null ? null : r.vod_id, r.offset_seconds == null ? null : r.offset_seconds],++ );++ } else {++ await s.db.exec('UPDATE meme_citations SET captured_at = now() WHERE id = $1', [row.id]);++ }++ } catch (err) {++ errors.push(`citation ${row.id}: ${err.message}`);++ log.warn(`[OpenVibe.Meme] re-verify ${row.id} failed: ${err.message}`);++ }++ }++++ let entryHides = 0;++ for (const entryId of toHide) {++ try {++ const originGone = await s.db.maybe(++ "SELECT id FROM meme_citations WHERE entry_id = $1 AND role = 'origin' AND source_status <> 'live' LIMIT 1",++ [entryId],++ );++ if (!originGone) { toReindex.add(entryId); continue; }++ const row = await entries.byId(entryId);++ if (!row || row.visibility === 'hidden') continue;++ const r = await entries.hide(row.slug, { changedBy: 'service:meme:reverify' });++ if (r && r.ok && !r.unchanged) entryHides++;++ } catch (err) {++ errors.push(`entry ${entryId}: ${err.message}`);++ log.warn(`[OpenVibe.Meme] re-verify entry ${entryId} failed: ${err.message}`);++ }++ }++ // A live use that came back, or a joined-VOD URL that moved, changes the public document without hiding it.++ for (const entryId of toReindex) {++ try { await entries.reindex(entryId); } catch (err) {++ errors.push(`entry ${entryId}: ${err.message}`);++ log.warn(`[OpenVibe.Meme] re-index entry ${entryId} failed: ${err.message}`);++ }++ }++++ stats.runs++;++ stats.checked += checked;++ stats.gone += gone;++ stats.hidden += hidden;++ stats.entries_hidden += entryHides;++ stats.errors += errors.length;++ stats.last_run_at = runAt;++ stats.last_error = errors[0] || null;++ log.log(`[OpenVibe.Meme] re-verify: ${checked} citations, ${gone} gone, ${hidden} hidden, ${entryHides} entries hidden${errors.length ? `, ${errors.length} errors` : ''}`);++ return { checked, gone, hidden, entries_hidden: entryHides, errors };++ }++++ function start() {++ if (config.drill || timers.length) return false;++ const quiet = () => runOnce().catch((err) => log.warn(`[OpenVibe.Meme] re-verify tick failed: ${err.message}`));++ const kick = setTimeout(quiet, 60_000);++ const tick = setInterval(quiet, config.reverify.intervalMs);++ if (kick.unref) kick.unref();++ if (tick.unref) tick.unref();++ timers = [kick, tick];++ return true;++ }++ function stop() {++ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }++ }++++ return { runOnce, verifyOne, start, stop, stats: () => ({ ...stats, running: timers.length > 0 }) };++}++++module.exports = { createReverifier, parseApiTime };nnew file mode 100644@@@ -0,0 +1,103 @@++'use strict';++++/**++ * OpenVibe.Meme's Search Fabric feed. Every public entry is one `search.index-document@1` (owner `meme`, type++ * `entry`, page `/m/<slug>`), written to `meme_outbox` in the same transaction as the entry change; a hidden++ * entry writes a tombstone, which wins over any older revision. The document carries the entry's AI-cleaned++ * summary, the verbatim quotes as plain-text body, facets `kind`/`status`/`source`, authorship `ai_assisted` and++ * one provenance reference per citation — the same shape Work, Help and the other record products send.++ *++ * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer++ * is configured for `meme` (research §6.2). The relay is deliberately not part of v1.++ */++const { ids } = require('openvibe-contracts');++++const OWNER = 'meme';++const TYPE = 'entry';++const EVENT_UPSERTED = 'meme.index_document.upserted';++const EVENT_DELETED = 'meme.index_document.deleted';++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const pagePath = (entry) => `/m/${entry.slug}`;++++function provenanceOf(citations) {++ const seen = new Set();++ const out = [];++ for (const c of citations || []) {++ const key = `${c.source_service}:${c.source_type}:${c.source_id}`;++ if (seen.has(key)) continue;++ seen.add(key);++ out.push({++ service: c.source_service,++ type: c.source_type,++ id: String(c.source_id).slice(0, 128),++ ...(c.url ? { url: String(c.url).slice(0, 2048) } : {}),++ ...(c.captured_at || c.observed_at ? { retrieved_at: iso(c.captured_at || c.observed_at) } : {}),++ });++ if (out.length >= 50) break;++ }++ return out;++}++++/** One entry + its live citations → the Search document (or a tombstone when it is not public). */++function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme' } = {}) {++ const revision = Math.max(1, Number(entry.revision) || 1);++ if (!entry || entry.visibility !== 'public') return { owner: OWNER, type: TYPE, id: entry.id, revision, deleted: true };++ const live = (citations || []).filter((c) => c.source_status === 'live');++ const sources = [...new Set(live.map((c) => c.source_service))];++ const body = [entry.title, entry.summary || '', ...live.map((c) => c.quote_text)]++ .filter(Boolean).join('\n').slice(0, 48_000);++ return {++ owner: OWNER,++ type: TYPE,++ id: entry.id,++ revision,++ deleted: false,++ visibility: 'public',++ canonical_url: `${String(baseUrl).replace(/\/+$/, '')}${pagePath(entry)}`,++ title: entry.title,++ ...(entry.summary ? { summary: entry.summary } : {}),++ body,++ facets: { kind: entry.kind, status: entry.status, source: sources },++ authorship: 'ai_assisted',++ provenance: provenanceOf(live),++ publication_state: 'published',++ published_at: iso(entry.created_at),++ updated_at: iso(entry.updated_at),++ indexability: { decision: 'index' },++ };++}++++function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {++ const nowIso = () => new Date(s.now()).toISOString();++++ async function enqueue(topic, payload, t = null) {++ const db = t || s.db;++ const eventId = ids.newId('event', s.now());++ await db.exec(++ `INSERT INTO meme_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,++ [eventId, topic, JSON.stringify(payload), nowIso()],++ );++ return eventId;++ }++++ /** Queue the current document on the caller's transaction (upsert when public, tombstone otherwise). */++ async function sync(t, entry, citations) {++ const doc = documentOf(entry, citations, { baseUrl });++ return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);++ }++++ /** Tombstone an id that no longer exists or is now hidden. */++ async function remove(t, id, revision) {++ return await enqueue(EVENT_DELETED, { owner: OWNER, type: TYPE, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);++ }++++ async function pending() {++ return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox WHERE published_at IS NULL'));++ }++++ return { sync, remove, enqueue, documentOf, pending, baseUrl: String(baseUrl).replace(/\/+$/, '') };++}++++module.exports = { createSearchIndex, documentOf, provenanceOf, pagePath, OWNER, TYPE, EVENT_UPSERTED, EVENT_DELETED };nnew file mode 100644@@@ -0,0 +1,517 @@++'use strict';++++/**++ * Meme's data: entries, their citations, status history, the week's votes, templates, saved captions and reports.++ *++ * A write that changes a public entry writes its Search document (or tombstone) in the same transaction++ * (server/search.js). A candidate is always inserted hidden; only entries.publish() makes it public, and only for++ * staff (server/http/api.js). revision goes up on every change, and every status change leaves a++ * meme_status_events row. Captions hold text lines only — no image is ever stored for a caption.++ */++const crypto = require('crypto');++const { isDenied } = require('./deny-list');++const { cleanSummary } = require('./text/no-looks');++const { weekBounds } = require('./week');++++const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;++const KINDS = Object.freeze(['joke', 'catchphrase', 'bit', 'meme', 'saga']);++const STATUSES = Object.freeze(['rising', 'peaked', 'classic', 'dead']);++const VISIBILITIES = Object.freeze(['public', 'unlisted', 'hidden']);++const CAPTION_VISIBILITIES = Object.freeze(['public', 'unlisted', 'hidden']);++const MAX_LINES = 4;++const MAX_LINE = 120;++const BLOCKED_WORDS = Object.freeze(['nigger', 'faggot', 'retard', 'kys', 'rape', 'pedo', 'nazi']);++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));++const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');++const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });++++function wireEntry(row) {++ if (!row) return null;++ return {++ id: row.id,++ slug: row.slug,++ title: row.title,++ kind: row.kind,++ summary: row.summary == null ? null : row.summary,++ status: row.status,++ status_since: iso(row.status_since),++ first_seen_at: iso(row.first_seen_at),++ last_seen_at: iso(row.last_seen_at),++ visibility: row.visibility,++ revision: Number(row.revision) || 1,++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ };++}++++function wireCitation(row) {++ if (!row) return null;++ return {++ id: Number(row.id),++ entry_id: row.entry_id,++ role: row.role,++ source_service: row.source_service,++ source_type: row.source_type,++ source_id: row.source_id,++ stream_id: nOrNull(row.stream_id),++ vod_id: nOrNull(row.vod_id),++ clip_id: nOrNull(row.clip_id),++ chat_message_id: nOrNull(row.chat_message_id),++ offset_seconds: nOrNull(row.offset_seconds),++ quote_text: row.quote_text,++ quote_hash: row.quote_hash,++ url: row.url,++ observed_at: iso(row.observed_at),++ source_status: row.source_status,++ captured_at: iso(row.captured_at),++ };++}++++function wireTemplate(row) {++ if (!row) return null;++ return { id: row.id, name: row.name, image_url: row.image_url, box_count: Number(row.box_count), source: row.source, license: row.license, created_at: iso(row.created_at) };++}++++function wireCaption(row) {++ if (!row) return null;++ let lines = row.lines;++ if (typeof lines === 'string') { try { lines = JSON.parse(lines); } catch { lines = []; } }++ return {++ id: row.id,++ subject: row.subject,++ template_id: row.template_id,++ lines: Array.isArray(lines) ? lines.map(String) : [],++ created_at: iso(row.created_at),++ visibility: row.visibility,++ };++}++++function wireReport(row) {++ if (!row) return null;++ return {++ id: row.id,++ entry_id: row.entry_id,++ subject: row.subject,++ kind: row.kind,++ reason: row.reason,++ note: row.note == null ? null : row.note,++ status: row.status,++ created_at: iso(row.created_at),++ handled_at: iso(row.handled_at),++ handled_by: row.handled_by,++ ...(row.entry_slug ? { entry_slug: row.entry_slug } : {}),++ ...(row.entry_title ? { entry_title: row.entry_title } : {}),++ };++}++++function normalizeLine(s) {++ return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();++}++++/** Validate a title/kind/status patch body. Returns { ok, value } or a refusal. */++function patchFields(body) {++ const b = body && typeof body === 'object' ? body : {};++ const allowed = ['title', 'kind', 'status', 'reason'];++ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));++ if (unknown.length) return refusal(400, 'meme.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);++ const out = {};++ if (b.title !== undefined) {++ const title = String(b.title == null ? '' : b.title).replace(/\s+/g, ' ').trim();++ if (!title || title.length > 160) return refusal(400, 'meme.invalid', 'title must be 1 to 160 characters', 'title');++ out.title = title;++ }++ if (b.kind !== undefined) {++ if (!KINDS.includes(b.kind)) return refusal(400, 'meme.invalid', `kind must be one of ${KINDS.join(', ')}`, 'kind');++ out.kind = b.kind;++ }++ if (b.status !== undefined) {++ if (!STATUSES.includes(b.status)) return refusal(400, 'meme.invalid', `status must be one of ${STATUSES.join(', ')}`, 'status');++ out.status = b.status;++ }++ if (b.reason !== undefined) {++ const reason = String(b.reason == null ? '' : b.reason).trim().slice(0, 500);++ if (reason) out.reason = reason;++ }++ if (!Object.keys(out).some((k) => k !== 'reason')) return refusal(400, 'meme.invalid', 'Send title, kind or status', null);++ return { ok: true, value: out };++}++++/** Captions: 1–4 plain-text lines, each 1–120 characters, a small blocked-word list, no control characters. */++function captionLines(raw) {++ if (!Array.isArray(raw)) return refusal(400, 'meme.invalid', 'lines must be an array of 1 to 4 strings', 'lines');++ if (raw.length < 1 || raw.length > MAX_LINES) return refusal(400, 'meme.invalid', `lines must have 1 to ${MAX_LINES} entries`, 'lines');++ const lines = [];++ for (let i = 0; i < raw.length; i++) {++ const line = normalizeLine(raw[i]);++ if (!line) return refusal(400, 'meme.invalid', `line ${i + 1} is empty`, 'lines');++ if (line.length > MAX_LINE) return refusal(400, 'meme.invalid', `line ${i + 1} is longer than ${MAX_LINE} characters`, 'lines');++ if (/[\u0000-\u001f\u007f]/.test(String(raw[i]))) return refusal(400, 'meme.invalid', 'lines are plain text: no control characters', 'lines');++ if (BLOCKED_WORDS.some((w) => new RegExp(`(^|[^a-z0-9])${w}([^a-z0-9]|$)`, 'i').test(line))) {++ return refusal(400, 'meme.blocked_words', 'That wording is not allowed in a caption', 'lines');++ }++ lines.push(line);++ }++ return { ok: true, lines };++}++++function createStore(s, { search }) {++ const db = s.db;++ // Every write function takes an optional transaction handle; without one it runs on the pool and is its own++ // statement (no multi-statement atomicity unless the caller opens s.tx()).++ const q = (t) => t || db;++++ async function bySlug(slug, t) {++ return await q(t).maybe('SELECT * FROM meme_entries WHERE slug = $1', [String(slug || '')]);++ }++ async function byId(id, t) {++ return await q(t).maybe('SELECT * FROM meme_entries WHERE id = $1', [String(id || '')]);++ }++ async function citations(entryId, t) {++ return (await q(t).many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at ASC, id ASC', [entryId])).map(wireCitation);++ }++ async function liveCitations(entryId, t) {++ return (await q(t).many("SELECT * FROM meme_citations WHERE entry_id = $1 AND source_status = 'live' ORDER BY observed_at ASC, id ASC", [entryId])).map(wireCitation);++ }++++ /** Public entries, newest first. cursor is an opaque (first_seen_at, id) keyset position. */++ async function list({ limit = 20, cursor = null, kind = null, status = null } = {}) {++ const n = Math.min(50, Math.max(1, Number(limit) || 20));++ const args = [];++ let where = "visibility = 'public'";++ if (kind) { if (!KINDS.includes(kind)) return { entry: null, error: 'kind' }; args.push(kind); where += ` AND kind = $${args.length}`; }++ if (status) { if (!STATUSES.includes(status)) return { entry: null, error: 'status' }; args.push(status); where += ` AND status = $${args.length}`; }++ let pos = null;++ if (cursor) {++ try {++ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));++ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;++ } catch { /* an unreadable cursor is refused by the caller */ }++ if (!pos) return { entry: null, error: 'cursor' };++ args.push(pos.t, pos.i);++ where += ` AND (first_seen_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;++ }++ args.push(n + 1);++ const rows = await db.many(`SELECT * FROM meme_entries WHERE ${where} ORDER BY first_seen_at DESC, id DESC LIMIT $${args.length}`, args);++ const hasMore = rows.length > n;++ const page = rows.slice(0, n);++ const last = page[page.length - 1];++ return {++ entries: page.map(wireEntry),++ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.first_seen_at), i: last.id })).toString('base64url') : null,++ };++ }++++ async function trending({ limit = 20 } = {}) {++ const n = Math.min(50, Math.max(1, Number(limit) || 20));++ const rows = await db.many(++ `SELECT * FROM meme_entries WHERE visibility = 'public'++ ORDER BY CASE status WHEN 'rising' THEN 0 WHEN 'peaked' THEN 1 WHEN 'classic' THEN 2 WHEN 'dead' THEN 3 ELSE 4 END,++ last_seen_at DESC, id DESC LIMIT $1`,++ [n],++ );++ return rows.map(wireEntry);++ }++++ /** The current week's candidates (public entries with a live use that week) and their ballot counts. */++ async function week({ week: label, subject = null } = {}) {++ const bounds = weekBounds(label);++ if (!bounds) return null;++ const rows = await db.many(++ `SELECT e.*,++ (SELECT COUNT(*)::int FROM meme_votes v WHERE v.week = $1 AND v.entry_id = e.id) AS votes,++ (SELECT COUNT(*)::int FROM meme_votes v WHERE v.week = $1 AND v.entry_id = e.id AND v.subject = $2) AS mine++ FROM meme_entries e++ WHERE e.visibility = 'public'++ AND EXISTS (SELECT 1 FROM meme_citations c++ WHERE c.entry_id = e.id AND c.source_status = 'live'++ AND c.observed_at >= $3::timestamptz AND c.observed_at < $4::timestamptz)++ ORDER BY votes DESC, e.last_seen_at DESC, e.id DESC LIMIT 30`,++ [label, subject, bounds.start, bounds.end],++ );++ return {++ week: label,++ starts_at: bounds.start,++ ends_at: bounds.end,++ candidates: rows.map((r) => ({ ...wireEntry(r), votes: Number(r.votes) || 0, mine: Number(r.mine) > 0 })),++ };++ }++++ /** Uses per UTC day over the last `days` days, zero-filled so a chart has no holes. */++ async function series(entryId, { days = 30 } = {}) {++ const n = Math.min(365, Math.max(1, Number(days) || 30));++ const now = new Date(s.now());++ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));++ start.setUTCDate(start.getUTCDate() - (n - 1));++ const rows = await db.many(++ `SELECT to_char(observed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, COUNT(*)::int AS uses++ FROM meme_citations++ WHERE entry_id = $1 AND role IN ('origin', 'use') AND source_status = 'live'++ AND observed_at >= $2::timestamptz AND observed_at <= $3::timestamptz++ GROUP BY 1 ORDER BY 1`,++ [entryId, start.toISOString(), now.toISOString()],++ );++ const counts = new Map(rows.map((r) => [r.day, Number(r.uses) || 0]));++ const out = [];++ for (let i = 0; i < n; i++) {++ const d = new Date(start.getTime() + i * 86_400_000);++ const day = d.toISOString().slice(0, 10);++ out.push({ day, uses: counts.get(day) || 0 });++ }++ return { days: n, from: start.toISOString(), to: now.toISOString(), series: out };++ }++++ /** The id for a slug, appending -2, -3… when one is already taken. */++ async function uniqueSlug(base, t) {++ const root = String(base || 'meme').slice(0, 70).replace(/^-+|-+$/g, '') || 'meme';++ let slug = root;++ for (let i = 2; i < 200; i++) {++ const found = await q(t).maybe('SELECT id FROM meme_entries WHERE slug = $1', [slug]);++ if (!found) return slug;++ slug = `${root}-${i}`;++ }++ return `${root}-${Date.now().toString(36)}`;++ }++++ /**++ * Insert one hidden candidate and its citations in the caller's transaction. Used by the importer and the++ * seed script only; it never publishes. Citations that name the deny list are dropped here too.++ */++ async function createCandidate({ slug = null, title, kind = 'meme', summary = null, status = 'rising', first_seen_at, last_seen_at, visibility = 'hidden', citations: cites = [] }, t = null) {++ const run = async (tx) => {++ const id = s.newId('mem');++ const cleanSlug = await uniqueSlug(slug || slugify(title), tx);++ const cleanTitle = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 160) || 'Untitled meme';++ let first = iso(first_seen_at) || s.iso();++ let last = iso(last_seen_at) || first;++ if (Date.parse(first) > Date.parse(last)) { const swap = first; first = last; last = swap; }++ const row = await q(tx).maybe(++ `INSERT INTO meme_entries (id, slug, title, kind, summary, status, status_since, first_seen_at, last_seen_at, visibility, revision, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz, $10, 1, now(), now()) RETURNING *`,++ [id, cleanSlug, cleanTitle, kind, cleanSummary(cleanTitle, summary), status, iso(last), first, last, visibility],++ );++ const added = await addCitations(row.id, cites, tx);++ const entry = wireEntry(row);++ if (entry.visibility === 'public') await search.sync(tx, entry, added);++ return { entry, citations: added };++ };++ return t ? await run(t) : await s.tx(run);++ }++++ /** Insert citations in the caller's transaction; the sha256 is computed from the verbatim quote. */++ async function addCitations(entryId, cites = [], t = null) {++ const run = async (tx) => {++ const out = [];++ for (const c of Array.isArray(cites) ? cites : []) {++ if (!c || isDenied(c)) continue;++ const quote = String(c.quote_text == null ? '' : c.quote_text);++ if (!quote.trim()) continue;++ const role = c.role === 'origin' || c.role === 'example' ? c.role : 'use';++ const observed = iso(c.observed_at) || s.iso();++ const row = await q(tx).maybe(++ `INSERT INTO meme_citations (entry_id, role, source_service, source_type, source_id, stream_id, vod_id, clip_id, chat_message_id,++ offset_seconds, quote_text, quote_hash, url, observed_at, source_status, captured_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::timestamptz, $15, now())++ ON CONFLICT DO NOTHING RETURNING *`,++ [entryId, role, c.source_service, c.source_type, String(c.source_id).slice(0, 200), c.stream_id == null ? null : Number(c.stream_id),++ c.vod_id == null ? null : Number(c.vod_id), c.clip_id == null ? null : Number(c.clip_id),++ c.chat_message_id == null ? null : Number(c.chat_message_id), c.offset_seconds == null ? null : Number(c.offset_seconds),++ quote, sha256(quote), String(c.url), observed, c.source_status || 'live'],++ );++ if (row) out.push(wireCitation(row));++ }++ return out;++ };++ return t ? await run(t) : await s.tx(run);++ }++++ /** Add uses to an existing candidate, extend its range and bump its revision only when something changed. */++ async function enrich(entryId, { first_seen_at, last_seen_at, cites = [] }) {++ return await s.tx(async (t) => {++ const row = await byId(entryId, t);++ if (!row) return null;++ const first = iso(first_seen_at) || iso(row.first_seen_at);++ const last = iso(last_seen_at) || iso(row.last_seen_at);++ const added = await addCitations(entryId, cites, t);++ const earlier = Date.parse(first) < Date.parse(iso(row.first_seen_at));++ const later = Date.parse(last) > Date.parse(iso(row.last_seen_at));++ if (!added.length && !earlier && !later) return { entry: wireEntry(row), added: 0, changed: false };++ const updated = await t.maybe(++ `UPDATE meme_entries SET first_seen_at = LEAST(first_seen_at, $2::timestamptz), last_seen_at = GREATEST(last_seen_at, $3::timestamptz),++ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,++ [entryId, first, last],++ );++ const all = await liveCitations(entryId, t);++ if (updated.visibility === 'public') await search.sync(t, wireEntry(updated), all);++ return { entry: wireEntry(updated), added: added.length, changed: true };++ });++ }++++ /** A citation changed (a redirect, or a use went away) while the entry stayed public: a new revision and document. */++ async function reindex(entryId, { changedBy = 'service:meme:reverify' } = {}) {++ return await s.tx(async (t) => {++ const row = await byId(entryId, t);++ if (!row || row.visibility !== 'public') return { ok: false, unchanged: true };++ const updated = await t.maybe('UPDATE meme_entries SET revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id]);++ const all = await liveCitations(row.id, t);++ await search.sync(t, wireEntry(updated), all);++ return { ok: true, entry: wireEntry(updated) };++ });++ }++++ async function publish(slug, { changedBy = 'service:meme' } = {}) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');++ const origin = await t.maybe("SELECT id FROM meme_citations WHERE entry_id = $1 AND role = 'origin' AND source_status = 'live' LIMIT 1", [row.id]);++ if (!origin) return refusal(409, 'meme.origin_gone', 'The origin citation is missing or gone; this entry cannot be published.');++ if (row.visibility === 'public') return { ok: true, entry: wireEntry(row), unchanged: true };++ const updated = await t.maybe('UPDATE meme_entries SET visibility = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id, 'public']);++ const all = await liveCitations(row.id, t);++ await search.sync(t, wireEntry(updated), all);++ return { ok: true, entry: wireEntry(updated), unchanged: false };++ });++ }++++ async function hide(slug, { changedBy = 'service:meme' } = {}) {++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');++ if (row.visibility === 'hidden') return { ok: true, entry: wireEntry(row), unchanged: true };++ const updated = await t.maybe('UPDATE meme_entries SET visibility = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id, 'hidden']);++ await search.sync(t, wireEntry(updated), []);++ return { ok: true, entry: wireEntry(updated), unchanged: false };++ });++ }++++ async function patch(slug, { body, changedBy = 'service:meme' } = {}) {++ const checked = patchFields(body);++ if (!checked.ok) return checked;++ const v = checked.value;++ return await s.tx(async (t) => {++ const row = await bySlug(slug, t);++ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');++ const sets = [];++ const args = [row.id];++ for (const key of ['title', 'kind', 'status']) {++ if (v[key] !== undefined) { args.push(v[key]); sets.push(`${key} = $${args.length}`); }++ }++ if (v.status !== undefined && v.status !== row.status) sets.push('status_since = now()');++ const updated = await t.maybe(++ `UPDATE meme_entries SET ${sets.join(', ')}, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,++ args,++ );++ if (v.status !== undefined && v.status !== row.status) {++ await t.exec('INSERT INTO meme_status_events (entry_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, $3, $4, $5)',++ [row.id, row.status, v.status, v.reason || null, changedBy]);++ }++ // A hidden entry has no Search document to correct; only a public one is re-indexed here.++ if (updated.visibility === 'public') {++ const all = await liveCitations(row.id, t);++ await search.sync(t, wireEntry(updated), all);++ }++ return { ok: true, entry: wireEntry(updated), unchanged: false };++ });++ }++++ async function templates() {++ return (await db.many('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC')).map(wireTemplate);++ }++ async function templateById(id) {++ return wireTemplate(await db.maybe('SELECT * FROM meme_templates WHERE id = $1', [String(id || '')]));++ }++++ async function createCaption({ subject, templateId, lines, visibility = 'public' }) {++ if (!CAPTION_VISIBILITIES.includes(visibility)) return refusal(400, 'meme.invalid', `visibility must be one of ${CAPTION_VISIBILITIES.join(', ')}`, 'visibility');++ const checked = captionLines(lines);++ if (!checked.ok) return checked;++ const tpl = await db.maybe('SELECT id FROM meme_templates WHERE id = $1', [String(templateId || '')]);++ if (!tpl) return refusal(404, 'meme.template_not_found', 'No such template.');++ const id = s.newId('mcp');++ const row = await db.maybe(++ `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility)++ VALUES ($1, $2, $3, $4::jsonb, now(), $5) RETURNING *`,++ [id, subject, tpl.id, JSON.stringify(checked.lines), visibility],++ );++ return { ok: true, caption: wireCaption(row) };++ }++++ async function captionById(id) {++ return wireCaption(await db.maybe('SELECT * FROM meme_captions WHERE id = $1', [String(id || '')]));++ }++++ async function captionCountForTemplate(templateId) {++ return Number(await db.value("SELECT COUNT(*)::int AS n FROM meme_captions WHERE template_id = $1 AND visibility = 'public'", [templateId])) || 0;++ }++++ async function castVote({ subject, entryId, week }) {++ const bounds = weekBounds(week);++ if (!bounds) return refusal(400, 'meme.invalid', 'week must be YYYY-Www');++ const row = await db.maybe(++ `SELECT e.* FROM meme_entries e++ WHERE e.id = $1 AND e.visibility = 'public'++ AND EXISTS (SELECT 1 FROM meme_citations c WHERE c.entry_id = e.id AND c.source_status = 'live'++ AND c.observed_at >= $2::timestamptz AND c.observed_at < $3::timestamptz)`,++ [String(entryId || ''), bounds.start, bounds.end],++ );++ if (!row) return refusal(404, 'meme.not_found', 'No such candidate in this week.');++ const prev = await db.maybe('SELECT entry_id FROM meme_votes WHERE week = $1 AND subject = $2', [week, subject]);++ await db.exec(++ `INSERT INTO meme_votes (week, entry_id, subject, created_at, updated_at)++ VALUES ($1, $2, $3, now(), now())++ ON CONFLICT (week, subject) DO UPDATE SET entry_id = EXCLUDED.entry_id, updated_at = now()`,++ [week, row.id, subject],++ );++ const counts = await db.many('SELECT entry_id, COUNT(*)::int AS votes FROM meme_votes WHERE week = $1 GROUP BY entry_id', [week]);++ return {++ ok: true,++ vote: { week, entry_id: row.id, changed: !prev || prev.entry_id !== row.id },++ counts: Object.fromEntries(counts.map((c) => [c.entry_id, Number(c.votes)])),++ };++ }++++ async function createReport({ entryId, subject, kind, reason, note = null }) {++ const row = await db.maybe("SELECT id FROM meme_entries WHERE id = $1 AND visibility = 'public'", [String(entryId || '')]);++ if (!row) return refusal(404, 'meme.not_found', 'No such public entry.');++ const id = s.newId('mrp');++ const saved = await db.maybe(++ `INSERT INTO meme_reports (id, entry_id, subject, kind, reason, note, status, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, 'open', now()) RETURNING *`,++ [id, row.id, subject, kind, reason, note],++ );++ return { ok: true, report: wireReport(saved) };++ }++++ async function listOpenReports({ limit = 100 } = {}) {++ const rows = await db.many(++ `SELECT r.*, e.slug AS entry_slug, e.title AS entry_title++ FROM meme_reports r JOIN meme_entries e ON e.id = r.entry_id++ WHERE r.status = 'open' ORDER BY r.created_at ASC LIMIT $1`,++ [Math.min(200, Math.max(1, Number(limit) || 100))],++ );++ return rows.map(wireReport);++ }++++ return {++ wireEntry, wireCitation, wireTemplate, wireCaption, wireReport,++ bySlug, byId, citations, liveCitations, list, trending, week, series, uniqueSlug,++ createCandidate, addCitations, enrich, reindex, publish, hide, patch,++ templates, templateById, createCaption, captionById, captionCountForTemplate,++ castVote, createReport, listOpenReports,++ KINDS, STATUSES, VISIBILITIES, BLOCKED_WORDS, MAX_LINES, MAX_LINE,++ };++}++++function slugify(title) {++ const s = String(title || '')++ .toLowerCase()++ .normalize('NFKD')++ .replace(/[\u0300-\u036f]/g, '')++ .replace(/[^a-z0-9]+/g, '-')++ .replace(/^-+|-+$/g, '')++ .slice(0, 70);++ return s || 'meme';++}++++module.exports = { createStore, slugify, patchFields, captionLines, wireEntry, wireCitation, wireTemplate, wireCaption, wireReport, SLUG_RE, KINDS, STATUSES, VISIBILITIES, BLOCKED_WORDS };nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++++/**++ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and MediaHub):++ * an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. A summary that++ * names looks at all is not repaired word by word — it is replaced by the entry's title, as Meme's plan says.++ * Verbatim quotes are never passed through this: rewriting a quote would falsify the citation.++ */++const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');++++/** The summary to store for an AI-written entry summary. */++function cleanSummary(title, summary) {++ const t = String(title || '').trim() || 'Untitled meme';++ const s = String(summary == null ? '' : summary).trim();++ if (!s) return t;++ if (hasLooks(s)) return t;++ const cleaned = String(noLooks(s) || '').trim();++ return cleaned || t;++}++++module.exports = { cleanSummary, noLooks, hasLooks };nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++++/**++ * The public reads OpenVibe.Meme makes: OpenVibe.Chat's global history and AI timeline, OpenVibe.Live's Rundown,++ * VOD rows and VOD transcripts, and OpenVibe.Video's canonical `?t=` redirect. No service grant is used in v1;++ * every call here is the same anonymous read a visitor can make, and a private or missing object answers 404++ * (Live and Chat both use 404, never 403). Nothing about a failed call is cached.++ */++const { isDenied } = require('./deny-list');++++/** "2026-10-28 00:11:53" (Live/Chat's shape) → an ISO instant, read as UTC when it carries no zone. */++function parseApiTime(v) {++ if (v == null || v === '') return null;++ if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v.toISOString();++ let s = String(v).trim();++ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) s = s.replace(' ', 'T') + 'Z';++ const d = new Date(s);++ return Number.isNaN(d.getTime()) ? null : d.toISOString();++}++++/** UTC "YYYY-MM-DD HH:mm:ss", the shape Chat's history `before` takes. */++function chatBefore(ms) {++ const d = new Date(ms);++ const p = (n) => String(n).padStart(2, '0');++ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;++}++++function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {++ const { chat, live, video, timeoutMs } = config.upstreams;++ const base = (u) => String(u).replace(/\/+$/, '');++++ async function call(url, { method = 'GET', redirect = 'follow' } = {}) {++ let res;++ try {++ res = await fetchImpl(url, {++ method, redirect,++ headers: { Accept: 'application/json, text/plain;q=0.5', 'User-Agent': 'OpenVibe.Meme/0.1 (+https://openvibe.meme)' },++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ throw Object.assign(new Error(`upstream unreachable: ${url}`), { code: 'upstream.unreachable', cause: err });++ }++ return res;++ }++++ async function json(url) {++ const res = await call(url);++ if (res.status === 404) return { notFound: true, status: 404 };++ if (!res.ok) throw Object.assign(new Error(`upstream ${res.status}: ${url}`), { code: 'upstream.status', status: res.status });++ const data = await res.json().catch(() => null);++ if (data == null) throw Object.assign(new Error(`upstream answered no JSON: ${url}`), { code: 'upstream.body' });++ return data;++ }++++ async function chatHistory({ before = null, limit = 200 } = {}) {++ const q = new URLSearchParams({ limit: String(Math.min(500, Math.max(1, limit))) });++ if (before) q.set('before', before);++ const data = await json(`${base(chat)}/api/chat/global/history?${q.toString()}`);++ if (data.notFound) return { messages: [], latest_id: null };++ return { messages: Array.isArray(data.messages) ? data.messages : [], latest_id: data.latest_id == null ? null : data.latest_id };++ }++++ async function chatTimeline({ limit = 60 } = {}) {++ const q = new URLSearchParams({ limit: String(Math.min(60, Math.max(1, limit))) });++ const data = await json(`${base(chat)}/api/chat/ai/timeline?${q.toString()}`);++ if (data.notFound) return { events: [] };++ return { events: Array.isArray(data.events) ? data.events : [], hasMore: Boolean(data.hasMore) };++ }++++ async function rundownTopics({ limit = 12 } = {}) {++ const q = new URLSearchParams({ limit: String(Math.max(1, limit)) });++ const data = await json(`${base(live)}/api/rundown/topics?${q.toString()}`);++ if (data.notFound) return [];++ return Array.isArray(data.topics) ? data.topics : [];++ }++++ async function rundownTopic(slug) {++ const data = await json(`${base(live)}/api/rundown/t/${encodeURIComponent(slug)}`);++ if (data.notFound) return null;++ return { topic: data.topic || null, takes: Array.isArray(data.takes) ? data.takes : [] };++ }++++ async function vod(id) {++ const data = await json(`${base(live)}/api/vods/${encodeURIComponent(id)}`);++ if (data.notFound) return null;++ return data.vod || null;++ }++++ async function transcripts(ids) {++ const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(String).filter(Boolean))];++ if (!wanted.length) return {};++ const q = new URLSearchParams({ ids: wanted.join(',') });++ const data = await json(`${base(live)}/api/chat-ai/vod-transcripts?${q.toString()}`);++ if (data.notFound) return {};++ const all = data.transcripts && typeof data.transcripts === 'object' ? data.transcripts : {};++ return Object.fromEntries(Object.entries(all).filter(([id]) => wanted.includes(String(id))));++ }++++ /**++ * The canonical public URL for a VOD second: follow OpenVibe.Video's own redirect (a joined multi-part recording++ * redirects `/v/6026?t=864` → `/v/6025?t=1541`, and the citation stores the target). A 404 means the VOD is++ * private or gone and must not be cited.++ */++ async function canonicalVideo(vodId, sec) {++ if (isDenied({ vod_id: vodId })) return { denied: true, gone: true };++ const offset = Number.isFinite(Number(sec)) ? Number(sec) : 0;++ const res = await call(`${base(video)}/v/${encodeURIComponent(vodId)}?t=${Math.max(0, Math.round(offset))}`, { redirect: 'manual' });++ if (res.status === 404 || res.status === 410) return { gone: true, status: res.status };++ if (res.status === 401 || res.status === 403) return { hidden: true, status: res.status };++ const loc = res.headers.get('location');++ if (res.status >= 300 && res.status < 400 && loc) {++ let u = null;++ try { u = new URL(loc, `${base(video)}/`); } catch { u = null; }++ if (!u) return { gone: true, status: res.status };++ const m = u.pathname.match(/\/v\/(\d+)\/?$/);++ const t = u.searchParams.get('t');++ const targetId = m ? Number(m[1]) : Number(vodId);++ const targetSec = t != null && t !== '' ? Number(t) : offset;++ if (isDenied({ vod_id: targetId })) return { denied: true, gone: true };++ return { url: u.toString(), vod_id: targetId, offset_seconds: Number.isFinite(targetSec) ? targetSec : offset, redirected: true };++ }++ if (res.ok) return { url: `${base(video)}/v/${encodeURIComponent(vodId)}?t=${Math.max(0, Math.round(offset))}`, vod_id: Number(vodId), offset_seconds: offset, redirected: false };++ throw Object.assign(new Error(`video ${vodId}@${offset} answered ${res.status}`), { code: 'upstream.status', status: res.status });++ }++++ /** A citation URL's current state: live, gone (404/410) or hidden (401/403). Other answers are an error. */++ async function citationUrl(url) {++ const res = await call(String(url), { redirect: 'follow' });++ if (res.status === 404 || res.status === 410) return { status: 'gone', http: res.status };++ if (res.status === 401 || res.status === 403) return { status: 'hidden', http: res.status };++ if (res.ok) return { status: 'live', http: res.status, finalUrl: res.url || null };++ throw Object.assign(new Error(`citation ${url} answered ${res.status}`), { code: 'upstream.status', status: res.status });++ }++++ /**++ * A chat line has no permalink API in v1 (research §2.1), so it is re-verified against the same public history++ * page: the message id must still be there and not deleted. Not finding it marks it gone; the message id is++ * what the citation stores.++ */++ async function chatMessage(citation) {++ const at = new Date(citation.observed_at).getTime();++ if (!Number.isFinite(at)) return { status: 'gone' };++ const wanted = String(citation.chat_message_id != null ? citation.chat_message_id : citation.source_id);++ // A public history page holds at most 500 lines, so walk back towards the citation's minute (a few pages++ // at most) rather than declaring a line gone just because a busy channel has moved it off page one.++ let before = chatBefore(at + 60_000);++ for (let page = 0; page < 5; page++) {++ const h = await chatHistory({ before, limit: 500 });++ const msg = h.messages.find((m) => String(m.id) === wanted);++ if (msg) return Number(msg.is_deleted) === 1 ? { status: 'hidden' } : { status: 'live', text: msg.message };++ const oldest = h.messages.length ? parseApiTime(h.messages[h.messages.length - 1].timestamp) : null;++ if (!oldest || Date.parse(oldest) <= at - 86_400_000) break;++ before = chatBefore(Date.parse(oldest) - 1000);++ }++ return { status: 'gone' };++ }++++ return {++ chatHistory, chatTimeline, rundownTopics, rundownTopic, vod, transcripts, canonicalVideo, citationUrl, chatMessage,++ base,++ };++}++++module.exports = { createUpstreams, parseApiTime, chatBefore };nnew file mode 100644@@@ -0,0 +1,254 @@++'use strict';++++/**++ * The Meme room's pages (plan 2026-10-28 §5.7). Server-rendered and whole without JavaScript: every action is a++ * plain form or link. public/js/meme.js is a one-comment placeholder the main session replaces; the preview and++ * the vote work without it.++ *++ * home({ trending, week }) trending, meme of the week, the generator teaser++ * entry({ e, citations, signedIn, staff }) the origin with its citation link, the use timeline, examples++ * week({ out, signedIn }) this week's candidates and the vote form++ * make({ templates, values, preview, signedIn }) the caption generator form++ * caption({ caption, template, mine }) one saved caption, text over the image++ * developers() the API++ * signIn({ title, text, next }) a page that needs an account++ * notFound()++ * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are++ * stable and start with `mm-`.++ */++const { esc, time } = require('../render/html');++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++++const STATUS_LABEL = { rising: 'Rising', peaked: 'Peaked', classic: 'Classic', dead: 'Dead' };++const KIND_LABEL = { joke: 'Joke', catchphrase: 'Catchphrase', bit: 'Bit', meme: 'Meme', saga: 'Saga' };++const SOURCE_LABEL = { chat_message: 'Chat', vod: 'VOD', clip: 'Clip', rundown_segment: 'The Rundown', ai_paste: 'Community', stream_memory: 'Stream memory' };++++const statusBadge = (s) => `<span class="mm-status mm-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;++const kindLabel = (k) => esc(KIND_LABEL[k] || k);++++function clock(seconds) {++ const n = Number(seconds);++ if (!Number.isFinite(n) || n < 0) return null;++ const s = Math.floor(n);++ const h = Math.floor(s / 3600);++ const m = Math.floor((s % 3600) / 60);++ const r = s % 60;++ return h ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`;++}++++function citation(c, { role = null } = {}) {++ const label = SOURCE_LABEL[c.source_type] || c.source_type;++ const at = clock(c.offset_seconds);++ const place = c.source_type === 'chat_message' ? `message ${esc(c.source_id)}` : at ? `at ${esc(at)}` : null;++ const state = c.source_status === 'live' ? '' : `<span class="mm-cite-state is-${esc(c.source_status)}">${esc(c.source_status === 'gone' ? 'source gone' : 'source hidden')}</span>`;++ return `<blockquote class="mm-cite mm-cite-${esc(c.role)}"${role ? ` data-role="${esc(role)}"` : ''} cite="${esc(c.url)}">++<p class="mm-quote">${esc(c.quote_text)}</p>++<footer class="mm-cite-foot"><span class="mm-cite-source">${esc(label)}</span>${place ? ` · <span class="mm-cite-place">${place}</span>` : ''} · <time datetime="${esc(c.observed_at)}">${esc(String(c.observed_at || '').slice(0, 10))}</time> · <a class="mm-cite-link" href="${esc(c.url)}" rel="nofollow noopener">the moment</a>${state}</footer>++</blockquote>`;++}++++function card(e) {++ return `<li class="mm-entry-card" data-entry="${esc(e.slug)}">++<a class="mm-entry-link" href="/m/${esc(e.slug)}">++<span class="mm-entry-title">${esc(e.title)}</span>++<span class="mm-entry-meta">${kindLabel(e.kind)} · ${statusBadge(e.status)}${e.votes != null ? ` · <strong class="mm-votes">${Number(e.votes)}</strong> ${Number(e.votes) === 1 ? 'vote' : 'votes'}` : ''}</span>++<span class="mm-entry-when">last use <time datetime="${esc(e.last_seen_at)}">${esc(String(e.last_seen_at || '').slice(0, 10))}</time></span>++</a></li>`;++}++++function empty(text, href = '/week', label = 'This week’s ballot') {++ return `<div class="mm-empty"><p>${esc(text)}</p><a class="mm-btn mm-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;++}++++/** The room's picture: a meme's family tree growing from its first moment, stickers fanning off the branches. */++function treeMotif() {++ return `<div class="mm-tree" aria-hidden="true">++<svg class="mm-tree-lines" viewBox="0 0 400 280" preserveAspectRatio="none"><path d="M200 78 C200 128 92 118 92 176"/><path d="M200 78 L200 176"/><path d="M200 78 C200 128 308 118 308 176"/><path d="M92 206 C92 236 52 232 52 258"/><path d="M92 206 C92 236 132 232 132 258"/><path d="M308 206 C308 236 348 232 348 258"/></svg>++<span class="mm-node mm-node-root"><b>Cozmo is offline</b><small>chat · the first time</small></span>++<span class="mm-node mm-n1">VOD · 25:41</span><span class="mm-node mm-n2">a clip</span><span class="mm-node mm-n3">the Rundown</span>++<span class="mm-sticker mm-s1">classic</span><span class="mm-sticker mm-s2">lol</span><span class="mm-sticker mm-s3">rising</span>++</div>`;++}++++function home({ trending = [], week = null, counts = {} } = {}) {++ const candidates = week ? week.candidates.slice(0, 3) : [];++ const live = `<section class="mm-section" id="mm-trending" aria-labelledby="mm-trending-h">++<div class="mm-head-row"><h2 id="mm-trending-h">Trending</h2><span class="mm-count">${Number(counts.entries) || trending.length} public ${Number(counts.entries) === 1 ? 'entry' : 'entries'}</span></div>++${trending.length ? `<ol class="mm-entries">${trending.slice(0, 8).map(card).join('')}</ol>` : empty('No entry is public yet. The first proof is being checked against its source.', '/developers', 'How entries are built')}++</section>++<section class="mm-section" id="mm-week-teaser" aria-labelledby="mm-week-teaser-h">++<div class="mm-head-row"><h2 id="mm-week-teaser-h">Meme of the week</h2><a class="mm-more" href="/week">The ballot${kit.icon('chevron')}</a></div>++${candidates.length ? `<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>` : empty('No candidate has a use this week yet. The ballot opens as soon as one does.')}++</section>`;++ const room = sc.room({++ room: 'meme',++ hero: {++ title: 'Every joke has a first time',++ lede: 'The open meme database. Each running joke is written down with the moment it happened, a chat line, a VOD second, a Rundown segment, and every later use. People vote for meme of the week, and anyone can caption a template.',++ start: { href: '/week', label: 'Vote for meme of the week' },++ chips: [{ label: 'Make a caption', href: '/make' }, { label: 'Trending', href: '#mm-trending' }, { label: 'The API', href: '/developers' }],++ proof: Number(counts.entries) ? [{ value: Number(counts.entries), label: Number(counts.entries) === 1 ? 'entry' : 'entries' }] : [],++ motif: treeMotif(),++ },++ scenes: [++ { title: 'A stream moment becomes an entry', text: 'An entry starts where the joke did: the chat line or the second of the VOD, quoted word for word and linked. A source that disappears is marked, and an entry without its origin goes back into hiding.',++ html: `<blockquote class="mm-cite mm-cite-origin mm-demo-cite"><p class="mm-quote">but yeah and Cosmo is broken now</p><footer class="mm-cite-foot"><span class="mm-cite-source">VOD</span> · at 25:41 · Oct 26 · <span class="mm-cite-link">the moment</span></footer></blockquote>`,++ action: { href: '/developers', label: 'How entries are built' } },++ { title: 'Watch a joke rise', text: 'Every use is dated, so you can see a joke catch on, peak and become a classic. Rising first on the trending list.',++ html: `<div class="mm-demo-rise"><span class="mm-status mm-status-rising">Rising</span><span class="mm-demo-bars">${[2, 3, 2, 4, 6, 5, 8, 11, 9, 14].map((h) => `<i style="--h:${h}"></i>`).join('')}</span><small>uses per day, last 10 days</small></div>`,++ action: { href: '/week', label: 'See this week\'s ballot' } },++ { title: 'Caption it', text: 'Pick a template, type up to four lines and see them over the picture. A caption is saved as text, so anyone can remix it.',++ html: `<figure class="mm-caption-card mm-demo-caption"><img src="/img/template-classic.svg" alt=""><figcaption><span>when the robot is offline</span><span>and chat has to cope</span></figcaption></figure>`,++ action: { href: '/make', label: 'Open the generator' } },++ ],++ faq: { items: [++ ['Where do the entries come from?', 'From OpenVibe itself: the Rundown, VOD transcripts and the public chat. An importer proposes a candidate when a phrase comes back on more than one day; a person checks it against its source before it is public.'],++ ['Can I get something taken down?', 'Yes. Every entry has a takedown and correction form for signed-in people, and staff answer it. VOD 5709 and clip 483 are never cited.'],++ ['Is the database open?', 'Yes: every public entry and citation is in the API, and in OpenVibe\'s Search.'],++ ] },++ });++ const at = room.indexOf('<section class="sc-room-scenes"');++ return `<div class="mm-wrap mm-home" id="mm-home">${at < 0 ? room : room.slice(0, at) + live + room.slice(at)}</div>`;++}++++function entry({ e, citations = [], signedIn = false, staff = false, reported = false, problem = null } = {}) {++ const origin = citations.find((c) => c.role === 'origin') || citations[0] || null;++ const uses = citations.filter((c) => c !== origin);++ const examples = citations.filter((c) => c.role === 'example');++ const others = uses.filter((c) => c.role !== 'example');++ return `<div class="mm-wrap" id="mm-entry" data-entry="${esc(e.slug)}">++<article class="mm-entry">++<header class="mm-entry-h">++<p class="mm-kicker">${kindLabel(e.kind)} · ${statusBadge(e.status)}${staff ? ' · <span class="mm-staff">staff view</span>' : ''}</p>++<h1>${esc(e.title)}</h1>++${e.summary ? `<p class="mm-summary">${esc(e.summary)}</p>` : ''}++<dl class="mm-facts">++<div><dt>First seen</dt><dd><time datetime="${esc(e.first_seen_at)}">${esc(String(e.first_seen_at || '').slice(0, 10))}</time></dd></div>++<div><dt>Last seen</dt><dd><time datetime="${esc(e.last_seen_at)}">${esc(String(e.last_seen_at || '').slice(0, 10))}</time></dd></div>++<div><dt>Uses cited</dt><dd>${citations.length}</dd></div>++<div><dt>Revision</dt><dd>${Number(e.revision) || 1}</dd></div>++</dl>++</header>++++<section class="mm-origin" id="mm-origin" aria-labelledby="mm-origin-h">++<h2 id="mm-origin-h">Where it started</h2>++${origin ? citation(origin) : '<p class="mm-muted">No origin citation is attached yet.</p>'}++</section>++++<section class="mm-timeline-sec" id="mm-timeline" aria-labelledby="mm-timeline-h">++<h2 id="mm-timeline-h">The use timeline</h2>++${others.length ? `<ol class="mm-timeline">${others.map((c) => `<li class="mm-timeline-item"><time class="mm-timeline-when" datetime="${esc(c.observed_at)}">${esc(String(c.observed_at || '').slice(0, 10))}</time>${citation(c)}</li>`).join('')}</ol>` : '<p class="mm-muted">No later use is cited yet. The timeline grows as the importer finds repeats.</p>'}++</section>++++${examples.length ? `<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2>${examples.map((c) => citation(c)).join('')}</section>` : '<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2><p class="mm-muted">No example is attached yet.</p></section>'}++++<footer class="mm-entry-foot">++${reported ? '<p class="mm-note" role="status">Thank you. Staff will look at the report.</p>' : ''}++${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}++${signedIn ? `<details class="mm-report"><summary>Report a correction or takedown</summary>++<form method="post" action="/m/${esc(e.slug)}/report" class="mm-form mm-form-inline">++<label class="mm-field"><span>What is wrong?</span><select name="kind"><option value="takedown">A takedown</option><option value="correction">A correction</option></select></label>++<label class="mm-field"><span>Reason</span><input name="reason" type="text" maxlength="2000" required placeholder="The source is gone, the quote is wrong…"></label>++<label class="mm-field"><span>Note (optional)</span><textarea name="note" rows="2" maxlength="4000"></textarea></label>++<button class="mm-btn mm-btn-quiet" type="submit">Send the report</button>++</form></details>` : `<p class="mm-muted"><a href="/auth/login?next=${encodeURIComponent(`/m/${e.slug}`)}">Sign in</a> to report a correction or takedown.</p>`}++</footer>++</article>++</div>`;++}++++function week({ out, signedIn = false, voted = false, error = false } = {}) {++ const candidates = out ? out.candidates : [];++ return `<div class="mm-wrap" id="mm-week">++<header class="mm-page-h"><p class="mm-kicker">Meme of the week</p><h1>The ballot</h1>++<p class="mm-lede">One vote a week, per person. A vote can change until the week ends; the week is ${esc(out ? out.week : '')} (Monday to Sunday, UTC).</p></header>++${voted ? '<p class="mm-note" role="status">Your vote is counted.</p>' : ''}++${error ? '<p class="mm-note is-bad" role="alert">That vote could not be counted. Try again from the ballot.</p>' : ''}++${signedIn ? '' : '<p class="mm-note">You can read the ballot without an account. <a href="/auth/login?next=%2Fweek">Sign in</a> to vote.</p>'}++${candidates.length ? `<ol class="mm-candidates">${candidates.map((c) => `<li class="mm-candidate${c.mine ? ' is-mine' : ''}" data-entry="${esc(c.slug)}">++<div class="mm-candidate-body"><a class="mm-candidate-title" href="/m/${esc(c.slug)}">${esc(c.title)}</a><span class="mm-candidate-meta">${kindLabel(c.kind)} · ${statusBadge(c.status)}</span></div>++<div class="mm-candidate-vote"><strong class="mm-vote-count">${Number(c.votes) || 0}</strong><span>${Number(c.votes) === 1 ? 'vote' : 'votes'}</span>++${signedIn ? `<form method="post" action="/week/vote" class="mm-inline"><input type="hidden" name="entry_id" value="${esc(c.id)}"><button class="mm-btn mm-btn-small${c.mine ? ' is-mine' : ''}" type="submit">${c.mine ? 'Your vote' : 'Vote'}</button></form>` : ''}++</div></li>`).join('')}</ol>` : empty('No candidate has a use this week yet.')}++<p class="mm-fine">A candidate is a public entry with at least one live use in this ISO week. The count is the number of people who chose it, not a score you can buy.</p>++</div>`;++}++++const LINES = [1, 2, 3, 4];++++function make({ templates = [], values = {}, preview = null, signedIn = false, problem = null } = {}) {++ const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };++ const lines = LINES.map((i) => v[`line${i}`] || '');++ return `<div class="mm-wrap" id="mm-make">++<header class="mm-page-h"><p class="mm-kicker">The caption generator</p><h1>Put words on a template</h1>++<p class="mm-lede">Pick a template and type up to four lines, ${LINES.length * 120} characters at most. The preview is HTML text over the image; saving stores the lines, never a picture.</p></header>++${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}++<div class="mm-make-grid">++<form id="mm-caption-form" class="mm-form mm-card" method="post" action="/make">++<fieldset class="mm-templates"><legend>Template</legend>++${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}++</fieldset>++${LINES.map((i) => `<label class="mm-field"><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when the robot is offline' : ''}"></label>`).join('')}++<label class="mm-field"><span>Who can see it?</span><select name="visibility"><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Anyone</option><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>++<div class="mm-actions"><button class="mm-btn" type="submit">Save the caption</button><button class="mm-btn mm-btn-quiet" type="submit" formaction="/make" formmethod="get">Preview</button></div>++${signedIn ? '' : '<p class="mm-fine">You can preview without signing in. <a href="/auth/login?next=%2Fmake">Sign in</a> to save a caption.</p>'}++</form>++<aside class="mm-preview" id="mm-preview" aria-labelledby="mm-preview-h"><h2 id="mm-preview-h">Preview</h2>++${preview ? `<figure class="mm-caption-card"><img src="${esc(preview.template.image_url)}" alt=""><figcaption>${preview.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption></figure><p class="mm-fine">Text only, over ${esc(preview.template.name)}. Nothing is saved until you press Save.</p>` : '<p class="mm-muted">Press Preview to see your lines over the template.</p>'}++</aside>++</div>++</div>`;++}++++function caption({ caption: c, template, mine = false } = {}) {++ return `<div class="mm-wrap" id="mm-caption" data-caption="${esc(c.id)}">++<header class="mm-page-h"><p class="mm-kicker">A saved caption</p><h1>${esc(template ? template.name : 'Caption')}</h1>++<p class="mm-lede">${mine ? 'Yours. ' : ''}Text over the template image, saved as lines — never as a rendered picture.</p></header>++<figure class="mm-caption-card mm-caption-large">++${template ? `<img src="${esc(template.image_url)}" alt="">` : ''}++<figcaption>${c.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption>++</figure>++<dl class="mm-facts"><div><dt>Saved</dt><dd>${time(c.created_at)}</dd></div><div><dt>Visibility</dt><dd>${esc(c.visibility)}</dd></div><div><dt>Caption</dt><dd><code>${esc(c.id)}</code></dd></div></dl>++<div class="mm-actions"><a class="mm-btn" href="/make">Make another</a><a class="mm-btn mm-btn-quiet" href="/">Back to the memes</a></div>++</div>`;++}++++function signIn({ title, text, next }) {++ return `<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="mm-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;++}++++function developers() {++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;++ return `<div class="mm-wrap" id="mm-developers">++<header class="mm-page-h"><p class="mm-kicker">Developers</p><h1>The Meme API</h1>++<p class="mm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>meme_at</code> session cookie or a Network user token as a Bearer.</p></header>++<section><h2>Read</h2><dl class="mm-eps">++${ep('GET', '/api/v1/entries?limit=&cursor=&kind=&status=', 'Public entries, newest first, cursor-paged (at most 50).')}++${ep('GET', '/api/v1/entries/:slug', 'One entry with every citation and the use timeline. A hidden one is 404 unless you are staff.')}++${ep('GET', '/api/v1/entries/:slug/series?days=30', 'Uses per UTC day, zero-filled (1–365 days).')}++${ep('GET', '/api/v1/trending', 'Public entries, rising first.')}++${ep('GET', '/api/v1/week', 'This ISO week’s candidates and their vote counts.')}++${ep('GET', '/api/v1/templates', 'Caption templates: image, box count, source and license.')}++${ep('GET', '/api/v1/captions/:id', 'One saved caption (a hidden one is 404 unless it is yours or you are staff).')}++</dl></section>++<section><h2>Write (signed in)</h2><dl class="mm-eps">++${ep('POST', '/api/v1/week/vote', '<code>{ entry_id }</code>. One vote a week per person; a second call changes the choice. Same-origin.')}++${ep('POST', '/api/v1/captions', '<code>{ template_id, lines, visibility? }</code>: 1–4 lines, each at most 120 characters, text only, blocked words refused.')}++${ep('POST', '/api/v1/entries/:slug/report', '<code>{ kind: "takedown"|"correction", reason, note? }</code> for staff review.')}++</dl></section>++<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="mm-eps">++${ep('POST', '/api/v1/entries/:slug/publish', 'Make a hidden candidate public. Refused when its origin citation is gone (409).')}++${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tombstone its Search document.')}++${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}++${ep('GET', '/api/v1/reports', 'The open takedown/correction reports.')}++</dl></section>++<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code>; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section>++</div>`;++}++++function notFound() {++ return `<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty" id="mm-not-found"><p class="mm-kicker">404</p><h1>This meme is not here</h1><p>It may have been hidden, its origin source may be gone, or the address was never an entry.</p><a class="mm-btn" href="/">Back to the memes</a></div></div>`;++}++++module.exports = { home, entry, week, make, caption, signIn, developers, notFound, statusBadge, kindLabel, citation, clock, STATUS_LABEL, KIND_LABEL, SOURCE_LABEL };nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++++/**++ * Meme of the week: ISO-8601 weeks in UTC, `YYYY-Www`. Monday 00:00 UTC is the start and the next Monday 00:00++ * UTC is the end, so a vote never straddles a week at midnight in one zone and not another. `weekBounds` answers++ * the instants the database compares against; a week label with the wrong number of days or W00/W54 is refused.++ */++const WEEK_RE = /^(\d{4})-W(0[1-9]|[1-4][0-9]|5[0-3])$/;++++function utcDay(d) {++ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));++}++++/** The Monday of the ISO week containing `date` (00:00:00.000 UTC). */++function mondayOf(date) {++ const d = utcDay(date instanceof Date ? date : new Date(date));++ const day = d.getUTCDay() || 7; // Sunday 0 → 7++ d.setUTCDate(d.getUTCDate() - (day - 1));++ return d;++}++++/** The ISO week label of an instant. */++function isoWeek(date = new Date()) {++ const d = utcDay(date instanceof Date ? date : new Date(date));++ const day = d.getUTCDay() || 7;++ d.setUTCDate(d.getUTCDate() + 4 - day); // the Thursday decides the week's year++ const isoYear = d.getUTCFullYear();++ const yearStart = new Date(Date.UTC(isoYear, 0, 1));++ const week = Math.ceil((((d - yearStart) / 86_400_000) + 1) / 7);++ return `${isoYear}-W${String(week).padStart(2, '0')}`;++}++++/** { start, end } as ISO instants for a label; null when it is not a real ISO week. */++function weekBounds(week) {++ const m = WEEK_RE.exec(String(week || ''));++ if (!m) return null;++ const year = Number(m[1]);++ const number = Number(m[2]);++ const jan4 = new Date(Date.UTC(year, 0, 4));++ const start = mondayOf(jan4);++ start.setUTCDate(start.getUTCDate() + (number - 1) * 7);++ // A label in week 53 of a year that has 52 weeks is not a real week.++ const check = isoWeek(start);++ if (check !== `${m[1]}-W${m[2]}`) return null;++ const end = new Date(start.getTime() + 7 * 86_400_000);++ return { start: start.toISOString(), end: end.toISOString(), startDate: start, endDate: end };++}++++/** The current week's label and bounds. */++function currentWeek(now = Date.now()) {++ const week = isoWeek(new Date(now));++ return { week, ...weekBounds(week) };++}++++module.exports = { WEEK_RE, isoWeek, weekBounds, currentWeek, mondayOf };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++/**++ * ADR-033: Meme's part of an account export (votes, captions, reports) and of a deletion, applied through++ * /internal/events with a stand-in Network. A deletion erases the person's rows and leaves other people's, the++ * entries themselves and the Search outbox alone. A redelivery changes nothing; a bad signature and a proxied++ * request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = ids.newId('user');++const B = ids.newId('user');++const EXP = `exp_${ids.ulid()}`;++const DEL = `del_${ids.ulid()}`;++const SECRET = `fixture_${'account'.repeat(6)}`;++const WRONG = `fixture_${'mismatch'.repeat(6)}`;++++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_meme', token_type: 'Bearer', expires_in: 300 });++ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });++ return json(201, {});++ });++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const stub = await startNetworkStub();++ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'meme', clientSecret: 'meme-secret' });++ const t = await boot({ env: { MEME_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const eid = `mem_${ids.ulid()}`;++ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at) VALUES ($1, 'account-entry', 'Account entry', 'joke', 'public', $2, $2)`, [eid, at]);++ const capA = `mcp_${ids.ulid()}`;++ const capB = `mcp_${ids.ulid()}`;++ await db.exec(`INSERT INTO meme_captions (id, subject, template_id, lines) VALUES ($1, $2, 'tpl_classic', '["A caption"]'::jsonb), ($3, $4, 'tpl_classic', '["B caption"]'::jsonb)`, [capA, A, capB, B]);++ await db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2), ('2026-W44', $1, $3)", [eid, A, B]);++ const repA = `mrp_${ids.ulid()}`;++ const repB = `mrp_${ids.ulid()}`;++ await db.exec(`INSERT INTO meme_reports (id, entry_id, subject, kind, reason) VALUES ($1, $2, $3, 'correction', 'A report'), ($4, $2, $5, 'takedown', 'B report')`, [repA, eid, A, repB, B]);++++ await check('an export carries only A\'s votes, captions and reports', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'exported');++ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);++ assert.ok(part, 'the part was pushed to Network');++ assert.strictEqual(part.auth, 'Bearer tok_meme');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['captions.json', 'reports.json', 'votes.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('A caption'));++ assert.ok(!all.includes('B caption'), 'nobody else\'s caption');++ assert.ok(all.includes('A report'));++ assert.ok(!all.includes('B report'));++ assert.ok(!all.includes('Account entry'), 'an entry is not the person\'s row');++ const votes = part.body.files.find((f) => f.name === 'votes.json').content;++ assert.deepStrictEqual(votes.map((v) => v.subject), [A]);++ const captions = part.body.files.find((f) => f.name === 'captions.json').content;++ assert.deepStrictEqual(captions.map((c) => c.id), [capA]);++ });++++ await check('a deletion erases A\'s rows and confirms with counts; B and the entry stay', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_votes WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_reports WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_votes WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_reports WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_entries WHERE id = $1', [eid])), 1);++ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);++ assert.ok(confirmation, 'the confirmation was sent');++ assert.ok(confirmation.body.erased.meme_votes >= 1);++ assert.ok(confirmation.body.erased.meme_captions >= 1);++ assert.ok(confirmation.body.erased.meme_reports >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'unchanged');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [A])), 0);++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,72 @@++'use strict';++/**++ * The JSON API's read shapes: cursor paging, trending order, one entry with its citations and timeline, an++ * unlisted entry reachable by slug but absent from the public list, and the problem+json validation refusals.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const now = new Date().toISOString();++ const mk = (slug, title, status, visibility = 'public', first = now) => t.ctx.entries.createCandidate({++ slug, title, kind: 'joke', summary: 'A test entry.', status, first_seen_at: first, last_seen_at: now, visibility,++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(Math.floor(Math.random() * 1e9)), chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: now }],++ });++ await mk('m-one', 'One', 'classic', 'public', new Date(Date.now() - 5 * 86_400_000).toISOString());++ await mk('m-two', 'Two', 'rising', 'public', new Date(Date.now() - 4 * 86_400_000).toISOString());++ await mk('m-three', 'Three', 'peaked', 'public', new Date(Date.now() - 3 * 86_400_000).toISOString());++ const unlisted = await mk('m-unlisted', 'Unlisted', 'rising', 'unlisted', new Date(Date.now() - 2 * 86_400_000).toISOString());++++ await check('GET /entries pages with an opaque cursor and refuses a garbage one', async () => {++ const one = await t.get('/api/v1/entries?limit=2');++ assert.strictEqual(one.status, 200);++ assert.strictEqual(one.json().count, 2);++ assert.ok(one.json().entries.every((e) => e.visibility === 'public'));++ assert.ok(one.json().next_cursor);++ const two = await t.get(`/api/v1/entries?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);++ assert.strictEqual(two.status, 200);++ assert.strictEqual(two.json().count, 1);++ assert.strictEqual(two.json().next_cursor, null);++ assert.strictEqual((await t.get('/api/v1/entries?cursor=garbage')).status, 400);++ assert.strictEqual((await t.get('/api/v1/entries?kind=nope')).status, 400);++ });++++ await check('GET /trending puts rising first', async () => {++ const r = await t.get('/api/v1/trending');++ assert.strictEqual(r.status, 200);++ assert.ok(r.json().count >= 1);++ assert.strictEqual(r.json().entries[0].status, 'rising');++ });++++ await check('one entry has its origin and the timeline; an unlisted entry is direct-only', async () => {++ const r = await t.get('/api/v1/entries/m-two');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().slug, 'm-two');++ assert.ok(r.json().origin);++ assert.strictEqual(r.json().origin.role, 'origin');++ assert.strictEqual(r.json().timeline.length, 1);++ assert.ok(Array.isArray(r.json().citations));++ const ul = await t.get('/api/v1/entries/m-unlisted');++ assert.strictEqual(ul.status, 200);++ assert.strictEqual(ul.headers.get('cache-control'), 'private, max-age=60');++ const listed = await t.get('/api/v1/entries?limit=50');++ assert.ok(!listed.json().entries.some((e) => e.slug === 'm-unlisted'));++ assert.strictEqual((await t.get('/api/v1/entries/m-missing')).status, 404);++ });++++ await check('problem+json is used for a malformed body and an unknown route', async () => {++ const bad = await t.get('/api/v1/week/vote', { as: t.network.addUser('voter'), body: '{', headers: { 'content-type': 'application/json' } });++ assert.strictEqual(bad.status, 400);++ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(bad.json().code, 'request.malformed_json');++ const nope = await t.get('/api/v1/nope');++ assert.strictEqual(nope.status, 404);++ assert.strictEqual(nope.json().code, 'route.not_found');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,83 @@++'use strict';++/**++ * Captions: text only, 1–4 lines of at most 120 characters, a small blocked-word list, an existing template, a++ * signed-in same-origin write. A public caption never returns its author's subject to an anonymous reader; a++ * hidden caption is 404 to anyone but its owner and staff. The table holds no image column at all.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const templates = (await t.get('/api/v1/templates')).json().templates;++ const tpl = templates[0];++++ await check('a signed-in caption is saved as lines and returned with its template', async () => {++ const r = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['when the robot', 'is offline'] } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual(r.headers.get('location'), `/api/v1/captions/${r.json().id}`);++ const c = r.json();++ assert.deepStrictEqual(c.lines, ['when the robot', 'is offline']);++ const got = await t.get(`/api/v1/captions/${c.id}`, { as: alice });++ assert.strictEqual(got.status, 200);++ assert.strictEqual(got.json().subject, alice.subject);++ assert.strictEqual(got.json().mine, true);++ assert.strictEqual(got.json().template.id, tpl.id);++ assert.strictEqual(got.headers.get('cache-control'), 'private, no-store');++ });++++ await check('an anonymous read does not expose the subject', async () => {++ const c = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['public'] } })).json();++ const r = await t.get(`/api/v1/captions/${c.id}`);++ assert.strictEqual(r.status, 200);++ assert.ok(!('subject' in r.json()));++ assert.strictEqual(r.json().mine, false);++ });++++ await check('line count, line length, empty lines, blocked words and the template are enforced', async () => {++ const tooMany = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['1', '2', '3', '4', '5'] } });++ assert.strictEqual(tooMany.status, 400);++ assert.strictEqual(tooMany.json().code, 'meme.invalid');++ const long = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['x'.repeat(121)] } });++ assert.strictEqual(long.status, 400);++ const empty = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['ok', ' '] } });++ assert.strictEqual(empty.status, 400);++ const blocked = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['this is kys'] } });++ assert.strictEqual(blocked.status, 400);++ assert.strictEqual(blocked.json().code, 'meme.blocked_words');++ const noTpl = await t.get('/api/v1/captions', { as: alice, json: { template_id: 'tpl_missing', lines: ['ok'] } });++ assert.strictEqual(noTpl.status, 404);++ });++++ await check('a signed-out, cross-site or non-array caption is refused', async () => {++ assert.strictEqual((await t.get('/api/v1/captions', { json: { template_id: tpl.id, lines: ['ok'] } })).status, 401);++ const cross = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['ok'] }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'meme.cross_site');++ const notArray = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: 'one line' } });++ assert.strictEqual(notArray.status, 400);++ });++++ await check('a hidden caption is readable only by its owner and staff', async () => {++ const c = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['secret'], visibility: 'hidden' } })).json();++ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: alice })).status, 200);++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: mod })).status, 200);++ });++++ await check('meme_captions stores no image and no rendered bytes', async () => {++ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'meme_captions'")).map((r) => r.column_name);++ assert.ok(!cols.some((c) => /image|blob|bytes|photo/i.test(c)), cols.join(','));++ const row = await t.ctx.s.db.maybe('SELECT lines FROM meme_captions LIMIT 1');++ assert.ok(Array.isArray(row.lines));++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,34 @@++'use strict';++/**++ * The fixed deny list: VOD 5709 and clip 483 are never cited. Both the unit helper and an importer run over a++ * fixture take that names VOD 5709 are checked, and no row may carry either id.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const deny = require('../server/deny-list');++++(async () => {++ await check('isDenied knows both ids, in wire and database shapes', async () => {++ assert.strictEqual(deny.isDenied({ vod_id: 5709 }), true);++ assert.strictEqual(deny.isDenied({ vodId: '5709' }), true);++ assert.strictEqual(deny.isDenied({ clip_id: 483 }), true);++ assert.strictEqual(deny.isDenied({ clipId: '483' }), true);++ assert.strictEqual(deny.isDenied({ vod_id: 6026, clip_id: 518 }), false);++ assert.strictEqual(deny.allowed([{ vod_id: 5709 }, { vod_id: 6026 }, { clip_id: 483 }]).length, 1);++ });++++ const t = await boot();++ try {++ await check('the importer never writes a citation for VOD 5709, even from a Rundown take', async () => {++ const r = await t.ctx.importer.runOnce();++ assert.strictEqual(r.errors.length, 0, JSON.stringify(r.errors));++ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483');++ assert.strictEqual(Number(n), 0);++ const all = await t.ctx.s.db.many('SELECT source_id, vod_id, quote_text FROM meme_citations');++ assert.ok(!all.some((c) => c.quote_text.includes('must never become a citation')));++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,100 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.MEME_RATE_LIMIT_PER_MIN = process.env.MEME_RATE_LIMIT_PER_MIN || '100000';++process.env.MEME_API_RATE_LIMIT_PER_MIN = process.env.MEME_API_RATE_LIMIT_PER_MIN || '100000';++process.env.MEME_LIMITS_MINUTE = process.env.MEME_LIMITS_MINUTE || '100000';++process.env.MEME_LIMITS_HOUR = process.env.MEME_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Meme on a temp database with a mock Network (test/helpers/mocks.js) and public upstreams++ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client. Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })++ * t.signIn(user) → cookie header value (meme_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork } = require('./mocks');++const { startUpstreams } = require('./upstreams');++++const captured = [];++for (const m of ['log', 'info', 'warn', 'error']) {++ const orig = console[m].bind(console);++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };++}++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const upstreams = await startUpstreams(opts.upstreams || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.meme', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'meme', OV_OAUTH_CLIENT_SECRET: 'meme-secret', COOKIE_SECURE: 'false',++ MEME_DRILL: '1',++ MEME_EVENTS_URL: network.url,++ OV_CHAT_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_VIDEO_URL: upstreams.url,++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };++++ const config = configLib.load(env);++ const { createStore } = require('../../server/db');++ const testdb = await require('./db').testDb();++ const store = createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `meme_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ built.ctx.importer.stop();++ built.ctx.reverifier.stop();++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await built.ctx.telemetry.stop().catch(() => {});++ await testdb.close();++ await network.close();++ await upstreams.close();++ },++ };++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * MEME_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.++ */++const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');++const { MIGRATIONS } = require('../../server/db');++++const testDb = ({ store = process.env.MEME_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'meme', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,137 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `meme`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Meme, kept for parity);++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.++ *++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function signJwt(claims, privateKey) {++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };++ let issuer = null;++ let nextUserId = 1;++++ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });++++ function addUser(username, { role = 'user' } = {}) {++ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };++ st.users.set(u.id, u); st.byUsername.set(username, u);++ return u;++ }++ function userToken(u, { ttl = 3600, iat = null } = {}) {++ const now = iat != null ? iat : Math.floor(Date.now() / 1000);++ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);++ }++ function userFrom(req) {++ const h = String(req.headers.authorization || '');++ if (!h.startsWith('Bearer ')) return null;++ const parts = h.slice(7).split('.');++ if (parts.length !== 3) return null;++ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));++ if (!ok) return null;++ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));++ if (c.exp * 1000 < Date.now()) return 'expired';++ return st.users.get(c.sub) || null;++ }++++ const srv = await listen(async (req, raw, json) => {++ const url = new URL(req.url, 'http://x');++ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });++ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');++++ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });++ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });++ if (url.pathname === '/oauth/revoke') return json(200, {});++ if (url.pathname === '/oauth/token' && req.method === 'POST') {++ const ct = String(req.headers['content-type'] || '');++ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));++ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });++ if (body.client_id !== 'meme' || body.client_secret !== 'meme-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });++ if (body.grant_type === 'authorization_code') {++ const c = st.codes.get(body.code);++ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });++ st.codes.delete(body.code);++ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');++ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, c.user);++ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'refresh_token') {++ const user = st.refresh.get(body.refresh_token);++ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });++ st.refresh.delete(body.refresh_token);++ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;++ st.refresh.set(rt, user);++ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });++ }++ if (body.grant_type === 'client_credentials') {++ const now = Math.floor(Date.now() / 1000);++ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);++ return json(200, {++ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:meme', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),++ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),++ });++ }++ return json(400, { error: 'unsupported_grant_type' });++ }++++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {++ const u = userFrom(req);++ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');++ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');++ return json(200, { items: [], next_cursor: null });++ }++++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setDown(v) { st.down = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,166 @@++'use strict';++++/**++ * In-process stand-ins for OpenVibe.Chat, OpenVibe.Live and OpenVibe.Video on one HTTP server. The boot helper++ * points OV_CHAT_URL, OV_LIVE_URL and OV_VIDEO_URL at it.++ *++ * The default fixture is a small running joke ("the robot is offline"): two Chat lines on two days, a Rundown++ * quote and its VOD transcript segment on a third, and the joined-recording redirect `/v/9001?t=60` →++ * `/v/9002?t=120`. It also carries the Cozmo proof fixture (message 86957, VOD 6026 at 864.08 s) and a take that++ * names the fixed deny list (VOD 5709) so both scripts can be exercised without the network.++ *++ * A test changes state.video[<vod>] = { status: 404 } to make a citation's URL vanish, or reads state.requests.++ */++const http = require('http');++++const DEFAULT_MESSAGES = [++ { id: 1001, stream_id: null, username: 'ada', message: 'the robot is offline again', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-20 10:00:00' },++ { id: 1002, stream_id: null, username: 'bo', message: 'still the robot is offline, huh', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-22 11:00:00' },++ { id: 86957, stream_id: null, username: 'Little_Buddy', message: 'o/ Maticus, you still got that same vibe. What’s good? The world’s still spinning, but your Cozmo ain’t online yet. (Or is it?)', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-28 00:11:53' },++ { id: 86960, stream_id: null, username: 'ada', message: 'the robot is offline and the coffee is cold', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-23 09:00:00' },++ { id: 86961, stream_id: null, username: 'bo', message: 'deleted line', message_type: 'chat', is_deleted: 1, timestamp: '2026-10-23 09:01:00' },++];++++const DEFAULT_TIMELINE = [++ { id: 2598, ts: '2026-10-28 00:13:06', label: 'Robot offline bit continues', detail: 'Chat keeps the robot-offline joke going.' },++ { id: 2597, ts: '2026-10-28 00:12:06', label: 'Cozmo phones home — not online', detail: 'Conversation humour around Cozmo not being online.' },++];++++const DEFAULT_TOPICS = [++ { slug: 'robot-test', title: 'ROBOT TEST', kind: 'games', headline: 'The robot test', summary: 'A robot test.', first_seen_at: '2026-10-20 09:00:00', last_seen_at: '2026-10-22 13:00:00' },++ { slug: 'looi-robot-test', title: 'LOOI ROBOT TEST', kind: 'games', headline: 'LOOI robot test', summary: 'A robot test.', first_seen_at: '2026-10-23 21:14:44', last_seen_at: '2026-10-26 21:30:52' },++];++++const DEFAULT_TAKES = {++ 'robot-test': [++ { type: 'segment', id: 501, title: 'The robot is offline', what: 'The robot is offline.', take: 'It stays offline.', quip: 'Offline again.', score: 3, quotes: [{ text: 'the robot is offline for good', sec: 60 }], stream_id: 7001, vod_id: 9001, sec: 60, said_at: '2026-10-21 12:00:00' },++ { type: 'segment', id: 590, title: 'A denied clip', what: 'Never cited.', take: 'No.', quip: 'No.', score: 1, quotes: [{ text: 'this line must never become a citation', sec: 12 }], stream_id: 7009, vod_id: 5709, sec: 12, said_at: '2026-10-21 12:05:00' },++ ],++ 'looi-robot-test': [++ { type: 'segment', id: 1165, title: 'Maticus can drive robots', what: 'Cosmo needs Wi-Fi.', take: 'Robots.', quip: 'Wi-Fi.', score: 4, quotes: [{ text: 'Cosmo requires Wi-Fi.', sec: 1582.8 }], stream_id: 2795, vod_id: 6026, sec: 1524, said_at: '2026-10-26 21:30:52' },++ ],++};++++const DEFAULT_VODS = {++ 9001: { id: 9001, stream_id: 7001, title: 'Robot test', created_at: '2026-10-21T11:00:00.000Z', visibility: 'public' },++ 9002: { id: 9002, stream_id: 7001, title: 'Robot test (joined)', created_at: '2026-10-21T11:00:00.000Z', visibility: 'public' },++ 6025: { id: 6025, stream_id: 2795, title: 'Night stream', created_at: '2026-10-26T21:00:00.000Z', visibility: 'public' },++ 6026: { id: 6026, stream_id: 2795, title: 'Night stream, part 2', created_at: '2026-10-26T21:00:00.000Z', visibility: 'public' },++};++++const DEFAULT_TRANSCRIPTS = {++ 9001: { transcript: 'the robot is offline for good', segments: [{ start: 60, end: 64, text: 'the robot is offline for good' }, { start: 120, end: 124, text: 'something else entirely' }] },++ 9002: { transcript: 'the robot is offline for good', segments: [{ start: 120, end: 124, text: 'the robot is offline for good' }] },++ 6026: { transcript: 'but yeah and Cosmo is broken now', segments: [{ start: 864.08, end: 869.84, text: 'but yeah and Cosmo is broken now, so I have to get little buddy back' }, { start: 1581.2, end: 1583.92, text: 'Cosmo requires Wi-Fi.' }] },++ 6025: { transcript: 'but yeah and Cosmo is broken now', segments: [{ start: 1541, end: 1546, text: 'but yeah and Cosmo is broken now, so I have to get little buddy back' }] },++};++++const DEFAULT_VIDEO = {++ 9001: { status: 301, location: '/v/9002?t=120' },++ 9002: { status: 200 },++ 6026: { status: 301, location: '/v/6025?t=1541' },++ 6025: { status: 200 },++};++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks).toString('utf8');++ const json = (status, obj, headers = {}) => {++ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });++ res.end(JSON.stringify(obj));++ };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++async function startUpstreams() {++ const state = {++ messages: DEFAULT_MESSAGES.map((m) => ({ ...m })),++ timeline: DEFAULT_TIMELINE.map((e) => ({ ...e })),++ topics: DEFAULT_TOPICS.map((t) => ({ ...t })),++ takes: JSON.parse(JSON.stringify(DEFAULT_TAKES)),++ vods: { ...DEFAULT_VODS },++ transcripts: JSON.parse(JSON.stringify(DEFAULT_TRANSCRIPTS)),++ video: { ...DEFAULT_VIDEO },++ requests: [],++ };++++ const srv = await listen(async (req, raw, json, res) => {++ const url = new URL(req.url, 'http://x');++ const p = url.pathname;++ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers } });++++ if (p === '/api/chat/global/history') {++ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 200)));++ const before = url.searchParams.get('before');++ const list = state.messages++ .filter((m) => !before || m.timestamp <= before)++ .sort((a, b) => b.id - a.id)++ .slice(0, limit);++ return json(200, { messages: list, latest_id: list[0] ? list[0].id : null });++ }++ if (p === '/api/chat/ai/timeline') {++ const limit = Math.min(60, Math.max(1, Number(url.searchParams.get('limit') || 60)));++ return json(200, { events: state.timeline.slice(0, limit), hasMore: false });++ }++ if (p === '/api/rundown/topics') {++ return json(200, { topics: state.topics, kinds: [] });++ }++ const topic = p.match(/^\/api\/rundown\/t\/([^/]+)$/);++ if (topic) {++ const slug = decodeURIComponent(topic[1]);++ const found = state.topics.find((t) => t.slug === slug);++ if (!found) return json(404, { error: 'not_found' });++ return json(200, { topic: found, takes: state.takes[slug] || [] });++ }++ const vod = p.match(/^\/api\/vods\/(\d+)$/);++ if (vod) {++ const row = state.vods[vod[1]];++ if (!row) return json(404, { error: 'not_found' });++ return json(200, { vod: row, clips: [] });++ }++ if (p === '/api/chat-ai/vod-transcripts') {++ const ids = String(url.searchParams.get('ids') || '').split(',').map((x) => x.trim()).filter(Boolean);++ const out = {};++ for (const id of ids) if (state.transcripts[id]) out[id] = state.transcripts[id];++ return json(200, { transcripts: out });++ }++ const video = p.match(/^\/v\/(\d+)$/);++ if (video) {++ const conf = state.video[video[1]];++ if (!conf || conf.status === 404) return json(404, { error: 'not_found' });++ if (conf.status >= 400) return json(conf.status, { error: 'video unavailable' });++ if (conf.status >= 300 && conf.status < 400) {++ res.writeHead(conf.status, { Location: conf.location, 'Content-Type': 'text/plain' });++ return res.end('redirect');++ }++ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });++ return res.end('<!doctype html><title>VOD</title>');++ }++ return json(404, { error: 'not found' });++ });++++ return {++ url: srv.url,++ state,++ close: srv.close,++ requestsFor(path) { return state.requests.filter((r) => r.path === path); },++ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },++ setVideo(id, conf) { state.video[String(id)] = conf; },++ addMessage(m) { state.messages.push(m); },++ };++}++++module.exports = { startUpstreams, DEFAULT_MESSAGES, DEFAULT_TIMELINE, DEFAULT_TOPICS, DEFAULT_TAKES, DEFAULT_VODS, DEFAULT_TRANSCRIPTS, DEFAULT_VIDEO };nnew file mode 100644@@@ -0,0 +1,58 @@++'use strict';++/**++ * The importer reads the fixture Chat history/timeline, Rundown topics and VOD transcripts, finds "robot is++ * offline" across four uses on three days, canonicalises `/v/9001?t=60` → `/v/9002?t=120`, and inserts the++ * candidate hidden. It never publishes; a second run enriches nothing and makes no second entry. The deny-list++ * take (VOD 5709) never reaches a citation.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ let out = null;++ await check('the fixture produces one candidate from ≥2 uses on ≥2 days, hidden', async () => {++ out = await t.ctx.importer.runOnce();++ assert.strictEqual(out.errors.length, 0, JSON.stringify(out.errors));++ assert.ok(out.created >= 1);++ const e = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'robot-is-offline'");++ assert.ok(e, 'the candidate entry exists');++ assert.strictEqual(e.visibility, 'hidden');++ assert.strictEqual(e.title, 'Robot is offline');++ const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at, id', [e.id]);++ assert.ok(cites.length >= 3, `expected several citations, got ${cites.length}`);++ const days = new Set(cites.map((c) => String(c.observed_at).slice(0, 10)));++ assert.ok(days.size >= 2, 'uses are on at least two days');++ assert.strictEqual(cites.filter((c) => c.role === 'origin').length, 1);++ assert.ok(cites.some((c) => c.source_type === 'chat_message'));++ assert.ok(cites.some((c) => c.source_type === 'rundown_segment'));++ assert.ok(cites.some((c) => c.source_type === 'vod'));++ });++++ await check('a joined-VOD redirect is stored canonically (9002@120, not 9001@60)', async () => {++ const c = await t.ctx.s.db.maybe("SELECT * FROM meme_citations WHERE source_type = 'rundown_segment' LIMIT 1");++ assert.strictEqual(Number(c.vod_id), 9002);++ assert.strictEqual(Number(c.offset_seconds), 120);++ assert.match(c.url, /\/v\/9002\?t=120$/);++ });++++ await check('no citation names VOD 5709 or clip 483', async () => {++ const bad = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483');++ assert.strictEqual(Number(bad), 0);++ });++++ await check('the candidate is hidden from the public API and a second run does not duplicate it', async () => {++ const list = await t.get('/api/v1/entries');++ assert.strictEqual(list.json().count, 0);++ const one = await t.get('/api/v1/entries/robot-is-offline');++ assert.strictEqual(one.status, 404);++ const again = await t.ctx.importer.runOnce();++ assert.strictEqual(again.created, 0);++ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_entries WHERE slug = 'robot-is-offline'")), 1);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,62 @@++'use strict';++/**++ * The expand migration applies in one pass and creates exactly the tables the plan names, plus the SDK's account++ * receipts and telemetry. The three data-free templates are seeded; the entry constraints refuse an unknown kind.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('0001_initial.sql applies and creates the Meme tables', async () => {++ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);++ for (const table of ['meme_entries', 'meme_citations', 'meme_status_events', 'meme_votes', 'meme_templates', 'meme_captions', 'meme_reports', 'meme_outbox', 'account_data_events', 'telemetry_samples']) {++ assert.ok(names.includes(table), `missing ${table}`);++ }++ });++++ await check('three data-free templates are seeded', async () => {++ const r = await t.get('/api/v1/templates');++ assert.strictEqual(r.status, 200);++ const b = r.json();++ assert.strictEqual(b.count, 3);++ for (const tpl of b.templates) {++ assert.match(tpl.image_url, /^(https:\/\/openvibe\.media\/|\/img\/)/);++ assert.ok(tpl.box_count >= 1 && tpl.box_count <= 8);++ assert.ok(tpl.license);++ }++ });++++ await check('entry constraints refuse an unknown kind, a bad slug and a duplicate slug', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const id = () => `mem_${ids.ulid()}`;++ await assert.rejects(db.exec(++ `INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'bad-kind', 'Bad', 'notakind', $2, $2)`,++ [id(), at],++ ), /meme_entries_kind/);++ await assert.rejects(db.exec(++ `INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'Not A Slug', 'Bad', 'joke', $2, $2)`,++ [id(), at],++ ), /meme_entries_slug/);++ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'once', 'Once', 'joke', $2, $2)`, [id(), at]);++ await assert.rejects(db.exec(`INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'once', 'Twice', 'joke', $2, $2)`, [id(), at]), /duplicate key|unique/i);++ });++++ await check('the one-vote-a-week primary key is real', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const id = () => `mem_${ids.ulid()}`;++ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at) VALUES ($1, 'votable', 'Votable', 'joke', 'public', $2, $2)`, [id(), at]);++ const subject = ids.newId('user');++ const eid = await db.value("SELECT id FROM meme_entries WHERE slug = 'votable'");++ await db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2)", [eid, subject]);++ await assert.rejects(db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2)", [eid, subject]), /duplicate key|unique/i);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,38 @@++'use strict';++/**++ * No looks in public AI text: a summary that names looks is replaced by the entry's title; a normal summary is++ * kept and repaired only where noLooks has something to repair. Verbatim quotes are never passed through.++ */++const assert = require('assert');++const { cleanSummary, hasLooks } = require('../server/text/no-looks');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ await check('a summary that names looks becomes the title', async () => {++ assert.strictEqual(cleanSummary('Cozmo is offline', 'A shirtless man in a kitchen laughs.'), 'Cozmo is offline');++ assert.strictEqual(cleanSummary('Cozmo is offline', 'A little robot is not online.'), 'Cozmo is offline');++ assert.ok(hasLooks('A shirtless man in a kitchen laughs.'));++ });++++ await check('an ordinary summary survives', async () => {++ const s = cleanSummary('Cozmo is offline', 'The robot is not online; chat keeps asking about its Wi-Fi.');++ assert.strictEqual(s, 'The robot is not online; chat keeps asking about its Wi-Fi.');++ });++++ const t = await boot();++ try {++ await check('createCandidate stores the title, never the looks summary', async () => {++ const out = await t.ctx.entries.createCandidate({++ slug: 'looks', title: 'Robot is offline', kind: 'meme', summary: 'A shirtless man reacts to the offline robot.',++ status: 'rising', first_seen_at: new Date().toISOString(), last_seen_at: new Date().toISOString(), visibility: 'hidden',++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: new Date().toISOString() }],++ });++ assert.strictEqual(out.entry.summary, 'Robot is offline');++ const quote = await t.ctx.s.db.value('SELECT quote_text FROM meme_citations WHERE entry_id = $1', [out.entry.id]);++ assert.strictEqual(quote, 'the robot is offline again', 'the verbatim quote is not rewritten');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,179 @@++'use strict';++/**++ * Server-rendered pages: the home (trending, the week teaser, the generator teaser), one entry with its origin++ * citation and timeline, the ballot, the generator's preview and save, one saved caption, developers, 404, the++ * cache headers, and the content-hashed assets. Public pages are public, max-age=60; a page for a signed-in person++ * is private, no-store.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { assetVersion } = require('../server/render/layout');++++function jsonLd(html) {++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++}++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ const now = new Date().toISOString();++ const publicEntry = await t.ctx.entries.createCandidate({++ slug: 'robot-is-offline', title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',++ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',++ citations: [++ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },++ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },++ ],++ });++ const hiddenEntry = await t.ctx.entries.createCandidate({++ slug: 'not-yet', title: 'Not yet', kind: 'joke', summary: 'Hidden.', status: 'rising',++ first_seen_at: now, last_seen_at: now, visibility: 'hidden',++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1002', chat_message_id: 1002, quote_text: 'still the robot is offline, huh', url: `${t.upstreams.url}/`, observed_at: now }],++ });++ const tpl = (await t.get('/api/v1/templates')).json().templates[0];++++ try {++ await check('home: semantic, the entry, the week teaser, public cache with Vary: Cookie', async () => {++ const r = await t.get('/');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ for (const marker of ['id="mm-home"', 'id="mm-trending"', 'id="mm-week-teaser"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Robot is offline', '/make']) assert.ok(r.text.includes(marker), marker);++ const types = jsonLd(r.text).map((x) => x['@type']);++ assert.ok(types.includes('WebSite'), types.join(','));++ assert.ok(types.includes('WebApplication'), types.join(','));++ });++++ await check('a page drawn for a signed-in person is private, no-store', async () => {++ const r = await t.get('/', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual(r.headers.get('vary'), 'Cookie');++ });++++ await check('one entry page carries the origin link, the timeline and the examples section', async () => {++ const r = await t.get('/m/robot-is-offline');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(r.text.includes('id="mm-entry"'));++ assert.ok(r.text.includes('id="mm-origin"'));++ assert.ok(r.text.includes('id="mm-timeline"'));++ assert.ok(r.text.includes('id="mm-examples"'));++ assert.ok(r.text.includes('the robot is offline again'));++ assert.ok(r.text.includes('the robot is offline for good'));++ assert.ok(r.text.includes('/v/9002?t=120'));++ });++++ await check('a hidden entry is 404 to the public and visible to staff', async () => {++ assert.strictEqual((await t.get('/m/not-yet')).status, 404);++ assert.strictEqual((await t.get('/m/not-yet', { as: alice })).status, 404);++ const r = await t.get('/m/not-yet', { as: mod });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('staff view'));++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ });++++ await check('the series API returns one zero-filled bucket per day', async () => {++ const r = await t.get('/api/v1/entries/robot-is-offline/series?days=3');++ assert.strictEqual(r.status, 200);++ const b = r.json();++ assert.strictEqual(b.days, 3);++ assert.strictEqual(b.series.length, 3);++ assert.ok(b.series.every((x) => /^\d{4}-\d{2}-\d{2}$/.test(x.day) && Number.isInteger(x.uses)));++ assert.strictEqual((await t.get('/api/v1/entries/robot-is-offline/series?days=0')).status, 400);++ });++++ await check('/week has the ballot and the plain POST form', async () => {++ const r = await t.get('/week');++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('id="mm-week"'));++ assert.ok(r.text.includes('Robot is offline'));++ const signed = await t.get('/week', { as: alice });++ assert.strictEqual(signed.status, 200);++ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');++ assert.ok(signed.text.includes('action="/week/vote"'));++ assert.ok(signed.text.includes('name="entry_id"'));++ const vote = await t.get('/week/vote', { as: alice, form: { entry_id: publicEntry.entry.id } });++ assert.strictEqual(vote.status, 303);++ assert.strictEqual(vote.headers.get('location'), '/week?voted=1');++ const cross = await t.get('/week/vote', { as: alice, form: { entry_id: publicEntry.entry.id }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ });++++ await check('/make previews over the image and saves a caption, public cache', async () => {++ const form = await t.get('/make');++ assert.strictEqual(form.status, 200);++ assert.strictEqual(form.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(form.text.includes('id="mm-make"'));++ assert.ok(form.text.includes('id="mm-caption-form"'));++ assert.ok(form.text.includes(`/img/template-`));++ const preview = await t.get(`/make?template_id=${tpl.id}&line1=when%20the%20robot&line2=is%20offline`);++ assert.strictEqual(preview.status, 200);++ assert.ok(preview.text.includes('id="mm-preview"'));++ assert.ok(preview.text.includes('mm-caption-card'));++ assert.ok(preview.text.includes('when the robot'));++ const saved = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: 'when the robot', line2: 'is offline', visibility: 'public' } });++ assert.strictEqual(saved.status, 303);++ assert.match(saved.headers.get('location'), /^\/c\/mcp_/);++ const cross = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: 'cross' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ const bad = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: '' } });++ assert.strictEqual(bad.status, 400);++ assert.ok(bad.text.includes('id="mm-caption-form"'));++ });++++ await check('/c/:id shows text over the template and respects a hidden caption', async () => {++ const cap = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['the caption', 'on the page'] } })).json();++ const r = await t.get(`/c/${cap.id}`);++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('id="mm-caption"'));++ assert.ok(r.text.includes('mm-caption-card'));++ assert.ok(r.text.includes('the caption'));++ assert.ok(r.text.includes('on the page'));++ const hidden = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['hidden one'], visibility: 'hidden' } })).json();++ assert.strictEqual((await t.get(`/c/${hidden.id}`)).status, 404);++ assert.strictEqual((await t.get(`/c/${hidden.id}`, { as: alice })).status, 200);++ });++++ await check('/developers, the 404 and the report form', async () => {++ const dev = await t.get('/developers');++ assert.strictEqual(dev.status, 200);++ assert.strictEqual(dev.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(dev.text.includes('id="mm-developers"'));++ assert.ok(dev.text.includes('/api/v1/entries'));++ const nf = await t.get('/no/such/page');++ assert.strictEqual(nf.status, 404);++ assert.match(nf.headers.get('content-type'), /^text\/html/);++ assert.ok(nf.text.includes('id="mm-not-found"'));++ const report = await t.get('/m/robot-is-offline/report', { as: alice, form: { kind: 'takedown', reason: 'A source is gone' } });++ assert.strictEqual(report.status, 303);++ assert.strictEqual(report.headers.get('location'), '/m/robot-is-offline?reported=1');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_reports')), 1);++ });++++ await check('assets are content-hashed; robots and sitemap list the public pages', async () => {++ const v = assetVersion('css/meme.css');++ const hashed = await t.get(`/css/meme.css?v=${v}`);++ assert.strictEqual(hashed.status, 200);++ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');++ const wrong = await t.get('/css/meme.css?v=deadbeefdeadbeef');++ assert.strictEqual(wrong.status, 200);++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');++ const js = await t.get('/js/meme.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ const robots = await t.get('/robots.txt');++ assert.match(robots.text, /Disallow: \/api\//);++ const map = await t.get('/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.ok(map.text.includes('https://openvibe.meme/m/robot-is-offline'));++ const llms = await t.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.ok(llms.text.includes('OpenVibe.Meme'));++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,87 @@++'use strict';++/**++ * Citation re-verification: a URL that now answers 404 marks the citation gone and, when it is the origin, hides++ * the entry (revision +1, Search tombstone). A transient 500 must leave the citation alone. A Chat line is checked++ * against the public history page, because v1 has no per-message permalink.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const now = new Date().toISOString();++ const entry = await t.ctx.entries.createCandidate({++ slug: 'vod-origin', title: 'VOD origin', kind: 'meme', summary: 'A proof.', status: 'rising',++ first_seen_at: '2026-10-21T12:00:00Z', last_seen_at: now, visibility: 'public',++ citations: [{ role: 'origin', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: '2026-10-21T12:00:00Z' }],++ });++++ await check('a 404 marks the citation gone and hides the entry whose origin it is', async () => {++ t.upstreams.setVideo(9002, { status: 404 });++ const r = await t.ctx.reverifier.runOnce();++ assert.strictEqual(r.checked, 1);++ assert.strictEqual(r.gone, 1);++ assert.strictEqual(r.entries_hidden, 1);++ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [entry.entry.id]);++ assert.strictEqual(c.source_status, 'gone');++ const e = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [entry.entry.id]);++ assert.strictEqual(e.visibility, 'hidden');++ assert.strictEqual(Number(e.revision), 2);++ const doc = await t.ctx.s.db.maybe('SELECT payload FROM meme_outbox ORDER BY created_at DESC LIMIT 1');++ assert.strictEqual(doc.payload.deleted, true);++ assert.strictEqual((await t.get('/api/v1/entries/vod-origin')).status, 404);++ });++++ await check('a gone use (not the origin) keeps the entry public and re-indexes it', async () => {++ const e4 = await t.ctx.entries.createCandidate({++ slug: 'use-gone', title: 'Use gone', kind: 'meme', summary: 'A proof.', status: 'rising',++ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',++ citations: [++ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },++ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },++ ],++ });++ const r = await t.ctx.reverifier.runOnce();++ assert.strictEqual(r.entries_hidden, 0);++ const e = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [e4.entry.id]);++ assert.strictEqual(e.visibility, 'public');++ assert.strictEqual(Number(e.revision), 2);++ const ups = await t.ctx.s.db.many("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at DESC LIMIT 1");++ assert.ok(ups.length);++ assert.strictEqual(ups[0].payload.revision, 2);++ assert.strictEqual(ups[0].payload.deleted, false);++ });++++ await check('a transient 500 leaves the citation and the entry as they were', async () => {++ const e2 = await t.ctx.entries.createCandidate({++ slug: 'transient', title: 'Transient', kind: 'meme', summary: 'A proof.', status: 'rising',++ first_seen_at: now, last_seen_at: now, visibility: 'public',++ citations: [{ role: 'origin', source_service: 'live', source_type: 'vod', source_id: '6025', vod_id: 6025, offset_seconds: 1541, quote_text: 'Cosmo requires Wi-Fi.', url: `${t.upstreams.url}/v/6025?t=1541`, observed_at: now }],++ });++ t.upstreams.setVideo(6025, { status: 500 });++ const r = await t.ctx.reverifier.runOnce();++ assert.ok(r.errors.length >= 1);++ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [e2.entry.id]);++ assert.strictEqual(c.source_status, 'live');++ const row = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [e2.entry.id]);++ assert.strictEqual(row.visibility, 'public');++ });++++ await check('a Chat line that is no longer in public history is marked gone', async () => {++ const e3 = await t.ctx.entries.createCandidate({++ slug: 'chat-origin', title: 'Chat origin', kind: 'joke', summary: 'A proof.', status: 'rising',++ first_seen_at: now, last_seen_at: now, visibility: 'public',++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '999999', chat_message_id: 999999, quote_text: 'a line that never existed', url: `${t.upstreams.url}/`, observed_at: now }],++ });++ const r = await t.ctx.reverifier.runOnce();++ assert.ok(r.errors.length >= 0);++ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [e3.entry.id]);++ assert.strictEqual(c.source_status, 'gone');++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT visibility FROM meme_entries WHERE id = $1', [e3.entry.id])).visibility, 'hidden');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an++ * in-process mock of OpenVibe.Network, and in-process Chat/Live/Video fixtures; none needs the network.++ *++ * npm test # everything++ * npm test -- importer # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/**++ * The Search Fabric document: owner meme, type entry, id mem_…, canonical page /m/<slug>, title/summary/body,++ * facets kind/status/source, authorship ai_assisted, one provenance reference per citation, the entry's revision.++ * A hidden entry has no document; publish upserts; a PATCH bumps the revision; hide writes a valid tombstone.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ const now = new Date().toISOString();++ const e = await t.ctx.entries.createCandidate({++ slug: 'search-proof', title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',++ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'hidden',++ citations: [++ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },++ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },++ ],++ });++++ await check('a hidden entry queues no Search document', async () => {++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox')), 0);++ });++++ await check('publish writes a valid search.index-document@1', async () => {++ assert.strictEqual((await t.get('/api/v1/entries/search-proof/publish', { as: mod, method: 'POST' })).status, 200);++ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at DESC LIMIT 1");++ assert.ok(row, 'an upsert row exists');++ const doc = row.payload;++ const v = contracts.validate('search.index-document@1', doc);++ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));++ assert.strictEqual(doc.owner, 'meme');++ assert.strictEqual(doc.type, 'entry');++ assert.strictEqual(doc.id, e.entry.id);++ assert.strictEqual(doc.deleted, false);++ assert.strictEqual(doc.visibility, 'public');++ assert.strictEqual(doc.publication_state, 'published');++ assert.strictEqual(doc.canonical_url, 'https://openvibe.meme/m/search-proof');++ assert.strictEqual(doc.title, 'Robot is offline');++ assert.strictEqual(doc.revision, 2);++ assert.strictEqual(doc.facets.kind, 'catchphrase');++ assert.strictEqual(doc.facets.status, 'rising');++ assert.deepStrictEqual(doc.facets.source.sort(), ['chat', 'live']);++ assert.strictEqual(doc.authorship, 'ai_assisted');++ assert.strictEqual(doc.provenance.length, 2);++ assert.strictEqual(doc.provenance[0].service, 'chat');++ assert.ok(doc.body.includes('the robot is offline again'));++ });++++ await check('an edit sends the next revision; hide sends a valid tombstone', async () => {++ assert.strictEqual((await t.get('/api/v1/entries/search-proof', { as: mod, method: 'PATCH', json: { title: 'Robot is really offline' } })).status, 200);++ const ups = await t.ctx.s.db.many("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at");++ assert.strictEqual(ups.length, 2);++ assert.strictEqual(ups[1].payload.revision, 3);++ assert.strictEqual(ups[1].payload.title, 'Robot is really offline');++ assert.strictEqual(contracts.validate('search.index-document@1', ups[1].payload).valid, true);++ assert.strictEqual((await t.get('/api/v1/entries/search-proof/hide', { as: mod, method: 'POST' })).status, 200);++ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.deleted' ORDER BY created_at DESC LIMIT 1");++ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));++ assert.strictEqual(tomb.payload.deleted, true);++ assert.strictEqual(tomb.payload.revision, 4);++ });++++ await check('the public list carries only public entries', async () => {++ const r = await t.get('/api/v1/entries');++ assert.strictEqual(r.json().count, 0);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,49 @@++'use strict';++/**++ * The first proof: scripts/seed-proof.js reads the fixture public APIs, creates "Cozmo is offline" hidden with++ * exactly two citations (chat message 86957 and VOD 6025 @ 1541 s, the canonical target of /v/6026?t=864), never++ * publishes it, and is idempotent on a second run.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { runSeed } = require('../scripts/seed-proof');++++(async () => {++ const t = await boot();++ try {++ let out = null;++ await check('the seed creates the hidden proof with its two canonical citations', async () => {++ out = await runSeed({ config: t.config, s: t.ctx.s, upstreams: t.ctx.upstreams, log: { log() {} } });++ assert.strictEqual(out.created, true);++ assert.strictEqual(out.entry.slug, 'cozmo-offline');++ assert.strictEqual(out.entry.title, 'Cozmo is offline');++ assert.strictEqual(out.entry.visibility, 'hidden');++ assert.strictEqual(out.entry.status, 'classic');++ const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY role', [out.entry.id]);++ assert.strictEqual(cites.length, 2);++ const origin = cites.find((c) => c.role === 'origin');++ assert.strictEqual(origin.source_type, 'chat_message');++ assert.strictEqual(Number(origin.chat_message_id), 86957);++ const use = cites.find((c) => c.role === 'use');++ assert.strictEqual(use.source_type, 'vod');++ assert.strictEqual(Number(use.vod_id), 6025);++ assert.strictEqual(Number(use.offset_seconds), 1541);++ assert.match(use.url, /\/v\/6025\?t=1541$/);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483')), 0);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox')), 0);++ assert.strictEqual((await t.get('/api/v1/entries/cozmo-offline')).status, 404);++ });++++ await check('a second run adds nothing and leaves the entry hidden', async () => {++ const again = await runSeed({ config: t.config, s: t.ctx.s, upstreams: t.ctx.upstreams, log: { log() {} } });++ assert.strictEqual(again.created, false);++ assert.strictEqual(again.added, 0);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations')), 2);++ const e = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'cozmo-offline'");++ assert.strictEqual(e.visibility, 'hidden');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id meme, cookies meme_at/meme_rt/meme_oauth), the session-token rules (a FedCM++ * assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback metrics,++ * pages without JavaScript, and cross-site sign-out.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { sanitizeNext } = require('../server/auth/sso');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++++ await check('login redirects to Network with state and an S256 code challenge, client id meme', async () => {++ const r = await t.get('/auth/login?next=/week');++ assert.strictEqual(r.status, 302);++ const loc = new URL(r.headers.get('location'));++ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);++ assert.strictEqual(loc.searchParams.get('client_id'), 'meme');++ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');++ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);++ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);++ assert.match(r.headers.get('set-cookie'), /meme_oauth=.*Path=\/auth.*HttpOnly/i);++ });++++ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {++ const login = await t.get('/auth/login?next=/week');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(r.status, 302, r.text);++ assert.strictEqual(r.headers.get('location'), '/week');++ const set = r.headers.get('set-cookie');++ assert.match(set, /meme_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /meme_rt=[^;]+;.*HttpOnly/i);++ assert.match(set, /meme_oauth=;/);++ const at = set.match(/meme_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `meme_at=${at}` });++ assert.strictEqual(me.json().user.username, 'kim');++ assert.ok(!me.text.includes(at), '/auth/me never returns the token');++ });++++ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {++ const login = await t.get('/auth/login');++ const loc = new URL(login.headers.get('location'));++ const flowCookie = login.headers.get('set-cookie').split(';')[0];++ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));++ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });++ assert.strictEqual(bad.status, 400);++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));++ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });++ assert.strictEqual(mismatch.status, 400);++ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);++ assert.strictEqual(noCookie.status, 400);++ });++++ await check('next= only accepts same-site paths', async () => {++ const r = await t.get('/auth/login?next=//evil.example/x');++ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/meme_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://openvibe.meme' };++ assert.strictEqual(sanitizeNext('/week?x=1', config), '/week?x=1');++ assert.strictEqual(sanitizeNext('https://openvibe.meme/m/x?y=1', config), '/m/x?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.meme.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ });++++ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {++ const r = await t.get('/api/ready');++ const b = r.json();++ assert.strictEqual(r.status, 200);++ assert.strictEqual(b.checks.db.status, 'ok');++ assert.strictEqual(b.checks.db.required, true);++ assert.strictEqual(b.checks.network_jwks.status, 'ok');++ assert.strictEqual(b.checks.network_jwks.required, false);++ assert.strictEqual(b.checks.oauth_client.status, 'ok');++ assert.strictEqual(b.checks.oauth_client.required, false);++ assert.ok('valkey' in b.checks);++ });++++ await check('/release.json and /metrics (loopback only)', async () => {++ const rel = await t.get('/release.json');++ assert.strictEqual(rel.status, 200);++ assert.strictEqual(rel.json().service, 'meme');++ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);++ const m = await t.get('/metrics');++ assert.strictEqual(m.status, 200);++ assert.match(m.text, /http_requests_total|release_info/);++ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });++ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');++ });++++ await check('public pages are complete without JavaScript', async () => {++ for (const p of ['/', '/week', '/make', '/developers']) {++ const r = await t.get(p);++ assert.strictEqual(r.status, 200, p);++ assert.match(r.text, /<noscript><nav/);++ assert.match(r.text, /<footer id="ov-footer"/);++ assert.match(r.text, /<main id="main"/);++ }++ });++++ await check('the API answers /ping and unknown routes are problem+json 404', async () => {++ const ping = await t.get('/api/v1/ping');++ assert.strictEqual(ping.status, 200);++ assert.deepStrictEqual(ping.json(), { ok: true, service: 'meme' });++ const gone = await t.get('/api/v1/nope');++ assert.strictEqual(gone.status, 404);++ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);++ assert.strictEqual(gone.json().code, 'route.not_found');++ });++++ await check('a FedCM assertion and an app token are not sessions', async () => {++ const now = Math.floor(Date.now() / 1000);++ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });++ assert.strictEqual((await t.get('/auth/me', { cookie: `meme_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `meme_at=${fedcm}` });++ assert.ok(!page.text.includes('Signed in as <strong>kim'));++ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });++ assert.strictEqual((await t.get('/auth/me', { cookie: `meme_at=${app}` })).status, 401);++ });++++ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {++ const guest = await t.get('/auth/me');++ assert.strictEqual(guest.status, 200);++ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });++ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');++ assert.strictEqual((await t.get('/auth/me', { cookie: 'meme_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'meme_rt=stale' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);++ });++++ await check('another site cannot sign a person out; this site can', async () => {++ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(cross.status, 200);++ assert.ok(!/meme_at=;|meme_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));++ assert.match(cross.text, /method="post"/);++ const crossPost = await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });++ assert.strictEqual(crossPost.status, 403);++ const same = await t.get('/auth/logout?next=/week', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/week');++ assert.match(String(same.headers.get('set-cookie') || ''), /meme_at=;/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,91 @@++'use strict';++/**++ * Staff: publishing a hidden candidate needs Network role global_mod or above; an entry whose origin is gone is++ * refused; PATCH changes title/kind/status and records the status event; hide tombstones the Search document.++ * Reports are stored for any signed-in person and read only by staff.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const kim = t.network.addUser('kim');++ const mod = t.network.addUser('mod', { role: 'global_mod' });++ const admin = t.network.addUser('admin', { role: 'admin' });++ const now = new Date().toISOString();++ const mk = (slug, opts = {}) => t.ctx.entries.createCandidate({++ slug, title: opts.title || 'A candidate', kind: 'joke', summary: 'A candidate.', status: 'rising',++ first_seen_at: now, last_seen_at: now, visibility: opts.visibility || 'hidden',++ citations: [{++ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(opts.msg || Math.floor(Math.random() * 1e9)),++ chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: now,++ source_status: opts.source_status || 'live',++ }],++ });++ const a = await mk('staff-publish', { title: 'Staff publish' });++ const gone = await mk('gone-origin', { source_status: 'gone' });++++ await check('publish is 403 for a person and 403 for a streamer, 200 for global_mod and admin', async () => {++ const streamer = t.network.addUser('streamer', { role: 'streamer' });++ const no = await t.get('/api/v1/entries/staff-publish/publish', { as: kim, method: 'POST' });++ assert.strictEqual(no.status, 403);++ assert.strictEqual(no.json().code, 'meme.staff_required');++ assert.strictEqual((await t.get('/api/v1/entries/staff-publish/publish', { as: streamer, method: 'POST' })).status, 403);++ const yes = await t.get('/api/v1/entries/staff-publish/publish', { as: mod, method: 'POST' });++ assert.strictEqual(yes.status, 200, yes.text);++ assert.strictEqual(yes.json().visibility, 'public');++ assert.strictEqual(yes.json().revision, 2);++ assert.strictEqual((await t.get('/api/v1/entries/staff-publish')).status, 200);++ assert.strictEqual((await t.get('/api/v1/entries/gone-origin/publish', { as: admin, method: 'POST' })).status, 409);++ });++++ await check('PATCH changes the fields and records a status event', async () => {++ const r = await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { title: 'Renamed', kind: 'bit', status: 'peaked', reason: 'people still say it' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().title, 'Renamed');++ assert.strictEqual(r.json().kind, 'bit');++ assert.strictEqual(r.json().status, 'peaked');++ assert.strictEqual(r.json().revision, 3);++ const events = await t.ctx.s.db.many('SELECT from_status, to_status, reason, changed_by FROM meme_status_events WHERE entry_id = $1', [a.entry.id]);++ assert.strictEqual(events.length, 1);++ assert.strictEqual(events[0].from_status, 'rising');++ assert.strictEqual(events[0].to_status, 'peaked');++ assert.strictEqual(events[0].reason, 'people still say it');++ assert.strictEqual(events[0].changed_by, mod.subject);++ });++++ await check('PATCH refuses an unknown field and a bad kind', async () => {++ const unknown = await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { slug: 'other' } });++ assert.strictEqual(unknown.status, 400);++ assert.strictEqual(unknown.json().code, 'meme.invalid');++ assert.strictEqual((await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { kind: 'not-a-kind' } })).status, 400);++ });++++ await check('hide makes it private and queues a tombstone', async () => {++ const r = await t.get('/api/v1/entries/staff-publish/hide', { as: mod, method: 'POST' });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().visibility, 'hidden');++ assert.strictEqual((await t.get('/api/v1/entries/staff-publish')).status, 404);++ assert.strictEqual((await t.get('/api/v1/entries/staff-publish', { as: mod })).status, 200);++ const docs = await t.ctx.s.db.many('SELECT payload FROM meme_outbox ORDER BY created_at');++ assert.strictEqual(docs[docs.length - 1].payload.deleted, true);++ });++++ await check('a report is stored for a signed-in person and listed only for staff', async () => {++ const rep = await mk('reportable', { title: 'Reportable' });++ assert.strictEqual((await t.get('/api/v1/entries/reportable/publish', { as: mod, method: 'POST' })).status, 200);++ const r = await t.get('/api/v1/entries/reportable/report', { as: kim, json: { kind: 'correction', reason: 'The quote is from another stream', note: 'please check 6025' } });++ assert.strictEqual(r.status, 201, r.text);++ assert.strictEqual((await t.get('/api/v1/reports', { as: kim })).status, 403);++ assert.strictEqual((await t.get('/api/v1/reports', { as: mod })).status, 200);++ const list = (await t.get('/api/v1/reports', { as: mod })).json();++ assert.strictEqual(list.count, 1);++ assert.strictEqual(list.reports[0].kind, 'correction');++ assert.strictEqual(list.reports[0].entry_slug, 'reportable');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,79 @@++'use strict';++/**++ * Meme of the week: one vote a week per person (a second call changes the choice, never adds a ballot), a public++ * count, a signed-in same-origin rule, and only public entries with a live use this week on the ballot.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const now = new Date().toISOString();++ const mk = (slug, title, visibility = 'public', observed = now) => t.ctx.entries.createCandidate({++ slug, title, kind: 'meme', summary: 'A candidate.', status: 'rising',++ first_seen_at: observed, last_seen_at: observed, visibility,++ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(Math.floor(Math.random() * 1e9)), chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: observed }],++ });++ const one = await mk('first-meme', 'First meme');++ const two = await mk('second-meme', 'Second meme');++ const hidden = await mk('hidden-meme', 'Hidden meme', 'hidden');++ const old = await mk('old-meme', 'Old meme', 'public', '2026-01-01T00:00:00Z');++++ await check('a signed-in vote is one ballot and the count is public', async () => {++ const r = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: one.entry.id } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().vote.changed, true);++ const wk = await t.get('/api/v1/week', { as: alice });++ assert.strictEqual(wk.status, 200);++ const c = wk.json().candidates.find((x) => x.id === one.entry.id);++ assert.ok(c, 'the entry is on this week’s ballot');++ assert.strictEqual(c.votes, 1);++ assert.strictEqual(c.mine, true);++ assert.ok(!wk.json().candidates.some((x) => x.id === hidden.entry.id), 'a hidden entry is not a candidate');++ assert.ok(!wk.json().candidates.some((x) => x.id === old.entry.id), 'an old use is not this week');++ });++++ await check('two people are two votes; one person changing their vote is still one row', async () => {++ assert.strictEqual((await t.get('/api/v1/week/vote', { as: bob, json: { entry_id: one.entry.id } })).status, 200);++ const changed = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: two.entry.id } });++ assert.strictEqual(changed.status, 200);++ assert.strictEqual(changed.json().vote.changed, true);++ const rows = await t.ctx.s.db.many('SELECT week, subject, entry_id FROM meme_votes ORDER BY subject');++ assert.strictEqual(rows.length, 2, 'one row per person per week');++ assert.strictEqual(rows.filter((r) => r.subject === alice.subject).length, 1);++ const counts = await t.ctx.s.db.many('SELECT entry_id, COUNT(*)::int AS n FROM meme_votes GROUP BY entry_id');++ const byId = Object.fromEntries(counts.map((c) => [c.entry_id, Number(c.n)]));++ assert.strictEqual(byId[one.entry.id], 1);++ assert.strictEqual(byId[two.entry.id], 1);++ });++++ await check('anonymous, cross-site and non-public votes are refused', async () => {++ const anon = await t.get('/api/v1/week/vote', { json: { entry_id: one.entry.id } });++ assert.strictEqual(anon.status, 401);++ assert.strictEqual(anon.json().code, 'meme.sign_in_required');++ const cross = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: one.entry.id }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'meme.cross_site');++ const gone = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: hidden.entry.id } });++ assert.strictEqual(gone.status, 404);++ const missing = await t.get('/api/v1/week/vote', { as: alice, json: {} });++ assert.strictEqual(missing.status, 400);++ });++++ await check('the /week page has the plain vote form and shows the count', async () => {++ const r = await t.get('/week', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('id="mm-week"'));++ assert.ok(r.text.includes('action="/week/vote"'));++ assert.ok(r.text.includes('name="entry_id"'));++ assert.ok(r.text.includes('Your vote'));++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ });++ } finally {++ await t.close();++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+# OpenVibe.Meme — environment (production: /etc/openvibe/meme.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5120
+HOST=127.0.0.1
+BASE_URL=https://openvibe.meme
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh: the runtime role through PgBouncer,
+# the owner on a direct connection (migrations), and Valkey (shared limit counters).
+# Without DATABASE_URL, development uses an embedded PGlite database in data/pglite.
+DATABASE_URL=
+DATABASE_DIRECT_URL=
+VALKEY_URL=
+VALKEY_PREFIX=ov:meme:
+# Per-actor limits (server/http/api.js) and per-address limits (server/app.js).
+#MEME_LIMITS_MINUTE=120
+#MEME_LIMITS_HOUR=3000
+#MEME_API_RATE_LIMIT_PER_MIN=240
+#MEME_RATE_LIMIT_PER_MIN=300
+
+# OpenVibe.Network: SSO (OAuth client `meme`, PKCE S256) and its JWKS; app tokens for audience openvibe.meme.
+OV_NETWORK_URL=https://openvibe.network
+OV_NETWORK_INTERNAL_URL=http://127.0.0.1:4000
+# OV_NETWORK_ISSUER=https://openvibe.network
+OV_OAUTH_CLIENT_ID=meme
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.meme/auth/callback
+# MEME_AUDIENCE=openvibe.meme
+# OV_SESSION_AUDIENCE=openvibe.network
+# MEME_PGLITE_DIR=./data/pglite
+
+# OpenVibe.Events: account export/deletion deliveries to POST /internal/events. The secret signs a delivery
+# (comma-separated for rotation, 32+ characters each; unset makes the route answer 503).
+MEME_EVENTS_SECRET=
+MEME_EVENTS_URL=
+#MEME_EVENTS_ENDPOINT=http://127.0.0.1:5120/internal/events
+#MEME_EVENTS_INTERVAL_MS=5000
+
+# The public APIs the importer and the citation re-verifier read. No service grant is used in v1.
+OV_CHAT_URL=https://openvibe.chat
+OV_LIVE_URL=https://openvibe.live
+OV_VIDEO_URL=https://openvibe.video
+#MEME_UPSTREAM_TIMEOUT_MS=8000
+
+# The candidate importer: every 30 minutes, hidden candidates only, off under MEME_DRILL.
+#MEME_IMPORTER_INTERVAL_MS=1800000
+#MEME_IMPORTER_MIN_USES=2
+#MEME_IMPORTER_MIN_DAYS=2
+#MEME_IMPORTER_MAX_TOPICS=12
+#MEME_IMPORTER_MAX_VODS=12
+#MEME_IMPORTER_MAX_CANDIDATES=10
+
+# Citation re-verification: daily, off under MEME_DRILL.
+#MEME_REVERIFY_INTERVAL_MS=86400000
+
+# A restore drill starts no jobs and creates no subscriptions.
+#MEME_DRILL=0
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test scripts
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+node_modules/
+data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
+!server/data/
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+# Changelog
+
+What changed in OpenVibe.Meme, newest first.
+
+## 0.1.0 — 2026-10-28
+
+The first version: the open meme database, rebuilt with AI.
+
+- **Entries and citations.** `meme_entries` with kind, AI summary (`noLooks`; a look-naming summary becomes the
+ title), status and `meme_status_events`, first/last seen, visibility and a revision that goes up on every change.
+ `meme_citations` carries a verbatim quote, its sha256, the canonical VOD second and URL, and
+ `live|hidden|gone`. VOD 5709 and clip 483 are a fixed deny list: never cited, kept or rendered.
+- **The importer.** Every 30 minutes (off under `MEME_DRILL`) it reads Chat's public history and timeline, Live's
+ Rundown and VOD transcripts, finds a 3–6 word phrase in at least two uses on two different days, follows
+ OpenVibe.Video's joined-VOD redirect, and inserts one hidden candidate with an origin and its uses. It never
+ publishes. A second run enriches the same slug, never duplicates it.
+- **Re-verification.** Daily, every citation URL is read again: 404/410 → `gone`, 401/403 → `hidden`, a redirect
+ updates the canonical VOD second. An entry whose origin is gone is hidden and tombstoned. A transient 5xx changes
+ nothing.
+- **The API.** Public cursor-paged `GET /entries`, one entry with its citations and timeline,
+ `GET /entries/:slug/series?days=30` zero-filled per UTC day, `GET /trending`, `GET /week`, `POST /week/vote` (one
+ ballot per week and person), `GET/POST /templates` and `/captions` (1–4 lines × 120 characters, blocked words,
+ text only), `GET /captions/:id`, `POST /entries/:slug/report`, and staff `publish|hide|PATCH` behind Network role
+ `global_mod`+ plus `GET /reports`.
+- **The week and the generator.** ISO `YYYY-Www` weeks, candidate counts and the reader's own vote; a caption
+ preview is server-rendered HTML text over a template image and a saved caption is its jsonb lines.
+- **Search.** Each public entry is one `search.index-document@1` (owner `meme`, type `entry`, `/m/<slug>`, revision
+ from the entry, provenance per citation) queued in `meme_outbox` in the same transaction; hiding writes a
+ tombstone. The relay waits for the `search.document.write` grant.
+- **The service.** Network sign-in as OAuth client `meme` (PKCE S256, `meme_at`/`meme_rt`/`meme_oauth`), truthful
+ readiness, Prometheus metrics on loopback, discovery, deploy files, and server-rendered pages with stable `mm-`
+ ids. `public/css/meme.css` and `public/js/meme.js` are one-comment placeholders for the main session.
+- **The first proof.** `scripts/seed-proof.js` creates `cozmo-offline` hidden from Chat message 86957 and VOD 6025
+ @ 1541 s (the canonical target of `/v/6026?t=864`), with its two citations and a noLooks-cleaned summary.
+- **Tests.** Migration, importer, re-verification, deny list, noLooks, votes, captions, staff, Search document,
+ SSO, pages/cache and account export/deletion, each in its own process on a temporary PGlite database with a mock
+ Network and a Chat/Live/Video fixture server.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are 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.
+
+ 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.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ 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 Affero 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. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ 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 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 work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero 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 Affero 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 Affero 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 Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero 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 Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ 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 AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,199 @@
@@ -0,0 +1,199 @@
+# OpenVibe.Meme
+
+> The open meme database, rebuilt with AI.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `openvibe.meme` · **Port:** 5120 · **Service id:** `meme` · **Env prefix:** `MEME`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Meme turns OpenVibe's own running jokes into entries with a citation to the moment each one happened —
+a chat line, a VOD second, a Rundown segment — a timeline of later uses, a status (`rising`, `peaked`, `classic`,
+`dead`), a weekly vote and a caption generator on templates. Every public entry is also a Search Fabric document.
+
+## Purpose
+
+- **Entries, cited.** Every entry has one origin citation and any number of later uses; the quote is verbatim,
+ the URL is canonical (OpenVibe.Video's `?t=` redirect is followed, e.g. `/v/6026?t=864` → `/v/6025?t=1541`),
+ and `quote_hash` is its sha256. A summary is AI-written and passes `noLooks`; if it names looks it is replaced by
+ the title. Verbatim quotes are never rewritten.
+- **Candidates stay hidden.** `server/importer.js` reads public Chat/Live APIs only, finds a 3–6 word phrase used at
+ least twice on two different days, attaches citations and inserts the entry `hidden`. It never publishes. A staff
+ person (Network role `global_mod` or above) publishes it with `POST /api/v1/entries/:slug/publish`.
+- **The citation is checked again.** A daily job reads every public citation URL; a 404/410 marks it `gone`, a
+ 401/403 marks it `hidden`, and an entry whose origin is gone is hidden (revision +1, Search tombstone).
+- **Meme of the week.** People vote once per ISO week; a second vote changes the choice, never adds a ballot.
+- **The generator.** Pick a template, type 1–4 lines of at most 120 characters each and preview them as HTML text
+ over the image; a caption is stored as text lines, never as a rendered image.
+
+## Owns
+
+| Table | Holds | Migration |
+|---|---|---|
+| `meme_entries` | `mem_` + ULID, unique slug (`/m/<slug>`), title, kind `joke\|catchphrase\|bit\|meme\|saga`, AI summary, status + `status_since`, first/last seen, visibility `public\|unlisted\|hidden`, `revision` (+1 on every change), timestamps | `migrations/0001_initial.sql` |
+| `meme_citations` | entry, role `origin\|use\|example`, source service/type/id, stream/VOD/clip/message ids, canonical VOD `offset_seconds`, verbatim `quote_text`, `quote_hash`, canonical `url`, `observed_at`, `source_status` `live\|hidden\|gone` | `0001_initial.sql` |
+| `meme_status_events` | every status change: from, to, reason, who, when | `0001_initial.sql` |
+| `meme_votes` | (`week` `YYYY-Www`, `subject`) → entry; one vote a week | `0001_initial.sql` |
+| `meme_templates` | id, name, `image_url` (openvibe.media or a local data-free placeholder), `box_count`, source, license; three placeholders seeded | `0001_initial.sql` |
+| `meme_captions` | `mcp_` + ULID, subject, template, `lines` jsonb (1–4 plain-text lines), visibility, created | `0001_initial.sql` |
+| `meme_reports` | `mrp_` + ULID, entry, subject, kind `takedown\|correction`, reason, note, status | `0001_initial.sql` |
+| `meme_outbox` | Today's outbox shape: fixed `evt_` id, topic, JSON payload, attempts, last error, claim/backoff lease — the Search document queue | `0001_initial.sql` |
+| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
+| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
+
+## Does not own
+
+- **Chat, Live and the VODs** — OpenVibe.Chat and OpenVibe.Live. Meme reads their public history, Rundown and
+ transcripts. It never reads a private VOD, never uses a service grant in v1, and never cites VOD **5709** or clip
+ **483** (the fixed deny list).
+- **Identity, roles and accounts** — OpenVibe.Network. Meme verifies session tokens against its JWKS; a staff
+ route asks for `global_mod` or above.
+- **Search** — OpenVibe.Search. Meme produces `search.index-document@1` documents in its own outbox; the Search
+ document writer is not wired yet because `meme` has no `search.document.write` grant.
+- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
+
+## Depends on
+
+Environment variable names are in [.env.example](.env.example); no value is committed.
+
+- **OpenVibe.Network** — sign-in (OAuth client `meme`, PKCE S256, cookies `meme_at`/`meme_rt`/`meme_oauth`), JWKS,
+ token exchange/refresh/revoke, account export/deletion, staff roles: `OV_NETWORK_URL`,
+ `OV_NETWORK_INTERNAL_URL`, `OV_NETWORK_ISSUER`, `OV_OAUTH_CLIENT_ID`, `OV_OAUTH_CLIENT_SECRET`,
+ `OV_OAUTH_REDIRECT_URI`, `OV_SESSION_AUDIENCE`, `MEME_AUDIENCE`.
+- **OpenVibe.Chat / Live / Video** — the public reads: `OV_CHAT_URL`, `OV_LIVE_URL`, `OV_VIDEO_URL`,
+ `MEME_UPSTREAM_TIMEOUT_MS`.
+- **OpenVibe.Events** — account deliveries: `MEME_EVENTS_SECRET`, `MEME_EVENTS_URL`, `MEME_EVENTS_ENDPOINT`.
+- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
+ `MEME_PGLITE_DIR` for development.
+- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).
+- **Packages** — `openvibe-contracts` v0.229.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same
+ tags OpenVibe.Today pins.
+
+## API
+
+Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `meme_at`
+session cookie or `Authorization: Bearer <Network user token>`, verified offline with the Network JWKS. A write
+made with the session cookie must come from `https://openvibe.meme` (403 `meme.cross_site`). A signed-in account
+also gets per-actor minute/hour limits (`MEME_LIMITS_MINUTE`, `MEME_LIMITS_HOUR`).
+
+| Route | What it does |
+|---|---|
+| `GET /entries?limit=&cursor=&kind=&status=` | public entries, newest first; opaque keyset cursor; `Cache-Control: public, max-age=60` |
+| `GET /entries/:slug` | one entry with `origin`, every `citation`, the use `timeline` and `examples`; a hidden one is 404 unless staff |
+| `GET /entries/:slug/series?days=30` | uses per UTC day, zero-filled, 1–365 days |
+| `GET /trending?limit=` | public entries, `rising` first, then `peaked`, `classic`, `dead`; last use newest first |
+| `GET /week` | this ISO week's candidates (public entries with a live use that week) and their vote counts |
+| `POST /week/vote` | signed in, same-origin; `{ entry_id }`; upsert per (`week`, subject) — one vote a week |
+| `GET /templates` | the caption templates with their caption counts |
+| `POST /captions` | signed in; `{ template_id, lines, visibility? }`, 1–4 lines × 120 chars, blocked words refused |
+| `GET /captions/:id` | one caption; a public read never returns the author's subject |
+| `POST /entries/:slug/report` | signed in; `{ kind: "takedown"\|"correction", reason, note? }`, stored for staff |
+| `GET /reports` | staff: the open reports |
+| `POST /entries/:slug/publish` | staff: hidden → public; `409 meme.origin_gone` when the origin is not live |
+| `POST /entries/:slug/hide` | staff: public → hidden; Search tombstone |
+| `PATCH /entries/:slug` | staff: `{ title, kind, status, reason? }`; a status change writes `meme_status_events` |
+
+Validation refusals are `400 { code: "meme.invalid", message }`; a blocked caption is
+`400 { code: "meme.blocked_words" }`. Other codes: `meme.sign_in_required`, `meme.staff_required`,
+`meme.cross_site`, `meme.not_found`, `meme.template_not_found`, `meme.origin_gone`, `meme.rate_limited`.
+
+## The importer
+
+`server/importer.js` runs every 30 minutes (`MEME_IMPORTER_INTERVAL_MS`), off under `MEME_DRILL`; tests call
+`runOnce()`. It reads:
+
+1. **Chat global history** — verbatim lines with ids and timestamps, each a usable citation.
+2. **Chat global timeline** — a model paraphrase with no message id, so it is a discovery hint only; it never
+ becomes a citation.
+3. **Rundown topics and segments** — every quote is verbatim speech with `vod_id` and a VOD second, with `said_at`.
+4. **VOD transcripts** — a segment is used only when the public VOD row gives a date; source part ids are
+ canonicalised through OpenVibe.Video before a citation is stored.
+
+A 3–6 word phrase that starts and ends with a content word, appears in at least `MEME_IMPORTER_MIN_USES` uses on at
+least `MEME_IMPORTER_MIN_DAYS` different days, and is not a substring of a stronger phrase becomes one candidate
+(at most `MEME_IMPORTER_MAX_CANDIDATES` per run). Its earliest use is `origin`, the rest are `use`, and the entry
+is inserted `hidden`. Re-running enriches the existing slug with new uses and an extended first/last range; it
+never creates a duplicate and never publishes. Uses naming the deny list are dropped before anything is written.
+
+## Citations and re-verification
+
+`server/reverify.js` runs daily (`MEME_REVERIFY_INTERVAL_MS`), off under `MEME_DRILL`. Each citation is read again:
+
+- a Chat line is looked up in `GET /api/chat/global/history?before=<timestamp+1m>&limit=500`; a missing id (or a
+ deleted message) is `gone`/`hidden`;
+- every other citation's canonical URL is fetched; 404/410 → `gone`, 401/403 → `hidden`, 2xx/3xx → `live`;
+- a redirect to a joined VOD updates `vod_id`, `offset_seconds` and `url`;
+- an entry whose origin is no longer `live` is hidden (`revision` +1) and a Search tombstone is queued.
+
+A transient 5xx is recorded and changes nothing: a hiccup must not erase a real citation.
+
+## Search
+
+`server/search.js` builds one `search.index-document@1` for each public entry: owner `meme`, type `entry`, id
+`mem_…`, revision = the entry's `revision`, canonical page `${BASE_URL}/m/<slug>`, title/summary/body (the
+verbatim quotes as plain text), facets `kind`/`status`/`source`, authorship `ai_assisted`, and one provenance
+reference per live citation. Publishing or editing queues `meme.index_document.upserted`; hiding, deleting or
+losing the origin queues `meme.index_document.deleted` (a tombstone wins over an older revision). Rows live in
+`meme_outbox` in the same transaction as the entry change and wait for the `search.document.write` grant; the relay
+is deliberately not part of v1. The `search-feed` recipe (Work, Help, Rent, Quest) is followed: a document from one
+row, a sequencer revision in the row, a tombstone when the page is no longer public.
+
+## Pages
+
+All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with semantic
+HTML and stable `mm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a
+signed-in person is `private, no-store`.
+
+| Page | What it is |
+|---|---|
+| `/` | trending, meme of the week, the generator teaser |
+| `/m/:slug` | one entry: origin with its citation link, the use timeline, examples, and a report form when signed in |
+| `/week` | the ballot (`POST /week/vote`) |
+| `/make` | the generator form: template, 1–4 lines, a server-rendered preview (`GET /make`), save (`POST /make` → 303 `/c/:id`) |
+| `/c/:id` | one saved caption, the lines over the template image |
+| `/developers` | the API, the Search document, the importer's sources |
+| 404 | the not-found page |
+
+`public/css/meme.css` and `public/js/meme.js` are one-comment placeholder files: the pages are complete without
+them, and the main session designs them. The templates' images are local data-free SVG placeholders (or an
+`openvibe.media` object when staff add a real one). `robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list
+the public pages and every public entry.
+
+## The first proof
+
+`scripts/seed-proof.js` reads the live public APIs and creates **one hidden entry**:
+
+- slug `cozmo-offline`, title `Cozmo is offline`, kind `joke`, status `classic`, visibility `hidden`;
+- origin: Chat message **86957** (2026-10-28 00:11:53, “your Cozmo ain’t online yet”);
+- use: VOD **6025** at **1541 s** — the canonical target of `/v/6026?t=864` (source part 6026 at 864.08 s), quote
+ “but yeah and Cosmo is broken now…”;
+- summary AI-written and `noLooks`-cleaned; `first_seen_at`/`last_seen_at` from the two citations.
+
+It never publishes. A re-run leaves the existing entry and adds only a citation that is not already there. VOD
+5709 and clip 483 are refused before anything is written.
+
+## Run and test
+
+```sh
+npm install
+npm test # PGlite; every test file in its own process, no network needed
+npm run test:pg # the same through PostgreSQL + PgBouncer (openvibe-sdk scripts/test-services.sh up)
+OV_ALLOW_SUITE=1 npm test
+npm start # PORT 5120, loopback; DATABASE_URL or an embedded PGlite in development
+node scripts/seed-proof.js # the first proof, hidden, from the live public APIs
+```
+
+The tests boot the app with a mock Network, a Chat/Live/Video fixture server, and a temporary database: the
+migration, the importer's candidate and its canonical redirect, the deny list, `noLooks`, the one-vote-a-week rule,
+the caption limits, staff publish/patch/hide, the Search document (validated against
+`search.index-document@1`), the citation 404 → gone → hidden path, sign-in, the pages and cache headers, and the
+account export/deletion over votes, captions and reports.
+
+## Deploy
+
+`deploy/systemd/openvibe-meme.service` (127.0.0.1:5120, `/etc/openvibe/meme.env`) and
+`deploy/nginx/openvibe.meme.conf` (TLS, www → apex, `/auth/me` on the API zone, `/internal` and `/metrics` 404, a
+forms zone for the POST routes). Not deployed by this change.
+
+## License
+
+AGPL-3.0-only. See [LICENSE](LICENSE).
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+# Security policy
+
+OpenVibe is run in the open, and we want to hear about security problems before anyone else does.
+
+## Reporting a vulnerability
+
+Email **[email protected]** with "security" in the subject. Please include what you found, where (URL,
+repository, file), how to reproduce it, and what an attacker could do with it. Do not open a public GitHub issue for
+a vulnerability, and do not test against other people's accounts, data or streams.
+
+We reply within 7 days, keep you updated while we fix it, and credit you when the fix ships if you would like.
+
+## Scope
+
+Every OpenVibe service and site (openvibe.live, openvibe.network, openvibe.meme, openvibe.today, openvibe.tools,
+openvibe.media, openvibe.community, openvibe.games and the other openvibe.* domains) and every repository under
+github.com/OpenVibers. Machine-readable contact details are at `/.well-known/security.txt` on each site.
+
+## What OpenVibe.Meme holds
+
+- Public entries: the title, kind, AI-written summary, status, times and revision. An entry is the network's record,
+ not a person's row, so it is not exported or erased with an account.
+- Public citations: a verbatim quote from public speech, its sha256, the canonical public URL and the source ids
+ (chat message id, VOD/clip id, Rundown segment id). No private VOD bytes, thumbnails or metadata are kept.
+- A person's weekly votes, saved caption lines and reports. These are exported and erased with an account.
+- Network session tokens only in httpOnly cookies (`meme_at`, `meme_rt`); no token is stored in the database,
+ rendered into a page or logged.
+
+VOD 5709 and clip 483 are a fixed deny list: no title, thumbnail or quote from either is ever stored, and a
+citation naming them is dropped before it is written. A citation URL is re-read daily; a 404 marks it gone.
+
+A caption is plain text lines on a template; v1 never accepts, renders or stores an image uploaded by a person.
+The `/internal/events` account route is loopback only and refuses a proxied request; nginx answers 404 for
+`/internal` and `/metrics`.
+
+## Supported versions
+
+Only the current `main` branch, which is what runs in production, receives fixes.
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+{
+ "repository": "OpenVibers/OpenVibe.Meme",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Meme: the open meme database rebuilt with AI — entries built from OpenVibe's own running jokes, each cited to the moment it happened, a timeline of later uses, a status, meme of the week and a caption generator on templates",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5120",
+ "domain": "openvibe.meme",
+ "contracts": "openvibe-contracts v0.229.0",
+ "sdk": "openvibe-sdk v0.70.0",
+ "shared": "openvibe-shared v4.31.0",
+ "packages": [
+ "openvibe-contracts v0.229.0",
+ "openvibe-sdk v0.70.0",
+ "openvibe-shared v4.31.0"
+ ],
+ "summary": "OpenVibe.Meme is the open meme database, rebuilt with AI. The importer reads only public Chat and Live APIs and proposes a candidate when a recurring phrase appears in at least two uses on two different days; the candidate is born hidden with a verbatim citation for every use and stays hidden until a staff person publishes it. Every citation is re-verified daily, a 404 marks it gone and an entry whose origin is gone is hidden. People vote once a week for meme of the week, and a caption is plain text over a template, never a rendered image. Every public entry is one Search Fabric document queued in the service's outbox.",
+ "works": [
+ "Entries with a unique slug, kind, AI summary passing noLooks, status and status history, first/last seen, visibility and a revision that goes up on every change",
+ "Citations with role origin/use/example, verbatim quote and sha256, canonical OpenVibe.Video URL, VOD seconds, observed time and source_status live/hidden/gone",
+ "The public importer: Chat history/timeline, Rundown topics and segments, VOD transcripts; a recurring phrase across ≥2 uses on ≥2 days; hidden candidates only, never a publish",
+ "Daily citation re-verification: 404/410 → gone, 401/403 → hidden, the origin gone hides the entry, a transient 5xx changes nothing",
+ "The fixed deny list: VOD 5709 and clip 483 are never cited, cached or rendered",
+ "Meme of the week: ISO weeks, one ballot per week and person, a second vote changes the choice",
+ "The caption generator: a template, 1–4 lines × 120 characters, a blocked-word list, server-rendered HTML preview, text lines stored in jsonb",
+ "The API: public cursor-paged entries, an entry with citations and timeline, a zero-filled use series, trending, the week, templates, captions, reports, and staff publish/hide/patch",
+ "Search Fabric documents (owner meme, type entry, /m/<slug>) in meme_outbox, with a tombstone when an entry is hidden",
+ "Sign-in with OpenVibe.Network (PKCE S256, meme_at/meme_rt/meme_oauth), staff role global_mod+",
+ "Account export and deletion over votes, captions and reports",
+ "Server-rendered pages with stable mm- ids/classes, cache headers, discovery artifacts and deploy files"
+ ],
+ "notYet": [
+ "The Search document relay: documents wait in meme_outbox until Network grants meme search.document.write",
+ "A per-message Chat permalink API; a chat citation stores the message id and the public history is re-read to verify it",
+ "Automatic status transitions by the proposed rising/peaked/classic/dead rules; the importer sets a first status and staff can change it",
+ "Real template artwork: v1 ships three data-free local SVG placeholders; a real template is an openvibe.media object",
+ "A staff moderation screen beyond GET /api/v1/reports; reports are stored and read through the API",
+ "A cron/startup backfill of the proof beyond scripts/seed-proof.js"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "entries and citations",
+ "hidden candidate importer",
+ "citation re-verification and deny list",
+ "meme of the week vote",
+ "caption generator",
+ "templates",
+ "reports",
+ "staff publish/hide/patch",
+ "Search Fabric outbox",
+ "account export and deletion",
+ "Network sign-in",
+ "server-rendered pages",
+ "discovery",
+ "deploy files"
+ ],
+ "deployment": "not deployed: the unit and nginx vhost are deploy/systemd/openvibe-meme.service and deploy/nginx/openvibe.meme.conf",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.meme — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.meme.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5120, systemd unit openvibe-meme) renders every page and serves
+## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the forms, and compresses
+## text.
+##
+## No proxy_cache: the app decides cacheability per response (public for the public pages and entries; private,
+## no-store for anything drawn for a signed-in person). Never log request bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=meme_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=meme_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=meme_forms:10m rate=1r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.meme www.openvibe.meme;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.meme$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.meme;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.meme/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.meme/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.meme$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.meme;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.meme/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.meme/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.meme.access.log;
+ error_log /var/log/nginx/openvibe.meme.error.log;
+
+ # The API's JSON body cap is 64 KB; leave room for the headers.
+ client_max_body_size 128k;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints ───────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ limit_req zone=meme_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5120;
+ }
+
+ location /auth/ {
+ limit_req zone=meme_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5120;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=meme_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5120;
+ }
+
+ # ── Forms and other writes: the vote, the caption generator, the report ──
+ location ~ ^/(week/vote|make|m/[a-z0-9-]+/report)$ {
+ limit_req zone=meme_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5120;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5120/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5120/internal/events directly. Never proxied from outside (ADR-033).
+ location ~* ^/internal(/|$) { return 404; }
+
+ # ── Pages, docs, robots.txt, sitemap, static assets ─────
+ location / {
+ proxy_pass http://127.0.0.1:5120;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe.Meme — the open meme database, rebuilt with AI.
+# Network (sign-in, JWKS, account events) should be up first, but Meme degrades honestly without it: the public
+# pages and API serve, and sign-in and the guarded routes wait for the signing key (see /api/ready).
+After=network.target postgresql.service pgbouncer.service valkey-server.service
+Wants=openvibe-network.service postgresql.service pgbouncer.service valkey-server.service
+
+[Service]
+Type=simple
+User=ubuntu
+Group=ubuntu
+WorkingDirectory=/opt/openvibe.meme
+EnvironmentFile=/etc/openvibe/meme.env
+Environment=NODE_ENV=production
+Environment=PORT=5120
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.meme/server/index.js
+Restart=always
+RestartSec=5
+KillSignal=SIGTERM
+# In-flight requests get the 10 s drain.
+TimeoutStopSec=15
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ProtectHome=true
+# OpenVibe.Meme's own database is PostgreSQL on the host's data role; the code tree stays read-only.
+
+[Install]
+WantedBy=multi-user.target
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -0,0 +1,207 @@
+-- phase: expand
+-- OpenVibe.Meme: the open meme database, rebuilt with AI. Every entry is built from OpenVibe's own running
+-- jokes and cites the moment it happened (a chat line, a VOD second, a Rundown segment). A candidate is born
+-- hidden and only a staff person publishes it. Nothing written here is a credential: a citation stores the
+-- public URL and a hash of the verbatim quote, never private bytes or a person's token.
+
+CREATE TABLE meme_entries (
+ id text COLLATE "C" PRIMARY KEY, -- mem_ + 26 Crockford base32 characters (ULID)
+ slug text COLLATE "C" NOT NULL UNIQUE, -- /m/<slug>
+ title text NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- joke | catchphrase | bit | meme | saga
+ summary text, -- AI-written, noLooks-cleaned; replaced by title when it names looks
+ status text COLLATE "C" NOT NULL DEFAULT 'rising',-- rising | peaked | classic | dead
+ status_since timestamptz NOT NULL DEFAULT now(),
+ first_seen_at timestamptz NOT NULL,
+ last_seen_at timestamptz NOT NULL,
+ visibility text COLLATE "C" NOT NULL DEFAULT 'hidden',-- public | unlisted | hidden
+ revision bigint NOT NULL DEFAULT 1, -- +1 on every change; the Search revision
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT meme_entries_id CHECK (id ~ '^mem_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT meme_entries_slug CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$' AND length(slug) <= 80),
+ CONSTRAINT meme_entries_title CHECK (length(title) BETWEEN 1 AND 160),
+ CONSTRAINT meme_entries_kind CHECK (kind IN ('joke', 'catchphrase', 'bit', 'meme', 'saga')),
+ CONSTRAINT meme_entries_status CHECK (status IN ('rising', 'peaked', 'classic', 'dead')),
+ CONSTRAINT meme_entries_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden')),
+ CONSTRAINT meme_entries_revision CHECK (revision > 0),
+ CONSTRAINT meme_entries_seen CHECK (first_seen_at <= last_seen_at)
+);
+CREATE INDEX meme_entries_public ON meme_entries (visibility, status, last_seen_at DESC);
+CREATE INDEX meme_entries_by_status ON meme_entries (status, last_seen_at DESC);
+CREATE INDEX meme_entries_by_first_seen ON meme_entries (first_seen_at DESC, id DESC);
+
+-- Where a use happened. role origin is the first moment (one per entry in practice), use is a later use, example
+-- is an illustrative citation. quote_text is verbatim human speech, never rewritten; quote_hash is sha256 of it,
+-- so a re-verification can tell whether the source still says the same thing. source_status: live | hidden | gone.
+CREATE TABLE meme_citations (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,
+ role text COLLATE "C" NOT NULL, -- origin | use | example
+ source_service text COLLATE "C" NOT NULL, -- live | chat | community
+ source_type text COLLATE "C" NOT NULL, -- chat_message | vod | clip | rundown_segment | ai_paste | stream_memory
+ source_id text COLLATE "C" NOT NULL, -- message id, VOD id, clip id, segment id, paste slug
+ stream_id bigint,
+ vod_id bigint,
+ clip_id bigint,
+ chat_message_id bigint,
+ offset_seconds numeric, -- canonical VOD seconds (never stream seconds)
+ quote_text text NOT NULL,
+ quote_hash text COLLATE "C" NOT NULL,
+ url text NOT NULL, -- canonical public URL, e.g. https://openvibe.video/v/6025?t=1541
+ observed_at timestamptz NOT NULL, -- when the use happened
+ source_status text COLLATE "C" NOT NULL DEFAULT 'live',
+ captured_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT meme_citations_role CHECK (role IN ('origin', 'use', 'example')),
+ CONSTRAINT meme_citations_source_service CHECK (source_service IN ('live', 'chat', 'community')),
+ CONSTRAINT meme_citations_source_type CHECK (source_type IN ('chat_message', 'vod', 'clip', 'rundown_segment', 'ai_paste', 'stream_memory')),
+ CONSTRAINT meme_citations_quote CHECK (length(quote_text) >= 1),
+ CONSTRAINT meme_citations_hash CHECK (quote_hash ~ '^[0-9a-f]{64}$'),
+ CONSTRAINT meme_citations_url CHECK (url ~ '^https?://'),
+ CONSTRAINT meme_citations_offset CHECK (offset_seconds IS NULL OR offset_seconds >= 0),
+ CONSTRAINT meme_citations_status CHECK (source_status IN ('live', 'hidden', 'gone'))
+);
+CREATE UNIQUE INDEX meme_citations_unique ON meme_citations
+ (entry_id, source_service, source_type, source_id, COALESCE(offset_seconds, -1));
+CREATE INDEX meme_citations_by_entry ON meme_citations (entry_id, role, observed_at);
+CREATE INDEX meme_citations_for_reverify ON meme_citations (source_status, captured_at);
+
+CREATE TABLE meme_status_events (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,
+ from_status text COLLATE "C",
+ to_status text COLLATE "C" NOT NULL,
+ reason text,
+ changed_by text COLLATE "C", -- usr_… or service:meme
+ changed_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT meme_status_events_from CHECK (from_status IS NULL OR from_status IN ('rising', 'peaked', 'classic', 'dead')),
+ CONSTRAINT meme_status_events_to CHECK (to_status IN ('rising', 'peaked', 'classic', 'dead'))
+);
+CREATE INDEX meme_status_events_by_entry ON meme_status_events (entry_id, changed_at DESC);
+
+-- One vote a week: (week, subject) is the key, so a second vote in the same ISO week changes the choice, never
+-- adds a second ballot. week is YYYY-Www (ISO-8601, UTC).
+CREATE TABLE meme_votes (
+ week text COLLATE "C" NOT NULL,
+ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (week, subject),
+ CONSTRAINT meme_votes_week CHECK (week ~ '^[0-9]{4}-W(0[1-9]|[1-4][0-9]|5[0-3])$'),
+ CONSTRAINT meme_votes_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$')
+);
+CREATE INDEX meme_votes_by_entry ON meme_votes (week, entry_id);
+
+CREATE TABLE meme_templates (
+ id text COLLATE "C" PRIMARY KEY,
+ name text NOT NULL,
+ image_url text NOT NULL, -- openvibe.media object, or a local data-free placeholder
+ box_count integer NOT NULL,
+ source text NOT NULL,
+ license text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT meme_templates_box_count CHECK (box_count BETWEEN 1 AND 8),
+ CONSTRAINT meme_templates_image CHECK (image_url ~ '^(https://openvibe\.media/|/img/)'),
+ CONSTRAINT meme_templates_name CHECK (length(name) BETWEEN 1 AND 80)
+);
+
+CREATE TABLE meme_captions (
+ id text COLLATE "C" PRIMARY KEY, -- mcp_ + ULID
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ template_id text COLLATE "C" NOT NULL REFERENCES meme_templates (id),
+ lines jsonb NOT NULL, -- array of 1–4 plain-text lines
+ created_at timestamptz NOT NULL DEFAULT now(),
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public', -- public | unlisted | hidden
+ CONSTRAINT meme_captions_id CHECK (id ~ '^mcp_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT meme_captions_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT meme_captions_lines CHECK (jsonb_typeof(lines) = 'array' AND jsonb_array_length(lines) BETWEEN 1 AND 4),
+ CONSTRAINT meme_captions_visibility CHECK (visibility IN ('public', 'unlisted', 'hidden'))
+);
+CREATE INDEX meme_captions_by_subject ON meme_captions (subject, created_at DESC);
+CREATE INDEX meme_captions_by_template ON meme_captions (template_id, created_at DESC);
+
+CREATE TABLE meme_reports (
+ id text COLLATE "C" PRIMARY KEY, -- mrp_ + ULID
+ entry_id text COLLATE "C" NOT NULL REFERENCES meme_entries (id) ON DELETE CASCADE,
+ subject text COLLATE "C" NOT NULL,
+ kind text COLLATE "C" NOT NULL, -- takedown | correction
+ reason text NOT NULL,
+ note text,
+ status text COLLATE "C" NOT NULL DEFAULT 'open', -- open | handled | dismissed
+ created_at timestamptz NOT NULL DEFAULT now(),
+ handled_at timestamptz,
+ handled_by text COLLATE "C",
+ CONSTRAINT meme_reports_id CHECK (id ~ '^mrp_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT meme_reports_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT meme_reports_kind CHECK (kind IN ('takedown', 'correction')),
+ CONSTRAINT meme_reports_reason CHECK (length(reason) BETWEEN 3 AND 2000),
+ CONSTRAINT meme_reports_note CHECK (note IS NULL OR length(note) <= 4000),
+ CONSTRAINT meme_reports_status CHECK (status IN ('open', 'handled', 'dismissed'))
+);
+CREATE INDEX meme_reports_by_entry ON meme_reports (entry_id, created_at DESC);
+CREATE INDEX meme_reports_open ON meme_reports (status, created_at);
+
+-- The Search Fabric outbox (Today's shape). A document for a public entry is written with this fixed evt_ id in
+-- the same transaction as the entry change; a hidden entry writes a tombstone. No service grant is needed to
+-- queue it, and the rows wait until OpenVibe.Search's document writer is configured.
+CREATE TABLE meme_outbox (
+ event_id text COLLATE "C" PRIMARY KEY,
+ topic text COLLATE "C" NOT NULL,
+ payload jsonb NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ published_at timestamptz,
+ attempts integer NOT NULL DEFAULT 0,
+ last_error text,
+ next_attempt_at timestamptz NOT NULL DEFAULT now(),
+ claimed_at timestamptz,
+ claimed_by text
+);
+CREATE INDEX meme_outbox_pending ON meme_outbox (next_attempt_at) WHERE published_at IS NULL;
+
+-- openvibe-sdk/account-data keeps its own idempotency here (ADR-033): a redelivered export or deletion changes
+-- nothing. It holds an export_id or deletion_id, the subject and the counts sent to Network; it is not a secret.
+CREATE TABLE account_data_events (
+ id text PRIMARY KEY,
+ kind text NOT NULL,
+ subject text NOT NULL,
+ outcome jsonb,
+ applied_at timestamptz NOT NULL DEFAULT now(),
+ sent_at timestamptz
+);
+
+-- Request telemetry (openvibe-sdk/telemetry): one row per aggregated sample, pruned after 14 days.
+CREATE TABLE telemetry_samples (
+ id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
+ at timestamptz NOT NULL,
+ service text NOT NULL,
+ project text,
+ subject text,
+ resource text,
+ provider text,
+ node text,
+ cell text,
+ region text,
+ operation text NOT NULL,
+ latency_ms double precision,
+ queue_delay_ms double precision,
+ ttfb_ms double precision,
+ throughput_per_second double precision,
+ bytes bigint,
+ status text,
+ cache_status text,
+ cost_estimate double precision,
+ route_epoch bigint,
+ trace_id text,
+ extra jsonb NOT NULL DEFAULT '{}'::jsonb
+);
+CREATE INDEX telemetry_samples_at_idx ON telemetry_samples (at);
+CREATE INDEX telemetry_samples_service_idx ON telemetry_samples (service, operation, at);
+
+-- v1 ships three data-free placeholders; a real template is an openvibe.media object added by staff. The
+-- generator preview is HTML text over the image, and no caption is ever stored as an image.
+INSERT INTO meme_templates (id, name, image_url, box_count, source, license) VALUES
+ ('tpl_classic', 'Classic two-line', '/img/template-classic.svg', 2, 'OpenVibe', 'CC0-1.0'),
+ ('tpl_reaction', 'Reaction', '/img/template-reaction.svg', 1, 'OpenVibe', 'CC0-1.0'),
+ ('tpl_triptych', 'Three panels', '/img/template-triptych.svg', 3, 'OpenVibe', 'CC0-1.0')
+ON CONFLICT (id) DO NOTHING;
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -0,0 +1,1407 @@
+{
+ "name": "openvibe-meme",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-meme",
+ "version": "0.1.0",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ }
+ },
+ "node_modules/@electric-sql/pglite": {
+ "version": "0.5.13",
+ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.13.tgz",
+ "integrity": "sha512-4rZuxK1k07w6sSpJzjMri0B9+HuXuC7V9pxVB51pR0T9KPqXg6TD0mdaEwBA9rkcL4jdht4tGDf/wYzAiGhT7g==",
+ "devOptional": true,
+ "license": "Apache-2.0"
+ },
+ "node_modules/@iovalkey/commands": {
+ "version": "0.1.0",
+ "resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+ "integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+ "license": "MIT"
+ },
+ "node_modules/accepts": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+ "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-types": "~2.1.34",
+ "negotiator": "0.6.3"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ajv": {
+ "version": "8.20.0",
+ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
+ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
+ "license": "MIT",
+ "dependencies": {
+ "fast-deep-equal": "^3.1.3",
+ "fast-uri": "^3.0.1",
+ "json-schema-traverse": "^1.0.0",
+ "require-from-string": "^2.0.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/epoberezkin"
+ }
+ },
+ "node_modules/ajv-formats": {
+ "version": "3.0.1",
+ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
+ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependencies": {
+ "ajv": "^8.0.0"
+ },
+ "peerDependenciesMeta": {
+ "ajv": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/array-flatten": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+ "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+ "license": "MIT"
+ },
+ "node_modules/body-parser": {
+ "version": "1.20.9",
+ "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.9.tgz",
+ "integrity": "sha512-FISIQxV5u91Y9gvuFq3q/tA/qTqLJn3AhRcEvyozyhJTh5OBFFtv827AdHU7Rbyct4r+Ba1/99oFa1Y3yuFsdw==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "content-type": "~1.0.5",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "~1.2.0",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "on-finished": "~2.4.1",
+ "qs": "~6.16.0",
+ "raw-body": "~2.5.3",
+ "type-is": "~1.6.18",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/buffer-equal-constant-time": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
+ "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/bytes": {
+ "version": "3.1.2",
+ "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+ "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/call-bind-apply-helpers": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+ "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/call-bound": {
+ "version": "1.0.4",
+ "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+ "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "get-intrinsic": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/cluster-key-slot": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
+ "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/content-disposition": {
+ "version": "0.5.4",
+ "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+ "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "5.2.1"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/content-type": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+ "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie": {
+ "version": "0.7.2",
+ "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+ "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/cookie-parser": {
+ "version": "1.4.7",
+ "resolved": "https://registry.npmjs.org/cookie-parser/-/cookie-parser-1.4.7.tgz",
+ "integrity": "sha512-nGUvgXnotP3BsjiLX2ypbQnWoGUPIIfHQNZkkC668ntrzGWEZVW70HDEB1qnNGMicPje6EttlIgzo51YSwNQGw==",
+ "license": "MIT",
+ "dependencies": {
+ "cookie": "0.7.2",
+ "cookie-signature": "1.0.6"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/cookie-signature": {
+ "version": "1.0.6",
+ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.6.tgz",
+ "integrity": "sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==",
+ "license": "MIT"
+ },
+ "node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/denque": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
+ "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=0.10"
+ }
+ },
+ "node_modules/depd": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+ "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/destroy": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+ "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8",
+ "npm": "1.2.8000 || >= 1.4.16"
+ }
+ },
+ "node_modules/dotenv": {
+ "version": "16.6.1",
+ "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+ "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+ "license": "BSD-2-Clause",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "url": "https://dotenvx.com"
+ }
+ },
+ "node_modules/dunder-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+ "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "gopd": "^1.2.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/ecdsa-sig-formatter": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz",
+ "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/ee-first": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+ "license": "MIT"
+ },
+ "node_modules/encodeurl": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+ "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/es-define-property": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+ "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-errors": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+ "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/es-object-atoms": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz",
+ "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/escape-html": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+ "license": "MIT"
+ },
+ "node_modules/etag": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+ "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/express": {
+ "version": "4.22.3",
+ "resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
+ "integrity": "sha512-Bdcs4+3qlpVlx2NRn6fgX2Ue2/gGRaPeawebgclM0ERSCqDpA+owF1fdPwjJUTAJWMTuAaxjDf+hzb0/4eKvvw==",
+ "license": "MIT",
+ "dependencies": {
+ "accepts": "~1.3.8",
+ "array-flatten": "1.1.1",
+ "body-parser": "~1.20.5",
+ "content-disposition": "~0.5.4",
+ "content-type": "~1.0.4",
+ "cookie": "~0.7.1",
+ "cookie-signature": "~1.0.6",
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "finalhandler": "~1.3.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.0",
+ "merge-descriptors": "1.0.3",
+ "methods": "~1.1.2",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "path-to-regexp": "~0.1.13",
+ "proxy-addr": "~2.0.7",
+ "qs": "~6.16.0",
+ "range-parser": "~1.2.1",
+ "safe-buffer": "5.2.1",
+ "send": "~0.19.0",
+ "serve-static": "~1.16.2",
+ "setprototypeof": "1.2.0",
+ "statuses": "~2.0.1",
+ "type-is": "~1.6.18",
+ "utils-merge": "1.0.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.10.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/express-rate-limit": {
+ "version": "7.5.1",
+ "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz",
+ "integrity": "sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 16"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/express-rate-limit"
+ },
+ "peerDependencies": {
+ "express": ">= 4.11"
+ }
+ },
+ "node_modules/fast-deep-equal": {
+ "version": "3.1.3",
+ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
+ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
+ "license": "MIT"
+ },
+ "node_modules/fast-uri": {
+ "version": "3.1.9",
+ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.9.tgz",
+ "integrity": "sha512-hS/0Wt3Ya043oKXs0sIxrMpKQITXhMLIrVAKKXi4gozGC9v7YRWVnXIZzjNPTO02M9BdxLCWqK1yRXgNziVyJQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/fastify"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/fastify"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "node_modules/finalhandler": {
+ "version": "1.3.2",
+ "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+ "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "on-finished": "~2.4.1",
+ "parseurl": "~1.3.3",
+ "statuses": "~2.0.2",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/forwarded": {
+ "version": "0.2.0",
+ "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+ "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/fresh": {
+ "version": "0.5.2",
+ "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+ "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/function-bind": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+ "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-intrinsic": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+ "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bind-apply-helpers": "^1.0.2",
+ "es-define-property": "^1.0.1",
+ "es-errors": "^1.3.0",
+ "es-object-atoms": "^1.1.1",
+ "function-bind": "^1.1.2",
+ "get-proto": "^1.0.1",
+ "gopd": "^1.2.0",
+ "has-symbols": "^1.1.0",
+ "hasown": "^2.0.2",
+ "math-intrinsics": "^1.1.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/get-proto": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+ "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+ "license": "MIT",
+ "dependencies": {
+ "dunder-proto": "^1.0.1",
+ "es-object-atoms": "^1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/gopd": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+ "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/has-symbols": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+ "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/hasown": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz",
+ "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==",
+ "license": "MIT",
+ "dependencies": {
+ "function-bind": "^1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/helmet": {
+ "version": "8.3.0",
+ "resolved": "https://registry.npmjs.org/helmet/-/helmet-8.3.0.tgz",
+ "integrity": "sha512-Qgpiaws3Sm30Av8Eah6sjMCZZwjlBu+E68rhpCWBshY1lb09HtLwj5GviX0OyQIn+ulUS0iX0AxN5n3tLZzz1w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=18.0.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/EvanHahn"
+ }
+ },
+ "node_modules/http-errors": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+ "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+ "license": "MIT",
+ "dependencies": {
+ "depd": "~2.0.0",
+ "inherits": "~2.0.4",
+ "setprototypeof": "~1.2.0",
+ "statuses": "~2.0.2",
+ "toidentifier": "~1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/iconv-lite": {
+ "version": "0.4.24",
+ "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+ "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+ "license": "MIT",
+ "dependencies": {
+ "safer-buffer": ">= 2.1.2 < 3"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/inherits": {
+ "version": "2.0.4",
+ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+ "license": "ISC"
+ },
+ "node_modules/iovalkey": {
+ "version": "0.4.0",
+ "resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.4.0.tgz",
+ "integrity": "sha512-OSUKxJ+s44CLdUTaicX4+pVrBN/zHKIYwr2oKhRDuczr19FwcSCXEZHzcMUYWZNh1mNSCV9bNJHW/T6cHVm9Aw==",
+ "license": "MIT",
+ "dependencies": {
+ "@iovalkey/commands": "^0.1.0",
+ "cluster-key-slot": "^1.1.0",
+ "debug": "^4.3.4",
+ "denque": "^2.1.0",
+ "lodash.defaults": "^4.2.0",
+ "lodash.isarguments": "^3.1.0",
+ "redis-errors": "^1.2.0",
+ "redis-parser": "^3.0.0",
+ "standard-as-callback": "^2.1.0"
+ },
+ "engines": {
+ "node": ">=18.12.0"
+ }
+ },
+ "node_modules/iovalkey/node_modules/debug": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
+ "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "^2.1.3"
+ },
+ "engines": {
+ "node": ">=6.0"
+ },
+ "peerDependenciesMeta": {
+ "supports-color": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/iovalkey/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/ipaddr.js": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+ "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.10"
+ }
+ },
+ "node_modules/json-schema-traverse": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
+ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
+ "license": "MIT"
+ },
+ "node_modules/jsonwebtoken": {
+ "version": "9.0.3",
+ "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
+ "integrity": "sha512-MT/xP0CrubFRNLNKvxJ2BYfy53Zkm++5bX9dtuPbqAeQpTVe0MQTFhao8+Cp//EmJp244xt6Drw/GVEGCUj40g==",
+ "license": "MIT",
+ "dependencies": {
+ "jws": "^4.0.1",
+ "lodash.includes": "^4.3.0",
+ "lodash.isboolean": "^3.0.3",
+ "lodash.isinteger": "^4.0.4",
+ "lodash.isnumber": "^3.0.3",
+ "lodash.isplainobject": "^4.0.6",
+ "lodash.isstring": "^4.0.1",
+ "lodash.once": "^4.0.0",
+ "ms": "^2.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": ">=12",
+ "npm": ">=6"
+ }
+ },
+ "node_modules/jsonwebtoken/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/jwa": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz",
+ "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer-equal-constant-time": "^1.0.1",
+ "ecdsa-sig-formatter": "1.0.11",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/jws": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz",
+ "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==",
+ "license": "MIT",
+ "dependencies": {
+ "jwa": "^2.0.1",
+ "safe-buffer": "^5.0.1"
+ }
+ },
+ "node_modules/lodash.defaults": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
+ "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.includes": {
+ "version": "4.3.0",
+ "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
+ "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isarguments": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
+ "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isboolean": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
+ "integrity": "sha512-Bz5mupy2SVbPHURB98VAcw+aHh4vRV5IPNhILUCsOzRmsTmSQ17jIuqopAentWoehktxGd9e/hbIXq980/1QJg==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isinteger": {
+ "version": "4.0.4",
+ "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz",
+ "integrity": "sha512-DBwtEWN2caHQ9/imiNeEA5ys1JoRtRfY3d7V9wkqtbycnAmTvRRmbHKDV4a0EYc678/dia0jrte4tjYwVBaZUA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isnumber": {
+ "version": "3.0.3",
+ "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz",
+ "integrity": "sha512-QYqzpfwO3/CWf3XP+Z+tkQsfaLL/EnUlXWVkIk5FUPc4sBdTehEqZONuyRt2P67PXAk+NXmTBcc97zw9t1FQrw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isplainobject": {
+ "version": "4.0.6",
+ "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz",
+ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.isstring": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz",
+ "integrity": "sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw==",
+ "license": "MIT"
+ },
+ "node_modules/lodash.once": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz",
+ "integrity": "sha512-Sb487aTOCr9drQVL8pIxOzVhafOjZN9UU54hiN8PU3uAiSV7lx1yYNpbNmex2PK6dSJoNTSJUUswT651yww3Mg==",
+ "license": "MIT"
+ },
+ "node_modules/math-intrinsics": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+ "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ }
+ },
+ "node_modules/media-typer": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+ "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/merge-descriptors": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+ "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/methods": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+ "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime": {
+ "version": "1.6.0",
+ "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+ "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+ "license": "MIT",
+ "bin": {
+ "mime": "cli.js"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/mime-db": {
+ "version": "1.52.0",
+ "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+ "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/mime-types": {
+ "version": "2.1.35",
+ "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+ "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": "1.52.0"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/negotiator": {
+ "version": "0.6.3",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+ "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/object-inspect": {
+ "version": "1.13.4",
+ "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+ "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/on-finished": {
+ "version": "2.4.2",
+ "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.2.tgz",
+ "integrity": "sha512-ATWUv387i+nLD49EiDoOft5scf32Vsdz3ktrQJG9bi7jRFmaXTssvyvj1EmeKVVgvesbqUtvQQvaS2TpTukAZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "ee-first": "1.1.1"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/openvibe-contracts": {
+ "version": "0.234.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
+ "integrity": "sha512-qCv+UuC1Q/megqkkLu69f5VBivuZao6jIztYmcj9e4KBT2cBe8zNykkOaQJS/Uu6weU+gy6NVHZ4w1K3SKM7FA==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.70.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.31.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-meme",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Meme — the open meme database, rebuilt with AI.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "seed:proof": "node scripts/seed-proof.js",
+ "test": "node test/run.js",
+ "test:pg": "MEME_TEST_STORE=pg node test/run.js"
+ },
+ "keywords": [
+ "openvibe"
+ ],
+ "author": "OpenVibers",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "cookie-parser": "^1.4.7",
+ "dotenv": "^16.4.7",
+ "express": "^4.21.2",
+ "express-rate-limit": "^7.5.0",
+ "helmet": "^8.1.0",
+ "iovalkey": "^0.4.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.234.0",
+ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "pg": "^8.23.0"
+ },
+ "engines": {
+ "node": ">=22.0.0"
+ },
+ "devDependencies": {
+ "@electric-sql/pglite": "^0.5.8"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,160 @@
@@ -0,0 +1,160 @@
+/* OpenVibe.Meme: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
+ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
+ first; nothing scrolls sideways. A caption is text over a template, drawn here in the classic meme style. */
+*, *::before, *::after { box-sizing: border-box; }
+html, body { margin: 0; background: var(--bg-primary, #0a0f1c); color: var(--text-primary, #e6edf7); font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
+.skip { position: absolute; left: -9999px; }
+.skip:focus { left: 12px; top: 12px; z-index: 99; padding: 8px 12px; border-radius: 10px; background: var(--bg-card, #0f1729); color: inherit; }
+.mm { display: block; }
+.mm a { color: var(--room-strong); }
+.mm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.mm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.mm-narrow { max-width: 620px; }
+.mm-page-pad { padding-top: clamp(28px, 6vw, 64px); }
+.mm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.mm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.mm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
+.mm-page-h h1, .mm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
+.mm-lede, .mm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.mm-fine, .mm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.mm-section { margin-top: clamp(28px, 4vw, 44px); }
+.mm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.mm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
+.mm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
+.mm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
+.mm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
+.mm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
+.mm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
+.mm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
+.mm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.mm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.mm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
+.mm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
+.mm-empty p { margin: 0; line-height: 1.5; }
+.mm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+
+/* Status: rising (the room's gold), peaked (warm), classic (calm), dead (muted). */
+.mm-status { display: inline-flex; align-items: center; padding: 1px 9px; border-radius: 999px; font-size: .76rem; font-weight: 800; letter-spacing: .01em; }
+.mm-status-rising { color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 18%, transparent); }
+.mm-status-peaked { color: var(--warning-text, #f59e0b); background: color-mix(in srgb, var(--warning, #f59e0b) 14%, transparent); }
+.mm-status-classic { color: var(--success-text, #22c55e); background: color-mix(in srgb, var(--success, #22c55e) 14%, transparent); }
+.mm-status-dead { color: var(--text-muted, #7386a3); background: color-mix(in srgb, var(--text-muted, #7386a3) 16%, transparent); }
+
+/* The family-tree motif in the hero */
+.mm-tree { position: relative; width: min(100%, 440px); aspect-ratio: 400 / 280; margin: 0 auto; }
+.mm-tree-lines { position: absolute; inset: 0; width: 100%; height: 100%; }
+.mm-tree-lines path { fill: none; stroke: var(--room-accent); stroke-width: 2.5; stroke-linecap: round; opacity: .55; stroke-dasharray: 260; stroke-dashoffset: 0; }
+html.ov-motion .mm-tree-lines path { animation: mm-grow 1.6s cubic-bezier(.2,.7,.2,1) both; }
+html.ov-motion .mm-tree-lines path:nth-child(n+4) { animation-delay: .5s; }
+@keyframes mm-grow { from { stroke-dashoffset: 260; } }
+.mm-node { position: absolute; translate: -50% -50%; display: grid; gap: 2px; padding: 7px 12px; border-radius: 12px; white-space: nowrap; font-size: .8rem; font-weight: 700;
+ background: var(--bg-card, #0f1729); border: 1px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); box-shadow: 0 8px 22px rgba(0, 0, 0, .22); }
+.mm-node small { font-weight: 600; color: var(--text-muted, #7386a3); font-size: .7rem; }
+.mm-node-root { left: 50%; top: 22%; padding: 10px 16px; font-size: .95rem; background: var(--room-fill); color: var(--room-on); border-color: transparent; }
+.mm-node-root small { color: inherit; opacity: .75; }
+.mm-n1 { left: 23%; top: 67%; } .mm-n2 { left: 50%; top: 67%; } .mm-n3 { left: 77%; top: 67%; }
+.mm-sticker { position: absolute; padding: 4px 10px; border-radius: 8px; font: 900 .78rem/1 Impact, 'Arial Black', sans-serif; letter-spacing: .04em; text-transform: uppercase; color: #fff;
+ background: color-mix(in srgb, var(--room-accent) 70%, #000); rotate: -8deg; box-shadow: 0 6px 14px rgba(0, 0, 0, .25); }
+.mm-s1 { left: 6%; top: 88%; } .mm-s2 { left: 31%; top: 90%; rotate: 7deg; } .mm-s3 { left: 82%; top: 89%; rotate: -4deg; }
+html.ov-motion .mm-sticker { animation: mm-fan .7s cubic-bezier(.2,.9,.3,1.3) both; animation-delay: calc(.9s + var(--d, 0s)); }
+.mm-s2 { --d: .12s; } .mm-s3 { --d: .24s; }
+@keyframes mm-fan { from { opacity: 0; translate: 0 12px; scale: .6; } }
+
+/* Entry cards */
+.mm-entries { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(250px, 1fr)); gap: 10px; }
+.mm-entry-link { display: grid; gap: 6px; height: 100%; padding: 14px 16px; border-radius: 16px; text-decoration: none; color: var(--text-primary, #e6edf7) !important;
+ background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); transition: border-color .15s, transform .15s; }
+.mm-entry-link:hover { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); transform: translateY(-1px); }
+.mm-entry-title { font-size: 1.05rem; font-weight: 800; line-height: 1.3; overflow-wrap: anywhere; }
+.mm-entry-meta, .mm-entry-when { display: flex; flex-wrap: wrap; align-items: center; gap: 6px; color: var(--text-secondary, #96a7c2); font-size: .84rem; }
+.mm-entry-when { color: var(--text-muted, #7386a3); font-size: .8rem; }
+
+/* Citations: the quote is the thing */
+.mm-cite { margin: 0; padding: 16px 18px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); border-left: 4px solid var(--room-accent); }
+.mm-quote { margin: 0; font-size: 1.12rem; line-height: 1.45; font-weight: 600; overflow-wrap: anywhere; }
+.mm-quote::before { content: '“'; color: var(--room-strong); } .mm-quote::after { content: '”'; color: var(--room-strong); }
+.mm-cite-foot { margin-top: 8px; color: var(--text-muted, #7386a3); font-size: .84rem; }
+.mm-cite-source { font-weight: 800; color: var(--text-secondary, #96a7c2); }
+.mm-cite-link { font-weight: 700; }
+.mm-cite-state { margin-left: 8px; padding: 1px 8px; border-radius: 999px; font-size: .74rem; font-weight: 800; color: var(--error-text, #f87171); background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); }
+
+/* An entry */
+.mm-entry-h { padding-top: clamp(20px, 4vw, 40px); }
+.mm-entry-h .mm-kicker { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.mm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
+.mm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.mm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.mm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.mm-origin, .mm-timeline-sec, .mm-examples { margin-top: 30px; }
+.mm-origin h2, .mm-timeline-sec h2, .mm-examples h2 { margin-bottom: 12px; }
+.mm-origin .mm-cite { border-left-width: 6px; padding: 20px 22px; }
+.mm-origin .mm-quote { font-size: clamp(1.2rem, 3vw, 1.6rem); }
+.mm-timeline { list-style: none; margin: 0; padding: 0 0 0 18px; display: grid; gap: 12px; border-left: 2px solid color-mix(in srgb, var(--room-accent) 35%, var(--border, #1f2d47)); }
+.mm-timeline-item { position: relative; display: grid; gap: 6px; }
+.mm-timeline-item::before { content: ''; position: absolute; left: -25px; top: 6px; width: 12px; height: 12px; border-radius: 50%; background: var(--room-fill); box-shadow: 0 0 0 3px var(--bg-primary, #0a0f1c); }
+.mm-timeline-when { font-size: .8rem; font-weight: 800; color: var(--text-muted, #7386a3); }
+.mm-examples .mm-cite + .mm-cite { margin-top: 10px; }
+.mm-entry-foot { margin-top: 32px; }
+.mm-report summary { cursor: pointer; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.mm-staff { color: var(--warning-text, #f59e0b); }
+
+/* The ballot */
+.mm-candidates { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.mm-candidate { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 18px; padding: 14px 16px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.mm-candidate.is-mine { border-color: color-mix(in srgb, var(--room-accent) 60%, var(--border, #1f2d47)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 14%, transparent); }
+.mm-candidate-body { display: grid; gap: 4px; min-width: 0; }
+.mm-candidate-title { font-size: 1.05rem; font-weight: 800; text-decoration: none; overflow-wrap: anywhere; }
+.mm-candidate-meta { display: flex; align-items: center; gap: 6px; font-size: .84rem; color: var(--text-secondary, #96a7c2); }
+.mm-candidate-vote { display: flex; align-items: center; gap: 8px; color: var(--text-muted, #7386a3); font-size: .84rem; }
+.mm-vote-count { font-size: 1.4rem; font-weight: 900; color: var(--text-primary, #e6edf7); font-variant-numeric: tabular-nums; }
+.mm-inline { margin: 0; }
+
+/* The caption generator: forms, templates, the classic meme text */
+.mm-make-grid { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 22px; align-items: start; }
+.mm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.mm-form { display: grid; gap: 14px; }
+.mm-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
+.mm-field input, .mm-field select, .mm-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
+.mm-field input:focus, .mm-field select:focus, .mm-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
+.mm-templates { margin: 0; padding: 0; border: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; }
+.mm-templates legend { margin-bottom: 8px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); }
+.mm-template { position: relative; display: grid; gap: 6px; padding: 8px; border-radius: 14px; border: 2px solid var(--border, #1f2d47); cursor: pointer; background: var(--bg-primary, #0a0f1c); }
+.mm-template input { position: absolute; opacity: 0; pointer-events: none; }
+.mm-template img { width: 100%; height: auto; aspect-ratio: 11 / 7; object-fit: cover; border-radius: 9px; display: block; }
+.mm-template span { display: grid; gap: 1px; font-size: .84rem; }
+.mm-template small { color: var(--text-muted, #7386a3); font-size: .74rem; }
+.mm-template:has(input:checked), .mm-template.is-chosen { border-color: var(--room-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--room-accent) 18%, transparent); }
+.mm-template:has(input:focus-visible) { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.mm-preview { position: sticky; top: 76px; }
+.mm-preview h2 { margin-bottom: 12px; }
+.mm-caption-card { position: relative; margin: 0; overflow: hidden; border-radius: 16px; background: #111; container-type: inline-size; box-shadow: 0 14px 34px rgba(0, 0, 0, .3); }
+.mm-caption-card img { display: block; width: 100%; height: auto; aspect-ratio: 11 / 7; object-fit: cover; }
+.mm-caption-card figcaption { position: absolute; inset: 0; display: flex; flex-direction: column; justify-content: space-between; padding: 4cqw 4cqw 5cqw; pointer-events: none; }
+.mm-caption-card figcaption span { display: block; text-align: center; color: #fff; text-transform: uppercase; overflow-wrap: anywhere;
+ font: 900 clamp(1rem, 7.2cqw, 3.2rem)/1.05 Impact, Haettenschweiler, 'Arial Narrow Bold', 'Arial Black', sans-serif; letter-spacing: .01em;
+ text-shadow: 2px 2px 0 #000, -2px 2px 0 #000, 2px -2px 0 #000, -2px -2px 0 #000, 0 2px 0 #000, 2px 0 0 #000, 0 -2px 0 #000, -2px 0 0 #000; }
+.mm-caption-card figcaption span:empty { display: none; }
+.mm-caption-large { max-width: 760px; }
+
+/* Scenes' demo pieces */
+.mm-demo-cite { max-width: 460px; }
+.mm-demo-rise { display: grid; gap: 10px; padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); justify-items: start; }
+.mm-demo-bars { display: flex; align-items: flex-end; gap: 6px; height: 120px; width: 100%; }
+.mm-demo-bars i { flex: 1; height: calc(var(--h) * 7%); border-radius: 4px 4px 0 0; background: linear-gradient(to top, color-mix(in srgb, var(--room-accent) 45%, transparent), var(--room-accent)); }
+html.ov-motion .mm-demo-bars i { animation: mm-bar .9s cubic-bezier(.2,.7,.2,1) both; animation-delay: calc(var(--h) * 30ms); transform-origin: bottom; }
+@keyframes mm-bar { from { transform: scaleY(0); } }
+.mm-demo-rise small { color: var(--text-muted, #7386a3); }
+.mm-demo-caption { max-width: 460px; }
+
+/* Developers */
+.mm-eps { display: grid; gap: 10px; margin: 12px 0 0; }
+.mm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.mm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
+.mm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+#mm-developers section { margin-top: 26px; }
+
+/* On a phone the room's picture is a faint corner mark behind the title: the branches only, no words to read through. */
+@media (max-width: 759px) { .sc-room-pic .mm-node, .sc-room-pic .mm-sticker { display: none; } }
+@media (max-width: 860px) { .mm-make-grid { grid-template-columns: minmax(0, 1fr); } .mm-preview { position: static; order: -1; } }
+@media (prefers-reduced-motion: reduce) { .mm-tree-lines path, .mm-sticker, .mm-demo-bars i { animation: none !important; } }
+html[data-ov-motion=reduced] .mm-tree-lines path, html[data-ov-motion=reduced] .mm-sticker, html[data-ov-motion=reduced] .mm-demo-bars i { animation: none !important; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="Two empty caption bars on a plain background">
+<rect width="440" height="280" fill="#20242c"/>
+<rect x="28" y="28" width="384" height="176" rx="14" fill="#2b313c"/>
+<circle cx="92" cy="86" r="26" fill="#c3ac00"/>
+<path d="M72 146h244M72 172h176" stroke="#4b5566" stroke-width="10" stroke-linecap="round"/>
+<rect x="28" y="220" width="384" height="32" rx="8" fill="#171a20"/>
+</svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="One empty caption bar under a reaction face outline">
+<rect width="440" height="280" fill="#171a20"/>
+<rect x="36" y="32" width="368" height="148" rx="20" fill="#2b313c"/>
+<circle cx="220" cy="106" r="48" fill="none" stroke="#c3ac00" stroke-width="10"/>
+<path d="M196 96h8M236 96h8M198 126c14 14 30 14 44 0" stroke="#c3ac00" stroke-width="8" stroke-linecap="round" fill="none"/>
+<rect x="60" y="204" width="320" height="34" rx="8" fill="#0f1115"/>
+</svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 440 280" role="img" aria-label="Three empty caption bars in a row">
+<rect width="440" height="280" fill="#20242c"/>
+<rect x="24" y="40" width="120" height="150" rx="12" fill="#2b313c"/>
+<rect x="160" y="40" width="120" height="150" rx="12" fill="#2b313c"/>
+<rect x="296" y="40" width="120" height="150" rx="12" fill="#2b313c"/>
+<path d="M40 160h88M176 160h88M312 160h88" stroke="#4b5566" stroke-width="8" stroke-linecap="round"/>
+<rect x="40" y="214" width="360" height="28" rx="7" fill="#171a20"/>
+</svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+/* OpenVibe.Meme: what the pages are complete without. On /make the preview follows the template and the lines as you
+ * type (the Preview button still works without JavaScript); a template card shows which one is chosen. */
+(function () {
+ 'use strict';
+ function maker() {
+ var form = document.getElementById('mm-caption-form');
+ var aside = document.getElementById('mm-preview');
+ if (!form || !aside || form.dataset.ready) return;
+ form.dataset.ready = '1';
+ var fig = aside.querySelector('.mm-caption-card');
+ if (!fig) {
+ aside.querySelectorAll('.mm-muted').forEach(function (p) { p.remove(); });
+ fig = document.createElement('figure'); fig.className = 'mm-caption-card';
+ fig.appendChild(document.createElement('img')); fig.appendChild(document.createElement('figcaption'));
+ aside.appendChild(fig);
+ }
+ var img = fig.querySelector('img'), cap = fig.querySelector('figcaption');
+ img.alt = '';
+ function draw() {
+ var chosen = form.querySelector('input[name=template_id]:checked');
+ form.querySelectorAll('.mm-template').forEach(function (l) { l.classList.toggle('is-chosen', !!l.querySelector('input:checked')); });
+ if (chosen) { var t = chosen.closest('.mm-template').querySelector('img'); if (t && img.src !== t.src) img.src = t.src; }
+ cap.replaceChildren();
+ for (var i = 1; i <= 4; i++) {
+ var input = form.querySelector('[name=line' + i + ']');
+ var span = document.createElement('span');
+ span.textContent = input ? input.value : '';
+ cap.appendChild(span);
+ }
+ }
+ form.addEventListener('input', draw);
+ form.addEventListener('change', draw);
+ draw();
+ }
+ function run() { maker(); }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,139 @@
@@ -0,0 +1,139 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * The first proof entry: "Cozmo is offline" (research §6.3), seeded from the live public APIs and left hidden for
+ * a staff person to publish.
+ *
+ * node scripts/seed-proof.js
+ *
+ * It reads three public things: the Chat global history around the known line (message 86957, 2026-10-28
+ * 00:11:53), VOD 6026's transcript segment at 864.08 s, and OpenVibe.Video's own redirect for
+ * `/v/6026?t=864` → `/v/6025?t=1541` so the stored citation is the canonical one. Nothing is invented: the quote
+ * is verbatim, the hash is its sha256, and the entry is created `hidden`.
+ *
+ * Re-running is safe: an existing `cozmo-offline` entry is left as it is (a second run adds only a citation that is
+ * not already there). The deny list (VOD 5709, clip 483) is enforced before anything is written.
+ */
+const { load } = require('../server/config');
+const { openStore } = require('../server/db');
+const { createUpstreams, parseApiTime } = require('../server/upstreams');
+const { createStore } = require('../server/store');
+const { cleanSummary } = require('../server/text/no-looks');
+const { isDenied } = require('../server/deny-list');
+
+const CHAT_MESSAGE_ID = 86957;
+const SOURCE_VOD = 6026;
+const SOURCE_SEC = 864.08;
+const TITLE = 'Cozmo is offline';
+const SLUG = 'cozmo-offline';
+
+function short(s, n = 100) { return String(s || '').replace(/\s+/g, ' ').trim().slice(0, n); }
+
+async function findChatLine(upstreams) {
+ // The known before-page: one minute after the line, so the page ends just past it.
+ for (const before of ['2026-10-28 00:12:00', null]) {
+ const page = await upstreams.chatHistory({ before, limit: 200 });
+ const byId = page.messages.find((m) => Number(m.id) === CHAT_MESSAGE_ID);
+ if (byId && Number(byId.is_deleted) !== 1) return byId;
+ const byText = page.messages.find((m) => Number(m.is_deleted) !== 1 && /\bcozmo\b/i.test(String(m.message)) && /\b(online|offline)\b/i.test(String(m.message)));
+ if (byText) return byText;
+ }
+ return null;
+}
+
+async function findSegment(upstreams) {
+ const all = await upstreams.transcripts([SOURCE_VOD]);
+ const t = all[String(SOURCE_VOD)];
+ const segments = t && Array.isArray(t.segments) ? t.segments : [];
+ let best = null;
+ for (const seg of segments) {
+ const d = Math.abs(Number(seg.start) - SOURCE_SEC);
+ if (!best || d < best.d) best = { seg, d };
+ }
+ if (!best || best.d > 5 || !/\b(cosmo|cozmo)\b/i.test(String(best.seg.text))) return null;
+ return best.seg;
+}
+
+async function runSeed({ config, s, upstreams, log = console } = {}) {
+ if (!config) config = load();
+ if (!upstreams) upstreams = createUpstreams({ config, log });
+ if (!s) s = await openStore(config, { log });
+ const search = { sync: async () => null }; // a hidden entry writes no Search document
+ const entries = createStore(s, { search });
+
+ const msg = await findChatLine(upstreams);
+ if (!msg) throw new Error('the public Chat history did not contain message 86957 (or another Cozmo line); nothing was written');
+ const seg = await findSegment(upstreams);
+ if (!seg) throw new Error(`VOD ${SOURCE_VOD}'s public transcript did not contain the Cozmo segment near ${SOURCE_SEC}s; nothing was written`);
+
+ const canon = await upstreams.canonicalVideo(SOURCE_VOD, SOURCE_SEC);
+ if (canon.denied || canon.gone || canon.hidden || !canon.url) {
+ throw new Error(`OpenVibe.Video would not resolve VOD ${SOURCE_VOD}@${SOURCE_SEC} to a public URL (${canon.gone ? '404/gone' : canon.hidden ? 'hidden' : 'denied'}); nothing was written`);
+ }
+ if (isDenied({ vod_id: canon.vod_id })) throw new Error('the resolved VOD is on the fixed deny list; nothing was written');
+
+ const chatAt = parseApiTime(msg.timestamp);
+ let vodAt = null;
+ try { const row = await upstreams.vod(SOURCE_VOD); vodAt = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null; } catch { /* the chat time is enough */ }
+ if (!vodAt) {
+ try { const row = await upstreams.vod(canon.vod_id); vodAt = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null; } catch { /* still fine */ }
+ }
+ const observedVod = vodAt || chatAt;
+
+ const citations = [
+ {
+ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(msg.id),
+ chat_message_id: Number(msg.id), quote_text: String(msg.message), url: `${upstreams.base(config.upstreams.chat)}/`,
+ observed_at: chatAt,
+ },
+ {
+ role: 'use', source_service: 'live', source_type: 'vod', source_id: String(canon.vod_id),
+ vod_id: canon.vod_id, offset_seconds: canon.offset_seconds, quote_text: String(seg.text), url: canon.url,
+ observed_at: observedVod,
+ },
+ ].filter((c) => !isDenied(c));
+ if (citations.length !== 2) throw new Error('a citation landed on the fixed deny list; nothing was written');
+
+ const first = citations.reduce((m, c) => (Date.parse(c.observed_at) < Date.parse(m) ? c.observed_at : m), citations[0].observed_at);
+ const last = citations.reduce((m, c) => (Date.parse(c.observed_at) > Date.parse(m) ? c.observed_at : m), citations[0].observed_at);
+ const summary = cleanSummary(TITLE, 'Cozmo, the robot, is not online: a chat line and a stream moment about its Wi-Fi. The robot is offline, again.');
+
+ const existing = await entries.bySlug(SLUG);
+ if (existing) {
+ const enriched = await entries.enrich(existing.id, { first_seen_at: first, last_seen_at: last, cites: citations });
+ const added = enriched && enriched.added ? enriched.added : 0;
+ log.log(`[seed-proof] ${SLUG} already exists (${existing.id}, ${existing.visibility}); ${added} new citation(s) added — left as it is`);
+ return { created: false, entry: existing, citations, added };
+ }
+
+ const out = await entries.createCandidate({
+ slug: SLUG, title: TITLE, kind: 'joke', summary, status: 'classic',
+ first_seen_at: first, last_seen_at: last, visibility: 'hidden', citations,
+ });
+ log.log(`[seed-proof] created ${out.entry.id} /${SLUG} (${out.entry.visibility}, ${out.entry.status})`);
+ log.log(`[seed-proof] origin: chat message ${msg.id} at ${msg.timestamp} — "${short(msg.message, 90)}"`);
+ log.log(`[seed-proof] use: VOD ${canon.vod_id} @${canon.offset_seconds}s (source part ${SOURCE_VOD} @${SOURCE_SEC}s) — "${short(seg.text, 90)}"`);
+ log.log('[seed-proof] publish it with: POST /api/v1/entries/cozmo-offline/publish (Network role staff+)');
+ return { created: true, entry: out.entry, citations: out.citations };
+}
+
+async function main() {
+ const config = load();
+ const s = await openStore(config, { log: console });
+ try {
+ const out = await runSeed({ config, s });
+ process.exitCode = out && out.entry ? 0 : 1;
+ } finally {
+ await s.close();
+ }
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = { runSeed, findChatLine, findSegment, TITLE, SLUG, CHAT_MESSAGE_ID, SOURCE_VOD, SOURCE_SEC };
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+
+/**
+ * OpenVibe.Meme — Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database and a mock Network.
+ *
+ * /, /m/:slug, /week, /make, /c/:id, /developers the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /internal/events account export/deletion (loopback only)
+ *
+ * The importer and the citation re-verifier are made here but started by server/index.js (and never under
+ * MEME_DRILL); tests drive their runOnce().
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, send } = require('./render/layout');
+const { raw } = require('./render/html');
+
+const accountDataLib = require('./identity/account-data');
+const { createStore } = require('./store');
+const { createSearchIndex } = require('./search');
+const { createUpstreams } = require('./upstreams');
+const { createImporter } = require('./importer');
+const { createReverifier } = require('./reverify');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+const { createHttpTelemetry } = require('openvibe-sdk/service');
+const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
+
+/**
+ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend (a
+ * stand-in for Network's internal routes; tests only), telemetrySink.
+ */
+async function createApp(opts = {}) {
+ const config = opts.config || configLib.load();
+ const log = opts.log || console;
+ const fetchImpl = opts.fetchImpl || globalThis.fetch;
+ const s = opts.store || await openStore(config, { now: opts.now, log });
+
+ const keys = createKeyStore({ config, fetchImpl, log });
+ const sso = createSso({ config, keys, fetchImpl, now: s.now, log });
+ const principal = createPrincipal({ config, keys });
+
+ const search = createSearchIndex({ config, s });
+ const entries = createStore(s, { search });
+ const upstreams = createUpstreams({ config, fetchImpl, log });
+ const importer = createImporter({ config, s, entries, upstreams, fetchImpl, log });
+ const reverifier = createReverifier({ config, s, entries, upstreams, log });
+
+ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
+ // client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Meme cannot answer account events'); });
+
+ const ctx = {
+ config, s, keys, sso, principal, search, entries, upstreams, importer, reverifier, accountData, accountSend, log,
+ };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'meme', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'meme', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'meme', sink: opts.telemetrySink || createPgTelemetrySink({ db: s.db, log }), skipPrefixes: ['/shared'], intervalMs: 60_000 });
+ app.use(ctx.telemetry.middleware());
+ app.locals.ctx = ctx;
+
+ const valkey = opts.valkey !== undefined ? opts.valkey : (config.valkey.url ? require('openvibe-sdk/valkey').createValkey({ url: config.valkey.url, prefix: config.valkey.prefix, log }) : null);
+ ctx.valkey = valkey;
+
+ app.use(contracts.http.middleware());
+ app.use(helmet({
+ contentSecurityPolicy: {
+ directives: {
+ defaultSrc: ["'self'"],
+ scriptSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://static.cloudflareinsights.com'],
+ styleSrc: ["'self'", "'unsafe-inline'", 'https://openvibe.network', 'https://fonts.googleapis.com', 'https://cdnjs.cloudflare.com'],
+ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com', 'https://cdnjs.cloudflare.com'],
+ imgSrc: ["'self'", 'data:', 'https://openvibe.network', 'https://openvibe.media'],
+ connectSrc: ["'self'", 'https://openvibe.network', 'https://cloudflareinsights.com', 'https://openvibe.events'],
+ frameSrc: ["'self'", 'https://openvibe.network'],
+ frameAncestors: ["'none'"],
+ objectSrc: ["'none'"],
+ baseUri: ["'self'"],
+ formAction: ["'self'", 'https://openvibe.network'],
+ },
+ },
+ frameguard: { action: 'deny' },
+ crossOriginEmbedderPolicy: false,
+ crossOriginResourcePolicy: { policy: 'same-site' },
+ referrerPolicy: { policy: 'strict-origin-when-cross-origin' },
+ }));
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-meme', name: 'OpenVibe.Meme', version: VERSION }));
+ release.mount(app, { registry: metrics.registry });
+ const readiness = createServiceReadiness({ s, config, release: release.release, valkey });
+ app.get('/api/ready', readiness.handler);
+
+ // ── Who is asking (verified offline; refreshed when expired) ──
+ app.use(sso.middleware());
+
+ // ── Sign-in (OAuth2 + PKCE client of OpenVibe.Network) ──
+ app.use('/auth/', rateLimit({ windowMs: 15 * 60_000, limit: 60, standardHeaders: true, legacyHeaders: false }));
+ app.use('/auth', sso.routes());
+ { const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'meme', service: 'meme', host: 'openvibe.meme', name: 'OpenVibe.Meme', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
+ // nginx answers 404 for /internal/.
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ app.use('/api/v1', createApi(ctx));
+ app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
+
+ // ── Pages ───────────────────────────────────────────────
+ app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEME_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false }));
+ app.use(createPageRoutes(ctx));
+ app.use((req, res) => send(res, 404, {
+ viewer: req.viewer, config, path: req.originalUrl, title: 'Not found',
+ body: raw(require('./views').notFound()),
+ }));
+
+ // eslint-disable-next-line no-unused-vars
+ app.use((err, req, res, _next) => {
+ log.error('[OpenVibe.Meme]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's Bearer token (the API).
+ *
+ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
+ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
+ * this module: token.no_key answers 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Meme] JWKS mirror refresh failed: ${err && err.message || err}`); mirror = null; }
+ }
+ async function ensure() { await refreshMirror(); }
+ function get() { return mirror ? mirror.pem : null; }
+ function loaded() { return Boolean(mirror); }
+
+ const asMs = (v) => (typeof v === 'function' ? v() : (typeof v === 'number' ? v : Date.now()));
+
+ async function verifyUser(token, { issuer, audience, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyUserToken(token, { jwks: url, issuer, audience, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Meme] user token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code, expired: code === 'token.expired' };
+ }
+ }
+
+ async function verifyApp(token, { issuer, audience, acceptSandbox = true, now = Date.now() } = {}) {
+ try {
+ const claims = await sdk.verifyAppToken(token, { jwks: url, issuer, audience, acceptSandbox, now: asMs(now), log });
+ await refreshMirror();
+ return { ok: true, claims };
+ } catch (err) {
+ await refreshMirror();
+ const code = err && err.code;
+ log.warn(`[OpenVibe.Meme] app token rejected (${code || 'error'}):`, (err && err.message) || err);
+ return { ok: false, reason: publicReason(err), code };
+ }
+ }
+
+ async function pemForToken(token) {
+ let kid = null;
+ try { kid = JSON.parse(Buffer.from(String(token).split('.')[0], 'base64url').toString('utf8')).kid || null; } catch { /* the guard refuses it */ }
+ try {
+ const ks = await client.keysForKid(kid);
+ const k = (kid && ks.find((x) => x.kid === kid)) || ks[0];
+ return k ? k.key.export({ type: 'spki', format: 'pem' }) : null;
+ } catch { return null; }
+ }
+
+ return { ensure, get, loaded, status: client.status, verifyUser, verifyApp, pemForToken, client };
+}
+
+module.exports = { createKeyStore, jwksUrl };
new file mode 100644
new file mode 100644
@@ -0,0 +1,260 @@
@@ -0,0 +1,260 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `meme`.
+ *
+ * GET /auth/login → Network /oauth/authorize?…&code_challenge=…&code_challenge_method=S256
+ * (?silent=1 adds prompt=none; ?next= a same-site path)
+ * GET /auth/callback → state check, server-side code exchange (client secret + code_verifier)
+ * GET /auth/logout → clear cookies, revoke the refresh token (best effort)
+ * GET /auth/me → { user } for the shared navbar; { user: null } for a guest
+ *
+ * This is Today's SSO module with the client id and cookie names changed. The person's Network access token lives
+ * in an httpOnly cookie (meme_at) no script can read; the refresh token (meme_rt) is httpOnly too. Neither is ever
+ * stored server-side, rendered or logged.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'meme_at';
+const REFRESH_COOKIE = 'meme_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'meme_oauth';
+
+function pkcePair() {
+ const verifier = crypto.randomBytes(32).toString('base64url');
+ const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
+ return { verifier, challenge };
+}
+
+function ownOrigins(config) {
+ const out = new Set();
+ for (const u of [config && config.baseUrl, config && config.homes && config.homes.url]) {
+ try { if (u) out.add(new URL(u).origin); } catch { /* not a URL */ }
+ }
+ return out;
+}
+
+/** Same-site relative paths only (never protocol-relative, never another origin). */
+function sanitizeNext(next, config) {
+ if (typeof next === 'string' && /[\u0000-\u001f\u007f\\]/.test(next)) return '/';
+ if (typeof next === 'string' && /^https?:\/\//i.test(next)) {
+ let u = null;
+ try { u = new URL(next); } catch { return '/'; }
+ if (!ownOrigins(config).has(u.origin)) return '/';
+ next = `${u.pathname}${u.search}${u.hash}`;
+ }
+ if (typeof next !== 'string' || !/^\/(?!\/|\\)/.test(next) || next.length > 500) return '/';
+ return next;
+}
+
+function authorizeUrl(config, { state, challenge, silent = false }) {
+ const q = new URLSearchParams({
+ response_type: 'code',
+ client_id: config.oauth.clientId,
+ redirect_uri: config.oauth.redirectUri,
+ scope: config.oauth.scope,
+ state,
+ code_challenge: challenge,
+ code_challenge_method: 'S256',
+ });
+ if (silent) q.set('prompt', 'none');
+ return `${config.networkUrl}/oauth/authorize?${q.toString()}`;
+}
+
+function createSso({ config, keys, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cookieBase = () => ({ sameSite: 'lax', secure: config.cookies.secure, httpOnly: true, path: '/' });
+
+ function setSession(res, data) {
+ res.cookie(ACCESS_COOKIE, data.access_token, { ...cookieBase(), maxAge: 24 * 60 * 60 * 1000 });
+ if (data.refresh_token) res.cookie(REFRESH_COOKIE, data.refresh_token, { ...cookieBase(), maxAge: 30 * 24 * 60 * 60 * 1000 });
+ res.cookie(HINT_COOKIE, 'account', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ }
+ function clearSession(res) {
+ res.clearCookie(ACCESS_COOKIE, cookieBase());
+ res.clearCookie(REFRESH_COOKIE, cookieBase());
+ }
+
+ async function tokenGrant(fields) {
+ const body = new URLSearchParams({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, ...fields });
+ let res;
+ try {
+ res = await fetchImpl(`${config.networkInternalUrl}/oauth/token`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json' },
+ body, signal: AbortSignal.timeout(10_000),
+ });
+ } catch (err) {
+ throw Object.assign(new Error('OpenVibe.Network did not answer'), { status: 502, error: err && err.name === 'TimeoutError' ? 'timeout' : 'unreachable' });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.access_token) {
+ throw Object.assign(new Error((data && (data.error_description || data.error)) || `token endpoint ${res.status}`),
+ { status: res.status, error: (data && data.error) || 'invalid_grant' });
+ }
+ return data;
+ }
+
+ /**
+ * A Meme session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
+ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ res.set('Clear-Site-Data', '"cache"');
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Meme] sign-in exchange failed:', err.status || '', err.error || '');
+ return res.status(err.status && err.status < 500 ? 400 : 502).type('text/plain')
+ .send(err.status && err.status < 500 ? 'The Network refused the sign-in code. Please sign in again.' : 'Sign-in failed: OpenVibe.Network did not answer. Please try again.');
+ }
+ });
+
+ const sameOriginRequest = (req) => {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin' || (site === 'none' && req.method === 'GET');
+ const origin = req.get('origin');
+ if (origin) return origin === `${req.protocol}://${req.get('host')}`;
+ return req.method === 'GET';
+ };
+ r.get('/logout', (req, res, next) => {
+ if (sameOriginRequest(req)) return next();
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const q = req.query.next ? `?next=${encodeURIComponent(sanitizeNext(req.query.next, config))}` : '';
+ res.type('html').send(`<!doctype html><meta charset="utf-8"><title>Sign out</title><meta name="viewport" content="width=device-width,initial-scale=1">`
+ + `<form method="post" action="/auth/logout${q}" style="font:16px system-ui;margin:3em auto;max-width:24em;text-align:center">`
+ + `<p>Sign out of OpenVibe.Meme?</p><button type="submit">Sign out</button> <a href="/">Cancel</a></form>`);
+ });
+ r.post('/logout', (req, res, next) => (sameOriginRequest(req) ? next() : res.status(403).type('text/plain').send('Sign-out must come from this site.')));
+ r.all('/logout', async (req, res) => {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (rt) {
+ fetchImpl(`${config.networkInternalUrl}/oauth/revoke`, {
+ method: 'POST', headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ client_id: config.oauth.clientId, client_secret: config.oauth.clientSecret, token: rt }),
+ signal: AbortSignal.timeout(3000),
+ }).catch(() => {});
+ }
+ clearSession(res);
+ res.set('Clear-Site-Data', '"cache"');
+ res.cookie(HINT_COOKIE, 'guest', { sameSite: 'lax', secure: config.cookies.secure, httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000 });
+ res.redirect(303, sanitizeNext(req.query.next, config));
+ });
+
+ r.get('/me', (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const c = req.cookies || {};
+ if (!c[ACCESS_COOKIE] && !c[REFRESH_COOKIE] && !req.get('authorization')) return res.json({ user: null });
+ const v = req.viewer;
+ if (!v || v.kind !== 'user') return res.status(401).json({ error: 'Not signed in' });
+ res.json({ user: { username: v.username, display_name: v.displayName, subject_id: v.subject, role: v.role, avatar_url: v.avatarUrl || null, profile_color: v.profileColor || null } });
+ });
+ return r;
+ }
+
+ return { middleware, routes, asViewer, tokenGrant, pkcePair };
+}
+
+module.exports = { createSso, pkcePair, sanitizeNext, ownOrigins, authorizeUrl, ACCESS_COOKIE, REFRESH_COOKIE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+'use strict';
+
+/**
+ * OpenVibe.Meme configuration. Every value comes from the environment (production: /etc/openvibe/meme.env,
+ * see .env.example). Only environment variable NAMES appear in code and docs; secrets are never logged.
+ *
+ * load(env) is pure so tests can build a config without touching process.env.
+ */
+require('dotenv').config();
+const trim = (s) => String(s || '').replace(/\/+$/, '');
+const int = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+
+function load(env = process.env) {
+ const nodeEnv = env.NODE_ENV || 'development';
+ const isProduction = nodeEnv === 'production';
+ const port = int(env.PORT, 5120);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.meme' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'meme',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.MEME_DRILL === '1' || env.MEME_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.MEME_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.MEME_LIMITS_HOUR, 3000)),
+ },
+
+ // PostgreSQL (ADR-035): DATABASE_URL serves (PgBouncer), DATABASE_DIRECT_URL migrates (owner role). In
+ // development without DATABASE_URL an embedded PGlite database in data/pglite is used (MEME_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.MEME_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:meme:' },
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE) and its JWKS.
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ audience: env.MEME_AUDIENCE || 'openvibe.meme',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'meme',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
+ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
+ events: {
+ secrets: String(env.MEME_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.MEME_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.MEME_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.MEME_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.MEME_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // The public APIs the importer reads. No service grant is used in v1: these are the anonymous reads a
+ // person can make too (OpenVibe.Live's Rundown and transcripts, OpenVibe.Chat's history and timeline).
+ upstreams: {
+ chat: trim(env.OV_CHAT_URL || env.OV_CHAT_INTERNAL_URL || 'https://openvibe.chat'),
+ live: trim(env.OV_LIVE_URL || env.OV_LIVE_INTERNAL_URL || 'https://openvibe.live'),
+ video: trim(env.OV_VIDEO_URL || 'https://openvibe.video'),
+ timeoutMs: Math.max(500, int(env.MEME_UPSTREAM_TIMEOUT_MS, 8000)),
+ },
+
+ // The candidate importer: every 30 minutes, off under MEME_DRILL (tests call runOnce()).
+ importer: {
+ intervalMs: Math.max(60_000, int(env.MEME_IMPORTER_INTERVAL_MS, 30 * 60_000)),
+ minUses: Math.max(2, int(env.MEME_IMPORTER_MIN_USES, 2)),
+ minDays: Math.max(2, int(env.MEME_IMPORTER_MIN_DAYS, 2)),
+ maxTopics: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_TOPICS, 12))),
+ maxVods: Math.min(50, Math.max(1, int(env.MEME_IMPORTER_MAX_VODS, 12))),
+ maxCandidates: Math.min(30, Math.max(1, int(env.MEME_IMPORTER_MAX_CANDIDATES, 10))),
+ },
+
+ // Every citation is re-verified daily, off under MEME_DRILL.
+ reverify: { intervalMs: Math.max(60_000, int(env.MEME_REVERIFY_INTERVAL_MS, 24 * 60 * 60_000)) },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Meme's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret. A citation stores a public URL and a sha256 of the verbatim quote;
+ * the quote itself is public speech already, and a person's vote, caption or report is theirs to export/erase.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Meme] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'meme', registry, log });
+ await db.migrate({ dir: MIGRATIONS, log });
+ return db;
+ }
+ if (!config.db.directUrl) throw new Error('DATABASE_DIRECT_URL is not set: migrations run with the owner role on a direct connection');
+ const owner = createDb({ url: config.db.directUrl, service: 'meme-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'meme', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx((t) => fn(t)),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+
+/**
+ * The fixed deny list: VOD 5709 and clip 483 are never cited, kept, cached or rendered by OpenVibe.Meme. Both answer
+ * 404 anonymously; clip 483 is Live's AI auto-clip of sexual content (server/ai/explicit.js), and VOD 5709 has no
+ * public row at all. A citation that would carry either id is dropped before it is written, and the seed script and
+ * the importer both call this filter. No title, thumbnail or quote from either ever enters the database.
+ */
+const DENIED_VOD_IDS = Object.freeze(new Set([5709]));
+const DENIED_CLIP_IDS = Object.freeze(new Set([483]));
+
+function num(v) {
+ if (v == null || v === '') return null;
+ const n = Number(v);
+ return Number.isFinite(n) ? n : null;
+}
+
+function isDeniedVod(vodId) {
+ const n = num(vodId);
+ return n != null && DENIED_VOD_IDS.has(n);
+}
+
+function isDeniedClip(clipId) {
+ const n = num(clipId);
+ return n != null && DENIED_CLIP_IDS.has(n);
+}
+
+/** True when a use names a denied VOD or clip. Accepts both wire and database column names. */
+function isDenied({ vod_id, vodId, clip_id, clipId } = {}) {
+ return isDeniedVod(vod_id != null ? vod_id : vodId) || isDeniedClip(clip_id != null ? clip_id : clipId);
+}
+
+/** The uses that may be cited: everything except the fixed deny list. */
+function allowed(uses) {
+ return (Array.isArray(uses) ? uses : []).filter((u) => !isDenied(u));
+}
+
+module.exports = { DENIED_VOD_IDS, DENIED_CLIP_IDS, isDenied, isDeniedVod, isDeniedClip, allowed };
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -0,0 +1,232 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Meme's JSON API.
+ *
+ * GET /entries?limit=&cursor=&kind=&status= public entries, newest first, cursor-paged
+ * GET /entries/:slug one entry with citations and the use timeline
+ * GET /entries/:slug/series?days=30 uses per UTC day, zero-filled
+ * GET /trending rising first
+ * GET /week this ISO week's candidates and vote counts
+ * POST /week/vote one vote a week (signed in, same-origin)
+ * GET /templates caption templates
+ * POST /captions save a caption (signed in; text only)
+ * GET /captions/:id one saved caption
+ * POST /entries/:slug/report a takedown/correction report (signed in)
+ * GET /reports open reports (staff)
+ * POST /entries/:slug/publish|hide staff
+ * PATCH /entries/:slug staff: title, kind, status
+ *
+ * Errors are application/problem+json with a `code`. A person is the meme_at session cookie or a Network user
+ * token as a Bearer; a session write must come from openvibe.meme itself (403 meme.cross_site).
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { isStaff } = require('../identity/staff');
+const { currentWeek } = require('../week');
+const { KINDS, STATUSES } = require('../store');
+
+function createApi(ctx) {
+ const { config, s, principal, entries } = ctx;
+ const r = asyncRouter();
+
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and
+ // hour budget from config, and an app or anonymous caller is left to the address limiter. A second process
+ // has its own counters; VALKEY_URL would make them shared (the readiness check says so).
+ const buckets = new Map();
+ function windowCount(key, windowMs, limit) {
+ const now = Date.now();
+ let b = buckets.get(key);
+ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }
+ b.n += 1;
+ return b.n <= limit;
+ }
+ r.use((req, res, next) => {
+ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {
+ const who = `user:${req.principal.subject}`;
+ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);
+ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);
+ if (!okMinute || !okHour) return problem(res, 429, 'meme.rate_limited', 'too many requests from this account; retry shortly');
+ if (buckets.size > 20_000) {
+ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);
+ }
+ }
+ return next();
+ });
+
+ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
+ const invalid = (res, detail) => problem(res, 400, 'meme.invalid', detail);
+
+ function person(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') { problem(res, 401, 'meme.sign_in_required', 'Sign in at openvibe.meme, or send a person\'s Network token as a Bearer.'); return null; }
+ if (p.kind !== 'user') { problem(res, 403, 'meme.person_required', 'This route is for a person, not an app.'); return null; }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ problem(res, 403, 'meme.cross_site', 'A signed-in request that changes something must come from openvibe.meme itself.');
+ return null;
+ }
+ return p;
+ }
+ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
+ const requireStaff = (req, res, next) => {
+ if (!person(req, res)) return undefined;
+ if (!isStaff(req.principal)) return problem(res, 403, 'meme.staff_required', 'This route is for OpenVibe staff (Network role global_mod or above).');
+ return next();
+ };
+
+ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+
+ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // ── Public reads ────────────────────────────────────────
+ r.get('/entries', async (req, res) => {
+ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
+ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');
+ const kind = typeof req.query.kind === 'string' && req.query.kind ? req.query.kind : null;
+ if (kind && !KINDS.includes(kind)) return invalid(res, `kind must be one of ${KINDS.join(', ')}`);
+ const status = typeof req.query.status === 'string' && req.query.status ? req.query.status : null;
+ if (status && !STATUSES.includes(status)) return invalid(res, `status must be one of ${STATUSES.join(', ')}`);
+ const out = await entries.list({
+ limit, kind, status,
+ cursor: typeof req.query.cursor === 'string' && req.query.cursor ? req.query.cursor : null,
+ });
+ if (out.error === 'cursor') return invalid(res, 'cursor is not a cursor this API handed out');
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ count: out.entries.length, entries: out.entries, next_cursor: out.next_cursor });
+ });
+
+ r.get('/entries/:slug/series', async (req, res) => {
+ const row = await entries.bySlug(String(req.params.slug || ''));
+ if (!row || (row.visibility === 'hidden' && !isStaff(req.principal))) return problem(res, 404, 'meme.not_found', 'No such meme.');
+ const days = req.query.days == null || req.query.days === '' ? 30 : Number.parseInt(req.query.days, 10);
+ if (!Number.isFinite(days) || days < 1 || days > 365) return invalid(res, 'days must be a whole number from 1 to 365');
+ const out = await entries.series(row.id, { days });
+ res.set('Cache-Control', row.visibility === 'public' ? 'public, max-age=60' : 'private, max-age=60');
+ return res.json({ slug: row.slug, ...out });
+ });
+
+ r.get('/entries/:slug', async (req, res) => {
+ const row = await entries.bySlug(String(req.params.slug || ''));
+ const staff = isStaff(req.principal);
+ if (!row || (row.visibility === 'hidden' && !staff)) return problem(res, 404, 'meme.not_found', 'No such meme.');
+ const citations = await entries.citations(row.id);
+ if (row.visibility === 'public') res.set('Cache-Control', 'public, max-age=60');
+ else if (row.visibility === 'unlisted') res.set('Cache-Control', 'private, max-age=60');
+ return res.json({
+ ...entries.wireEntry(row),
+ origin: citations.find((c) => c.role === 'origin') || citations[0] || null,
+ citations,
+ timeline: citations.filter((c) => c.role !== 'example'),
+ examples: citations.filter((c) => c.role === 'example'),
+ });
+ });
+
+ r.get('/trending', async (req, res) => {
+ const limit = req.query.limit == null || req.query.limit === '' ? 20 : Number.parseInt(req.query.limit, 10);
+ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit must be from 1 to 50');
+ const rows = await entries.trending({ limit });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ count: rows.length, entries: rows });
+ });
+
+ r.get('/week', async (req, res) => {
+ const { week } = currentWeek(s.now());
+ const subject = req.principal.kind === 'user' ? req.principal.subject : null;
+ const out = await entries.week({ week, subject });
+ res.set('Cache-Control', subject ? 'private, no-store' : 'public, max-age=60');
+ return res.json(out);
+ });
+
+ r.get('/templates', async (_req, res) => {
+ const list = await entries.templates();
+ const counted = await Promise.all(list.map(async (t) => ({ ...t, captions: await entries.captionCountForTemplate(t.id) })));
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.json({ count: counted.length, templates: counted });
+ });
+
+ r.get('/captions/:id', async (req, res) => {
+ const row = await entries.captionById(String(req.params.id || ''));
+ if (!row) return problem(res, 404, 'meme.not_found', 'No such caption.');
+ const mine = req.principal.kind === 'user' && req.principal.subject === row.subject;
+ if (row.visibility === 'hidden' && !mine && !isStaff(req.principal)) return problem(res, 404, 'meme.not_found', 'No such caption.');
+ const template = await entries.templateById(row.template_id);
+ const staff = isStaff(req.principal);
+ // A response that names the owner (or is the owner's own) must never sit in a shared cache.
+ res.set('Cache-Control', row.visibility === 'public' && !mine && !staff ? 'public, max-age=60' : 'private, no-store');
+ const out = { ...row, template, mine };
+ if (!mine && !staff) delete out.subject;
+ return res.json(out);
+ });
+
+ // ── Signed-in writes ────────────────────────────────────
+ r.post('/week/vote', requirePerson, async (req, res) => {
+ const entryId = req.body && typeof req.body.entry_id === 'string' ? req.body.entry_id.trim() : '';
+ if (!entryId || entryId.length > 40) return invalid(res, 'entry_id is required');
+ const { week } = currentWeek(s.now());
+ const out = await entries.castVote({ subject: req.principal.subject, entryId, week });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid vote');
+ return res.json({ week, ...out });
+ });
+
+ r.post('/captions', requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const out = await entries.createCaption({
+ subject: req.principal.subject,
+ templateId: b.template_id,
+ lines: b.lines,
+ visibility: b.visibility == null ? 'public' : String(b.visibility),
+ });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid caption');
+ res.set('Location', `/api/v1/captions/${out.caption.id}`);
+ return res.status(201).json(out.caption);
+ });
+
+ r.post('/entries/:slug/report', requirePerson, async (req, res) => {
+ const row = await entries.bySlug(String(req.params.slug || ''));
+ if (!row || row.visibility !== 'public') return problem(res, 404, 'meme.not_found', 'No such public meme.');
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const kind = b.kind === 'takedown' || b.kind === 'correction' ? b.kind : null;
+ if (!kind) return invalid(res, 'kind must be takedown or correction');
+ const reason = String(b.reason == null ? '' : b.reason).replace(/\s+/g, ' ').trim();
+ if (reason.length < 3 || reason.length > 2000) return invalid(res, 'reason must be 3 to 2000 characters');
+ const note = b.note == null || b.note === '' ? null : String(b.note).trim().slice(0, 4000);
+ const out = await entries.createReport({ entryId: row.id, subject: req.principal.subject, kind, reason, note });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid report');
+ res.set('Location', `/api/v1/reports`);
+ return res.status(201).json({ id: out.report.id, status: out.report.status, created_at: out.report.created_at });
+ });
+
+ // ── Staff ───────────────────────────────────────────────
+ r.post('/entries/:slug/publish', requireStaff, async (req, res) => {
+ const out = await entries.publish(String(req.params.slug || ''), { changedBy: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'cannot publish');
+ return res.json(out.entry);
+ });
+
+ r.post('/entries/:slug/hide', requireStaff, async (req, res) => {
+ const out = await entries.hide(String(req.params.slug || ''), { changedBy: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'cannot hide');
+ return res.json(out.entry);
+ });
+
+ r.patch('/entries/:slug', requireStaff, async (req, res) => {
+ const out = await entries.patch(String(req.params.slug || ''), { body: req.body || {}, changedBy: req.principal.subject });
+ if (!out.ok) return problem(res, out.status || 400, out.code || 'meme.invalid', out.message || 'invalid change');
+ return res.json(out.entry);
+ });
+
+ r.get('/reports', requireStaff, async (req, res) => {
+ const reports = await entries.listOpenReports({ limit: asInt(req.query.limit, 100) });
+ return res.json({ count: reports.length, reports });
+ });
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,114 @@
@@ -0,0 +1,114 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.meme: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Public entries are for
+ * search engines and AI crawlers; sign-in, the API, the internal route and a hidden entry are not. A sitemap also
+ * lists every public entry page (/m/<slug>).
+ */
+const fs = require('fs');
+const path = require('path');
+const seo = require('openvibe-shared/seo');
+const cache = require('openvibe-shared/cache-policy');
+const { asyncRouter } = require('./router');
+
+const SITE_NAME = 'OpenVibe.Meme';
+const DESCRIPTION = 'OpenVibe.Meme is the open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and is voted meme of the week.';
+const DISALLOW = ['/auth/', '/api/', '/internal', '/metrics'];
+
+function dayOf(ts) {
+ const m = String(ts == null ? '' : ts).match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+}
+function siteUpdated() {
+ try { return dayOf(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated); } catch { return null; }
+}
+
+function homeJsonLd(config) {
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ return [
+ seo.jsonLd.website({ name: SITE_NAME, url: site, description: DESCRIPTION }),
+ seo.jsonLd.softwareApp({ name: SITE_NAME, url: site, description: DESCRIPTION, category: 'EntertainmentApplication', keywords: 'openvibe meme, meme database, running jokes, meme of the week, caption generator' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function publicPages() {
+ return [
+ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Meme home', text: 'The open meme database: trending entries, meme of the week and the caption generator.' },
+ { path: '/week', changefreq: 'daily', priority: 0.9, title: 'Meme of the week', text: 'This week’s candidates and the people’s vote.' },
+ { path: '/make', changefreq: 'weekly', priority: 0.7, title: 'The caption generator', text: 'Pick a template, type up to four lines and preview them over the image.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Meme API', text: 'The JSON API, the Search document and the importer’s public sources.' },
+ ];
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config, s } = ctx;
+ const r = asyncRouter();
+ const site = String(config.baseUrl).replace(/\/+$/, '');
+ const abs = (p) => `${site}${p}`;
+ const TEXT = cache.htmlHeaders({ maxAge: 3600 });
+ const pages = publicPages();
+
+ r.get('/robots.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(
+ '# openvibe.meme: the public entries are for search and AI crawlers; sign-in, the API and the internal route are not.\n'
+ + seo.robotsTxt({ sitemaps: [abs('/sitemap.xml')], disallow: DISALLOW }));
+ });
+
+ r.get('/llms.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsTxt({
+ name: SITE_NAME,
+ summary: 'OpenVibe.Meme is the open meme database, rebuilt with AI. Every public entry cites the moment a running joke happened (a chat line, a VOD second, a Rundown segment), follows its later uses on a timeline, has a status (rising, peaked, classic, dead), and people vote for meme of the week. A caption generator puts plain text lines over a template image.',
+ details: 'GET /api/v1/entries lists public entries; GET /api/v1/entries/:slug adds every citation and the use timeline; GET /api/v1/entries/:slug/series?days=30 is uses per UTC day. GET /api/v1/week lists this ISO week’s candidates and vote counts. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Meme', url: abs('/'), note: 'trending and meme of the week' },
+ { title: 'The ballot', url: abs('/week') },
+ { title: 'The caption generator', url: abs('/make') },
+ { title: 'The API', url: abs('/developers') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { title: 'Public entries (JSON)', url: abs('/api/v1/entries') },
+ { title: 'Sitemap', url: abs('/sitemap.xml') },
+ { title: 'Full text for language models', url: abs('/llms-full.txt') },
+ { title: 'Release metadata (JSON)', url: abs('/release.json') },
+ ] },
+ { title: 'Elsewhere', links: [
+ { title: 'OpenVibe.Chat', url: 'https://openvibe.chat', note: 'where a running joke is often typed first' },
+ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'the Rundown and the VOD transcripts' },
+ { title: 'OpenVibe.Video', url: 'https://openvibe.video', note: 'the canonical ?t= moment' },
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and staff roles' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', async (_req, res) => {
+ const rows = await s.db.many("SELECT slug, title, summary, kind, status FROM meme_entries WHERE visibility = 'public' ORDER BY last_seen_at DESC LIMIT 2000");
+ const entries = rows.map((e) => ({ url: `/m/${e.slug}`, title: e.title, text: [e.kind, e.status, e.summary].filter(Boolean).join(' · ') }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Meme: the home, the weekly ballot, the generator, the API and every public entry.',
+ base: site,
+ maxBytes: 512 * 1024,
+ sections: [
+ { title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) },
+ { title: 'Entries', pages: entries },
+ ],
+ }));
+ });
+
+ r.get('/sitemap.xml', async (_req, res) => {
+ const lastmod = siteUpdated();
+ const rows = await s.db.many("SELECT slug, updated_at FROM meme_entries WHERE visibility = 'public' ORDER BY slug LIMIT 5000");
+ const urls = [
+ ...pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority })),
+ ...rows.map((e) => ({ loc: abs(`/m/${e.slug}`), lastmod: dayOf(e.updated_at) || lastmod, changefreq: 'weekly', priority: 0.7 })),
+ ];
+ res.type('application/xml').set('Cache-Control', TEXT).send(seo.sitemapXml(urls));
+ });
+
+ return r;
+}
+
+module.exports = { createDiscoveryRoutes, homeJsonLd, publicPages, DESCRIPTION, SITE_NAME };
new file mode 100644
new file mode 100644
@@ -0,0 +1,187 @@
@@ -0,0 +1,187 @@
+'use strict';
+
+/**
+ * Meme's server-rendered pages. They work without JavaScript: forms are plain POSTs and every link is a real link.
+ * Public pages are served `public, max-age=60` with Vary: Cookie; a page drawn for a signed-in person is
+ * `private, no-store`.
+ *
+ * / trending, meme of the week, the generator teaser
+ * /m/:slug an entry: origin with its citation link, the use timeline, examples, the report form
+ * /week this week's candidates and the vote form (POST /week/vote)
+ * /make the caption generator form (POST /make) with a server-rendered preview (GET /make)
+ * /c/:id one saved caption, text over the template image
+ * /developers the API
+ * 404 the not-found page
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { sameOrigin } = require('./principal');
+const { html, raw, notice } = require('../render/html');
+const { send, SITE_NAME } = require('../render/layout');
+const { isStaff } = require('../identity/staff');
+const { currentWeek } = require('../week');
+const views = require('../views');
+
+const PUBLIC_CACHE = 'public, max-age=60';
+
+function createPageRoutes(ctx) {
+ const { config, s, entries } = ctx;
+ const r = asyncRouter();
+ r.use(express.urlencoded({ extended: false, limit: '64kb' }));
+ const base = () => String(config.baseUrl).replace(/\/+$/, '');
+ const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
+ const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ const staffViewer = (req) => isStaff(req.viewer);
+ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
+ const notFromHere = (req, res) => page(req, res, { title: 'Not from this site', body: raw(html`<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty"><h1>That came from somewhere else</h1><p class="mm-muted">Use the form on <a href="/">OpenVibe.Meme</a>.</p></div></div>`) }, 403);
+
+ async function counts() {
+ const rows = await s.db.many("SELECT COUNT(*)::int AS entries, (SELECT COUNT(*)::int FROM meme_citations c JOIN meme_entries e ON e.id = c.entry_id WHERE e.visibility = 'public') AS citations FROM meme_entries WHERE visibility = 'public'");
+ return rows[0] || { entries: 0, citations: 0 };
+ }
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ const { week } = currentWeek(s.now());
+ const [trending, weekOut, c] = await Promise.all([entries.trending({ limit: 8 }), entries.week({ week }), counts()]);
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: null,
+ description: 'The open meme database, rebuilt with AI: every entry cites the moment it happened, follows its later uses, and people vote for meme of the week.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ trending, week: weekOut, counts: c })),
+ });
+ });
+
+ // ── One entry ────────────────────────────────────────────
+ async function entryPage(req, res, { problem = null } = {}) {
+ const slug = String(req.params.slug || '');
+ const row = await entries.bySlug(slug);
+ const staff = staffViewer(req);
+ if (!row || (row.visibility === 'hidden' && !staff)) return notFound(req, res);
+ const citations = await entries.citations(row.id);
+ const e = entries.wireEntry(row);
+ const jsonLd = seo.jsonLd.webPage({
+ name: e.title,
+ url: `${base()}/m/${e.slug}`,
+ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),
+ siteUrl: base(),
+ });
+ return page(req, res, {
+ index: e.visibility === 'public',
+ cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
+ title: e.title,
+ description: (e.summary || `${e.title} on OpenVibe.Meme`).slice(0, 160),
+ jsonLd: [jsonLd],
+ body: raw(views.entry({ e, citations, signedIn: Boolean(signedIn(req)), staff, reported: req.query.reported === '1', problem })),
+ });
+ }
+ r.get('/m/:slug', (req, res) => entryPage(req, res));
+
+ r.post('/m/:slug/report', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/m/${req.params.slug}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const row = await entries.bySlug(String(req.params.slug || ''));
+ if (!row || row.visibility !== 'public') return notFound(req, res);
+ const b = req.body || {};
+ const kind = b.kind === 'takedown' || b.kind === 'correction' ? b.kind : null;
+ if (!kind) return entryPage(req, res, { problem: 'Choose a takedown or a correction.' });
+ const reason = String(b.reason == null ? '' : b.reason).replace(/\s+/g, ' ').trim();
+ if (reason.length < 3 || reason.length > 2000) return entryPage(req, res, { problem: 'The reason must be 3 to 2000 characters.' });
+ const note = b.note == null || b.note === '' ? null : String(b.note).trim().slice(0, 4000);
+ const out = await entries.createReport({ entryId: row.id, subject: req.viewer.subject, kind, reason, note });
+ if (!out.ok) return entryPage(req, res, { problem: out.message || 'That report could not be saved.' });
+ return res.redirect(303, `/m/${encodeURIComponent(row.slug)}?reported=1`);
+ });
+
+ // ── Meme of the week ────────────────────────────────────
+ r.get('/week', async (req, res) => {
+ const { week } = currentWeek(s.now());
+ const subject = signedIn(req) ? req.viewer.subject : null;
+ const out = await entries.week({ week, subject });
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Meme of the week',
+ description: 'This ISO week’s candidates for meme of the week and the people’s vote.',
+ body: raw(views.week({ out, signedIn: Boolean(subject), voted: req.query.voted === '1', error: req.query.error === '1' })),
+ });
+ });
+
+ r.post('/week/vote', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fweek');
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const entryId = req.body && typeof req.body.entry_id === 'string' ? req.body.entry_id.trim() : '';
+ if (!entryId) return res.redirect(303, '/week?error=1');
+ const { week } = currentWeek(s.now());
+ const out = await entries.castVote({ subject: req.viewer.subject, entryId, week });
+ return res.redirect(303, out.ok ? '/week?voted=1' : '/week?error=1');
+ });
+
+ // ── The caption generator ───────────────────────────────
+ const LINES = [1, 2, 3, 4];
+ function makeValues(src = {}) {
+ const v = { template_id: src.template_id, visibility: src.visibility || 'public' };
+ for (const i of LINES) v[`line${i}`] = String(src[`line${i}`] == null ? '' : src[`line${i}`]).slice(0, 120);
+ return v;
+ }
+ async function renderMake(req, res, { values = {}, problem = null, status = 200 } = {}) {
+ const templates = await entries.templates();
+ const chosen = templates.find((t) => t.id === values.template_id) || templates[0] || null;
+ const previewLines = LINES.map((i) => String(values[`line${i}`] || '').replace(/\s+/g, ' ').trim()).filter(Boolean);
+ const preview = chosen && previewLines.length ? { template: chosen, lines: previewLines } : null;
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'Make a caption',
+ description: 'Pick a template, type up to four lines and preview them over the image before saving.',
+ body: raw(views.make({ templates, values: { ...values, template_id: chosen ? chosen.id : '' }, preview, signedIn: Boolean(signedIn(req)), problem })),
+ }, status);
+ }
+ r.get('/make', async (req, res) => renderMake(req, res, { values: makeValues(req.query) }));
+ r.post('/make', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Make a caption', body: raw(views.signIn({ title: 'Make a caption', text: 'A saved caption belongs to your OpenVibe account, so you can find it later.', next: '/make' })) }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const values = makeValues(req.body || {});
+ const lines = LINES.map((i) => values[`line${i}`]).filter((l) => String(l).trim());
+ const out = await entries.createCaption({ subject: req.viewer.subject, templateId: values.template_id, lines, visibility: values.visibility });
+ if (!out.ok) return renderMake(req, res, { values, problem: out.message || 'That caption could not be saved.', status: out.status || 400 });
+ return res.redirect(303, `/c/${encodeURIComponent(out.caption.id)}`);
+ });
+
+ // ── One saved caption ───────────────────────────────────
+ r.get('/c/:id', async (req, res) => {
+ const c = await entries.captionById(String(req.params.id || ''));
+ if (!c) return notFound(req, res);
+ const mine = Boolean(signedIn(req) && req.viewer.subject === c.subject);
+ if (c.visibility === 'hidden' && !mine && !staffViewer(req)) return notFound(req, res);
+ const template = await entries.templateById(c.template_id);
+ return page(req, res, {
+ index: c.visibility === 'public',
+ cache: c.visibility === 'public' ? PUBLIC_CACHE : null,
+ title: template ? `A caption on ${template.name}` : 'A saved caption',
+ description: c.lines.join(' / ').slice(0, 160),
+ body: raw(views.caption({ caption: c, template, mine })),
+ });
+ });
+
+ // ── Developers ──────────────────────────────────────────
+ r.get('/developers', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'The OpenVibe.Meme API',
+ description: 'The JSON API, the Search document and the public sources the importer reads.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Meme API', url: `${base()}/developers`, description: 'The JSON API, the Search document and the public sources the importer reads.', siteUrl: base() })],
+ body: raw(views.developers()),
+ }));
+
+ // ── Discovery ───────────────────────────────────────────
+ r.use(createDiscoveryRoutes(ctx));
+
+ function notFound(req, res) {
+ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);
+ }
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * Who is calling /api/v1 — req.principal:
+ *
+ * { kind: 'user', subject: 'usr_…', username, role, token, viaSession }
+ * a person: their Network user token as a Bearer, or this site's session cookie (meme_at).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience openvibe.meme. No Meme route needs one; the shape is
+ * kept so a future capability route can be added in one place.
+ * { kind: 'anonymous' }
+ *
+ * A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
+ * session or to anonymous. A write made with the session cookie must come from this site (Sec-Fetch-Site
+ * same-origin, or an Origin equal to ours): another site cannot make a signed-in visitor vote, caption or report.
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// Meme's routes are a person acting for themself, a public read, and staff acting on an entry. No capability.
+const CAPABILITIES = [];
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function roleClaim(claims) {
+ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
+}
+function usernameClaim(claims) {
+ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+}
+
+function requesterOfSub(sub) {
+ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
+ return sub;
+}
+
+/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+ return false;
+}
+
+function createPrincipal({ config, keys }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
+ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
+ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
+ }
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
+ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
+ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
+ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
+ }
+ const viewer = req.viewer;
+ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
+ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
+ }
+ return { principal: { kind: 'anonymous' } };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ function require_(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Meme's capabilities`);
+ return function guard(req, res, next) {
+ if (req.principal.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.meme, or send a person\'s Network token as a Bearer.', ctx: req.ov });
+ if (req.principal.kind === 'user') {
+ if (req.principal.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'meme.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.meme itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'meme.person_required', { detail: 'This route is for a person, not an app.', ctx: req.ov });
+ };
+ }
+
+ return { middleware, resolve, requireCapability: require_ };
+}
+
+module.exports = { createPrincipal, CAPABILITIES, decodePayload, sameOrigin };
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+'use strict';
+
+/**
+ * express.Router() whose get/post/patch/delete handlers may be async: a rejected promise goes to next(err)
+ * (the app's error handler, which logs without request bodies) instead of becoming an unhandled rejection.
+ */
+const express = require('express');
+
+function asyncRouter() {
+ const r = express.Router();
+ for (const method of ['get', 'post', 'patch', 'delete', 'put', 'all']) {
+ const orig = r[method].bind(r);
+ r[method] = (path, ...handlers) => orig(path, ...handlers.map((h) => (h.length === 4 ? h : (req, res, next) => {
+ try {
+ const out = h(req, res, next);
+ if (out && typeof out.catch === 'function') out.catch(next);
+ } catch (err) { next(err); }
+ })));
+ }
+ return r;
+}
+
+module.exports = { asyncRouter };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+
+/**
+ * Account export and deletion → Meme (ADR-033; openvibe-sdk/account-data). The rows a person owns are their weekly
+ * votes, their saved captions and the reports they filed. An entry is the network's, not a person's, so it is not
+ * exported or erased with an account; a caption or report is deleted outright.
+ *
+ * The database stores bare usr_ ids in the subject columns. A vote is one row per week; a caption is text lines on a
+ * template; a report is a takedown/correction request and is never a secret.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'meme_votes', subject: 'subject', value: (usr) => usr, file: 'votes.json', erase: 'delete' },
+ { table: 'meme_captions', subject: 'subject', value: (usr) => usr, file: 'captions.json', erase: 'delete' },
+ { table: 'meme_reports', subject: 'subject', value: (usr) => usr, file: 'reports.json', erase: 'delete' },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'meme',
+ tables: TABLES,
+ note: 'Entries are the network\'s meme record; only your votes, captions and reports are here.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
+'use strict';
+
+/**
+ * Who is staff at OpenVibe.Meme: the Network role claim on the person's session (server/auth/sso.js puts `role`
+ * on req.viewer, and http/principal.js puts it on req.principal). The roles are OpenVibe.Network's
+ * (manifests/policy/staff-roles.json): global_mod, admin and owner are staff+; user and streamer are not.
+ *
+ * contracts.staff.atLeast is the one place the ranking lives, so the API and the pages cannot drift apart.
+ */
+const { staff } = require('openvibe-contracts');
+
+const STAFF_MIN = 'global_mod';
+const STAFF_ROLES = ['global_mod', 'admin', 'owner'];
+
+/** Is this viewer/principal a member of staff? Anonymous, an app token and an unlisted role are not. */
+function isStaff(who) {
+ if (!who || typeof who !== 'object' || who.kind !== 'user') return false;
+ if (typeof who.role !== 'string' || !who.role) return false;
+ try { return staff.atLeast(who.role, STAFF_MIN); } catch { return false; }
+}
+
+module.exports = { isStaff, STAFF_MIN, STAFF_ROLES };
new file mode 100644
new file mode 100644
@@ -0,0 +1,287 @@
@@ -0,0 +1,287 @@
+'use strict';
+
+/**
+ * The candidate importer (v1). It reads only public APIs — OpenVibe.Live's Rundown and VOD transcripts, and
+ * OpenVibe.Chat's global timeline and history — and proposes a candidate entry when the same 3–6 word phrase
+ * appears in at least two uses on at least two different days. A candidate is inserted hidden, with one citation
+ * per use; it never publishes. Staff publish it through POST /api/v1/entries/:slug/publish.
+ *
+ * The job runs every 30 minutes (MEME_IMPORTER_INTERVAL_MS) and is off under MEME_DRILL; tests call runOnce().
+ * A failing upstream is logged and skipped: Chat down still lets the Rundown produce candidates, and vice versa.
+ * A phrase already in the table is enriched (new citations, an extended first/last range) rather than copied.
+ */
+const { parseApiTime } = require('./upstreams');
+const { isDenied } = require('./deny-list');
+const { cleanSummary } = require('./text/no-looks');
+const { slugify } = require('./store');
+
+const MIN_WORDS = 3;
+const MAX_WORDS = 6;
+const MAX_USES_PER_PHRASE = 25;
+const STOP = new Set(('a about above actually after again against all almost alone along already also although always am among an and another any anyone anything are around as at back be became because become been before being below between both but by came can cannot cant could did didnt do does doesnt doing don dont done down during each either else enough even ever every few first for found from get gets getting give go going gone gonna got gotta great group guy guys had has have having he her here hers him himself his hi how however i id if im in into is isnt it its itself ive just kind kinda know last least less let like likely little lot made make many may maybe me mean might mine more most much must my myself near need neither never new next no nobody none nor not nothing now of off often on once one only or other others otherwise our ours out over own part people perhaps place point put quite rather really right said same saw say saying see seen several she should since small so some somebody something still such sure take tell than that thats the their them then there these they thing think this those though thought three through time to today together too took toward try turn two under until up upon us use used using very via want was way we well went were what whatever when where whether which while who why will with within without would year years yes yet you your yours yourself').split(' '));
+
+const DAY = 86_400_000;
+const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+const displayTitle = (phrase) => phrase.charAt(0).toUpperCase() + phrase.slice(1);
+
+function normalize(text) {
+ return String(text || '')
+ .toLowerCase()
+ .replace(/https?:\/\/\S+/g, ' ')
+ .replace(/[^a-z0-9']+/g, ' ')
+ .replace(/'+/g, '')
+ .replace(/\s+/g, ' ')
+ .trim();
+}
+function words(text) { return normalize(text) ? normalize(text).split(' ') : []; }
+function contentCount(tokens) { return tokens.filter((w) => !STOP.has(w) && w.length > 1).length; }
+function utcDay(iso) { return String(iso || '').slice(0, 10); }
+function useKey(u) { return `${u.source_type}:${u.source_id}:${u.offset_seconds == null ? '' : u.offset_seconds}`; }
+
+/**
+ * Every 3–6 word window of every use, filtered to phrases that start and end with a content word and contain at
+ * least two content words. Returns [{ phrase, uses }] with ≥ minUses uses on ≥ minDays days.
+ */
+function recurringPhrases(uses, { minUses = 2, minDays = 2 } = {}) {
+ const map = new Map();
+ for (const use of uses) {
+ const toks = words(use.quote_text);
+ if (toks.length < MIN_WORDS) continue;
+ for (let n = MIN_WORDS; n <= MAX_WORDS; n++) {
+ for (let i = 0; i + n <= toks.length; i++) {
+ const win = toks.slice(i, i + n);
+ if (STOP.has(win[0]) || STOP.has(win[n - 1])) continue;
+ if (contentCount(win) < 2) continue;
+ const phrase = win.join(' ');
+ if (phrase.length < 12) continue;
+ const list = map.get(phrase);
+ if (list) { if (!list.some((u) => useKey(u) === useKey(use))) list.push(use); }
+ else map.set(phrase, [use]);
+ }
+ }
+ }
+ const out = [];
+ for (const [phrase, list] of map) {
+ if (list.length < minUses) continue;
+ const days = new Set(list.map((u) => utcDay(u.observed_at)));
+ if (days.size < minDays) continue;
+ if (new Set(list.map(useKey)).size < minUses) continue;
+ out.push({ phrase, uses: list });
+ }
+ return out;
+}
+
+function detectCandidates(uses, { minUses = 2, minDays = 2, hints = '', max = 10 } = {}) {
+ const found = recurringPhrases(uses, { minUses, minDays });
+ for (const c of found) {
+ const days = new Set(c.uses.map((u) => utcDay(u.observed_at))).size;
+ c.score = c.uses.length * 10 + days * 5 + (hints.includes(c.phrase) ? 4 : 0) + Math.min(c.phrase.length, 40) / 10;
+ c.days = days;
+ }
+ found.sort((a, b) => b.score - a.score || b.phrase.length - a.phrase.length || (a.phrase < b.phrase ? -1 : 1));
+ const kept = [];
+ for (const c of found) {
+ if (kept.some((k) => k.phrase.includes(c.phrase) || c.phrase.includes(k.phrase))) continue;
+ kept.push(c);
+ if (kept.length >= max) break;
+ }
+ return kept;
+}
+
+function statusFor(firstSeen, lastSeen, now) {
+ const first = Date.parse(firstSeen);
+ const last = Date.parse(lastSeen);
+ if (!Number.isFinite(first) || !Number.isFinite(last)) return 'rising';
+ if (last < now - 90 * DAY) return 'dead';
+ if (first < now - 60 * DAY && last > now - 30 * DAY) return 'classic';
+ if (first > now - 14 * DAY) return 'rising';
+ return 'peaked';
+}
+
+function createImporter({ config, s, entries, upstreams, fetchImpl = globalThis.fetch, log = console }) {
+ let timers = [];
+ const stats = { runs: 0, uses: 0, candidates: 0, created: 0, enriched: 0, errors: 0, last_run_at: null, last_error: null };
+
+ /** One openvibe.video call per (vod, sec) is enough; a 404 removes the use. */
+ function resolver() {
+ const cache = new Map();
+ return async (vodId, sec) => {
+ const key = `${vodId}:${Math.floor(Number(sec) || 0)}`;
+ if (!cache.has(key)) cache.set(key, upstreams.canonicalVideo(vodId, sec).catch((err) => ({ error: err })));
+ return await cache.get(key);
+ };
+ }
+
+ async function collect() {
+ const errors = [];
+ const uses = [];
+ const hints = [];
+ const canon = resolver();
+
+ // Chat: the history is verbatim; the timeline is a model paraphrase, so it is a discovery hint only and
+ // never becomes a citation (there is no message id in it — research §2.1).
+ try {
+ const history = await upstreams.chatHistory({ limit: 200 });
+ for (const m of history.messages) {
+ if (Number(m.is_deleted) === 1) continue;
+ if (m.message_type && m.message_type !== 'chat') continue;
+ const observed = parseApiTime(m.timestamp);
+ if (!observed || !normalize(m.message)) continue;
+ uses.push({
+ source_service: 'chat', source_type: 'chat_message', source_id: String(m.id),
+ chat_message_id: Number(m.id), quote_text: String(m.message), observed_at: observed,
+ url: `${upstreams.base(config.upstreams.chat)}/`,
+ });
+ }
+ } catch (err) { errors.push(`chat history: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
+
+ try {
+ const timeline = await upstreams.chatTimeline({ limit: 60 });
+ for (const e of timeline.events) hints.push(normalize(`${e.label || ''} ${e.detail || ''}`));
+ } catch (err) { errors.push(`chat timeline: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
+
+ // Rundown: every quote is verbatim speech with a VOD second already converted to VOD time.
+ const vods = new Set();
+ try {
+ const topics = await upstreams.rundownTopics({ limit: config.importer.maxTopics });
+ for (const topic of topics) {
+ let detail = null;
+ try { detail = await upstreams.rundownTopic(topic.slug); } catch (err) { errors.push(`rundown ${topic.slug}: ${err.message}`); continue; }
+ if (!detail) continue;
+ for (const take of detail.takes) {
+ const said = parseApiTime(take.said_at) || parseApiTime(topic.last_seen_at);
+ const vodId = nOrNull(take.vod_id);
+ const streamId = nOrNull(take.stream_id);
+ if (vodId && !isDenied({ vod_id: vodId })) vods.add(vodId);
+ for (const quote of Array.isArray(take.quotes) ? take.quotes : []) {
+ const sec = nOrNull(quote.sec) != null ? nOrNull(quote.sec) : nOrNull(take.sec);
+ if (!said || !normalize(quote.text) || vodId == null || sec == null) continue;
+ if (isDenied({ vod_id: vodId })) continue;
+ uses.push({
+ source_service: 'live', source_type: 'rundown_segment', source_id: String(take.id),
+ stream_id: streamId, vod_id: vodId, offset_seconds: sec, quote_text: String(quote.text),
+ observed_at: said, url: `${upstreams.base(config.upstreams.video)}/v/${vodId}?t=${Math.floor(sec)}`,
+ });
+ }
+ }
+ }
+ } catch (err) { errors.push(`rundown: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
+
+ // VOD transcripts: a segment has no date of its own, so it is used only when the VOD row gives one.
+ try {
+ const ids = [...vods].slice(0, config.importer.maxVods);
+ const rows = {};
+ for (const id of ids) {
+ try { const row = await upstreams.vod(id); if (row) rows[id] = row; } catch (err) { errors.push(`vod ${id}: ${err.message}`); }
+ }
+ const tr = await upstreams.transcripts(ids);
+ for (const [id, data] of Object.entries(tr)) {
+ const row = rows[id];
+ const observed = row ? parseApiTime(row.created_at || row.published_at || row.recorded_at || row.started_at) : null;
+ if (!observed) continue;
+ for (const seg of Array.isArray(data.segments) ? data.segments : []) {
+ if (!normalize(seg.text) || seg.text.length < 8) continue;
+ const sec = nOrNull(seg.start);
+ if (sec == null) continue;
+ uses.push({
+ source_service: 'live', source_type: 'vod', source_id: String(id), vod_id: Number(id), offset_seconds: sec,
+ quote_text: String(seg.text), observed_at: observed,
+ url: `${upstreams.base(config.upstreams.video)}/v/${id}?t=${Math.floor(sec)}`,
+ });
+ }
+ }
+ } catch (err) { errors.push(`transcripts: ${err.message}`); log.warn(`[OpenVibe.Meme] importer: ${err.message}`); }
+
+ return { uses, hints: hints.join(' '), errors };
+ }
+
+ /** Canonicalise a candidate's citations (the joined-VOD redirect) and drop what is gone/denied. */
+ async function materialize(candidate, canon) {
+ const picked = [...candidate.uses]
+ .sort((a, b) => Date.parse(a.observed_at) - Date.parse(b.observed_at) || (a.source_type < b.source_type ? -1 : 1))
+ .slice(0, MAX_USES_PER_PHRASE);
+ const out = [];
+ for (const u of picked) {
+ if (isDenied(u)) continue;
+ const c = { ...u };
+ if (u.vod_id != null && u.offset_seconds != null) {
+ const resolved = await canon(u.vod_id, u.offset_seconds);
+ if (resolved.denied || resolved.gone || resolved.hidden) continue;
+ if (!resolved.error && resolved.url) {
+ c.vod_id = resolved.vod_id;
+ c.offset_seconds = resolved.offset_seconds;
+ c.url = resolved.url;
+ }
+ }
+ if (isDenied(c)) continue;
+ out.push({ ...c, role: out.length === 0 ? 'origin' : 'use' });
+ }
+ return out;
+ }
+
+ async function runOnce() {
+ const runAt = new Date(s.now()).toISOString();
+ const { uses, hints, errors } = await collect();
+ const canon = resolver();
+ const found = detectCandidates(uses, {
+ minUses: config.importer.minUses, minDays: config.importer.minDays, hints,
+ max: Math.max(1, Number(config.importer.maxCandidates) || 10),
+ });
+ let created = 0;
+ let enriched = 0;
+ for (const candidate of found) {
+ try {
+ const cites = await materialize(candidate, canon);
+ if (!cites.length) continue;
+ const firstSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) < Date.parse(m) ? c.observed_at : m), cites[0].observed_at);
+ const lastSeen = cites.reduce((m, c) => (Date.parse(c.observed_at) > Date.parse(m) ? c.observed_at : m), cites[0].observed_at);
+ const title = displayTitle(candidate.phrase);
+ const summary = cleanSummary(title, `"${title}" recurs in ${cites.length} moments across ${new Set(cites.map((c) => utcDay(c.observed_at))).size} days on OpenVibe, from ${firstSeen.slice(0, 10)} to ${lastSeen.slice(0, 10)}.${hints.includes(candidate.phrase) ? ' Chat\'s timeline names it too.' : ''}`);
+ const existing = await entries.bySlug(slugify(title));
+ if (existing) {
+ const r = await entries.enrich(existing.id, { first_seen_at: firstSeen, last_seen_at: lastSeen, cites });
+ if (r && r.changed) enriched++;
+ } else {
+ await entries.createCandidate({
+ slug: slugify(title), title, kind: candidate.phrase.split(' ').length <= 4 ? 'catchphrase' : 'meme',
+ summary, status: statusFor(firstSeen, lastSeen, s.now()), first_seen_at: firstSeen, last_seen_at: lastSeen,
+ visibility: 'hidden', citations: cites,
+ });
+ created++;
+ }
+ } catch (err) {
+ errors.push(`candidate ${candidate.phrase}: ${err.message}`);
+ log.warn(`[OpenVibe.Meme] importer candidate failed: ${err.message}`);
+ }
+ }
+ stats.runs++;
+ stats.uses = uses.length;
+ stats.candidates = found.length;
+ stats.created += created;
+ stats.enriched += enriched;
+ stats.errors += errors.length;
+ stats.last_run_at = runAt;
+ stats.last_error = errors[0] || null;
+ const out = { ok: errors.length === 0, uses: uses.length, candidates: found.length, created, enriched, errors };
+ log.log(`[OpenVibe.Meme] importer: ${uses.length} uses → ${found.length} candidates, ${created} created, ${enriched} enriched${errors.length ? `, ${errors.length} errors` : ''}`);
+ return out;
+ }
+
+ function start() {
+ if (config.drill || timers.length) return false;
+ const quiet = () => runOnce().catch((err) => log.warn(`[OpenVibe.Meme] importer tick failed: ${err.message}`));
+ const kick = setTimeout(quiet, 30_000);
+ const tick = setInterval(quiet, config.importer.intervalMs);
+ if (kick.unref) kick.unref();
+ if (tick.unref) tick.unref();
+ timers = [kick, tick];
+ return true;
+ }
+ function stop() {
+ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
+ }
+
+ return { runOnce, start, stop, stats: () => ({ ...stats, running: timers.length > 0 }) };
+}
+
+module.exports = { createImporter, detectCandidates, recurringPhrases, statusFor, normalize, STOP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * OpenVibe.Meme — process entry. `node server/index.js`
+ * Listens on PORT (5120) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the
+ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Meme', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.keys.client.stop(),
+ () => ctx.importer.stop(),
+ () => ctx.reverifier.stop(),
+ async () => { clearInterval(ctx.telemetryTick); if (ctx.retention) await ctx.retention.stop(); if (ctx.telemetry) await ctx.telemetry.stop(); },
+ () => ctx.s.close(),
+ ...extra,
+ ],
+ });
+}
+
+async function start() {
+ const { app, ctx } = await createApp();
+ const { config } = ctx;
+
+ const server = app.listen(config.port, config.host, () => {
+ console.log(`[OpenVibe.Meme] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (db ${ctx.s.db.store})`);
+ // Warm the Network signing key immediately: a service nobody has signed in to yet must not read degraded
+ // only because the first JWKS fetch had not happened.
+ ctx.keys.ensure().catch(() => {});
+ });
+ server.keepAliveTimeout = 65_000;
+ ctx.retention = startTelemetryRetention({ db: ctx.s.db, log: ctx.log || console, olderThanDays: 14 });
+ ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
+ ctx.telemetryTick.unref();
+ ctx.keys.client.start();
+
+ // Jobs. Each refuses to start under MEME_DRILL; tests call their runOnce().
+ ctx.importer.start();
+ ctx.reverifier.start();
+
+ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
+ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.
+ const subs = config.drill ? null : startSubscriptions({
+ eventsUrl: config.events.url,
+ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
+ secret: config.events.secrets[0] || '',
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ log: ctx.log || console,
+ });
+ const extra = [() => { if (subs) subs.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Meme] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -0,0 +1,39 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
+ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has
+ * signed in to yet is not degraded only because the first fetch had not happened.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'meme',
+ release,
+ checks: [
+ {
+ name: 'db', required: true,
+ check: async () => {
+ const r = await s.db.ready();
+ return r.ok ? { ok: true, detail: r.detail } : r.error;
+ },
+ },
+ { name: 'valkey', required: false, check: async () => (valkey ? valkey.ready() : { skipped: 'VALKEY_URL not set: per-caller limits count in this process only' }) },
+ {
+ name: 'network_jwks', required: false,
+ check: () => {
+ const statuses = jwksStatus();
+ if (!statuses.length) return 'no JWKS clients (misconfigured)';
+ const first = statuses[0];
+ if (!first.ready) return 'Network signing key not loaded yet: sign-in and the API are unavailable';
+ return { ok: true, detail: { keys: first.keys, failures: first.failures, stale: first.stale, fetched_at: first.fetchedAt } };
+ },
+ },
+ { name: 'oauth_client', required: false, check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: sign-in cannot complete') },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * This is Today's layout with Meme's name, navigation, room accent and assets.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+const { render, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Meme';
+const TAGLINE = 'The open meme database, rebuilt with AI.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'This week', href: '/week' },
+ { label: 'Make a caption', href: '/make' },
+ { label: 'Developers', href: '/developers' },
+];
+
+const hashes = new Map();
+function assetVersion(rel) {
+ if (hashes.has(rel)) return hashes.get(rel);
+ let v = 'dev';
+ try { v = crypto.createHash('sha256').update(fs.readFileSync(path.join(PUBLIC_DIR, rel))).digest('hex').slice(0, 10); } catch { /* missing asset */ }
+ hashes.set(rel, v);
+ return v;
+}
+const asset = (rel) => `/${rel}?v=${assetVersion(rel)}`;
+
+let RELEASE = 'dev';
+function setRelease(id) { if (id) RELEASE = String(id); }
+
+/**
+ * o: title, description, body (html), viewer, config, path, index, cache, crumbs, jsonLd, styles
+ */
+function renderPage(o) {
+ const viewer = o.viewer || { kind: 'anonymous' };
+ const signedIn = viewer.kind === 'user';
+ const pathNow = o.path || '/';
+ const loginNext = encodeURIComponent(pathNow);
+ const canonical = `${o.config.baseUrl}${pathNow.split('?')[0]}`;
+ const nav = {
+ service: 'meme',
+ apiBase: NETWORK_URL,
+ links: NAV.map((l) => ({ label: l.label, href: l.href, active: pathNow === l.href || pathNow.startsWith(`${l.href}/`) })),
+ history: { type: 'page', title: o.title || SITE_NAME },
+ silentLogin: `${o.config.baseUrl}/auth/login?silent=1&next={url}`,
+ sessionUrl: '/auth/me',
+ loginUrl: '/auth/login?next={path}',
+ logoutUrl: '/auth/logout?next={path}',
+ notificationsRealtime: true,
+ };
+ const footer = { service: 'meme', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'meme' })}
+<link rel="stylesheet" href="${ovServe.url('kit.css')}">
+<link rel="stylesheet" href="${ovServe.url('room.css')}">
+<link rel="stylesheet" href="${ovServe.url('showcase.css')}">
+<link rel="stylesheet" href="${asset('css/meme.css')}">
+<script src="${ovServe.url('motion.js')}" defer></script>
+<script src="${asset('js/meme.js')}" defer></script>
+${render(scripts)}
+<meta name="ov-boost" content="meme@${require('./html').esc(RELEASE)}">
+<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
+<main id="main" class="mm ov-room" style="${rooms.style('meme')}">
+${crumbs}
+${render(o.body)}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -0,0 +1,136 @@
+'use strict';
+
+/**
+ * Citation re-verification, once a day (MEME_REVERIFY_INTERVAL_MS; off under MEME_DRILL). Every citation's public
+ * URL is read again: a 404/410 (a private VOD, a deleted clip) marks it gone; a 401/403 marks it hidden. A Chat
+ * citation is re-read from the public history page because v1 has no per-message permalink API. An entry whose
+ * origin citation is no longer live is hidden (revision +1, Search tombstone) — the plan's rule.
+ *
+ * The job is small and sequential on purpose: a handful of reads per citation, and a failing upstream leaves that
+ * citation as it was (a transient 503 must not erase a real citation).
+ */
+const { parseApiTime } = require('./upstreams');
+const { isDenied } = require('./deny-list');
+
+function createReverifier({ config, s, entries, upstreams, log = console }) {
+ let timers = [];
+ const stats = { runs: 0, checked: 0, gone: 0, hidden: 0, entries_hidden: 0, errors: 0, last_run_at: null, last_error: null };
+
+ async function verifyOne(citation) {
+ if (isDenied({ vod_id: citation.vod_id, clip_id: citation.clip_id })) return { status: 'gone', denied: true };
+ if (citation.source_type === 'chat_message') {
+ const r = await upstreams.chatMessage(citation);
+ return { status: r.status, text: r.text || null };
+ }
+ const r = await upstreams.citationUrl(citation.url);
+ const out = { status: r.status };
+ if (r.finalUrl && r.finalUrl !== citation.url) {
+ try {
+ const u = new URL(r.finalUrl);
+ const m = u.pathname.match(/\/v\/(\d+)\/?$/);
+ if (m) {
+ out.vod_id = Number(m[1]);
+ const t = u.searchParams.get('t');
+ if (t != null && t !== '') out.offset_seconds = Number(t);
+ }
+ out.url = u.toString();
+ } catch { /* keep the stored URL */ }
+ }
+ return out;
+ }
+
+ async function runOnce({ limit = 500 } = {}) {
+ const runAt = new Date(s.now()).toISOString();
+ const rows = await s.db.many(
+ "SELECT * FROM meme_citations WHERE source_status <> 'gone' ORDER BY captured_at ASC, id ASC LIMIT $1",
+ [Math.min(5000, Math.max(1, Number(limit) || 500))],
+ );
+ let checked = 0;
+ let gone = 0;
+ let hidden = 0;
+ const toHide = new Set();
+ const toReindex = new Set();
+ const errors = [];
+ for (const row of rows) {
+ checked++;
+ try {
+ const r = await verifyOne(row);
+ const status = r.status === 'live' ? 'live' : r.status === 'hidden' ? 'hidden' : 'gone';
+ if (status === 'gone') gone++;
+ if (status === 'hidden') hidden++;
+ if (status !== row.source_status || r.url || r.vod_id != null || r.offset_seconds != null) {
+ if (status !== 'live') toHide.add(row.entry_id);
+ else toReindex.add(row.entry_id);
+ await s.db.exec(
+ `UPDATE meme_citations SET source_status = $2,
+ url = COALESCE($3, url),
+ vod_id = COALESCE($4, vod_id),
+ offset_seconds = COALESCE($5, offset_seconds),
+ captured_at = now()
+ WHERE id = $1`,
+ [row.id, status, r.url || null, r.vod_id == null ? null : r.vod_id, r.offset_seconds == null ? null : r.offset_seconds],
+ );
+ } else {
+ await s.db.exec('UPDATE meme_citations SET captured_at = now() WHERE id = $1', [row.id]);
+ }
+ } catch (err) {
+ errors.push(`citation ${row.id}: ${err.message}`);
+ log.warn(`[OpenVibe.Meme] re-verify ${row.id} failed: ${err.message}`);
+ }
+ }
+
+ let entryHides = 0;
+ for (const entryId of toHide) {
+ try {
+ const originGone = await s.db.maybe(
+ "SELECT id FROM meme_citations WHERE entry_id = $1 AND role = 'origin' AND source_status <> 'live' LIMIT 1",
+ [entryId],
+ );
+ if (!originGone) { toReindex.add(entryId); continue; }
+ const row = await entries.byId(entryId);
+ if (!row || row.visibility === 'hidden') continue;
+ const r = await entries.hide(row.slug, { changedBy: 'service:meme:reverify' });
+ if (r && r.ok && !r.unchanged) entryHides++;
+ } catch (err) {
+ errors.push(`entry ${entryId}: ${err.message}`);
+ log.warn(`[OpenVibe.Meme] re-verify entry ${entryId} failed: ${err.message}`);
+ }
+ }
+ // A live use that came back, or a joined-VOD URL that moved, changes the public document without hiding it.
+ for (const entryId of toReindex) {
+ try { await entries.reindex(entryId); } catch (err) {
+ errors.push(`entry ${entryId}: ${err.message}`);
+ log.warn(`[OpenVibe.Meme] re-index entry ${entryId} failed: ${err.message}`);
+ }
+ }
+
+ stats.runs++;
+ stats.checked += checked;
+ stats.gone += gone;
+ stats.hidden += hidden;
+ stats.entries_hidden += entryHides;
+ stats.errors += errors.length;
+ stats.last_run_at = runAt;
+ stats.last_error = errors[0] || null;
+ log.log(`[OpenVibe.Meme] re-verify: ${checked} citations, ${gone} gone, ${hidden} hidden, ${entryHides} entries hidden${errors.length ? `, ${errors.length} errors` : ''}`);
+ return { checked, gone, hidden, entries_hidden: entryHides, errors };
+ }
+
+ function start() {
+ if (config.drill || timers.length) return false;
+ const quiet = () => runOnce().catch((err) => log.warn(`[OpenVibe.Meme] re-verify tick failed: ${err.message}`));
+ const kick = setTimeout(quiet, 60_000);
+ const tick = setInterval(quiet, config.reverify.intervalMs);
+ if (kick.unref) kick.unref();
+ if (tick.unref) tick.unref();
+ timers = [kick, tick];
+ return true;
+ }
+ function stop() {
+ for (const t of timers.splice(0)) { clearTimeout(t); clearInterval(t); }
+ }
+
+ return { runOnce, verifyOne, start, stop, stats: () => ({ ...stats, running: timers.length > 0 }) };
+}
+
+module.exports = { createReverifier, parseApiTime };
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+'use strict';
+
+/**
+ * OpenVibe.Meme's Search Fabric feed. Every public entry is one `search.index-document@1` (owner `meme`, type
+ * `entry`, page `/m/<slug>`), written to `meme_outbox` in the same transaction as the entry change; a hidden
+ * entry writes a tombstone, which wins over any older revision. The document carries the entry's AI-cleaned
+ * summary, the verbatim quotes as plain-text body, facets `kind`/`status`/`source`, authorship `ai_assisted` and
+ * one provenance reference per citation — the same shape Work, Help and the other record products send.
+ *
+ * No service grant is needed to queue the rows: they wait in the outbox until OpenVibe.Search's document writer
+ * is configured for `meme` (research §6.2). The relay is deliberately not part of v1.
+ */
+const { ids } = require('openvibe-contracts');
+
+const OWNER = 'meme';
+const TYPE = 'entry';
+const EVENT_UPSERTED = 'meme.index_document.upserted';
+const EVENT_DELETED = 'meme.index_document.deleted';
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const pagePath = (entry) => `/m/${entry.slug}`;
+
+function provenanceOf(citations) {
+ const seen = new Set();
+ const out = [];
+ for (const c of citations || []) {
+ const key = `${c.source_service}:${c.source_type}:${c.source_id}`;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({
+ service: c.source_service,
+ type: c.source_type,
+ id: String(c.source_id).slice(0, 128),
+ ...(c.url ? { url: String(c.url).slice(0, 2048) } : {}),
+ ...(c.captured_at || c.observed_at ? { retrieved_at: iso(c.captured_at || c.observed_at) } : {}),
+ });
+ if (out.length >= 50) break;
+ }
+ return out;
+}
+
+/** One entry + its live citations → the Search document (or a tombstone when it is not public). */
+function documentOf(entry, citations = [], { baseUrl = 'https://openvibe.meme' } = {}) {
+ const revision = Math.max(1, Number(entry.revision) || 1);
+ if (!entry || entry.visibility !== 'public') return { owner: OWNER, type: TYPE, id: entry.id, revision, deleted: true };
+ const live = (citations || []).filter((c) => c.source_status === 'live');
+ const sources = [...new Set(live.map((c) => c.source_service))];
+ const body = [entry.title, entry.summary || '', ...live.map((c) => c.quote_text)]
+ .filter(Boolean).join('\n').slice(0, 48_000);
+ return {
+ owner: OWNER,
+ type: TYPE,
+ id: entry.id,
+ revision,
+ deleted: false,
+ visibility: 'public',
+ canonical_url: `${String(baseUrl).replace(/\/+$/, '')}${pagePath(entry)}`,
+ title: entry.title,
+ ...(entry.summary ? { summary: entry.summary } : {}),
+ body,
+ facets: { kind: entry.kind, status: entry.status, source: sources },
+ authorship: 'ai_assisted',
+ provenance: provenanceOf(live),
+ publication_state: 'published',
+ published_at: iso(entry.created_at),
+ updated_at: iso(entry.updated_at),
+ indexability: { decision: 'index' },
+ };
+}
+
+function createSearchIndex({ config, s, baseUrl = config.baseUrl }) {
+ const nowIso = () => new Date(s.now()).toISOString();
+
+ async function enqueue(topic, payload, t = null) {
+ const db = t || s.db;
+ const eventId = ids.newId('event', s.now());
+ await db.exec(
+ `INSERT INTO meme_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
+ [eventId, topic, JSON.stringify(payload), nowIso()],
+ );
+ return eventId;
+ }
+
+ /** Queue the current document on the caller's transaction (upsert when public, tombstone otherwise). */
+ async function sync(t, entry, citations) {
+ const doc = documentOf(entry, citations, { baseUrl });
+ return await enqueue(doc.deleted ? EVENT_DELETED : EVENT_UPSERTED, doc, t);
+ }
+
+ /** Tombstone an id that no longer exists or is now hidden. */
+ async function remove(t, id, revision) {
+ return await enqueue(EVENT_DELETED, { owner: OWNER, type: TYPE, id, revision: Math.max(1, Number(revision) || 1), deleted: true }, t);
+ }
+
+ async function pending() {
+ return Number(await s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox WHERE published_at IS NULL'));
+ }
+
+ return { sync, remove, enqueue, documentOf, pending, baseUrl: String(baseUrl).replace(/\/+$/, '') };
+}
+
+module.exports = { createSearchIndex, documentOf, provenanceOf, pagePath, OWNER, TYPE, EVENT_UPSERTED, EVENT_DELETED };
new file mode 100644
new file mode 100644
@@ -0,0 +1,517 @@
@@ -0,0 +1,517 @@
+'use strict';
+
+/**
+ * Meme's data: entries, their citations, status history, the week's votes, templates, saved captions and reports.
+ *
+ * A write that changes a public entry writes its Search document (or tombstone) in the same transaction
+ * (server/search.js). A candidate is always inserted hidden; only entries.publish() makes it public, and only for
+ * staff (server/http/api.js). revision goes up on every change, and every status change leaves a
+ * meme_status_events row. Captions hold text lines only — no image is ever stored for a caption.
+ */
+const crypto = require('crypto');
+const { isDenied } = require('./deny-list');
+const { cleanSummary } = require('./text/no-looks');
+const { weekBounds } = require('./week');
+
+const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
+const KINDS = Object.freeze(['joke', 'catchphrase', 'bit', 'meme', 'saga']);
+const STATUSES = Object.freeze(['rising', 'peaked', 'classic', 'dead']);
+const VISIBILITIES = Object.freeze(['public', 'unlisted', 'hidden']);
+const CAPTION_VISIBILITIES = Object.freeze(['public', 'unlisted', 'hidden']);
+const MAX_LINES = 4;
+const MAX_LINE = 120;
+const BLOCKED_WORDS = Object.freeze(['nigger', 'faggot', 'retard', 'kys', 'rape', 'pedo', 'nazi']);
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+const sha256 = (s) => crypto.createHash('sha256').update(String(s), 'utf8').digest('hex');
+const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
+
+function wireEntry(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ slug: row.slug,
+ title: row.title,
+ kind: row.kind,
+ summary: row.summary == null ? null : row.summary,
+ status: row.status,
+ status_since: iso(row.status_since),
+ first_seen_at: iso(row.first_seen_at),
+ last_seen_at: iso(row.last_seen_at),
+ visibility: row.visibility,
+ revision: Number(row.revision) || 1,
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ };
+}
+
+function wireCitation(row) {
+ if (!row) return null;
+ return {
+ id: Number(row.id),
+ entry_id: row.entry_id,
+ role: row.role,
+ source_service: row.source_service,
+ source_type: row.source_type,
+ source_id: row.source_id,
+ stream_id: nOrNull(row.stream_id),
+ vod_id: nOrNull(row.vod_id),
+ clip_id: nOrNull(row.clip_id),
+ chat_message_id: nOrNull(row.chat_message_id),
+ offset_seconds: nOrNull(row.offset_seconds),
+ quote_text: row.quote_text,
+ quote_hash: row.quote_hash,
+ url: row.url,
+ observed_at: iso(row.observed_at),
+ source_status: row.source_status,
+ captured_at: iso(row.captured_at),
+ };
+}
+
+function wireTemplate(row) {
+ if (!row) return null;
+ return { id: row.id, name: row.name, image_url: row.image_url, box_count: Number(row.box_count), source: row.source, license: row.license, created_at: iso(row.created_at) };
+}
+
+function wireCaption(row) {
+ if (!row) return null;
+ let lines = row.lines;
+ if (typeof lines === 'string') { try { lines = JSON.parse(lines); } catch { lines = []; } }
+ return {
+ id: row.id,
+ subject: row.subject,
+ template_id: row.template_id,
+ lines: Array.isArray(lines) ? lines.map(String) : [],
+ created_at: iso(row.created_at),
+ visibility: row.visibility,
+ };
+}
+
+function wireReport(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ entry_id: row.entry_id,
+ subject: row.subject,
+ kind: row.kind,
+ reason: row.reason,
+ note: row.note == null ? null : row.note,
+ status: row.status,
+ created_at: iso(row.created_at),
+ handled_at: iso(row.handled_at),
+ handled_by: row.handled_by,
+ ...(row.entry_slug ? { entry_slug: row.entry_slug } : {}),
+ ...(row.entry_title ? { entry_title: row.entry_title } : {}),
+ };
+}
+
+function normalizeLine(s) {
+ return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
+}
+
+/** Validate a title/kind/status patch body. Returns { ok, value } or a refusal. */
+function patchFields(body) {
+ const b = body && typeof body === 'object' ? body : {};
+ const allowed = ['title', 'kind', 'status', 'reason'];
+ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));
+ if (unknown.length) return refusal(400, 'meme.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);
+ const out = {};
+ if (b.title !== undefined) {
+ const title = String(b.title == null ? '' : b.title).replace(/\s+/g, ' ').trim();
+ if (!title || title.length > 160) return refusal(400, 'meme.invalid', 'title must be 1 to 160 characters', 'title');
+ out.title = title;
+ }
+ if (b.kind !== undefined) {
+ if (!KINDS.includes(b.kind)) return refusal(400, 'meme.invalid', `kind must be one of ${KINDS.join(', ')}`, 'kind');
+ out.kind = b.kind;
+ }
+ if (b.status !== undefined) {
+ if (!STATUSES.includes(b.status)) return refusal(400, 'meme.invalid', `status must be one of ${STATUSES.join(', ')}`, 'status');
+ out.status = b.status;
+ }
+ if (b.reason !== undefined) {
+ const reason = String(b.reason == null ? '' : b.reason).trim().slice(0, 500);
+ if (reason) out.reason = reason;
+ }
+ if (!Object.keys(out).some((k) => k !== 'reason')) return refusal(400, 'meme.invalid', 'Send title, kind or status', null);
+ return { ok: true, value: out };
+}
+
+/** Captions: 1–4 plain-text lines, each 1–120 characters, a small blocked-word list, no control characters. */
+function captionLines(raw) {
+ if (!Array.isArray(raw)) return refusal(400, 'meme.invalid', 'lines must be an array of 1 to 4 strings', 'lines');
+ if (raw.length < 1 || raw.length > MAX_LINES) return refusal(400, 'meme.invalid', `lines must have 1 to ${MAX_LINES} entries`, 'lines');
+ const lines = [];
+ for (let i = 0; i < raw.length; i++) {
+ const line = normalizeLine(raw[i]);
+ if (!line) return refusal(400, 'meme.invalid', `line ${i + 1} is empty`, 'lines');
+ if (line.length > MAX_LINE) return refusal(400, 'meme.invalid', `line ${i + 1} is longer than ${MAX_LINE} characters`, 'lines');
+ if (/[\u0000-\u001f\u007f]/.test(String(raw[i]))) return refusal(400, 'meme.invalid', 'lines are plain text: no control characters', 'lines');
+ if (BLOCKED_WORDS.some((w) => new RegExp(`(^|[^a-z0-9])${w}([^a-z0-9]|$)`, 'i').test(line))) {
+ return refusal(400, 'meme.blocked_words', 'That wording is not allowed in a caption', 'lines');
+ }
+ lines.push(line);
+ }
+ return { ok: true, lines };
+}
+
+function createStore(s, { search }) {
+ const db = s.db;
+ // Every write function takes an optional transaction handle; without one it runs on the pool and is its own
+ // statement (no multi-statement atomicity unless the caller opens s.tx()).
+ const q = (t) => t || db;
+
+ async function bySlug(slug, t) {
+ return await q(t).maybe('SELECT * FROM meme_entries WHERE slug = $1', [String(slug || '')]);
+ }
+ async function byId(id, t) {
+ return await q(t).maybe('SELECT * FROM meme_entries WHERE id = $1', [String(id || '')]);
+ }
+ async function citations(entryId, t) {
+ return (await q(t).many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at ASC, id ASC', [entryId])).map(wireCitation);
+ }
+ async function liveCitations(entryId, t) {
+ return (await q(t).many("SELECT * FROM meme_citations WHERE entry_id = $1 AND source_status = 'live' ORDER BY observed_at ASC, id ASC", [entryId])).map(wireCitation);
+ }
+
+ /** Public entries, newest first. cursor is an opaque (first_seen_at, id) keyset position. */
+ async function list({ limit = 20, cursor = null, kind = null, status = null } = {}) {
+ const n = Math.min(50, Math.max(1, Number(limit) || 20));
+ const args = [];
+ let where = "visibility = 'public'";
+ if (kind) { if (!KINDS.includes(kind)) return { entry: null, error: 'kind' }; args.push(kind); where += ` AND kind = $${args.length}`; }
+ if (status) { if (!STATUSES.includes(status)) return { entry: null, error: 'status' }; args.push(status); where += ` AND status = $${args.length}`; }
+ let pos = null;
+ if (cursor) {
+ try {
+ const decoded = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
+ if (typeof decoded.t === 'string' && typeof decoded.i === 'string' && !Number.isNaN(Date.parse(decoded.t))) pos = decoded;
+ } catch { /* an unreadable cursor is refused by the caller */ }
+ if (!pos) return { entry: null, error: 'cursor' };
+ args.push(pos.t, pos.i);
+ where += ` AND (first_seen_at, id) < ($${args.length - 1}::timestamptz, $${args.length})`;
+ }
+ args.push(n + 1);
+ const rows = await db.many(`SELECT * FROM meme_entries WHERE ${where} ORDER BY first_seen_at DESC, id DESC LIMIT $${args.length}`, args);
+ const hasMore = rows.length > n;
+ const page = rows.slice(0, n);
+ const last = page[page.length - 1];
+ return {
+ entries: page.map(wireEntry),
+ next_cursor: hasMore && last ? Buffer.from(JSON.stringify({ t: iso(last.first_seen_at), i: last.id })).toString('base64url') : null,
+ };
+ }
+
+ async function trending({ limit = 20 } = {}) {
+ const n = Math.min(50, Math.max(1, Number(limit) || 20));
+ const rows = await db.many(
+ `SELECT * FROM meme_entries WHERE visibility = 'public'
+ ORDER BY CASE status WHEN 'rising' THEN 0 WHEN 'peaked' THEN 1 WHEN 'classic' THEN 2 WHEN 'dead' THEN 3 ELSE 4 END,
+ last_seen_at DESC, id DESC LIMIT $1`,
+ [n],
+ );
+ return rows.map(wireEntry);
+ }
+
+ /** The current week's candidates (public entries with a live use that week) and their ballot counts. */
+ async function week({ week: label, subject = null } = {}) {
+ const bounds = weekBounds(label);
+ if (!bounds) return null;
+ const rows = await db.many(
+ `SELECT e.*,
+ (SELECT COUNT(*)::int FROM meme_votes v WHERE v.week = $1 AND v.entry_id = e.id) AS votes,
+ (SELECT COUNT(*)::int FROM meme_votes v WHERE v.week = $1 AND v.entry_id = e.id AND v.subject = $2) AS mine
+ FROM meme_entries e
+ WHERE e.visibility = 'public'
+ AND EXISTS (SELECT 1 FROM meme_citations c
+ WHERE c.entry_id = e.id AND c.source_status = 'live'
+ AND c.observed_at >= $3::timestamptz AND c.observed_at < $4::timestamptz)
+ ORDER BY votes DESC, e.last_seen_at DESC, e.id DESC LIMIT 30`,
+ [label, subject, bounds.start, bounds.end],
+ );
+ return {
+ week: label,
+ starts_at: bounds.start,
+ ends_at: bounds.end,
+ candidates: rows.map((r) => ({ ...wireEntry(r), votes: Number(r.votes) || 0, mine: Number(r.mine) > 0 })),
+ };
+ }
+
+ /** Uses per UTC day over the last `days` days, zero-filled so a chart has no holes. */
+ async function series(entryId, { days = 30 } = {}) {
+ const n = Math.min(365, Math.max(1, Number(days) || 30));
+ const now = new Date(s.now());
+ const start = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
+ start.setUTCDate(start.getUTCDate() - (n - 1));
+ const rows = await db.many(
+ `SELECT to_char(observed_at AT TIME ZONE 'UTC', 'YYYY-MM-DD') AS day, COUNT(*)::int AS uses
+ FROM meme_citations
+ WHERE entry_id = $1 AND role IN ('origin', 'use') AND source_status = 'live'
+ AND observed_at >= $2::timestamptz AND observed_at <= $3::timestamptz
+ GROUP BY 1 ORDER BY 1`,
+ [entryId, start.toISOString(), now.toISOString()],
+ );
+ const counts = new Map(rows.map((r) => [r.day, Number(r.uses) || 0]));
+ const out = [];
+ for (let i = 0; i < n; i++) {
+ const d = new Date(start.getTime() + i * 86_400_000);
+ const day = d.toISOString().slice(0, 10);
+ out.push({ day, uses: counts.get(day) || 0 });
+ }
+ return { days: n, from: start.toISOString(), to: now.toISOString(), series: out };
+ }
+
+ /** The id for a slug, appending -2, -3… when one is already taken. */
+ async function uniqueSlug(base, t) {
+ const root = String(base || 'meme').slice(0, 70).replace(/^-+|-+$/g, '') || 'meme';
+ let slug = root;
+ for (let i = 2; i < 200; i++) {
+ const found = await q(t).maybe('SELECT id FROM meme_entries WHERE slug = $1', [slug]);
+ if (!found) return slug;
+ slug = `${root}-${i}`;
+ }
+ return `${root}-${Date.now().toString(36)}`;
+ }
+
+ /**
+ * Insert one hidden candidate and its citations in the caller's transaction. Used by the importer and the
+ * seed script only; it never publishes. Citations that name the deny list are dropped here too.
+ */
+ async function createCandidate({ slug = null, title, kind = 'meme', summary = null, status = 'rising', first_seen_at, last_seen_at, visibility = 'hidden', citations: cites = [] }, t = null) {
+ const run = async (tx) => {
+ const id = s.newId('mem');
+ const cleanSlug = await uniqueSlug(slug || slugify(title), tx);
+ const cleanTitle = String(title || '').replace(/\s+/g, ' ').trim().slice(0, 160) || 'Untitled meme';
+ let first = iso(first_seen_at) || s.iso();
+ let last = iso(last_seen_at) || first;
+ if (Date.parse(first) > Date.parse(last)) { const swap = first; first = last; last = swap; }
+ const row = await q(tx).maybe(
+ `INSERT INTO meme_entries (id, slug, title, kind, summary, status, status_since, first_seen_at, last_seen_at, visibility, revision, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9::timestamptz, $10, 1, now(), now()) RETURNING *`,
+ [id, cleanSlug, cleanTitle, kind, cleanSummary(cleanTitle, summary), status, iso(last), first, last, visibility],
+ );
+ const added = await addCitations(row.id, cites, tx);
+ const entry = wireEntry(row);
+ if (entry.visibility === 'public') await search.sync(tx, entry, added);
+ return { entry, citations: added };
+ };
+ return t ? await run(t) : await s.tx(run);
+ }
+
+ /** Insert citations in the caller's transaction; the sha256 is computed from the verbatim quote. */
+ async function addCitations(entryId, cites = [], t = null) {
+ const run = async (tx) => {
+ const out = [];
+ for (const c of Array.isArray(cites) ? cites : []) {
+ if (!c || isDenied(c)) continue;
+ const quote = String(c.quote_text == null ? '' : c.quote_text);
+ if (!quote.trim()) continue;
+ const role = c.role === 'origin' || c.role === 'example' ? c.role : 'use';
+ const observed = iso(c.observed_at) || s.iso();
+ const row = await q(tx).maybe(
+ `INSERT INTO meme_citations (entry_id, role, source_service, source_type, source_id, stream_id, vod_id, clip_id, chat_message_id,
+ offset_seconds, quote_text, quote_hash, url, observed_at, source_status, captured_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14::timestamptz, $15, now())
+ ON CONFLICT DO NOTHING RETURNING *`,
+ [entryId, role, c.source_service, c.source_type, String(c.source_id).slice(0, 200), c.stream_id == null ? null : Number(c.stream_id),
+ c.vod_id == null ? null : Number(c.vod_id), c.clip_id == null ? null : Number(c.clip_id),
+ c.chat_message_id == null ? null : Number(c.chat_message_id), c.offset_seconds == null ? null : Number(c.offset_seconds),
+ quote, sha256(quote), String(c.url), observed, c.source_status || 'live'],
+ );
+ if (row) out.push(wireCitation(row));
+ }
+ return out;
+ };
+ return t ? await run(t) : await s.tx(run);
+ }
+
+ /** Add uses to an existing candidate, extend its range and bump its revision only when something changed. */
+ async function enrich(entryId, { first_seen_at, last_seen_at, cites = [] }) {
+ return await s.tx(async (t) => {
+ const row = await byId(entryId, t);
+ if (!row) return null;
+ const first = iso(first_seen_at) || iso(row.first_seen_at);
+ const last = iso(last_seen_at) || iso(row.last_seen_at);
+ const added = await addCitations(entryId, cites, t);
+ const earlier = Date.parse(first) < Date.parse(iso(row.first_seen_at));
+ const later = Date.parse(last) > Date.parse(iso(row.last_seen_at));
+ if (!added.length && !earlier && !later) return { entry: wireEntry(row), added: 0, changed: false };
+ const updated = await t.maybe(
+ `UPDATE meme_entries SET first_seen_at = LEAST(first_seen_at, $2::timestamptz), last_seen_at = GREATEST(last_seen_at, $3::timestamptz),
+ revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
+ [entryId, first, last],
+ );
+ const all = await liveCitations(entryId, t);
+ if (updated.visibility === 'public') await search.sync(t, wireEntry(updated), all);
+ return { entry: wireEntry(updated), added: added.length, changed: true };
+ });
+ }
+
+ /** A citation changed (a redirect, or a use went away) while the entry stayed public: a new revision and document. */
+ async function reindex(entryId, { changedBy = 'service:meme:reverify' } = {}) {
+ return await s.tx(async (t) => {
+ const row = await byId(entryId, t);
+ if (!row || row.visibility !== 'public') return { ok: false, unchanged: true };
+ const updated = await t.maybe('UPDATE meme_entries SET revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id]);
+ const all = await liveCitations(row.id, t);
+ await search.sync(t, wireEntry(updated), all);
+ return { ok: true, entry: wireEntry(updated) };
+ });
+ }
+
+ async function publish(slug, { changedBy = 'service:meme' } = {}) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');
+ const origin = await t.maybe("SELECT id FROM meme_citations WHERE entry_id = $1 AND role = 'origin' AND source_status = 'live' LIMIT 1", [row.id]);
+ if (!origin) return refusal(409, 'meme.origin_gone', 'The origin citation is missing or gone; this entry cannot be published.');
+ if (row.visibility === 'public') return { ok: true, entry: wireEntry(row), unchanged: true };
+ const updated = await t.maybe('UPDATE meme_entries SET visibility = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id, 'public']);
+ const all = await liveCitations(row.id, t);
+ await search.sync(t, wireEntry(updated), all);
+ return { ok: true, entry: wireEntry(updated), unchanged: false };
+ });
+ }
+
+ async function hide(slug, { changedBy = 'service:meme' } = {}) {
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');
+ if (row.visibility === 'hidden') return { ok: true, entry: wireEntry(row), unchanged: true };
+ const updated = await t.maybe('UPDATE meme_entries SET visibility = $2, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *', [row.id, 'hidden']);
+ await search.sync(t, wireEntry(updated), []);
+ return { ok: true, entry: wireEntry(updated), unchanged: false };
+ });
+ }
+
+ async function patch(slug, { body, changedBy = 'service:meme' } = {}) {
+ const checked = patchFields(body);
+ if (!checked.ok) return checked;
+ const v = checked.value;
+ return await s.tx(async (t) => {
+ const row = await bySlug(slug, t);
+ if (!row) return refusal(404, 'meme.not_found', 'No such meme.');
+ const sets = [];
+ const args = [row.id];
+ for (const key of ['title', 'kind', 'status']) {
+ if (v[key] !== undefined) { args.push(v[key]); sets.push(`${key} = $${args.length}`); }
+ }
+ if (v.status !== undefined && v.status !== row.status) sets.push('status_since = now()');
+ const updated = await t.maybe(
+ `UPDATE meme_entries SET ${sets.join(', ')}, revision = revision + 1, updated_at = now() WHERE id = $1 RETURNING *`,
+ args,
+ );
+ if (v.status !== undefined && v.status !== row.status) {
+ await t.exec('INSERT INTO meme_status_events (entry_id, from_status, to_status, reason, changed_by) VALUES ($1, $2, $3, $4, $5)',
+ [row.id, row.status, v.status, v.reason || null, changedBy]);
+ }
+ // A hidden entry has no Search document to correct; only a public one is re-indexed here.
+ if (updated.visibility === 'public') {
+ const all = await liveCitations(row.id, t);
+ await search.sync(t, wireEntry(updated), all);
+ }
+ return { ok: true, entry: wireEntry(updated), unchanged: false };
+ });
+ }
+
+ async function templates() {
+ return (await db.many('SELECT * FROM meme_templates ORDER BY box_count ASC, name ASC')).map(wireTemplate);
+ }
+ async function templateById(id) {
+ return wireTemplate(await db.maybe('SELECT * FROM meme_templates WHERE id = $1', [String(id || '')]));
+ }
+
+ async function createCaption({ subject, templateId, lines, visibility = 'public' }) {
+ if (!CAPTION_VISIBILITIES.includes(visibility)) return refusal(400, 'meme.invalid', `visibility must be one of ${CAPTION_VISIBILITIES.join(', ')}`, 'visibility');
+ const checked = captionLines(lines);
+ if (!checked.ok) return checked;
+ const tpl = await db.maybe('SELECT id FROM meme_templates WHERE id = $1', [String(templateId || '')]);
+ if (!tpl) return refusal(404, 'meme.template_not_found', 'No such template.');
+ const id = s.newId('mcp');
+ const row = await db.maybe(
+ `INSERT INTO meme_captions (id, subject, template_id, lines, created_at, visibility)
+ VALUES ($1, $2, $3, $4::jsonb, now(), $5) RETURNING *`,
+ [id, subject, tpl.id, JSON.stringify(checked.lines), visibility],
+ );
+ return { ok: true, caption: wireCaption(row) };
+ }
+
+ async function captionById(id) {
+ return wireCaption(await db.maybe('SELECT * FROM meme_captions WHERE id = $1', [String(id || '')]));
+ }
+
+ async function captionCountForTemplate(templateId) {
+ return Number(await db.value("SELECT COUNT(*)::int AS n FROM meme_captions WHERE template_id = $1 AND visibility = 'public'", [templateId])) || 0;
+ }
+
+ async function castVote({ subject, entryId, week }) {
+ const bounds = weekBounds(week);
+ if (!bounds) return refusal(400, 'meme.invalid', 'week must be YYYY-Www');
+ const row = await db.maybe(
+ `SELECT e.* FROM meme_entries e
+ WHERE e.id = $1 AND e.visibility = 'public'
+ AND EXISTS (SELECT 1 FROM meme_citations c WHERE c.entry_id = e.id AND c.source_status = 'live'
+ AND c.observed_at >= $2::timestamptz AND c.observed_at < $3::timestamptz)`,
+ [String(entryId || ''), bounds.start, bounds.end],
+ );
+ if (!row) return refusal(404, 'meme.not_found', 'No such candidate in this week.');
+ const prev = await db.maybe('SELECT entry_id FROM meme_votes WHERE week = $1 AND subject = $2', [week, subject]);
+ await db.exec(
+ `INSERT INTO meme_votes (week, entry_id, subject, created_at, updated_at)
+ VALUES ($1, $2, $3, now(), now())
+ ON CONFLICT (week, subject) DO UPDATE SET entry_id = EXCLUDED.entry_id, updated_at = now()`,
+ [week, row.id, subject],
+ );
+ const counts = await db.many('SELECT entry_id, COUNT(*)::int AS votes FROM meme_votes WHERE week = $1 GROUP BY entry_id', [week]);
+ return {
+ ok: true,
+ vote: { week, entry_id: row.id, changed: !prev || prev.entry_id !== row.id },
+ counts: Object.fromEntries(counts.map((c) => [c.entry_id, Number(c.votes)])),
+ };
+ }
+
+ async function createReport({ entryId, subject, kind, reason, note = null }) {
+ const row = await db.maybe("SELECT id FROM meme_entries WHERE id = $1 AND visibility = 'public'", [String(entryId || '')]);
+ if (!row) return refusal(404, 'meme.not_found', 'No such public entry.');
+ const id = s.newId('mrp');
+ const saved = await db.maybe(
+ `INSERT INTO meme_reports (id, entry_id, subject, kind, reason, note, status, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, 'open', now()) RETURNING *`,
+ [id, row.id, subject, kind, reason, note],
+ );
+ return { ok: true, report: wireReport(saved) };
+ }
+
+ async function listOpenReports({ limit = 100 } = {}) {
+ const rows = await db.many(
+ `SELECT r.*, e.slug AS entry_slug, e.title AS entry_title
+ FROM meme_reports r JOIN meme_entries e ON e.id = r.entry_id
+ WHERE r.status = 'open' ORDER BY r.created_at ASC LIMIT $1`,
+ [Math.min(200, Math.max(1, Number(limit) || 100))],
+ );
+ return rows.map(wireReport);
+ }
+
+ return {
+ wireEntry, wireCitation, wireTemplate, wireCaption, wireReport,
+ bySlug, byId, citations, liveCitations, list, trending, week, series, uniqueSlug,
+ createCandidate, addCitations, enrich, reindex, publish, hide, patch,
+ templates, templateById, createCaption, captionById, captionCountForTemplate,
+ castVote, createReport, listOpenReports,
+ KINDS, STATUSES, VISIBILITIES, BLOCKED_WORDS, MAX_LINES, MAX_LINE,
+ };
+}
+
+function slugify(title) {
+ const s = String(title || '')
+ .toLowerCase()
+ .normalize('NFKD')
+ .replace(/[\u0300-\u036f]/g, '')
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-+|-+$/g, '')
+ .slice(0, 70);
+ return s || 'meme';
+}
+
+module.exports = { createStore, slugify, patchFields, captionLines, wireEntry, wireCitation, wireTemplate, wireCaption, wireReport, SLUG_RE, KINDS, STATUSES, VISIBILITIES, BLOCKED_WORDS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+
+/**
+ * No looks in public AI text (the network rule, openvibe-sdk/no-looks, shared with Live, Community and MediaHub):
+ * an AI-written summary never describes a person's body, clothing, hair, face or guessed gender. A summary that
+ * names looks at all is not repaired word by word — it is replaced by the entry's title, as Meme's plan says.
+ * Verbatim quotes are never passed through this: rewriting a quote would falsify the citation.
+ */
+const { noLooks, hasLooks } = require('openvibe-sdk/no-looks');
+
+/** The summary to store for an AI-written entry summary. */
+function cleanSummary(title, summary) {
+ const t = String(title || '').trim() || 'Untitled meme';
+ const s = String(summary == null ? '' : summary).trim();
+ if (!s) return t;
+ if (hasLooks(s)) return t;
+ const cleaned = String(noLooks(s) || '').trim();
+ return cleaned || t;
+}
+
+module.exports = { cleanSummary, noLooks, hasLooks };
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+
+/**
+ * The public reads OpenVibe.Meme makes: OpenVibe.Chat's global history and AI timeline, OpenVibe.Live's Rundown,
+ * VOD rows and VOD transcripts, and OpenVibe.Video's canonical `?t=` redirect. No service grant is used in v1;
+ * every call here is the same anonymous read a visitor can make, and a private or missing object answers 404
+ * (Live and Chat both use 404, never 403). Nothing about a failed call is cached.
+ */
+const { isDenied } = require('./deny-list');
+
+/** "2026-10-28 00:11:53" (Live/Chat's shape) → an ISO instant, read as UTC when it carries no zone. */
+function parseApiTime(v) {
+ if (v == null || v === '') return null;
+ if (v instanceof Date) return Number.isNaN(v.getTime()) ? null : v.toISOString();
+ let s = String(v).trim();
+ if (/^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}/.test(s)) s = s.replace(' ', 'T') + 'Z';
+ const d = new Date(s);
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
+}
+
+/** UTC "YYYY-MM-DD HH:mm:ss", the shape Chat's history `before` takes. */
+function chatBefore(ms) {
+ const d = new Date(ms);
+ const p = (n) => String(n).padStart(2, '0');
+ return `${d.getUTCFullYear()}-${p(d.getUTCMonth() + 1)}-${p(d.getUTCDate())} ${p(d.getUTCHours())}:${p(d.getUTCMinutes())}:${p(d.getUTCSeconds())}`;
+}
+
+function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const { chat, live, video, timeoutMs } = config.upstreams;
+ const base = (u) => String(u).replace(/\/+$/, '');
+
+ async function call(url, { method = 'GET', redirect = 'follow' } = {}) {
+ let res;
+ try {
+ res = await fetchImpl(url, {
+ method, redirect,
+ headers: { Accept: 'application/json, text/plain;q=0.5', 'User-Agent': 'OpenVibe.Meme/0.1 (+https://openvibe.meme)' },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ throw Object.assign(new Error(`upstream unreachable: ${url}`), { code: 'upstream.unreachable', cause: err });
+ }
+ return res;
+ }
+
+ async function json(url) {
+ const res = await call(url);
+ if (res.status === 404) return { notFound: true, status: 404 };
+ if (!res.ok) throw Object.assign(new Error(`upstream ${res.status}: ${url}`), { code: 'upstream.status', status: res.status });
+ const data = await res.json().catch(() => null);
+ if (data == null) throw Object.assign(new Error(`upstream answered no JSON: ${url}`), { code: 'upstream.body' });
+ return data;
+ }
+
+ async function chatHistory({ before = null, limit = 200 } = {}) {
+ const q = new URLSearchParams({ limit: String(Math.min(500, Math.max(1, limit))) });
+ if (before) q.set('before', before);
+ const data = await json(`${base(chat)}/api/chat/global/history?${q.toString()}`);
+ if (data.notFound) return { messages: [], latest_id: null };
+ return { messages: Array.isArray(data.messages) ? data.messages : [], latest_id: data.latest_id == null ? null : data.latest_id };
+ }
+
+ async function chatTimeline({ limit = 60 } = {}) {
+ const q = new URLSearchParams({ limit: String(Math.min(60, Math.max(1, limit))) });
+ const data = await json(`${base(chat)}/api/chat/ai/timeline?${q.toString()}`);
+ if (data.notFound) return { events: [] };
+ return { events: Array.isArray(data.events) ? data.events : [], hasMore: Boolean(data.hasMore) };
+ }
+
+ async function rundownTopics({ limit = 12 } = {}) {
+ const q = new URLSearchParams({ limit: String(Math.max(1, limit)) });
+ const data = await json(`${base(live)}/api/rundown/topics?${q.toString()}`);
+ if (data.notFound) return [];
+ return Array.isArray(data.topics) ? data.topics : [];
+ }
+
+ async function rundownTopic(slug) {
+ const data = await json(`${base(live)}/api/rundown/t/${encodeURIComponent(slug)}`);
+ if (data.notFound) return null;
+ return { topic: data.topic || null, takes: Array.isArray(data.takes) ? data.takes : [] };
+ }
+
+ async function vod(id) {
+ const data = await json(`${base(live)}/api/vods/${encodeURIComponent(id)}`);
+ if (data.notFound) return null;
+ return data.vod || null;
+ }
+
+ async function transcripts(ids) {
+ const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(String).filter(Boolean))];
+ if (!wanted.length) return {};
+ const q = new URLSearchParams({ ids: wanted.join(',') });
+ const data = await json(`${base(live)}/api/chat-ai/vod-transcripts?${q.toString()}`);
+ if (data.notFound) return {};
+ const all = data.transcripts && typeof data.transcripts === 'object' ? data.transcripts : {};
+ return Object.fromEntries(Object.entries(all).filter(([id]) => wanted.includes(String(id))));
+ }
+
+ /**
+ * The canonical public URL for a VOD second: follow OpenVibe.Video's own redirect (a joined multi-part recording
+ * redirects `/v/6026?t=864` → `/v/6025?t=1541`, and the citation stores the target). A 404 means the VOD is
+ * private or gone and must not be cited.
+ */
+ async function canonicalVideo(vodId, sec) {
+ if (isDenied({ vod_id: vodId })) return { denied: true, gone: true };
+ const offset = Number.isFinite(Number(sec)) ? Number(sec) : 0;
+ const res = await call(`${base(video)}/v/${encodeURIComponent(vodId)}?t=${Math.max(0, Math.round(offset))}`, { redirect: 'manual' });
+ if (res.status === 404 || res.status === 410) return { gone: true, status: res.status };
+ if (res.status === 401 || res.status === 403) return { hidden: true, status: res.status };
+ const loc = res.headers.get('location');
+ if (res.status >= 300 && res.status < 400 && loc) {
+ let u = null;
+ try { u = new URL(loc, `${base(video)}/`); } catch { u = null; }
+ if (!u) return { gone: true, status: res.status };
+ const m = u.pathname.match(/\/v\/(\d+)\/?$/);
+ const t = u.searchParams.get('t');
+ const targetId = m ? Number(m[1]) : Number(vodId);
+ const targetSec = t != null && t !== '' ? Number(t) : offset;
+ if (isDenied({ vod_id: targetId })) return { denied: true, gone: true };
+ return { url: u.toString(), vod_id: targetId, offset_seconds: Number.isFinite(targetSec) ? targetSec : offset, redirected: true };
+ }
+ if (res.ok) return { url: `${base(video)}/v/${encodeURIComponent(vodId)}?t=${Math.max(0, Math.round(offset))}`, vod_id: Number(vodId), offset_seconds: offset, redirected: false };
+ throw Object.assign(new Error(`video ${vodId}@${offset} answered ${res.status}`), { code: 'upstream.status', status: res.status });
+ }
+
+ /** A citation URL's current state: live, gone (404/410) or hidden (401/403). Other answers are an error. */
+ async function citationUrl(url) {
+ const res = await call(String(url), { redirect: 'follow' });
+ if (res.status === 404 || res.status === 410) return { status: 'gone', http: res.status };
+ if (res.status === 401 || res.status === 403) return { status: 'hidden', http: res.status };
+ if (res.ok) return { status: 'live', http: res.status, finalUrl: res.url || null };
+ throw Object.assign(new Error(`citation ${url} answered ${res.status}`), { code: 'upstream.status', status: res.status });
+ }
+
+ /**
+ * A chat line has no permalink API in v1 (research §2.1), so it is re-verified against the same public history
+ * page: the message id must still be there and not deleted. Not finding it marks it gone; the message id is
+ * what the citation stores.
+ */
+ async function chatMessage(citation) {
+ const at = new Date(citation.observed_at).getTime();
+ if (!Number.isFinite(at)) return { status: 'gone' };
+ const wanted = String(citation.chat_message_id != null ? citation.chat_message_id : citation.source_id);
+ // A public history page holds at most 500 lines, so walk back towards the citation's minute (a few pages
+ // at most) rather than declaring a line gone just because a busy channel has moved it off page one.
+ let before = chatBefore(at + 60_000);
+ for (let page = 0; page < 5; page++) {
+ const h = await chatHistory({ before, limit: 500 });
+ const msg = h.messages.find((m) => String(m.id) === wanted);
+ if (msg) return Number(msg.is_deleted) === 1 ? { status: 'hidden' } : { status: 'live', text: msg.message };
+ const oldest = h.messages.length ? parseApiTime(h.messages[h.messages.length - 1].timestamp) : null;
+ if (!oldest || Date.parse(oldest) <= at - 86_400_000) break;
+ before = chatBefore(Date.parse(oldest) - 1000);
+ }
+ return { status: 'gone' };
+ }
+
+ return {
+ chatHistory, chatTimeline, rundownTopics, rundownTopic, vod, transcripts, canonicalVideo, citationUrl, chatMessage,
+ base,
+ };
+}
+
+module.exports = { createUpstreams, parseApiTime, chatBefore };
new file mode 100644
new file mode 100644
@@ -0,0 +1,254 @@
@@ -0,0 +1,254 @@
+'use strict';
+
+/**
+ * The Meme room's pages (plan 2026-10-28 §5.7). Server-rendered and whole without JavaScript: every action is a
+ * plain form or link. public/js/meme.js is a one-comment placeholder the main session replaces; the preview and
+ * the vote work without it.
+ *
+ * home({ trending, week }) trending, meme of the week, the generator teaser
+ * entry({ e, citations, signedIn, staff }) the origin with its citation link, the use timeline, examples
+ * week({ out, signedIn }) this week's candidates and the vote form
+ * make({ templates, values, preview, signedIn }) the caption generator form
+ * caption({ caption, template, mine }) one saved caption, text over the image
+ * developers() the API
+ * signIn({ title, text, next }) a page that needs an account
+ * notFound()
+ * Each returns HTML for the page body; server/http/pages.js sends it through the layout. Ids and classes are
+ * stable and start with `mm-`.
+ */
+const { esc, time } = require('../render/html');
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+
+const STATUS_LABEL = { rising: 'Rising', peaked: 'Peaked', classic: 'Classic', dead: 'Dead' };
+const KIND_LABEL = { joke: 'Joke', catchphrase: 'Catchphrase', bit: 'Bit', meme: 'Meme', saga: 'Saga' };
+const SOURCE_LABEL = { chat_message: 'Chat', vod: 'VOD', clip: 'Clip', rundown_segment: 'The Rundown', ai_paste: 'Community', stream_memory: 'Stream memory' };
+
+const statusBadge = (s) => `<span class="mm-status mm-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
+const kindLabel = (k) => esc(KIND_LABEL[k] || k);
+
+function clock(seconds) {
+ const n = Number(seconds);
+ if (!Number.isFinite(n) || n < 0) return null;
+ const s = Math.floor(n);
+ const h = Math.floor(s / 3600);
+ const m = Math.floor((s % 3600) / 60);
+ const r = s % 60;
+ return h ? `${h}:${String(m).padStart(2, '0')}:${String(r).padStart(2, '0')}` : `${m}:${String(r).padStart(2, '0')}`;
+}
+
+function citation(c, { role = null } = {}) {
+ const label = SOURCE_LABEL[c.source_type] || c.source_type;
+ const at = clock(c.offset_seconds);
+ const place = c.source_type === 'chat_message' ? `message ${esc(c.source_id)}` : at ? `at ${esc(at)}` : null;
+ const state = c.source_status === 'live' ? '' : `<span class="mm-cite-state is-${esc(c.source_status)}">${esc(c.source_status === 'gone' ? 'source gone' : 'source hidden')}</span>`;
+ return `<blockquote class="mm-cite mm-cite-${esc(c.role)}"${role ? ` data-role="${esc(role)}"` : ''} cite="${esc(c.url)}">
+<p class="mm-quote">${esc(c.quote_text)}</p>
+<footer class="mm-cite-foot"><span class="mm-cite-source">${esc(label)}</span>${place ? ` · <span class="mm-cite-place">${place}</span>` : ''} · <time datetime="${esc(c.observed_at)}">${esc(String(c.observed_at || '').slice(0, 10))}</time> · <a class="mm-cite-link" href="${esc(c.url)}" rel="nofollow noopener">the moment</a>${state}</footer>
+</blockquote>`;
+}
+
+function card(e) {
+ return `<li class="mm-entry-card" data-entry="${esc(e.slug)}">
+<a class="mm-entry-link" href="/m/${esc(e.slug)}">
+<span class="mm-entry-title">${esc(e.title)}</span>
+<span class="mm-entry-meta">${kindLabel(e.kind)} · ${statusBadge(e.status)}${e.votes != null ? ` · <strong class="mm-votes">${Number(e.votes)}</strong> ${Number(e.votes) === 1 ? 'vote' : 'votes'}` : ''}</span>
+<span class="mm-entry-when">last use <time datetime="${esc(e.last_seen_at)}">${esc(String(e.last_seen_at || '').slice(0, 10))}</time></span>
+</a></li>`;
+}
+
+function empty(text, href = '/week', label = 'This week’s ballot') {
+ return `<div class="mm-empty"><p>${esc(text)}</p><a class="mm-btn mm-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;
+}
+
+/** The room's picture: a meme's family tree growing from its first moment, stickers fanning off the branches. */
+function treeMotif() {
+ return `<div class="mm-tree" aria-hidden="true">
+<svg class="mm-tree-lines" viewBox="0 0 400 280" preserveAspectRatio="none"><path d="M200 78 C200 128 92 118 92 176"/><path d="M200 78 L200 176"/><path d="M200 78 C200 128 308 118 308 176"/><path d="M92 206 C92 236 52 232 52 258"/><path d="M92 206 C92 236 132 232 132 258"/><path d="M308 206 C308 236 348 232 348 258"/></svg>
+<span class="mm-node mm-node-root"><b>Cozmo is offline</b><small>chat · the first time</small></span>
+<span class="mm-node mm-n1">VOD · 25:41</span><span class="mm-node mm-n2">a clip</span><span class="mm-node mm-n3">the Rundown</span>
+<span class="mm-sticker mm-s1">classic</span><span class="mm-sticker mm-s2">lol</span><span class="mm-sticker mm-s3">rising</span>
+</div>`;
+}
+
+function home({ trending = [], week = null, counts = {} } = {}) {
+ const candidates = week ? week.candidates.slice(0, 3) : [];
+ const live = `<section class="mm-section" id="mm-trending" aria-labelledby="mm-trending-h">
+<div class="mm-head-row"><h2 id="mm-trending-h">Trending</h2><span class="mm-count">${Number(counts.entries) || trending.length} public ${Number(counts.entries) === 1 ? 'entry' : 'entries'}</span></div>
+${trending.length ? `<ol class="mm-entries">${trending.slice(0, 8).map(card).join('')}</ol>` : empty('No entry is public yet. The first proof is being checked against its source.', '/developers', 'How entries are built')}
+</section>
+<section class="mm-section" id="mm-week-teaser" aria-labelledby="mm-week-teaser-h">
+<div class="mm-head-row"><h2 id="mm-week-teaser-h">Meme of the week</h2><a class="mm-more" href="/week">The ballot${kit.icon('chevron')}</a></div>
+${candidates.length ? `<ol class="mm-entries mm-entries-week">${candidates.map(card).join('')}</ol>` : empty('No candidate has a use this week yet. The ballot opens as soon as one does.')}
+</section>`;
+ const room = sc.room({
+ room: 'meme',
+ hero: {
+ title: 'Every joke has a first time',
+ lede: 'The open meme database. Each running joke is written down with the moment it happened, a chat line, a VOD second, a Rundown segment, and every later use. People vote for meme of the week, and anyone can caption a template.',
+ start: { href: '/week', label: 'Vote for meme of the week' },
+ chips: [{ label: 'Make a caption', href: '/make' }, { label: 'Trending', href: '#mm-trending' }, { label: 'The API', href: '/developers' }],
+ proof: Number(counts.entries) ? [{ value: Number(counts.entries), label: Number(counts.entries) === 1 ? 'entry' : 'entries' }] : [],
+ motif: treeMotif(),
+ },
+ scenes: [
+ { title: 'A stream moment becomes an entry', text: 'An entry starts where the joke did: the chat line or the second of the VOD, quoted word for word and linked. A source that disappears is marked, and an entry without its origin goes back into hiding.',
+ html: `<blockquote class="mm-cite mm-cite-origin mm-demo-cite"><p class="mm-quote">but yeah and Cosmo is broken now</p><footer class="mm-cite-foot"><span class="mm-cite-source">VOD</span> · at 25:41 · Oct 26 · <span class="mm-cite-link">the moment</span></footer></blockquote>`,
+ action: { href: '/developers', label: 'How entries are built' } },
+ { title: 'Watch a joke rise', text: 'Every use is dated, so you can see a joke catch on, peak and become a classic. Rising first on the trending list.',
+ html: `<div class="mm-demo-rise"><span class="mm-status mm-status-rising">Rising</span><span class="mm-demo-bars">${[2, 3, 2, 4, 6, 5, 8, 11, 9, 14].map((h) => `<i style="--h:${h}"></i>`).join('')}</span><small>uses per day, last 10 days</small></div>`,
+ action: { href: '/week', label: 'See this week\'s ballot' } },
+ { title: 'Caption it', text: 'Pick a template, type up to four lines and see them over the picture. A caption is saved as text, so anyone can remix it.',
+ html: `<figure class="mm-caption-card mm-demo-caption"><img src="/img/template-classic.svg" alt=""><figcaption><span>when the robot is offline</span><span>and chat has to cope</span></figcaption></figure>`,
+ action: { href: '/make', label: 'Open the generator' } },
+ ],
+ faq: { items: [
+ ['Where do the entries come from?', 'From OpenVibe itself: the Rundown, VOD transcripts and the public chat. An importer proposes a candidate when a phrase comes back on more than one day; a person checks it against its source before it is public.'],
+ ['Can I get something taken down?', 'Yes. Every entry has a takedown and correction form for signed-in people, and staff answer it. VOD 5709 and clip 483 are never cited.'],
+ ['Is the database open?', 'Yes: every public entry and citation is in the API, and in OpenVibe\'s Search.'],
+ ] },
+ });
+ const at = room.indexOf('<section class="sc-room-scenes"');
+ return `<div class="mm-wrap mm-home" id="mm-home">${at < 0 ? room : room.slice(0, at) + live + room.slice(at)}</div>`;
+}
+
+function entry({ e, citations = [], signedIn = false, staff = false, reported = false, problem = null } = {}) {
+ const origin = citations.find((c) => c.role === 'origin') || citations[0] || null;
+ const uses = citations.filter((c) => c !== origin);
+ const examples = citations.filter((c) => c.role === 'example');
+ const others = uses.filter((c) => c.role !== 'example');
+ return `<div class="mm-wrap" id="mm-entry" data-entry="${esc(e.slug)}">
+<article class="mm-entry">
+<header class="mm-entry-h">
+<p class="mm-kicker">${kindLabel(e.kind)} · ${statusBadge(e.status)}${staff ? ' · <span class="mm-staff">staff view</span>' : ''}</p>
+<h1>${esc(e.title)}</h1>
+${e.summary ? `<p class="mm-summary">${esc(e.summary)}</p>` : ''}
+<dl class="mm-facts">
+<div><dt>First seen</dt><dd><time datetime="${esc(e.first_seen_at)}">${esc(String(e.first_seen_at || '').slice(0, 10))}</time></dd></div>
+<div><dt>Last seen</dt><dd><time datetime="${esc(e.last_seen_at)}">${esc(String(e.last_seen_at || '').slice(0, 10))}</time></dd></div>
+<div><dt>Uses cited</dt><dd>${citations.length}</dd></div>
+<div><dt>Revision</dt><dd>${Number(e.revision) || 1}</dd></div>
+</dl>
+</header>
+
+<section class="mm-origin" id="mm-origin" aria-labelledby="mm-origin-h">
+<h2 id="mm-origin-h">Where it started</h2>
+${origin ? citation(origin) : '<p class="mm-muted">No origin citation is attached yet.</p>'}
+</section>
+
+<section class="mm-timeline-sec" id="mm-timeline" aria-labelledby="mm-timeline-h">
+<h2 id="mm-timeline-h">The use timeline</h2>
+${others.length ? `<ol class="mm-timeline">${others.map((c) => `<li class="mm-timeline-item"><time class="mm-timeline-when" datetime="${esc(c.observed_at)}">${esc(String(c.observed_at || '').slice(0, 10))}</time>${citation(c)}</li>`).join('')}</ol>` : '<p class="mm-muted">No later use is cited yet. The timeline grows as the importer finds repeats.</p>'}
+</section>
+
+${examples.length ? `<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2>${examples.map((c) => citation(c)).join('')}</section>` : '<section class="mm-examples" id="mm-examples" aria-labelledby="mm-examples-h"><h2 id="mm-examples-h">Examples</h2><p class="mm-muted">No example is attached yet.</p></section>'}
+
+<footer class="mm-entry-foot">
+${reported ? '<p class="mm-note" role="status">Thank you. Staff will look at the report.</p>' : ''}
+${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+${signedIn ? `<details class="mm-report"><summary>Report a correction or takedown</summary>
+<form method="post" action="/m/${esc(e.slug)}/report" class="mm-form mm-form-inline">
+<label class="mm-field"><span>What is wrong?</span><select name="kind"><option value="takedown">A takedown</option><option value="correction">A correction</option></select></label>
+<label class="mm-field"><span>Reason</span><input name="reason" type="text" maxlength="2000" required placeholder="The source is gone, the quote is wrong…"></label>
+<label class="mm-field"><span>Note (optional)</span><textarea name="note" rows="2" maxlength="4000"></textarea></label>
+<button class="mm-btn mm-btn-quiet" type="submit">Send the report</button>
+</form></details>` : `<p class="mm-muted"><a href="/auth/login?next=${encodeURIComponent(`/m/${e.slug}`)}">Sign in</a> to report a correction or takedown.</p>`}
+</footer>
+</article>
+</div>`;
+}
+
+function week({ out, signedIn = false, voted = false, error = false } = {}) {
+ const candidates = out ? out.candidates : [];
+ return `<div class="mm-wrap" id="mm-week">
+<header class="mm-page-h"><p class="mm-kicker">Meme of the week</p><h1>The ballot</h1>
+<p class="mm-lede">One vote a week, per person. A vote can change until the week ends; the week is ${esc(out ? out.week : '')} (Monday to Sunday, UTC).</p></header>
+${voted ? '<p class="mm-note" role="status">Your vote is counted.</p>' : ''}
+${error ? '<p class="mm-note is-bad" role="alert">That vote could not be counted. Try again from the ballot.</p>' : ''}
+${signedIn ? '' : '<p class="mm-note">You can read the ballot without an account. <a href="/auth/login?next=%2Fweek">Sign in</a> to vote.</p>'}
+${candidates.length ? `<ol class="mm-candidates">${candidates.map((c) => `<li class="mm-candidate${c.mine ? ' is-mine' : ''}" data-entry="${esc(c.slug)}">
+<div class="mm-candidate-body"><a class="mm-candidate-title" href="/m/${esc(c.slug)}">${esc(c.title)}</a><span class="mm-candidate-meta">${kindLabel(c.kind)} · ${statusBadge(c.status)}</span></div>
+<div class="mm-candidate-vote"><strong class="mm-vote-count">${Number(c.votes) || 0}</strong><span>${Number(c.votes) === 1 ? 'vote' : 'votes'}</span>
+${signedIn ? `<form method="post" action="/week/vote" class="mm-inline"><input type="hidden" name="entry_id" value="${esc(c.id)}"><button class="mm-btn mm-btn-small${c.mine ? ' is-mine' : ''}" type="submit">${c.mine ? 'Your vote' : 'Vote'}</button></form>` : ''}
+</div></li>`).join('')}</ol>` : empty('No candidate has a use this week yet.')}
+<p class="mm-fine">A candidate is a public entry with at least one live use in this ISO week. The count is the number of people who chose it, not a score you can buy.</p>
+</div>`;
+}
+
+const LINES = [1, 2, 3, 4];
+
+function make({ templates = [], values = {}, preview = null, signedIn = false, problem = null } = {}) {
+ const v = { template_id: templates[0] ? templates[0].id : '', visibility: 'public', ...values };
+ const lines = LINES.map((i) => v[`line${i}`] || '');
+ return `<div class="mm-wrap" id="mm-make">
+<header class="mm-page-h"><p class="mm-kicker">The caption generator</p><h1>Put words on a template</h1>
+<p class="mm-lede">Pick a template and type up to four lines, ${LINES.length * 120} characters at most. The preview is HTML text over the image; saving stores the lines, never a picture.</p></header>
+${problem ? `<p class="mm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+<div class="mm-make-grid">
+<form id="mm-caption-form" class="mm-form mm-card" method="post" action="/make">
+<fieldset class="mm-templates"><legend>Template</legend>
+${templates.map((t) => `<label class="mm-template${t.id === v.template_id ? ' is-chosen' : ''}"><input type="radio" name="template_id" value="${esc(t.id)}"${t.id === v.template_id ? ' checked' : ''} required><img src="${esc(t.image_url)}" alt="" width="220" height="140" loading="lazy"><span><b>${esc(t.name)}</b><small>${Number(t.box_count)} ${Number(t.box_count) === 1 ? 'box' : 'boxes'} · ${esc(t.license)}</small></span></label>`).join('')}
+</fieldset>
+${LINES.map((i) => `<label class="mm-field"><span>Line ${i}</span><input name="line${i}" type="text" maxlength="120" value="${esc(lines[i - 1])}" placeholder="${i === 1 ? 'when the robot is offline' : ''}"></label>`).join('')}
+<label class="mm-field"><span>Who can see it?</span><select name="visibility"><option value="public"${v.visibility === 'public' ? ' selected' : ''}>Anyone</option><option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Anyone with the link</option><option value="hidden"${v.visibility === 'hidden' ? ' selected' : ''}>Only me</option></select></label>
+<div class="mm-actions"><button class="mm-btn" type="submit">Save the caption</button><button class="mm-btn mm-btn-quiet" type="submit" formaction="/make" formmethod="get">Preview</button></div>
+${signedIn ? '' : '<p class="mm-fine">You can preview without signing in. <a href="/auth/login?next=%2Fmake">Sign in</a> to save a caption.</p>'}
+</form>
+<aside class="mm-preview" id="mm-preview" aria-labelledby="mm-preview-h"><h2 id="mm-preview-h">Preview</h2>
+${preview ? `<figure class="mm-caption-card"><img src="${esc(preview.template.image_url)}" alt=""><figcaption>${preview.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption></figure><p class="mm-fine">Text only, over ${esc(preview.template.name)}. Nothing is saved until you press Save.</p>` : '<p class="mm-muted">Press Preview to see your lines over the template.</p>'}
+</aside>
+</div>
+</div>`;
+}
+
+function caption({ caption: c, template, mine = false } = {}) {
+ return `<div class="mm-wrap" id="mm-caption" data-caption="${esc(c.id)}">
+<header class="mm-page-h"><p class="mm-kicker">A saved caption</p><h1>${esc(template ? template.name : 'Caption')}</h1>
+<p class="mm-lede">${mine ? 'Yours. ' : ''}Text over the template image, saved as lines — never as a rendered picture.</p></header>
+<figure class="mm-caption-card mm-caption-large">
+${template ? `<img src="${esc(template.image_url)}" alt="">` : ''}
+<figcaption>${c.lines.map((l) => `<span>${esc(l)}</span>`).join('')}</figcaption>
+</figure>
+<dl class="mm-facts"><div><dt>Saved</dt><dd>${time(c.created_at)}</dd></div><div><dt>Visibility</dt><dd>${esc(c.visibility)}</dd></div><div><dt>Caption</dt><dd><code>${esc(c.id)}</code></dd></div></dl>
+<div class="mm-actions"><a class="mm-btn" href="/make">Make another</a><a class="mm-btn mm-btn-quiet" href="/">Back to the memes</a></div>
+</div>`;
+}
+
+function signIn({ title, text, next }) {
+ return `<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="mm-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
+}
+
+function developers() {
+ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
+ return `<div class="mm-wrap" id="mm-developers">
+<header class="mm-page-h"><p class="mm-kicker">Developers</p><h1>The Meme API</h1>
+<p class="mm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>meme_at</code> session cookie or a Network user token as a Bearer.</p></header>
+<section><h2>Read</h2><dl class="mm-eps">
+${ep('GET', '/api/v1/entries?limit=&cursor=&kind=&status=', 'Public entries, newest first, cursor-paged (at most 50).')}
+${ep('GET', '/api/v1/entries/:slug', 'One entry with every citation and the use timeline. A hidden one is 404 unless you are staff.')}
+${ep('GET', '/api/v1/entries/:slug/series?days=30', 'Uses per UTC day, zero-filled (1–365 days).')}
+${ep('GET', '/api/v1/trending', 'Public entries, rising first.')}
+${ep('GET', '/api/v1/week', 'This ISO week’s candidates and their vote counts.')}
+${ep('GET', '/api/v1/templates', 'Caption templates: image, box count, source and license.')}
+${ep('GET', '/api/v1/captions/:id', 'One saved caption (a hidden one is 404 unless it is yours or you are staff).')}
+</dl></section>
+<section><h2>Write (signed in)</h2><dl class="mm-eps">
+${ep('POST', '/api/v1/week/vote', '<code>{ entry_id }</code>. One vote a week per person; a second call changes the choice. Same-origin.')}
+${ep('POST', '/api/v1/captions', '<code>{ template_id, lines, visibility? }</code>: 1–4 lines, each at most 120 characters, text only, blocked words refused.')}
+${ep('POST', '/api/v1/entries/:slug/report', '<code>{ kind: "takedown"|"correction", reason, note? }</code> for staff review.')}
+</dl></section>
+<section><h2>Staff (Network role <code>global_mod</code> or above)</h2><dl class="mm-eps">
+${ep('POST', '/api/v1/entries/:slug/publish', 'Make a hidden candidate public. Refused when its origin citation is gone (409).')}
+${ep('POST', '/api/v1/entries/:slug/hide', 'Remove it from public lists and tombstone its Search document.')}
+${ep('PATCH', '/api/v1/entries/:slug', '<code>{ title, kind, status }</code>: change an entry; status history is kept.')}
+${ep('GET', '/api/v1/reports', 'The open takedown/correction reports.')}
+</dl></section>
+<section id="mm-search"><h2>Search</h2><p class="mm-fine">Each public entry is one <code>search.index-document@1</code> (owner <code>meme</code>, type <code>entry</code>, canonical page <code>/m/<slug></code>) queued in <code>meme_outbox</code>; hiding or deleting an entry queues a tombstone. The importer reads only public APIs and never publishes: a candidate stays hidden until a staff person publishes it.</p></section>
+</div>`;
+}
+
+function notFound() {
+ return `<div class="mm-wrap mm-page-pad mm-narrow"><div class="mm-empty" id="mm-not-found"><p class="mm-kicker">404</p><h1>This meme is not here</h1><p>It may have been hidden, its origin source may be gone, or the address was never an entry.</p><a class="mm-btn" href="/">Back to the memes</a></div></div>`;
+}
+
+module.exports = { home, entry, week, make, caption, signIn, developers, notFound, statusBadge, kindLabel, citation, clock, STATUS_LABEL, KIND_LABEL, SOURCE_LABEL };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+
+/**
+ * Meme of the week: ISO-8601 weeks in UTC, `YYYY-Www`. Monday 00:00 UTC is the start and the next Monday 00:00
+ * UTC is the end, so a vote never straddles a week at midnight in one zone and not another. `weekBounds` answers
+ * the instants the database compares against; a week label with the wrong number of days or W00/W54 is refused.
+ */
+const WEEK_RE = /^(\d{4})-W(0[1-9]|[1-4][0-9]|5[0-3])$/;
+
+function utcDay(d) {
+ return new Date(Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate()));
+}
+
+/** The Monday of the ISO week containing `date` (00:00:00.000 UTC). */
+function mondayOf(date) {
+ const d = utcDay(date instanceof Date ? date : new Date(date));
+ const day = d.getUTCDay() || 7; // Sunday 0 → 7
+ d.setUTCDate(d.getUTCDate() - (day - 1));
+ return d;
+}
+
+/** The ISO week label of an instant. */
+function isoWeek(date = new Date()) {
+ const d = utcDay(date instanceof Date ? date : new Date(date));
+ const day = d.getUTCDay() || 7;
+ d.setUTCDate(d.getUTCDate() + 4 - day); // the Thursday decides the week's year
+ const isoYear = d.getUTCFullYear();
+ const yearStart = new Date(Date.UTC(isoYear, 0, 1));
+ const week = Math.ceil((((d - yearStart) / 86_400_000) + 1) / 7);
+ return `${isoYear}-W${String(week).padStart(2, '0')}`;
+}
+
+/** { start, end } as ISO instants for a label; null when it is not a real ISO week. */
+function weekBounds(week) {
+ const m = WEEK_RE.exec(String(week || ''));
+ if (!m) return null;
+ const year = Number(m[1]);
+ const number = Number(m[2]);
+ const jan4 = new Date(Date.UTC(year, 0, 4));
+ const start = mondayOf(jan4);
+ start.setUTCDate(start.getUTCDate() + (number - 1) * 7);
+ // A label in week 53 of a year that has 52 weeks is not a real week.
+ const check = isoWeek(start);
+ if (check !== `${m[1]}-W${m[2]}`) return null;
+ const end = new Date(start.getTime() + 7 * 86_400_000);
+ return { start: start.toISOString(), end: end.toISOString(), startDate: start, endDate: end };
+}
+
+/** The current week's label and bounds. */
+function currentWeek(now = Date.now()) {
+ const week = isoWeek(new Date(now));
+ return { week, ...weekBounds(week) };
+}
+
+module.exports = { WEEK_RE, isoWeek, weekBounds, currentWeek, mondayOf };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+/**
+ * ADR-033: Meme's part of an account export (votes, captions, reports) and of a deletion, applied through
+ * /internal/events with a stand-in Network. A deletion erases the person's rows and leaves other people's, the
+ * entries themselves and the Search outbox alone. A redelivery changes nothing; a bad signature and a proxied
+ * request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = ids.newId('user');
+const B = ids.newId('user');
+const EXP = `exp_${ids.ulid()}`;
+const DEL = `del_${ids.ulid()}`;
+const SECRET = `fixture_${'account'.repeat(6)}`;
+const WRONG = `fixture_${'mismatch'.repeat(6)}`;
+
+const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_meme', token_type: 'Bearer', expires_in: 300 });
+ calls.push({ url: req.url, auth: req.headers.authorization, body: JSON.parse(raw.toString() || 'null') });
+ return json(201, {});
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const stub = await startNetworkStub();
+ const sender = createNetworkSender({ networkInternalUrl: stub.url, clientId: 'meme', clientSecret: 'meme-secret' });
+ const t = await boot({ env: { MEME_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const eid = `mem_${ids.ulid()}`;
+ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at) VALUES ($1, 'account-entry', 'Account entry', 'joke', 'public', $2, $2)`, [eid, at]);
+ const capA = `mcp_${ids.ulid()}`;
+ const capB = `mcp_${ids.ulid()}`;
+ await db.exec(`INSERT INTO meme_captions (id, subject, template_id, lines) VALUES ($1, $2, 'tpl_classic', '["A caption"]'::jsonb), ($3, $4, 'tpl_classic', '["B caption"]'::jsonb)`, [capA, A, capB, B]);
+ await db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2), ('2026-W44', $1, $3)", [eid, A, B]);
+ const repA = `mrp_${ids.ulid()}`;
+ const repB = `mrp_${ids.ulid()}`;
+ await db.exec(`INSERT INTO meme_reports (id, entry_id, subject, kind, reason) VALUES ($1, $2, $3, 'correction', 'A report'), ($4, $2, $5, 'takedown', 'B report')`, [repA, eid, A, repB, B]);
+
+ await check('an export carries only A\'s votes, captions and reports', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'exported');
+ const part = stub.calls.find((c) => c.url === `/internal/account-exports/${EXP}/parts`);
+ assert.ok(part, 'the part was pushed to Network');
+ assert.strictEqual(part.auth, 'Bearer tok_meme');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['captions.json', 'reports.json', 'votes.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('A caption'));
+ assert.ok(!all.includes('B caption'), 'nobody else\'s caption');
+ assert.ok(all.includes('A report'));
+ assert.ok(!all.includes('B report'));
+ assert.ok(!all.includes('Account entry'), 'an entry is not the person\'s row');
+ const votes = part.body.files.find((f) => f.name === 'votes.json').content;
+ assert.deepStrictEqual(votes.map((v) => v.subject), [A]);
+ const captions = part.body.files.find((f) => f.name === 'captions.json').content;
+ assert.deepStrictEqual(captions.map((c) => c.id), [capA]);
+ });
+
+ await check('a deletion erases A\'s rows and confirms with counts; B and the entry stay', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_votes WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_reports WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_votes WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_reports WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_entries WHERE id = $1', [eid])), 1);
+ const confirmation = stub.calls.find((c) => c.url === `/internal/account-deletions/${DEL}/confirmations`);
+ assert.ok(confirmation, 'the confirmation was sent');
+ assert.ok(confirmation.body.erased.meme_votes >= 1);
+ assert.ok(confirmation.body.erased.meme_captions >= 1);
+ assert.ok(confirmation.body.erased.meme_reports >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM meme_captions WHERE subject = $1', [A])), 0);
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,72 @@
@@ -0,0 +1,72 @@
+'use strict';
+/**
+ * The JSON API's read shapes: cursor paging, trending order, one entry with its citations and timeline, an
+ * unlisted entry reachable by slug but absent from the public list, and the problem+json validation refusals.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const now = new Date().toISOString();
+ const mk = (slug, title, status, visibility = 'public', first = now) => t.ctx.entries.createCandidate({
+ slug, title, kind: 'joke', summary: 'A test entry.', status, first_seen_at: first, last_seen_at: now, visibility,
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(Math.floor(Math.random() * 1e9)), chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: now }],
+ });
+ await mk('m-one', 'One', 'classic', 'public', new Date(Date.now() - 5 * 86_400_000).toISOString());
+ await mk('m-two', 'Two', 'rising', 'public', new Date(Date.now() - 4 * 86_400_000).toISOString());
+ await mk('m-three', 'Three', 'peaked', 'public', new Date(Date.now() - 3 * 86_400_000).toISOString());
+ const unlisted = await mk('m-unlisted', 'Unlisted', 'rising', 'unlisted', new Date(Date.now() - 2 * 86_400_000).toISOString());
+
+ await check('GET /entries pages with an opaque cursor and refuses a garbage one', async () => {
+ const one = await t.get('/api/v1/entries?limit=2');
+ assert.strictEqual(one.status, 200);
+ assert.strictEqual(one.json().count, 2);
+ assert.ok(one.json().entries.every((e) => e.visibility === 'public'));
+ assert.ok(one.json().next_cursor);
+ const two = await t.get(`/api/v1/entries?limit=2&cursor=${encodeURIComponent(one.json().next_cursor)}`);
+ assert.strictEqual(two.status, 200);
+ assert.strictEqual(two.json().count, 1);
+ assert.strictEqual(two.json().next_cursor, null);
+ assert.strictEqual((await t.get('/api/v1/entries?cursor=garbage')).status, 400);
+ assert.strictEqual((await t.get('/api/v1/entries?kind=nope')).status, 400);
+ });
+
+ await check('GET /trending puts rising first', async () => {
+ const r = await t.get('/api/v1/trending');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.json().count >= 1);
+ assert.strictEqual(r.json().entries[0].status, 'rising');
+ });
+
+ await check('one entry has its origin and the timeline; an unlisted entry is direct-only', async () => {
+ const r = await t.get('/api/v1/entries/m-two');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().slug, 'm-two');
+ assert.ok(r.json().origin);
+ assert.strictEqual(r.json().origin.role, 'origin');
+ assert.strictEqual(r.json().timeline.length, 1);
+ assert.ok(Array.isArray(r.json().citations));
+ const ul = await t.get('/api/v1/entries/m-unlisted');
+ assert.strictEqual(ul.status, 200);
+ assert.strictEqual(ul.headers.get('cache-control'), 'private, max-age=60');
+ const listed = await t.get('/api/v1/entries?limit=50');
+ assert.ok(!listed.json().entries.some((e) => e.slug === 'm-unlisted'));
+ assert.strictEqual((await t.get('/api/v1/entries/m-missing')).status, 404);
+ });
+
+ await check('problem+json is used for a malformed body and an unknown route', async () => {
+ const bad = await t.get('/api/v1/week/vote', { as: t.network.addUser('voter'), body: '{', headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(bad.status, 400);
+ assert.match(bad.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(bad.json().code, 'request.malformed_json');
+ const nope = await t.get('/api/v1/nope');
+ assert.strictEqual(nope.status, 404);
+ assert.strictEqual(nope.json().code, 'route.not_found');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+/**
+ * Captions: text only, 1–4 lines of at most 120 characters, a small blocked-word list, an existing template, a
+ * signed-in same-origin write. A public caption never returns its author's subject to an anonymous reader; a
+ * hidden caption is 404 to anyone but its owner and staff. The table holds no image column at all.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const templates = (await t.get('/api/v1/templates')).json().templates;
+ const tpl = templates[0];
+
+ await check('a signed-in caption is saved as lines and returned with its template', async () => {
+ const r = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['when the robot', 'is offline'] } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual(r.headers.get('location'), `/api/v1/captions/${r.json().id}`);
+ const c = r.json();
+ assert.deepStrictEqual(c.lines, ['when the robot', 'is offline']);
+ const got = await t.get(`/api/v1/captions/${c.id}`, { as: alice });
+ assert.strictEqual(got.status, 200);
+ assert.strictEqual(got.json().subject, alice.subject);
+ assert.strictEqual(got.json().mine, true);
+ assert.strictEqual(got.json().template.id, tpl.id);
+ assert.strictEqual(got.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('an anonymous read does not expose the subject', async () => {
+ const c = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['public'] } })).json();
+ const r = await t.get(`/api/v1/captions/${c.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(!('subject' in r.json()));
+ assert.strictEqual(r.json().mine, false);
+ });
+
+ await check('line count, line length, empty lines, blocked words and the template are enforced', async () => {
+ const tooMany = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['1', '2', '3', '4', '5'] } });
+ assert.strictEqual(tooMany.status, 400);
+ assert.strictEqual(tooMany.json().code, 'meme.invalid');
+ const long = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['x'.repeat(121)] } });
+ assert.strictEqual(long.status, 400);
+ const empty = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['ok', ' '] } });
+ assert.strictEqual(empty.status, 400);
+ const blocked = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['this is kys'] } });
+ assert.strictEqual(blocked.status, 400);
+ assert.strictEqual(blocked.json().code, 'meme.blocked_words');
+ const noTpl = await t.get('/api/v1/captions', { as: alice, json: { template_id: 'tpl_missing', lines: ['ok'] } });
+ assert.strictEqual(noTpl.status, 404);
+ });
+
+ await check('a signed-out, cross-site or non-array caption is refused', async () => {
+ assert.strictEqual((await t.get('/api/v1/captions', { json: { template_id: tpl.id, lines: ['ok'] } })).status, 401);
+ const cross = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['ok'] }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'meme.cross_site');
+ const notArray = await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: 'one line' } });
+ assert.strictEqual(notArray.status, 400);
+ });
+
+ await check('a hidden caption is readable only by its owner and staff', async () => {
+ const c = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['secret'], visibility: 'hidden' } })).json();
+ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: alice })).status, 200);
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ assert.strictEqual((await t.get(`/api/v1/captions/${c.id}`, { as: mod })).status, 200);
+ });
+
+ await check('meme_captions stores no image and no rendered bytes', async () => {
+ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'meme_captions'")).map((r) => r.column_name);
+ assert.ok(!cols.some((c) => /image|blob|bytes|photo/i.test(c)), cols.join(','));
+ const row = await t.ctx.s.db.maybe('SELECT lines FROM meme_captions LIMIT 1');
+ assert.ok(Array.isArray(row.lines));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,34 @@
@@ -0,0 +1,34 @@
+'use strict';
+/**
+ * The fixed deny list: VOD 5709 and clip 483 are never cited. Both the unit helper and an importer run over a
+ * fixture take that names VOD 5709 are checked, and no row may carry either id.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const deny = require('../server/deny-list');
+
+(async () => {
+ await check('isDenied knows both ids, in wire and database shapes', async () => {
+ assert.strictEqual(deny.isDenied({ vod_id: 5709 }), true);
+ assert.strictEqual(deny.isDenied({ vodId: '5709' }), true);
+ assert.strictEqual(deny.isDenied({ clip_id: 483 }), true);
+ assert.strictEqual(deny.isDenied({ clipId: '483' }), true);
+ assert.strictEqual(deny.isDenied({ vod_id: 6026, clip_id: 518 }), false);
+ assert.strictEqual(deny.allowed([{ vod_id: 5709 }, { vod_id: 6026 }, { clip_id: 483 }]).length, 1);
+ });
+
+ const t = await boot();
+ try {
+ await check('the importer never writes a citation for VOD 5709, even from a Rundown take', async () => {
+ const r = await t.ctx.importer.runOnce();
+ assert.strictEqual(r.errors.length, 0, JSON.stringify(r.errors));
+ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483');
+ assert.strictEqual(Number(n), 0);
+ const all = await t.ctx.s.db.many('SELECT source_id, vod_id, quote_text FROM meme_citations');
+ assert.ok(!all.some((c) => c.quote_text.includes('must never become a citation')));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,100 @@
@@ -0,0 +1,100 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.MEME_RATE_LIMIT_PER_MIN = process.env.MEME_RATE_LIMIT_PER_MIN || '100000';
+process.env.MEME_API_RATE_LIMIT_PER_MIN = process.env.MEME_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.MEME_LIMITS_MINUTE = process.env.MEME_LIMITS_MINUTE || '100000';
+process.env.MEME_LIMITS_HOUR = process.env.MEME_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Meme on a temp database with a mock Network (test/helpers/mocks.js) and public upstreams
+ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client. Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, headers, method, cookie, origin })
+ * t.signIn(user) → cookie header value (meme_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+const { startUpstreams } = require('./upstreams');
+
+const captured = [];
+for (const m of ['log', 'info', 'warn', 'error']) {
+ const orig = console[m].bind(console);
+ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const upstreams = await startUpstreams(opts.upstreams || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.meme', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'meme', OV_OAUTH_CLIENT_SECRET: 'meme-secret', COOKIE_SECURE: 'false',
+ MEME_DRILL: '1',
+ MEME_EVENTS_URL: network.url,
+ OV_CHAT_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_VIDEO_URL: upstreams.url,
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
+
+ const config = configLib.load(env);
+ const { createStore } = require('../../server/db');
+ const testdb = await require('./db').testDb();
+ const store = createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `meme_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ built.ctx.importer.stop();
+ built.ctx.reverifier.stop();
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await built.ctx.telemetry.stop().catch(() => {});
+ await testdb.close();
+ await network.close();
+ await upstreams.close();
+ },
+ };
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * MEME_TEST_STORE=pg (npm run test:pg) the PostgreSQL + PgBouncer containers, with roles and a schema of this run's own.
+ */
+const { createTestDb, pgAvailable } = require('openvibe-sdk/testing');
+const { MIGRATIONS } = require('../../server/db');
+
+const testDb = ({ store = process.env.MEME_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'meme', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,137 @@
@@ -0,0 +1,137 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `meme`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows (not used by Meme, kept for parity);
+ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
+ *
+ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function signJwt(claims, privateKey) {
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false };
+ let issuer = null;
+ let nextUserId = 1;
+
+ const problem = (json, status, code, detail) => json(status, { type: `https://openvibe.network/problems/${code}`, title: 'Error', status, code, detail, error: detail, request_id: `req_${crypto.randomBytes(6).toString('hex')}` }, { 'Content-Type': 'application/problem+json' });
+
+ function addUser(username, { role = 'user' } = {}) {
+ const u = { id: nextUserId++, subject: ids.newId('user'), username, display_name: username, role };
+ st.users.set(u.id, u); st.byUsername.set(username, u);
+ return u;
+ }
+ function userToken(u, { ttl = 3600, iat = null } = {}) {
+ const now = iat != null ? iat : Math.floor(Date.now() / 1000);
+ return signJwt({ iss: issuer, sub: u.id, subject_id: u.subject, username: u.username, display_name: u.display_name, role: u.role, aud: ['openvibe.network'], iat: now, exp: now + ttl }, privatePem);
+ }
+ function userFrom(req) {
+ const h = String(req.headers.authorization || '');
+ if (!h.startsWith('Bearer ')) return null;
+ const parts = h.slice(7).split('.');
+ if (parts.length !== 3) return null;
+ const ok = crypto.verify('RSA-SHA256', Buffer.from(`${parts[0]}.${parts[1]}`), publicPem, Buffer.from(parts[2], 'base64url'));
+ if (!ok) return null;
+ const c = JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8'));
+ if (c.exp * 1000 < Date.now()) return 'expired';
+ return st.users.get(c.sub) || null;
+ }
+
+ const srv = await listen(async (req, raw, json) => {
+ const url = new URL(req.url, 'http://x');
+ st.requests.push({ method: req.method, path: url.pathname, query: url.search, headers: req.headers, body: raw.toString('utf8') });
+ if (st.down) return problem(json, 503, 'network.down', 'Network is restarting');
+
+ if (url.pathname === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
+ if (url.pathname === '/.well-known/openvibe') return json(200, { name: 'OpenVibe', issuer, contracts: { version: require('openvibe-contracts/package.json').version }, services: [] });
+ if (url.pathname === '/oauth/revoke') return json(200, {});
+ if (url.pathname === '/oauth/token' && req.method === 'POST') {
+ const ct = String(req.headers['content-type'] || '');
+ const body = ct.includes('json') ? JSON.parse(raw.toString('utf8') || '{}') : Object.fromEntries(new URLSearchParams(raw.toString('utf8')));
+ st.tokenRequests.push({ grant_type: body.grant_type, client_id: body.client_id, audience: body.audience, scope: body.scope });
+ if (body.client_id !== 'meme' || body.client_secret !== 'meme-secret') return json(401, { error: 'invalid_client', error_description: 'bad client secret' });
+ if (body.grant_type === 'authorization_code') {
+ const c = st.codes.get(body.code);
+ if (!c) return json(400, { error: 'invalid_grant', error_description: 'unknown code' });
+ st.codes.delete(body.code);
+ const challenge = crypto.createHash('sha256').update(String(body.code_verifier || '')).digest('base64url');
+ if (challenge !== c.challenge) return json(400, { error: 'invalid_grant', error_description: 'PKCE verification failed' });
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, c.user);
+ return json(200, { access_token: userToken(c.user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'refresh_token') {
+ const user = st.refresh.get(body.refresh_token);
+ if (!user) return json(400, { error: 'invalid_grant', error_description: 'refresh token rejected' });
+ st.refresh.delete(body.refresh_token);
+ const rt = `rt_${crypto.randomBytes(12).toString('hex')}`;
+ st.refresh.set(rt, user);
+ return json(200, { access_token: userToken(user), refresh_token: rt, token_type: 'Bearer' });
+ }
+ if (body.grant_type === 'client_credentials') {
+ const now = Math.floor(Date.now() / 1000);
+ const caps = String(body.scope || '').split(/\s+/).filter(Boolean);
+ return json(200, {
+ access_token: serviceAuth.signServiceToken({ iss: issuer, sub: 'svc:meme', actor_type: 'service', aud: [body.audience || 'openvibe.network'], cap: caps, iat: now, exp: now + 300, jti: crypto.randomUUID() }, privatePem),
+ token_type: 'Bearer', expires_in: 300, scope: caps.join(' '),
+ });
+ }
+ return json(400, { error: 'unsupported_grant_type' });
+ }
+
+ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') {
+ const u = userFrom(req);
+ if (u === 'expired') return problem(json, 401, 'auth.expired', 'expired');
+ if (!u) return problem(json, 401, 'auth.required', 'send Authorization: Bearer <Network access token>');
+ return json(200, { items: [], next_cursor: null });
+ }
+
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setDown(v) { st.down = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,166 @@
@@ -0,0 +1,166 @@
+'use strict';
+
+/**
+ * In-process stand-ins for OpenVibe.Chat, OpenVibe.Live and OpenVibe.Video on one HTTP server. The boot helper
+ * points OV_CHAT_URL, OV_LIVE_URL and OV_VIDEO_URL at it.
+ *
+ * The default fixture is a small running joke ("the robot is offline"): two Chat lines on two days, a Rundown
+ * quote and its VOD transcript segment on a third, and the joined-recording redirect `/v/9001?t=60` →
+ * `/v/9002?t=120`. It also carries the Cozmo proof fixture (message 86957, VOD 6026 at 864.08 s) and a take that
+ * names the fixed deny list (VOD 5709) so both scripts can be exercised without the network.
+ *
+ * A test changes state.video[<vod>] = { status: 404 } to make a citation's URL vanish, or reads state.requests.
+ */
+const http = require('http');
+
+const DEFAULT_MESSAGES = [
+ { id: 1001, stream_id: null, username: 'ada', message: 'the robot is offline again', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-20 10:00:00' },
+ { id: 1002, stream_id: null, username: 'bo', message: 'still the robot is offline, huh', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-22 11:00:00' },
+ { id: 86957, stream_id: null, username: 'Little_Buddy', message: 'o/ Maticus, you still got that same vibe. What’s good? The world’s still spinning, but your Cozmo ain’t online yet. (Or is it?)', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-28 00:11:53' },
+ { id: 86960, stream_id: null, username: 'ada', message: 'the robot is offline and the coffee is cold', message_type: 'chat', is_deleted: 0, timestamp: '2026-10-23 09:00:00' },
+ { id: 86961, stream_id: null, username: 'bo', message: 'deleted line', message_type: 'chat', is_deleted: 1, timestamp: '2026-10-23 09:01:00' },
+];
+
+const DEFAULT_TIMELINE = [
+ { id: 2598, ts: '2026-10-28 00:13:06', label: 'Robot offline bit continues', detail: 'Chat keeps the robot-offline joke going.' },
+ { id: 2597, ts: '2026-10-28 00:12:06', label: 'Cozmo phones home — not online', detail: 'Conversation humour around Cozmo not being online.' },
+];
+
+const DEFAULT_TOPICS = [
+ { slug: 'robot-test', title: 'ROBOT TEST', kind: 'games', headline: 'The robot test', summary: 'A robot test.', first_seen_at: '2026-10-20 09:00:00', last_seen_at: '2026-10-22 13:00:00' },
+ { slug: 'looi-robot-test', title: 'LOOI ROBOT TEST', kind: 'games', headline: 'LOOI robot test', summary: 'A robot test.', first_seen_at: '2026-10-23 21:14:44', last_seen_at: '2026-10-26 21:30:52' },
+];
+
+const DEFAULT_TAKES = {
+ 'robot-test': [
+ { type: 'segment', id: 501, title: 'The robot is offline', what: 'The robot is offline.', take: 'It stays offline.', quip: 'Offline again.', score: 3, quotes: [{ text: 'the robot is offline for good', sec: 60 }], stream_id: 7001, vod_id: 9001, sec: 60, said_at: '2026-10-21 12:00:00' },
+ { type: 'segment', id: 590, title: 'A denied clip', what: 'Never cited.', take: 'No.', quip: 'No.', score: 1, quotes: [{ text: 'this line must never become a citation', sec: 12 }], stream_id: 7009, vod_id: 5709, sec: 12, said_at: '2026-10-21 12:05:00' },
+ ],
+ 'looi-robot-test': [
+ { type: 'segment', id: 1165, title: 'Maticus can drive robots', what: 'Cosmo needs Wi-Fi.', take: 'Robots.', quip: 'Wi-Fi.', score: 4, quotes: [{ text: 'Cosmo requires Wi-Fi.', sec: 1582.8 }], stream_id: 2795, vod_id: 6026, sec: 1524, said_at: '2026-10-26 21:30:52' },
+ ],
+};
+
+const DEFAULT_VODS = {
+ 9001: { id: 9001, stream_id: 7001, title: 'Robot test', created_at: '2026-10-21T11:00:00.000Z', visibility: 'public' },
+ 9002: { id: 9002, stream_id: 7001, title: 'Robot test (joined)', created_at: '2026-10-21T11:00:00.000Z', visibility: 'public' },
+ 6025: { id: 6025, stream_id: 2795, title: 'Night stream', created_at: '2026-10-26T21:00:00.000Z', visibility: 'public' },
+ 6026: { id: 6026, stream_id: 2795, title: 'Night stream, part 2', created_at: '2026-10-26T21:00:00.000Z', visibility: 'public' },
+};
+
+const DEFAULT_TRANSCRIPTS = {
+ 9001: { transcript: 'the robot is offline for good', segments: [{ start: 60, end: 64, text: 'the robot is offline for good' }, { start: 120, end: 124, text: 'something else entirely' }] },
+ 9002: { transcript: 'the robot is offline for good', segments: [{ start: 120, end: 124, text: 'the robot is offline for good' }] },
+ 6026: { transcript: 'but yeah and Cosmo is broken now', segments: [{ start: 864.08, end: 869.84, text: 'but yeah and Cosmo is broken now, so I have to get little buddy back' }, { start: 1581.2, end: 1583.92, text: 'Cosmo requires Wi-Fi.' }] },
+ 6025: { transcript: 'but yeah and Cosmo is broken now', segments: [{ start: 1541, end: 1546, text: 'but yeah and Cosmo is broken now, so I have to get little buddy back' }] },
+};
+
+const DEFAULT_VIDEO = {
+ 9001: { status: 301, location: '/v/9002?t=120' },
+ 9002: { status: 200 },
+ 6026: { status: 301, location: '/v/6025?t=1541' },
+ 6025: { status: 200 },
+};
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks).toString('utf8');
+ const json = (status, obj, headers = {}) => {
+ res.writeHead(status, { 'Content-Type': 'application/json', ...headers });
+ res.end(JSON.stringify(obj));
+ };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => json(500, { error: String(err && err.message) }));
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+async function startUpstreams() {
+ const state = {
+ messages: DEFAULT_MESSAGES.map((m) => ({ ...m })),
+ timeline: DEFAULT_TIMELINE.map((e) => ({ ...e })),
+ topics: DEFAULT_TOPICS.map((t) => ({ ...t })),
+ takes: JSON.parse(JSON.stringify(DEFAULT_TAKES)),
+ vods: { ...DEFAULT_VODS },
+ transcripts: JSON.parse(JSON.stringify(DEFAULT_TRANSCRIPTS)),
+ video: { ...DEFAULT_VIDEO },
+ requests: [],
+ };
+
+ const srv = await listen(async (req, raw, json, res) => {
+ const url = new URL(req.url, 'http://x');
+ const p = url.pathname;
+ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers } });
+
+ if (p === '/api/chat/global/history') {
+ const limit = Math.min(500, Math.max(1, Number(url.searchParams.get('limit') || 200)));
+ const before = url.searchParams.get('before');
+ const list = state.messages
+ .filter((m) => !before || m.timestamp <= before)
+ .sort((a, b) => b.id - a.id)
+ .slice(0, limit);
+ return json(200, { messages: list, latest_id: list[0] ? list[0].id : null });
+ }
+ if (p === '/api/chat/ai/timeline') {
+ const limit = Math.min(60, Math.max(1, Number(url.searchParams.get('limit') || 60)));
+ return json(200, { events: state.timeline.slice(0, limit), hasMore: false });
+ }
+ if (p === '/api/rundown/topics') {
+ return json(200, { topics: state.topics, kinds: [] });
+ }
+ const topic = p.match(/^\/api\/rundown\/t\/([^/]+)$/);
+ if (topic) {
+ const slug = decodeURIComponent(topic[1]);
+ const found = state.topics.find((t) => t.slug === slug);
+ if (!found) return json(404, { error: 'not_found' });
+ return json(200, { topic: found, takes: state.takes[slug] || [] });
+ }
+ const vod = p.match(/^\/api\/vods\/(\d+)$/);
+ if (vod) {
+ const row = state.vods[vod[1]];
+ if (!row) return json(404, { error: 'not_found' });
+ return json(200, { vod: row, clips: [] });
+ }
+ if (p === '/api/chat-ai/vod-transcripts') {
+ const ids = String(url.searchParams.get('ids') || '').split(',').map((x) => x.trim()).filter(Boolean);
+ const out = {};
+ for (const id of ids) if (state.transcripts[id]) out[id] = state.transcripts[id];
+ return json(200, { transcripts: out });
+ }
+ const video = p.match(/^\/v\/(\d+)$/);
+ if (video) {
+ const conf = state.video[video[1]];
+ if (!conf || conf.status === 404) return json(404, { error: 'not_found' });
+ if (conf.status >= 400) return json(conf.status, { error: 'video unavailable' });
+ if (conf.status >= 300 && conf.status < 400) {
+ res.writeHead(conf.status, { Location: conf.location, 'Content-Type': 'text/plain' });
+ return res.end('redirect');
+ }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ return res.end('<!doctype html><title>VOD</title>');
+ }
+ return json(404, { error: 'not found' });
+ });
+
+ return {
+ url: srv.url,
+ state,
+ close: srv.close,
+ requestsFor(path) { return state.requests.filter((r) => r.path === path); },
+ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },
+ setVideo(id, conf) { state.video[String(id)] = conf; },
+ addMessage(m) { state.messages.push(m); },
+ };
+}
+
+module.exports = { startUpstreams, DEFAULT_MESSAGES, DEFAULT_TIMELINE, DEFAULT_TOPICS, DEFAULT_TAKES, DEFAULT_VODS, DEFAULT_TRANSCRIPTS, DEFAULT_VIDEO };
new file mode 100644
new file mode 100644
@@ -0,0 +1,58 @@
@@ -0,0 +1,58 @@
+'use strict';
+/**
+ * The importer reads the fixture Chat history/timeline, Rundown topics and VOD transcripts, finds "robot is
+ * offline" across four uses on three days, canonicalises `/v/9001?t=60` → `/v/9002?t=120`, and inserts the
+ * candidate hidden. It never publishes; a second run enriches nothing and makes no second entry. The deny-list
+ * take (VOD 5709) never reaches a citation.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ let out = null;
+ await check('the fixture produces one candidate from ≥2 uses on ≥2 days, hidden', async () => {
+ out = await t.ctx.importer.runOnce();
+ assert.strictEqual(out.errors.length, 0, JSON.stringify(out.errors));
+ assert.ok(out.created >= 1);
+ const e = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'robot-is-offline'");
+ assert.ok(e, 'the candidate entry exists');
+ assert.strictEqual(e.visibility, 'hidden');
+ assert.strictEqual(e.title, 'Robot is offline');
+ const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY observed_at, id', [e.id]);
+ assert.ok(cites.length >= 3, `expected several citations, got ${cites.length}`);
+ const days = new Set(cites.map((c) => String(c.observed_at).slice(0, 10)));
+ assert.ok(days.size >= 2, 'uses are on at least two days');
+ assert.strictEqual(cites.filter((c) => c.role === 'origin').length, 1);
+ assert.ok(cites.some((c) => c.source_type === 'chat_message'));
+ assert.ok(cites.some((c) => c.source_type === 'rundown_segment'));
+ assert.ok(cites.some((c) => c.source_type === 'vod'));
+ });
+
+ await check('a joined-VOD redirect is stored canonically (9002@120, not 9001@60)', async () => {
+ const c = await t.ctx.s.db.maybe("SELECT * FROM meme_citations WHERE source_type = 'rundown_segment' LIMIT 1");
+ assert.strictEqual(Number(c.vod_id), 9002);
+ assert.strictEqual(Number(c.offset_seconds), 120);
+ assert.match(c.url, /\/v\/9002\?t=120$/);
+ });
+
+ await check('no citation names VOD 5709 or clip 483', async () => {
+ const bad = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483');
+ assert.strictEqual(Number(bad), 0);
+ });
+
+ await check('the candidate is hidden from the public API and a second run does not duplicate it', async () => {
+ const list = await t.get('/api/v1/entries');
+ assert.strictEqual(list.json().count, 0);
+ const one = await t.get('/api/v1/entries/robot-is-offline');
+ assert.strictEqual(one.status, 404);
+ const again = await t.ctx.importer.runOnce();
+ assert.strictEqual(again.created, 0);
+ assert.strictEqual(Number(await t.ctx.s.db.value("SELECT COUNT(*)::int AS n FROM meme_entries WHERE slug = 'robot-is-offline'")), 1);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+/**
+ * The expand migration applies in one pass and creates exactly the tables the plan names, plus the SDK's account
+ * receipts and telemetry. The three data-free templates are seeded; the entry constraints refuse an unknown kind.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('0001_initial.sql applies and creates the Meme tables', async () => {
+ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
+ for (const table of ['meme_entries', 'meme_citations', 'meme_status_events', 'meme_votes', 'meme_templates', 'meme_captions', 'meme_reports', 'meme_outbox', 'account_data_events', 'telemetry_samples']) {
+ assert.ok(names.includes(table), `missing ${table}`);
+ }
+ });
+
+ await check('three data-free templates are seeded', async () => {
+ const r = await t.get('/api/v1/templates');
+ assert.strictEqual(r.status, 200);
+ const b = r.json();
+ assert.strictEqual(b.count, 3);
+ for (const tpl of b.templates) {
+ assert.match(tpl.image_url, /^(https:\/\/openvibe\.media\/|\/img\/)/);
+ assert.ok(tpl.box_count >= 1 && tpl.box_count <= 8);
+ assert.ok(tpl.license);
+ }
+ });
+
+ await check('entry constraints refuse an unknown kind, a bad slug and a duplicate slug', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const id = () => `mem_${ids.ulid()}`;
+ await assert.rejects(db.exec(
+ `INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'bad-kind', 'Bad', 'notakind', $2, $2)`,
+ [id(), at],
+ ), /meme_entries_kind/);
+ await assert.rejects(db.exec(
+ `INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'Not A Slug', 'Bad', 'joke', $2, $2)`,
+ [id(), at],
+ ), /meme_entries_slug/);
+ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'once', 'Once', 'joke', $2, $2)`, [id(), at]);
+ await assert.rejects(db.exec(`INSERT INTO meme_entries (id, slug, title, kind, first_seen_at, last_seen_at) VALUES ($1, 'once', 'Twice', 'joke', $2, $2)`, [id(), at]), /duplicate key|unique/i);
+ });
+
+ await check('the one-vote-a-week primary key is real', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const id = () => `mem_${ids.ulid()}`;
+ await db.exec(`INSERT INTO meme_entries (id, slug, title, kind, visibility, first_seen_at, last_seen_at) VALUES ($1, 'votable', 'Votable', 'joke', 'public', $2, $2)`, [id(), at]);
+ const subject = ids.newId('user');
+ const eid = await db.value("SELECT id FROM meme_entries WHERE slug = 'votable'");
+ await db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2)", [eid, subject]);
+ await assert.rejects(db.exec("INSERT INTO meme_votes (week, entry_id, subject) VALUES ('2026-W44', $1, $2)", [eid, subject]), /duplicate key|unique/i);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,38 @@
@@ -0,0 +1,38 @@
+'use strict';
+/**
+ * No looks in public AI text: a summary that names looks is replaced by the entry's title; a normal summary is
+ * kept and repaired only where noLooks has something to repair. Verbatim quotes are never passed through.
+ */
+const assert = require('assert');
+const { cleanSummary, hasLooks } = require('../server/text/no-looks');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ await check('a summary that names looks becomes the title', async () => {
+ assert.strictEqual(cleanSummary('Cozmo is offline', 'A shirtless man in a kitchen laughs.'), 'Cozmo is offline');
+ assert.strictEqual(cleanSummary('Cozmo is offline', 'A little robot is not online.'), 'Cozmo is offline');
+ assert.ok(hasLooks('A shirtless man in a kitchen laughs.'));
+ });
+
+ await check('an ordinary summary survives', async () => {
+ const s = cleanSummary('Cozmo is offline', 'The robot is not online; chat keeps asking about its Wi-Fi.');
+ assert.strictEqual(s, 'The robot is not online; chat keeps asking about its Wi-Fi.');
+ });
+
+ const t = await boot();
+ try {
+ await check('createCandidate stores the title, never the looks summary', async () => {
+ const out = await t.ctx.entries.createCandidate({
+ slug: 'looks', title: 'Robot is offline', kind: 'meme', summary: 'A shirtless man reacts to the offline robot.',
+ status: 'rising', first_seen_at: new Date().toISOString(), last_seen_at: new Date().toISOString(), visibility: 'hidden',
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: new Date().toISOString() }],
+ });
+ assert.strictEqual(out.entry.summary, 'Robot is offline');
+ const quote = await t.ctx.s.db.value('SELECT quote_text FROM meme_citations WHERE entry_id = $1', [out.entry.id]);
+ assert.strictEqual(quote, 'the robot is offline again', 'the verbatim quote is not rewritten');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,179 @@
@@ -0,0 +1,179 @@
+'use strict';
+/**
+ * Server-rendered pages: the home (trending, the week teaser, the generator teaser), one entry with its origin
+ * citation and timeline, the ballot, the generator's preview and save, one saved caption, developers, 404, the
+ * cache headers, and the content-hashed assets. Public pages are public, max-age=60; a page for a signed-in person
+ * is private, no-store.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { assetVersion } = require('../server/render/layout');
+
+function jsonLd(html) {
+ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+}
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ const now = new Date().toISOString();
+ const publicEntry = await t.ctx.entries.createCandidate({
+ slug: 'robot-is-offline', title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',
+ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',
+ citations: [
+ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },
+ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },
+ ],
+ });
+ const hiddenEntry = await t.ctx.entries.createCandidate({
+ slug: 'not-yet', title: 'Not yet', kind: 'joke', summary: 'Hidden.', status: 'rising',
+ first_seen_at: now, last_seen_at: now, visibility: 'hidden',
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1002', chat_message_id: 1002, quote_text: 'still the robot is offline, huh', url: `${t.upstreams.url}/`, observed_at: now }],
+ });
+ const tpl = (await t.get('/api/v1/templates')).json().templates[0];
+
+ try {
+ await check('home: semantic, the entry, the week teaser, public cache with Vary: Cookie', async () => {
+ const r = await t.get('/');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ for (const marker of ['id="mm-home"', 'id="mm-trending"', 'id="mm-week-teaser"', 'class="sc-room-scenes"', '<noscript><nav', '<footer id="ov-footer"', '<main id="main"', 'Robot is offline', '/make']) assert.ok(r.text.includes(marker), marker);
+ const types = jsonLd(r.text).map((x) => x['@type']);
+ assert.ok(types.includes('WebSite'), types.join(','));
+ assert.ok(types.includes('WebApplication'), types.join(','));
+ });
+
+ await check('a page drawn for a signed-in person is private, no-store', async () => {
+ const r = await t.get('/', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual(r.headers.get('vary'), 'Cookie');
+ });
+
+ await check('one entry page carries the origin link, the timeline and the examples section', async () => {
+ const r = await t.get('/m/robot-is-offline');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(r.text.includes('id="mm-entry"'));
+ assert.ok(r.text.includes('id="mm-origin"'));
+ assert.ok(r.text.includes('id="mm-timeline"'));
+ assert.ok(r.text.includes('id="mm-examples"'));
+ assert.ok(r.text.includes('the robot is offline again'));
+ assert.ok(r.text.includes('the robot is offline for good'));
+ assert.ok(r.text.includes('/v/9002?t=120'));
+ });
+
+ await check('a hidden entry is 404 to the public and visible to staff', async () => {
+ assert.strictEqual((await t.get('/m/not-yet')).status, 404);
+ assert.strictEqual((await t.get('/m/not-yet', { as: alice })).status, 404);
+ const r = await t.get('/m/not-yet', { as: mod });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('staff view'));
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ });
+
+ await check('the series API returns one zero-filled bucket per day', async () => {
+ const r = await t.get('/api/v1/entries/robot-is-offline/series?days=3');
+ assert.strictEqual(r.status, 200);
+ const b = r.json();
+ assert.strictEqual(b.days, 3);
+ assert.strictEqual(b.series.length, 3);
+ assert.ok(b.series.every((x) => /^\d{4}-\d{2}-\d{2}$/.test(x.day) && Number.isInteger(x.uses)));
+ assert.strictEqual((await t.get('/api/v1/entries/robot-is-offline/series?days=0')).status, 400);
+ });
+
+ await check('/week has the ballot and the plain POST form', async () => {
+ const r = await t.get('/week');
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('id="mm-week"'));
+ assert.ok(r.text.includes('Robot is offline'));
+ const signed = await t.get('/week', { as: alice });
+ assert.strictEqual(signed.status, 200);
+ assert.strictEqual(signed.headers.get('cache-control'), 'private, no-store');
+ assert.ok(signed.text.includes('action="/week/vote"'));
+ assert.ok(signed.text.includes('name="entry_id"'));
+ const vote = await t.get('/week/vote', { as: alice, form: { entry_id: publicEntry.entry.id } });
+ assert.strictEqual(vote.status, 303);
+ assert.strictEqual(vote.headers.get('location'), '/week?voted=1');
+ const cross = await t.get('/week/vote', { as: alice, form: { entry_id: publicEntry.entry.id }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ });
+
+ await check('/make previews over the image and saves a caption, public cache', async () => {
+ const form = await t.get('/make');
+ assert.strictEqual(form.status, 200);
+ assert.strictEqual(form.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(form.text.includes('id="mm-make"'));
+ assert.ok(form.text.includes('id="mm-caption-form"'));
+ assert.ok(form.text.includes(`/img/template-`));
+ const preview = await t.get(`/make?template_id=${tpl.id}&line1=when%20the%20robot&line2=is%20offline`);
+ assert.strictEqual(preview.status, 200);
+ assert.ok(preview.text.includes('id="mm-preview"'));
+ assert.ok(preview.text.includes('mm-caption-card'));
+ assert.ok(preview.text.includes('when the robot'));
+ const saved = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: 'when the robot', line2: 'is offline', visibility: 'public' } });
+ assert.strictEqual(saved.status, 303);
+ assert.match(saved.headers.get('location'), /^\/c\/mcp_/);
+ const cross = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: 'cross' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ const bad = await t.get('/make', { as: alice, form: { template_id: tpl.id, line1: '' } });
+ assert.strictEqual(bad.status, 400);
+ assert.ok(bad.text.includes('id="mm-caption-form"'));
+ });
+
+ await check('/c/:id shows text over the template and respects a hidden caption', async () => {
+ const cap = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['the caption', 'on the page'] } })).json();
+ const r = await t.get(`/c/${cap.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('id="mm-caption"'));
+ assert.ok(r.text.includes('mm-caption-card'));
+ assert.ok(r.text.includes('the caption'));
+ assert.ok(r.text.includes('on the page'));
+ const hidden = (await t.get('/api/v1/captions', { as: alice, json: { template_id: tpl.id, lines: ['hidden one'], visibility: 'hidden' } })).json();
+ assert.strictEqual((await t.get(`/c/${hidden.id}`)).status, 404);
+ assert.strictEqual((await t.get(`/c/${hidden.id}`, { as: alice })).status, 200);
+ });
+
+ await check('/developers, the 404 and the report form', async () => {
+ const dev = await t.get('/developers');
+ assert.strictEqual(dev.status, 200);
+ assert.strictEqual(dev.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(dev.text.includes('id="mm-developers"'));
+ assert.ok(dev.text.includes('/api/v1/entries'));
+ const nf = await t.get('/no/such/page');
+ assert.strictEqual(nf.status, 404);
+ assert.match(nf.headers.get('content-type'), /^text\/html/);
+ assert.ok(nf.text.includes('id="mm-not-found"'));
+ const report = await t.get('/m/robot-is-offline/report', { as: alice, form: { kind: 'takedown', reason: 'A source is gone' } });
+ assert.strictEqual(report.status, 303);
+ assert.strictEqual(report.headers.get('location'), '/m/robot-is-offline?reported=1');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_reports')), 1);
+ });
+
+ await check('assets are content-hashed; robots and sitemap list the public pages', async () => {
+ const v = assetVersion('css/meme.css');
+ const hashed = await t.get(`/css/meme.css?v=${v}`);
+ assert.strictEqual(hashed.status, 200);
+ assert.strictEqual(hashed.headers.get('cache-control'), 'public, max-age=31536000, immutable');
+ const wrong = await t.get('/css/meme.css?v=deadbeefdeadbeef');
+ assert.strictEqual(wrong.status, 200);
+ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
+ const js = await t.get('/js/meme.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ const robots = await t.get('/robots.txt');
+ assert.match(robots.text, /Disallow: \/api\//);
+ const map = await t.get('/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.ok(map.text.includes('https://openvibe.meme/m/robot-is-offline'));
+ const llms = await t.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.ok(llms.text.includes('OpenVibe.Meme'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * Citation re-verification: a URL that now answers 404 marks the citation gone and, when it is the origin, hides
+ * the entry (revision +1, Search tombstone). A transient 500 must leave the citation alone. A Chat line is checked
+ * against the public history page, because v1 has no per-message permalink.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const now = new Date().toISOString();
+ const entry = await t.ctx.entries.createCandidate({
+ slug: 'vod-origin', title: 'VOD origin', kind: 'meme', summary: 'A proof.', status: 'rising',
+ first_seen_at: '2026-10-21T12:00:00Z', last_seen_at: now, visibility: 'public',
+ citations: [{ role: 'origin', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: '2026-10-21T12:00:00Z' }],
+ });
+
+ await check('a 404 marks the citation gone and hides the entry whose origin it is', async () => {
+ t.upstreams.setVideo(9002, { status: 404 });
+ const r = await t.ctx.reverifier.runOnce();
+ assert.strictEqual(r.checked, 1);
+ assert.strictEqual(r.gone, 1);
+ assert.strictEqual(r.entries_hidden, 1);
+ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [entry.entry.id]);
+ assert.strictEqual(c.source_status, 'gone');
+ const e = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [entry.entry.id]);
+ assert.strictEqual(e.visibility, 'hidden');
+ assert.strictEqual(Number(e.revision), 2);
+ const doc = await t.ctx.s.db.maybe('SELECT payload FROM meme_outbox ORDER BY created_at DESC LIMIT 1');
+ assert.strictEqual(doc.payload.deleted, true);
+ assert.strictEqual((await t.get('/api/v1/entries/vod-origin')).status, 404);
+ });
+
+ await check('a gone use (not the origin) keeps the entry public and re-indexes it', async () => {
+ const e4 = await t.ctx.entries.createCandidate({
+ slug: 'use-gone', title: 'Use gone', kind: 'meme', summary: 'A proof.', status: 'rising',
+ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'public',
+ citations: [
+ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },
+ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },
+ ],
+ });
+ const r = await t.ctx.reverifier.runOnce();
+ assert.strictEqual(r.entries_hidden, 0);
+ const e = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [e4.entry.id]);
+ assert.strictEqual(e.visibility, 'public');
+ assert.strictEqual(Number(e.revision), 2);
+ const ups = await t.ctx.s.db.many("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
+ assert.ok(ups.length);
+ assert.strictEqual(ups[0].payload.revision, 2);
+ assert.strictEqual(ups[0].payload.deleted, false);
+ });
+
+ await check('a transient 500 leaves the citation and the entry as they were', async () => {
+ const e2 = await t.ctx.entries.createCandidate({
+ slug: 'transient', title: 'Transient', kind: 'meme', summary: 'A proof.', status: 'rising',
+ first_seen_at: now, last_seen_at: now, visibility: 'public',
+ citations: [{ role: 'origin', source_service: 'live', source_type: 'vod', source_id: '6025', vod_id: 6025, offset_seconds: 1541, quote_text: 'Cosmo requires Wi-Fi.', url: `${t.upstreams.url}/v/6025?t=1541`, observed_at: now }],
+ });
+ t.upstreams.setVideo(6025, { status: 500 });
+ const r = await t.ctx.reverifier.runOnce();
+ assert.ok(r.errors.length >= 1);
+ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [e2.entry.id]);
+ assert.strictEqual(c.source_status, 'live');
+ const row = await t.ctx.s.db.maybe('SELECT * FROM meme_entries WHERE id = $1', [e2.entry.id]);
+ assert.strictEqual(row.visibility, 'public');
+ });
+
+ await check('a Chat line that is no longer in public history is marked gone', async () => {
+ const e3 = await t.ctx.entries.createCandidate({
+ slug: 'chat-origin', title: 'Chat origin', kind: 'joke', summary: 'A proof.', status: 'rising',
+ first_seen_at: now, last_seen_at: now, visibility: 'public',
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '999999', chat_message_id: 999999, quote_text: 'a line that never existed', url: `${t.upstreams.url}/`, observed_at: now }],
+ });
+ const r = await t.ctx.reverifier.runOnce();
+ assert.ok(r.errors.length >= 0);
+ const c = await t.ctx.s.db.maybe('SELECT * FROM meme_citations WHERE entry_id = $1', [e3.entry.id]);
+ assert.strictEqual(c.source_status, 'gone');
+ assert.strictEqual((await t.ctx.s.db.maybe('SELECT visibility FROM meme_entries WHERE id = $1', [e3.entry.id])).visibility, 'hidden');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
+ * in-process mock of OpenVibe.Network, and in-process Chat/Live/Video fixtures; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- importer # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * The Search Fabric document: owner meme, type entry, id mem_…, canonical page /m/<slug>, title/summary/body,
+ * facets kind/status/source, authorship ai_assisted, one provenance reference per citation, the entry's revision.
+ * A hidden entry has no document; publish upserts; a PATCH bumps the revision; hide writes a valid tombstone.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ const now = new Date().toISOString();
+ const e = await t.ctx.entries.createCandidate({
+ slug: 'search-proof', title: 'Robot is offline', kind: 'catchphrase', summary: 'The robot is offline, again.', status: 'rising',
+ first_seen_at: '2026-10-20T10:00:00Z', last_seen_at: now, visibility: 'hidden',
+ citations: [
+ { role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: '1001', chat_message_id: 1001, quote_text: 'the robot is offline again', url: `${t.upstreams.url}/`, observed_at: '2026-10-20T10:00:00Z' },
+ { role: 'use', source_service: 'live', source_type: 'vod', source_id: '9002', vod_id: 9002, offset_seconds: 120, quote_text: 'the robot is offline for good', url: `${t.upstreams.url}/v/9002?t=120`, observed_at: now },
+ ],
+ });
+
+ await check('a hidden entry queues no Search document', async () => {
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox')), 0);
+ });
+
+ await check('publish writes a valid search.index-document@1', async () => {
+ assert.strictEqual((await t.get('/api/v1/entries/search-proof/publish', { as: mod, method: 'POST' })).status, 200);
+ const row = await t.ctx.s.db.maybe("SELECT topic, payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at DESC LIMIT 1");
+ assert.ok(row, 'an upsert row exists');
+ const doc = row.payload;
+ const v = contracts.validate('search.index-document@1', doc);
+ assert.strictEqual(v.valid, true, JSON.stringify(v.errors));
+ assert.strictEqual(doc.owner, 'meme');
+ assert.strictEqual(doc.type, 'entry');
+ assert.strictEqual(doc.id, e.entry.id);
+ assert.strictEqual(doc.deleted, false);
+ assert.strictEqual(doc.visibility, 'public');
+ assert.strictEqual(doc.publication_state, 'published');
+ assert.strictEqual(doc.canonical_url, 'https://openvibe.meme/m/search-proof');
+ assert.strictEqual(doc.title, 'Robot is offline');
+ assert.strictEqual(doc.revision, 2);
+ assert.strictEqual(doc.facets.kind, 'catchphrase');
+ assert.strictEqual(doc.facets.status, 'rising');
+ assert.deepStrictEqual(doc.facets.source.sort(), ['chat', 'live']);
+ assert.strictEqual(doc.authorship, 'ai_assisted');
+ assert.strictEqual(doc.provenance.length, 2);
+ assert.strictEqual(doc.provenance[0].service, 'chat');
+ assert.ok(doc.body.includes('the robot is offline again'));
+ });
+
+ await check('an edit sends the next revision; hide sends a valid tombstone', async () => {
+ assert.strictEqual((await t.get('/api/v1/entries/search-proof', { as: mod, method: 'PATCH', json: { title: 'Robot is really offline' } })).status, 200);
+ const ups = await t.ctx.s.db.many("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.upserted' ORDER BY created_at");
+ assert.strictEqual(ups.length, 2);
+ assert.strictEqual(ups[1].payload.revision, 3);
+ assert.strictEqual(ups[1].payload.title, 'Robot is really offline');
+ assert.strictEqual(contracts.validate('search.index-document@1', ups[1].payload).valid, true);
+ assert.strictEqual((await t.get('/api/v1/entries/search-proof/hide', { as: mod, method: 'POST' })).status, 200);
+ const tomb = await t.ctx.s.db.maybe("SELECT payload FROM meme_outbox WHERE topic = 'meme.index_document.deleted' ORDER BY created_at DESC LIMIT 1");
+ assert.strictEqual(contracts.validate('search.index-document@1', tomb.payload).valid, true, JSON.stringify(contracts.validate('search.index-document@1', tomb.payload).errors));
+ assert.strictEqual(tomb.payload.deleted, true);
+ assert.strictEqual(tomb.payload.revision, 4);
+ });
+
+ await check('the public list carries only public entries', async () => {
+ const r = await t.get('/api/v1/entries');
+ assert.strictEqual(r.json().count, 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
+'use strict';
+/**
+ * The first proof: scripts/seed-proof.js reads the fixture public APIs, creates "Cozmo is offline" hidden with
+ * exactly two citations (chat message 86957 and VOD 6025 @ 1541 s, the canonical target of /v/6026?t=864), never
+ * publishes it, and is idempotent on a second run.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { runSeed } = require('../scripts/seed-proof');
+
+(async () => {
+ const t = await boot();
+ try {
+ let out = null;
+ await check('the seed creates the hidden proof with its two canonical citations', async () => {
+ out = await runSeed({ config: t.config, s: t.ctx.s, upstreams: t.ctx.upstreams, log: { log() {} } });
+ assert.strictEqual(out.created, true);
+ assert.strictEqual(out.entry.slug, 'cozmo-offline');
+ assert.strictEqual(out.entry.title, 'Cozmo is offline');
+ assert.strictEqual(out.entry.visibility, 'hidden');
+ assert.strictEqual(out.entry.status, 'classic');
+ const cites = await t.ctx.s.db.many('SELECT * FROM meme_citations WHERE entry_id = $1 ORDER BY role', [out.entry.id]);
+ assert.strictEqual(cites.length, 2);
+ const origin = cites.find((c) => c.role === 'origin');
+ assert.strictEqual(origin.source_type, 'chat_message');
+ assert.strictEqual(Number(origin.chat_message_id), 86957);
+ const use = cites.find((c) => c.role === 'use');
+ assert.strictEqual(use.source_type, 'vod');
+ assert.strictEqual(Number(use.vod_id), 6025);
+ assert.strictEqual(Number(use.offset_seconds), 1541);
+ assert.match(use.url, /\/v\/6025\?t=1541$/);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations WHERE vod_id = 5709 OR clip_id = 483')), 0);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_outbox')), 0);
+ assert.strictEqual((await t.get('/api/v1/entries/cozmo-offline')).status, 404);
+ });
+
+ await check('a second run adds nothing and leaves the entry hidden', async () => {
+ const again = await runSeed({ config: t.config, s: t.ctx.s, upstreams: t.ctx.upstreams, log: { log() {} } });
+ assert.strictEqual(again.created, false);
+ assert.strictEqual(again.added, 0);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM meme_citations')), 2);
+ const e = await t.ctx.s.db.maybe("SELECT * FROM meme_entries WHERE slug = 'cozmo-offline'");
+ assert.strictEqual(e.visibility, 'hidden');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id meme, cookies meme_at/meme_rt/meme_oauth), the session-token rules (a FedCM
+ * assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback metrics,
+ * pages without JavaScript, and cross-site sign-out.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { sanitizeNext } = require('../server/auth/sso');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+
+ await check('login redirects to Network with state and an S256 code challenge, client id meme', async () => {
+ const r = await t.get('/auth/login?next=/week');
+ assert.strictEqual(r.status, 302);
+ const loc = new URL(r.headers.get('location'));
+ assert.strictEqual(loc.origin + loc.pathname, `${t.network.url}/oauth/authorize`);
+ assert.strictEqual(loc.searchParams.get('client_id'), 'meme');
+ assert.strictEqual(loc.searchParams.get('code_challenge_method'), 'S256');
+ assert.match(loc.searchParams.get('code_challenge'), /^[A-Za-z0-9_-]{43}$/);
+ assert.match(loc.searchParams.get('state'), /^[0-9a-f]{32}$/);
+ assert.match(r.headers.get('set-cookie'), /meme_oauth=.*Path=\/auth.*HttpOnly/i);
+ });
+
+ await check('callback exchanges the code with the matching verifier and sets httpOnly session cookies', async () => {
+ const login = await t.get('/auth/login?next=/week');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const r = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(r.status, 302, r.text);
+ assert.strictEqual(r.headers.get('location'), '/week');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /meme_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /meme_rt=[^;]+;.*HttpOnly/i);
+ assert.match(set, /meme_oauth=;/);
+ const at = set.match(/meme_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `meme_at=${at}` });
+ assert.strictEqual(me.json().user.username, 'kim');
+ assert.ok(!me.text.includes(at), '/auth/me never returns the token');
+ });
+
+ await check('a wrong state, a verifier that does not match, or no flow cookie is refused', async () => {
+ const login = await t.get('/auth/login');
+ const loc = new URL(login.headers.get('location'));
+ const flowCookie = login.headers.get('set-cookie').split(';')[0];
+ const code = t.network.issueCode(user, loc.searchParams.get('code_challenge'));
+ const bad = await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie });
+ assert.strictEqual(bad.status, 400);
+ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
+ const mismatch = await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie });
+ assert.strictEqual(mismatch.status, 400);
+ const noCookie = await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`);
+ assert.strictEqual(noCookie.status, 400);
+ });
+
+ await check('next= only accepts same-site paths', async () => {
+ const r = await t.get('/auth/login?next=//evil.example/x');
+ const flow = decodeURIComponent(r.headers.get('set-cookie').match(/meme_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://openvibe.meme' };
+ assert.strictEqual(sanitizeNext('/week?x=1', config), '/week?x=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.meme/m/x?y=1', config), '/m/x?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.meme.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ });
+
+ await check('readiness is truthful: db required; JWKS, the OAuth client and Valkey optional', async () => {
+ const r = await t.get('/api/ready');
+ const b = r.json();
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(b.checks.db.status, 'ok');
+ assert.strictEqual(b.checks.db.required, true);
+ assert.strictEqual(b.checks.network_jwks.status, 'ok');
+ assert.strictEqual(b.checks.network_jwks.required, false);
+ assert.strictEqual(b.checks.oauth_client.status, 'ok');
+ assert.strictEqual(b.checks.oauth_client.required, false);
+ assert.ok('valkey' in b.checks);
+ });
+
+ await check('/release.json and /metrics (loopback only)', async () => {
+ const rel = await t.get('/release.json');
+ assert.strictEqual(rel.status, 200);
+ assert.strictEqual(rel.json().service, 'meme');
+ assert.deepStrictEqual(require('openvibe-contracts').validate('registry.release-manifest@1', rel.json()).errors, []);
+ const m = await t.get('/metrics');
+ assert.strictEqual(m.status, 200);
+ assert.match(m.text, /http_requests_total|release_info/);
+ const proxied = await t.get('/metrics', { headers: { 'x-forwarded-for': '203.0.113.9' } });
+ assert.notStrictEqual(proxied.status, 200, 'a proxied request is not loopback');
+ });
+
+ await check('public pages are complete without JavaScript', async () => {
+ for (const p of ['/', '/week', '/make', '/developers']) {
+ const r = await t.get(p);
+ assert.strictEqual(r.status, 200, p);
+ assert.match(r.text, /<noscript><nav/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ assert.match(r.text, /<main id="main"/);
+ }
+ });
+
+ await check('the API answers /ping and unknown routes are problem+json 404', async () => {
+ const ping = await t.get('/api/v1/ping');
+ assert.strictEqual(ping.status, 200);
+ assert.deepStrictEqual(ping.json(), { ok: true, service: 'meme' });
+ const gone = await t.get('/api/v1/nope');
+ assert.strictEqual(gone.status, 404);
+ assert.match(gone.headers.get('content-type'), /application\/problem\+json/);
+ assert.strictEqual(gone.json().code, 'route.not_found');
+ });
+
+ await check('a FedCM assertion and an app token are not sessions', async () => {
+ const now = Math.floor(Date.now() / 1000);
+ const fedcm = t.network.signJwt({ iss: t.network.issuer(), sub: user.id, id: user.id, subject_id: user.subject, username: user.username, display_name: user.username, nonce: null, typ: 'fedcm', jti: 'x', aud: 'https://tenant.openvibe.host', iat: now, exp: now + 300 });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `meme_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `meme_at=${fedcm}` });
+ assert.ok(!page.text.includes('Signed in as <strong>kim'));
+ const app = t.network.signJwt({ iss: t.network.issuer(), sub: 'app:app_01JABCDEFGHJKMNPQRSTVWXYZ0', actor_type: 'app', aud: ['openvibe.network'], cap: ['x.y.z'], subject_id: user.subject, iat: now, exp: now + 300 });
+ assert.strictEqual((await t.get('/auth/me', { cookie: `meme_at=${app}` })).status, 401);
+ });
+
+ await check('/auth/me: a guest is signed out (200 { user: null }); a failing session cookie is 401', async () => {
+ const guest = await t.get('/auth/me');
+ assert.strictEqual(guest.status, 200);
+ assert.deepStrictEqual(JSON.parse(guest.text), { user: null });
+ assert.strictEqual(guest.headers.get('cache-control'), 'private, no-store');
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'meme_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'meme_rt=stale' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { as: user })).status, 200);
+ });
+
+ await check('another site cannot sign a person out; this site can', async () => {
+ const cross = await t.get('/auth/logout', { as: user, headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(cross.status, 200);
+ assert.ok(!/meme_at=;|meme_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));
+ assert.match(cross.text, /method="post"/);
+ const crossPost = await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } });
+ assert.strictEqual(crossPost.status, 403);
+ const same = await t.get('/auth/logout?next=/week', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/week');
+ assert.match(String(same.headers.get('set-cookie') || ''), /meme_at=;/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+'use strict';
+/**
+ * Staff: publishing a hidden candidate needs Network role global_mod or above; an entry whose origin is gone is
+ * refused; PATCH changes title/kind/status and records the status event; hide tombstones the Search document.
+ * Reports are stored for any signed-in person and read only by staff.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const kim = t.network.addUser('kim');
+ const mod = t.network.addUser('mod', { role: 'global_mod' });
+ const admin = t.network.addUser('admin', { role: 'admin' });
+ const now = new Date().toISOString();
+ const mk = (slug, opts = {}) => t.ctx.entries.createCandidate({
+ slug, title: opts.title || 'A candidate', kind: 'joke', summary: 'A candidate.', status: 'rising',
+ first_seen_at: now, last_seen_at: now, visibility: opts.visibility || 'hidden',
+ citations: [{
+ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(opts.msg || Math.floor(Math.random() * 1e9)),
+ chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: now,
+ source_status: opts.source_status || 'live',
+ }],
+ });
+ const a = await mk('staff-publish', { title: 'Staff publish' });
+ const gone = await mk('gone-origin', { source_status: 'gone' });
+
+ await check('publish is 403 for a person and 403 for a streamer, 200 for global_mod and admin', async () => {
+ const streamer = t.network.addUser('streamer', { role: 'streamer' });
+ const no = await t.get('/api/v1/entries/staff-publish/publish', { as: kim, method: 'POST' });
+ assert.strictEqual(no.status, 403);
+ assert.strictEqual(no.json().code, 'meme.staff_required');
+ assert.strictEqual((await t.get('/api/v1/entries/staff-publish/publish', { as: streamer, method: 'POST' })).status, 403);
+ const yes = await t.get('/api/v1/entries/staff-publish/publish', { as: mod, method: 'POST' });
+ assert.strictEqual(yes.status, 200, yes.text);
+ assert.strictEqual(yes.json().visibility, 'public');
+ assert.strictEqual(yes.json().revision, 2);
+ assert.strictEqual((await t.get('/api/v1/entries/staff-publish')).status, 200);
+ assert.strictEqual((await t.get('/api/v1/entries/gone-origin/publish', { as: admin, method: 'POST' })).status, 409);
+ });
+
+ await check('PATCH changes the fields and records a status event', async () => {
+ const r = await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { title: 'Renamed', kind: 'bit', status: 'peaked', reason: 'people still say it' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().title, 'Renamed');
+ assert.strictEqual(r.json().kind, 'bit');
+ assert.strictEqual(r.json().status, 'peaked');
+ assert.strictEqual(r.json().revision, 3);
+ const events = await t.ctx.s.db.many('SELECT from_status, to_status, reason, changed_by FROM meme_status_events WHERE entry_id = $1', [a.entry.id]);
+ assert.strictEqual(events.length, 1);
+ assert.strictEqual(events[0].from_status, 'rising');
+ assert.strictEqual(events[0].to_status, 'peaked');
+ assert.strictEqual(events[0].reason, 'people still say it');
+ assert.strictEqual(events[0].changed_by, mod.subject);
+ });
+
+ await check('PATCH refuses an unknown field and a bad kind', async () => {
+ const unknown = await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { slug: 'other' } });
+ assert.strictEqual(unknown.status, 400);
+ assert.strictEqual(unknown.json().code, 'meme.invalid');
+ assert.strictEqual((await t.get('/api/v1/entries/staff-publish', { as: mod, method: 'PATCH', json: { kind: 'not-a-kind' } })).status, 400);
+ });
+
+ await check('hide makes it private and queues a tombstone', async () => {
+ const r = await t.get('/api/v1/entries/staff-publish/hide', { as: mod, method: 'POST' });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().visibility, 'hidden');
+ assert.strictEqual((await t.get('/api/v1/entries/staff-publish')).status, 404);
+ assert.strictEqual((await t.get('/api/v1/entries/staff-publish', { as: mod })).status, 200);
+ const docs = await t.ctx.s.db.many('SELECT payload FROM meme_outbox ORDER BY created_at');
+ assert.strictEqual(docs[docs.length - 1].payload.deleted, true);
+ });
+
+ await check('a report is stored for a signed-in person and listed only for staff', async () => {
+ const rep = await mk('reportable', { title: 'Reportable' });
+ assert.strictEqual((await t.get('/api/v1/entries/reportable/publish', { as: mod, method: 'POST' })).status, 200);
+ const r = await t.get('/api/v1/entries/reportable/report', { as: kim, json: { kind: 'correction', reason: 'The quote is from another stream', note: 'please check 6025' } });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.strictEqual((await t.get('/api/v1/reports', { as: kim })).status, 403);
+ assert.strictEqual((await t.get('/api/v1/reports', { as: mod })).status, 200);
+ const list = (await t.get('/api/v1/reports', { as: mod })).json();
+ assert.strictEqual(list.count, 1);
+ assert.strictEqual(list.reports[0].kind, 'correction');
+ assert.strictEqual(list.reports[0].entry_slug, 'reportable');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,79 @@
@@ -0,0 +1,79 @@
+'use strict';
+/**
+ * Meme of the week: one vote a week per person (a second call changes the choice, never adds a ballot), a public
+ * count, a signed-in same-origin rule, and only public entries with a live use this week on the ballot.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const now = new Date().toISOString();
+ const mk = (slug, title, visibility = 'public', observed = now) => t.ctx.entries.createCandidate({
+ slug, title, kind: 'meme', summary: 'A candidate.', status: 'rising',
+ first_seen_at: observed, last_seen_at: observed, visibility,
+ citations: [{ role: 'origin', source_service: 'chat', source_type: 'chat_message', source_id: String(Math.floor(Math.random() * 1e9)), chat_message_id: 1, quote_text: 'a repeated phrase here', url: `${t.upstreams.url}/`, observed_at: observed }],
+ });
+ const one = await mk('first-meme', 'First meme');
+ const two = await mk('second-meme', 'Second meme');
+ const hidden = await mk('hidden-meme', 'Hidden meme', 'hidden');
+ const old = await mk('old-meme', 'Old meme', 'public', '2026-01-01T00:00:00Z');
+
+ await check('a signed-in vote is one ballot and the count is public', async () => {
+ const r = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: one.entry.id } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().vote.changed, true);
+ const wk = await t.get('/api/v1/week', { as: alice });
+ assert.strictEqual(wk.status, 200);
+ const c = wk.json().candidates.find((x) => x.id === one.entry.id);
+ assert.ok(c, 'the entry is on this week’s ballot');
+ assert.strictEqual(c.votes, 1);
+ assert.strictEqual(c.mine, true);
+ assert.ok(!wk.json().candidates.some((x) => x.id === hidden.entry.id), 'a hidden entry is not a candidate');
+ assert.ok(!wk.json().candidates.some((x) => x.id === old.entry.id), 'an old use is not this week');
+ });
+
+ await check('two people are two votes; one person changing their vote is still one row', async () => {
+ assert.strictEqual((await t.get('/api/v1/week/vote', { as: bob, json: { entry_id: one.entry.id } })).status, 200);
+ const changed = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: two.entry.id } });
+ assert.strictEqual(changed.status, 200);
+ assert.strictEqual(changed.json().vote.changed, true);
+ const rows = await t.ctx.s.db.many('SELECT week, subject, entry_id FROM meme_votes ORDER BY subject');
+ assert.strictEqual(rows.length, 2, 'one row per person per week');
+ assert.strictEqual(rows.filter((r) => r.subject === alice.subject).length, 1);
+ const counts = await t.ctx.s.db.many('SELECT entry_id, COUNT(*)::int AS n FROM meme_votes GROUP BY entry_id');
+ const byId = Object.fromEntries(counts.map((c) => [c.entry_id, Number(c.n)]));
+ assert.strictEqual(byId[one.entry.id], 1);
+ assert.strictEqual(byId[two.entry.id], 1);
+ });
+
+ await check('anonymous, cross-site and non-public votes are refused', async () => {
+ const anon = await t.get('/api/v1/week/vote', { json: { entry_id: one.entry.id } });
+ assert.strictEqual(anon.status, 401);
+ assert.strictEqual(anon.json().code, 'meme.sign_in_required');
+ const cross = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: one.entry.id }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'meme.cross_site');
+ const gone = await t.get('/api/v1/week/vote', { as: alice, json: { entry_id: hidden.entry.id } });
+ assert.strictEqual(gone.status, 404);
+ const missing = await t.get('/api/v1/week/vote', { as: alice, json: {} });
+ assert.strictEqual(missing.status, 400);
+ });
+
+ await check('the /week page has the plain vote form and shows the count', async () => {
+ const r = await t.get('/week', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('id="mm-week"'));
+ assert.ok(r.text.includes('action="/week/vote"'));
+ assert.ok(r.text.includes('name="entry_id"'));
+ assert.ok(r.text.includes('Your vote'));
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();