openvibers/openvibe.today Commits
OpenVibe.Today v1: the network's calendar authority (schedules, Remind, ICS feeds, the morning brief), with the room's designed pages
Changes
nnew file mode 100644@@@ -0,0 +1,55 @@++# OpenVibe.Today — environment (production: /etc/openvibe/today.env). Names only; never commit values.++++NODE_ENV=production++PORT=5100++HOST=127.0.0.1++BASE_URL=https://openvibe.today++TRUST_PROXY=2++# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh today: 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:today:++# Per-caller limits (server/app.js): requests one address may make to the API and to the pages per minute.++#TODAY_LIMITS_MINUTE=120++#TODAY_LIMITS_HOUR=3000++++# OpenVibe.Network: SSO (OAuth client `today`, PKCE S256) and its JWKS; app tokens for audience openvibe.today.++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=today++OV_OAUTH_CLIENT_SECRET=++# OV_OAUTH_REDIRECT_URI=https://openvibe.today/auth/callback++# TODAY_AUDIENCE=openvibe.today++# OV_SESSION_AUDIENCE=openvibe.network++# TODAY_PGLITE_DIR=./data/pglite++++# OpenVibe.Events: where the outbox publishes and the subscriptions are created at boot. The secret signs++# deliveries (comma-separated for rotation, 32+ characters each; unset makes POST /internal/events answer 503).++# TODAY_EVENTS_SUBSCRIBE=0 turns the boot-time subscriptions off.++TODAY_EVENTS_SECRET=++TODAY_EVENTS_URL=++#TODAY_EVENTS_ENDPOINT=http://127.0.0.1:5100/internal/events++#TODAY_EVENTS_INTERVAL_MS=5000++#TODAY_EVENTS_SCOPE=events.publish++++# OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.++OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000++#TODAY_LIVE_TIMEOUT_MS=2500++++# The daily brief at 06:00 UTC for subjects active in the last 30 days.++#TODAY_BRIEF_HOUR_UTC=6++#TODAY_BRIEF_ACTIVE_DAYS=30++++# Caches: the follow graph per subject (60 s) and Network user ids for reminders (5 min).++#TODAY_FOLLOWS_CACHE_MS=60000++#TODAY_RESOLVE_CACHE_MS=300000++++# A restore drill starts no jobs and creates no subscriptions.++#TODAY_DRILL=0++++# optional: defaults to true in production++# COOKIE_SECURE=truennew file mode 100644@@@ -0,0 +1,18 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test++ contracts-service: today++ # 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,35 @@++# Changelog++++What changed in OpenVibe.Today, newest first.++++## 0.1.0 — 2026-10-28++++The first version: the network's time bus and calendar authority.++++- **Scheduling.** `POST|PATCH|DELETE /api/v1/events`, with title/description/time-zone/range validation, a stream's++ channel and URL taken from the caller's token, followers-only visibility, a cap of 60 scheduled future events per++ owner, an `Idempotency-Key` that returns the first event, and a same-origin rule for writes made with the session++ cookie (`403 today.cross_site`). The event and its `today.event.scheduled` outbox row commit in one transaction.++- **Public read.** `GET /api/v1/events` (soonest first, public cache 60 s) and `GET /api/v1/events/:id`; a cancelled++ event answers with its cancelled status, and a followers-only one is 404 unless the caller owns it.++- **ICS.** `/ics/@<username>.ics` (public) and `/ics/<token>.ics` (private, 300 s): RFC 5545 CRLF, 75-octet UTF-8-safe++ folding, TEXT escaping, UID/SEQUENCE/STATUS/LAST-MODIFIED, one-hour default end, a cancelled event as++ `STATUS:CANCELLED` for 7 days then dropped, and rotation that makes the old token 404 at once.++- **Reminders.** Upsert/remove/list and a 60 s job that claims a due row atomically before pushing one++ `TODAY_REMINDER` through OpenVibe.Network's `/internal/notifications/push` (user id resolved in batches and cached).++ A cancelled or done event's reminder never sends.++- **Agenda.** Own events, reminded events and the public or followers-only events of followed channels, with the++ follow graph read from Network with the person's own token and cached 60 s. Network down still answers own and++ reminded events with `follows_unavailable: true`.++- **Brief.** Later today, your reminders and up to three items from OpenVibe.Live's public Rundown; the Rundown++ section is omitted when it fails; one stored brief per person per day; a 06:00 UTC job for subjects active in the++ last 30 days (no notification in v1).++- **Events.** A transactional outbox with a fixed `evt_` id, claim lease, backoff and `attempts`/`last_error`, and a++ v2-signed consumer at `/internal/events` for `live.stream.started|ended`; `today_processed` makes a replay a no-op.++ Account export and deletion go through openvibe-sdk/account-data over every table that holds a subject; the feed++ token itself is never exported.++- **The service.** OpenVibe.Network sign-in as OAuth client `today` (PKCE S256, `today_at`/`today_rt`/`today_oauth`),++ truthful readiness (the database is required), Prometheus metrics on loopback, crawl artifacts, deploy files, and++ server-rendered pages with schema.org Event JSON-LD.++- **Tests.** `events`, `ics`, `consumer`, `outbox`, `reminders`, `agenda`, `brief`, `pages`, `sso`, `account-data` and++ `discovery`, each in its own process on a temporary PGlite database with a mock Network/Events.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,207 @@++# OpenVibe.Today++++> The network's time bus and calendar authority.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `openvibe.today` · **Port:** 5100 · **Service id:** `today` · **Env prefix:** `TODAY`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Today is where a person puts a stream or an event on the network's calendar. It serves the public schedule,++one channel's schedule, a private calendar feed per person, reminders through OpenVibe.Network and a daily brief. It++is also the authority for those events over OpenVibe.Events: a producer writes an event to Today's outbox in the same++transaction as the change, and the calendar moves from scheduled to live to done as OpenVibe.Live says so.++++## Purpose++++One schedule for the whole network:++++- **Public events** — `GET /api/v1/events` and the home page, soonest first. A stream belongs to the caller's Live++ channel; an event is a plain appointment. A followers-only event is visible to its owner and absent from public++ lists.++- **Calendars** — RFC 5545. `/ics/@<username>.ics` is a channel's public calendar; `/ics/<token>.ics` is one person's++ private agenda. Any calendar app can subscribe; rotation invalidates the old URL immediately.++- **Reminders** — one per person and event, sent once through OpenVibe.Network's notification push when due, and never++ for a cancelled event.++- **The brief** — later today, your reminders, and up to three things OpenVibe.Live's public Rundown talked about++ yesterday; one stored brief per person per day.++- **The bus** — `today.event.scheduled|updated|cancelled` published through a transactional outbox with a fixed `evt_`++ id, and `live.stream.started|ended` consumed to move events live and done exactly once.++++## Owns++++| Table | Holds | Migration |++|---|---|---|++| `today_events` | `tev_` + 20 base32 characters, `owner_subject` (`usr_…`), `kind` `stream\|event`, the owner's Live `channel` for a stream, title, description, `starts_at`, `ends_at`, IANA `tz`, `status` `scheduled\|live\|done\|cancelled`, `visibility` `public\|followers`, `url`, `seq` (the ICS SEQUENCE), timestamps | `migrations/0001_initial.sql` |++| `today_idempotency` | (`subject`, `key`) → the first event; a repeated `Idempotency-Key` never makes a second one | `0001_initial.sql` |++| `today_reminders` | (`subject`, `event_id`), `remind_at`, `sent_at`, `created_at` | `0001_initial.sql` |++| `today_feeds` | one private feed token per subject (32 random bytes, base64url) | `0001_initial.sql` |++| `today_outbox` | the fixed `evt_` id, topic, JSON payload, attempts, last error and a claim/backoff lease | `0001_initial.sql` |++| `today_processed` | inbound Events ids already handled, so a replay is a no-op | `0001_initial.sql` |++| `today_briefs` | (`subject`, `day`) → the sections, one per person per day | `0001_initial.sql` |++| `account_data_events` | the receipts openvibe-sdk/account-data keeps for export and deletion deliveries | `0001_initial.sql` |++| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |++++## Does not own++++- **Identity, follows and notifications** — OpenVibe.Network. Today verifies its session tokens and JWKS, reads a++ person's follows with their own token (`GET /api/v1/me/follows`, cached 60 s), and pushes reminders through++ `/internal/notifications/push` with its own client-credentials token.++- **The live stream and its lifecycle** — OpenVibe.Live. Today consumes `live.stream.started|ended`; it never starts or++ stops a stream.++- **The Rundown** — OpenVibe.Live's public `/api/rundown`; Today reads it for the brief and links back to it.++- **Event delivery** — OpenVibe.Events. Today publishes its outbox and subscribes to the live and account topics.++- **Account export and deletion** — OpenVibe.Network (ADR-033). Today answers its part and confirms it.++- **The host, database and Valkey** — OpenVibe.Host.++++## Depends on++++Environment variable names are in [.env.example](.env.example); no value is committed.++++- **OpenVibe.Network** — sign-in (OAuth client `today`, PKCE S256), JWKS, token exchange/refresh/revoke, follows,++ identity resolution and notifications: `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`, `TODAY_AUDIENCE`.++- **OpenVibe.Events** — publishing and subscriptions: `TODAY_EVENTS_SECRET`, `TODAY_EVENTS_URL`,++ `TODAY_EVENTS_ENDPOINT`, `TODAY_EVENTS_INTERVAL_MS`, `TODAY_EVENTS_SCOPE` (the product scope `events.publish`;++ the publisher falls back to the capability `events.event.publish` if the token endpoint refuses it).++- **OpenVibe.Live** — the public Rundown API: `OV_LIVE_INTERNAL_URL`, `TODAY_LIVE_TIMEOUT_MS`.++- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in++ `TODAY_PGLITE_DIR` for development.++- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (the readiness check lists it; the app's per-address limits do++ not require it).++- **Packages** — `openvibe-contracts`, `openvibe-sdk`, `openvibe-shared` (pinned to the same versions as++ OpenVibe.Help).++++## API++++Everything is JSON under `/api/v1`. A person is the `today_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.today` (403 `today.cross_site`).++++| Route | What it does |++|---|---|++| `GET /events?channel=&from=&to=&limit=` | public events (`visibility=public`, not cancelled), soonest first; `from` is now − 2 h, `to` now + 60 days, `limit` ≤ 100; `Cache-Control: public, max-age=60` |++| `GET /events/:id` | one event; a cancelled one answers `status: "cancelled"`; a followers-only one is 404 unless the caller owns it |++| `POST /events` | signed in; `{ kind, title, starts_at, ends_at?, tz, description?, visibility? }`; a stream's `channel` and `url` come from the token, never the body; `Idempotency-Key` (≤ 100 characters) honoured; at most 60 scheduled future events per owner (409 `today.too_many`); the event and its `today.event.scheduled` outbox row commit together |++| `PATCH /events/:id` | owner; title, description, starts_at, ends_at, tz, visibility; `seq` + 1; `today.event.updated`; refused once cancelled or done |++| `DELETE /events/:id` | owner; status `cancelled`, `seq` + 1, `today.event.cancelled`, reminders dropped; idempotent (twice is 200) |++| `POST /events/:id/remind` | signed in; `{ minutes_before?: 0–1440 }` (default 10); upsert |++| `DELETE /events/:id/remind` | remove it (idempotent) |++| `GET /me/reminders` | the caller's upcoming reminders |++| `GET /me/agenda?days=14` | own events, reminded events and the public or followers-only events of followed channels; `follows_unavailable: true` when Network is down |++| `GET /me/feed` | `{ url: "https://openvibe.today/ics/<token>.ics" }`, creating the token on first call |++| `POST /me/feed/rotate` | replace the token; the old URL is 404 at once |++| `GET /me/brief` | today's brief, generated on demand when missing (`?tz=` optional IANA zone) |++++Validation refusals are `400 { code: "today.invalid", field, message }`. Other codes:++`today.too_many`, `today.cross_site`, `today.locked`, `today.not_found`, `today.sign_in_required`.++++### Validation++++- title 1–120 characters; description ≤ 1000++- `starts_at` an ISO time no earlier than 5 minutes ago and no later than 366 days ahead++- `ends_at` after `starts_at` and within 24 hours of it++- `tz` a valid IANA zone (checked with `Intl`)++- `kind` `stream` or `event`; `visibility` `public` or `followers`++++## ICS++++`GET /ics/<token>.ics` is the token owner's agenda (60 days ahead, 7 back), `private, max-age=300`.++`GET /ics/@<username>.ics` is a channel's public events, `public, max-age=300`.++++The calendars follow RFC 5545: CRLF line endings, lines folded at 75 octets without splitting a UTF-8 sequence, TEXT++escaping (`\\`, `\;`, `\,`, newline as `\n`), `PRODID:-//OpenVibe//Today//EN`, `VERSION:2.0`, `CALSCALE:GREGORIAN`,++`X-WR-CALNAME`, and a VEVENT per event with `UID:<id>@openvibe.today`, `DTSTAMP`, UTC `DTSTART`/`DTEND` (no end means++one hour), `SUMMARY`, `DESCRIPTION`, `URL`, `SEQUENCE`, `STATUS:CONFIRMED|CANCELLED` and `LAST-MODIFIED`. A cancelled++event is emitted with `STATUS:CANCELLED` for 7 days so calendars remove it, then dropped.++++A token feed carries the owner's own and reminded events. When this process has read the person's follows (visiting++`/me` or `/api/v1/me/feed` warms the 60 s cache), it also carries their followed channels' public or followers-only++events. The feed request itself carries no person's Network token, so a freshly started process cannot read the follow++graph for that one URL.++++## Events++++**Publish.** Every change writes a `today_outbox` row in the same transaction, with the `evt_` id fixed at that++moment. A job every 5 s claims pending rows with a lease, posts each envelope to OpenVibe.Events `POST /api/v1/events`++with this service's client-credentials token (audience `openvibe.events`), and records the attempt. A failure backs++off (5 s doubling to 5 minutes), stores `last_error`, and never crashes the process. Because the id is fixed and Events++is idempotent on it, a retry or a restart cannot make a second event.++++Envelope: `source: "today"`, `subject: { type: "user", id: <owner_subject> }`, `actor` the same person,++`event_type` `today.event.scheduled|updated|cancelled`, payload++`{ event_id, kind, channel, title, starts_at, ends_at, tz, status, visibility, url, seq }`.++++**Consume.** `POST /internal/events` is loopback only (nginx answers 404 for `/internal`). Deliveries are verified with++the v2 signature over the raw body and the ±300 s replay window (openvibe-sdk/events `parseDelivery`, `requireV2`).++`live.stream.started` makes the channel's scheduled event whose start is within 2 hours of now live;++`live.stream.ended` makes a live event done. The inbound event id is recorded in `today_processed` in the same++transaction as the change, so a replay does nothing. Account export/deletion events go to openvibe-sdk/account-data++over every table that holds a subject. `startSubscriptions()` creates the two live and two account subscriptions at++boot, idempotently, with `events.subscription.manage`.++++## Reminders++++Every 60 s, due reminders (`remind_at <= now`, `sent_at` null, event scheduled or live) are claimed with++`UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. The Network++user id is resolved in batches via `/internal/identity/resolve-batch` (cached), then one notification is pushed:++type `TODAY_REMINDER`, category `stream` for a stream else `system`, title `"<title> starts in N min"` (or++`"<title> is live now"`), `service: "today"`, URL `https://openvibe.today/e/<id>`. A failed push releases the claim++for the next tick.++++## The brief++++`server/brief.js` builds `{ day, sections: [{ id, title, items: [{ text, url, action }] }] }` from:++++1. **Later today** — events in the person's agenda that start today in their zone (action `remind`).++2. **Your reminders** — today's reminders.++3. **Yesterday on OpenVibe** — up to three items from OpenVibe.Live's public Rundown API, omitted when it fails.++++Sections with no items are left out. The body (the sections) is stored in `today_briefs`, one row per subject per day.++A daily job at 06:00 UTC builds briefs for subjects active in the last 30 days (a reminder, an own event or a feed);++v1 does not notify.++++## Pages++++All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with semantic++HTML and stable `td-` ids/classes:++++| Page | What it is |++|---|---|++| `/` | the room's front door: the day's ring (today's events on the reader's clock), what is coming up by day, three scenes |++| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |++| `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) |++| `/me` | agenda, reminders, the feed URL and its rotate form |++| `/brief` | today's brief |++| `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) |++| `/developers` | the API |++| 404 | the not-found page |++++Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in person is `private, no-store`.++The pages are built on the shared room kit (`kit.css`, `room.css`, `showcase.css`, `motion.js`; Today's amber from `openvibe-shared/rooms`) in `server/views/`. `public/js/today.js` adds the reader's own time, the device's zone on the form, copy and confirm; everything works without it. `scripts/seed-dev.js` puts a few events in the local development database.++`robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list the public pages only.++++## 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 5100, loopback; DATABASE_URL or an embedded PGlite in development++```++++The tests boot the app with a mock Network and Events: events (validation, idempotency, cap, followers, cross-site,++transactional outbox), ICS (folding, escaping, cancellation window, token rotation), the consumer (signed, replay, bad++signature), the outbox (fixed id, backoff, restart), reminders (one notification, concurrency, cancelled), agenda++(follows and Network down), brief (sections, Rundown failure, one per day), pages, sign-in, account export/deletion++and discovery.++++## Deploy++++`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and++`deploy/nginx/openvibe.today.conf` (TLS, www → apex, sign-in/API/ICS limits, `/auth/me` on the API zone,++`/ics/` GET only, `/internal` and `/metrics` 404).++++## License++++AGPL-3.0-only. See [LICENSE](LICENSE).nnew file mode 100644@@@ -0,0 +1,33 @@++# 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.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.Today holds++++- Events a person scheduled (the title, description, times, timezone, visibility and the Live channel for a stream).++- Reminders a person set, and the calendar feed token that makes one URL readable by whoever holds it.++- Briefs built from the person's own agenda and the public Rundown.++- Receipts of Events deliveries already handled and of account exports/deletions applied.++- Network session tokens only in httpOnly cookies (`today_at`, `today_rt`); no token is stored in the database,++ rendered into a page or logged.++++A feed token is a bearer capability for one person's calendar. The URL is shown only to its owner, rotation makes the++old URL a 404 at once, and the token is never exported with an account export.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,57 @@++{++ "repository": "OpenVibers/OpenVibe.Today",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Today: the network's time bus and calendar authority — one schedule for streams and events, reminders through Network, private and public ICS feeds, and a daily brief",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5100",++ "domain": "openvibe.today",++ "contracts": "openvibe-contracts v0.213.0",++ "sdk": "openvibe-sdk v0.64.0",++ "shared": "openvibe-shared v4.12.0",++ "packages": [++ "openvibe-contracts v0.213.0",++ "openvibe-sdk v0.64.0",++ "openvibe-shared v4.12.0"++ ],++ "summary": "OpenVibe.Today is the network's time bus and calendar authority. A person schedules a stream (on their Live channel) or a plain event; the public schedule and a channel's ICS feed are readable without an account, a private token feed is one URL per person and rotation invalidates the old one, and reminders arrive through OpenVibe.Network. Events move from scheduled to live and done as OpenVibe.Live says so over OpenVibe.Events, while the daily brief is built from the agenda, today's reminders and OpenVibe.Live's public Rundown.",++ "works": [++ "Public events API and pages: GET /api/v1/events, one event with schema.org Event JSON-LD, a channel's schedule and its ICS feed",++ "Scheduling: POST/PATCH/DELETE /api/v1/events, validation, Idempotency-Key, a cap of 60 scheduled future events per owner, followers-only events, and a same-origin rule for session writes",++ "ICS: RFC 5545 with CRLF, 75-octet UTF-8-safe folding, TEXT escaping, UID/SEQUENCE/STATUS/LAST-MODIFIED, cancelled events emitted for 7 days then dropped, private token feeds and rotation",++ "Reminders: upsert/remove/list, a 60 s job that claims a due row atomically and sends one Network notification (TODAY_REMINDER)",++ "Agenda: own events, reminded events and the public or followers-only events of followed channels, with follows_unavailable when Network is down",++ "The brief: later today, your reminders and up to three Rundown items; one stored brief per subject per day and a 06:00 UTC job",++ "Events: a transactional outbox with a fixed evt_ id, backoff and a claim lease; live.stream.started|ended consumed exactly once through today_processed",++ "Account export and deletion over every table that holds a subject (the feed token itself is never exported)",++ "Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies), readiness, Prometheus metrics, crawl artifacts and deploy files"++ ],++ "notYet": [++ "Notifications for the daily brief (v1 builds and stores it without notifying)",++ "A watched-event reminder scheduler beyond the one reminder per person and event",++ "Followed channels in the private ICS token feed are best-effort: the feed request has no person token, so they appear only while this process remembers the last follows read",++ "Timezone selection persisted per person (the brief accepts tz; otherwise it is UTC)"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "events API",++ "scheduling pages",++ "ICS feeds",++ "reminders",++ "agenda with follows",++ "daily brief",++ "Events outbox and consumer",++ "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-today.service and deploy/nginx/openvibe.today.conf",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,125 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.today — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.today.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5100, systemd unit openvibe-today) renders every page and serves++## the API and the ICS feeds; nginx terminates TLS, redirects www → apex, rate-limits sign-in and the API, and++## compresses text.++##++## No proxy_cache: the app decides cacheability per response (public for the schedule and a channel's calendar;++## private, no-store for everything personal; private/public 5 minutes for the ICS feeds). Never log request++## bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=today_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=today_api:10m rate=5r/s;++limit_req_zone $binary_remote_addr zone=today_forms:10m rate=1r/s;++limit_req_zone $binary_remote_addr zone=today_ics:10m rate=2r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.today www.openvibe.today;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.today$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.today;++++ ssl_certificate /etc/letsencrypt/live/openvibe.today/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.today/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.today$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.today;++++ ssl_certificate /etc/letsencrypt/live/openvibe.today/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.today/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.today.access.log;++ error_log /var/log/nginx/openvibe.today.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 text/calendar;++++ 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=today_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5100;++ }++++ location /auth/ {++ limit_req zone=today_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5100;++ }++++ # ── API ─────────────────────────────────────────────────++ location /api/ {++ limit_req zone=today_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5100;++ }++++ # ── ICS feeds: a calendar client only ever GETs them ────++ location /ics/ {++ limit_except GET { deny all; }++ limit_req zone=today_ics burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5100;++ }++++ # ── Forms and other writes ──────────────────────────────++ location ~ ^/(schedule|me/feed/rotate|e/[A-Za-z0-9_]+/(remind|remind/remove|cancel))$ {++ limit_req zone=today_forms burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5100;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5100/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5100/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:5100;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,32 @@++[Unit]++Description=OpenVibe.Today — the network's time bus and calendar authority.++# Network (sign-in, JWKS, follows and notifications) should be up first, but Today degrades honestly without it:++# the public schedule and ICS feeds 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.today++EnvironmentFile=/etc/openvibe/today.env++Environment=NODE_ENV=production++Environment=PORT=5100++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.today/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.Today'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,125 @@++-- phase: expand++-- OpenVibe.Today: the network's time bus and calendar authority. Every table here holds events a++-- person scheduled, the reminders other people set on them, the calendar feed tokens a person owns,++-- the outbox that tells OpenVibe.Events what changed, the receipts of inbound deliveries already++-- handled and the briefs built from a person's day. Nothing written here is a credential: a feed++-- token is a bearer capability for one calendar, and it is never logged.++++CREATE TABLE today_events (++ id text COLLATE "C" PRIMARY KEY, -- tev_ + 20 base32 characters++ owner_subject text COLLATE "C" NOT NULL, -- usr_…; the person who scheduled it++ kind text COLLATE "C" NOT NULL, -- stream | event++ channel text COLLATE "C", -- the owner's Live username, for a stream++ title text NOT NULL,++ description text,++ starts_at timestamptz NOT NULL,++ ends_at timestamptz,++ tz text NOT NULL, -- IANA zone, validated with Intl++ status text COLLATE "C" NOT NULL DEFAULT 'scheduled', -- scheduled | live | done | cancelled++ visibility text COLLATE "C" NOT NULL DEFAULT 'public', -- public | followers++ url text,++ seq integer NOT NULL DEFAULT 0, -- the ICS SEQUENCE, bumped on every change++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT today_events_kind CHECK (kind IN ('stream', 'event')),++ CONSTRAINT today_events_status CHECK (status IN ('scheduled', 'live', 'done', 'cancelled')),++ CONSTRAINT today_events_visibility CHECK (visibility IN ('public', 'followers'))++);++CREATE INDEX today_events_by_channel ON today_events (channel, starts_at);++CREATE INDEX today_events_by_owner ON today_events (owner_subject, starts_at);++CREATE INDEX today_events_by_status ON today_events (status, starts_at);++++-- A repeated Idempotency-Key on POST returns the first event, never a second one. event_id is null++-- only for the instant between claiming the key and inserting the event, inside one transaction.++CREATE TABLE today_idempotency (++ subject text COLLATE "C" NOT NULL,++ key text COLLATE "C" NOT NULL,++ event_id text COLLATE "C",++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (subject, key)++);++++CREATE TABLE today_reminders (++ subject text COLLATE "C" NOT NULL,++ event_id text COLLATE "C" NOT NULL,++ remind_at timestamptz NOT NULL,++ sent_at timestamptz,++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (subject, event_id)++);++CREATE INDEX today_reminders_due ON today_reminders (remind_at) WHERE sent_at IS NULL;++++CREATE TABLE today_feeds (++ subject text COLLATE "C" PRIMARY KEY,++ token text COLLATE "C" UNIQUE NOT NULL, -- 32 random bytes, base64url++ created_at timestamptz NOT NULL DEFAULT now()++);++++-- The fixed Events evt_ id is chosen when the row is written, so a retry never creates a second event.++CREATE TABLE today_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 today_outbox_pending ON today_outbox (next_attempt_at) WHERE published_at IS NULL;++++-- Inbound Events ids already handled: a replay is a no-op.++CREATE TABLE today_processed (++ event_id text COLLATE "C" PRIMARY KEY,++ processed_at timestamptz NOT NULL DEFAULT now()++);++++CREATE TABLE today_briefs (++ subject text COLLATE "C" NOT NULL,++ day date NOT NULL,++ body jsonb NOT NULL, -- the sections++ created_at timestamptz NOT NULL DEFAULT now(),++ PRIMARY KEY (subject, day)++);++++-- 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);nnew file mode 100644@@@ -0,0 +1,1407 @@++{++ "name": "openvibe-today",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-today",++ "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.229.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.229.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",++ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==",++ "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,36 @@++{++ "name": "openvibe-today",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Today \u2014 the network's time bus and calendar authority.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "test": "node test/run.js",++ "test:pg": "TODAY_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.229.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,167 @@++/* OpenVibe.Today — the room's own pieces, on the shared room kit (kit.css, room.css, showcase.css). The page wears the++ person's theme; --room-* is Today's amber (rooms.js). Phone first: lists are one column, the agenda's side panel++ drops under it; from 980 px the agenda has its side column. */++*, *::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; }++.td-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.td-page-pad { padding-top: clamp(20px, 4vw, 40px); }++.td-narrow { max-width: 720px; }++.td a:not([class]) { color: var(--room-strong); }++.td h2 { margin: 0 0 10px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }++:where(.td) .ovk-i { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }++.td-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }++.td-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.td-kicker a { color: inherit; }++.td-lede { margin: 8px 0 0; max-width: 680px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.td-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }++.td-page-h h1, .td-event-h h1 { margin: 0; font-size: clamp(1.7rem, 4.2vw, 2.4rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.1; }++.td-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0 0; }++.td-acts .ovk-btn .ovk-i, .td-set .ovk-i { width: 1.05em; height: 1.05em; }++.td-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }++.td-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; color: var(--room-strong); }++.td-more .ovk-i { width: 1em; height: 1em; }++.td section + section { margin-top: 28px; }++.td-note { margin: 16px 0 0; padding: 10px 14px; border-radius: 12px; font-size: .9rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }++.td-note.is-bad { background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent); color: var(--text-primary, #e6edf7); }++++/* Date tile and pills. */++.td-tile { flex: none; display: grid; justify-items: center; align-content: center; width: 52px; height: 56px; border-radius: 14px; background: var(--room-fill); color: var(--room-on); line-height: 1; }++.td-tile-m { font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }++.td-tile-d { margin-top: 3px; font-size: 1.35rem; font-weight: 800; letter-spacing: -.02em; }++.td-pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; vertical-align: middle; white-space: nowrap; }++.td-pill.is-live { background: var(--live-red, #e11d48); color: #fff; }++.td-pill.is-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: td-pulse 1.4s ease-in-out infinite; }++.td-pill.is-off { background: color-mix(in srgb, var(--text-muted, #7386a3) 22%, transparent); color: var(--text-secondary, #96a7c2); }++.td-pill.is-done { background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); color: var(--text-muted, #7386a3); }++.td-tag { display: inline-flex; padding: 1px 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; color: var(--text-secondary, #96a7c2); border: 1px solid var(--border, #1f2d47); white-space: nowrap; }++.td-tag.is-remind { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); color: var(--room-strong); }++@keyframes td-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }++++/* Day-grouped lists. */++.td-day + .td-day { margin-top: 18px; }++.td-day-h { position: sticky; top: calc(var(--ovnav-real-h, 56px) + 4px); z-index: 1; margin: 0 0 8px; padding: 6px 0; font-size: .78rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: var(--bg-primary, #0a0f1c); }++.td-evs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }++.td-ev-link { display: flex; align-items: center; gap: 14px; padding: 10px 14px 10px 10px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s, transform .15s; }++.td-ev-link:hover, .td-ev-link:focus-visible { border-color: var(--room-accent); outline: none; }++.td-ev-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }++.td-ev-title { font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }++.td-ev-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: .84rem; color: var(--text-muted, #7386a3); }++.td-ev-meta time { font-weight: 700; color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }++.td-ev-end { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }++.td-ev-cancelled .td-ev-title, .td-ev-done .td-ev-title { color: var(--text-muted, #7386a3); }++.td-ev-cancelled .td-ev-title { text-decoration: line-through; }++.td-ev-live .td-ev-link { border-color: color-mix(in srgb, var(--live-red, #e11d48) 55%, var(--border, #1f2d47)); }++@media (max-width: 560px) { .td-ev-end { display: none; } .td-ev-live .td-ev-end, .td-ev-cancelled .td-ev-end { display: flex; } }++.td-empty { display: grid; justify-items: center; gap: 10px; padding: 36px 20px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); text-align: center; color: var(--text-secondary, #96a7c2); }++.td-empty > .ovk-i { width: 40px; height: 40px; color: var(--room-accent); stroke-width: 1.6; }++.td-empty p { margin: 0; max-width: 460px; line-height: 1.5; }++.td-empty h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #e6edf7); }++.td-signin { margin-top: 24px; }++++/* The day's ring (the home's motif): 24 hours, today's events as dots, now as a hand. */++.td-ring { position: relative; width: min(100%, 320px); aspect-ratio: 1; margin: 0 auto; container-type: inline-size; }++.td-ring-face { position: absolute; inset: 6%; border-radius: 50%; background: radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--room-accent) 16%, transparent), transparent 62%); border: 1.5px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); }++.td-ring-tick { position: absolute; left: 50%; top: 50%; width: 2px; height: 5%; margin-left: -1px; border-radius: 2px; background: color-mix(in srgb, var(--text-muted, #7386a3) 60%, transparent); transform: rotate(var(--a)) translateY(-41cqw); transform-origin: 50% 0; }++.td-ring-tick.is-major { height: 8%; width: 3px; background: var(--room-accent); }++.td-ring-dot { position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; background: var(--room-accent); box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c), 0 0 18px color-mix(in srgb, var(--room-accent) 60%, transparent); transform: rotate(var(--a)) translateY(-35cqw) rotate(calc(-1 * var(--a))); }++.td-ring-dot.is-live { background: var(--live-red, #e11d48); }++.td-ring-dot.is-sample { opacity: .45; box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c); }++html.ov-motion .td-ring-dot { animation: td-dot-in .6s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i) * 90ms + 200ms); }++@keyframes td-dot-in { from { opacity: 0; transform: rotate(var(--a)) translateY(-20cqw) rotate(calc(-1 * var(--a))) scale(.4); } }++.td-ring-hand { position: absolute; left: 50%; bottom: 50%; width: 3px; height: 34%; margin-left: -1.5px; border-radius: 3px; background: linear-gradient(to top, transparent, var(--text-primary, #e6edf7)); transform-origin: 50% 100%; transform: rotate(var(--now)); }++.td-ring-hub { position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px; border-radius: 50%; background: var(--text-primary, #e6edf7); box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c); }++.td-ring-label { position: absolute; font-size: .66rem; font-weight: 800; letter-spacing: .06em; color: var(--text-muted, #7386a3); }++.td-ring-l0 { left: 50%; top: 0; translate: -50% 0; }++.td-ring-l6 { right: 0; top: 50%; translate: 0 -50%; }++.td-ring-l12 { left: 50%; bottom: 0; translate: -50% 0; }++.td-ring-l18 { left: 0; top: 50%; translate: 0 -50%; }++++/* The home's scenes. */++.td-demo { position: relative; display: grid; gap: 12px; }++.td-demo-card { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.td-demo-card > span:nth-child(2), .td-demo-toast > span { display: grid; gap: 2px; min-width: 0; flex: 1; }++.td-demo-card small, .td-demo-toast small { color: var(--text-muted, #7386a3); font-size: .8rem; }++.td-demo-card.is-form { flex-direction: column; align-items: flex-start; gap: 4px; }++.td-demo-k { font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--room-strong); }++.td-demo-btn { flex: none; padding: 6px 14px; border-radius: 999px; background: var(--room-fill); color: var(--room-on); font-size: .8rem; font-weight: 800; }++.td-demo-card.is-form .td-demo-btn { margin-top: 8px; }++.td-demo-toast { display: flex; align-items: center; gap: 12px; margin-left: 10%; padding: 12px 14px; border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); box-shadow: 0 16px 40px rgba(0, 0, 0, .3); position: relative; overflow: hidden; }++.td-demo-toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--room-accent); }++.td-demo-toast > .ovk-i { flex: none; width: 36px; height: 36px; padding: 8px; border-radius: 12px; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 16%, transparent); }++html.ov-motion .td-demo-toast { animation: td-toast 4.5s ease-in-out infinite; }++@keyframes td-toast { 0%, 18% { opacity: 0; transform: translateY(10px); } 28%, 82% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(-6px); } }++.td-demo-cal { padding: 12px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.td-cal-head, .td-cal-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++.td-cal-head span { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); text-align: center; }++.td-cal-grid { grid-template-rows: repeat(6, 22px); margin-top: 8px; background: repeating-linear-gradient(to bottom, transparent 0 21px, var(--border, #1f2d47) 21px 22px); }++.td-cal-grid i { grid-column: var(--c); grid-row: var(--r) / span 2; padding: 3px 6px; border-radius: 6px; font-style: normal; font-size: .72rem; font-weight: 700; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--text-primary, #e6edf7); }++.td-cal-grid i.is-room { background: var(--room-fill); color: var(--room-on); }++++/* One event. */++.td-event-h { display: flex; align-items: flex-start; gap: 16px; }++.td-event-h .td-tile { width: 64px; height: 70px; }++.td-event-h .td-tile-d { font-size: 1.7rem; }++.td-when { margin: 10px 0 0; font-size: 1.05rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }++.td-local { margin: 4px 0 0; font-size: .9rem; color: var(--text-muted, #7386a3); }++.td-desc { margin: 20px 0 0; max-width: 720px; line-height: 1.6; white-space: pre-line; }++.td-event.is-over h1 { color: var(--text-secondary, #96a7c2); }++.td-inline { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }++.td-remind select, .td-form select, .td-form input, .td-form textarea, .td-feed input { min-height: 40px; padding: 6px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: .92rem; }++.td-set { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--room-strong); }++.td-owner { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin: 22px 0 0; padding: 12px 14px; border-radius: 14px; border: 1px dashed var(--border, #1f2d47); }++.td-owner p { margin: 0; flex: 1 1 260px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }++.td-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin: 24px 0 0; }++.td-facts > div { padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.td-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.td-facts dd { margin: 4px 0 0; font-weight: 650; }++++/* Cards: the agenda's side panel and the brief. */++.td-card { padding: 16px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); }++.td-card h2 { display: flex; align-items: center; gap: 8px; }++.td-card h2 .ovk-i { width: 18px; height: 18px; color: var(--room-strong); }++.td-me-grid { display: grid; gap: 20px; margin-top: 24px; grid-template-columns: minmax(0, 1fr); }++.td-side { display: grid; gap: 14px; align-content: start; }++@media (min-width: 980px) { .td-me-grid { grid-template-columns: minmax(0, 1fr) 340px; } .td-side { position: sticky; top: calc(var(--ovnav-real-h, 56px) + 12px); } }++.td-rems { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.td-rems li { display: grid; gap: 2px; }++.td-rems a { font-weight: 700; color: inherit; text-decoration: none; }++.td-rems a:hover { text-decoration: underline; }++.td-rems small { color: var(--text-muted, #7386a3); }++.td-feed { display: flex; gap: 8px; margin-top: 10px; }++.td-feed input { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .78rem; }++.td-brief-grid { display: grid; gap: 14px; margin-top: 24px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }++.td-brief-sec ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }++.td-brief-sec li { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; line-height: 1.45; }++.td-brief-sec li > a:first-child { color: inherit; text-decoration: none; }++.td-brief-sec li > a:first-child:hover { text-decoration: underline; }++.td-chip { flex: none; padding: 3px 10px; border-radius: 999px; font-size: .74rem; font-weight: 800; text-decoration: none; background: var(--room-fill); color: var(--room-on); }++.td-brief > .td-fine { margin-top: 18px; }++++/* The schedule form. */++.td-form { display: grid; gap: 16px; margin-top: 20px; }++.td-field { display: grid; gap: 6px; }++.td-field > span { font-size: .84rem; font-weight: 750; }++.td-field > span i { font-style: normal; font-weight: 500; color: var(--text-muted, #7386a3); }++.td-field small { color: var(--text-muted, #7386a3); font-size: .78rem; }++.td-row { display: grid; gap: 12px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }++@media (max-width: 520px) { .td-row { grid-template-columns: minmax(0, 1fr); } }++.td-opts { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); margin: 0; padding: 0; border: 0; }++.td-opts legend { margin-bottom: 6px; font-size: .84rem; font-weight: 750; }++.td-opt { position: relative; display: flex; gap: 10px; align-items: flex-start; padding: 12px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); cursor: pointer; }++.td-opt input { margin: 3px 0 0; accent-color: var(--room-accent); }++.td-opt span { display: grid; gap: 2px; }++.td-opt small { color: var(--text-muted, #7386a3); font-size: .8rem; }++.td-opt:has(input:checked) { border-color: var(--room-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--room-accent) 30%, transparent); }++.td-opt:has(input:focus-visible) { outline: 2px solid var(--room-accent); outline-offset: 2px; }++++/* The API page. */++.td-eps { display: grid; gap: 10px; margin: 0; }++.td-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.td-eps dt code { font: 600 .88rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }++.td-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++.td section > h2 { margin-top: 8px; }++++@media (prefers-reduced-motion: reduce) { .td-pill.is-live::before, .td-demo-toast { animation: none !important; } .td-ring-dot { animation: none !important; } }++html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ring-dot { animation: none !important; }nnew file mode 100644@@@ -0,0 +1,66 @@++/* OpenVibe.Today: small things the pages are complete without.++ * - an event's time in the reader's own zone beside the event's own (when they differ);++ * - the schedule form starts in the device's time zone;++ * - copy buttons; a confirm before cancelling or replacing the feed link;++ * - the day's ring's hand follows the clock. */++(function () {++ 'use strict';++ var zone = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();++ function fmt(d, opts) { try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e) { return d.toISOString(); } }++++ function run() {++ // "Your time" on an event page.++ document.querySelectorAll('[data-local="range"]').forEach(function (el) {++ var a = new Date(el.getAttribute('data-start')), b = new Date(el.getAttribute('data-end'));++ if (isNaN(a)) return;++ var mine = fmt(a, { weekday: 'short', month: 'short', day: 'numeric' }) + ' · ' + fmt(a, { hour: 'numeric', minute: '2-digit' }) + ' – ' + fmt(b, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });++ var page = document.querySelector('.td-when');++ if (page && page.textContent.indexOf(mine.split(' · ')[1]) !== -1) return;++ el.textContent = 'Your time: ' + mine;++ el.hidden = false;++ });++ // List times in the reader's zone.++ document.querySelectorAll('time[data-local="time"]').forEach(function (el) {++ var d = new Date(el.getAttribute('datetime'));++ if (!isNaN(d)) { el.title = el.textContent; el.textContent = fmt(d, { hour: 'numeric', minute: '2-digit' }); }++ });++ document.querySelectorAll('time[data-local="ago"]').forEach(function (el) {++ var d = new Date(el.getAttribute('datetime'));++ if (!isNaN(d)) el.textContent = fmt(d, { dateStyle: 'medium', timeStyle: 'short' });++ });++ // The schedule form starts in this device's zone.++ document.querySelectorAll('select[data-tz]').forEach(function (sel) {++ if (sel.value !== 'UTC' || sel.dataset.picked) return;++ if (![].some.call(sel.options, function (o) { return o.value === zone; })) {++ var o = document.createElement('option'); o.value = zone; o.textContent = zone.replace(/_/g, ' ');++ sel.insertBefore(o, sel.firstChild);++ }++ sel.value = zone;++ sel.dataset.picked = '1';++ });++ // Copy.++ document.querySelectorAll('[data-copy]').forEach(function (btn) {++ var src = document.querySelector(btn.getAttribute('data-copy'));++ if (!src || !navigator.clipboard) return;++ btn.hidden = false;++ btn.addEventListener('click', function () {++ navigator.clipboard.writeText(src.value || src.textContent || '').then(function () {++ var was = btn.textContent; btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = was; }, 1400);++ }, function () { src.select && src.select(); });++ });++ });++ // Confirm before something that cannot be undone.++ document.querySelectorAll('form[data-confirm]').forEach(function (f) {++ f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); });++ });++ // The ring on the reader's own clock: each dot at its local hour, the hand at the local time now.++ document.querySelectorAll('[data-ring]').forEach(function (ring) {++ var deg = function (d) { return ((d.getHours() + d.getMinutes() / 60) / 24 * 360).toFixed(1) + 'deg'; };++ ring.querySelectorAll('[data-at]').forEach(function (dot) { var d = new Date(dot.getAttribute('data-at')); if (!isNaN(d)) dot.style.setProperty('--a', deg(d)); });++ var tick = function () { ring.style.setProperty('--now', deg(new Date())); };++ tick(); if (!ring.dataset.ticking) { ring.dataset.ticking = '1'; setInterval(tick, 60000); }++ });++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();++ document.addEventListener('ov:boost:load', run);++})();nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++/**++ * Development only: a few events in the local PGlite database, so the pages have something to show++ * (node scripts/seed-dev.js; refuses to run with NODE_ENV=production or a DATABASE_URL).++ */++const { load } = require('../server/config');++const { openDb } = require('../server/db');++++(async () => {++ if (process.env.NODE_ENV === 'production' || process.env.DATABASE_URL) throw new Error('seed-dev is for the local development database only');++ const config = load(process.env);++ const db = await openDb(config, { log: { log() {}, warn() {}, error: console.error } });++ const now = Date.now();++ const h = (n) => new Date(now + n * 3600e3).toISOString();++ const rows = [++ ['tev_DEVSEED0000000000001', 'stream', 'goosely', 'Night owls: Seattle walk', 'A slow walk downtown, chat picks the turns.', h(-0.5), h(1.5), 'America/Los_Angeles', 'live'],++ ['tev_DEVSEED0000000000002', 'stream', 'carol', 'Robot build, part 2', 'Wiring the arm and the first test moves.', h(5), h(7), 'America/Los_Angeles', 'scheduled'],++ ['tev_DEVSEED0000000000003', 'stream', 'JapaneseOldGuy', 'Morning workshop', 'Blue cylinder, round two.', h(20), h(22), 'Asia/Tokyo', 'scheduled'],++ ['tev_DEVSEED0000000000004', 'event', 'kiwi', 'Speedrun Sunday: community race', 'Bring your best route.', h(50), h(53), 'Europe/London', 'scheduled'],++ ['tev_DEVSEED0000000000005', 'stream', 'carol', 'Q&A and parts haul', null, h(74), h(75.5), 'America/Los_Angeles', 'cancelled'],++ ];++ for (const r of rows) {++ await db.prepare(`INSERT INTO today_events (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url)++ VALUES (?, 'usr_01DEVSEED0000000000000000', ?, ?, ?, ?, ?, ?, ?, ?, 'public', ?) ON CONFLICT (id) DO UPDATE SET starts_at = EXCLUDED.starts_at, ends_at = EXCLUDED.ends_at, status = EXCLUDED.status`)++ .run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[2] ? `https://openvibe.live/@${r[2]}` : null);++ }++ console.log(`seeded ${rows.length} events`);++ process.exit(0);++})().catch((e) => { console.error(e.message); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * A person's agenda: their own events, the events they set a reminder on, and the public or followers-only events++ * of the channels they follow. Follows come from OpenVibe.Network with the person's own token (server/identity/++ * follows.js, cached 60 s); when Network is down the agenda still answers with own and reminded events and says++ * follows_unavailable: true.++ */++const { wire } = require('./events/store');++++function createAgenda({ s, follows }) {++ const db = s.db;++++ async function forSubject({ subject, token, days = 14, now = s.now() }) {++ const from = new Date(now - 2 * 60 * 60_000).toISOString();++ const to = new Date(now + days * 24 * 60 * 60_000).toISOString();++ let followed = [];++ let unavailable = false;++ if (token) {++ const r = await follows.forSubject(subject, token);++ followed = r.follows;++ unavailable = r.unavailable;++ } else {++ unavailable = true;++ }++ const rows = await db.many(++ `SELECT * FROM today_events e++ WHERE e.starts_at >= $1::timestamptz AND e.starts_at <= $2::timestamptz AND e.status <> 'cancelled'++ AND (e.owner_subject = $3++ OR EXISTS (SELECT 1 FROM today_reminders r WHERE r.subject = $3 AND r.event_id = e.id)++ OR (e.owner_subject = ANY($4::text[]) AND e.visibility IN ('public', 'followers')))++ ORDER BY e.starts_at ASC, e.id ASC`,++ [from, to, subject, followed],++ );++ const reminded = new Set((await db.many('SELECT event_id FROM today_reminders WHERE subject = $1', [subject])).map((r) => r.event_id));++ return {++ from, to, days,++ events: rows.map((r) => ({ ...wire(r), own: r.owner_subject === subject, reminded: reminded.has(r.id) })),++ follows_unavailable: unavailable,++ };++ }++++ /** For the ICS token feed and the brief: the same set, without the Network flag. */++ async function windowFor({ subject, token, fromMs, toMs, now = s.now() }) {++ const from = new Date(fromMs).toISOString();++ const to = new Date(toMs).toISOString();++ let followed = [];++ if (token) followed = (await follows.forSubject(subject, token)).follows;++ else followed = follows.peek(subject);++ return await db.many(++ `SELECT * FROM today_events e++ WHERE e.starts_at >= $1::timestamptz AND e.starts_at <= $2::timestamptz++ AND (e.status <> 'cancelled' OR e.updated_at >= $3::timestamptz)++ AND (e.owner_subject = $4++ OR EXISTS (SELECT 1 FROM today_reminders r WHERE r.subject = $4 AND r.event_id = e.id)++ OR (e.owner_subject = ANY($5::text[]) AND e.visibility IN ('public', 'followers')))++ ORDER BY e.starts_at ASC, e.id ASC`,++ [from, to, new Date(now - 7 * 24 * 60 * 60_000).toISOString(), subject, followed],++ );++ }++++ return { forSubject, windowFor };++}++++module.exports = { createAgenda };nnew file mode 100644@@@ -0,0 +1,183 @@++'use strict';++++/**++ * OpenVibe.Today — Express app factory; server/index.js listens, tests build their own instance with a temp++ * database and a mock Network.++ *++ * /, /@:username, /e/:id, /me, /brief, /schedule, /developers the pages (http/pages.js)++ * /ics/* RFC 5545 calendars (http/pages.js, ics.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)++ *++ * The jobs (outbox publisher, reminders, daily brief) are made here but started by server/index.js (and never++ * under TODAY_DRILL); tests drive them explicitly.++ */++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 { createEventsStore } = require('./events/store');++const { createEventsConsumer } = require('./events/consumer');++const { createPublisher } = require('./events/publisher');++const { createFeeds } = require('./feeds');++const { createFollows } = require('./identity/follows');++const { createAgenda } = require('./agenda');++const { createBrief } = require('./brief');++const { createNotifier } = require('./notifications');++const { createReminders } = require('./reminders');++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), 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 events = createEventsStore({ s, log });++ const feeds = createFeeds({ s });++ const follows = createFollows({ config, fetchImpl, now: s.now, log });++ const agenda = createAgenda({ s, follows });++ const brief = createBrief({ config, s, agenda, fetchImpl, log });++ const notifier = createNotifier({ config, s, fetchImpl, log });++ const reminders = createReminders({ config, s, events, notifier, log });++ const publisher = createPublisher({ config, s, fetchImpl, log });++++ // Account export and deletion (ADR-033). The sender posts to Network's internal export/deletion 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: Today cannot answer account events'); });++ const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });++++ const ctx = {++ config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, publisher,++ accountData, accountSend, consumer, log,++ };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'today', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'today', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'today', 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-today', name: 'OpenVibe.Today', 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: 'today', service: 'today', host: 'openvibe.today', name: 'OpenVibe.Today', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only) ──────++ // live.stream.started|ended and the account topics; the consumer reads the raw body itself, so it is mounted++ // before any body parser. nginx answers 404 for /internal/.++ app.use('/internal/events', consumer.router);++++ // ── 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.TODAY_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.TODAY_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.Today]', 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,85 @@++'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).++ *++ * This is Help's shim, with the service's name changed: 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.Today] 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.Today] 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.Today] 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,261 @@++'use strict';++++/**++ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `today`.++ *++ * 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 Help's SSO module with the client id and cookie names changed. The person's Network access++ * token lives in an httpOnly cookie (today_at) no script can read; the refresh token (today_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 = 'today_at';++const REFRESH_COOKIE = 'today_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'today_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 Today 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 of them 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.Today] 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.Today?</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,186 @@++'use strict';++++/**++ * The brief (v1, text): what is left of today, the reminders due today, and up to three things from OpenVibe.Live's++ * public Rundown API. `buildBrief(subject, { now, tz })` returns `{ day, sections }`; `getOrBuild` stores one brief++ * per subject per day in today_briefs. The daily job at 06:00 UTC builds briefs for subjects active in the last++ * 30 days (a reminder, an own event or a feed); v1 does not notify.++ *++ * A brief built for a request with the person's token can include the channels they follow; the daily job has no++ * token and therefore uses the same agenda with own and reminded events only.++ */++const { isIanaZone } = require('./events/validate');++++function dayIn(tz, ms) {++ try {++ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(ms));++ const get = (t) => parts.find((p) => p.type === t).value;++ return `${get('year')}-${get('month')}-${get('day')}`;++ } catch {++ return new Date(ms).toISOString().slice(0, 10);++ }++}++++function timeIn(tz, ms) {++ try {++ return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(new Date(ms));++ } catch {++ return new Date(ms).toISOString().slice(11, 16) + ' UTC';++ }++}++++function createBrief({ config, s, agenda, fetchImpl = globalThis.fetch, log = console }) {++ const db = s.db;++ const base = String(config.baseUrl).replace(/\/+$/, '');++++ async function rundownItems(now) {++ try {++ const res = await fetchImpl(`${config.live.internalUrl.replace(/\/+$/, '')}/api/rundown`, {++ headers: { Accept: 'application/json' },++ signal: AbortSignal.timeout(config.live.timeoutMs),++ });++ if (!res.ok) throw new Error(`rundown ${res.status}`);++ const data = await res.json().catch(() => null);++ if (!data || typeof data !== 'object') throw new Error('rundown: no body');++ const yesterday = dayIn('UTC', now - 24 * 60 * 60_000);++ const isYesterday = (v) => {++ if (!v) return true; // a fixture without a date is still a Rundown item++ const d = new Date(v);++ return Number.isNaN(d.getTime()) || dayIn('UTC', d.getTime()) === yesterday;++ };++ const items = [];++ const seen = new Set();++ const push = (text, url, at) => {++ if (items.length >= 3 || !text || !url || seen.has(url) || !isYesterday(at)) return;++ seen.add(url);++ items.push({ text: String(text).slice(0, 300), url, action: null });++ };++ const lead = data.lead;++ if (lead) {++ const who = lead.user && (lead.user.display_name || lead.user.username);++ const what = lead.take || lead.title || lead.topic && lead.topic.title;++ const url = lead.topic && lead.topic.slug++ ? `https://openvibe.live/rundown/t/${encodeURIComponent(lead.topic.slug)}`++ : `https://openvibe.live/rundown/e/${encodeURIComponent(lead.stream_id)}`;++ push(`${what || 'A moment'}${who ? ` — ${who}` : ''}`, url, lead.said_at);++ }++ for (const stream of Array.isArray(data.streams) ? data.streams : []) {++ const ep = stream.episode || stream;++ const who = (ep.user || stream.user) && ((ep.user || stream.user).display_name || (ep.user || stream.user).username);++ const what = ep.title || ep.logline || stream.talking_about;++ const id = ep.stream_id || stream.stream_id || stream.id;++ if (id) push(`${what || 'An episode'}${who ? ` — ${who}` : ''}`, `https://openvibe.live/rundown/e/${encodeURIComponent(id)}`, ep.aired_at || stream.said_at);++ }++ for (const topic of Array.isArray(data.trending) ? data.trending : []) {++ if (!topic.slug) continue;++ const extra = topic.streamers ? ` — ${topic.streamers} streamer${Number(topic.streamers) === 1 ? '' : 's'}` : '';++ push(`${topic.headline || topic.title}${extra}`, `https://openvibe.live/rundown/t/${encodeURIComponent(topic.slug)}`, topic.last_seen_at);++ }++ return items;++ } catch (err) {++ log.warn(`[OpenVibe.Today] Rundown unavailable for the brief: ${(err && err.message) || err}`);++ return null;++ }++ }++++ async function buildBrief(subject, { now = s.now(), tz = 'UTC', token = null } = {}) {++ if (!isIanaZone(tz)) tz = 'UTC';++ const day = dayIn(tz, now);++ const sections = [];++++ const agendaOut = await agenda.forSubject({ subject, token, days: 2, now });++ const today = agendaOut.events.filter((e) => (e.status === 'scheduled' || e.status === 'live') && dayIn(tz, new Date(e.starts_at).getTime()) === day);++ const later = [];++ for (const e of today) {++ const when = e.status === 'live' ? 'live now' : `at ${timeIn(tz, new Date(e.starts_at).getTime())}`;++ later.push({ text: `${e.title} ${when}`, url: `${base}/e/${e.id}`, action: 'remind' });++ }++ if (later.length) sections.push({ id: 'later-today', title: 'Later today', items: later });++++ const reminderRows = await db.many(++ `SELECT r.remind_at, e.* FROM today_reminders r JOIN today_events e ON e.id = r.event_id++ WHERE r.subject = $1 AND e.status IN ('scheduled', 'live') ORDER BY r.remind_at ASC`,++ [subject],++ );++ const reminders = reminderRows++ .filter((r) => dayIn(tz, new Date(r.remind_at).getTime()) === day)++ .map((r) => ({ text: `${r.title} — reminder at ${timeIn(tz, new Date(r.remind_at).getTime())}`, url: `${base}/e/${r.id}`, action: 'remind' }));++ if (reminders.length) sections.push({ id: 'your-reminders', title: 'Your reminders', items: reminders });++++ const rundown = await rundownItems(now);++ if (rundown && rundown.length) sections.push({ id: 'yesterday', title: 'Yesterday on OpenVibe', items: rundown });++++ return { day, sections };++ }++++ async function getOrBuild(subject, opts = {}) {++ const tz = isIanaZone(opts.tz) ? opts.tz : 'UTC';++ const day = dayIn(tz, opts.now == null ? s.now() : opts.now);++ const found = await db.maybe('SELECT body FROM today_briefs WHERE subject = $1 AND day = $2::date', [subject, day]);++ if (found) return { day, sections: found.body || [] };++ const built = await buildBrief(subject, { ...opts, tz });++ await db.exec(++ 'INSERT INTO today_briefs (subject, day, body, created_at) VALUES ($1, $2::date, $3::jsonb, $4::timestamptz) ON CONFLICT (subject, day) DO NOTHING',++ [subject, built.day, JSON.stringify(built.sections), new Date(s.now()).toISOString()],++ );++ const stored = await db.maybe('SELECT body FROM today_briefs WHERE subject = $1 AND day = $2::date', [subject, built.day]);++ return { day: built.day, sections: stored ? (stored.body || []) : built.sections };++ }++++ /** The daily job: subjects with a reminder, an own event or a feed in the last 30 days. */++ async function runOnce({ now = s.now(), force = false } = {}) {++ const at = new Date(now);++ if (!force && at.getUTCHours() !== config.brief.hourUtc) return { built: 0, skipped: 'hour' };++ const cutoff = new Date(now - config.brief.activeDays * 24 * 60 * 60_000).toISOString();++ const rows = await db.many(++ `SELECT DISTINCT subject FROM (++ SELECT subject FROM today_reminders WHERE created_at >= $1::timestamptz++ UNION SELECT owner_subject AS subject FROM today_events WHERE created_at >= $1::timestamptz OR updated_at >= $1::timestamptz++ UNION SELECT subject FROM today_feeds WHERE created_at >= $1::timestamptz++ ) s ORDER BY subject`,++ [cutoff],++ );++ let built = 0;++ for (const row of rows) {++ try {++ await getOrBuild(row.subject, { now, tz: 'UTC' });++ built++;++ } catch (err) {++ log.warn(`[OpenVibe.Today] brief for ${row.subject} failed: ${(err && err.message) || err}`);++ }++ }++ return { built, subjects: rows.length };++ }++++ let timer = null;++ let lastDay = null;++ function start() {++ if (timer || config.drill) return false;++ timer = setInterval(() => {++ const now = s.now();++ const day = dayIn('UTC', now);++ if (day === lastDay) return;++ runOnce({ now }).then((r) => { if (r.subjects !== undefined) lastDay = day; }).catch((err) => log.warn('[OpenVibe.Today] brief tick failed:', err.message));++ }, 60_000);++ if (timer.unref) timer.unref();++ return true;++ }++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ const handle = { buildBrief, getOrBuild, runOnce, start, stop, rundownItems };++ active = handle;++ return handle;++}++++// The module-level name the spec gives. It uses the builder the app created most recently; a test that boots one++// app can call require('../server/brief').buildBrief(subject, { now, tz }) directly.++let active = null;++async function buildBrief(subject, opts = {}) {++ if (!active) throw new Error('buildBrief: no Today app has created a brief builder in this process yet');++ return active.buildBrief(subject, opts);++}++++module.exports = { createBrief, buildBrief, dayIn, timeIn };nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * OpenVibe.Today configuration. Every value comes from the environment (production: /etc/openvibe/today.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, 5100);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.today' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'today',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.TODAY_DRILL === '1' || env.TODAY_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.TODAY_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.TODAY_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 (TODAY_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.TODAY_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:today:' },++++ // 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.TODAY_AUDIENCE || 'openvibe.today',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'today',++ 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. The secret signs a delivery (comma-separated for rotation, 32+++ // characters each); unset turns POST /internal/events off (503). The url is where subscriptions are++ // created at boot, off when unset.++ events: {++ secrets: String(env.TODAY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.TODAY_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.TODAY_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.TODAY_EVENTS_INTERVAL_MS, 5000)),++ // The scope this service's client-credentials token asks for when publishing. The product name is++ // events.publish; OpenVibe.Contracts names the capability events.event.publish, which the publisher++ // falls back to if the token endpoint refuses the product name. TODAY_EVENTS_SCOPE overrides both.++ scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',++ },++++ // OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.++ live: {++ internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),++ timeoutMs: Math.max(300, int(env.TODAY_LIVE_TIMEOUT_MS, 2500)),++ },++++ brief: {++ hourUtc: Math.min(23, Math.max(0, int(env.TODAY_BRIEF_HOUR_UTC, 6))),++ activeDays: Math.max(1, int(env.TODAY_BRIEF_ACTIVE_DAYS, 30)),++ },++++ // The follows and notification calls this service makes on a person's behalf.++ follows: { cacheMs: Math.max(0, int(env.TODAY_FOLLOWS_CACHE_MS, 60_000)) },++ notifications: { resolveCacheMs: Math.max(0, int(env.TODAY_RESOLVE_CACHE_MS, 5 * 60_000)) },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,71 @@++'use strict';++++/**++ * OpenVibe.Today's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret except a feed token, which is a bearer capability for one calendar and++ * is treated as one (never logged).++ */++const fs = require('fs');++const path = require('path');++const crypto = require('crypto');++const { createDb } = require('openvibe-sdk/db');++++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.Today] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'today', 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: 'today-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'today', 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) => {++ if (prefix === 'event') return `evt_${require('openvibe-contracts').ids.ulid(now())}`;++ return `${prefix}_${require('openvibe-contracts').ids.ulid(now())}`;++ },++ tx: async (fn) => await db.tx(() => fn()),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++/** A today_events id: tev_ + 20 Crockford base32 characters (100 bits from the OS random source). */++const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';++function newEventId() {++ const bytes = crypto.randomBytes(13);++ let bits = 0;++ let value = 0;++ let out = '';++ for (const byte of bytes) {++ value = (value << 8) | byte;++ bits += 8;++ while (bits >= 5 && out.length < 20) {++ out += BASE32[(value >>> (bits - 5)) & 31];++ bits -= 5;++ }++ value &= (1 << bits) - 1;++ }++ while (out.length < 20) { out += BASE32[value & 31]; value >>>= 5; }++ return `tev_${out.slice(0, 20)}`;++}++++module.exports = { openDb, openStore, createStore, newEventId, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,9 @@++'use strict';++++/**++ * Help's file name, today's consumer: POST /internal/events and the boot-time startSubscriptions(). The++ * implementation lives in server/events/consumer.js.++ */++const { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS } = require('./events/consumer');++++module.exports = { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS };nnew file mode 100644@@@ -0,0 +1,222 @@++'use strict';++++/**++ * OpenVibe.Events → Today. POST /internal/events is this service's subscription endpoint (loopback only; nginx++ * answers 404 for /internal).++ *++ * live.stream.started the channel's scheduled event whose start is within 2 h of now becomes live++ * live.stream.ended a live event of that channel becomes done++ * network.account.export_requested / network.account.deleted++ * handed to openvibe-sdk/account-data (server/identity/account-data.js), which keeps++ * its own idempotency and sends the export part / deletion confirmation to Network++ *++ * Every other inbound event id is recorded in today_processed in the same transaction as its effect, so a replay++ * changes nothing. A delivery is verified with the v2 signature (replay window, raw body) exactly as Help's++ * consumer verifies. The boot-time half, startSubscriptions(), creates any missing subscriptions at Events.++ */++const express = require('express');++const { http, serviceAuth } = require('openvibe-contracts');++const { parseDelivery } = require('openvibe-sdk/events');++++const LIVE_TOPICS = Object.freeze(['live.stream.started', 'live.stream.ended']);++const TOPICS = Object.freeze([...LIVE_TOPICS, ...require('openvibe-sdk/account-data').TOPICS]);++const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createEventsConsumer({ config, s, accountData, accountSend = null, now = s.now, log = console }) {++ const db = s.db;++ const secrets = (config.events.secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);++ const topics = [...LIVE_TOPICS, ...accountData.TOPICS];++ const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };++ const router = express.Router();++++ function record(eventId, atIso) {++ return db.exec('INSERT INTO today_processed (event_id, processed_at) VALUES ($1, $2::timestamptz) ON CONFLICT (event_id) DO NOTHING', [eventId, atIso]);++ }++++ async function applyLive(event) {++ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};++ const username = payload.channel && typeof payload.channel.username === 'string' ? payload.channel.username : null;++ if (!username) return 'ignored:payload';++ const atIso = new Date(now()).toISOString();++ return await s.tx(async () => {++ const inserted = await record(event.event_id, atIso);++ if (!inserted) return { duplicate: true, outcome: null };++ if (event.event_type === 'live.stream.started') {++ const at = new Date(now()).toISOString();++ const row = await db.maybe(++ `SELECT * FROM today_events++ WHERE LOWER(channel) = LOWER($1) AND status = 'scheduled'++ AND starts_at BETWEEN $2::timestamptz - interval '2 hours' AND $2::timestamptz + interval '2 hours'++ ORDER BY ABS(EXTRACT(EPOCH FROM (starts_at - $2::timestamptz))) ASC, starts_at ASC LIMIT 1`,++ [username, at],++ );++ if (!row) return { duplicate: false, outcome: 'ignored:no_event' };++ await db.exec("UPDATE today_events SET status = 'live', seq = seq + 1, updated_at = $1::timestamptz WHERE id = $2", [atIso, row.id]);++ return { duplicate: false, outcome: 'live', event_id: row.id };++ }++ // live.stream.ended++ const row = await db.maybe(++ "SELECT * FROM today_events WHERE LOWER(channel) = LOWER($1) AND status = 'live' ORDER BY starts_at DESC, updated_at DESC LIMIT 1",++ [username],++ );++ if (!row) return { duplicate: false, outcome: 'ignored:no_event' };++ await db.exec("UPDATE today_events SET status = 'done', seq = seq + 1, updated_at = $1::timestamptz WHERE id = $2", [atIso, row.id]);++ return { duplicate: false, outcome: 'done', event_id: row.id };++ });++ }++++ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {++ const ctx = http.requestContext(req.headers);++ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });++ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'today.internal_only', 'internal route');++ if (!secrets.length) return problem(503, 'today.events_disabled', 'TODAY_EVENTS_SECRET is not set');++ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);++ let delivery = null;++ for (const secret of secrets) { delivery = parseDelivery(raw, req.headers, secret, { requireV2: true, now: now() }); if (delivery) break; }++ if (!delivery) { stats.refused++; return problem(401, 'today.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window'); }++ const event = delivery.event;++ if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'today.bad_delivery', 'body must be { event: <envelope>, seq }'); }++ stats.received++;++ stats.last_at = new Date(now()).toISOString();++++ if (accountData.TOPICS.includes(event.event_type)) {++ let outcome;++ try {++ outcome = await accountData.apply(event, { send: accountSend });++ } catch (err) {++ stats.failed++;++ log.error(`[OpenVibe.Today] account event ${event.event_id} failed:`, err.message);++ return problem(503, 'today.event_failed', 'processing failed; it will be retried');++ }++ if (String(outcome).startsWith('ignored:')) stats.ignored++; else stats.applied++;++ return res.json({ event_id: event.event_id, outcome });++ }++++ if (!LIVE_TOPICS.includes(event.event_type)) {++ const inserted = await record(event.event_id, new Date(now()).toISOString());++ stats.ignored++;++ if (!inserted) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }++ return res.json({ event_id: event.event_id, outcome: 'ignored:type' });++ }++++ let result;++ try {++ result = await applyLive(event);++ } catch (err) {++ stats.failed++;++ log.error(`[OpenVibe.Today] event ${event.event_id} (${event.event_type}) failed:`, err.message);++ return problem(503, 'today.event_failed', 'processing failed; it will be retried');++ }++ if (result.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }++ if (String(result.outcome).startsWith('ignored:')) stats.ignored++; else stats.applied++;++ return res.json({ event_id: event.event_id, duplicate: false, outcome: result.outcome, ...(result.event_id ? { affected: result.event_id } : {}) });++ });++++ /** The subscriptions this service needs: the two live topics plus the two account topics. */++ function startSubscriptions({ port = config.port, secret = secrets[0] || '', eventsUrl = config.events.url, fetchImpl = globalThis.fetch } = {}) {++ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.TODAY_EVENTS_SUBSCRIBE === '0') return null;++ const base = String(eventsUrl).replace(/\/+$/, '');++ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events',++ scope: 'events.subscription.manage',++ fetchImpl,++ });++ const call = async (method, path, body) => {++ const res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(15000),++ });++ const json = await res.json().catch(() => ({}));++ return { status: res.status, ok: res.ok, body: json };++ };++ const attempt = async () => {++ const listed = await call('GET', '/api/v1/subscriptions');++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);++ const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);++ for (const topic of topics) {++ if (mine.some((x) => x.topic_pattern === topic)) continue;++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);++ if (r.ok) log.log(`[OpenVibe.Today] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ }++ };++ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];++ let i = 0;++ let timer = null;++ let stopped = false;++ const run = () => {++ timer = null;++ if (stopped) return;++ attempt().catch((err) => {++ if (stopped) return;++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[OpenVibe.Today] subscriptions not created:', err.message);++ });++ };++ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();++ return { topics, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };++ }++++ return { router, topics, stats: () => ({ ...stats, enabled: secrets.length > 0 }), startSubscriptions };++}++++/** The boot-time half on its own (Help's shape): create the missing subscriptions for `topics`. */++function startSubscriptions({++ config, port = config.port, secret = '', eventsUrl = config.events.url, fetchImpl = globalThis.fetch,++ topics = [...LIVE_TOPICS, ...require('openvibe-sdk/account-data').TOPICS], log = console,++} = {}) {++ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.TODAY_EVENTS_SUBSCRIBE === '0') return null;++ const base = String(eventsUrl).replace(/\/+$/, '');++ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;++ const tokens = serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events',++ scope: 'events.subscription.manage',++ fetchImpl,++ });++ const call = async (method, path, body) => {++ const res = await fetchImpl(`${base}${path}`, {++ method,++ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },++ body: body ? JSON.stringify(body) : undefined,++ signal: AbortSignal.timeout(15000),++ });++ const json = await res.json().catch(() => ({}));++ return { status: res.status, ok: res.ok, body: json };++ };++ const attempt = async () => {++ const listed = await call('GET', '/api/v1/subscriptions');++ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);++ const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);++ for (const topic of topics) {++ if (mine.some((x) => x.topic_pattern === topic)) continue;++ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });++ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);++ if (r.ok) log.log(`[OpenVibe.Today] subscription created: ${r.body.id} (${topic} → ${endpoint})`);++ }++ };++ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];++ let i = 0;++ let timer = null;++ let stopped = false;++ const run = () => {++ timer = null;++ if (stopped) return;++ attempt().catch((err) => {++ if (stopped) return;++ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); }++ else log.warn('[OpenVibe.Today] subscriptions not created:', err.message);++ });++ };++ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();++ return { topics, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };++}++++module.exports = { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS, EVENT_ID_RE };nnew file mode 100644@@@ -0,0 +1,167 @@++'use strict';++++/**++ * The transactional outbox → OpenVibe.Events. A row is written in the same transaction as the event change and++ * carries the evt_ id it will always publish under, so a retry (or a restart) never makes a second event. A claim++ * lease means two processes do not send the same row at once. Failures back off and are recorded; the job never++ * throws into the process.++ */++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++++const MAX_ATTEMPTS_BACKOFF_MS = 5 * 60_000;++++function createPublisher({ config, s, fetchImpl = globalThis.fetch, log = console, intervalMs = config.events.intervalMs }) {++ const db = s.db;++ const worker = `today-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;++ const configured = Boolean(config.events.url && config.oauth.clientSecret);++ // OpenVibe.Events' publish capability (TODAY_EVENTS_SCOPE overrides it for a self-hosted Events).++ const scopes = [...new Set([config.events.scope || 'events.event.publish'])];++ const clients = configured ? scopes.map((scope) => serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.events',++ scope,++ fetchImpl,++ })) : [];++ let preferred = 0;++ async function authHeaders() {++ for (let i = preferred; i < clients.length; i++) {++ try {++ const headers = await clients[i].authHeaders();++ preferred = i;++ return headers;++ } catch (err) {++ if (i === clients.length - 1) throw err;++ }++ }++ throw new Error('no Events token client is configured');++ }++ let timer = null;++ let running = false;++ const stats = { published: 0, failed: 0, last_error: null };++++ function backoffMs(attempts) {++ return Math.min(MAX_ATTEMPTS_BACKOFF_MS, 5000 * Math.pow(2, Math.max(0, Math.min(6, attempts))));++ }++++ async function claim(limit = 20) {++ const now = new Date(s.now()).toISOString();++ const stale = new Date(s.now() - 2 * 60_000).toISOString();++ return await db.many(++ `UPDATE today_outbox o SET claimed_at = $1::timestamptz, claimed_by = $2++ WHERE o.event_id IN (++ SELECT o2.event_id FROM today_outbox o2++ WHERE o2.published_at IS NULL AND o2.next_attempt_at <= $1::timestamptz++ AND (o2.claimed_at IS NULL OR o2.claimed_at < $3::timestamptz)++ ORDER BY o2.created_at ASC, o2.event_id ASC++ LIMIT $4++ FOR UPDATE SKIP LOCKED++ )++ RETURNING o.*`,++ [now, worker, stale, Math.min(100, Math.max(1, limit))],++ );++ }++++ function envelope(row, owner) {++ return {++ event_id: row.event_id,++ event_type: row.topic,++ version: 1,++ source: 'today',++ actor: { type: 'user', id: owner },++ subject: { type: 'user', id: owner },++ timestamp: new Date(s.now()).toISOString(),++ priority: 'important',++ visibility: 'internal',++ payload: row.payload,++ };++ }++++ async function ownerOf(row) {++ const owner = await db.value(++ "SELECT owner_subject FROM today_events WHERE id = $1",++ [row.payload && row.payload.event_id],++ );++ return owner || null;++ }++++ async function send(row) {++ const owner = await ownerOf(row);++ if (!owner) {++ await db.exec("UPDATE today_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = 'event row is gone' WHERE event_id = $2", [new Date(s.now()).toISOString(), row.event_id]);++ return { ok: true, gone: true };++ }++ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await authHeaders()) };++ const res = await fetchImpl(`${config.events.url.replace(/\/+$/, '')}/api/v1/events`, {++ method: 'POST',++ headers,++ body: JSON.stringify(envelope(row, owner)),++ signal: AbortSignal.timeout(15_000),++ });++ if (!res.ok) {++ const text = await res.text().catch(() => '');++ throw new Error(`events ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`);++ }++ await db.exec('UPDATE today_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = NULL WHERE event_id = $2', [new Date(s.now()).toISOString(), row.event_id]);++ return { ok: true };++ }++++ async function runOnce({ limit = 20 } = {}) {++ if (!configured) return { configured: false, claimed: 0, published: 0, failed: 0 };++ if (running) return { configured: true, busy: true, claimed: 0, published: 0, failed: 0 };++ running = true;++ let claimed = 0;++ let published = 0;++ let failed = 0;++ try {++ const rows = await claim(limit);++ claimed = rows.length;++ for (const row of rows) {++ try {++ await send(row);++ published++;++ stats.published++;++ } catch (err) {++ failed++;++ stats.failed++;++ const message = String((err && err.message) || err).slice(0, 500);++ stats.last_error = message;++ const attempts = Number(row.attempts) + 1;++ const next = new Date(s.now() + backoffMs(attempts)).toISOString();++ await db.exec(++ `UPDATE today_outbox SET attempts = attempts + 1, last_error = $1, claimed_at = NULL, claimed_by = NULL,++ next_attempt_at = $2::timestamptz WHERE event_id = $3`,++ [message, next, row.event_id],++ );++ log.warn(`[OpenVibe.Today] outbox ${row.event_id} publish failed (attempt ${attempts}): ${message}`);++ }++ }++ return { configured: true, claimed, published, failed };++ } finally {++ running = false;++ }++ }++++ function start() {++ if (!configured || timer || config.drill) return false;++ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Today] outbox tick failed:', err.message)); }, intervalMs);++ if (timer.unref) timer.unref();++ return true;++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ return Promise.resolve();++ }++++ async function pending() {++ return Number(await db.value('SELECT COUNT(*)::int AS n FROM today_outbox WHERE published_at IS NULL')) || 0;++ }++++ return { configured, runOnce, start, stop, pending, stats: () => ({ ...stats }) };++}++++module.exports = { createPublisher, MAX_ATTEMPTS_BACKOFF_MS };nnew file mode 100644@@@ -0,0 +1,297 @@++'use strict';++++/**++ * Today's events and reminders in PostgreSQL. An event and the outbox row that announces it are written in one++ * transaction; a repeated Idempotency-Key returns the event it made the first time. Every change bumps seq (the ICS++ * SEQUENCE). A cancelled event stays readable (status cancelled) so calendars can remove it.++ */++const { ids } = require('openvibe-contracts');++const { newEventId } = require('../db');++const { validateCreate, validatePatch } = require('./validate');++++const MAX_FUTURE = 60;++++class StoreRefusal extends Error {++ constructor(status, code, field, message) {++ super(message);++ this.status = status; this.code = code; this.field = field;++ }++}++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++++/** The JSON shape the API returns. */++function wire(row) {++ if (!row) return null;++ return {++ id: row.id,++ owner_subject: row.owner_subject,++ kind: row.kind,++ channel: row.channel || null,++ title: row.title,++ description: row.description == null ? null : row.description,++ starts_at: iso(row.starts_at),++ ends_at: iso(row.ends_at),++ tz: row.tz,++ status: row.status,++ visibility: row.visibility,++ url: row.url || null,++ seq: Number(row.seq) || 0,++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ };++}++++/** The payload of an Events envelope (never the owner: the envelope's subject carries that). */++function payloadOf(row) {++ return {++ event_id: row.id,++ kind: row.kind,++ channel: row.channel || null,++ title: row.title,++ starts_at: iso(row.starts_at),++ ends_at: iso(row.ends_at),++ tz: row.tz,++ status: row.status,++ visibility: row.visibility,++ url: row.url || null,++ seq: Number(row.seq) || 0,++ };++}++++function createEventsStore({ s, log = console }) {++ const db = s.db;++ const nowIso = () => new Date(s.now()).toISOString();++++ async function insertOutbox(topic, row) {++ const eventId = ids.newId('event', s.now());++ await db.exec(++ `INSERT INTO today_outbox (event_id, topic, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,++ [eventId, topic, JSON.stringify(payloadOf(row)), nowIso()],++ );++ return eventId;++ }++++ async function byId(id) {++ return await db.maybe('SELECT * FROM today_events WHERE id = $1', [id]);++ }++++ async function byIdForViewer(id, subject) {++ const row = await byId(id);++ if (!row) return null;++ if (row.visibility === 'followers' && row.owner_subject !== subject) return null;++ return row;++ }++++ async function listPublic({ channel = null, from, to, limit = 50 } = {}) {++ const args = [from, to, Math.min(100, Math.max(1, Number(limit) || 50))];++ let where = "visibility = 'public' AND status <> 'cancelled' AND starts_at >= $1::timestamptz AND starts_at <= $2::timestamptz";++ if (channel) { args.push(channel); where += ` AND LOWER(channel) = LOWER($${args.length})`; }++ const rows = await db.many(++ `SELECT * FROM today_events WHERE ${where} ORDER BY starts_at ASC, id ASC LIMIT $3`,++ args,++ );++ return rows.map(wire);++ }++++ async function listChannel(channel, { from, to, includeCancelled = false } = {}) {++ const args = [channel, from, to];++ const cancelled = includeCancelled ? '' : " AND status <> 'cancelled'";++ return (await db.many(++ `SELECT * FROM today_events WHERE LOWER(channel) = LOWER($1) AND visibility = 'public'${cancelled}++ AND starts_at >= $2::timestamptz AND starts_at <= $3::timestamptz ORDER BY starts_at ASC, id ASC`,++ args,++ )).map(wire);++ }++++ async function countScheduledFuture(subject) {++ const row = await db.one(++ "SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1 AND status = 'scheduled' AND starts_at > $2::timestamptz",++ [subject, nowIso()],++ );++ return Number(row.n) || 0;++ }++++ async function create({ subject, username, body, idempotencyKey = null, now = s.now() }) {++ const check = validateCreate(body, { now, username });++ if (!check.ok) return check;++ const key = typeof idempotencyKey === 'string' && idempotencyKey ? idempotencyKey : null;++ try {++ return await s.tx(async () => {++ if (key) {++ const claim = await db.maybe(++ `INSERT INTO today_idempotency (subject, key, event_id, created_at)++ VALUES ($1, $2, NULL, $3::timestamptz) ON CONFLICT (subject, key) DO NOTHING++ RETURNING subject`,++ [subject, key, nowIso()],++ );++ if (!claim) {++ const prior = await db.maybe(++ `SELECT e.* FROM today_idempotency i JOIN today_events e ON e.id = i.event_id++ WHERE i.subject = $1 AND i.key = $2`,++ [subject, key],++ );++ if (prior) return { ok: true, event: wire(prior), replayed: true };++ throw new StoreRefusal(409, 'today.idempotency_in_progress', 'idempotency_key', 'a request with this key is still being handled; retry shortly');++ }++ }++ if (await countScheduledFuture(subject) >= MAX_FUTURE) {++ throw new StoreRefusal(409, 'today.too_many', 'events', `at most ${MAX_FUTURE} scheduled future events per owner`);++ }++ const id = newEventId();++ const row = await db.one(++ `INSERT INTO today_events++ (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url, seq, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9, 'scheduled', $10, $11, 0, $12::timestamptz, $12::timestamptz)++ RETURNING *`,++ [id, subject, check.fields.kind, check.fields.channel, check.fields.title, check.fields.description,++ check.fields.starts_at, check.fields.ends_at, check.fields.tz, check.fields.visibility, check.fields.url, nowIso()],++ );++ if (key) await db.exec('UPDATE today_idempotency SET event_id = $1 WHERE subject = $2 AND key = $3', [id, subject, key]);++ await insertOutbox('today.event.scheduled', row);++ return { ok: true, event: wire(row), replayed: false };++ });++ } catch (err) {++ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };++ throw err;++ }++ }++++ async function patch({ id, subject, body, now = s.now() }) {++ try {++ return await s.tx(async () => {++ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1 FOR UPDATE', [id]);++ if (!row || row.owner_subject !== subject) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');++ if (row.status === 'cancelled' || row.status === 'done') throw new StoreRefusal(409, 'today.locked', 'status', 'an event that is cancelled or done cannot be changed');++ const check = validatePatch(body, row, { now });++ if (!check.ok) return check;++ const after = await db.one(++ `UPDATE today_events SET title = $1, description = $2, starts_at = $3::timestamptz, ends_at = $4::timestamptz,++ tz = $5, visibility = $6, seq = seq + 1, updated_at = $7::timestamptz++ WHERE id = $8 RETURNING *`,++ [check.fields.title, check.fields.description, check.fields.starts_at, check.fields.ends_at,++ check.fields.tz, check.fields.visibility, nowIso(), id],++ );++ await insertOutbox('today.event.updated', after);++ return { ok: true, event: wire(after) };++ });++ } catch (err) {++ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };++ throw err;++ }++ }++++ async function cancel({ id, subject }) {++ try {++ return await s.tx(async () => {++ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1 FOR UPDATE', [id]);++ if (!row || row.owner_subject !== subject) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');++ if (row.status === 'cancelled') return { ok: true, event: wire(row), already: true };++ const after = await db.one(++ `UPDATE today_events SET status = 'cancelled', seq = seq + 1, updated_at = $1::timestamptz++ WHERE id = $2 RETURNING *`,++ [nowIso(), id],++ );++ await db.exec('DELETE FROM today_reminders WHERE event_id = $1', [id]);++ await insertOutbox('today.event.cancelled', after);++ return { ok: true, event: wire(after), already: false };++ });++ } catch (err) {++ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };++ throw err;++ }++ }++++ // ── Reminders ───────────────────────────────────────────++ function validateMinutes(v) {++ if (v == null || v === '') return { ok: true, value: 10 };++ const n = Number(v);++ if (!Number.isInteger(n) || n < 0 || n > 1440) return { ok: false, status: 400, code: 'today.invalid', field: 'minutes_before', message: 'minutes_before must be a whole number from 0 to 1440' };++ return { ok: true, value: n };++ }++++ async function remind({ subject, id, minutesBefore, now = s.now() }) {++ const minutes = validateMinutes(minutesBefore);++ if (!minutes.ok) return minutes;++ try {++ return await s.tx(async () => {++ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1', [id]);++ if (!row || (row.visibility === 'followers' && row.owner_subject !== subject)) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');++ if (row.status === 'cancelled' || row.status === 'done') throw new StoreRefusal(409, 'today.locked', 'status', 'a cancelled or done event cannot be reminded');++ const remindAt = new Date(new Date(row.starts_at).getTime() - minutes.value * 60_000).toISOString();++ const saved = await db.one(++ `INSERT INTO today_reminders (subject, event_id, remind_at, sent_at, created_at)++ VALUES ($1, $2, $3::timestamptz, NULL, $4::timestamptz)++ ON CONFLICT (subject, event_id) DO UPDATE SET remind_at = EXCLUDED.remind_at, sent_at = NULL++ RETURNING *`,++ [subject, id, remindAt, nowIso()],++ );++ return { ok: true, reminder: { event_id: id, remind_at: iso(saved.remind_at), minutes_before: minutes.value }, event: wire(row) };++ });++ } catch (err) {++ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };++ throw err;++ }++ }++++ async function unremind({ subject, id }) {++ const n = await db.exec('DELETE FROM today_reminders WHERE subject = $1 AND event_id = $2', [subject, id]);++ return { ok: true, removed: Number(n) > 0 };++ }++++ async function listReminders(subject, { now = s.now() } = {}) {++ const rows = await db.many(++ `SELECT r.event_id, r.remind_at, r.sent_at, r.created_at, e.*++ FROM today_reminders r JOIN today_events e ON e.id = r.event_id++ WHERE r.subject = $1 AND r.remind_at >= $2::timestamptz AND e.status IN ('scheduled', 'live')++ ORDER BY r.remind_at ASC, r.event_id ASC`,++ [subject, new Date(now).toISOString()],++ );++ return rows.map((r) => ({++ event_id: r.event_id,++ remind_at: iso(r.remind_at),++ sent_at: iso(r.sent_at),++ minutes_before: Math.max(0, Math.round((new Date(r.starts_at).getTime() - new Date(r.remind_at).getTime()) / 60_000)),++ event: wire(r),++ }));++ }++++ /** Due reminders with their events, soonest first. The job claims each row before sending. */++ async function dueReminders(now = s.now(), limit = 100) {++ return await db.many(++ `SELECT r.subject, r.event_id, r.remind_at, e.*++ FROM today_reminders r JOIN today_events e ON e.id = r.event_id++ WHERE r.sent_at IS NULL AND r.remind_at <= $1::timestamptz AND e.status IN ('scheduled', 'live')++ ORDER BY r.remind_at ASC, r.event_id ASC LIMIT $2`,++ [new Date(now).toISOString(), Math.min(500, Math.max(1, limit))],++ );++ }++++ /** Claim one due reminder atomically; null when another run already took it. */++ async function claimReminder(subject, eventId, now = s.now()) {++ const row = await db.maybe(++ `UPDATE today_reminders r SET sent_at = $3::timestamptz++ WHERE r.subject = $1 AND r.event_id = $2 AND r.sent_at IS NULL++ AND EXISTS (SELECT 1 FROM today_events e WHERE e.id = r.event_id AND e.status IN ('scheduled', 'live'))++ RETURNING r.*`,++ [subject, eventId, new Date(now).toISOString()],++ );++ if (!row) return null;++ return { reminder: row, event: await byId(eventId) };++ }++++ /** Put a claimed reminder back when the notification could not be sent. */++ async function releaseReminder(subject, eventId) {++ await db.exec('UPDATE today_reminders SET sent_at = NULL WHERE subject = $1 AND event_id = $2', [subject, eventId]);++ }++++ return {++ wire, payloadOf, byId, byIdForViewer, listPublic, listChannel,++ countScheduledFuture, create, patch, cancel,++ remind, unremind, listReminders, dueReminders, claimReminder, releaseReminder,++ MAX_FUTURE,++ };++}++++module.exports = { createEventsStore, wire, payloadOf, StoreRefusal, MAX_FUTURE };nnew file mode 100644@@@ -0,0 +1,135 @@++'use strict';++++/** Validation for what a person may schedule. Pure: no clock of its own, no database. */++++const KINDS = ['stream', 'event'];++const VISIBILITIES = ['public', 'followers'];++const TITLE_MAX = 120;++const DESCRIPTION_MAX = 1000;++const EARLIEST_MS = 5 * 60_000;++const LATEST_MS = 366 * 24 * 60 * 60_000;++const MAX_LENGTH_MS = 24 * 60 * 60_000;++++const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;++const PLAIN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;++++function bad(field, message) { return { ok: false, status: 400, code: 'today.invalid', field, message }; }++++/** An ISO time (a plain local form is read as UTC, so a form and the API agree). */++function parseTime(value) {++ if (typeof value !== 'string' || !value.trim()) return null;++ const s = value.trim();++ if (!ISO_RE.test(s) && !PLAIN_RE.test(s)) return null;++ const d = new Date(PLAIN_RE.test(s) ? `${s}Z` : s);++ return Number.isNaN(d.getTime()) ? null : d;++}++++function isIanaZone(tz) {++ if (typeof tz !== 'string' || !tz.trim() || tz.length > 80) return false;++ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }++}++++function validateTitle(v) {++ if (typeof v !== 'string') return bad('title', 'title is required and must be 1-120 characters');++ const s = v.trim();++ if (s.length < 1 || s.length > TITLE_MAX) return bad('title', 'title must be 1-120 characters');++ return { ok: true, value: s };++}++function validateDescription(v, { optional = false } = {}) {++ if (v == null || v === '') return { ok: true, value: null };++ if (typeof v !== 'string') return bad('description', 'description must be text of at most 1000 characters');++ if (v.length > DESCRIPTION_MAX) return bad('description', 'description must be at most 1000 characters');++ return { ok: true, value: v };++}++function validateVisibility(v) {++ if (v == null) return { ok: true, value: 'public' };++ if (typeof v !== 'string' || !VISIBILITIES.includes(v)) return bad('visibility', 'visibility must be public or followers');++ return { ok: true, value: v };++}++function validateStarts(v, now) {++ const d = parseTime(v);++ if (!d) return bad('starts_at', 'starts_at must be an ISO time');++ if (d.getTime() < now - EARLIEST_MS) return bad('starts_at', 'starts_at must not be earlier than 5 minutes ago');++ if (d.getTime() > now + LATEST_MS) return bad('starts_at', 'starts_at must not be later than 366 days ahead');++ return { ok: true, value: d };++}++function validateEnds(v, start) {++ if (v == null || v === '') return { ok: true, value: null };++ const d = parseTime(v);++ if (!d) return bad('ends_at', 'ends_at must be an ISO time');++ if (d.getTime() <= start.getTime()) return bad('ends_at', 'ends_at must be after starts_at');++ if (d.getTime() - start.getTime() > MAX_LENGTH_MS) return bad('ends_at', 'ends_at must be within 24 hours of starts_at');++ return { ok: true, value: d };++}++function validateTz(v) {++ if (!isIanaZone(v)) return bad('tz', 'tz must be a valid IANA time zone');++ return { ok: true, value: String(v).trim() };++}++++/** POST body → the fields to write, or a today.invalid refusal. */++function validateCreate(body, { now = Date.now(), username = null } = {}) {++ const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};++ if (!KINDS.includes(b.kind)) return bad('kind', 'kind must be stream or event');++ const t = validateTitle(b.title); if (!t.ok) return t;++ const d = validateDescription(b.description); if (!d.ok) return d;++ const s = validateStarts(b.starts_at, now); if (!s.ok) return s;++ const e = validateEnds(b.ends_at, s.value); if (!e.ok) return e;++ const z = validateTz(b.tz); if (!z.ok) return z;++ const v = validateVisibility(b.visibility); if (!v.ok) return v;++++ let channel = null;++ let url = null;++ if (b.kind === 'stream') {++ if (typeof username !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) {++ return bad('channel', 'a stream needs the username on your OpenVibe account');++ }++ channel = username;++ url = `https://openvibe.live/@${encodeURIComponent(username)}`;++ }++ return {++ ok: true,++ fields: {++ kind: b.kind, channel, url,++ title: t.value, description: d.value,++ starts_at: s.value.toISOString(), ends_at: e.value ? e.value.toISOString() : null,++ tz: z.value, visibility: v.value,++ },++ };++}++++function has(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); }++++/** PATCH body → the merged fields to write, or a today.invalid refusal. */++function validatePatch(body, row, { now = Date.now() } = {}) {++ const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};++ const fields = ['title', 'description', 'starts_at', 'ends_at', 'tz', 'visibility'];++ if (!fields.some((f) => has(b, f))) return bad('body', `nothing to update; send one of ${fields.join(', ')}`);++++ let title = row.title;++ if (has(b, 'title')) { const t = validateTitle(b.title); if (!t.ok) return t; title = t.value; }++ let description = row.description;++ if (has(b, 'description')) { const d = validateDescription(b.description); if (!d.ok) return d; description = d.value; }++ let starts = new Date(row.starts_at);++ if (has(b, 'starts_at')) { const s = validateStarts(b.starts_at, now); if (!s.ok) return s; starts = s.value; }++ let ends = row.ends_at ? new Date(row.ends_at) : null;++ if (has(b, 'ends_at')) { const e = validateEnds(b.ends_at, starts); if (!e.ok) return e; ends = e.value; }++ else if (ends) { const e = validateEnds(ends.toISOString(), starts); if (!e.ok) return e; ends = e.value; }++ let tz = row.tz;++ if (has(b, 'tz')) { const z = validateTz(b.tz); if (!z.ok) return z; tz = z.value; }++ let visibility = row.visibility;++ if (has(b, 'visibility')) { const v = validateVisibility(b.visibility); if (!v.ok) return v; visibility = v.value; }++++ return {++ ok: true,++ fields: {++ title, description,++ starts_at: starts.toISOString(), ends_at: ends ? ends.toISOString() : null,++ tz, visibility,++ },++ };++}++++module.exports = {++ KINDS, VISIBILITIES, TITLE_MAX, DESCRIPTION_MAX, EARLIEST_MS, LATEST_MS, MAX_LENGTH_MS,++ parseTime, isIanaZone, validateCreate, validatePatch, bad,++};nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++++/** A person's private calendar feed token (32 random bytes, base64url) and its rotation. */++const crypto = require('crypto');++++function newFeedToken() {++ return crypto.randomBytes(32).toString('base64url');++}++++function createFeeds({ s }) {++ const db = s.db;++++ async function getOrCreate(subject) {++ const existing = await db.maybe('SELECT token FROM today_feeds WHERE subject = $1', [subject]);++ if (existing) return existing.token;++ for (let i = 0; i < 3; i++) {++ const token = newFeedToken();++ const row = await db.maybe(++ `INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, $2, $3::timestamptz)++ ON CONFLICT (subject) DO NOTHING++ RETURNING token`,++ [subject, token, new Date(s.now()).toISOString()],++ );++ if (row) return row.token;++ const again = await db.maybe('SELECT token FROM today_feeds WHERE subject = $1', [subject]);++ if (again) return again.token;++ }++ throw new Error('could not create a feed token');++ }++++ async function rotate(subject) {++ for (let i = 0; i < 3; i++) {++ const token = newFeedToken();++ const row = await db.maybe(++ `INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, $2, $3::timestamptz)++ ON CONFLICT (subject) DO UPDATE SET token = EXCLUDED.token, created_at = EXCLUDED.created_at++ RETURNING token`,++ [subject, token, new Date(s.now()).toISOString()],++ );++ if (row) return row.token;++ }++ throw new Error('could not rotate the feed token');++ }++++ async function findByToken(token) {++ return await db.maybe('SELECT subject, token, created_at FROM today_feeds WHERE token = $1', [token]);++ }++++ function url(config, token) {++ return `${String(config.baseUrl).replace(/\/+$/, '')}/ics/${token}.ics`;++ }++++ return { getOrCreate, rotate, findByToken, url, newFeedToken };++}++++module.exports = { createFeeds, newFeedToken };nnew file mode 100644@@@ -0,0 +1,172 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Today's JSON API.++ *++ * GET /events?channel=&from=&to=&limit= public events, soonest first, cached 60 s++ * GET /events/:id one event (owner sees a followers-only one; cancelled answers)++ * POST /events schedule a stream or an event (signed in, Idempotency-Key honoured)++ * PATCH /events/:id change one (owner; seq + 1)++ * DELETE /events/:id cancel one (owner; idempotent)++ * POST /events/:id/remind set a reminder (signed in)++ * DELETE /events/:id/remind remove it++ * GET /me/reminders the caller's upcoming reminders++ * GET /me/agenda?days=14 own + reminded + followed channels, or follows_unavailable++ * GET /me/feed the private ICS URL, created on first call++ * POST /me/feed/rotate replace it (the old URL is 404 at once)++ * GET /me/brief today's brief, built on demand when missing++ *++ * A person is the session cookie (today_at) or Authorization: Bearer <Network user token>, verified offline with++ * the Network JWKS. A write made with the session cookie must come from openvibe.today itself (403++ * today.cross_site). Errors are JSON: { code, message } (a validation refusal adds { field }).++ */++const express = require('express');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { parseTime, isIanaZone } = require('../events/validate');++++function createApi(ctx) {++ const { config, s, principal, events, feeds, follows, agenda, brief } = ctx;++ const r = asyncRouter();++++ const invalid = (res, field, message) => res.status(400).json({ code: 'today.invalid', field, message });++ const refuse = (res, status, code, message) => res.status(status).json({ code, message });++++ r.use(express.json({ limit: '64kb' }));++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ function person(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') { refuse(res, 401, 'today.sign_in_required', 'Sign in at openvibe.today, or send a person\'s Network token as a Bearer.'); return null; }++ if (p.kind !== 'user') { refuse(res, 403, 'today.person_required', 'A schedule belongs to a person; an app, agent or service token cannot change one.'); return null; }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ refuse(res, 403, 'today.cross_site', 'A signed-in request that changes something must come from openvibe.today itself.');++ return null;++ }++ return p;++ }++ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);++++ function jsonError(res, out) {++ return res.status(out.status || 400).json({ code: out.code || 'today.invalid', ...(out.field ? { field: out.field } : {}), message: out.message || 'invalid request' });++ }++++ /** A query bound: an ISO time, or a date (read as UTC midnight) for convenience in a URL. */++ function queryTime(v) {++ const d = parseTime(v);++ if (d) return d;++ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) {++ const x = new Date(`${v}T00:00:00Z`);++ return Number.isNaN(x.getTime()) ? null : x;++ }++ return null;++ }++++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));++++ // ── Public reads ────────────────────────────────────────++ r.get('/events', async (req, res) => {++ const now = s.now();++ let from = new Date(now - 2 * 60 * 60_000);++ if (req.query.from != null && req.query.from !== '') {++ const d = queryTime(req.query.from);++ if (!d) return invalid(res, 'from', 'from must be an ISO time');++ from = d;++ }++ let to = new Date(now + 60 * 24 * 60 * 60_000);++ if (req.query.to != null && req.query.to !== '') {++ const d = queryTime(req.query.to);++ if (!d) return invalid(res, 'to', 'to must be an ISO time');++ to = d;++ }++ if (from.getTime() > to.getTime()) return invalid(res, 'from', 'from must not be later than to');++ const channel = typeof req.query.channel === 'string' && req.query.channel.trim() ? req.query.channel.trim().replace(/^@/, '').slice(0, 64) : null;++ const limit = req.query.limit == null || req.query.limit === '' ? 50 : Number.parseInt(req.query.limit, 10);++ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit', 'limit must be from 1 to 100');++ const rows = await events.listPublic({ channel, from: from.toISOString(), to: to.toISOString(), limit });++ res.set('Cache-Control', 'public, max-age=60');++ return res.json({ from: from.toISOString(), to: to.toISOString(), count: rows.length, events: rows });++ });++++ r.get('/events/:id', async (req, res) => {++ const subject = req.principal.kind === 'user' ? req.principal.subject : null;++ const row = await events.byIdForViewer(String(req.params.id || ''), subject);++ if (!row) return refuse(res, 404, 'today.not_found', 'No such event.');++ if (row.visibility === 'public') res.set('Cache-Control', 'public, max-age=60');++ return res.json(events.wire(row));++ });++++ // ── Writes ──────────────────────────────────────────────++ r.post('/events', requirePerson, async (req, res) => {++ const p = req.principal;++ const key = req.get('Idempotency-Key');++ if (key != null && key.length > 100) return invalid(res, 'idempotency_key', 'Idempotency-Key must be at most 100 characters');++ const out = await events.create({ subject: p.subject, username: p.username, body: req.body || {}, idempotencyKey: key || null, now: s.now() });++ if (!out.ok) return jsonError(res, out);++ res.set('Location', `/api/v1/events/${out.event.id}`);++ return res.status(out.replayed ? 200 : 201).json(out.event);++ });++++ r.patch('/events/:id', requirePerson, async (req, res) => {++ const out = await events.patch({ id: String(req.params.id || ''), subject: req.principal.subject, body: req.body || {}, now: s.now() });++ if (!out.ok) return jsonError(res, out);++ return res.json(out.event);++ });++++ r.delete('/events/:id', requirePerson, async (req, res) => {++ const out = await events.cancel({ id: String(req.params.id || ''), subject: req.principal.subject });++ if (!out.ok) return jsonError(res, out);++ return res.json(out.event);++ });++++ // ── Reminders ───────────────────────────────────────────++ r.post('/events/:id/remind', requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const out = await events.remind({ subject: req.principal.subject, id: String(req.params.id || ''), minutesBefore: b.minutes_before, now: s.now() });++ if (!out.ok) return jsonError(res, out);++ return res.json({ event_id: out.reminder.event_id, remind_at: out.reminder.remind_at, minutes_before: out.reminder.minutes_before });++ });++++ r.delete('/events/:id/remind', requirePerson, async (req, res) => {++ const out = await events.unremind({ subject: req.principal.subject, id: String(req.params.id || '') });++ return res.json(out);++ });++++ r.get('/me/reminders', requirePerson, async (req, res) => {++ res.json({ reminders: await events.listReminders(req.principal.subject) });++ });++++ r.get('/me/agenda', requirePerson, async (req, res) => {++ const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);++ if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');++ const out = await agenda.forSubject({ subject: req.principal.subject, token: req.principal.token, days: raw, now: s.now() });++ return res.json(out);++ });++++ r.get('/me/feed', requirePerson, async (req, res) => {++ // Warm the follow cache with the person's own token so their token feed can include followed channels++ // while this process still knows them; a failure is the agenda's follows_unavailable, not this route's.++ if (req.principal.token) await follows.forSubject(req.principal.subject, req.principal.token).catch(() => {});++ const token = await feeds.getOrCreate(req.principal.subject);++ res.set('Cache-Control', 'no-store');++ return res.json({ url: feeds.url(config, token) });++ });++++ r.post('/me/feed/rotate', requirePerson, async (req, res) => {++ if (req.principal.token) await follows.forSubject(req.principal.subject, req.principal.token).catch(() => {});++ const token = await feeds.rotate(req.principal.subject);++ return res.json({ url: feeds.url(config, token) });++ });++++ r.get('/me/brief', requirePerson, async (req, res) => {++ const tz = typeof req.query.tz === 'string' && req.query.tz ? req.query.tz : 'UTC';++ if (!isIanaZone(tz)) return invalid(res, 'tz', 'tz must be a valid IANA time zone');++ const out = await brief.getOrBuild(req.principal.subject, { now: s.now(), tz, token: req.principal.token });++ return res.json(out);++ });++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.today: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The public pages are for++ * search engines and AI crawlers; sign-in, the API, a person's agenda, brief and schedule form are not.++ */++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.Today';++const DESCRIPTION = 'OpenVibe.Today is the network\'s time bus and calendar authority: one schedule for streams and events, reminders before they start, a private ICS feed, and a daily brief.';++const DISALLOW = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];++++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: 'ProductivityApplication', keywords: 'openvibe schedule, stream calendar, ics, reminders, events' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function publicPages() {++ return [++ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Today home', text: 'One schedule for every stream and event on OpenVibe, with reminders and a private calendar feed.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'OpenVibe.Today API', text: 'The JSON API, the ICS feeds and the Events topics Today publishes and consumes.' },++ ];++}++++function createDiscoveryRoutes(ctx) {++ const { config } = 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.today: the public pages are for search and AI crawlers; sign-in, the API, a person\'s agenda and brief 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.Today is the network\'s time bus and calendar authority: a public schedule of streams and events, reminders sent through OpenVibe.Network, a private ICS feed per person, and a daily brief that includes what OpenVibe.Live\'s Rundown talked about yesterday.',++ details: 'GET /api/v1/events lists public events (soonest first). GET /api/v1/events/:id is one event with JSON-LD on its page. A signed-in person schedules a stream or an event, changes it, cancels it and sets a reminder. GET /ics/@<username>.ics is a channel\'s public calendar; a person\'s private feed is at GET /ics/<token>.ics. Events are published as today.event.scheduled|updated|cancelled and live.stream.started|ended is consumed to move an event live and then done.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Today', url: abs('/'), note: 'the public schedule' },++ { title: 'The API', url: abs('/developers') },++ { title: 'Public events (JSON)', url: abs('/api/v1/events') },++ ] },++ { title: 'Machine-readable', links: [++ { 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.Network', url: 'https://openvibe.network', note: 'accounts, follows and notifications' },++ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'where a stream happens' },++ { title: 'OpenVibe.Events', url: 'https://openvibe.events', note: 'the durable event backbone' },++ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ const entries = pages.map((p) => ({ url: p.path, title: p.title, text: p.text }));++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Today: the schedule and the API.',++ base: site,++ maxBytes: 256 * 1024,++ sections: [{ title: 'Pages', pages: entries }],++ }));++ });++++ r.get('/sitemap.xml', (_req, res) => {++ const lastmod = siteUpdated();++ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));++ 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,258 @@++'use strict';++++/**++ * OpenVibe.Today'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`.++ *++ * / the public schedule++ * /@:username a channel's schedule, with its ICS link++ * /e/:id one event, with schema.org Event JSON-LD++ * /me agenda, reminders and the private feed URL++ * /brief today's brief++ * /schedule schedule a stream (the form POSTs and redirects to /e/:id)++ * /developers the API++ * /ics/:file a private token feed or a channel's public calendar++ */++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, badge, time } = require('../render/html');++const { send, SITE_NAME } = require('../render/layout');++const { calendar } = require('../ics');++const views = require('../views');++++const PUBLIC_CACHE = 'public, max-age=60';++const DAY = 24 * 60 * 60_000;++++function createPageRoutes(ctx) {++ const { config, s, events, feeds, agenda, brief } = 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 person = (req) => ({ subject: req.viewer.subject, token: req.viewer.token, username: req.viewer.username });++ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', async (req, res) => {++ const now = s.now();++ const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: null,++ body: raw(views.home({ upcoming: rows, now })),++ description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',++ jsonLd: homeJsonLd(config),++ });++ });++++ // ── A channel's schedule ────────────────────────────────++ r.get('/@:username', async (req, res) => {++ const username = String(req.params.username || '');++ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) return notFound(req, res, 'No such channel');++ const now = s.now();++ const rows = await events.listChannel(username, { from: new Date(now - 7 * DAY).toISOString(), to: new Date(now + 60 * DAY).toISOString() });++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: `@${username}'s schedule`,++ description: `OpenVibe.Today's public schedule for @${username}, with an ICS calendar feed.`,++ body: raw(views.channel({ username, rows, now })),++ });++ });++++ // ── One event's calendar file ("Add to calendar") ──────++ r.get('/e/:id.ics', async (req, res) => {++ const subject = signedIn(req) ? req.viewer.subject : null;++ const row = await events.byIdForViewer(String(req.params.id || ''), subject);++ if (!row) return res.status(404).type('text/plain').send('Not found');++ res.set('Content-Type', 'text/calendar; charset=utf-8');++ res.set('Content-Disposition', `attachment; filename="${row.id}.ics"`);++ res.set('Cache-Control', row.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');++ return res.send(calendar({ name: row.title, events: [events.wire(row)], now: s.now(), baseUrl: base() }));++ });++++ // ── Remind, remove, cancel (plain forms; the API does the same) ──++ const formGuard = (req, res, next) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/e/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);++ return next();++ };++ r.post('/e/:id/remind', formGuard, async (req, res) => {++ await events.remind({ subject: req.viewer.subject, id: String(req.params.id), minutesBefore: (req.body || {}).minutes_before, now: s.now() });++ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);++ });++ r.post('/e/:id/remind/remove', formGuard, async (req, res) => {++ await events.unremind({ subject: req.viewer.subject, id: String(req.params.id) });++ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);++ });++ r.post('/e/:id/cancel', formGuard, async (req, res) => {++ await events.cancel({ id: String(req.params.id), subject: req.viewer.subject });++ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);++ });++++ // ── One event ───────────────────────────────────────────++ r.get('/e/:id', async (req, res) => {++ const subject = signedIn(req) ? req.viewer.subject : null;++ const row = await events.byIdForViewer(String(req.params.id || ''), subject);++ if (!row) return notFound(req, res, 'No such event');++ const e = events.wire(row);++ const own = !!subject && row.owner_subject === subject;++ const reminder = subject ? ((await events.listReminders(subject)).find((x) => x.event_id === e.id) || null) : null;++ if (reminder && reminder.minutes_before == null) reminder.minutes_before = Math.round((new Date(e.starts_at).getTime() - new Date(reminder.remind_at).getTime()) / 60000);++ const end = e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();++ const organizer = e.channel++ ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }++ : { '@type': 'Organization', name: SITE_NAME, url: base() };++ const jsonLd = {++ '@context': 'https://schema.org',++ '@type': 'Event',++ name: e.title,++ startDate: e.starts_at,++ endDate: end,++ eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',++ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',++ location: { '@type': 'VirtualLocation', url: e.url || `${base()}/e/${e.id}` },++ organizer,++ ...(e.description ? { description: e.description } : {}),++ url: `${base()}/e/${e.id}`,++ };++ return page(req, res, {++ index: e.visibility === 'public' && e.status !== 'cancelled',++ cache: e.visibility === 'public' ? PUBLIC_CACHE : null,++ title: e.title,++ description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),++ jsonLd: [jsonLd],++ body: raw(views.event({ e, end, reminder, own, signedIn: !!subject, path: req.originalUrl })),++ });++ });++++ // ── Your agenda ─────────────────────────────────────────++ r.get('/me', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Your agenda', body: raw(views.signIn({ title: 'Your agenda', text: 'Your events, your reminders and the channels you follow, in one place and in your own calendar app.', next: '/me' })) }, 401);++ const p = person(req);++ const a = await agenda.forSubject({ subject: p.subject, token: p.token, days: 14, now: s.now() });++ const reminders = await events.listReminders(p.subject);++ const token = await feeds.getOrCreate(p.subject);++ return page(req, res, {++ title: 'Your agenda',++ description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',++ body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now() })),++ });++ });++++ r.post('/me/feed/rotate', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);++ await feeds.rotate(req.viewer.subject);++ return res.redirect(303, '/me');++ });++++ // ── The brief ───────────────────────────────────────────++ r.get('/brief', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Your brief', body: raw(views.signIn({ title: 'Your brief', text: 'One short page for your day: what is coming up, your reminders and what streamers talked about yesterday.', next: '/brief' })) }, 401);++ const tz = typeof req.query.tz === 'string' && req.query.tz ? req.query.tz : 'UTC';++ const out = await brief.getOrBuild(req.viewer.subject, { now: s.now(), tz, token: req.viewer.token });++ return page(req, res, {++ title: `Your brief for ${out.day}`,++ description: 'What is left of today, your reminders and what OpenVibe.Live talked about yesterday.',++ body: raw(views.brief({ out, name: req.viewer.displayName || req.viewer.username, tz })),++ });++ });++++ // ── Schedule a stream ───────────────────────────────────++ /** "2026-10-30T20:00" read as wall time in tz → ISO UTC (null when it is not that shape). */++ function wallToUtc(local, tz) {++ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(local || ''));++ if (!m) return null;++ const asUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);++ const offset = (t) => {++ const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })++ .formatToParts(new Date(t)).map((x) => [x.type, x.value]));++ return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute) - t;++ };++ try { let t = asUtc - offset(asUtc); t = asUtc - offset(t); return new Date(t).toISOString(); } catch { return null; }++ }++ const scheduleForm = (values = {}, problem = null) => raw(views.schedule({ values, problem }));++++ r.get('/schedule', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Schedule a stream', body: raw(views.signIn({ title: 'Schedule a stream', text: 'Scheduling belongs to your OpenVibe account, so it shows on your channel and you can change or cancel it later.', next: '/schedule' })) }, 401);++ return page(req, res, {++ title: 'Schedule a stream',++ description: 'Put a stream or an event on your OpenVibe.Today schedule.',++ body: scheduleForm({ tz: typeof req.query.tz === 'string' ? req.query.tz : 'UTC' }),++ });++ });++++ r.post('/schedule', async (req, res) => {++ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('Scheduling belongs to an account.', '/schedule')}` }, 401);++ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Schedule from <a href="/schedule">the form</a>.</p>` }, 403);++ const b = req.body || {};++ const tz = b.tz || 'UTC';++ // The form's datetime-local is wall time in the chosen zone; the API takes an instant.++ const startsAt = wallToUtc(b.starts_at, tz) || b.starts_at;++ const length = Math.min(24 * 60, Math.max(0, parseInt(b.length, 10) || 0));++ const body = { kind: b.kind || 'stream', title: b.title, description: b.description, visibility: b.visibility, tz, starts_at: startsAt,++ ...(length && startsAt && !Number.isNaN(Date.parse(startsAt)) ? { ends_at: new Date(Date.parse(startsAt) + length * 60_000).toISOString() } : {}) };++ const out = await events.create({ subject: req.viewer.subject, username: req.viewer.username, body, now: s.now() });++ if (!out.ok) {++ return page(req, res, {++ title: 'Schedule a stream',++ body: scheduleForm({ ...b, tz }, out.message || 'That could not be scheduled.'),++ }, out.status || 400);++ }++ return res.redirect(303, `/e/${out.event.id}`);++ });++++ // ── Developers ──────────────────────────────────────────++ r.get('/developers', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE,++ title: 'The OpenVibe.Today API',++ description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.', siteUrl: base() })],++ body: raw(views.developers()),++ }));++++ // ── ICS feeds ───────────────────────────────────────────++ r.get('/ics/:file', async (req, res) => {++ const file = String(req.params.file || '');++ if (!file.endsWith('.ics')) return res.status(404).type('text/plain').send('Not found');++ const key = file.slice(0, -4);++ const now = s.now();++ const fromMs = now - 7 * DAY;++ const toMs = now + 60 * DAY;++ const nowDate = new Date(now).toISOString();++ if (key.startsWith('@')) {++ const username = key.slice(1);++ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) return res.status(404).type('text/plain').send('Not found');++ const rows = (await events.listChannel(username, { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString(), includeCancelled: true }))++ .filter((e) => e.status !== 'cancelled' || new Date(e.updated_at).getTime() >= fromMs);++ res.set('Content-Type', 'text/calendar; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=300');++ return res.send(calendar({ name: `${SITE_NAME} — @${username}`, events: rows, now, baseUrl: base() }));++ }++ const feed = await feeds.findByToken(key);++ if (!feed) return res.status(404).type('text/plain').send('Not found');++ const rows = await agenda.windowFor({ subject: feed.subject, token: null, fromMs, toMs, now });++ res.set('Content-Type', 'text/calendar; charset=utf-8');++ res.set('Cache-Control', 'private, max-age=300');++ return res.send(calendar({ name: `${SITE_NAME} — your agenda`, events: rows.map((x) => events.wire(x)), now, baseUrl: base() }));++ });++++ // ── Discovery ───────────────────────────────────────────++ r.use(createDiscoveryRoutes(ctx));++++ function notFound(req, res, title) {++ return page(req, res, {++ title,++ body: raw(views.notFound()),++ }, 404);++ }++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,102 @@++'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 (today_at).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience openvibe.today. No Today 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 schedule, change or cancel an event here.++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++// Today's routes are all a person acting for themself, or a public read. No capability is declared.++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.Today'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.today, 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, 'today.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.today itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'today.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,83 @@++'use strict';++++/**++ * RFC 5545 calendars. Correct line endings (CRLF), TEXT escaping and folding at 75 octets without splitting a++ * UTF-8 sequence; every VEVENT carries a stable UID, SEQUENCE, STATUS and LAST-MODIFIED, and times are UTC.++ */++const { wire } = require('./events/store');++++const CRLF = '\r\n';++++function escapeText(value) {++ return String(value == null ? '' : value)++ .replace(/\\/g, '\\\\')++ .replace(/;/g, '\\;')++ .replace(/,/g, '\\,')++ .replace(/\r\n|\r|\n/g, '\\n');++}++++/** Fold one content line at 75 octets per line, never splitting a UTF-8 code point. */++function foldLine(line, limit = 75) {++ const s = String(line);++ if (Buffer.byteLength(s, 'utf8') <= limit) return s;++ const out = [];++ let current = '';++ let bytes = 0;++ for (const ch of s) {++ const n = Buffer.byteLength(ch, 'utf8');++ if (bytes + n > limit) {++ out.push(current);++ current = ` ${ch}`;++ bytes = 1 + n;++ } else {++ current += ch;++ bytes += n;++ }++ }++ out.push(current);++ return out.join(CRLF);++}++++function stamp(value) {++ const d = value instanceof Date ? value : new Date(value);++ const p = (n, w = 2) => String(n).padStart(w, '0');++ return `${p(d.getUTCFullYear(), 4)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;++}++++function vevent(row, { now = Date.now(), baseUrl = 'https://openvibe.today' } = {}) {++ const e = row.id ? row : wire(row);++ const starts = new Date(e.starts_at);++ const ends = e.ends_at ? new Date(e.ends_at) : new Date(starts.getTime() + 60 * 60_000);++ const status = e.status === 'cancelled' ? 'CANCELLED' : 'CONFIRMED';++ const url = e.url || `${String(baseUrl).replace(/\/+$/, '')}/e/${e.id}`;++ const lines = [++ 'BEGIN:VEVENT',++ `UID:${e.id}@openvibe.today`,++ `DTSTAMP:${stamp(now)}`,++ `DTSTART:${stamp(starts)}`,++ `DTEND:${stamp(ends)}`,++ `SUMMARY:${escapeText(e.title)}`,++ `DESCRIPTION:${escapeText(e.description || '')}`,++ `URL:${url}`,++ `SEQUENCE:${Number(e.seq) || 0}`,++ `STATUS:${status}`,++ `LAST-MODIFIED:${stamp(e.updated_at || now)}`,++ 'END:VEVENT',++ ];++ return lines.map((l) => foldLine(l)).join(CRLF);++}++++function calendar({ name, events, now = Date.now(), baseUrl = 'https://openvibe.today' }) {++ const lines = [++ 'BEGIN:VCALENDAR',++ 'VERSION:2.0',++ 'PRODID:-//OpenVibe//Today//EN',++ 'CALSCALE:GREGORIAN',++ `X-WR-CALNAME:${escapeText(name)}`,++ ...events.map((e) => vevent(e, { now, baseUrl })),++ 'END:VCALENDAR',++ ];++ return `${lines.join(CRLF)}${CRLF}`;++}++++module.exports = { calendar, vevent, escapeText, foldLine, stamp, CRLF };nnew file mode 100644@@@ -0,0 +1,41 @@++'use strict';++++/**++ * Account export and deletion → Today (ADR-033; openvibe-sdk/account-data). The rows a person owns are their++ * events, the reminders they set, their feed token's row, their briefs and their idempotency keys. Every subject++ * column stores the bare usr_ id.++ *++ * A feed token is a bearer capability for one calendar, so it is NOT exported: today_feeds exports only its++ * subject and created_at. On deletion the row goes, so the old URL is 404 at once.++ *++ * The order matters for the orphan cleanup in extraErase: the person's events are erased with their subject rows,++ * then reminders other people set on those events and still-pending outbox rows for those events go too.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'today_events', subject: 'owner_subject', value: (usr) => usr, file: 'events.json', erase: 'delete' },++ { table: 'today_reminders', subject: 'subject', value: (usr) => usr, file: 'reminders.json', erase: 'delete' },++ // The token itself is never exported; it is a bearer capability for the person's calendar.++ { table: 'today_feeds', subject: 'subject', value: (usr) => usr, file: 'feeds.json', columns: ['subject', 'created_at'], erase: 'delete' },++ { table: 'today_briefs', subject: 'subject', value: (usr) => usr, file: 'briefs.json', erase: 'delete' },++ { table: 'today_idempotency', subject: 'subject', value: (usr) => usr, file: 'idempotency.json', erase: 'delete' },++];++++async function extraErase(t) {++ await t.exec('DELETE FROM today_reminders WHERE event_id NOT IN (SELECT id FROM today_events)');++ await t.exec("DELETE FROM today_outbox WHERE payload->>'event_id' NOT IN (SELECT id FROM today_events)");++}++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'today',++ tables: TABLES,++ note: 'A feed token is a bearer capability for one calendar and is not exported.',++ extraErase,++ log,++ });++}++++module.exports = { create, TABLES, TOPICS, extraErase };nnew file mode 100644@@@ -0,0 +1,56 @@++'use strict';++++/**++ * The follow graph from OpenVibe.Network, read with the person's own token (GET /api/v1/me/follows), cached 60 s++ * per subject. A followed channel's target_id is its owner's usr_ subject, which is what today_events.owner_subject++ * stores. A failure returns no follows and `unavailable: true`: the agenda still answers with the person's own and++ * reminded events.++ */++function createFollows({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {++ const cache = new Map(); // subject -> { at, expires, follows, unavailable }++ const lastGood = new Map(); // subject -> the last successful follows, for a token feed with no Network token++ const ttl = config.follows.cacheMs;++++ async function fetchFollows(token) {++ const res = await fetchImpl(`${config.networkUrl}/api/v1/me/follows?type=channel&limit=200`, {++ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },++ signal: AbortSignal.timeout(5000),++ });++ if (!res.ok) throw new Error(`follows ${res.status}`);++ const body = await res.json().catch(() => null);++ if (!body || !Array.isArray(body.items)) throw new Error('follows: no items');++ const targets = [];++ for (const item of body.items) {++ if (item && item.target_type === 'channel' && typeof item.target_id === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(item.target_id)) targets.push(item.target_id);++ }++ return [...new Set(targets)];++ }++++ async function forSubject(subject, token) {++ const hit = cache.get(subject);++ if (hit && ttl > 0 && hit.expires > now()) return { follows: hit.follows, unavailable: hit.unavailable };++ try {++ const follows = await fetchFollows(token);++ cache.set(subject, { at: now(), expires: now() + ttl, follows, unavailable: false });++ lastGood.set(subject, follows);++ return { follows, unavailable: false };++ } catch (err) {++ log.warn(`[OpenVibe.Today] follows unavailable for ${subject}: ${(err && err.message) || err}`);++ cache.set(subject, { at: now(), expires: now() + ttl, follows: [], unavailable: true });++ return { follows: [], unavailable: true };++ }++ }++++ /** The last successful follows for a subject, even after the 60 s cache: a token feed has no person token. */++ function peek(subject) {++ return [...(lastGood.get(subject) || [])];++ }++++ function clear(subject = null) {++ if (subject) { cache.delete(subject); lastGood.delete(subject); } else { cache.clear(); lastGood.clear(); }++ }++++ return { forSubject, peek, clear, cache, lastGood };++}++++module.exports = { createFollows };nnew file mode 100644@@@ -0,0 +1,64 @@++'use strict';++++/**++ * OpenVibe.Today — process entry. `node server/index.js`++ * Listens on PORT (5100) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++++/**++ * 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.Today', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.keys.client.stop(),++ () => ctx.publisher.stop(),++ () => ctx.reminders.stop(),++ () => ctx.brief.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.Today] ${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 TODAY_DRILL (or when it is not configured); tests call their runOnce().++ ctx.publisher.start();++ ctx.reminders.start();++ ctx.brief.start();++++ // Subscribe to the two live topics and the two account topics at OpenVibe.Events (idempotent; off without the++ // Events URL and secret). The consumer itself is mounted in server/app.js.++ const subscriptions = config.drill ? null : ctx.consumer.startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });++ const extra = [() => { if (subscriptions) subscriptions.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Today] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,95 @@++'use strict';++++/**++ * Network notifications for a due reminder. A reminder row stores the person's usr_ subject; Network's push route++ * wants its numeric user id, resolved in batches through POST /internal/identity/resolve-batch and cached. Both calls++ * use this service's own client-credentials token for audience openvibe.network (identity.subject.resolve and++ * network.notifications.push).++ */++const { serviceAuth } = require('openvibe-contracts');++++function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = console }) {++ const cache = new Map(); // subject -> { at, expires, userId }++ const ttl = config.notifications.resolveCacheMs;++ const canSend = Boolean(config.oauth.clientSecret);++ const identityTokens = canSend ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,++ }) : null;++ const pushTokens = canSend ? serviceAuth.createTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'network.notifications.push', fetchImpl,++ }) : null;++++ async function resolveUserIds(subjects) {++ const out = new Map();++ const missing = [];++ for (const subject of subjects) {++ const hit = cache.get(subject);++ if (hit && (ttl === 0 || hit.expires > s.now())) { out.set(subject, hit.userId); } else missing.push(subject);++ }++ const unique = [...new Set(missing)].filter((x) => typeof x === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(x));++ for (let i = 0; i < unique.length; i += 500) {++ const batch = unique.slice(i, i + 500);++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/identity/resolve-batch`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await identityTokens.authHeaders()) },++ body: JSON.stringify({ subject_ids: batch }),++ signal: AbortSignal.timeout(10_000),++ });++ if (!res.ok) throw new Error(`identity resolve-batch ${res.status}`);++ const body = await res.json().catch(() => null);++ const results = (body && body.results) || {};++ for (const subject of batch) {++ const user = results[subject];++ const userId = user && Number.isFinite(Number(user.network_user_id)) ? Number(user.network_user_id) : null;++ cache.set(subject, { at: s.now(), expires: s.now() + ttl, userId });++ out.set(subject, userId);++ }++ }++ return out;++ }++++ function titleFor(event, now) {++ if (event.status === 'live') return `${event.title} is live now`;++ const minutes = Math.max(0, Math.round((new Date(event.starts_at).getTime() - now) / 60_000));++ return `${event.title} starts in ${minutes} min`;++ }++++ async function sendReminder({ subject, event, now = s.now() }) {++ if (!canSend) throw new Error('TODAY_OAUTH_CLIENT_SECRET is not set: Today cannot send a notification');++ const ids = await resolveUserIds([subject]);++ const userId = ids.get(subject);++ if (!userId) {++ log.warn(`[OpenVibe.Today] reminder for ${subject}: Network has no user id for that subject`);++ return { ok: false, reason: 'no_user' };++ }++ const title = titleFor(event, now);++ const body = {++ user_id: userId,++ type: 'TODAY_REMINDER',++ category: event.kind === 'stream' ? 'stream' : 'system',++ priority: 'normal',++ icon: '⏰',++ title,++ service: 'today',++ url: `${String(config.baseUrl).replace(/\/+$/, '')}/e/${event.id}`,++ };++ const res = await fetchImpl(`${config.networkInternalUrl}/internal/notifications/push`, {++ method: 'POST',++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await pushTokens.authHeaders()) },++ body: JSON.stringify(body),++ signal: AbortSignal.timeout(10_000),++ });++ if (!res.ok) throw new Error(`notification push ${res.status}`);++ return { ok: true, user_id: userId, title };++ }++++ function clear(subject = null) { if (subject) cache.delete(subject); else cache.clear(); }++++ return { sendReminder, resolveUserIds, clear, titleFor, canSend };++}++++module.exports = { createNotifier };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 (server/index.js), 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: 'today',++ 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,50 @@++'use strict';++++/**++ * Due reminders: every 60 s, one Network notification each. The row is claimed with an atomic++ * `UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. A send++ * failure puts the claim back for the next tick; a cancelled or done event's reminder never sends.++ */++function createReminders({ config, s, events, notifier, log = console, intervalMs = 60_000 }) {++ let timer = null;++ const stats = { sent: 0, failed: 0, skipped: 0, last_error: null, last_run: null };++++ async function runOnce({ now = s.now() } = {}) {++ const due = await events.dueReminders(now);++ let sent = 0;++ let failed = 0;++ let skipped = 0;++ for (const row of due) {++ const claimed = await events.claimReminder(row.subject, row.event_id, now);++ if (!claimed) { skipped++; continue; }++ try {++ const out = await notifier.sendReminder({ subject: row.subject, event: claimed.event, now });++ if (out && out.ok) { sent++; stats.sent++; } else { skipped++; stats.skipped++; }++ } catch (err) {++ failed++;++ stats.failed++;++ stats.last_error = String((err && err.message) || err).slice(0, 300);++ log.warn(`[OpenVibe.Today] reminder ${row.event_id} not sent: ${stats.last_error}`);++ await events.releaseReminder(row.subject, row.event_id).catch(() => {});++ }++ }++ stats.last_run = new Date(now).toISOString();++ return { due: due.length, sent, failed, skipped };++ }++++ function start() {++ if (timer || config.drill) return false;++ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Today] reminder tick failed:', err.message)); }, intervalMs);++ if (timer.unref) timer.unref();++ return true;++ }++++ function stop() {++ if (timer) clearInterval(timer);++ timer = null;++ }++++ return { runOnce, start, stop, stats: () => ({ ...stats }) };++}++++module.exports = { createReminders };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,119 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * This is Help's layout with Today's name, navigation 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 { html, raw, render } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Today';++const TAGLINE = 'The network\'s time bus and calendar authority.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Schedule', href: '/schedule' },++ { label: 'Your brief', href: '/brief' },++ { 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: 'today',++ 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: 'today', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/me">Your agenda</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => html`<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="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">++${appIcon.headTags({ site: 'today' })}++<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/today.css')}">++<script src="${ovServe.url('motion.js')}" defer></script>++<script src="${asset('js/today.js')}" defer></script>++${(o.styles || []).map((name) => `<link rel="stylesheet" href="${require('openvibe-shared/serve').url(name)}">`).join('\n')}++${render(scripts)}++<meta name="ov-boost" content="today@${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">${render(account)}</div></noscript>++<main id="main" class="td ov-room" style="${rooms.style('today')}">++${render(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, raw, html };nnew file mode 100644@@@ -0,0 +1,275 @@++'use strict';++/**++ * The Today room's pages (plan 2026-10-28 §5.3, v1: the authority, schedules, Remind, ICS and the brief). Server-++ * rendered and whole without JavaScript: every action is a plain form or link. public/js/today.js adds the reader's++ * own time beside each event's, picks the device's time zone on the schedule form, and copies the feed URL.++ *++ * home({ upcoming, now }) the front door: the day's ring, what is coming, three scenes++ * channel({ username, rows, now }) a channel's schedule and its calendar++ * event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)++ * me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders and private feed++ * brief({ out, name, tz }) the day's brief++ * schedule({ values, problem, zones }) the schedule form++ * signIn({ title, text, next }) a page that needs an account++ * developers() the API, the feeds and the topics++ * notFound()++ * Each returns HTML for the page body; server/http/pages.js sends it through the layout.++ */++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++const rooms = require('openvibe-shared/rooms');++const { esc } = require('../render/html');++++const ROOM = rooms.get('today');++const DAY = 24 * 60 * 60_000;++++/** Parts of an instant in a zone (UTC when the zone is unknown). */++function parts(iso, tz) {++ const d = new Date(iso);++ const f = (zone) => Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })++ .formatToParts(d).map((p) => [p.type, p.value]));++ try { return f(tz || 'UTC'); } catch { return f('UTC'); }++}++const timeText = (p) => `${p.hour}:${p.minute} ${p.dayPeriod || ''}`.trim();++function dayKey(iso, tz) {++ try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); }++}++function dayLabel(iso, tz, now) {++ const k = dayKey(iso, tz);++ if (k === dayKey(new Date(now).toISOString(), tz)) return 'Today';++ if (k === dayKey(new Date(now + DAY).toISOString(), tz)) return 'Tomorrow';++ const p = parts(iso, tz);++ return `${p.weekday}, ${p.month} ${p.day}`;++}++/** "8:00 PM – 9:30 PM PDT" in the event's zone. */++function range(e, end) {++ const a = parts(e.starts_at, e.tz);++ const b = parts(end, e.tz);++ return `${timeText(a)} – ${timeText(b)} ${b.timeZoneName}`;++}++const statusPill = (st) => (st === 'live' ? '<span class="td-pill is-live">Live now</span>'++ : st === 'cancelled' ? '<span class="td-pill is-off">Cancelled</span>'++ : st === 'done' ? '<span class="td-pill is-done">Ended</span>' : '');++++/** A date tile: MON / 28. */++function tile(iso, tz) {++ const p = parts(iso, tz);++ return `<span class="td-tile" aria-hidden="true"><span class="td-tile-m">${esc(p.month)}</span><span class="td-tile-d">${esc(p.day)}</span></span>`;++}++++/** One event as a row in a list. */++function row(e, { now, showChannel = true } = {}) {++ const p = parts(e.starts_at, e.tz);++ const tags = [e.own ? '<span class="td-tag">Yours</span>' : '', e.reminded ? '<span class="td-tag is-remind">Reminder set</span>' : '', e.visibility === 'followers' ? '<span class="td-tag">Followers</span>' : ''].join('');++ return `<li class="td-ev td-ev-${esc(e.status)}">++<a class="td-ev-link" href="/e/${esc(e.id)}">++${tile(e.starts_at, e.tz)}++<span class="td-ev-body"><span class="td-ev-title">${esc(e.title)}</span>++<span class="td-ev-meta"><time datetime="${esc(e.starts_at)}" data-local="time">${esc(timeText(p))} ${esc(p.timeZoneName)}</time>${showChannel && e.channel ? `<span>@${esc(e.channel)}</span>` : ''}${e.kind === 'event' ? '<span>Event</span>' : ''}</span></span>++<span class="td-ev-end">${statusPill(e.status)}${tags}</span>++</a></li>`;++}++++/** Events grouped by day (in each event's zone), soonest first. */++function days(rows, { now, empty, showChannel = true } = {}) {++ if (!rows.length) return `<div class="td-empty">${kit.icon('calendar')}<p>${esc(empty)}</p></div>`;++ const groups = [];++ for (const e of rows) {++ const label = dayLabel(e.starts_at, e.tz, now);++ const g = groups[groups.length - 1];++ if (g && g.label === label) g.rows.push(e); else groups.push({ label, rows: [e] });++ }++ return groups.map((g) => `<section class="td-day" aria-label="${esc(g.label)}"><h3 class="td-day-h">${esc(g.label)}</h3><ul class="td-evs">${g.rows.map((e) => row(e, { now, showChannel })).join('')}</ul></section>`).join('');++}++++/** The day's ring: 24 hours, today's events as dots at their hour, and "now" as a hand. Drawn in UTC; today.js turns it++ * to the reader's own clock (the dots move to their local hours, the hand follows the time). */++function ring(rows, now) {++ const today = dayKey(new Date(now).toISOString(), 'UTC');++ const hourOf = (iso) => { const d = new Date(iso); return d.getUTCHours() + d.getUTCMinutes() / 60; };++ const dots = rows.filter((e) => e.status !== 'cancelled' && dayKey(e.starts_at, 'UTC') === today).slice(0, 12)++ .map((e, i) => `<i class="td-ring-dot${e.status === 'live' ? ' is-live' : ''}" data-at="${esc(e.starts_at)}" style="--a:${(hourOf(e.starts_at) / 24 * 360).toFixed(1)}deg;--i:${i}" title="${esc(e.title)}"></i>`);++ // A quiet day still shows the ring's shape: a few sample hours, marked as examples.++ const sample = dots.length ? '' : [9, 13.5, 19, 21].map((h, i) => `<i class="td-ring-dot is-sample" style="--a:${(h / 24 * 360).toFixed(1)}deg;--i:${i}"></i>`).join('');++ const ticks = Array.from({ length: 24 }, (_, h) => `<b class="td-ring-tick${h % 6 === 0 ? ' is-major' : ''}" style="--a:${h * 15}deg"></b>`).join('');++ const nowDeg = (hourOf(new Date(now).toISOString()) / 24 * 360).toFixed(1);++ return `<div class="td-ring" aria-hidden="true" data-ring style="--now:${nowDeg}deg">++<span class="td-ring-face"></span>${ticks}${dots.join('')}${sample}++<span class="td-ring-hand"></span><span class="td-ring-hub"></span>++<span class="td-ring-label td-ring-l0">00</span><span class="td-ring-label td-ring-l6">06</span><span class="td-ring-label td-ring-l12">12</span><span class="td-ring-label td-ring-l18">18</span>++</div>`;++}++++function home({ upcoming = [], now = Date.now() } = {}) {++ const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));++ const live = upcoming.filter((e) => e.status === 'live');++ const body = sc.room({++ room: 'today',++ hero: {++ title: ROOM.line,++ lede: 'Streams, events and reminders across OpenVibe on one calendar. Schedule a stream and your followers hear about it, set a reminder and it finds you on whatever site you are on, and subscribe once to see it all in your own calendar app.',++ start: { href: '/schedule', label: 'Schedule a stream' },++ chips: [{ label: 'Your agenda', href: '/me' }, { label: 'Your brief', href: '/brief' }, { label: 'Calendar feeds', href: '/developers#feeds' }],++ motif: ring(upcoming, now),++ },++ proof: [++ { value: live.length, label: live.length === 1 ? 'live now' : 'live now' },++ { value: upcoming.length, label: upcoming.length === 1 ? 'thing coming up' : 'things coming up' },++ { value: channels.size, label: channels.size === 1 ? 'channel scheduling' : 'channels scheduling' },++ ].filter((p) => p.value),++ html: `<section class="td-coming" id="td-upcoming" aria-labelledby="td-coming-h"><div class="td-head-row"><h2 id="td-coming-h">Coming up</h2><a class="td-more" href="/developers#feeds">Subscribe${kit.icon('chevron')}</a></div>++${days(upcoming, { now, empty: 'Nothing is scheduled yet. Be the first: schedule a stream and it shows here, on your channel and in your followers’ calendars.' })}</section>`,++ scenes: [++ { title: 'A reminder that finds you', text: 'Tap Remind on anything coming up. Ten minutes before, it arrives in the bell on whichever OpenVibe site you have open, and it waits politely through your quiet hours.',++ html: `<div class="td-demo td-demo-remind"><div class="td-demo-card">${tile(new Date(now + DAY).toISOString(), 'UTC')}<span><b>Night owls: Seattle walk</b><small>goosely · 8:00 PM</small></span><span class="td-demo-btn">Remind me</span></div>++<div class="td-demo-toast">${kit.icon('calendar')}<span><b>Night owls: Seattle walk starts in 10 min</b><small>Today · openvibe.today</small></span></div></div>`,++ action: { href: '/me', label: 'Your agenda' } },++ { title: 'In the calendar you already use', text: 'Every channel has a public calendar, and you get a private one with everything you follow. Subscribe once in Google Calendar, Apple Calendar or Outlook, and changes and cancellations follow on their own.',++ html: `<div class="td-demo td-demo-cal" aria-hidden="true"><div class="td-cal-head"><span>Fri</span><span>Sat</span><span>Sun</span></div><div class="td-cal-grid"><i style="--c:1;--r:3">Robot build, part 2</i><i style="--c:2;--r:5" class="is-room">Night owls</i><i style="--c:3;--r:2">Speedrun Sunday</i></div></div>`,++ action: { href: '/developers#feeds', label: 'Calendar feeds' } },++ { title: 'Schedule once, your followers know', text: 'Put a stream on your schedule and it shows on your channel, in your followers’ bells and in their calendars. Cancel it and it disappears everywhere.',++ html: `<div class="td-demo td-demo-sched"><div class="td-demo-card is-form"><span class="td-demo-k">Stream</span><b>Robot build, part 2</b><small>Fri, 8:00 PM PDT · 2 hours · Public</small><span class="td-demo-btn">Schedule it</span></div>++<div class="td-demo-toast">${kit.icon('live')}<span><b>carol scheduled a stream</b><small>Robot build, part 2 · Fri, 8:00 PM PDT</small></span></div></div>`,++ action: { href: '/schedule', label: 'Schedule a stream' } },++ ],++ explore: { title: 'More on Today', items: [++ { title: 'Your agenda', text: 'Your events, reminders and follows', href: '/me', icon: 'ov:calendar' },++ { title: 'Your brief', text: 'Today in one page', href: '/brief', icon: 'ov:news' },++ { title: 'The API', text: 'JSON, calendar feeds and topics', href: '/developers', icon: 'ov:code' },++ ] },++ faq: { items: [++ ['How does a reminder reach me?', 'As an OpenVibe notification: it shows in the bell on every OpenVibe site you have open, and by email or push if you chose that for its kind. Your quiet hours hold it until they end.'],++ ['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],++ ['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],++ ] },++ });++ return `<div class="td-wrap" id="td-home">${body}</div>`;++}++++function channel({ username, rows = [], now = Date.now() } = {}) {++ const feed = `/ics/@${encodeURIComponent(username)}.ics`;++ return `<div class="td-wrap td-page-pad" id="td-channel" data-channel="${esc(username)}">++<header class="td-page-h"><p class="td-kicker">Schedule</p><h1>@${esc(username)}</h1>++<p class="td-lede">What this channel has planned, and a calendar you can subscribe to.</p>++<div class="td-acts">${kit.button({ href: `https://openvibe.live/@${encodeURIComponent(username)}`, label: 'Watch on Live', kind: 'primary' })}++<a class="ovk-btn ovk-btn--quiet" href="webcal://openvibe.today${esc(feed)}" data-feed="${esc(feed)}">${kit.icon('calendar')}Subscribe</a>++<a class="ovk-btn ovk-btn--quiet" href="${esc(feed)}" download>Download .ics</a></div></header>++${days(rows, { now, showChannel: false, empty: 'Nothing public is planned on this channel yet.' })}++</div>`;++}++++const MINUTES = [[0, 'When it starts'], [10, '10 minutes before'], [30, '30 minutes before'], [60, '1 hour before'], [1440, '1 day before']];++++function event({ e, end, reminder = null, own = false, signedIn = false, path = '/' } = {}) {++ const p = parts(e.starts_at, e.tz);++ const done = e.status === 'cancelled' || e.status === 'done';++ const remindForm = done ? '' : !signedIn++ ? `<a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(path)}">${kit.icon('bell')}Sign in to get a reminder</a>`++ : reminder++ ? `<form method="post" action="/e/${esc(e.id)}/remind/remove" class="td-inline"><span class="td-set">${kit.icon('check')}Reminder set: ${esc((MINUTES.find(([m]) => m === reminder.minutes_before) || [0, `${reminder.minutes_before} minutes before`])[1].toLowerCase())}</span><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Remove</button></form>`++ : `<form method="post" action="/e/${esc(e.id)}/remind" class="td-inline td-remind"><label class="td-sr" for="td-min">When</label><select id="td-min" name="minutes_before">${MINUTES.map(([m, l]) => `<option value="${m}"${m === 10 ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select><button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('bell')}Remind me</button></form>`;++ const watch = e.channel ? kit.button({ href: `https://openvibe.live/@${encodeURIComponent(e.channel)}`, label: e.status === 'live' ? 'Watch now' : `@${e.channel} on Live`, kind: e.status === 'live' ? 'primary' : 'quiet' }) : '';++ return `<div class="td-wrap td-page-pad"><article class="td-event${done ? ' is-over' : ''}" data-event-id="${esc(e.id)}">++<header class="td-event-h">${tile(e.starts_at, e.tz)}++<div><p class="td-kicker">${e.kind === 'stream' ? 'Stream' : 'Event'}${e.channel ? ` · <a href="/@${esc(e.channel)}">@${esc(e.channel)}</a>` : ''}</p>++<h1>${esc(e.title)} ${statusPill(e.status)}</h1>++<p class="td-when"><time datetime="${esc(e.starts_at)}">${esc(`${p.weekday}, ${p.month} ${p.day}, ${p.year}`)}</time> · ${esc(range(e, end))}</p>++<p class="td-local" data-local="range" data-start="${esc(e.starts_at)}" data-end="${esc(end)}" hidden></p></div></header>++${e.description ? `<p class="td-desc">${esc(e.description)}</p>` : ''}++<div class="td-acts">${remindForm}<a class="ovk-btn ovk-btn--quiet" href="/e/${esc(e.id)}.ics" download>${kit.icon('calendar')}Add to calendar</a>${watch}</div>++${own && !done ? `<form method="post" action="/e/${esc(e.id)}/cancel" class="td-owner" data-confirm="Cancel this ${e.kind === 'stream' ? 'stream' : 'event'}? Followers' calendars and bells will drop it."><p>This is yours. Cancelling removes it from your channel, from calendars that subscribe, and from your followers' bells.</p><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Cancel it</button></form>` : ''}++<dl class="td-facts"><div><dt>Who can see it</dt><dd>${e.visibility === 'followers' ? 'Followers only' : 'Everyone'}</dd></div><div><dt>Time zone</dt><dd>${esc(e.tz.replace(/_/g, ' '))}</dd></div>${e.channel ? `<div><dt>Calendar</dt><dd><a href="/ics/@${esc(encodeURIComponent(e.channel))}.ics">@${esc(e.channel)}'s feed</a></dd></div>` : ''}<div><dt>Changed</dt><dd><time datetime="${esc(e.updated_at)}" data-local="ago">${esc(String(e.updated_at).slice(0, 16).replace('T', ' '))} UTC</time></dd></div></dl>++</article></div>`;++}++++function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now() } = {}) {++ const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');++ return `<div class="td-wrap td-page-pad" id="td-me">++<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>++<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>++<div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>++${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}++<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>++<aside class="td-side">++<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Reminders</h2>++${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/e/${esc(x.event_id)}">${esc(x.event.title)}</a><small><time datetime="${esc(x.remind_at)}" data-local="time">${esc(timeText(parts(x.remind_at, x.event.tz)))} ${esc(parts(x.remind_at, x.event.tz).timeZoneName)}</time>, ${esc(dayLabel(x.remind_at, x.event.tz, now).toLowerCase())}</small></li>`).join('')}</ul>` : '<p class="td-fine">None yet. Open anything coming up and tap Remind me.</p>'}++</section>++<section class="td-card" aria-labelledby="td-feed-h"><h2 id="td-feed-h">${kit.icon('calendar')}Your calendar feed</h2>++<p class="td-fine">Everything above, in Google Calendar, Apple Calendar or Outlook. Anyone with this link can read it, so keep it to yourself.</p>++<div class="td-feed"><input id="td-feed-url" type="text" readonly value="${esc(feedUrl)}" aria-label="Your private calendar link"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-copy="#td-feed-url" hidden>Copy</button></div>++<div class="td-acts"><a class="ovk-btn ovk-btn--primary ovk-btn--small" href="${esc(webcal)}">Subscribe</a>++<form method="post" action="/me/feed/rotate" class="td-inline" data-confirm="Make a new link? Calendars using the old one stop updating."><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">New link</button></form></div>++</section></aside></div>++</div>`;++}++++function brief({ out, name, tz = 'UTC' } = {}) {++ const ACT = { remind: 'Remind', watch: 'Watch', buy: 'Buy from', approve: 'Approve', open: 'Open' };++ const sections = (out && out.sections) || [];++ return `<div class="td-wrap td-page-pad td-brief" id="td-brief">++<header class="td-page-h"><p class="td-kicker">Your brief</p><h1 data-typed>${esc(out.day)}</h1>++<p class="td-lede">What is left of today for ${esc(name || 'you')}${tz !== 'UTC' ? `, in ${esc(tz.replace(/_/g, ' '))}` : ''}.</p></header>++${sections.length ? `<div class="td-brief-grid">${sections.map((sec) => `<section class="td-card td-brief-sec" id="td-brief-${esc(sec.id)}"><h2>${esc(sec.title)}</h2><ul>${sec.items.map((it) => `<li><a href="${esc(it.url)}">${esc(it.text)}</a>${it.action && ACT[it.action] ? `<a class="td-chip" href="${esc(it.url)}">${esc(ACT[it.action])}</a>` : ''}</li>`).join('')}</ul></section>`).join('')}</div>`++ : `<div class="td-empty">${kit.icon('book')}<p>A quiet day so far. Schedule a stream, or set a reminder on something coming up, and it shows here tomorrow morning.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div>`}++<p class="td-fine">Built each morning from your agenda, your reminders and what streamers talked about yesterday on The Rundown. Every line links to where it came from.</p>++</div>`;++}++++const ZONES = ['UTC', 'America/Los_Angeles', 'America/Denver', 'America/Chicago', 'America/New_York', 'America/Halifax', 'America/Sao_Paulo', 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Stockholm', 'Europe/Warsaw', 'Europe/Athens', 'Europe/Istanbul', 'Africa/Lagos', 'Africa/Johannesburg', 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Shanghai', 'Asia/Seoul', 'Asia/Tokyo', 'Australia/Perth', 'Australia/Sydney', 'Pacific/Auckland', 'Pacific/Honolulu'];++const LENGTHS = [[30, '30 minutes'], [60, '1 hour'], [90, '1½ hours'], [120, '2 hours'], [180, '3 hours'], [240, '4 hours'], [360, '6 hours']];++++function schedule({ values = {}, problem = null } = {}) {++ const v = { kind: 'stream', visibility: 'public', length: '120', tz: 'UTC', ...values };++ const zones = ZONES.includes(v.tz) ? ZONES : [v.tz, ...ZONES];++ const radio = (name, val, label, hint) => `<label class="td-opt"><input type="radio" name="${name}" value="${val}"${String(v[name]) === val ? ' checked' : ''}><span><b>${esc(label)}</b>${hint ? `<small>${esc(hint)}</small>` : ''}</span></label>`;++ return `<div class="td-wrap td-page-pad td-narrow">++<header class="td-page-h"><p class="td-kicker">Schedule</p><h1>Put it on the calendar</h1>++<p class="td-lede">It shows here, on your channel and in calendars that subscribe. Your followers hear about it in their bell.</p></header>++<form id="td-schedule-form" class="td-card td-form" method="post" action="/schedule">++${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}++<fieldset class="td-opts"><legend>What is it?</legend>${radio('kind', 'stream', 'A stream', 'On your Live channel')}${radio('kind', 'event', 'An event', 'Anything else with a time')}</fieldset>++<label class="td-field"><span>Title</span><input name="title" type="text" maxlength="120" required value="${esc(v.title || '')}" placeholder="Robot build, part 2"></label>++<div class="td-row"><label class="td-field"><span>Starts</span><input name="starts_at" type="datetime-local" required value="${esc(v.starts_at || '')}"></label>++<label class="td-field"><span>How long</span><select name="length">${LENGTHS.map(([m, l]) => `<option value="${m}"${String(v.length) === String(m) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label></div>++<label class="td-field"><span>Time zone</span><select name="tz" data-tz>${zones.map((z) => `<option value="${esc(z)}"${z === v.tz ? ' selected' : ''}>${esc(z.replace(/_/g, ' '))}</option>`).join('')}</select><small>The time above is read in this zone. Everyone sees it in their own.</small></label>++<label class="td-field"><span>Description <i>optional</i></span><textarea name="description" rows="3" maxlength="1000" placeholder="What you are doing, who is joining">${esc(v.description || '')}</textarea></label>++<fieldset class="td-opts"><legend>Who can see it?</legend>${radio('visibility', 'public', 'Everyone', 'Here, on your channel and in feeds')}${radio('visibility', 'followers', 'Followers', 'Only people who follow you')}</fieldset>++<div class="td-acts"><button class="ovk-btn ovk-btn--primary" type="submit">Schedule it</button><a class="ovk-btn ovk-btn--quiet" href="/me">Back to your agenda</a></div>++</form></div>`;++}++++function signIn({ title, text, next }) {++ return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</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="td-wrap td-page-pad" id="td-developers">++<header class="td-page-h"><p class="td-kicker">Developers</p><h1>The Today API</h1>++<p class="td-lede">JSON under <code>/api/v1</code>, calendars under <code>/ics/</code>, and three topics on OpenVibe.Events. A person is the session cookie, or a Network user token as a Bearer.</p></header>++<section><h2>Read</h2><dl class="td-eps">++${ep('GET', '/api/v1/events?channel=&from=&to=&limit=', 'Public events, soonest first (at most 100; cached 60 s).')}++${ep('GET', '/api/v1/events/:id', 'One event. A cancelled one answers with <code>status: "cancelled"</code>; a followers-only one is 404 unless it is yours.')}++${ep('GET', '/api/v1/me/agenda?days=14', 'Your events, reminded events and followed channels’ events (up to 60 days).')}++${ep('GET', '/api/v1/me/brief', 'Today’s brief: sections of cited lines.')}++</dl></section>++<section><h2>Write (signed in)</h2><dl class="td-eps">++${ep('POST', '/api/v1/events', '<code>{ kind, title, starts_at, ends_at?, tz, description?, visibility? }</code>. <code>Idempotency-Key</code> is honoured; at most 60 future events per person.')}++${ep('PATCH', '/api/v1/events/:id', 'Change the title, times, zone, description or visibility (the calendar sequence goes up).')}++${ep('DELETE', '/api/v1/events/:id', 'Cancel it (again is fine).')}++${ep('POST', '/api/v1/events/:id/remind', '<code>{ minutes_before: 0–1440 }</code>; <code>DELETE</code> removes it.')}++</dl></section>++<section id="feeds"><h2>Calendar feeds</h2><dl class="td-eps">++${ep('GET', '/ics/@<username>.ics', 'A channel’s public calendar (RFC 5545). Subscribe with <code>webcal://openvibe.today/ics/@<username>.ics</code>.')}++${ep('GET', '/e/:id.ics', 'One event, for "Add to calendar".')}++${ep('GET', '/ics/<token>.ics', 'Your private agenda; the link is on <a href="/me">your agenda</a>, and a new one replaces it at once.')}++</dl></section>++<section><h2>Events</h2><p class="td-fine">Today publishes <code>today.event.scheduled</code>, <code>today.event.updated</code> and <code>today.event.cancelled</code>, and listens for <code>live.stream.started</code> and <code>live.stream.ended</code> to mark a stream live and then ended. A refusal is <code>400 { code: "today.invalid", field, message }</code>; others are <code>today.too_many</code>, <code>today.cross_site</code>, <code>today.not_found</code>.</p></section>++</div>`;++}++++function notFound() {++ return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;++}++++module.exports = { home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };nnew file mode 100644@@@ -0,0 +1,121 @@++'use strict';++/**++ * ADR-033: Today's part of an account export (events, reminders, feed row without its token, briefs, idempotency++ * keys) and of a deletion, applied through /internal/events with a stand-in Network. A deletion erases the person's++ * rows, other people's reminders on their events and pending outbox rows for those events, and confirms with counts;++ * a redelivery changes nothing.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = 'usr_01JZ0000000000000000000AAA';++const B = 'usr_01JZ0000000000000000000BBB';++const EXP = 'exp_01JZ0000000000000000000EXP';++const DEL = 'del_01JZ0000000000000000000DEX';++const SECRET = `fixture_${'account'.repeat(6)}`;++const WRONG = `fixture_${'mismatch'.repeat(6)}`;++++const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', 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_today', 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: 'today', clientSecret: 'today-secret' });++ // The mock Network's people use generated subjects; the account events name fixed fixtures, so the rows are++ // inserted directly with those subjects.++ const t = await boot({ env: { TODAY_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const db = t.ctx.s.db;++ const at = new Date(Date.now() + 2 * 60 * 60_000).toISOString();++ await db.exec(`INSERT INTO today_events (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url, seq)++ VALUES ('tev_01JZ000000000000000000AA', $1, 'stream', 'a', 'A stream', NULL, $2::timestamptz, NULL, 'UTC', 'scheduled', 'public', 'https://openvibe.live/@a', 0),++ ('tev_01JZ000000000000000000BB', $3, 'stream', 'b', 'B stream', NULL, $2::timestamptz, NULL, 'UTC', 'scheduled', 'public', 'https://openvibe.live/@b', 0)`, [A, at, B]);++ await db.exec(`INSERT INTO today_reminders (subject, event_id, remind_at, created_at) VALUES++ ($1, 'tev_01JZ000000000000000000BB', $2::timestamptz, now()),++ ($3, 'tev_01JZ000000000000000000AA', $2::timestamptz, now()),++ ($3, 'tev_01JZ000000000000000000BB', $2::timestamptz, now())`, [A, at, B]);++ await db.exec(`INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, 'feedtokenA_fixture', now()), ($2, 'feedtokenB_fixture', now())`, [A, B]);++ await db.exec(`INSERT INTO today_briefs (subject, day, body, created_at) VALUES ($1, '2026-10-28'::date, '[]'::jsonb, now()), ($2, '2026-10-28'::date, '[]'::jsonb, now())`, [A, B]);++ await db.exec(`INSERT INTO today_idempotency (subject, key, event_id, created_at) VALUES ($1, 'a-key', 'tev_01JZ000000000000000000AA', now())`, [A]);++ await db.exec(`INSERT INTO today_outbox (event_id, topic, payload, created_at) VALUES ('evt_01JZ0000000000000000000A1', 'today.event.scheduled', '{"event_id":"tev_01JZ000000000000000000AA"}'::jsonb, now())`);++++ await check('an export carries only A\'s rows, and never the feed token', 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_today');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['briefs.json', 'events.json', 'feeds.json', 'idempotency.json', 'reminders.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('A stream'));++ assert.ok(!all.includes('B stream'), 'nobody else\'s event');++ assert.ok(!all.includes('feedtokenA_fixture'), 'the feed token is never exported');++ assert.ok(!all.includes('feedtokenB_fixture'));++ const events = part.body.files.find((f) => f.name === 'events.json').content;++ assert.deepStrictEqual(events.map((e) => e.id), ['tev_01JZ000000000000000000AA']);++ const reminders = part.body.files.find((f) => f.name === 'reminders.json').content;++ assert.deepStrictEqual(reminders.map((r) => r.event_id), ['tev_01JZ000000000000000000BB']);++ });++++ await check('a deletion erases A\'s rows, other people\'s reminders on A\'s events, and confirms with counts', 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(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [A]), 0);++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE subject = $1', [A]), 0);++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1', ['tev_01JZ000000000000000000AA']), 0, 'B\'s reminder on A\'s event is an orphan and goes');++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_feeds WHERE subject = $1', [A]), 0);++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_briefs WHERE subject = $1', [A]), 0);++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_idempotency WHERE subject = $1', [A]), 0);++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [B]), 1, 'B\'s event stays');++ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE subject = $1', [B]), 1, 'B\'s own reminder stays');++ 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.today_events >= 1);++ assert.ok(confirmation.body.erased.today_reminders >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing; a new row made afterwards stays', async () => {++ await db.exec(`INSERT INTO today_events (id, owner_subject, kind, channel, title, starts_at, tz, status, visibility, seq) VALUES ('tev_01JZ000000000000000000AC', $1, 'event', NULL, 'written later', now() + interval '1 hour', 'UTC', 'scheduled', 'public', 0)`, [A]);++ 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(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [A]), 1);++ });++++ 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,87 @@++'use strict';++/**++ * The agenda: the caller's own events, the events they set a reminder on and the public or followers-only events of++ * the channels they follow. Follows come from Network with the person's token; when Network is down the agenda still++ * answers with own and reminded events and follows_unavailable: true.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ const at = (mins) => new Date(Date.now() + mins * 60_000).toISOString();++ const create = async (user, title, opts = {}) => {++ const r = await t.get('/api/v1/events', { as: user, json: { kind: opts.kind || 'stream', title, starts_at: opts.starts_at || at(60), tz: 'UTC', ...(opts.visibility ? { visibility: opts.visibility } : {}) } });++ assert.strictEqual(r.status, 201, r.text);++ return r.json();++ };++++ let own;++ let reminded;++ let followed;++ let followedPrivate;++++ await check('own + reminded + followed channels\' events, and nothing else', async () => {++ own = await create(alice, 'My own stream', { starts_at: at(30) });++ reminded = await create(bob, 'Bob reminded', { starts_at: at(90) });++ followed = await create(carol, 'Carol public', { starts_at: at(120) });++ followedPrivate = await create(carol, 'Carol followers', { starts_at: at(150), visibility: 'followers' });++ const unfollowed = await create(bob, 'Bob not followed', { starts_at: at(180) });++ await t.get(`/api/v1/events/${reminded.id}/remind`, { as: alice, json: { minutes_before: 5 } });++ t.network.setFollow(alice, carol.subject);++ const r = await t.get('/api/v1/me/agenda?days=14', { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ const b = r.json();++ assert.strictEqual(b.days, 14);++ assert.strictEqual(b.follows_unavailable, false);++ const ids = b.events.map((e) => e.id);++ assert.ok(ids.includes(own.id), 'own');++ assert.ok(ids.includes(reminded.id), 'reminded');++ assert.ok(ids.includes(followed.id), 'followed public');++ assert.ok(ids.includes(followedPrivate.id), 'followed followers-only');++ assert.ok(!ids.includes(unfollowed.id), 'not followed and not reminded');++ assert.strictEqual(b.events.find((e) => e.id === own.id).own, true);++ assert.strictEqual(b.events.find((e) => e.id === reminded.id).reminded, true);++ assert.deepStrictEqual(ids, [...ids].sort((x, y) => {++ const a = b.events.find((e) => e.id === x); const c = b.events.find((e) => e.id === y);++ return new Date(a.starts_at) - new Date(c.starts_at);++ }));++ });++++ await check('an anonymous caller is 401 and days is validated', async () => {++ assert.strictEqual((await t.get('/api/v1/me/agenda')).status, 401);++ assert.strictEqual((await t.get('/api/v1/me/agenda?days=0', { as: alice })).status, 400);++ assert.strictEqual((await t.get('/api/v1/me/agenda?days=61', { as: alice })).status, 400);++ assert.strictEqual((await t.get('/api/v1/me/agenda?days=60', { as: alice })).status, 200);++ });++++ await check('Network down: own and reminded events still answer, follows_unavailable true', async () => {++ const dave = t.network.addUser('dave');++ const mine = await create(dave, 'Dave own', { starts_at: at(45) });++ t.network.setFollow(dave, carol.subject);++ t.network.setDown(true);++ const r = await t.get('/api/v1/me/agenda', { as: dave });++ assert.strictEqual(r.status, 200, r.text);++ const b = r.json();++ assert.strictEqual(b.follows_unavailable, true);++ assert.deepStrictEqual(b.events.map((e) => e.id), [mine.id]);++ t.network.setDown(false);++ });++++ await check('the reminders API lists upcoming reminders only', async () => {++ const r = await t.get('/api/v1/me/reminders', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.ok(r.json().reminders.some((x) => x.event_id === reminded.id));++ const removed = await t.get(`/api/v1/events/${reminded.id}/remind`, { as: alice, method: 'DELETE' });++ assert.strictEqual(removed.status, 200);++ assert.strictEqual(removed.json().removed, true);++ const after = await t.get('/api/v1/me/reminders', { as: alice });++ assert.ok(!after.json().reminders.some((x) => x.event_id === reminded.id));++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++/**++ * The brief: sections from real rows (later today, today's reminders), up to three Rundown items, the Rundown++ * section omitted when Live fails, and one stored brief per subject per day.++ */++const assert = require('assert');++const http = require('http');++const { boot, check, done } = require('./helpers/boot');++++async function startLive() {++ let fail = false;++ const server = http.createServer((req, res) => {++ if (req.url !== '/api/rundown') { res.writeHead(404); res.end('{}'); return; }++ if (fail) { res.writeHead(500); res.end('{}'); return; }++ const now = Date.now();++ const yesterday = new Date(now - 24 * 60 * 60_000).toISOString();++ const today = new Date(now).toISOString();++ res.writeHead(200, { 'Content-Type': 'application/json' });++ res.end(JSON.stringify({++ lead: { type: 'segment', stream_id: 11, said_at: yesterday, take: 'The lead take', user: { username: 'carl', display_name: 'Carl' }, topic: { slug: 'lead-topic', title: 'Lead topic' } },++ streams: [++ { stream_id: 12, said_at: yesterday, episode: { stream_id: 12, title: 'Episode twelve', aired_at: yesterday, user: { username: 'dana', display_name: 'Dana' } } },++ { stream_id: 13, said_at: yesterday, episode: { stream_id: 13, title: 'Episode thirteen', aired_at: yesterday, user: { username: 'erin', display_name: 'Erin' } } },++ { stream_id: 14, said_at: today, episode: { stream_id: 14, title: 'Today episode', aired_at: today, user: { username: 'frank' } } },++ ],++ trending: [{ slug: 'trend', title: 'A trend', headline: 'Trending headline', streamers: 3, last_seen_at: yesterday }],++ }));++ });++ await new Promise((r) => server.listen(0, '127.0.0.1', r));++ return { url: `http://127.0.0.1:${server.address().port}`, fail(v) { fail = v; }, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const live = await startLive();++ const t = await boot({ env: { OV_LIVE_INTERNAL_URL: live.url } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const now = Date.now();++ const at = (mins) => new Date(now + mins * 60_000).toISOString();++ // Choose a zone in which now and the next three hours are the same calendar day, so "later today" is stable++ // whatever time of day the suite runs.++ const { dayIn } = require('../server/brief');++ const tz = ['UTC', 'Europe/Berlin', 'America/New_York', 'Asia/Tokyo', 'Australia/Sydney', 'Pacific/Auckland', 'America/Los_Angeles']++ .find((z) => dayIn(z, now) === dayIn(z, now + 3 * 60 * 60_000)) || 'UTC';++++ const own = (await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'My later stream', starts_at: at(120), tz } })).json();++ const tomorrow = (await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Tomorrow thing', starts_at: at(30 * 60), tz } })).json();++ const bobEvent = (await t.get('/api/v1/events', { as: bob, json: { kind: 'event', title: 'Bob reminder', starts_at: at(180), tz } })).json();++ await t.get(`/api/v1/events/${bobEvent.id}/remind`, { as: alice, json: { minutes_before: 30 } });++++ await check('sections come from the agenda, today\'s reminders and the Rundown', async () => {++ const brief = await t.ctx.brief.buildBrief(alice.subject, { now, tz });++ assert.match(brief.day, /^\d{4}-\d{2}-\d{2}$/);++ const byId = Object.fromEntries(brief.sections.map((s) => [s.id, s]));++ assert.ok(byId['later-today'], 'Later today');++ assert.ok(byId['later-today'].items.some((i) => i.text.includes('My later stream')));++ assert.ok(!byId['later-today'].items.some((i) => i.text.includes('Tomorrow thing')), 'tomorrow is not today');++ assert.ok(byId['later-today'].items.every((i) => i.action === 'remind'));++ assert.ok(byId['later-today'].items.every((i) => i.url.startsWith('https://openvibe.today/e/')));++ assert.ok(byId['your-reminders'], 'Your reminders');++ assert.ok(byId['your-reminders'].items.some((i) => i.text.includes('Bob reminder')));++ assert.ok(byId.yesterday, 'Yesterday on OpenVibe');++ assert.ok(byId.yesterday.items.length <= 3);++ assert.ok(byId.yesterday.items.some((i) => i.url === 'https://openvibe.live/rundown/t/lead-topic'));++ assert.ok(byId.yesterday.items.some((i) => i.url.includes('/rundown/e/12')));++ assert.ok(!byId.yesterday.items.some((i) => i.url.includes('/rundown/e/14')), 'today\'s item is not yesterday');++ assert.ok(byId.yesterday.items.every((i) => i.action === null));++ });++++ await check('GET /me/brief builds on demand and stores one brief per subject per day', async () => {++ const r = await t.get(`/api/v1/me/brief?tz=${encodeURIComponent(tz)}`, { as: alice });++ assert.strictEqual(r.status, 200, r.text);++ assert.ok(r.json().sections.some((s) => s.id === 'later-today'));++ const again = await t.get(`/api/v1/me/brief?tz=${encodeURIComponent(tz)}`, { as: alice });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs WHERE subject = $1', [alice.subject]), 1);++ assert.strictEqual((await t.get('/api/v1/me/brief?tz=Nope/Zone', { as: alice })).status, 400);++ assert.strictEqual((await t.get('/api/v1/me/brief')).status, 401);++ });++++ await check('the Rundown section is omitted when Live fails', async () => {++ const carol = t.network.addUser('carol');++ await t.get('/api/v1/events', { as: carol, json: { kind: 'event', title: 'Carol today', starts_at: at(60), tz } });++ live.fail(true);++ try {++ const brief = await t.ctx.brief.buildBrief(carol.subject, { now, tz });++ assert.ok(brief.sections.some((s) => s.id === 'later-today'));++ assert.ok(!brief.sections.some((s) => s.id === 'yesterday'), 'no fake Rundown section');++ } finally {++ live.fail(false);++ }++ });++++ await check('the daily job builds for subjects active in the last 30 days', async () => {++ const six = new Date(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate(), 6, 0, 0));++ const r = await t.ctx.brief.runOnce({ now: six.getTime(), force: true });++ assert.ok(r.subjects >= 2, `active subjects: ${r.subjects}`);++ assert.ok(r.built >= 2);++ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs');++ assert.ok(n >= 2);++ // The same run again does not add a second brief for the same day.++ const before = Number(n);++ await t.ctx.brief.runOnce({ now: six.getTime(), force: true });++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs')), before);++ });++++ await t.close();++ await live.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * POST /internal/events: a signed live.stream.started turns the channel's scheduled event (start within 2 h of now)++ * live, live.stream.ended makes a live one done, a replayed delivery changes nothing, and a bad signature is refused.++ */++const assert = require('assert');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++const { boot, check, done } = require('./helpers/boot');++++const SECRET = `fixture_${'consumer'.repeat(6)}`;++const bodyOf = (event) => JSON.stringify({ event, seq: 1 });++const started = (id) => ({ event_id: 'evt_01M61F7R3WXM5T5ET9CRGNHEP1', event_type: 'live.stream.started', source: 'live', payload: { stream_id: 7, channel: { username: 'alice' }, title: 'Hello', started_at: new Date().toISOString() } });++const ended = (id) => ({ event_id: 'evt_01M61F7R3WXM5T5ET9CRGNHEP2', event_type: 'live.stream.ended', source: 'live', payload: { stream_id: 7, channel: { username: 'alice' }, title: 'Hello', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), duration_seconds: 60 } });++++(async () => {++ const t = await boot({ env: { TODAY_EVENTS_SECRET: SECRET } });++ const alice = t.network.addUser('alice');++ const starts = new Date(Date.now() + 30 * 60_000).toISOString();++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Hello', starts_at: starts, tz: 'UTC' } });++ assert.strictEqual(created.status, 201);++ const id = created.json().id;++++ const post = (b, extra = {}) => t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(b, SECRET), ...extra } });++++ await check('a signed live.stream.started makes the scheduled event live', async () => {++ const b = bodyOf(started(id));++ const r = await post(b);++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'live');++ const e = await t.get(`/api/v1/events/${id}`);++ assert.strictEqual(e.json().status, 'live');++ assert.strictEqual(e.json().seq, 1);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_processed WHERE event_id = $1', [started(id).event_id]), 1);++ });++++ await check('a replayed delivery changes nothing', async () => {++ const b = bodyOf(started(id));++ const r = await post(b);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.json().duplicate, true);++ const e = await t.get(`/api/v1/events/${id}`);++ assert.strictEqual(e.json().status, 'live');++ assert.strictEqual(e.json().seq, 1, 'no second bump');++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_processed WHERE event_id = $1', [started(id).event_id]), 1);++ });++++ await check('a signed live.stream.ended makes the live event done', async () => {++ const r = await post(bodyOf(ended(id)));++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().outcome, 'done');++ const e = await t.get(`/api/v1/events/${id}`);++ assert.strictEqual(e.json().status, 'done');++ assert.strictEqual(e.json().seq, 2);++ });++++ await check('a bad signature is refused (401) and a forwarded request (403)', async () => {++ const b = bodyOf(started(id));++ const bad = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(b, `${'wrong'.repeat(8)}`) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(b, SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ const missing = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json' } });++ assert.strictEqual(missing.status, 401);++ });++++ await check('with no secret configured the route answers 503', async () => {++ const t2 = await boot();++ const r = await t2.get('/internal/events', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });++ assert.strictEqual(r.status, 503);++ await t2.close();++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,76 @@++'use strict';++/**++ * The public crawl artifacts: /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt and the home page's JSON-LD.++ * Each is served with the right status and content type, lists public pages only, and takes lastmod from STATUS.json.++ */++const assert = require('assert');++const fs = require('fs');++const path = require('path');++const { boot, check, done } = require('./helpers/boot');++++const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;++const PRIVATE = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];++++(async () => {++ const t = await boot();++ try {++ await check('robots.txt: text/plain, private paths disallowed, public pages crawlable, sitemap named', async () => {++ const r = await t.get('/robots.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ for (const d of ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);++ for (const d of ['/developers']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable`);++ assert.ok(r.text.includes('Sitemap: https://openvibe.today/sitemap.xml'), 'sitemap not named');++ assert.ok(/^User-agent: \*$/m.test(r.text));++ });++++ await check('sitemap.xml: application/xml, real entries, lastmod from STATUS.json, nothing private', async () => {++ const r = await t.get('/sitemap.xml');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^application\/xml/);++ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);++ for (const p of ['/', '/developers']) assert.ok(r.text.includes(`<loc>https://openvibe.today${p}</loc>`), `no ${p} entry`);++ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);++ assert.ok(lastmods.length > 0);++ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.today${p}`), `private path in sitemap: ${p}`);++ });++++ await check('llms.txt: text/plain, real links, nothing private', async () => {++ const r = await t.get('/llms.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.ok(r.text.startsWith('# OpenVibe.Today'));++ assert.ok(r.text.includes('(https://openvibe.today/developers)'));++ assert.ok(r.text.includes('https://openvibe.today/sitemap.xml'));++ assert.ok(r.text.includes('(https://openvibe.today/llms-full.txt)'));++ for (const p of ['/auth/', '/me', '/brief']) assert.ok(!r.text.includes(`https://openvibe.today${p}`), `private path in llms.txt: ${p}`);++ });++++ await check('llms-full.txt: every page the sitemap lists', async () => {++ const r = await t.get('/llms-full.txt');++ assert.strictEqual(r.status, 200);++ assert.match(r.headers.get('content-type'), /^text\/plain/);++ assert.ok(r.text.startsWith('# OpenVibe.Today'));++ const sitemap = await t.get('/sitemap.xml');++ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);++ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);++ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.today${p}`));++ });++++ await check('home page: WebSite, WebApplication and WebPage JSON-LD', async () => {++ const r = await t.get('/');++ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++ const types = blocks.map((b) => b['@type']);++ assert.ok(types.includes('WebSite'), types.join(','));++ assert.ok(types.includes('WebApplication'), types.join(','));++ assert.ok(types.includes('WebPage'), types.join(','));++ assert.strictEqual(blocks.find((b) => b['@type'] === 'WebSite').url, 'https://openvibe.today');++ assert.strictEqual(blocks.find((b) => b['@type'] === 'WebPage').url, 'https://openvibe.today/');++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,164 @@++'use strict';++/**++ * The events API: create (validation, the stream channel from the token, Idempotency-Key), patch (seq + 1),++ * cancel (idempotent), the per-owner cap, followers-only visibility, the cross-site refusal, and the outbox row++ * written in the same transaction as the event (a failed outbox insert leaves neither).++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const iso = (ms) => new Date(ms).toISOString();++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const soon = () => iso(Date.now() + 60 * 60_000);++++ await check('a stream takes its channel and URL from the token, never from the body', async () => {++ const r = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', channel: 'evil', url: 'https://evil.example', title: 'Live soon', starts_at: soon(), tz: 'Europe/Berlin' } });++ assert.strictEqual(r.status, 201, r.text);++ const e = r.json();++ assert.match(e.id, /^tev_[0-9A-HJKMNP-TV-Z]{20}$/);++ assert.strictEqual(e.channel, 'alice');++ assert.strictEqual(e.url, 'https://openvibe.live/@alice');++ assert.strictEqual(e.status, 'scheduled');++ assert.strictEqual(e.seq, 0);++ assert.strictEqual(e.owner_subject, alice.subject);++ });++++ await check('validation errors are 400 today.invalid with the field', async () => {++ const cases = [++ [{ kind: 'nope', title: 'x', starts_at: soon(), tz: 'UTC' }, 'kind'],++ [{ kind: 'event', title: '', starts_at: soon(), tz: 'UTC' }, 'title'],++ [{ kind: 'event', title: 'x'.repeat(121), starts_at: soon(), tz: 'UTC' }, 'title'],++ [{ kind: 'event', title: 'x', starts_at: iso(Date.now() - 10 * 60_000), tz: 'UTC' }, 'starts_at'],++ [{ kind: 'event', title: 'x', starts_at: iso(Date.now() + 367 * 24 * 60 * 60_000), tz: 'UTC' }, 'starts_at'],++ [{ kind: 'event', title: 'x', starts_at: soon(), ends_at: iso(Date.now() + 30 * 60_000), tz: 'UTC' }, 'ends_at'],++ [{ kind: 'event', title: 'x', starts_at: soon(), ends_at: iso(Date.now() + 26 * 60 * 60_000), tz: 'UTC' }, 'ends_at'],++ [{ kind: 'event', title: 'x', starts_at: soon(), tz: 'Not/AZone' }, 'tz'],++ [{ kind: 'event', title: 'x', starts_at: soon(), tz: 'UTC', description: 'd'.repeat(1001) }, 'description'],++ [{ kind: 'event', title: 'x', starts_at: 'tomorrow', tz: 'UTC' }, 'starts_at'],++ ];++ for (const [body, field] of cases) {++ const r = await t.get('/api/v1/events', { as: alice, json: body });++ assert.strictEqual(r.status, 400, `${field}: ${r.text}`);++ const b = r.json();++ assert.strictEqual(b.code, 'today.invalid');++ assert.strictEqual(b.field, field, `${field}: ${r.text}`);++ assert.ok(b.message);++ }++ });++++ await check('a repeated Idempotency-Key returns the first event, never a second one', async () => {++ const headers = { 'Idempotency-Key': 'fixture-key-1' };++ const body = { kind: 'event', title: 'Idempotent', starts_at: soon(), tz: 'UTC' };++ const a = await t.get('/api/v1/events', { as: alice, json: body, headers });++ const b = await t.get('/api/v1/events', { as: alice, json: body, headers });++ assert.strictEqual(a.status, 201);++ assert.strictEqual(b.status, 200);++ assert.strictEqual(a.json().id, b.json().id);++ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE title = $1', ['Idempotent']);++ assert.strictEqual(n, 1);++ const long = await t.get('/api/v1/events', { as: alice, json: body, headers: { 'Idempotency-Key': 'k'.repeat(101) } });++ assert.strictEqual(long.status, 400);++ assert.strictEqual(long.json().field, 'idempotency_key');++ });++++ await check('PATCH bumps seq and writes today.event.updated in the same transaction', async () => {++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Before', starts_at: soon(), tz: 'UTC' } });++ const id = created.json().id;++ const r = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'PATCH', json: { title: 'After', visibility: 'followers' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().title, 'After');++ assert.strictEqual(r.json().visibility, 'followers');++ assert.strictEqual(r.json().seq, 1);++ const rows = await t.ctx.s.db.many('SELECT topic, payload FROM today_outbox WHERE payload->>$1 = $2 ORDER BY created_at', ['event_id', id]);++ assert.deepStrictEqual(rows.map((x) => x.topic), ['today.event.scheduled', 'today.event.updated']);++ assert.strictEqual(rows[1].payload.seq, 1);++ });++++ await check('a followers-only event is 404 to everyone but its owner and absent from the public list', async () => {++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Followers only', starts_at: soon(), tz: 'UTC', visibility: 'followers' } });++ const id = created.json().id;++ assert.strictEqual((await t.get(`/api/v1/events/${id}`)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: alice })).status, 200);++ const list = await t.get('/api/v1/events?limit=100');++ assert.ok(!list.json().events.some((e) => e.id === id));++ });++++ await check('a session write from another origin is 403 today.cross_site', async () => {++ const r = await t.get('/api/v1/events', { as: alice, origin: 'https://evil.example', json: { kind: 'event', title: 'Cross', starts_at: soon(), tz: 'UTC' } });++ assert.strictEqual(r.status, 403);++ assert.strictEqual(r.json().code, 'today.cross_site');++ const same = await t.get('/api/v1/events', { as: alice, origin: 'https://openvibe.today', json: { kind: 'event', title: 'Same', starts_at: soon(), tz: 'UTC' } });++ assert.strictEqual(same.status, 201);++ const bearer = await t.get('/api/v1/events', { bearer: t.network.userToken(alice), origin: 'https://evil.example', json: { kind: 'event', title: 'Bearer', starts_at: soon(), tz: 'UTC' } });++ assert.strictEqual(bearer.status, 201, 'a Bearer token is not a browser session');++ });++++ await check('DELETE cancels idempotently, bumps seq once, drops reminders and writes one cancelled row', async () => {++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'To cancel', starts_at: soon(), tz: 'UTC' } });++ const id = created.json().id;++ assert.strictEqual((await t.get(`/api/v1/events/${id}/remind`, { as: bob, json: { minutes_before: 5 } })).status, 200);++ const a = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(a.status, 200, a.text);++ assert.strictEqual(a.json().status, 'cancelled');++ assert.strictEqual(a.json().seq, 1);++ const b = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(b.status, 200);++ assert.strictEqual(b.json().seq, 1);++ const cancelled = await t.ctx.s.db.many("SELECT * FROM today_outbox WHERE topic = 'today.event.cancelled' AND payload->>'event_id' = $1", [id]);++ assert.strictEqual(cancelled.length, 1);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1', [id]), 0);++ const get = await t.get(`/api/v1/events/${id}`);++ assert.strictEqual(get.status, 200);++ assert.strictEqual(get.json().status, 'cancelled');++ });++++ await check('PATCH is refused once cancelled, and another owner is 404', async () => {++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Locked', starts_at: soon(), tz: 'UTC' } });++ const id = created.json().id;++ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: bob, method: 'PATCH', json: { title: 'mine now' } })).status, 404);++ await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });++ const r = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'PATCH', json: { title: 'nope' } });++ assert.strictEqual(r.status, 409);++ assert.strictEqual(r.json().code, 'today.locked');++ });++++ await check('at most 60 scheduled future events per owner (409 today.too_many)', async () => {++ const u = t.network.addUser('capped');++ const at = soon();++ const created = [];++ for (let i = 0; i < 60; i++) {++ const r = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: `Event ${i}`, starts_at: at, tz: 'UTC' } });++ assert.strictEqual(r.status, 201, r.text);++ created.push(r.json().id);++ }++ const over = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'One too many', starts_at: at, tz: 'UTC' } });++ assert.strictEqual(over.status, 409);++ assert.strictEqual(over.json().code, 'today.too_many');++ // A cancelled event no longer counts, so the owner can schedule again.++ await t.get(`/api/v1/events/${created[0]}`, { as: u, method: 'DELETE' });++ const again = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'Room again', starts_at: at, tz: 'UTC' } });++ assert.strictEqual(again.status, 201);++ });++++ await check('a failed outbox insert leaves neither the event nor the outbox row', async () => {++ const u = t.network.addUser('atomic');++ await t.ctx.s.db.exec('DELETE FROM today_outbox');++ await t.ctx.s.db.exec("ALTER TABLE today_outbox ADD CONSTRAINT outbox_never CHECK (topic <> 'today.event.scheduled')");++ try {++ const r = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'Atomic', starts_at: soon(), tz: 'UTC' } });++ assert.strictEqual(r.status, 500);++ } finally {++ await t.ctx.s.db.exec('ALTER TABLE today_outbox DROP CONSTRAINT outbox_never');++ }++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE title = $1', ['Atomic']), 0);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_outbox WHERE payload->>$1 = $2', ['title', 'Atomic']), 0);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.TODAY_RATE_LIMIT_PER_MIN = process.env.TODAY_RATE_LIMIT_PER_MIN || '100000';++process.env.TODAY_API_RATE_LIMIT_PER_MIN = process.env.TODAY_API_RATE_LIMIT_PER_MIN || '100000';++/**++ * Boots OpenVibe.Today on a temp database with a mock Network/Events (test/helpers/mocks.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 (today_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork } = require('./mocks');++++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 env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.today', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'today', OV_OAUTH_CLIENT_SECRET: 'today-secret', COOKIE_SECURE: 'false',++ TODAY_EVENTS_URL: network.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) => `today_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, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ built.ctx.publisher.stop();++ built.ctx.reminders.stop();++ built.ctx.brief.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();++ },++ };++ 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++ * TODAY_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.TODAY_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'today', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,185 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network and the parts of OpenVibe.Events the tests use, with a real RS256 key++ * pair.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `today`, refresh_token,++ * client_credentials); /oauth/revoke; GET /api/v1/me/follows with a person's token;++ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.++ * Events POST /api/v1/events records each envelope once (a repeated event_id is a duplicate); failEvents(n)++ * makes the next n calls 503 so the outbox test can watch a backoff and a retry.++ *++ * Every request is recorded in `.requests`, published events in `.publishedEvents`, pushed notifications in++ * `.notifications`, and the follows graph in `.state.follows`.++ */++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(),++ follows: new Map(), requests: [], tokenRequests: [], publishedEvents: [], notifications: [], eventsFail: 0,++ 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 !== 'today' || body.client_secret !== 'today-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:today', 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>');++ const type = url.searchParams.get('type');++ const items = [...(st.follows.get(u.subject) || new Set())]++ .filter(() => !type || type === 'channel')++ .map((target) => ({ follower: u.subject, target_type: 'channel', target_id: target, notify_email: true, notify_push: true, created_at: new Date().toISOString() }));++ return json(200, { items, next_cursor: null });++ }++++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');++ const body = JSON.parse(raw.toString('utf8') || '{}');++ const results = {};++ for (const sid of Array.isArray(body.subject_ids) ? body.subject_ids : []) {++ const u = [...st.users.values()].find((x) => x.subject === sid);++ results[sid] = u ? { subject: { type: 'user', id: u.subject }, network_user_id: u.id, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;++ }++ return json(200, { results });++ }++++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');++ const body = JSON.parse(raw.toString('utf8') || '{}');++ st.notifications.push(body);++ return json(200, { ok: true, notification: { id: `ntf_${crypto.randomBytes(8).toString('hex')}`, ...body } });++ }++++ if (url.pathname === '/api/v1/events' && req.method === 'POST') {++ const auth = String(req.headers.authorization || '');++ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');++ if (st.eventsFail > 0) { st.eventsFail--; return problem(json, 503, 'events.down', 'Events is restarting'); }++ const body = JSON.parse(raw.toString('utf8') || '{}');++ const event = body && body.events ? body.events[0] : body;++ const seen = st.publishedEvents.find((e) => e.event_id === event.event_id);++ if (seen) return json(200, { event_id: event.event_id, cursor: seen.cursor, duplicate: true });++ const cursor = `cur_${crypto.randomBytes(8).toString('hex')}`;++ st.publishedEvents.push({ ...event, cursor });++ return json(201, { event_id: event.event_id, cursor, duplicate: false });++ }++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ publishedEvents: st.publishedEvents, notifications: st.notifications,++ 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; },++ setFollow(user, targetSubject, active = true) {++ if (!st.follows.has(user.subject)) st.follows.set(user.subject, new Set());++ if (active) st.follows.get(user.subject).add(targetSubject); else st.follows.get(user.subject).delete(targetSubject);++ },++ setDown(v) { st.down = v; },++ failEvents(n = 1) { st.eventsFail = n; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * RFC 5545: CRLF, 75-octet folding with multibyte text, TEXT escaping, UID/SEQUENCE/STATUS, a cancelled event as++ * CANCELLED for 7 days then gone, a private token feed that rotation invalidates at once, and a channel's public++ * calendar.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const CRLF = '\r\n';++function unfold(text) {++ return String(text).replace(/\r\n[ \t]/g, '');++}++function lines(text) { return String(text).split(CRLF); }++++(async () => {++ let clock = Date.now();++ const t = await boot({ now: () => clock });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const starts = () => new Date(clock + 24 * 60 * 60_000).toISOString();++++ let eventId = null;++ await check('channel calendar: CRLF, folding ≤ 75 octets, escaping, UID/SEQUENCE/STATUS', async () => {++ const title = `A, B; C\\D — ${'é'.repeat(80)}`;++ const description = `line one\nline two, with; punctuation\\`;++ const r = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title, description, starts_at: starts(), tz: 'Europe/Berlin' } });++ assert.strictEqual(r.status, 201, r.text);++ eventId = r.json().id;++ const ics = await t.get('/ics/@alice.ics');++ assert.strictEqual(ics.status, 200);++ assert.strictEqual(ics.headers.get('content-type'), 'text/calendar; charset=utf-8');++ assert.strictEqual(ics.headers.get('cache-control'), 'public, max-age=300');++ assert.ok(!/[^\r]\n/.test(ics.text), 'every line ends CRLF');++ assert.ok(ics.text.endsWith(CRLF));++ for (const line of lines(ics.text)) assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `folded line ${Buffer.byteLength(line, 'utf8')} octets: ${line.slice(0, 20)}`);++ const body = unfold(ics.text);++ for (const line of ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//OpenVibe//Today//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:OpenVibe.Today — @alice']) assert.ok(body.includes(line), line);++ assert.ok(body.includes(`UID:${eventId}@openvibe.today`));++ assert.match(body, /DTSTAMP:\d{8}T\d{6}Z/);++ assert.match(body, /DTSTART:\d{8}T\d{6}Z/);++ assert.match(body, /DTEND:\d{8}T\d{6}Z/);++ assert.ok(body.includes('SEQUENCE:0'));++ assert.ok(body.includes('STATUS:CONFIRMED'));++ assert.match(body, /LAST-MODIFIED:\d{8}T\d{6}Z/);++ // TEXT escaping: comma, semicolon, backslash and the newline, with the multibyte title intact.++ assert.ok(body.includes('SUMMARY:A\\, B\\; C\\\\D — ' + 'é'.repeat(80)), 'the title is escaped and intact');++ assert.ok(body.includes('DESCRIPTION:line one\\nline two\\, with\\; punctuation\\\\'), 'the description is escaped');++ // No end given: DTEND is one hour after DTSTART.++ const dtstart = /DTSTART:(\d{8}T\d{6}Z)/.exec(body)[1];++ const dtend = /DTEND:(\d{8}T\d{6}Z)/.exec(body)[1];++ const parse = (s) => Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(9, 11), +s.slice(11, 13), +s.slice(13, 15));++ assert.strictEqual(parse(dtend) - parse(dtstart), 60 * 60_000);++ });++++ await check('a patch raises SEQUENCE in every feed', async () => {++ const r = await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'PATCH', json: { title: 'Changed' } });++ assert.strictEqual(r.status, 200);++ const ics = await t.get('/ics/@alice.ics');++ assert.ok(unfold(ics.text).includes('SEQUENCE:1'));++ assert.strictEqual((await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'PATCH', json: { title: 'Changed again' } })).json().seq, 2);++ });++++ await check('a cancelled event is STATUS:CANCELLED for 7 days, then dropped', async () => {++ const r = await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(r.status, 200);++ let ics = await t.get('/ics/@alice.ics');++ assert.ok(unfold(ics.text).includes('STATUS:CANCELLED'), 'still in the feed to remove it');++ assert.ok(unfold(ics.text).includes('SEQUENCE:3'));++ clock += 8 * 24 * 60 * 60_000;++ ics = await t.get('/ics/@alice.ics');++ assert.ok(!unfold(ics.text).includes(eventId), 'dropped after 7 days');++ assert.ok(!unfold(ics.text).includes('STATUS:CANCELLED'));++ });++++ await check('the private token feed is the owner\'s, and rotation makes the old URL 404 at once', async () => {++ clock = Date.now();++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Private agenda item', starts_at: starts(), tz: 'UTC' } });++ assert.strictEqual(created.status, 201);++ const f = await t.get('/api/v1/me/feed', { as: alice });++ assert.strictEqual(f.status, 200, f.text);++ const path = new URL(f.json().url).pathname;++ const ics = await t.get(path);++ assert.strictEqual(ics.status, 200);++ assert.strictEqual(ics.headers.get('cache-control'), 'private, max-age=300');++ assert.ok(unfold(ics.text).includes('Private agenda item'));++ const rotated = await t.get('/api/v1/me/feed/rotate', { as: alice, method: 'POST' });++ assert.strictEqual(rotated.status, 200);++ assert.notStrictEqual(rotated.json().url, f.json().url);++ assert.strictEqual((await t.get(path)).status, 404, 'the old URL is gone');++ const fresh = await t.get(new URL(rotated.json().url).pathname);++ assert.strictEqual(fresh.status, 200);++ assert.ok(unfold(fresh.text).includes('Private agenda item'));++ // Someone else's token feed is not this person's.++ const bf = await t.get('/api/v1/me/feed', { as: bob });++ assert.notStrictEqual(new URL(bf.json().url).pathname, new URL(rotated.json().url).pathname);++ });++++ await check('the private feed includes followed channels once Today has read the person\'s follows', async () => {++ const carol = t.network.addUser('carol');++ t.network.setFollow(alice, carol.subject);++ t.ctx.follows.clear(alice.subject); // a follow made after the 60 s cache was read++ const current = await t.get('/api/v1/me/feed', { as: alice });++ const path = new URL(current.json().url).pathname;++ const made = await t.get('/api/v1/events', { as: carol, json: { kind: 'stream', title: 'Followed channel stream', starts_at: starts(), tz: 'UTC' } });++ assert.strictEqual(made.status, 201);++ const ics = await t.get(path);++ assert.strictEqual(ics.status, 200);++ assert.ok(unfold(ics.text).includes('Followed channel stream'));++ });++++ await check('the channel feed carries public events only', async () => {++ const hidden = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Followers only', starts_at: starts(), tz: 'UTC', visibility: 'followers' } });++ assert.strictEqual(hidden.status, 201);++ const ics = await t.get('/ics/@alice.ics');++ assert.ok(unfold(ics.text).includes('Private agenda item'), 'the public one is there');++ assert.ok(!unfold(ics.text).includes('Followers only'), 'the followers-only one is not');++ assert.strictEqual((await t.get('/ics/@nobody.ics')).status, 200);++ assert.strictEqual((await t.get('/ics/@..bad.ics')).status, 404);++ assert.strictEqual((await t.get('/ics/no-such-token.ics')).status, 404);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++/**++ * The outbox publisher: each row is sent once under the evt_ id fixed when it was written, a failure records the++ * attempt and backs off, a later retry publishes the same fixed id, and a new publisher over the same rows does not++ * duplicate anything.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { createPublisher } = require('../server/events/publisher');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const at = new Date(Date.now() + 60 * 60_000).toISOString();++ const make = async (title) => (await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title, starts_at: at, tz: 'UTC' } })).json();++++ await check('each row is published once, with its fixed evt_ id and the payload Today defined', async () => {++ const a = await make('First');++ const b = await make('Second');++ const rows = await t.ctx.s.db.many('SELECT * FROM today_outbox ORDER BY created_at');++ assert.strictEqual(rows.length, 2);++ for (const row of rows) assert.match(row.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);++ const r = await t.ctx.publisher.runOnce();++ assert.deepStrictEqual(r, { configured: true, claimed: 2, published: 2, failed: 0 });++ const token = t.network.tokenRequests.find((x) => x.audience === 'openvibe.events');++ assert.ok(token, 'an Events token was requested');++ assert.strictEqual(token.scope, 'events.event.publish', 'OpenVibe.Events\' publish capability');++ assert.strictEqual(t.network.publishedEvents.length, 2);++ const ids = new Set(t.network.publishedEvents.map((e) => e.event_id));++ for (const row of rows) assert.ok(ids.has(row.event_id));++ const first = t.network.publishedEvents.find((e) => e.payload.event_id === a.id);++ assert.strictEqual(first.event_type, 'today.event.scheduled');++ assert.strictEqual(first.source, 'today');++ assert.deepStrictEqual(first.subject, { type: 'user', id: alice.subject });++ assert.deepStrictEqual(Object.keys(first.payload).sort(), ['channel', 'ends_at', 'event_id', 'kind', 'seq', 'starts_at', 'status', 'title', 'tz', 'url', 'visibility']);++ assert.strictEqual(first.payload.title, 'First');++ assert.strictEqual(first.payload.channel, 'alice');++ const again = await t.ctx.publisher.runOnce();++ assert.strictEqual(again.claimed, 0);++ assert.strictEqual(t.network.publishedEvents.length, 2, 'nothing sent twice');++ assert.ok(b.id);++ });++++ await check('a failure records attempts/last_error, backs off, then retries the same id', async () => {++ const e = await make('Retry me');++ t.network.failEvents(1);++ const failed = await t.ctx.publisher.runOnce();++ assert.strictEqual(failed.failed, 1);++ let row = await t.ctx.s.db.maybe("SELECT * FROM today_outbox WHERE payload->>'event_id' = $1", [e.id]);++ assert.strictEqual(row.attempts, 1);++ assert.match(row.last_error, /events 503/);++ assert.strictEqual(row.published_at, null);++ assert.ok(new Date(row.next_attempt_at).getTime() > Date.now(), 'backed off into the future');++ assert.strictEqual((await t.ctx.publisher.runOnce()).claimed, 0, 'not due yet');++ await t.ctx.s.db.exec("UPDATE today_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);++ const retried = await t.ctx.publisher.runOnce();++ assert.deepStrictEqual(retried, { configured: true, claimed: 1, published: 1, failed: 0 });++ row = await t.ctx.s.db.maybe('SELECT * FROM today_outbox WHERE event_id = $1', [row.event_id]);++ assert.ok(row.published_at);++ assert.strictEqual(row.attempts, 1, 'the attempt that failed is recorded');++ const event = t.network.publishedEvents.find((x) => x.event_id === row.event_id);++ assert.strictEqual(event.payload.event_id, e.id);++ });++++ await check('a new publisher over the same rows does not duplicate', async () => {++ const before = t.network.publishedEvents.length;++ const restarted = createPublisher({ config: t.config, s: t.ctx.s, fetchImpl: globalThis.fetch, log: { warn() {}, log() {}, error() {} } });++ assert.strictEqual((await restarted.runOnce()).claimed, 0);++ assert.strictEqual(t.network.publishedEvents.length, before);++ assert.strictEqual(await t.ctx.publisher.pending(), 0);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * Server-rendered pages: the public schedule, a channel, one event with Event JSON-LD, the signed-in agenda, brief,++ * the plain schedule form (POST → /e/:id), developers, 404, cache headers and content-hashed assets.++ */++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 bob = t.network.addUser('bob');++ const at = new Date(Date.now() + 2 * 60 * 60_000).toISOString();++ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Public stream', description: 'A description', starts_at: at, ends_at: new Date(Date.now() + 3 * 60 * 60_000).toISOString(), tz: 'Europe/Berlin' } });++ assert.strictEqual(created.status, 201);++ const event = created.json();++++ await check('home: semantic, no-JavaScript navigation, the upcoming event, 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="td-home"', 'id="td-upcoming"', 'id="main"', '<noscript><nav', '<footer id="ov-footer"', 'Public stream']) 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('a channel page lists its public events and links the ICS feed', async () => {++ const r = await t.get('/@alice');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ assert.ok(r.text.includes('id="td-channel"'));++ assert.ok(r.text.includes('Public stream'));++ assert.ok(r.text.includes('/ics/@alice.ics'));++ assert.strictEqual((await t.get('/@..bad')).status, 404);++ });++++ await check('one event page carries schema.org Event JSON-LD', async () => {++ const r = await t.get(`/e/${event.id}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ const ev = jsonLd(r.text).find((x) => x['@type'] === 'Event');++ assert.ok(ev, 'Event JSON-LD missing');++ assert.strictEqual(ev.name, 'Public stream');++ assert.strictEqual(ev.startDate, event.starts_at);++ assert.strictEqual(ev.endDate, event.ends_at);++ assert.strictEqual(ev.eventStatus, 'https://schema.org/EventScheduled');++ assert.strictEqual(ev.eventAttendanceMode, 'https://schema.org/OnlineEventAttendanceMode');++ assert.strictEqual(ev.location['@type'], 'VirtualLocation');++ assert.strictEqual(ev.location.url, 'https://openvibe.live/@alice');++ assert.ok(ev.organizer);++ assert.strictEqual((await t.get('/e/tev_DOESNOTEXIST0000000')).status, 404);++ });++++ await check('a cancelled event answers with EventCancelled, and a followers-only event is private', async () => {++ const hidden = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Hidden', starts_at: at, tz: 'UTC', visibility: 'followers' } });++ const hid = hidden.json();++ assert.strictEqual((await t.get(`/e/${hid.id}`)).status, 404);++ assert.strictEqual((await t.get(`/e/${hid.id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/e/${hid.id}`, { as: alice })).status, 200);++ const cancelled = await t.get(`/api/v1/events/${event.id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(cancelled.status, 200);++ const r = await t.get(`/e/${event.id}`);++ const ev = jsonLd(r.text).find((x) => x['@type'] === 'Event');++ assert.strictEqual(ev.eventStatus, 'https://schema.org/EventCancelled');++ });++++ await check('/me is 401 signed out and shows the agenda, reminders and feed URL signed in', async () => {++ assert.strictEqual((await t.get('/me')).status, 401);++ const agendaItem = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Agenda item', starts_at: new Date(Date.now() + 4 * 60 * 60_000).toISOString(), tz: 'Europe/Berlin' } });++ assert.strictEqual(agendaItem.status, 201);++ const r = await t.get('/me', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ assert.ok(r.text.includes('id="td-me"'));++ assert.ok(r.text.includes('Agenda item'));++ assert.match(r.text, /\/ics\/[A-Za-z0-9_-]{43}\.ics/);++ assert.ok(r.text.includes('action="/me/feed/rotate"'));++ const rotated = await t.get('/me/feed/rotate', { as: alice, method: 'POST', origin: 'https://openvibe.today' });++ assert.strictEqual(rotated.status, 303);++ assert.strictEqual(rotated.headers.get('location'), '/me');++ const cross = await t.get('/me/feed/rotate', { as: alice, method: 'POST', origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ });++++ await check('/brief is 401 signed out and renders sections signed in', async () => {++ assert.strictEqual((await t.get('/brief')).status, 401);++ const r = await t.get('/brief', { as: alice });++ assert.strictEqual(r.status, 200);++ assert.ok(r.text.includes('id="td-brief"'));++ assert.ok(r.text.includes('Later today'));++ });++++ await check('/schedule: signed out 401; the plain form POSTs and redirects to /e/:id', async () => {++ assert.strictEqual((await t.get('/schedule')).status, 401);++ const form = await t.get('/schedule', { as: alice });++ assert.strictEqual(form.status, 200);++ assert.ok(form.text.includes('id="td-schedule-form"'));++ assert.ok(form.text.includes('method="post" action="/schedule"'));++ const local = new Date(Date.now() + 3 * 60 * 60_000).toISOString().slice(0, 16);++ const r = await t.get('/schedule', { as: alice, form: { kind: 'stream', title: 'From the form', starts_at: local, tz: 'Europe/Berlin', description: 'Form text', visibility: 'public' } });++ assert.strictEqual(r.status, 303, r.text.slice(0, 300));++ assert.match(r.headers.get('location'), /^\/e\/tev_/);++ const page = await t.get(r.headers.get('location'));++ assert.strictEqual(page.status, 200);++ assert.ok(page.text.includes('From the form'));++ const cross = await t.get('/schedule', { as: alice, origin: 'https://evil.example', form: { kind: 'event', title: 'Cross', starts_at: local, tz: 'UTC' } });++ assert.strictEqual(cross.status, 403);++ const bad = await t.get('/schedule', { as: alice, form: { kind: 'event', title: '', starts_at: local, tz: 'UTC' } });++ assert.strictEqual(bad.status, 400);++ assert.ok(bad.text.includes('id="td-schedule-form"'));++ });++++ await check('/developers and the 404 page', 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="td-developers"'));++ assert.ok(dev.text.includes('/api/v1/events'));++ 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('Not on the calendar'));++ });++++ await check('assets are content-hashed and served with the shared cache policy', async () => {++ const v = assetVersion('css/today.css');++ const hashed = await t.get(`/css/today.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/today.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/today.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,94 @@++'use strict';++/**++ * Due reminders: exactly one Network notification each, two concurrent runs never double-send, and a reminder on a++ * cancelled event never sends. The row is claimed with UPDATE … WHERE sent_at IS NULL RETURNING before the push.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const at = new Date(Date.now() + 10 * 60_000).toISOString();++++ async function eventAndReminder({ title = 'Soon', kind = 'stream', user = alice, minutes = 10 } = {}) {++ const e = await t.get('/api/v1/events', { as: user, json: { kind, title, starts_at: at, tz: 'Europe/Berlin' } });++ assert.strictEqual(e.status, 201, e.text);++ const r = await t.get(`/api/v1/events/${e.json().id}/remind`, { as: bob, json: { minutes_before: minutes } });++ assert.strictEqual(r.status, 200, r.text);++ await t.ctx.s.db.exec("UPDATE today_reminders SET remind_at = now() - interval '1 minute' WHERE event_id = $1", [e.json().id]);++ return e.json();++ }++++ await check('a due reminder sends one notification with the Today fields', async () => {++ const e = await eventAndReminder({ title: 'Morning show' });++ const r = await t.ctx.reminders.runOnce();++ assert.deepStrictEqual({ due: r.due, sent: r.sent, failed: r.failed }, { due: 1, sent: 1, failed: 0 });++ assert.strictEqual(t.network.notifications.length, 1);++ const n = t.network.notifications[0];++ assert.strictEqual(n.type, 'TODAY_REMINDER');++ assert.strictEqual(n.category, 'stream');++ assert.strictEqual(n.service, 'today');++ assert.match(n.title, /^Morning show starts in \d+ min$/);++ assert.strictEqual(n.url, `https://openvibe.today/e/${e.id}`);++ assert.strictEqual(n.user_id, bob.id);++ assert.ok(await t.ctx.s.db.value('SELECT sent_at IS NOT NULL FROM today_reminders WHERE event_id = $1 AND subject = $2', [e.id, bob.subject]));++ });++++ await check('two concurrent runs never double-send', async () => {++ const before = t.network.notifications.length;++ const e = await eventAndReminder({ title: 'Concurrent' });++ const [a, b] = await Promise.all([t.ctx.reminders.runOnce(), t.ctx.reminders.runOnce()]);++ assert.strictEqual(a.sent + b.sent, 1, `one send, got ${a.sent} + ${b.sent}`);++ assert.strictEqual(t.network.notifications.length, before + 1);++ assert.strictEqual(t.network.notifications.filter((x) => x.title.startsWith('Concurrent')).length, 1);++ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1 AND sent_at IS NOT NULL', [e.id]), 1);++ });++++ await check('an event that is live sends "is live now"', async () => {++ const e = await eventAndReminder({ title: 'Live now', kind: 'event' });++ await t.ctx.s.db.exec("UPDATE today_events SET status = 'live' WHERE id = $1", [e.id]);++ await t.ctx.reminders.runOnce();++ const n = t.network.notifications.find((x) => x.title.startsWith('Live now'));++ assert.ok(n);++ assert.strictEqual(n.title, 'Live now is live now');++ assert.strictEqual(n.category, 'system');++ });++++ await check('a cancelled event\'s reminder never sends, even if a row survives a race', async () => {++ const before = t.network.notifications.length;++ const e = await eventAndReminder({ title: 'Cancelled' });++ await t.get(`/api/v1/events/${e.id}`, { as: alice, method: 'DELETE' });++ // A reminder row that raced the cancellation: insert it by hand to prove the claim checks the event status.++ await t.ctx.s.db.exec(++ `INSERT INTO today_reminders (subject, event_id, remind_at, created_at) VALUES ($1, $2, now() - interval '1 minute', now())++ ON CONFLICT (subject, event_id) DO UPDATE SET remind_at = EXCLUDED.remind_at, sent_at = NULL`,++ [bob.subject, e.id],++ );++ const r = await t.ctx.reminders.runOnce();++ assert.strictEqual(r.due, 0, 'a cancelled event is not due');++ assert.strictEqual(t.network.notifications.length, before);++ });++++ await check('a failed push releases the claim so the next run retries', async () => {++ const e = await eventAndReminder({ title: 'Retry push' });++ const orig = t.network.notifications.length;++ const { createReminders } = require('../server/reminders');++ const failing = createReminders({++ config: t.config, s: t.ctx.s, events: t.ctx.events,++ notifier: { sendReminder: async () => { throw new Error('push down'); } },++ log: { warn() {} },++ });++ const first = await failing.runOnce();++ assert.strictEqual(first.failed, 1);++ assert.strictEqual(await t.ctx.s.db.value('SELECT sent_at IS NULL FROM today_reminders WHERE event_id = $1 AND subject = $2', [e.id, bob.subject]), true);++ const retry = await t.ctx.reminders.runOnce();++ assert.strictEqual(retry.sent, 1);++ assert.strictEqual(t.network.notifications.length, orig + 1);++ assert.strictEqual(t.network.notifications.at(-1).title.startsWith('Retry push'), true);++ });++++ 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 and an++ * in-process mock of OpenVibe.Network; none needs the network or a running site.++ *++ * npm test # everything++ * npm test -- events # 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: 60000, 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,149 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id today, cookies today_at/today_rt/today_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', async () => {++ const r = await t.get('/auth/login?next=/brief');++ 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'), 'today');++ 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'), /today_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=/brief');++ 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'), '/brief');++ const set = r.headers.get('set-cookie');++ assert.match(set, /today_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /today_rt=[^;]+;.*HttpOnly/i);++ const at = set.match(/today_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `today_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(/today_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://openvibe.today' };++ assert.strictEqual(sanitizeNext('/me?tab=1', config), '/me?tab=1');++ assert.strictEqual(sanitizeNext('https://openvibe.today/e/x?y=1', config), '/e/x?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.today.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, 'today');++ 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 ['/', '/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: 'today' });++ 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: `today_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `today_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: `today_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: 'today_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'today_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(!/today_at=;|today_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=/brief', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/brief');++ assert.match(String(same.headers.get('set-cookie') || ''), /today_at=;/);++ });++++ await t.close();++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+# OpenVibe.Today — environment (production: /etc/openvibe/today.env). Names only; never commit values.
+
+NODE_ENV=production
+PORT=5100
+HOST=127.0.0.1
+BASE_URL=https://openvibe.today
+TRUST_PROXY=2
+# PostgreSQL (ADR-035), written by OpenVibe.Host roles/data/add-service.sh today: 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:today:
+# Per-caller limits (server/app.js): requests one address may make to the API and to the pages per minute.
+#TODAY_LIMITS_MINUTE=120
+#TODAY_LIMITS_HOUR=3000
+
+# OpenVibe.Network: SSO (OAuth client `today`, PKCE S256) and its JWKS; app tokens for audience openvibe.today.
+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=today
+OV_OAUTH_CLIENT_SECRET=
+# OV_OAUTH_REDIRECT_URI=https://openvibe.today/auth/callback
+# TODAY_AUDIENCE=openvibe.today
+# OV_SESSION_AUDIENCE=openvibe.network
+# TODAY_PGLITE_DIR=./data/pglite
+
+# OpenVibe.Events: where the outbox publishes and the subscriptions are created at boot. The secret signs
+# deliveries (comma-separated for rotation, 32+ characters each; unset makes POST /internal/events answer 503).
+# TODAY_EVENTS_SUBSCRIBE=0 turns the boot-time subscriptions off.
+TODAY_EVENTS_SECRET=
+TODAY_EVENTS_URL=
+#TODAY_EVENTS_ENDPOINT=http://127.0.0.1:5100/internal/events
+#TODAY_EVENTS_INTERVAL_MS=5000
+#TODAY_EVENTS_SCOPE=events.publish
+
+# OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.
+OV_LIVE_INTERNAL_URL=http://127.0.0.1:3000
+#TODAY_LIVE_TIMEOUT_MS=2500
+
+# The daily brief at 06:00 UTC for subjects active in the last 30 days.
+#TODAY_BRIEF_HOUR_UTC=6
+#TODAY_BRIEF_ACTIVE_DAYS=30
+
+# Caches: the follow graph per subject (60 s) and Network user ids for reminders (5 min).
+#TODAY_FOLLOWS_CACHE_MS=60000
+#TODAY_RESOLVE_CACHE_MS=300000
+
+# A restore drill starts no jobs and creates no subscriptions.
+#TODAY_DRILL=0
+
+# optional: defaults to true in production
+# COOKIE_SECURE=true
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check, npm test and openvibe-contracts-check: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test
+ contracts-service: today
+ # 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,35 @@
@@ -0,0 +1,35 @@
+# Changelog
+
+What changed in OpenVibe.Today, newest first.
+
+## 0.1.0 — 2026-10-28
+
+The first version: the network's time bus and calendar authority.
+
+- **Scheduling.** `POST|PATCH|DELETE /api/v1/events`, with title/description/time-zone/range validation, a stream's
+ channel and URL taken from the caller's token, followers-only visibility, a cap of 60 scheduled future events per
+ owner, an `Idempotency-Key` that returns the first event, and a same-origin rule for writes made with the session
+ cookie (`403 today.cross_site`). The event and its `today.event.scheduled` outbox row commit in one transaction.
+- **Public read.** `GET /api/v1/events` (soonest first, public cache 60 s) and `GET /api/v1/events/:id`; a cancelled
+ event answers with its cancelled status, and a followers-only one is 404 unless the caller owns it.
+- **ICS.** `/ics/@<username>.ics` (public) and `/ics/<token>.ics` (private, 300 s): RFC 5545 CRLF, 75-octet UTF-8-safe
+ folding, TEXT escaping, UID/SEQUENCE/STATUS/LAST-MODIFIED, one-hour default end, a cancelled event as
+ `STATUS:CANCELLED` for 7 days then dropped, and rotation that makes the old token 404 at once.
+- **Reminders.** Upsert/remove/list and a 60 s job that claims a due row atomically before pushing one
+ `TODAY_REMINDER` through OpenVibe.Network's `/internal/notifications/push` (user id resolved in batches and cached).
+ A cancelled or done event's reminder never sends.
+- **Agenda.** Own events, reminded events and the public or followers-only events of followed channels, with the
+ follow graph read from Network with the person's own token and cached 60 s. Network down still answers own and
+ reminded events with `follows_unavailable: true`.
+- **Brief.** Later today, your reminders and up to three items from OpenVibe.Live's public Rundown; the Rundown
+ section is omitted when it fails; one stored brief per person per day; a 06:00 UTC job for subjects active in the
+ last 30 days (no notification in v1).
+- **Events.** A transactional outbox with a fixed `evt_` id, claim lease, backoff and `attempts`/`last_error`, and a
+ v2-signed consumer at `/internal/events` for `live.stream.started|ended`; `today_processed` makes a replay a no-op.
+ Account export and deletion go through openvibe-sdk/account-data over every table that holds a subject; the feed
+ token itself is never exported.
+- **The service.** OpenVibe.Network sign-in as OAuth client `today` (PKCE S256, `today_at`/`today_rt`/`today_oauth`),
+ truthful readiness (the database is required), Prometheus metrics on loopback, crawl artifacts, deploy files, and
+ server-rendered pages with schema.org Event JSON-LD.
+- **Tests.** `events`, `ics`, `consumer`, `outbox`, `reminders`, `agenda`, `brief`, `pages`, `sso`, `account-data` and
+ `discovery`, each in its own process on a temporary PGlite database with a mock Network/Events.
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,207 @@
@@ -0,0 +1,207 @@
+# OpenVibe.Today
+
+> The network's time bus and calendar authority.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `openvibe.today` · **Port:** 5100 · **Service id:** `today` · **Env prefix:** `TODAY`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Today is where a person puts a stream or an event on the network's calendar. It serves the public schedule,
+one channel's schedule, a private calendar feed per person, reminders through OpenVibe.Network and a daily brief. It
+is also the authority for those events over OpenVibe.Events: a producer writes an event to Today's outbox in the same
+transaction as the change, and the calendar moves from scheduled to live to done as OpenVibe.Live says so.
+
+## Purpose
+
+One schedule for the whole network:
+
+- **Public events** — `GET /api/v1/events` and the home page, soonest first. A stream belongs to the caller's Live
+ channel; an event is a plain appointment. A followers-only event is visible to its owner and absent from public
+ lists.
+- **Calendars** — RFC 5545. `/ics/@<username>.ics` is a channel's public calendar; `/ics/<token>.ics` is one person's
+ private agenda. Any calendar app can subscribe; rotation invalidates the old URL immediately.
+- **Reminders** — one per person and event, sent once through OpenVibe.Network's notification push when due, and never
+ for a cancelled event.
+- **The brief** — later today, your reminders, and up to three things OpenVibe.Live's public Rundown talked about
+ yesterday; one stored brief per person per day.
+- **The bus** — `today.event.scheduled|updated|cancelled` published through a transactional outbox with a fixed `evt_`
+ id, and `live.stream.started|ended` consumed to move events live and done exactly once.
+
+## Owns
+
+| Table | Holds | Migration |
+|---|---|---|
+| `today_events` | `tev_` + 20 base32 characters, `owner_subject` (`usr_…`), `kind` `stream\|event`, the owner's Live `channel` for a stream, title, description, `starts_at`, `ends_at`, IANA `tz`, `status` `scheduled\|live\|done\|cancelled`, `visibility` `public\|followers`, `url`, `seq` (the ICS SEQUENCE), timestamps | `migrations/0001_initial.sql` |
+| `today_idempotency` | (`subject`, `key`) → the first event; a repeated `Idempotency-Key` never makes a second one | `0001_initial.sql` |
+| `today_reminders` | (`subject`, `event_id`), `remind_at`, `sent_at`, `created_at` | `0001_initial.sql` |
+| `today_feeds` | one private feed token per subject (32 random bytes, base64url) | `0001_initial.sql` |
+| `today_outbox` | the fixed `evt_` id, topic, JSON payload, attempts, last error and a claim/backoff lease | `0001_initial.sql` |
+| `today_processed` | inbound Events ids already handled, so a replay is a no-op | `0001_initial.sql` |
+| `today_briefs` | (`subject`, `day`) → the sections, one per person per day | `0001_initial.sql` |
+| `account_data_events` | the receipts openvibe-sdk/account-data keeps for export and deletion deliveries | `0001_initial.sql` |
+| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
+
+## Does not own
+
+- **Identity, follows and notifications** — OpenVibe.Network. Today verifies its session tokens and JWKS, reads a
+ person's follows with their own token (`GET /api/v1/me/follows`, cached 60 s), and pushes reminders through
+ `/internal/notifications/push` with its own client-credentials token.
+- **The live stream and its lifecycle** — OpenVibe.Live. Today consumes `live.stream.started|ended`; it never starts or
+ stops a stream.
+- **The Rundown** — OpenVibe.Live's public `/api/rundown`; Today reads it for the brief and links back to it.
+- **Event delivery** — OpenVibe.Events. Today publishes its outbox and subscribes to the live and account topics.
+- **Account export and deletion** — OpenVibe.Network (ADR-033). Today answers its part and confirms it.
+- **The host, database and Valkey** — OpenVibe.Host.
+
+## Depends on
+
+Environment variable names are in [.env.example](.env.example); no value is committed.
+
+- **OpenVibe.Network** — sign-in (OAuth client `today`, PKCE S256), JWKS, token exchange/refresh/revoke, follows,
+ identity resolution and notifications: `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`, `TODAY_AUDIENCE`.
+- **OpenVibe.Events** — publishing and subscriptions: `TODAY_EVENTS_SECRET`, `TODAY_EVENTS_URL`,
+ `TODAY_EVENTS_ENDPOINT`, `TODAY_EVENTS_INTERVAL_MS`, `TODAY_EVENTS_SCOPE` (the product scope `events.publish`;
+ the publisher falls back to the capability `events.event.publish` if the token endpoint refuses it).
+- **OpenVibe.Live** — the public Rundown API: `OV_LIVE_INTERNAL_URL`, `TODAY_LIVE_TIMEOUT_MS`.
+- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
+ `TODAY_PGLITE_DIR` for development.
+- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (the readiness check lists it; the app's per-address limits do
+ not require it).
+- **Packages** — `openvibe-contracts`, `openvibe-sdk`, `openvibe-shared` (pinned to the same versions as
+ OpenVibe.Help).
+
+## API
+
+Everything is JSON under `/api/v1`. A person is the `today_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.today` (403 `today.cross_site`).
+
+| Route | What it does |
+|---|---|
+| `GET /events?channel=&from=&to=&limit=` | public events (`visibility=public`, not cancelled), soonest first; `from` is now − 2 h, `to` now + 60 days, `limit` ≤ 100; `Cache-Control: public, max-age=60` |
+| `GET /events/:id` | one event; a cancelled one answers `status: "cancelled"`; a followers-only one is 404 unless the caller owns it |
+| `POST /events` | signed in; `{ kind, title, starts_at, ends_at?, tz, description?, visibility? }`; a stream's `channel` and `url` come from the token, never the body; `Idempotency-Key` (≤ 100 characters) honoured; at most 60 scheduled future events per owner (409 `today.too_many`); the event and its `today.event.scheduled` outbox row commit together |
+| `PATCH /events/:id` | owner; title, description, starts_at, ends_at, tz, visibility; `seq` + 1; `today.event.updated`; refused once cancelled or done |
+| `DELETE /events/:id` | owner; status `cancelled`, `seq` + 1, `today.event.cancelled`, reminders dropped; idempotent (twice is 200) |
+| `POST /events/:id/remind` | signed in; `{ minutes_before?: 0–1440 }` (default 10); upsert |
+| `DELETE /events/:id/remind` | remove it (idempotent) |
+| `GET /me/reminders` | the caller's upcoming reminders |
+| `GET /me/agenda?days=14` | own events, reminded events and the public or followers-only events of followed channels; `follows_unavailable: true` when Network is down |
+| `GET /me/feed` | `{ url: "https://openvibe.today/ics/<token>.ics" }`, creating the token on first call |
+| `POST /me/feed/rotate` | replace the token; the old URL is 404 at once |
+| `GET /me/brief` | today's brief, generated on demand when missing (`?tz=` optional IANA zone) |
+
+Validation refusals are `400 { code: "today.invalid", field, message }`. Other codes:
+`today.too_many`, `today.cross_site`, `today.locked`, `today.not_found`, `today.sign_in_required`.
+
+### Validation
+
+- title 1–120 characters; description ≤ 1000
+- `starts_at` an ISO time no earlier than 5 minutes ago and no later than 366 days ahead
+- `ends_at` after `starts_at` and within 24 hours of it
+- `tz` a valid IANA zone (checked with `Intl`)
+- `kind` `stream` or `event`; `visibility` `public` or `followers`
+
+## ICS
+
+`GET /ics/<token>.ics` is the token owner's agenda (60 days ahead, 7 back), `private, max-age=300`.
+`GET /ics/@<username>.ics` is a channel's public events, `public, max-age=300`.
+
+The calendars follow RFC 5545: CRLF line endings, lines folded at 75 octets without splitting a UTF-8 sequence, TEXT
+escaping (`\\`, `\;`, `\,`, newline as `\n`), `PRODID:-//OpenVibe//Today//EN`, `VERSION:2.0`, `CALSCALE:GREGORIAN`,
+`X-WR-CALNAME`, and a VEVENT per event with `UID:<id>@openvibe.today`, `DTSTAMP`, UTC `DTSTART`/`DTEND` (no end means
+one hour), `SUMMARY`, `DESCRIPTION`, `URL`, `SEQUENCE`, `STATUS:CONFIRMED|CANCELLED` and `LAST-MODIFIED`. A cancelled
+event is emitted with `STATUS:CANCELLED` for 7 days so calendars remove it, then dropped.
+
+A token feed carries the owner's own and reminded events. When this process has read the person's follows (visiting
+`/me` or `/api/v1/me/feed` warms the 60 s cache), it also carries their followed channels' public or followers-only
+events. The feed request itself carries no person's Network token, so a freshly started process cannot read the follow
+graph for that one URL.
+
+## Events
+
+**Publish.** Every change writes a `today_outbox` row in the same transaction, with the `evt_` id fixed at that
+moment. A job every 5 s claims pending rows with a lease, posts each envelope to OpenVibe.Events `POST /api/v1/events`
+with this service's client-credentials token (audience `openvibe.events`), and records the attempt. A failure backs
+off (5 s doubling to 5 minutes), stores `last_error`, and never crashes the process. Because the id is fixed and Events
+is idempotent on it, a retry or a restart cannot make a second event.
+
+Envelope: `source: "today"`, `subject: { type: "user", id: <owner_subject> }`, `actor` the same person,
+`event_type` `today.event.scheduled|updated|cancelled`, payload
+`{ event_id, kind, channel, title, starts_at, ends_at, tz, status, visibility, url, seq }`.
+
+**Consume.** `POST /internal/events` is loopback only (nginx answers 404 for `/internal`). Deliveries are verified with
+the v2 signature over the raw body and the ±300 s replay window (openvibe-sdk/events `parseDelivery`, `requireV2`).
+`live.stream.started` makes the channel's scheduled event whose start is within 2 hours of now live;
+`live.stream.ended` makes a live event done. The inbound event id is recorded in `today_processed` in the same
+transaction as the change, so a replay does nothing. Account export/deletion events go to openvibe-sdk/account-data
+over every table that holds a subject. `startSubscriptions()` creates the two live and two account subscriptions at
+boot, idempotently, with `events.subscription.manage`.
+
+## Reminders
+
+Every 60 s, due reminders (`remind_at <= now`, `sent_at` null, event scheduled or live) are claimed with
+`UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. The Network
+user id is resolved in batches via `/internal/identity/resolve-batch` (cached), then one notification is pushed:
+type `TODAY_REMINDER`, category `stream` for a stream else `system`, title `"<title> starts in N min"` (or
+`"<title> is live now"`), `service: "today"`, URL `https://openvibe.today/e/<id>`. A failed push releases the claim
+for the next tick.
+
+## The brief
+
+`server/brief.js` builds `{ day, sections: [{ id, title, items: [{ text, url, action }] }] }` from:
+
+1. **Later today** — events in the person's agenda that start today in their zone (action `remind`).
+2. **Your reminders** — today's reminders.
+3. **Yesterday on OpenVibe** — up to three items from OpenVibe.Live's public Rundown API, omitted when it fails.
+
+Sections with no items are left out. The body (the sections) is stored in `today_briefs`, one row per subject per day.
+A daily job at 06:00 UTC builds briefs for subjects active in the last 30 days (a reminder, an own event or a feed);
+v1 does not notify.
+
+## Pages
+
+All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with semantic
+HTML and stable `td-` ids/classes:
+
+| Page | What it is |
+|---|---|
+| `/` | the room's front door: the day's ring (today's events on the reader's clock), what is coming up by day, three scenes |
+| `/@:username` | a channel's schedule, with Subscribe (`webcal://`) and the `.ics` |
+| `/e/:id` | one event, with `application/ld+json` `Event` (name, startDate, endDate, eventStatus, eventAttendanceMode, VirtualLocation, organizer); Remind me (`POST /e/:id/remind`, `/remind/remove`), Add to calendar (`/e/:id.ics`), Watch on Live, and Cancel for its owner (`POST /e/:id/cancel`) |
+| `/me` | agenda, reminders, the feed URL and its rotate form |
+| `/brief` | today's brief |
+| `/schedule` | a plain form that schedules a stream or an event: the start is wall time in the chosen zone (the device's, with JavaScript), plus a length (`POST /schedule` → 303 `/e/:id`) |
+| `/developers` | the API |
+| 404 | the not-found page |
+
+Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn for a signed-in person is `private, no-store`.
+The pages are built on the shared room kit (`kit.css`, `room.css`, `showcase.css`, `motion.js`; Today's amber from `openvibe-shared/rooms`) in `server/views/`. `public/js/today.js` adds the reader's own time, the device's zone on the form, copy and confirm; everything works without it. `scripts/seed-dev.js` puts a few events in the local development database.
+`robots.txt`, `sitemap.xml`, `llms.txt` and `llms-full.txt` list the public pages only.
+
+## 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 5100, loopback; DATABASE_URL or an embedded PGlite in development
+```
+
+The tests boot the app with a mock Network and Events: events (validation, idempotency, cap, followers, cross-site,
+transactional outbox), ICS (folding, escaping, cancellation window, token rotation), the consumer (signed, replay, bad
+signature), the outbox (fixed id, backoff, restart), reminders (one notification, concurrency, cancelled), agenda
+(follows and Network down), brief (sections, Rundown failure, one per day), pages, sign-in, account export/deletion
+and discovery.
+
+## Deploy
+
+`deploy/systemd/openvibe-today.service` (127.0.0.1:5100, `/etc/openvibe/today.env`) and
+`deploy/nginx/openvibe.today.conf` (TLS, www → apex, sign-in/API/ICS limits, `/auth/me` on the API zone,
+`/ics/` GET only, `/internal` and `/metrics` 404).
+
+## License
+
+AGPL-3.0-only. See [LICENSE](LICENSE).
new file mode 100644
new file mode 100644
@@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
+# 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.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.Today holds
+
+- Events a person scheduled (the title, description, times, timezone, visibility and the Live channel for a stream).
+- Reminders a person set, and the calendar feed token that makes one URL readable by whoever holds it.
+- Briefs built from the person's own agenda and the public Rundown.
+- Receipts of Events deliveries already handled and of account exports/deletions applied.
+- Network session tokens only in httpOnly cookies (`today_at`, `today_rt`); no token is stored in the database,
+ rendered into a page or logged.
+
+A feed token is a bearer capability for one person's calendar. The URL is shown only to its owner, rotation makes the
+old URL a 404 at once, and the token is never exported with an account export.
+
+## 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,57 @@
@@ -0,0 +1,57 @@
+{
+ "repository": "OpenVibers/OpenVibe.Today",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Today: the network's time bus and calendar authority — one schedule for streams and events, reminders through Network, private and public ICS feeds, and a daily brief",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5100",
+ "domain": "openvibe.today",
+ "contracts": "openvibe-contracts v0.213.0",
+ "sdk": "openvibe-sdk v0.64.0",
+ "shared": "openvibe-shared v4.12.0",
+ "packages": [
+ "openvibe-contracts v0.213.0",
+ "openvibe-sdk v0.64.0",
+ "openvibe-shared v4.12.0"
+ ],
+ "summary": "OpenVibe.Today is the network's time bus and calendar authority. A person schedules a stream (on their Live channel) or a plain event; the public schedule and a channel's ICS feed are readable without an account, a private token feed is one URL per person and rotation invalidates the old one, and reminders arrive through OpenVibe.Network. Events move from scheduled to live and done as OpenVibe.Live says so over OpenVibe.Events, while the daily brief is built from the agenda, today's reminders and OpenVibe.Live's public Rundown.",
+ "works": [
+ "Public events API and pages: GET /api/v1/events, one event with schema.org Event JSON-LD, a channel's schedule and its ICS feed",
+ "Scheduling: POST/PATCH/DELETE /api/v1/events, validation, Idempotency-Key, a cap of 60 scheduled future events per owner, followers-only events, and a same-origin rule for session writes",
+ "ICS: RFC 5545 with CRLF, 75-octet UTF-8-safe folding, TEXT escaping, UID/SEQUENCE/STATUS/LAST-MODIFIED, cancelled events emitted for 7 days then dropped, private token feeds and rotation",
+ "Reminders: upsert/remove/list, a 60 s job that claims a due row atomically and sends one Network notification (TODAY_REMINDER)",
+ "Agenda: own events, reminded events and the public or followers-only events of followed channels, with follows_unavailable when Network is down",
+ "The brief: later today, your reminders and up to three Rundown items; one stored brief per subject per day and a 06:00 UTC job",
+ "Events: a transactional outbox with a fixed evt_ id, backoff and a claim lease; live.stream.started|ended consumed exactly once through today_processed",
+ "Account export and deletion over every table that holds a subject (the feed token itself is never exported)",
+ "Sign-in with OpenVibe.Network (PKCE S256, httpOnly session cookies), readiness, Prometheus metrics, crawl artifacts and deploy files"
+ ],
+ "notYet": [
+ "Notifications for the daily brief (v1 builds and stores it without notifying)",
+ "A watched-event reminder scheduler beyond the one reminder per person and event",
+ "Followed channels in the private ICS token feed are best-effort: the feed request has no person token, so they appear only while this process remembers the last follows read",
+ "Timezone selection persisted per person (the brief accepts tz; otherwise it is UTC)"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "events API",
+ "scheduling pages",
+ "ICS feeds",
+ "reminders",
+ "agenda with follows",
+ "daily brief",
+ "Events outbox and consumer",
+ "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-today.service and deploy/nginx/openvibe.today.conf",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.today — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.today.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5100, systemd unit openvibe-today) renders every page and serves
+## the API and the ICS feeds; nginx terminates TLS, redirects www → apex, rate-limits sign-in and the API, and
+## compresses text.
+##
+## No proxy_cache: the app decides cacheability per response (public for the schedule and a channel's calendar;
+## private, no-store for everything personal; private/public 5 minutes for the ICS feeds). Never log request
+## bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=today_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=today_api:10m rate=5r/s;
+limit_req_zone $binary_remote_addr zone=today_forms:10m rate=1r/s;
+limit_req_zone $binary_remote_addr zone=today_ics:10m rate=2r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.today www.openvibe.today;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.today$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.today;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.today/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.today/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.today$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.today;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.today/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.today/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.today.access.log;
+ error_log /var/log/nginx/openvibe.today.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 text/calendar;
+
+ 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=today_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5100;
+ }
+
+ location /auth/ {
+ limit_req zone=today_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5100;
+ }
+
+ # ── API ─────────────────────────────────────────────────
+ location /api/ {
+ limit_req zone=today_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5100;
+ }
+
+ # ── ICS feeds: a calendar client only ever GETs them ────
+ location /ics/ {
+ limit_except GET { deny all; }
+ limit_req zone=today_ics burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5100;
+ }
+
+ # ── Forms and other writes ──────────────────────────────
+ location ~ ^/(schedule|me/feed/rotate|e/[A-Za-z0-9_]+/(remind|remind/remove|cancel))$ {
+ limit_req zone=today_forms burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5100;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5100/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5100/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:5100;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+[Unit]
+Description=OpenVibe.Today — the network's time bus and calendar authority.
+# Network (sign-in, JWKS, follows and notifications) should be up first, but Today degrades honestly without it:
+# the public schedule and ICS feeds 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.today
+EnvironmentFile=/etc/openvibe/today.env
+Environment=NODE_ENV=production
+Environment=PORT=5100
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.today/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.Today'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,125 @@
@@ -0,0 +1,125 @@
+-- phase: expand
+-- OpenVibe.Today: the network's time bus and calendar authority. Every table here holds events a
+-- person scheduled, the reminders other people set on them, the calendar feed tokens a person owns,
+-- the outbox that tells OpenVibe.Events what changed, the receipts of inbound deliveries already
+-- handled and the briefs built from a person's day. Nothing written here is a credential: a feed
+-- token is a bearer capability for one calendar, and it is never logged.
+
+CREATE TABLE today_events (
+ id text COLLATE "C" PRIMARY KEY, -- tev_ + 20 base32 characters
+ owner_subject text COLLATE "C" NOT NULL, -- usr_…; the person who scheduled it
+ kind text COLLATE "C" NOT NULL, -- stream | event
+ channel text COLLATE "C", -- the owner's Live username, for a stream
+ title text NOT NULL,
+ description text,
+ starts_at timestamptz NOT NULL,
+ ends_at timestamptz,
+ tz text NOT NULL, -- IANA zone, validated with Intl
+ status text COLLATE "C" NOT NULL DEFAULT 'scheduled', -- scheduled | live | done | cancelled
+ visibility text COLLATE "C" NOT NULL DEFAULT 'public', -- public | followers
+ url text,
+ seq integer NOT NULL DEFAULT 0, -- the ICS SEQUENCE, bumped on every change
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT today_events_kind CHECK (kind IN ('stream', 'event')),
+ CONSTRAINT today_events_status CHECK (status IN ('scheduled', 'live', 'done', 'cancelled')),
+ CONSTRAINT today_events_visibility CHECK (visibility IN ('public', 'followers'))
+);
+CREATE INDEX today_events_by_channel ON today_events (channel, starts_at);
+CREATE INDEX today_events_by_owner ON today_events (owner_subject, starts_at);
+CREATE INDEX today_events_by_status ON today_events (status, starts_at);
+
+-- A repeated Idempotency-Key on POST returns the first event, never a second one. event_id is null
+-- only for the instant between claiming the key and inserting the event, inside one transaction.
+CREATE TABLE today_idempotency (
+ subject text COLLATE "C" NOT NULL,
+ key text COLLATE "C" NOT NULL,
+ event_id text COLLATE "C",
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (subject, key)
+);
+
+CREATE TABLE today_reminders (
+ subject text COLLATE "C" NOT NULL,
+ event_id text COLLATE "C" NOT NULL,
+ remind_at timestamptz NOT NULL,
+ sent_at timestamptz,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (subject, event_id)
+);
+CREATE INDEX today_reminders_due ON today_reminders (remind_at) WHERE sent_at IS NULL;
+
+CREATE TABLE today_feeds (
+ subject text COLLATE "C" PRIMARY KEY,
+ token text COLLATE "C" UNIQUE NOT NULL, -- 32 random bytes, base64url
+ created_at timestamptz NOT NULL DEFAULT now()
+);
+
+-- The fixed Events evt_ id is chosen when the row is written, so a retry never creates a second event.
+CREATE TABLE today_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 today_outbox_pending ON today_outbox (next_attempt_at) WHERE published_at IS NULL;
+
+-- Inbound Events ids already handled: a replay is a no-op.
+CREATE TABLE today_processed (
+ event_id text COLLATE "C" PRIMARY KEY,
+ processed_at timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE TABLE today_briefs (
+ subject text COLLATE "C" NOT NULL,
+ day date NOT NULL,
+ body jsonb NOT NULL, -- the sections
+ created_at timestamptz NOT NULL DEFAULT now(),
+ PRIMARY KEY (subject, day)
+);
+
+-- 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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1407 @@
@@ -0,0 +1,1407 @@
+{
+ "name": "openvibe-today",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-today",
+ "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.229.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.229.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
+ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==",
+ "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,36 @@
@@ -0,0 +1,36 @@
+{
+ "name": "openvibe-today",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Today \u2014 the network's time bus and calendar authority.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "test": "node test/run.js",
+ "test:pg": "TODAY_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.229.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,167 @@
@@ -0,0 +1,167 @@
+/* OpenVibe.Today — the room's own pieces, on the shared room kit (kit.css, room.css, showcase.css). The page wears the
+ person's theme; --room-* is Today's amber (rooms.js). Phone first: lists are one column, the agenda's side panel
+ drops under it; from 980 px the agenda has its side column. */
+*, *::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; }
+.td-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.td-page-pad { padding-top: clamp(20px, 4vw, 40px); }
+.td-narrow { max-width: 720px; }
+.td a:not([class]) { color: var(--room-strong); }
+.td h2 { margin: 0 0 10px; font-size: 1.05rem; font-weight: 800; letter-spacing: -.01em; }
+:where(.td) .ovk-i { fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
+.td-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
+.td-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.td-kicker a { color: inherit; }
+.td-lede { margin: 8px 0 0; max-width: 680px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.td-fine { color: var(--text-muted, #7386a3); font-size: .84rem; line-height: 1.5; }
+.td-page-h h1, .td-event-h h1 { margin: 0; font-size: clamp(1.7rem, 4.2vw, 2.4rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.1; }
+.td-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 16px 0 0; }
+.td-acts .ovk-btn .ovk-i, .td-set .ovk-i { width: 1.05em; height: 1.05em; }
+.td-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
+.td-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; color: var(--room-strong); }
+.td-more .ovk-i { width: 1em; height: 1em; }
+.td section + section { margin-top: 28px; }
+.td-note { margin: 16px 0 0; padding: 10px 14px; border-radius: 12px; font-size: .9rem; line-height: 1.45; color: var(--text-secondary, #96a7c2); background: color-mix(in srgb, var(--warning, #f5a524) 12%, transparent); }
+.td-note.is-bad { background: color-mix(in srgb, var(--danger, #ef4444) 14%, transparent); color: var(--text-primary, #e6edf7); }
+
+/* Date tile and pills. */
+.td-tile { flex: none; display: grid; justify-items: center; align-content: center; width: 52px; height: 56px; border-radius: 14px; background: var(--room-fill); color: var(--room-on); line-height: 1; }
+.td-tile-m { font-size: .68rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
+.td-tile-d { margin-top: 3px; font-size: 1.35rem; font-weight: 800; letter-spacing: -.02em; }
+.td-pill { display: inline-flex; align-items: center; gap: 6px; padding: 2px 10px; border-radius: 999px; font-size: .72rem; font-weight: 800; letter-spacing: .02em; vertical-align: middle; white-space: nowrap; }
+.td-pill.is-live { background: var(--live-red, #e11d48); color: #fff; }
+.td-pill.is-live::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: #fff; animation: td-pulse 1.4s ease-in-out infinite; }
+.td-pill.is-off { background: color-mix(in srgb, var(--text-muted, #7386a3) 22%, transparent); color: var(--text-secondary, #96a7c2); }
+.td-pill.is-done { background: color-mix(in srgb, var(--text-muted, #7386a3) 14%, transparent); color: var(--text-muted, #7386a3); }
+.td-tag { display: inline-flex; padding: 1px 8px; border-radius: 999px; font-size: .7rem; font-weight: 700; color: var(--text-secondary, #96a7c2); border: 1px solid var(--border, #1f2d47); white-space: nowrap; }
+.td-tag.is-remind { border-color: color-mix(in srgb, var(--room-accent) 55%, var(--border, #1f2d47)); color: var(--room-strong); }
+@keyframes td-pulse { 0%, 100% { opacity: 1; } 50% { opacity: .35; } }
+
+/* Day-grouped lists. */
+.td-day + .td-day { margin-top: 18px; }
+.td-day-h { position: sticky; top: calc(var(--ovnav-real-h, 56px) + 4px); z-index: 1; margin: 0 0 8px; padding: 6px 0; font-size: .78rem; font-weight: 800; letter-spacing: .07em; text-transform: uppercase; color: var(--text-muted, #7386a3); background: var(--bg-primary, #0a0f1c); }
+.td-evs { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
+.td-ev-link { display: flex; align-items: center; gap: 14px; padding: 10px 14px 10px 10px; border-radius: 16px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; text-decoration: none; transition: border-color .15s, transform .15s; }
+.td-ev-link:hover, .td-ev-link:focus-visible { border-color: var(--room-accent); outline: none; }
+.td-ev-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
+.td-ev-title { font-weight: 750; line-height: 1.3; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }
+.td-ev-meta { display: flex; flex-wrap: wrap; gap: 4px 10px; font-size: .84rem; color: var(--text-muted, #7386a3); }
+.td-ev-meta time { font-weight: 700; color: var(--text-secondary, #96a7c2); font-variant-numeric: tabular-nums; }
+.td-ev-end { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 6px; }
+.td-ev-cancelled .td-ev-title, .td-ev-done .td-ev-title { color: var(--text-muted, #7386a3); }
+.td-ev-cancelled .td-ev-title { text-decoration: line-through; }
+.td-ev-live .td-ev-link { border-color: color-mix(in srgb, var(--live-red, #e11d48) 55%, var(--border, #1f2d47)); }
+@media (max-width: 560px) { .td-ev-end { display: none; } .td-ev-live .td-ev-end, .td-ev-cancelled .td-ev-end { display: flex; } }
+.td-empty { display: grid; justify-items: center; gap: 10px; padding: 36px 20px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); text-align: center; color: var(--text-secondary, #96a7c2); }
+.td-empty > .ovk-i { width: 40px; height: 40px; color: var(--room-accent); stroke-width: 1.6; }
+.td-empty p { margin: 0; max-width: 460px; line-height: 1.5; }
+.td-empty h1 { margin: 0; font-size: 1.5rem; font-weight: 800; color: var(--text-primary, #e6edf7); }
+.td-signin { margin-top: 24px; }
+
+/* The day's ring (the home's motif): 24 hours, today's events as dots, now as a hand. */
+.td-ring { position: relative; width: min(100%, 320px); aspect-ratio: 1; margin: 0 auto; container-type: inline-size; }
+.td-ring-face { position: absolute; inset: 6%; border-radius: 50%; background: radial-gradient(circle at 50% 50%, color-mix(in srgb, var(--room-accent) 16%, transparent), transparent 62%); border: 1.5px solid color-mix(in srgb, var(--room-accent) 45%, var(--border, #1f2d47)); }
+.td-ring-tick { position: absolute; left: 50%; top: 50%; width: 2px; height: 5%; margin-left: -1px; border-radius: 2px; background: color-mix(in srgb, var(--text-muted, #7386a3) 60%, transparent); transform: rotate(var(--a)) translateY(-41cqw); transform-origin: 50% 0; }
+.td-ring-tick.is-major { height: 8%; width: 3px; background: var(--room-accent); }
+.td-ring-dot { position: absolute; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px; border-radius: 50%; background: var(--room-accent); box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c), 0 0 18px color-mix(in srgb, var(--room-accent) 60%, transparent); transform: rotate(var(--a)) translateY(-35cqw) rotate(calc(-1 * var(--a))); }
+.td-ring-dot.is-live { background: var(--live-red, #e11d48); }
+.td-ring-dot.is-sample { opacity: .45; box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c); }
+html.ov-motion .td-ring-dot { animation: td-dot-in .6s cubic-bezier(.2, .8, .2, 1) both; animation-delay: calc(var(--i) * 90ms + 200ms); }
+@keyframes td-dot-in { from { opacity: 0; transform: rotate(var(--a)) translateY(-20cqw) rotate(calc(-1 * var(--a))) scale(.4); } }
+.td-ring-hand { position: absolute; left: 50%; bottom: 50%; width: 3px; height: 34%; margin-left: -1.5px; border-radius: 3px; background: linear-gradient(to top, transparent, var(--text-primary, #e6edf7)); transform-origin: 50% 100%; transform: rotate(var(--now)); }
+.td-ring-hub { position: absolute; left: 50%; top: 50%; width: 12px; height: 12px; margin: -6px; border-radius: 50%; background: var(--text-primary, #e6edf7); box-shadow: 0 0 0 4px var(--bg-primary, #0a0f1c); }
+.td-ring-label { position: absolute; font-size: .66rem; font-weight: 800; letter-spacing: .06em; color: var(--text-muted, #7386a3); }
+.td-ring-l0 { left: 50%; top: 0; translate: -50% 0; }
+.td-ring-l6 { right: 0; top: 50%; translate: 0 -50%; }
+.td-ring-l12 { left: 50%; bottom: 0; translate: -50% 0; }
+.td-ring-l18 { left: 0; top: 50%; translate: 0 -50%; }
+
+/* The home's scenes. */
+.td-demo { position: relative; display: grid; gap: 12px; }
+.td-demo-card { display: flex; align-items: center; gap: 12px; padding: 12px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.td-demo-card > span:nth-child(2), .td-demo-toast > span { display: grid; gap: 2px; min-width: 0; flex: 1; }
+.td-demo-card small, .td-demo-toast small { color: var(--text-muted, #7386a3); font-size: .8rem; }
+.td-demo-card.is-form { flex-direction: column; align-items: flex-start; gap: 4px; }
+.td-demo-k { font-size: .7rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; color: var(--room-strong); }
+.td-demo-btn { flex: none; padding: 6px 14px; border-radius: 999px; background: var(--room-fill); color: var(--room-on); font-size: .8rem; font-weight: 800; }
+.td-demo-card.is-form .td-demo-btn { margin-top: 8px; }
+.td-demo-toast { display: flex; align-items: center; gap: 12px; margin-left: 10%; padding: 12px 14px; border-radius: 16px; background: var(--bg-elevated, var(--bg-card, #0f1729)); border: 1px solid var(--border, #1f2d47); box-shadow: 0 16px 40px rgba(0, 0, 0, .3); position: relative; overflow: hidden; }
+.td-demo-toast::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 3px; background: var(--room-accent); }
+.td-demo-toast > .ovk-i { flex: none; width: 36px; height: 36px; padding: 8px; border-radius: 12px; color: var(--room-strong); background: color-mix(in srgb, var(--room-accent) 16%, transparent); }
+html.ov-motion .td-demo-toast { animation: td-toast 4.5s ease-in-out infinite; }
+@keyframes td-toast { 0%, 18% { opacity: 0; transform: translateY(10px); } 28%, 82% { opacity: 1; transform: none; } 100% { opacity: 0; transform: translateY(-6px); } }
+.td-demo-cal { padding: 12px; border-radius: 16px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.td-cal-head, .td-cal-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+.td-cal-head span { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); text-align: center; }
+.td-cal-grid { grid-template-rows: repeat(6, 22px); margin-top: 8px; background: repeating-linear-gradient(to bottom, transparent 0 21px, var(--border, #1f2d47) 21px 22px); }
+.td-cal-grid i { grid-column: var(--c); grid-row: var(--r) / span 2; padding: 3px 6px; border-radius: 6px; font-style: normal; font-size: .72rem; font-weight: 700; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; background: color-mix(in srgb, var(--accent, #3b82f6) 22%, transparent); color: var(--text-primary, #e6edf7); }
+.td-cal-grid i.is-room { background: var(--room-fill); color: var(--room-on); }
+
+/* One event. */
+.td-event-h { display: flex; align-items: flex-start; gap: 16px; }
+.td-event-h .td-tile { width: 64px; height: 70px; }
+.td-event-h .td-tile-d { font-size: 1.7rem; }
+.td-when { margin: 10px 0 0; font-size: 1.05rem; font-weight: 650; color: var(--text-secondary, #96a7c2); }
+.td-local { margin: 4px 0 0; font-size: .9rem; color: var(--text-muted, #7386a3); }
+.td-desc { margin: 20px 0 0; max-width: 720px; line-height: 1.6; white-space: pre-line; }
+.td-event.is-over h1 { color: var(--text-secondary, #96a7c2); }
+.td-inline { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0; }
+.td-remind select, .td-form select, .td-form input, .td-form textarea, .td-feed input { min-height: 40px; padding: 6px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, #0d1322); color: var(--text-primary, #e6edf7); font: inherit; font-size: .92rem; }
+.td-set { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; color: var(--room-strong); }
+.td-owner { display: flex; flex-wrap: wrap; align-items: center; gap: 10px 16px; margin: 22px 0 0; padding: 12px 14px; border-radius: 14px; border: 1px dashed var(--border, #1f2d47); }
+.td-owner p { margin: 0; flex: 1 1 260px; font-size: .88rem; color: var(--text-secondary, #96a7c2); }
+.td-facts { display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 10px; margin: 24px 0 0; }
+.td-facts > div { padding: 10px 12px; border-radius: 12px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.td-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .05em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.td-facts dd { margin: 4px 0 0; font-weight: 650; }
+
+/* Cards: the agenda's side panel and the brief. */
+.td-card { padding: 16px; border-radius: 18px; border: 1px solid var(--border, #1f2d47); background: var(--bg-secondary, #101828); }
+.td-card h2 { display: flex; align-items: center; gap: 8px; }
+.td-card h2 .ovk-i { width: 18px; height: 18px; color: var(--room-strong); }
+.td-me-grid { display: grid; gap: 20px; margin-top: 24px; grid-template-columns: minmax(0, 1fr); }
+.td-side { display: grid; gap: 14px; align-content: start; }
+@media (min-width: 980px) { .td-me-grid { grid-template-columns: minmax(0, 1fr) 340px; } .td-side { position: sticky; top: calc(var(--ovnav-real-h, 56px) + 12px); } }
+.td-rems { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.td-rems li { display: grid; gap: 2px; }
+.td-rems a { font-weight: 700; color: inherit; text-decoration: none; }
+.td-rems a:hover { text-decoration: underline; }
+.td-rems small { color: var(--text-muted, #7386a3); }
+.td-feed { display: flex; gap: 8px; margin-top: 10px; }
+.td-feed input { flex: 1; min-width: 0; font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: .78rem; }
+.td-brief-grid { display: grid; gap: 14px; margin-top: 24px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr)); }
+.td-brief-sec ul { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
+.td-brief-sec li { display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; line-height: 1.45; }
+.td-brief-sec li > a:first-child { color: inherit; text-decoration: none; }
+.td-brief-sec li > a:first-child:hover { text-decoration: underline; }
+.td-chip { flex: none; padding: 3px 10px; border-radius: 999px; font-size: .74rem; font-weight: 800; text-decoration: none; background: var(--room-fill); color: var(--room-on); }
+.td-brief > .td-fine { margin-top: 18px; }
+
+/* The schedule form. */
+.td-form { display: grid; gap: 16px; margin-top: 20px; }
+.td-field { display: grid; gap: 6px; }
+.td-field > span { font-size: .84rem; font-weight: 750; }
+.td-field > span i { font-style: normal; font-weight: 500; color: var(--text-muted, #7386a3); }
+.td-field small { color: var(--text-muted, #7386a3); font-size: .78rem; }
+.td-row { display: grid; gap: 12px; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); }
+@media (max-width: 520px) { .td-row { grid-template-columns: minmax(0, 1fr); } }
+.td-opts { display: grid; gap: 8px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); margin: 0; padding: 0; border: 0; }
+.td-opts legend { margin-bottom: 6px; font-size: .84rem; font-weight: 750; }
+.td-opt { position: relative; display: flex; gap: 10px; align-items: flex-start; padding: 12px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); cursor: pointer; }
+.td-opt input { margin: 3px 0 0; accent-color: var(--room-accent); }
+.td-opt span { display: grid; gap: 2px; }
+.td-opt small { color: var(--text-muted, #7386a3); font-size: .8rem; }
+.td-opt:has(input:checked) { border-color: var(--room-accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.td-opt:has(input:focus-visible) { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+
+/* The API page. */
+.td-eps { display: grid; gap: 10px; margin: 0; }
+.td-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.td-eps dt code { font: 600 .88rem/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; color: var(--room-strong); overflow-wrap: anywhere; }
+.td-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+.td section > h2 { margin-top: 8px; }
+
+@media (prefers-reduced-motion: reduce) { .td-pill.is-live::before, .td-demo-toast { animation: none !important; } .td-ring-dot { animation: none !important; } }
+html[data-ov-motion=reduced] .td-demo-toast, html[data-ov-motion=reduced] .td-ring-dot { animation: none !important; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -0,0 +1,66 @@
+/* OpenVibe.Today: small things the pages are complete without.
+ * - an event's time in the reader's own zone beside the event's own (when they differ);
+ * - the schedule form starts in the device's time zone;
+ * - copy buttons; a confirm before cancelling or replacing the feed link;
+ * - the day's ring's hand follows the clock. */
+(function () {
+ 'use strict';
+ var zone = (function () { try { return Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC'; } catch (e) { return 'UTC'; } })();
+ function fmt(d, opts) { try { return new Intl.DateTimeFormat(undefined, opts).format(d); } catch (e) { return d.toISOString(); } }
+
+ function run() {
+ // "Your time" on an event page.
+ document.querySelectorAll('[data-local="range"]').forEach(function (el) {
+ var a = new Date(el.getAttribute('data-start')), b = new Date(el.getAttribute('data-end'));
+ if (isNaN(a)) return;
+ var mine = fmt(a, { weekday: 'short', month: 'short', day: 'numeric' }) + ' · ' + fmt(a, { hour: 'numeric', minute: '2-digit' }) + ' – ' + fmt(b, { hour: 'numeric', minute: '2-digit', timeZoneName: 'short' });
+ var page = document.querySelector('.td-when');
+ if (page && page.textContent.indexOf(mine.split(' · ')[1]) !== -1) return;
+ el.textContent = 'Your time: ' + mine;
+ el.hidden = false;
+ });
+ // List times in the reader's zone.
+ document.querySelectorAll('time[data-local="time"]').forEach(function (el) {
+ var d = new Date(el.getAttribute('datetime'));
+ if (!isNaN(d)) { el.title = el.textContent; el.textContent = fmt(d, { hour: 'numeric', minute: '2-digit' }); }
+ });
+ document.querySelectorAll('time[data-local="ago"]').forEach(function (el) {
+ var d = new Date(el.getAttribute('datetime'));
+ if (!isNaN(d)) el.textContent = fmt(d, { dateStyle: 'medium', timeStyle: 'short' });
+ });
+ // The schedule form starts in this device's zone.
+ document.querySelectorAll('select[data-tz]').forEach(function (sel) {
+ if (sel.value !== 'UTC' || sel.dataset.picked) return;
+ if (![].some.call(sel.options, function (o) { return o.value === zone; })) {
+ var o = document.createElement('option'); o.value = zone; o.textContent = zone.replace(/_/g, ' ');
+ sel.insertBefore(o, sel.firstChild);
+ }
+ sel.value = zone;
+ sel.dataset.picked = '1';
+ });
+ // Copy.
+ document.querySelectorAll('[data-copy]').forEach(function (btn) {
+ var src = document.querySelector(btn.getAttribute('data-copy'));
+ if (!src || !navigator.clipboard) return;
+ btn.hidden = false;
+ btn.addEventListener('click', function () {
+ navigator.clipboard.writeText(src.value || src.textContent || '').then(function () {
+ var was = btn.textContent; btn.textContent = 'Copied'; setTimeout(function () { btn.textContent = was; }, 1400);
+ }, function () { src.select && src.select(); });
+ });
+ });
+ // Confirm before something that cannot be undone.
+ document.querySelectorAll('form[data-confirm]').forEach(function (f) {
+ f.addEventListener('submit', function (e) { if (!window.confirm(f.getAttribute('data-confirm'))) e.preventDefault(); });
+ });
+ // The ring on the reader's own clock: each dot at its local hour, the hand at the local time now.
+ document.querySelectorAll('[data-ring]').forEach(function (ring) {
+ var deg = function (d) { return ((d.getHours() + d.getMinutes() / 60) / 24 * 360).toFixed(1) + 'deg'; };
+ ring.querySelectorAll('[data-at]').forEach(function (dot) { var d = new Date(dot.getAttribute('data-at')); if (!isNaN(d)) dot.style.setProperty('--a', deg(d)); });
+ var tick = function () { ring.style.setProperty('--now', deg(new Date())); };
+ tick(); if (!ring.dataset.ticking) { ring.dataset.ticking = '1'; setInterval(tick, 60000); }
+ });
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', run); else run();
+ document.addEventListener('ov:boost:load', run);
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+/**
+ * Development only: a few events in the local PGlite database, so the pages have something to show
+ * (node scripts/seed-dev.js; refuses to run with NODE_ENV=production or a DATABASE_URL).
+ */
+const { load } = require('../server/config');
+const { openDb } = require('../server/db');
+
+(async () => {
+ if (process.env.NODE_ENV === 'production' || process.env.DATABASE_URL) throw new Error('seed-dev is for the local development database only');
+ const config = load(process.env);
+ const db = await openDb(config, { log: { log() {}, warn() {}, error: console.error } });
+ const now = Date.now();
+ const h = (n) => new Date(now + n * 3600e3).toISOString();
+ const rows = [
+ ['tev_DEVSEED0000000000001', 'stream', 'goosely', 'Night owls: Seattle walk', 'A slow walk downtown, chat picks the turns.', h(-0.5), h(1.5), 'America/Los_Angeles', 'live'],
+ ['tev_DEVSEED0000000000002', 'stream', 'carol', 'Robot build, part 2', 'Wiring the arm and the first test moves.', h(5), h(7), 'America/Los_Angeles', 'scheduled'],
+ ['tev_DEVSEED0000000000003', 'stream', 'JapaneseOldGuy', 'Morning workshop', 'Blue cylinder, round two.', h(20), h(22), 'Asia/Tokyo', 'scheduled'],
+ ['tev_DEVSEED0000000000004', 'event', 'kiwi', 'Speedrun Sunday: community race', 'Bring your best route.', h(50), h(53), 'Europe/London', 'scheduled'],
+ ['tev_DEVSEED0000000000005', 'stream', 'carol', 'Q&A and parts haul', null, h(74), h(75.5), 'America/Los_Angeles', 'cancelled'],
+ ];
+ for (const r of rows) {
+ await db.prepare(`INSERT INTO today_events (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url)
+ VALUES (?, 'usr_01DEVSEED0000000000000000', ?, ?, ?, ?, ?, ?, ?, ?, 'public', ?) ON CONFLICT (id) DO UPDATE SET starts_at = EXCLUDED.starts_at, ends_at = EXCLUDED.ends_at, status = EXCLUDED.status`)
+ .run(r[0], r[1], r[2], r[3], r[4], r[5], r[6], r[7], r[8], r[2] ? `https://openvibe.live/@${r[2]}` : null);
+ }
+ console.log(`seeded ${rows.length} events`);
+ process.exit(0);
+})().catch((e) => { console.error(e.message); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * A person's agenda: their own events, the events they set a reminder on, and the public or followers-only events
+ * of the channels they follow. Follows come from OpenVibe.Network with the person's own token (server/identity/
+ * follows.js, cached 60 s); when Network is down the agenda still answers with own and reminded events and says
+ * follows_unavailable: true.
+ */
+const { wire } = require('./events/store');
+
+function createAgenda({ s, follows }) {
+ const db = s.db;
+
+ async function forSubject({ subject, token, days = 14, now = s.now() }) {
+ const from = new Date(now - 2 * 60 * 60_000).toISOString();
+ const to = new Date(now + days * 24 * 60 * 60_000).toISOString();
+ let followed = [];
+ let unavailable = false;
+ if (token) {
+ const r = await follows.forSubject(subject, token);
+ followed = r.follows;
+ unavailable = r.unavailable;
+ } else {
+ unavailable = true;
+ }
+ const rows = await db.many(
+ `SELECT * FROM today_events e
+ WHERE e.starts_at >= $1::timestamptz AND e.starts_at <= $2::timestamptz AND e.status <> 'cancelled'
+ AND (e.owner_subject = $3
+ OR EXISTS (SELECT 1 FROM today_reminders r WHERE r.subject = $3 AND r.event_id = e.id)
+ OR (e.owner_subject = ANY($4::text[]) AND e.visibility IN ('public', 'followers')))
+ ORDER BY e.starts_at ASC, e.id ASC`,
+ [from, to, subject, followed],
+ );
+ const reminded = new Set((await db.many('SELECT event_id FROM today_reminders WHERE subject = $1', [subject])).map((r) => r.event_id));
+ return {
+ from, to, days,
+ events: rows.map((r) => ({ ...wire(r), own: r.owner_subject === subject, reminded: reminded.has(r.id) })),
+ follows_unavailable: unavailable,
+ };
+ }
+
+ /** For the ICS token feed and the brief: the same set, without the Network flag. */
+ async function windowFor({ subject, token, fromMs, toMs, now = s.now() }) {
+ const from = new Date(fromMs).toISOString();
+ const to = new Date(toMs).toISOString();
+ let followed = [];
+ if (token) followed = (await follows.forSubject(subject, token)).follows;
+ else followed = follows.peek(subject);
+ return await db.many(
+ `SELECT * FROM today_events e
+ WHERE e.starts_at >= $1::timestamptz AND e.starts_at <= $2::timestamptz
+ AND (e.status <> 'cancelled' OR e.updated_at >= $3::timestamptz)
+ AND (e.owner_subject = $4
+ OR EXISTS (SELECT 1 FROM today_reminders r WHERE r.subject = $4 AND r.event_id = e.id)
+ OR (e.owner_subject = ANY($5::text[]) AND e.visibility IN ('public', 'followers')))
+ ORDER BY e.starts_at ASC, e.id ASC`,
+ [from, to, new Date(now - 7 * 24 * 60 * 60_000).toISOString(), subject, followed],
+ );
+ }
+
+ return { forSubject, windowFor };
+}
+
+module.exports = { createAgenda };
new file mode 100644
new file mode 100644
@@ -0,0 +1,183 @@
@@ -0,0 +1,183 @@
+'use strict';
+
+/**
+ * OpenVibe.Today — Express app factory; server/index.js listens, tests build their own instance with a temp
+ * database and a mock Network.
+ *
+ * /, /@:username, /e/:id, /me, /brief, /schedule, /developers the pages (http/pages.js)
+ * /ics/* RFC 5545 calendars (http/pages.js, ics.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)
+ *
+ * The jobs (outbox publisher, reminders, daily brief) are made here but started by server/index.js (and never
+ * under TODAY_DRILL); tests drive them explicitly.
+ */
+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 { createEventsStore } = require('./events/store');
+const { createEventsConsumer } = require('./events/consumer');
+const { createPublisher } = require('./events/publisher');
+const { createFeeds } = require('./feeds');
+const { createFollows } = require('./identity/follows');
+const { createAgenda } = require('./agenda');
+const { createBrief } = require('./brief');
+const { createNotifier } = require('./notifications');
+const { createReminders } = require('./reminders');
+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), 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 events = createEventsStore({ s, log });
+ const feeds = createFeeds({ s });
+ const follows = createFollows({ config, fetchImpl, now: s.now, log });
+ const agenda = createAgenda({ s, follows });
+ const brief = createBrief({ config, s, agenda, fetchImpl, log });
+ const notifier = createNotifier({ config, s, fetchImpl, log });
+ const reminders = createReminders({ config, s, events, notifier, log });
+ const publisher = createPublisher({ config, s, fetchImpl, log });
+
+ // Account export and deletion (ADR-033). The sender posts to Network's internal export/deletion 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: Today cannot answer account events'); });
+ const consumer = createEventsConsumer({ config, s, accountData, accountSend, now: s.now, log });
+
+ const ctx = {
+ config, s, keys, sso, principal, events, feeds, follows, agenda, brief, notifier, reminders, publisher,
+ accountData, accountSend, consumer, log,
+ };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'today', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'today', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'today', 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-today', name: 'OpenVibe.Today', 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: 'today', service: 'today', host: 'openvibe.today', name: 'OpenVibe.Today', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // live.stream.started|ended and the account topics; the consumer reads the raw body itself, so it is mounted
+ // before any body parser. nginx answers 404 for /internal/.
+ app.use('/internal/events', consumer.router);
+
+ // ── 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.TODAY_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.TODAY_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.Today]', 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,85 @@
@@ -0,0 +1,85 @@
+'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).
+ *
+ * This is Help's shim, with the service's name changed: 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.Today] 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.Today] 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.Today] 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,261 @@
@@ -0,0 +1,261 @@
+'use strict';
+
+/**
+ * Sign-in with OpenVibe.Network: OAuth 2 authorization code with PKCE (S256), as OAuth client `today`.
+ *
+ * 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 Help's SSO module with the client id and cookie names changed. The person's Network access
+ * token lives in an httpOnly cookie (today_at) no script can read; the refresh token (today_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 = 'today_at';
+const REFRESH_COOKIE = 'today_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'today_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 Today 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 of them 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.Today] 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.Today?</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,186 @@
@@ -0,0 +1,186 @@
+'use strict';
+
+/**
+ * The brief (v1, text): what is left of today, the reminders due today, and up to three things from OpenVibe.Live's
+ * public Rundown API. `buildBrief(subject, { now, tz })` returns `{ day, sections }`; `getOrBuild` stores one brief
+ * per subject per day in today_briefs. The daily job at 06:00 UTC builds briefs for subjects active in the last
+ * 30 days (a reminder, an own event or a feed); v1 does not notify.
+ *
+ * A brief built for a request with the person's token can include the channels they follow; the daily job has no
+ * token and therefore uses the same agenda with own and reminded events only.
+ */
+const { isIanaZone } = require('./events/validate');
+
+function dayIn(tz, ms) {
+ try {
+ const parts = new Intl.DateTimeFormat('en-CA', { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }).formatToParts(new Date(ms));
+ const get = (t) => parts.find((p) => p.type === t).value;
+ return `${get('year')}-${get('month')}-${get('day')}`;
+ } catch {
+ return new Date(ms).toISOString().slice(0, 10);
+ }
+}
+
+function timeIn(tz, ms) {
+ try {
+ return new Intl.DateTimeFormat('en-US', { timeZone: tz, hour: 'numeric', minute: '2-digit' }).format(new Date(ms));
+ } catch {
+ return new Date(ms).toISOString().slice(11, 16) + ' UTC';
+ }
+}
+
+function createBrief({ config, s, agenda, fetchImpl = globalThis.fetch, log = console }) {
+ const db = s.db;
+ const base = String(config.baseUrl).replace(/\/+$/, '');
+
+ async function rundownItems(now) {
+ try {
+ const res = await fetchImpl(`${config.live.internalUrl.replace(/\/+$/, '')}/api/rundown`, {
+ headers: { Accept: 'application/json' },
+ signal: AbortSignal.timeout(config.live.timeoutMs),
+ });
+ if (!res.ok) throw new Error(`rundown ${res.status}`);
+ const data = await res.json().catch(() => null);
+ if (!data || typeof data !== 'object') throw new Error('rundown: no body');
+ const yesterday = dayIn('UTC', now - 24 * 60 * 60_000);
+ const isYesterday = (v) => {
+ if (!v) return true; // a fixture without a date is still a Rundown item
+ const d = new Date(v);
+ return Number.isNaN(d.getTime()) || dayIn('UTC', d.getTime()) === yesterday;
+ };
+ const items = [];
+ const seen = new Set();
+ const push = (text, url, at) => {
+ if (items.length >= 3 || !text || !url || seen.has(url) || !isYesterday(at)) return;
+ seen.add(url);
+ items.push({ text: String(text).slice(0, 300), url, action: null });
+ };
+ const lead = data.lead;
+ if (lead) {
+ const who = lead.user && (lead.user.display_name || lead.user.username);
+ const what = lead.take || lead.title || lead.topic && lead.topic.title;
+ const url = lead.topic && lead.topic.slug
+ ? `https://openvibe.live/rundown/t/${encodeURIComponent(lead.topic.slug)}`
+ : `https://openvibe.live/rundown/e/${encodeURIComponent(lead.stream_id)}`;
+ push(`${what || 'A moment'}${who ? ` — ${who}` : ''}`, url, lead.said_at);
+ }
+ for (const stream of Array.isArray(data.streams) ? data.streams : []) {
+ const ep = stream.episode || stream;
+ const who = (ep.user || stream.user) && ((ep.user || stream.user).display_name || (ep.user || stream.user).username);
+ const what = ep.title || ep.logline || stream.talking_about;
+ const id = ep.stream_id || stream.stream_id || stream.id;
+ if (id) push(`${what || 'An episode'}${who ? ` — ${who}` : ''}`, `https://openvibe.live/rundown/e/${encodeURIComponent(id)}`, ep.aired_at || stream.said_at);
+ }
+ for (const topic of Array.isArray(data.trending) ? data.trending : []) {
+ if (!topic.slug) continue;
+ const extra = topic.streamers ? ` — ${topic.streamers} streamer${Number(topic.streamers) === 1 ? '' : 's'}` : '';
+ push(`${topic.headline || topic.title}${extra}`, `https://openvibe.live/rundown/t/${encodeURIComponent(topic.slug)}`, topic.last_seen_at);
+ }
+ return items;
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] Rundown unavailable for the brief: ${(err && err.message) || err}`);
+ return null;
+ }
+ }
+
+ async function buildBrief(subject, { now = s.now(), tz = 'UTC', token = null } = {}) {
+ if (!isIanaZone(tz)) tz = 'UTC';
+ const day = dayIn(tz, now);
+ const sections = [];
+
+ const agendaOut = await agenda.forSubject({ subject, token, days: 2, now });
+ const today = agendaOut.events.filter((e) => (e.status === 'scheduled' || e.status === 'live') && dayIn(tz, new Date(e.starts_at).getTime()) === day);
+ const later = [];
+ for (const e of today) {
+ const when = e.status === 'live' ? 'live now' : `at ${timeIn(tz, new Date(e.starts_at).getTime())}`;
+ later.push({ text: `${e.title} ${when}`, url: `${base}/e/${e.id}`, action: 'remind' });
+ }
+ if (later.length) sections.push({ id: 'later-today', title: 'Later today', items: later });
+
+ const reminderRows = await db.many(
+ `SELECT r.remind_at, e.* FROM today_reminders r JOIN today_events e ON e.id = r.event_id
+ WHERE r.subject = $1 AND e.status IN ('scheduled', 'live') ORDER BY r.remind_at ASC`,
+ [subject],
+ );
+ const reminders = reminderRows
+ .filter((r) => dayIn(tz, new Date(r.remind_at).getTime()) === day)
+ .map((r) => ({ text: `${r.title} — reminder at ${timeIn(tz, new Date(r.remind_at).getTime())}`, url: `${base}/e/${r.id}`, action: 'remind' }));
+ if (reminders.length) sections.push({ id: 'your-reminders', title: 'Your reminders', items: reminders });
+
+ const rundown = await rundownItems(now);
+ if (rundown && rundown.length) sections.push({ id: 'yesterday', title: 'Yesterday on OpenVibe', items: rundown });
+
+ return { day, sections };
+ }
+
+ async function getOrBuild(subject, opts = {}) {
+ const tz = isIanaZone(opts.tz) ? opts.tz : 'UTC';
+ const day = dayIn(tz, opts.now == null ? s.now() : opts.now);
+ const found = await db.maybe('SELECT body FROM today_briefs WHERE subject = $1 AND day = $2::date', [subject, day]);
+ if (found) return { day, sections: found.body || [] };
+ const built = await buildBrief(subject, { ...opts, tz });
+ await db.exec(
+ 'INSERT INTO today_briefs (subject, day, body, created_at) VALUES ($1, $2::date, $3::jsonb, $4::timestamptz) ON CONFLICT (subject, day) DO NOTHING',
+ [subject, built.day, JSON.stringify(built.sections), new Date(s.now()).toISOString()],
+ );
+ const stored = await db.maybe('SELECT body FROM today_briefs WHERE subject = $1 AND day = $2::date', [subject, built.day]);
+ return { day: built.day, sections: stored ? (stored.body || []) : built.sections };
+ }
+
+ /** The daily job: subjects with a reminder, an own event or a feed in the last 30 days. */
+ async function runOnce({ now = s.now(), force = false } = {}) {
+ const at = new Date(now);
+ if (!force && at.getUTCHours() !== config.brief.hourUtc) return { built: 0, skipped: 'hour' };
+ const cutoff = new Date(now - config.brief.activeDays * 24 * 60 * 60_000).toISOString();
+ const rows = await db.many(
+ `SELECT DISTINCT subject FROM (
+ SELECT subject FROM today_reminders WHERE created_at >= $1::timestamptz
+ UNION SELECT owner_subject AS subject FROM today_events WHERE created_at >= $1::timestamptz OR updated_at >= $1::timestamptz
+ UNION SELECT subject FROM today_feeds WHERE created_at >= $1::timestamptz
+ ) s ORDER BY subject`,
+ [cutoff],
+ );
+ let built = 0;
+ for (const row of rows) {
+ try {
+ await getOrBuild(row.subject, { now, tz: 'UTC' });
+ built++;
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] brief for ${row.subject} failed: ${(err && err.message) || err}`);
+ }
+ }
+ return { built, subjects: rows.length };
+ }
+
+ let timer = null;
+ let lastDay = null;
+ function start() {
+ if (timer || config.drill) return false;
+ timer = setInterval(() => {
+ const now = s.now();
+ const day = dayIn('UTC', now);
+ if (day === lastDay) return;
+ runOnce({ now }).then((r) => { if (r.subjects !== undefined) lastDay = day; }).catch((err) => log.warn('[OpenVibe.Today] brief tick failed:', err.message));
+ }, 60_000);
+ if (timer.unref) timer.unref();
+ return true;
+ }
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ const handle = { buildBrief, getOrBuild, runOnce, start, stop, rundownItems };
+ active = handle;
+ return handle;
+}
+
+// The module-level name the spec gives. It uses the builder the app created most recently; a test that boots one
+// app can call require('../server/brief').buildBrief(subject, { now, tz }) directly.
+let active = null;
+async function buildBrief(subject, opts = {}) {
+ if (!active) throw new Error('buildBrief: no Today app has created a brief builder in this process yet');
+ return active.buildBrief(subject, opts);
+}
+
+module.exports = { createBrief, buildBrief, dayIn, timeIn };
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * OpenVibe.Today configuration. Every value comes from the environment (production: /etc/openvibe/today.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, 5100);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.today' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'today',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.TODAY_DRILL === '1' || env.TODAY_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.TODAY_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.TODAY_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 (TODAY_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.TODAY_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:today:' },
+
+ // 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.TODAY_AUDIENCE || 'openvibe.today',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'today',
+ 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. The secret signs a delivery (comma-separated for rotation, 32+
+ // characters each); unset turns POST /internal/events off (503). The url is where subscriptions are
+ // created at boot, off when unset.
+ events: {
+ secrets: String(env.TODAY_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.TODAY_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.TODAY_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.TODAY_EVENTS_INTERVAL_MS, 5000)),
+ // The scope this service's client-credentials token asks for when publishing. The product name is
+ // events.publish; OpenVibe.Contracts names the capability events.event.publish, which the publisher
+ // falls back to if the token endpoint refuses the product name. TODAY_EVENTS_SCOPE overrides both.
+ scope: env.TODAY_EVENTS_SCOPE || 'events.event.publish',
+ },
+
+ // OpenVibe.Live's public Rundown API, for "Yesterday on OpenVibe" in the brief.
+ live: {
+ internalUrl: trim(env.OV_LIVE_INTERNAL_URL || 'http://127.0.0.1:3000'),
+ timeoutMs: Math.max(300, int(env.TODAY_LIVE_TIMEOUT_MS, 2500)),
+ },
+
+ brief: {
+ hourUtc: Math.min(23, Math.max(0, int(env.TODAY_BRIEF_HOUR_UTC, 6))),
+ activeDays: Math.max(1, int(env.TODAY_BRIEF_ACTIVE_DAYS, 30)),
+ },
+
+ // The follows and notification calls this service makes on a person's behalf.
+ follows: { cacheMs: Math.max(0, int(env.TODAY_FOLLOWS_CACHE_MS, 60_000)) },
+ notifications: { resolveCacheMs: Math.max(0, int(env.TODAY_RESOLVE_CACHE_MS, 5 * 60_000)) },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,71 @@
@@ -0,0 +1,71 @@
+'use strict';
+
+/**
+ * OpenVibe.Today's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret except a feed token, which is a bearer capability for one calendar and
+ * is treated as one (never logged).
+ */
+const fs = require('fs');
+const path = require('path');
+const crypto = require('crypto');
+const { createDb } = require('openvibe-sdk/db');
+
+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.Today] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'today', 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: 'today-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'today', 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) => {
+ if (prefix === 'event') return `evt_${require('openvibe-contracts').ids.ulid(now())}`;
+ return `${prefix}_${require('openvibe-contracts').ids.ulid(now())}`;
+ },
+ tx: async (fn) => await db.tx(() => fn()),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+/** A today_events id: tev_ + 20 Crockford base32 characters (100 bits from the OS random source). */
+const BASE32 = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
+function newEventId() {
+ const bytes = crypto.randomBytes(13);
+ let bits = 0;
+ let value = 0;
+ let out = '';
+ for (const byte of bytes) {
+ value = (value << 8) | byte;
+ bits += 8;
+ while (bits >= 5 && out.length < 20) {
+ out += BASE32[(value >>> (bits - 5)) & 31];
+ bits -= 5;
+ }
+ value &= (1 << bits) - 1;
+ }
+ while (out.length < 20) { out += BASE32[value & 31]; value >>>= 5; }
+ return `tev_${out.slice(0, 20)}`;
+}
+
+module.exports = { openDb, openStore, createStore, newEventId, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+'use strict';
+
+/**
+ * Help's file name, today's consumer: POST /internal/events and the boot-time startSubscriptions(). The
+ * implementation lives in server/events/consumer.js.
+ */
+const { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS } = require('./events/consumer');
+
+module.exports = { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,222 @@
@@ -0,0 +1,222 @@
+'use strict';
+
+/**
+ * OpenVibe.Events → Today. POST /internal/events is this service's subscription endpoint (loopback only; nginx
+ * answers 404 for /internal).
+ *
+ * live.stream.started the channel's scheduled event whose start is within 2 h of now becomes live
+ * live.stream.ended a live event of that channel becomes done
+ * network.account.export_requested / network.account.deleted
+ * handed to openvibe-sdk/account-data (server/identity/account-data.js), which keeps
+ * its own idempotency and sends the export part / deletion confirmation to Network
+ *
+ * Every other inbound event id is recorded in today_processed in the same transaction as its effect, so a replay
+ * changes nothing. A delivery is verified with the v2 signature (replay window, raw body) exactly as Help's
+ * consumer verifies. The boot-time half, startSubscriptions(), creates any missing subscriptions at Events.
+ */
+const express = require('express');
+const { http, serviceAuth } = require('openvibe-contracts');
+const { parseDelivery } = require('openvibe-sdk/events');
+
+const LIVE_TOPICS = Object.freeze(['live.stream.started', 'live.stream.ended']);
+const TOPICS = Object.freeze([...LIVE_TOPICS, ...require('openvibe-sdk/account-data').TOPICS]);
+const EVENT_ID_RE = /^evt_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createEventsConsumer({ config, s, accountData, accountSend = null, now = s.now, log = console }) {
+ const db = s.db;
+ const secrets = (config.events.secrets || []).filter((x) => typeof x === 'string' && x.length >= 32);
+ const topics = [...LIVE_TOPICS, ...accountData.TOPICS];
+ const stats = { received: 0, applied: 0, duplicates: 0, ignored: 0, refused: 0, failed: 0, last_at: null };
+ const router = express.Router();
+
+ function record(eventId, atIso) {
+ return db.exec('INSERT INTO today_processed (event_id, processed_at) VALUES ($1, $2::timestamptz) ON CONFLICT (event_id) DO NOTHING', [eventId, atIso]);
+ }
+
+ async function applyLive(event) {
+ const payload = event.payload && typeof event.payload === 'object' ? event.payload : {};
+ const username = payload.channel && typeof payload.channel.username === 'string' ? payload.channel.username : null;
+ if (!username) return 'ignored:payload';
+ const atIso = new Date(now()).toISOString();
+ return await s.tx(async () => {
+ const inserted = await record(event.event_id, atIso);
+ if (!inserted) return { duplicate: true, outcome: null };
+ if (event.event_type === 'live.stream.started') {
+ const at = new Date(now()).toISOString();
+ const row = await db.maybe(
+ `SELECT * FROM today_events
+ WHERE LOWER(channel) = LOWER($1) AND status = 'scheduled'
+ AND starts_at BETWEEN $2::timestamptz - interval '2 hours' AND $2::timestamptz + interval '2 hours'
+ ORDER BY ABS(EXTRACT(EPOCH FROM (starts_at - $2::timestamptz))) ASC, starts_at ASC LIMIT 1`,
+ [username, at],
+ );
+ if (!row) return { duplicate: false, outcome: 'ignored:no_event' };
+ await db.exec("UPDATE today_events SET status = 'live', seq = seq + 1, updated_at = $1::timestamptz WHERE id = $2", [atIso, row.id]);
+ return { duplicate: false, outcome: 'live', event_id: row.id };
+ }
+ // live.stream.ended
+ const row = await db.maybe(
+ "SELECT * FROM today_events WHERE LOWER(channel) = LOWER($1) AND status = 'live' ORDER BY starts_at DESC, updated_at DESC LIMIT 1",
+ [username],
+ );
+ if (!row) return { duplicate: false, outcome: 'ignored:no_event' };
+ await db.exec("UPDATE today_events SET status = 'done', seq = seq + 1, updated_at = $1::timestamptz WHERE id = $2", [atIso, row.id]);
+ return { duplicate: false, outcome: 'done', event_id: row.id };
+ });
+ }
+
+ router.post('/', express.raw({ type: () => true, limit: '256kb' }), async (req, res) => {
+ const ctx = http.requestContext(req.headers);
+ const problem = (status, code, detail) => http.sendProblem(res, status, code, { detail, ctx });
+ if (req.headers['x-forwarded-for'] || req.headers['x-real-ip'] || req.headers['cf-connecting-ip']) return problem(403, 'today.internal_only', 'internal route');
+ if (!secrets.length) return problem(503, 'today.events_disabled', 'TODAY_EVENTS_SECRET is not set');
+ const raw = Buffer.isBuffer(req.body) ? req.body : Buffer.alloc(0);
+ let delivery = null;
+ for (const secret of secrets) { delivery = parseDelivery(raw, req.headers, secret, { requireV2: true, now: now() }); if (delivery) break; }
+ if (!delivery) { stats.refused++; return problem(401, 'today.bad_signature', 'X-OpenVibe-Signature-V2 does not verify or is outside the replay window'); }
+ const event = delivery.event;
+ if (!event || !EVENT_ID_RE.test(String(event.event_id || '')) || typeof event.event_type !== 'string') { stats.refused++; return problem(400, 'today.bad_delivery', 'body must be { event: <envelope>, seq }'); }
+ stats.received++;
+ stats.last_at = new Date(now()).toISOString();
+
+ if (accountData.TOPICS.includes(event.event_type)) {
+ let outcome;
+ try {
+ outcome = await accountData.apply(event, { send: accountSend });
+ } catch (err) {
+ stats.failed++;
+ log.error(`[OpenVibe.Today] account event ${event.event_id} failed:`, err.message);
+ return problem(503, 'today.event_failed', 'processing failed; it will be retried');
+ }
+ if (String(outcome).startsWith('ignored:')) stats.ignored++; else stats.applied++;
+ return res.json({ event_id: event.event_id, outcome });
+ }
+
+ if (!LIVE_TOPICS.includes(event.event_type)) {
+ const inserted = await record(event.event_id, new Date(now()).toISOString());
+ stats.ignored++;
+ if (!inserted) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
+ return res.json({ event_id: event.event_id, outcome: 'ignored:type' });
+ }
+
+ let result;
+ try {
+ result = await applyLive(event);
+ } catch (err) {
+ stats.failed++;
+ log.error(`[OpenVibe.Today] event ${event.event_id} (${event.event_type}) failed:`, err.message);
+ return problem(503, 'today.event_failed', 'processing failed; it will be retried');
+ }
+ if (result.duplicate) { stats.duplicates++; return res.json({ event_id: event.event_id, duplicate: true, outcome: null }); }
+ if (String(result.outcome).startsWith('ignored:')) stats.ignored++; else stats.applied++;
+ return res.json({ event_id: event.event_id, duplicate: false, outcome: result.outcome, ...(result.event_id ? { affected: result.event_id } : {}) });
+ });
+
+ /** The subscriptions this service needs: the two live topics plus the two account topics. */
+ function startSubscriptions({ port = config.port, secret = secrets[0] || '', eventsUrl = config.events.url, fetchImpl = globalThis.fetch } = {}) {
+ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.TODAY_EVENTS_SUBSCRIBE === '0') return null;
+ const base = String(eventsUrl).replace(/\/+$/, '');
+ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events',
+ scope: 'events.subscription.manage',
+ fetchImpl,
+ });
+ const call = async (method, path, body) => {
+ const res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(15000),
+ });
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, ok: res.ok, body: json };
+ };
+ const attempt = async () => {
+ const listed = await call('GET', '/api/v1/subscriptions');
+ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
+ const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);
+ for (const topic of topics) {
+ if (mine.some((x) => x.topic_pattern === topic)) continue;
+ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
+ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
+ if (r.ok) log.log(`[OpenVibe.Today] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ }
+ };
+ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
+ let i = 0;
+ let timer = null;
+ let stopped = false;
+ const run = () => {
+ timer = null;
+ if (stopped) return;
+ attempt().catch((err) => {
+ if (stopped) return;
+ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); } else log.warn('[OpenVibe.Today] subscriptions not created:', err.message);
+ });
+ };
+ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
+ return { topics, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
+ }
+
+ return { router, topics, stats: () => ({ ...stats, enabled: secrets.length > 0 }), startSubscriptions };
+}
+
+/** The boot-time half on its own (Help's shape): create the missing subscriptions for `topics`. */
+function startSubscriptions({
+ config, port = config.port, secret = '', eventsUrl = config.events.url, fetchImpl = globalThis.fetch,
+ topics = [...LIVE_TOPICS, ...require('openvibe-sdk/account-data').TOPICS], log = console,
+} = {}) {
+ if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.TODAY_EVENTS_SUBSCRIBE === '0') return null;
+ const base = String(eventsUrl).replace(/\/+$/, '');
+ const endpoint = config.events.endpoint || `http://127.0.0.1:${port}/internal/events`;
+ const tokens = serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events',
+ scope: 'events.subscription.manage',
+ fetchImpl,
+ });
+ const call = async (method, path, body) => {
+ const res = await fetchImpl(`${base}${path}`, {
+ method,
+ headers: { Accept: 'application/json', ...(body ? { 'Content-Type': 'application/json' } : {}), ...(await tokens.authHeaders()) },
+ body: body ? JSON.stringify(body) : undefined,
+ signal: AbortSignal.timeout(15000),
+ });
+ const json = await res.json().catch(() => ({}));
+ return { status: res.status, ok: res.ok, body: json };
+ };
+ const attempt = async () => {
+ const listed = await call('GET', '/api/v1/subscriptions');
+ if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
+ const mine = (listed.body.subscriptions || []).filter((x) => x.endpoint === endpoint);
+ for (const topic of topics) {
+ if (mine.some((x) => x.topic_pattern === topic)) continue;
+ const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
+ if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
+ if (r.ok) log.log(`[OpenVibe.Today] subscription created: ${r.body.id} (${topic} → ${endpoint})`);
+ }
+ };
+ const delays = [0, 10_000, 60_000, 5 * 60_000, 15 * 60_000];
+ let i = 0;
+ let timer = null;
+ let stopped = false;
+ const run = () => {
+ timer = null;
+ if (stopped) return;
+ attempt().catch((err) => {
+ if (stopped) return;
+ if (++i < delays.length) { timer = setTimeout(run, delays[i]); if (timer.unref) timer.unref(); }
+ else log.warn('[OpenVibe.Today] subscriptions not created:', err.message);
+ });
+ };
+ timer = setTimeout(run, delays[0]); if (timer.unref) timer.unref();
+ return { topics, endpoint, stop() { stopped = true; if (timer) clearTimeout(timer); timer = null; } };
+}
+
+module.exports = { createEventsConsumer, startSubscriptions, LIVE_TOPICS, TOPICS, EVENT_ID_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,167 @@
@@ -0,0 +1,167 @@
+'use strict';
+
+/**
+ * The transactional outbox → OpenVibe.Events. A row is written in the same transaction as the event change and
+ * carries the evt_ id it will always publish under, so a retry (or a restart) never makes a second event. A claim
+ * lease means two processes do not send the same row at once. Failures back off and are recorded; the job never
+ * throws into the process.
+ */
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+
+const MAX_ATTEMPTS_BACKOFF_MS = 5 * 60_000;
+
+function createPublisher({ config, s, fetchImpl = globalThis.fetch, log = console, intervalMs = config.events.intervalMs }) {
+ const db = s.db;
+ const worker = `today-${process.pid}-${crypto.randomBytes(4).toString('hex')}`;
+ const configured = Boolean(config.events.url && config.oauth.clientSecret);
+ // OpenVibe.Events' publish capability (TODAY_EVENTS_SCOPE overrides it for a self-hosted Events).
+ const scopes = [...new Set([config.events.scope || 'events.event.publish'])];
+ const clients = configured ? scopes.map((scope) => serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.events',
+ scope,
+ fetchImpl,
+ })) : [];
+ let preferred = 0;
+ async function authHeaders() {
+ for (let i = preferred; i < clients.length; i++) {
+ try {
+ const headers = await clients[i].authHeaders();
+ preferred = i;
+ return headers;
+ } catch (err) {
+ if (i === clients.length - 1) throw err;
+ }
+ }
+ throw new Error('no Events token client is configured');
+ }
+ let timer = null;
+ let running = false;
+ const stats = { published: 0, failed: 0, last_error: null };
+
+ function backoffMs(attempts) {
+ return Math.min(MAX_ATTEMPTS_BACKOFF_MS, 5000 * Math.pow(2, Math.max(0, Math.min(6, attempts))));
+ }
+
+ async function claim(limit = 20) {
+ const now = new Date(s.now()).toISOString();
+ const stale = new Date(s.now() - 2 * 60_000).toISOString();
+ return await db.many(
+ `UPDATE today_outbox o SET claimed_at = $1::timestamptz, claimed_by = $2
+ WHERE o.event_id IN (
+ SELECT o2.event_id FROM today_outbox o2
+ WHERE o2.published_at IS NULL AND o2.next_attempt_at <= $1::timestamptz
+ AND (o2.claimed_at IS NULL OR o2.claimed_at < $3::timestamptz)
+ ORDER BY o2.created_at ASC, o2.event_id ASC
+ LIMIT $4
+ FOR UPDATE SKIP LOCKED
+ )
+ RETURNING o.*`,
+ [now, worker, stale, Math.min(100, Math.max(1, limit))],
+ );
+ }
+
+ function envelope(row, owner) {
+ return {
+ event_id: row.event_id,
+ event_type: row.topic,
+ version: 1,
+ source: 'today',
+ actor: { type: 'user', id: owner },
+ subject: { type: 'user', id: owner },
+ timestamp: new Date(s.now()).toISOString(),
+ priority: 'important',
+ visibility: 'internal',
+ payload: row.payload,
+ };
+ }
+
+ async function ownerOf(row) {
+ const owner = await db.value(
+ "SELECT owner_subject FROM today_events WHERE id = $1",
+ [row.payload && row.payload.event_id],
+ );
+ return owner || null;
+ }
+
+ async function send(row) {
+ const owner = await ownerOf(row);
+ if (!owner) {
+ await db.exec("UPDATE today_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = 'event row is gone' WHERE event_id = $2", [new Date(s.now()).toISOString(), row.event_id]);
+ return { ok: true, gone: true };
+ }
+ const headers = { 'Content-Type': 'application/json', Accept: 'application/json', ...(await authHeaders()) };
+ const res = await fetchImpl(`${config.events.url.replace(/\/+$/, '')}/api/v1/events`, {
+ method: 'POST',
+ headers,
+ body: JSON.stringify(envelope(row, owner)),
+ signal: AbortSignal.timeout(15_000),
+ });
+ if (!res.ok) {
+ const text = await res.text().catch(() => '');
+ throw new Error(`events ${res.status}${text ? `: ${text.slice(0, 300)}` : ''}`);
+ }
+ await db.exec('UPDATE today_outbox SET published_at = $1::timestamptz, claimed_at = NULL, claimed_by = NULL, last_error = NULL WHERE event_id = $2', [new Date(s.now()).toISOString(), row.event_id]);
+ return { ok: true };
+ }
+
+ async function runOnce({ limit = 20 } = {}) {
+ if (!configured) return { configured: false, claimed: 0, published: 0, failed: 0 };
+ if (running) return { configured: true, busy: true, claimed: 0, published: 0, failed: 0 };
+ running = true;
+ let claimed = 0;
+ let published = 0;
+ let failed = 0;
+ try {
+ const rows = await claim(limit);
+ claimed = rows.length;
+ for (const row of rows) {
+ try {
+ await send(row);
+ published++;
+ stats.published++;
+ } catch (err) {
+ failed++;
+ stats.failed++;
+ const message = String((err && err.message) || err).slice(0, 500);
+ stats.last_error = message;
+ const attempts = Number(row.attempts) + 1;
+ const next = new Date(s.now() + backoffMs(attempts)).toISOString();
+ await db.exec(
+ `UPDATE today_outbox SET attempts = attempts + 1, last_error = $1, claimed_at = NULL, claimed_by = NULL,
+ next_attempt_at = $2::timestamptz WHERE event_id = $3`,
+ [message, next, row.event_id],
+ );
+ log.warn(`[OpenVibe.Today] outbox ${row.event_id} publish failed (attempt ${attempts}): ${message}`);
+ }
+ }
+ return { configured: true, claimed, published, failed };
+ } finally {
+ running = false;
+ }
+ }
+
+ function start() {
+ if (!configured || timer || config.drill) return false;
+ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Today] outbox tick failed:', err.message)); }, intervalMs);
+ if (timer.unref) timer.unref();
+ return true;
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ return Promise.resolve();
+ }
+
+ async function pending() {
+ return Number(await db.value('SELECT COUNT(*)::int AS n FROM today_outbox WHERE published_at IS NULL')) || 0;
+ }
+
+ return { configured, runOnce, start, stop, pending, stats: () => ({ ...stats }) };
+}
+
+module.exports = { createPublisher, MAX_ATTEMPTS_BACKOFF_MS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,297 @@
@@ -0,0 +1,297 @@
+'use strict';
+
+/**
+ * Today's events and reminders in PostgreSQL. An event and the outbox row that announces it are written in one
+ * transaction; a repeated Idempotency-Key returns the event it made the first time. Every change bumps seq (the ICS
+ * SEQUENCE). A cancelled event stays readable (status cancelled) so calendars can remove it.
+ */
+const { ids } = require('openvibe-contracts');
+const { newEventId } = require('../db');
+const { validateCreate, validatePatch } = require('./validate');
+
+const MAX_FUTURE = 60;
+
+class StoreRefusal extends Error {
+ constructor(status, code, field, message) {
+ super(message);
+ this.status = status; this.code = code; this.field = field;
+ }
+}
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+
+/** The JSON shape the API returns. */
+function wire(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ owner_subject: row.owner_subject,
+ kind: row.kind,
+ channel: row.channel || null,
+ title: row.title,
+ description: row.description == null ? null : row.description,
+ starts_at: iso(row.starts_at),
+ ends_at: iso(row.ends_at),
+ tz: row.tz,
+ status: row.status,
+ visibility: row.visibility,
+ url: row.url || null,
+ seq: Number(row.seq) || 0,
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ };
+}
+
+/** The payload of an Events envelope (never the owner: the envelope's subject carries that). */
+function payloadOf(row) {
+ return {
+ event_id: row.id,
+ kind: row.kind,
+ channel: row.channel || null,
+ title: row.title,
+ starts_at: iso(row.starts_at),
+ ends_at: iso(row.ends_at),
+ tz: row.tz,
+ status: row.status,
+ visibility: row.visibility,
+ url: row.url || null,
+ seq: Number(row.seq) || 0,
+ };
+}
+
+function createEventsStore({ s, log = console }) {
+ const db = s.db;
+ const nowIso = () => new Date(s.now()).toISOString();
+
+ async function insertOutbox(topic, row) {
+ const eventId = ids.newId('event', s.now());
+ await db.exec(
+ `INSERT INTO today_outbox (event_id, topic, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3::jsonb, $4::timestamptz, $4::timestamptz)`,
+ [eventId, topic, JSON.stringify(payloadOf(row)), nowIso()],
+ );
+ return eventId;
+ }
+
+ async function byId(id) {
+ return await db.maybe('SELECT * FROM today_events WHERE id = $1', [id]);
+ }
+
+ async function byIdForViewer(id, subject) {
+ const row = await byId(id);
+ if (!row) return null;
+ if (row.visibility === 'followers' && row.owner_subject !== subject) return null;
+ return row;
+ }
+
+ async function listPublic({ channel = null, from, to, limit = 50 } = {}) {
+ const args = [from, to, Math.min(100, Math.max(1, Number(limit) || 50))];
+ let where = "visibility = 'public' AND status <> 'cancelled' AND starts_at >= $1::timestamptz AND starts_at <= $2::timestamptz";
+ if (channel) { args.push(channel); where += ` AND LOWER(channel) = LOWER($${args.length})`; }
+ const rows = await db.many(
+ `SELECT * FROM today_events WHERE ${where} ORDER BY starts_at ASC, id ASC LIMIT $3`,
+ args,
+ );
+ return rows.map(wire);
+ }
+
+ async function listChannel(channel, { from, to, includeCancelled = false } = {}) {
+ const args = [channel, from, to];
+ const cancelled = includeCancelled ? '' : " AND status <> 'cancelled'";
+ return (await db.many(
+ `SELECT * FROM today_events WHERE LOWER(channel) = LOWER($1) AND visibility = 'public'${cancelled}
+ AND starts_at >= $2::timestamptz AND starts_at <= $3::timestamptz ORDER BY starts_at ASC, id ASC`,
+ args,
+ )).map(wire);
+ }
+
+ async function countScheduledFuture(subject) {
+ const row = await db.one(
+ "SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1 AND status = 'scheduled' AND starts_at > $2::timestamptz",
+ [subject, nowIso()],
+ );
+ return Number(row.n) || 0;
+ }
+
+ async function create({ subject, username, body, idempotencyKey = null, now = s.now() }) {
+ const check = validateCreate(body, { now, username });
+ if (!check.ok) return check;
+ const key = typeof idempotencyKey === 'string' && idempotencyKey ? idempotencyKey : null;
+ try {
+ return await s.tx(async () => {
+ if (key) {
+ const claim = await db.maybe(
+ `INSERT INTO today_idempotency (subject, key, event_id, created_at)
+ VALUES ($1, $2, NULL, $3::timestamptz) ON CONFLICT (subject, key) DO NOTHING
+ RETURNING subject`,
+ [subject, key, nowIso()],
+ );
+ if (!claim) {
+ const prior = await db.maybe(
+ `SELECT e.* FROM today_idempotency i JOIN today_events e ON e.id = i.event_id
+ WHERE i.subject = $1 AND i.key = $2`,
+ [subject, key],
+ );
+ if (prior) return { ok: true, event: wire(prior), replayed: true };
+ throw new StoreRefusal(409, 'today.idempotency_in_progress', 'idempotency_key', 'a request with this key is still being handled; retry shortly');
+ }
+ }
+ if (await countScheduledFuture(subject) >= MAX_FUTURE) {
+ throw new StoreRefusal(409, 'today.too_many', 'events', `at most ${MAX_FUTURE} scheduled future events per owner`);
+ }
+ const id = newEventId();
+ const row = await db.one(
+ `INSERT INTO today_events
+ (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url, seq, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::timestamptz, $8::timestamptz, $9, 'scheduled', $10, $11, 0, $12::timestamptz, $12::timestamptz)
+ RETURNING *`,
+ [id, subject, check.fields.kind, check.fields.channel, check.fields.title, check.fields.description,
+ check.fields.starts_at, check.fields.ends_at, check.fields.tz, check.fields.visibility, check.fields.url, nowIso()],
+ );
+ if (key) await db.exec('UPDATE today_idempotency SET event_id = $1 WHERE subject = $2 AND key = $3', [id, subject, key]);
+ await insertOutbox('today.event.scheduled', row);
+ return { ok: true, event: wire(row), replayed: false };
+ });
+ } catch (err) {
+ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };
+ throw err;
+ }
+ }
+
+ async function patch({ id, subject, body, now = s.now() }) {
+ try {
+ return await s.tx(async () => {
+ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1 FOR UPDATE', [id]);
+ if (!row || row.owner_subject !== subject) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');
+ if (row.status === 'cancelled' || row.status === 'done') throw new StoreRefusal(409, 'today.locked', 'status', 'an event that is cancelled or done cannot be changed');
+ const check = validatePatch(body, row, { now });
+ if (!check.ok) return check;
+ const after = await db.one(
+ `UPDATE today_events SET title = $1, description = $2, starts_at = $3::timestamptz, ends_at = $4::timestamptz,
+ tz = $5, visibility = $6, seq = seq + 1, updated_at = $7::timestamptz
+ WHERE id = $8 RETURNING *`,
+ [check.fields.title, check.fields.description, check.fields.starts_at, check.fields.ends_at,
+ check.fields.tz, check.fields.visibility, nowIso(), id],
+ );
+ await insertOutbox('today.event.updated', after);
+ return { ok: true, event: wire(after) };
+ });
+ } catch (err) {
+ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };
+ throw err;
+ }
+ }
+
+ async function cancel({ id, subject }) {
+ try {
+ return await s.tx(async () => {
+ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1 FOR UPDATE', [id]);
+ if (!row || row.owner_subject !== subject) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');
+ if (row.status === 'cancelled') return { ok: true, event: wire(row), already: true };
+ const after = await db.one(
+ `UPDATE today_events SET status = 'cancelled', seq = seq + 1, updated_at = $1::timestamptz
+ WHERE id = $2 RETURNING *`,
+ [nowIso(), id],
+ );
+ await db.exec('DELETE FROM today_reminders WHERE event_id = $1', [id]);
+ await insertOutbox('today.event.cancelled', after);
+ return { ok: true, event: wire(after), already: false };
+ });
+ } catch (err) {
+ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };
+ throw err;
+ }
+ }
+
+ // ── Reminders ───────────────────────────────────────────
+ function validateMinutes(v) {
+ if (v == null || v === '') return { ok: true, value: 10 };
+ const n = Number(v);
+ if (!Number.isInteger(n) || n < 0 || n > 1440) return { ok: false, status: 400, code: 'today.invalid', field: 'minutes_before', message: 'minutes_before must be a whole number from 0 to 1440' };
+ return { ok: true, value: n };
+ }
+
+ async function remind({ subject, id, minutesBefore, now = s.now() }) {
+ const minutes = validateMinutes(minutesBefore);
+ if (!minutes.ok) return minutes;
+ try {
+ return await s.tx(async () => {
+ const row = await db.maybe('SELECT * FROM today_events WHERE id = $1', [id]);
+ if (!row || (row.visibility === 'followers' && row.owner_subject !== subject)) throw new StoreRefusal(404, 'today.not_found', null, 'no such event');
+ if (row.status === 'cancelled' || row.status === 'done') throw new StoreRefusal(409, 'today.locked', 'status', 'a cancelled or done event cannot be reminded');
+ const remindAt = new Date(new Date(row.starts_at).getTime() - minutes.value * 60_000).toISOString();
+ const saved = await db.one(
+ `INSERT INTO today_reminders (subject, event_id, remind_at, sent_at, created_at)
+ VALUES ($1, $2, $3::timestamptz, NULL, $4::timestamptz)
+ ON CONFLICT (subject, event_id) DO UPDATE SET remind_at = EXCLUDED.remind_at, sent_at = NULL
+ RETURNING *`,
+ [subject, id, remindAt, nowIso()],
+ );
+ return { ok: true, reminder: { event_id: id, remind_at: iso(saved.remind_at), minutes_before: minutes.value }, event: wire(row) };
+ });
+ } catch (err) {
+ if (err instanceof StoreRefusal) return { ok: false, status: err.status, code: err.code, field: err.field, message: err.message };
+ throw err;
+ }
+ }
+
+ async function unremind({ subject, id }) {
+ const n = await db.exec('DELETE FROM today_reminders WHERE subject = $1 AND event_id = $2', [subject, id]);
+ return { ok: true, removed: Number(n) > 0 };
+ }
+
+ async function listReminders(subject, { now = s.now() } = {}) {
+ const rows = await db.many(
+ `SELECT r.event_id, r.remind_at, r.sent_at, r.created_at, e.*
+ FROM today_reminders r JOIN today_events e ON e.id = r.event_id
+ WHERE r.subject = $1 AND r.remind_at >= $2::timestamptz AND e.status IN ('scheduled', 'live')
+ ORDER BY r.remind_at ASC, r.event_id ASC`,
+ [subject, new Date(now).toISOString()],
+ );
+ return rows.map((r) => ({
+ event_id: r.event_id,
+ remind_at: iso(r.remind_at),
+ sent_at: iso(r.sent_at),
+ minutes_before: Math.max(0, Math.round((new Date(r.starts_at).getTime() - new Date(r.remind_at).getTime()) / 60_000)),
+ event: wire(r),
+ }));
+ }
+
+ /** Due reminders with their events, soonest first. The job claims each row before sending. */
+ async function dueReminders(now = s.now(), limit = 100) {
+ return await db.many(
+ `SELECT r.subject, r.event_id, r.remind_at, e.*
+ FROM today_reminders r JOIN today_events e ON e.id = r.event_id
+ WHERE r.sent_at IS NULL AND r.remind_at <= $1::timestamptz AND e.status IN ('scheduled', 'live')
+ ORDER BY r.remind_at ASC, r.event_id ASC LIMIT $2`,
+ [new Date(now).toISOString(), Math.min(500, Math.max(1, limit))],
+ );
+ }
+
+ /** Claim one due reminder atomically; null when another run already took it. */
+ async function claimReminder(subject, eventId, now = s.now()) {
+ const row = await db.maybe(
+ `UPDATE today_reminders r SET sent_at = $3::timestamptz
+ WHERE r.subject = $1 AND r.event_id = $2 AND r.sent_at IS NULL
+ AND EXISTS (SELECT 1 FROM today_events e WHERE e.id = r.event_id AND e.status IN ('scheduled', 'live'))
+ RETURNING r.*`,
+ [subject, eventId, new Date(now).toISOString()],
+ );
+ if (!row) return null;
+ return { reminder: row, event: await byId(eventId) };
+ }
+
+ /** Put a claimed reminder back when the notification could not be sent. */
+ async function releaseReminder(subject, eventId) {
+ await db.exec('UPDATE today_reminders SET sent_at = NULL WHERE subject = $1 AND event_id = $2', [subject, eventId]);
+ }
+
+ return {
+ wire, payloadOf, byId, byIdForViewer, listPublic, listChannel,
+ countScheduledFuture, create, patch, cancel,
+ remind, unremind, listReminders, dueReminders, claimReminder, releaseReminder,
+ MAX_FUTURE,
+ };
+}
+
+module.exports = { createEventsStore, wire, payloadOf, StoreRefusal, MAX_FUTURE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,135 @@
@@ -0,0 +1,135 @@
+'use strict';
+
+/** Validation for what a person may schedule. Pure: no clock of its own, no database. */
+
+const KINDS = ['stream', 'event'];
+const VISIBILITIES = ['public', 'followers'];
+const TITLE_MAX = 120;
+const DESCRIPTION_MAX = 1000;
+const EARLIEST_MS = 5 * 60_000;
+const LATEST_MS = 366 * 24 * 60 * 60_000;
+const MAX_LENGTH_MS = 24 * 60 * 60_000;
+
+const ISO_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/;
+const PLAIN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?$/;
+
+function bad(field, message) { return { ok: false, status: 400, code: 'today.invalid', field, message }; }
+
+/** An ISO time (a plain local form is read as UTC, so a form and the API agree). */
+function parseTime(value) {
+ if (typeof value !== 'string' || !value.trim()) return null;
+ const s = value.trim();
+ if (!ISO_RE.test(s) && !PLAIN_RE.test(s)) return null;
+ const d = new Date(PLAIN_RE.test(s) ? `${s}Z` : s);
+ return Number.isNaN(d.getTime()) ? null : d;
+}
+
+function isIanaZone(tz) {
+ if (typeof tz !== 'string' || !tz.trim() || tz.length > 80) return false;
+ try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); return true; } catch { return false; }
+}
+
+function validateTitle(v) {
+ if (typeof v !== 'string') return bad('title', 'title is required and must be 1-120 characters');
+ const s = v.trim();
+ if (s.length < 1 || s.length > TITLE_MAX) return bad('title', 'title must be 1-120 characters');
+ return { ok: true, value: s };
+}
+function validateDescription(v, { optional = false } = {}) {
+ if (v == null || v === '') return { ok: true, value: null };
+ if (typeof v !== 'string') return bad('description', 'description must be text of at most 1000 characters');
+ if (v.length > DESCRIPTION_MAX) return bad('description', 'description must be at most 1000 characters');
+ return { ok: true, value: v };
+}
+function validateVisibility(v) {
+ if (v == null) return { ok: true, value: 'public' };
+ if (typeof v !== 'string' || !VISIBILITIES.includes(v)) return bad('visibility', 'visibility must be public or followers');
+ return { ok: true, value: v };
+}
+function validateStarts(v, now) {
+ const d = parseTime(v);
+ if (!d) return bad('starts_at', 'starts_at must be an ISO time');
+ if (d.getTime() < now - EARLIEST_MS) return bad('starts_at', 'starts_at must not be earlier than 5 minutes ago');
+ if (d.getTime() > now + LATEST_MS) return bad('starts_at', 'starts_at must not be later than 366 days ahead');
+ return { ok: true, value: d };
+}
+function validateEnds(v, start) {
+ if (v == null || v === '') return { ok: true, value: null };
+ const d = parseTime(v);
+ if (!d) return bad('ends_at', 'ends_at must be an ISO time');
+ if (d.getTime() <= start.getTime()) return bad('ends_at', 'ends_at must be after starts_at');
+ if (d.getTime() - start.getTime() > MAX_LENGTH_MS) return bad('ends_at', 'ends_at must be within 24 hours of starts_at');
+ return { ok: true, value: d };
+}
+function validateTz(v) {
+ if (!isIanaZone(v)) return bad('tz', 'tz must be a valid IANA time zone');
+ return { ok: true, value: String(v).trim() };
+}
+
+/** POST body → the fields to write, or a today.invalid refusal. */
+function validateCreate(body, { now = Date.now(), username = null } = {}) {
+ const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
+ if (!KINDS.includes(b.kind)) return bad('kind', 'kind must be stream or event');
+ const t = validateTitle(b.title); if (!t.ok) return t;
+ const d = validateDescription(b.description); if (!d.ok) return d;
+ const s = validateStarts(b.starts_at, now); if (!s.ok) return s;
+ const e = validateEnds(b.ends_at, s.value); if (!e.ok) return e;
+ const z = validateTz(b.tz); if (!z.ok) return z;
+ const v = validateVisibility(b.visibility); if (!v.ok) return v;
+
+ let channel = null;
+ let url = null;
+ if (b.kind === 'stream') {
+ if (typeof username !== 'string' || !/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) {
+ return bad('channel', 'a stream needs the username on your OpenVibe account');
+ }
+ channel = username;
+ url = `https://openvibe.live/@${encodeURIComponent(username)}`;
+ }
+ return {
+ ok: true,
+ fields: {
+ kind: b.kind, channel, url,
+ title: t.value, description: d.value,
+ starts_at: s.value.toISOString(), ends_at: e.value ? e.value.toISOString() : null,
+ tz: z.value, visibility: v.value,
+ },
+ };
+}
+
+function has(obj, key) { return Object.prototype.hasOwnProperty.call(obj, key); }
+
+/** PATCH body → the merged fields to write, or a today.invalid refusal. */
+function validatePatch(body, row, { now = Date.now() } = {}) {
+ const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
+ const fields = ['title', 'description', 'starts_at', 'ends_at', 'tz', 'visibility'];
+ if (!fields.some((f) => has(b, f))) return bad('body', `nothing to update; send one of ${fields.join(', ')}`);
+
+ let title = row.title;
+ if (has(b, 'title')) { const t = validateTitle(b.title); if (!t.ok) return t; title = t.value; }
+ let description = row.description;
+ if (has(b, 'description')) { const d = validateDescription(b.description); if (!d.ok) return d; description = d.value; }
+ let starts = new Date(row.starts_at);
+ if (has(b, 'starts_at')) { const s = validateStarts(b.starts_at, now); if (!s.ok) return s; starts = s.value; }
+ let ends = row.ends_at ? new Date(row.ends_at) : null;
+ if (has(b, 'ends_at')) { const e = validateEnds(b.ends_at, starts); if (!e.ok) return e; ends = e.value; }
+ else if (ends) { const e = validateEnds(ends.toISOString(), starts); if (!e.ok) return e; ends = e.value; }
+ let tz = row.tz;
+ if (has(b, 'tz')) { const z = validateTz(b.tz); if (!z.ok) return z; tz = z.value; }
+ let visibility = row.visibility;
+ if (has(b, 'visibility')) { const v = validateVisibility(b.visibility); if (!v.ok) return v; visibility = v.value; }
+
+ return {
+ ok: true,
+ fields: {
+ title, description,
+ starts_at: starts.toISOString(), ends_at: ends ? ends.toISOString() : null,
+ tz, visibility,
+ },
+ };
+}
+
+module.exports = {
+ KINDS, VISIBILITIES, TITLE_MAX, DESCRIPTION_MAX, EARLIEST_MS, LATEST_MS, MAX_LENGTH_MS,
+ parseTime, isIanaZone, validateCreate, validatePatch, bad,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+
+/** A person's private calendar feed token (32 random bytes, base64url) and its rotation. */
+const crypto = require('crypto');
+
+function newFeedToken() {
+ return crypto.randomBytes(32).toString('base64url');
+}
+
+function createFeeds({ s }) {
+ const db = s.db;
+
+ async function getOrCreate(subject) {
+ const existing = await db.maybe('SELECT token FROM today_feeds WHERE subject = $1', [subject]);
+ if (existing) return existing.token;
+ for (let i = 0; i < 3; i++) {
+ const token = newFeedToken();
+ const row = await db.maybe(
+ `INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, $2, $3::timestamptz)
+ ON CONFLICT (subject) DO NOTHING
+ RETURNING token`,
+ [subject, token, new Date(s.now()).toISOString()],
+ );
+ if (row) return row.token;
+ const again = await db.maybe('SELECT token FROM today_feeds WHERE subject = $1', [subject]);
+ if (again) return again.token;
+ }
+ throw new Error('could not create a feed token');
+ }
+
+ async function rotate(subject) {
+ for (let i = 0; i < 3; i++) {
+ const token = newFeedToken();
+ const row = await db.maybe(
+ `INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, $2, $3::timestamptz)
+ ON CONFLICT (subject) DO UPDATE SET token = EXCLUDED.token, created_at = EXCLUDED.created_at
+ RETURNING token`,
+ [subject, token, new Date(s.now()).toISOString()],
+ );
+ if (row) return row.token;
+ }
+ throw new Error('could not rotate the feed token');
+ }
+
+ async function findByToken(token) {
+ return await db.maybe('SELECT subject, token, created_at FROM today_feeds WHERE token = $1', [token]);
+ }
+
+ function url(config, token) {
+ return `${String(config.baseUrl).replace(/\/+$/, '')}/ics/${token}.ics`;
+ }
+
+ return { getOrCreate, rotate, findByToken, url, newFeedToken };
+}
+
+module.exports = { createFeeds, newFeedToken };
new file mode 100644
new file mode 100644
@@ -0,0 +1,172 @@
@@ -0,0 +1,172 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Today's JSON API.
+ *
+ * GET /events?channel=&from=&to=&limit= public events, soonest first, cached 60 s
+ * GET /events/:id one event (owner sees a followers-only one; cancelled answers)
+ * POST /events schedule a stream or an event (signed in, Idempotency-Key honoured)
+ * PATCH /events/:id change one (owner; seq + 1)
+ * DELETE /events/:id cancel one (owner; idempotent)
+ * POST /events/:id/remind set a reminder (signed in)
+ * DELETE /events/:id/remind remove it
+ * GET /me/reminders the caller's upcoming reminders
+ * GET /me/agenda?days=14 own + reminded + followed channels, or follows_unavailable
+ * GET /me/feed the private ICS URL, created on first call
+ * POST /me/feed/rotate replace it (the old URL is 404 at once)
+ * GET /me/brief today's brief, built on demand when missing
+ *
+ * A person is the session cookie (today_at) or Authorization: Bearer <Network user token>, verified offline with
+ * the Network JWKS. A write made with the session cookie must come from openvibe.today itself (403
+ * today.cross_site). Errors are JSON: { code, message } (a validation refusal adds { field }).
+ */
+const express = require('express');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { parseTime, isIanaZone } = require('../events/validate');
+
+function createApi(ctx) {
+ const { config, s, principal, events, feeds, follows, agenda, brief } = ctx;
+ const r = asyncRouter();
+
+ const invalid = (res, field, message) => res.status(400).json({ code: 'today.invalid', field, message });
+ const refuse = (res, status, code, message) => res.status(status).json({ code, message });
+
+ r.use(express.json({ limit: '64kb' }));
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ function person(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') { refuse(res, 401, 'today.sign_in_required', 'Sign in at openvibe.today, or send a person\'s Network token as a Bearer.'); return null; }
+ if (p.kind !== 'user') { refuse(res, 403, 'today.person_required', 'A schedule belongs to a person; an app, agent or service token cannot change one.'); return null; }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ refuse(res, 403, 'today.cross_site', 'A signed-in request that changes something must come from openvibe.today itself.');
+ return null;
+ }
+ return p;
+ }
+ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
+
+ function jsonError(res, out) {
+ return res.status(out.status || 400).json({ code: out.code || 'today.invalid', ...(out.field ? { field: out.field } : {}), message: out.message || 'invalid request' });
+ }
+
+ /** A query bound: an ISO time, or a date (read as UTC midnight) for convenience in a URL. */
+ function queryTime(v) {
+ const d = parseTime(v);
+ if (d) return d;
+ if (typeof v === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(v)) {
+ const x = new Date(`${v}T00:00:00Z`);
+ return Number.isNaN(x.getTime()) ? null : x;
+ }
+ return null;
+ }
+
+ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // ── Public reads ────────────────────────────────────────
+ r.get('/events', async (req, res) => {
+ const now = s.now();
+ let from = new Date(now - 2 * 60 * 60_000);
+ if (req.query.from != null && req.query.from !== '') {
+ const d = queryTime(req.query.from);
+ if (!d) return invalid(res, 'from', 'from must be an ISO time');
+ from = d;
+ }
+ let to = new Date(now + 60 * 24 * 60 * 60_000);
+ if (req.query.to != null && req.query.to !== '') {
+ const d = queryTime(req.query.to);
+ if (!d) return invalid(res, 'to', 'to must be an ISO time');
+ to = d;
+ }
+ if (from.getTime() > to.getTime()) return invalid(res, 'from', 'from must not be later than to');
+ const channel = typeof req.query.channel === 'string' && req.query.channel.trim() ? req.query.channel.trim().replace(/^@/, '').slice(0, 64) : null;
+ const limit = req.query.limit == null || req.query.limit === '' ? 50 : Number.parseInt(req.query.limit, 10);
+ if (!Number.isFinite(limit) || limit < 1) return invalid(res, 'limit', 'limit must be from 1 to 100');
+ const rows = await events.listPublic({ channel, from: from.toISOString(), to: to.toISOString(), limit });
+ res.set('Cache-Control', 'public, max-age=60');
+ return res.json({ from: from.toISOString(), to: to.toISOString(), count: rows.length, events: rows });
+ });
+
+ r.get('/events/:id', async (req, res) => {
+ const subject = req.principal.kind === 'user' ? req.principal.subject : null;
+ const row = await events.byIdForViewer(String(req.params.id || ''), subject);
+ if (!row) return refuse(res, 404, 'today.not_found', 'No such event.');
+ if (row.visibility === 'public') res.set('Cache-Control', 'public, max-age=60');
+ return res.json(events.wire(row));
+ });
+
+ // ── Writes ──────────────────────────────────────────────
+ r.post('/events', requirePerson, async (req, res) => {
+ const p = req.principal;
+ const key = req.get('Idempotency-Key');
+ if (key != null && key.length > 100) return invalid(res, 'idempotency_key', 'Idempotency-Key must be at most 100 characters');
+ const out = await events.create({ subject: p.subject, username: p.username, body: req.body || {}, idempotencyKey: key || null, now: s.now() });
+ if (!out.ok) return jsonError(res, out);
+ res.set('Location', `/api/v1/events/${out.event.id}`);
+ return res.status(out.replayed ? 200 : 201).json(out.event);
+ });
+
+ r.patch('/events/:id', requirePerson, async (req, res) => {
+ const out = await events.patch({ id: String(req.params.id || ''), subject: req.principal.subject, body: req.body || {}, now: s.now() });
+ if (!out.ok) return jsonError(res, out);
+ return res.json(out.event);
+ });
+
+ r.delete('/events/:id', requirePerson, async (req, res) => {
+ const out = await events.cancel({ id: String(req.params.id || ''), subject: req.principal.subject });
+ if (!out.ok) return jsonError(res, out);
+ return res.json(out.event);
+ });
+
+ // ── Reminders ───────────────────────────────────────────
+ r.post('/events/:id/remind', requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const out = await events.remind({ subject: req.principal.subject, id: String(req.params.id || ''), minutesBefore: b.minutes_before, now: s.now() });
+ if (!out.ok) return jsonError(res, out);
+ return res.json({ event_id: out.reminder.event_id, remind_at: out.reminder.remind_at, minutes_before: out.reminder.minutes_before });
+ });
+
+ r.delete('/events/:id/remind', requirePerson, async (req, res) => {
+ const out = await events.unremind({ subject: req.principal.subject, id: String(req.params.id || '') });
+ return res.json(out);
+ });
+
+ r.get('/me/reminders', requirePerson, async (req, res) => {
+ res.json({ reminders: await events.listReminders(req.principal.subject) });
+ });
+
+ r.get('/me/agenda', requirePerson, async (req, res) => {
+ const raw = req.query.days == null || req.query.days === '' ? 14 : Number.parseInt(req.query.days, 10);
+ if (!Number.isFinite(raw) || raw < 1 || raw > 60) return invalid(res, 'days', 'days must be a whole number from 1 to 60');
+ const out = await agenda.forSubject({ subject: req.principal.subject, token: req.principal.token, days: raw, now: s.now() });
+ return res.json(out);
+ });
+
+ r.get('/me/feed', requirePerson, async (req, res) => {
+ // Warm the follow cache with the person's own token so their token feed can include followed channels
+ // while this process still knows them; a failure is the agenda's follows_unavailable, not this route's.
+ if (req.principal.token) await follows.forSubject(req.principal.subject, req.principal.token).catch(() => {});
+ const token = await feeds.getOrCreate(req.principal.subject);
+ res.set('Cache-Control', 'no-store');
+ return res.json({ url: feeds.url(config, token) });
+ });
+
+ r.post('/me/feed/rotate', requirePerson, async (req, res) => {
+ if (req.principal.token) await follows.forSubject(req.principal.subject, req.principal.token).catch(() => {});
+ const token = await feeds.rotate(req.principal.subject);
+ return res.json({ url: feeds.url(config, token) });
+ });
+
+ r.get('/me/brief', requirePerson, async (req, res) => {
+ const tz = typeof req.query.tz === 'string' && req.query.tz ? req.query.tz : 'UTC';
+ if (!isIanaZone(tz)) return invalid(res, 'tz', 'tz must be a valid IANA time zone');
+ const out = await brief.getOrBuild(req.principal.subject, { now: s.now(), tz, token: req.principal.token });
+ return res.json(out);
+ });
+
+ return r;
+}
+
+module.exports = { createApi };
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+
+/**
+ * Crawl artifacts for openvibe.today: robots.txt, sitemap.xml, llms.txt and llms-full.txt. The public pages are for
+ * search engines and AI crawlers; sign-in, the API, a person's agenda, brief and schedule form are not.
+ */
+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.Today';
+const DESCRIPTION = 'OpenVibe.Today is the network\'s time bus and calendar authority: one schedule for streams and events, reminders before they start, a private ICS feed, and a daily brief.';
+const DISALLOW = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];
+
+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: 'ProductivityApplication', keywords: 'openvibe schedule, stream calendar, ics, reminders, events' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function publicPages() {
+ return [
+ { path: '/', changefreq: 'daily', priority: 1.0, title: 'OpenVibe.Today home', text: 'One schedule for every stream and event on OpenVibe, with reminders and a private calendar feed.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'OpenVibe.Today API', text: 'The JSON API, the ICS feeds and the Events topics Today publishes and consumes.' },
+ ];
+}
+
+function createDiscoveryRoutes(ctx) {
+ const { config } = 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.today: the public pages are for search and AI crawlers; sign-in, the API, a person\'s agenda and brief 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.Today is the network\'s time bus and calendar authority: a public schedule of streams and events, reminders sent through OpenVibe.Network, a private ICS feed per person, and a daily brief that includes what OpenVibe.Live\'s Rundown talked about yesterday.',
+ details: 'GET /api/v1/events lists public events (soonest first). GET /api/v1/events/:id is one event with JSON-LD on its page. A signed-in person schedules a stream or an event, changes it, cancels it and sets a reminder. GET /ics/@<username>.ics is a channel\'s public calendar; a person\'s private feed is at GET /ics/<token>.ics. Events are published as today.event.scheduled|updated|cancelled and live.stream.started|ended is consumed to move an event live and then done.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Today', url: abs('/'), note: 'the public schedule' },
+ { title: 'The API', url: abs('/developers') },
+ { title: 'Public events (JSON)', url: abs('/api/v1/events') },
+ ] },
+ { title: 'Machine-readable', links: [
+ { 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.Network', url: 'https://openvibe.network', note: 'accounts, follows and notifications' },
+ { title: 'OpenVibe.Live', url: 'https://openvibe.live', note: 'where a stream happens' },
+ { title: 'OpenVibe.Events', url: 'https://openvibe.events', note: 'the durable event backbone' },
+ { title: 'OpenVibe.Services', url: 'https://openvibe.services', note: 'apps, keys and capability grants' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ const entries = pages.map((p) => ({ url: p.path, title: p.title, text: p.text }));
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Today: the schedule and the API.',
+ base: site,
+ maxBytes: 256 * 1024,
+ sections: [{ title: 'Pages', pages: entries }],
+ }));
+ });
+
+ r.get('/sitemap.xml', (_req, res) => {
+ const lastmod = siteUpdated();
+ const urls = pages.map((e) => ({ loc: abs(e.path), ...(lastmod ? { lastmod } : {}), changefreq: e.changefreq, priority: e.priority }));
+ 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,258 @@
@@ -0,0 +1,258 @@
+'use strict';
+
+/**
+ * OpenVibe.Today'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`.
+ *
+ * / the public schedule
+ * /@:username a channel's schedule, with its ICS link
+ * /e/:id one event, with schema.org Event JSON-LD
+ * /me agenda, reminders and the private feed URL
+ * /brief today's brief
+ * /schedule schedule a stream (the form POSTs and redirects to /e/:id)
+ * /developers the API
+ * /ics/:file a private token feed or a channel's public calendar
+ */
+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, badge, time } = require('../render/html');
+const { send, SITE_NAME } = require('../render/layout');
+const { calendar } = require('../ics');
+const views = require('../views');
+
+const PUBLIC_CACHE = 'public, max-age=60';
+const DAY = 24 * 60 * 60_000;
+
+function createPageRoutes(ctx) {
+ const { config, s, events, feeds, agenda, brief } = 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 person = (req) => ({ subject: req.viewer.subject, token: req.viewer.token, username: req.viewer.username });
+ const signInPrompt = (what, next) => html`<div class="notice">${what} <a href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a>.</div>`;
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', async (req, res) => {
+ const now = s.now();
+ const rows = await events.listPublic({ from: new Date(now - 2 * 60 * 60_000).toISOString(), to: new Date(now + 60 * DAY).toISOString(), limit: 20 });
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: null,
+ body: raw(views.home({ upcoming: rows, now })),
+ description: 'Streams, events and reminders across OpenVibe on one calendar: schedule a stream, get reminded, and subscribe in your own calendar app.',
+ jsonLd: homeJsonLd(config),
+ });
+ });
+
+ // ── A channel's schedule ────────────────────────────────
+ r.get('/@:username', async (req, res) => {
+ const username = String(req.params.username || '');
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) return notFound(req, res, 'No such channel');
+ const now = s.now();
+ const rows = await events.listChannel(username, { from: new Date(now - 7 * DAY).toISOString(), to: new Date(now + 60 * DAY).toISOString() });
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: `@${username}'s schedule`,
+ description: `OpenVibe.Today's public schedule for @${username}, with an ICS calendar feed.`,
+ body: raw(views.channel({ username, rows, now })),
+ });
+ });
+
+ // ── One event's calendar file ("Add to calendar") ──────
+ r.get('/e/:id.ics', async (req, res) => {
+ const subject = signedIn(req) ? req.viewer.subject : null;
+ const row = await events.byIdForViewer(String(req.params.id || ''), subject);
+ if (!row) return res.status(404).type('text/plain').send('Not found');
+ res.set('Content-Type', 'text/calendar; charset=utf-8');
+ res.set('Content-Disposition', `attachment; filename="${row.id}.ics"`);
+ res.set('Cache-Control', row.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
+ return res.send(calendar({ name: row.title, events: [events.wire(row)], now: s.now(), baseUrl: base() }));
+ });
+
+ // ── Remind, remove, cancel (plain forms; the API does the same) ──
+ const formGuard = (req, res, next) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/e/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: raw(views.notFound()) }, 403);
+ return next();
+ };
+ r.post('/e/:id/remind', formGuard, async (req, res) => {
+ await events.remind({ subject: req.viewer.subject, id: String(req.params.id), minutesBefore: (req.body || {}).minutes_before, now: s.now() });
+ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);
+ });
+ r.post('/e/:id/remind/remove', formGuard, async (req, res) => {
+ await events.unremind({ subject: req.viewer.subject, id: String(req.params.id) });
+ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);
+ });
+ r.post('/e/:id/cancel', formGuard, async (req, res) => {
+ await events.cancel({ id: String(req.params.id), subject: req.viewer.subject });
+ return res.redirect(303, `/e/${encodeURIComponent(req.params.id)}`);
+ });
+
+ // ── One event ───────────────────────────────────────────
+ r.get('/e/:id', async (req, res) => {
+ const subject = signedIn(req) ? req.viewer.subject : null;
+ const row = await events.byIdForViewer(String(req.params.id || ''), subject);
+ if (!row) return notFound(req, res, 'No such event');
+ const e = events.wire(row);
+ const own = !!subject && row.owner_subject === subject;
+ const reminder = subject ? ((await events.listReminders(subject)).find((x) => x.event_id === e.id) || null) : null;
+ if (reminder && reminder.minutes_before == null) reminder.minutes_before = Math.round((new Date(e.starts_at).getTime() - new Date(reminder.remind_at).getTime()) / 60000);
+ const end = e.ends_at || new Date(new Date(e.starts_at).getTime() + 60 * 60_000).toISOString();
+ const organizer = e.channel
+ ? { '@type': 'Person', name: `@${e.channel}`, url: `https://openvibe.live/@${encodeURIComponent(e.channel)}` }
+ : { '@type': 'Organization', name: SITE_NAME, url: base() };
+ const jsonLd = {
+ '@context': 'https://schema.org',
+ '@type': 'Event',
+ name: e.title,
+ startDate: e.starts_at,
+ endDate: end,
+ eventStatus: e.status === 'cancelled' ? 'https://schema.org/EventCancelled' : 'https://schema.org/EventScheduled',
+ eventAttendanceMode: 'https://schema.org/OnlineEventAttendanceMode',
+ location: { '@type': 'VirtualLocation', url: e.url || `${base()}/e/${e.id}` },
+ organizer,
+ ...(e.description ? { description: e.description } : {}),
+ url: `${base()}/e/${e.id}`,
+ };
+ return page(req, res, {
+ index: e.visibility === 'public' && e.status !== 'cancelled',
+ cache: e.visibility === 'public' ? PUBLIC_CACHE : null,
+ title: e.title,
+ description: (e.description || `${e.title} on OpenVibe.Today`).slice(0, 160),
+ jsonLd: [jsonLd],
+ body: raw(views.event({ e, end, reminder, own, signedIn: !!subject, path: req.originalUrl })),
+ });
+ });
+
+ // ── Your agenda ─────────────────────────────────────────
+ r.get('/me', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Your agenda', body: raw(views.signIn({ title: 'Your agenda', text: 'Your events, your reminders and the channels you follow, in one place and in your own calendar app.', next: '/me' })) }, 401);
+ const p = person(req);
+ const a = await agenda.forSubject({ subject: p.subject, token: p.token, days: 14, now: s.now() });
+ const reminders = await events.listReminders(p.subject);
+ const token = await feeds.getOrCreate(p.subject);
+ return page(req, res, {
+ title: 'Your agenda',
+ description: 'Your own events, the reminders you set, the channels you follow and your private calendar feed.',
+ body: raw(views.me({ name: req.viewer.displayName || req.viewer.username, agenda: a, reminders, feedUrl: feeds.url(config, token), now: s.now() })),
+ });
+ });
+
+ r.post('/me/feed/rotate', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('A feed belongs to an account.', '/me')}` }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Replace a feed from <a href="/me">your agenda</a>.</p>` }, 403);
+ await feeds.rotate(req.viewer.subject);
+ return res.redirect(303, '/me');
+ });
+
+ // ── The brief ───────────────────────────────────────────
+ r.get('/brief', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Your brief', body: raw(views.signIn({ title: 'Your brief', text: 'One short page for your day: what is coming up, your reminders and what streamers talked about yesterday.', next: '/brief' })) }, 401);
+ const tz = typeof req.query.tz === 'string' && req.query.tz ? req.query.tz : 'UTC';
+ const out = await brief.getOrBuild(req.viewer.subject, { now: s.now(), tz, token: req.viewer.token });
+ return page(req, res, {
+ title: `Your brief for ${out.day}`,
+ description: 'What is left of today, your reminders and what OpenVibe.Live talked about yesterday.',
+ body: raw(views.brief({ out, name: req.viewer.displayName || req.viewer.username, tz })),
+ });
+ });
+
+ // ── Schedule a stream ───────────────────────────────────
+ /** "2026-10-30T20:00" read as wall time in tz → ISO UTC (null when it is not that shape). */
+ function wallToUtc(local, tz) {
+ const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})$/.exec(String(local || ''));
+ if (!m) return null;
+ const asUtc = Date.UTC(+m[1], +m[2] - 1, +m[3], +m[4], +m[5]);
+ const offset = (t) => {
+ const p = Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: tz, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' })
+ .formatToParts(new Date(t)).map((x) => [x.type, x.value]));
+ return Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour, +p.minute) - t;
+ };
+ try { let t = asUtc - offset(asUtc); t = asUtc - offset(t); return new Date(t).toISOString(); } catch { return null; }
+ }
+ const scheduleForm = (values = {}, problem = null) => raw(views.schedule({ values, problem }));
+
+ r.get('/schedule', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Schedule a stream', body: raw(views.signIn({ title: 'Schedule a stream', text: 'Scheduling belongs to your OpenVibe account, so it shows on your channel and you can change or cancel it later.', next: '/schedule' })) }, 401);
+ return page(req, res, {
+ title: 'Schedule a stream',
+ description: 'Put a stream or an event on your OpenVibe.Today schedule.',
+ body: scheduleForm({ tz: typeof req.query.tz === 'string' ? req.query.tz : 'UTC' }),
+ });
+ });
+
+ r.post('/schedule', async (req, res) => {
+ if (!signedIn(req)) return page(req, res, { title: 'Sign in', body: html`<h1>Sign in first</h1>${signInPrompt('Scheduling belongs to an account.', '/schedule')}` }, 401);
+ if (!sameOrigin(req, config.baseUrl)) return page(req, res, { title: 'Not from this site', body: html`<h1>That came from somewhere else</h1><p class="muted">Schedule from <a href="/schedule">the form</a>.</p>` }, 403);
+ const b = req.body || {};
+ const tz = b.tz || 'UTC';
+ // The form's datetime-local is wall time in the chosen zone; the API takes an instant.
+ const startsAt = wallToUtc(b.starts_at, tz) || b.starts_at;
+ const length = Math.min(24 * 60, Math.max(0, parseInt(b.length, 10) || 0));
+ const body = { kind: b.kind || 'stream', title: b.title, description: b.description, visibility: b.visibility, tz, starts_at: startsAt,
+ ...(length && startsAt && !Number.isNaN(Date.parse(startsAt)) ? { ends_at: new Date(Date.parse(startsAt) + length * 60_000).toISOString() } : {}) };
+ const out = await events.create({ subject: req.viewer.subject, username: req.viewer.username, body, now: s.now() });
+ if (!out.ok) {
+ return page(req, res, {
+ title: 'Schedule a stream',
+ body: scheduleForm({ ...b, tz }, out.message || 'That could not be scheduled.'),
+ }, out.status || 400);
+ }
+ return res.redirect(303, `/e/${out.event.id}`);
+ });
+
+ // ── Developers ──────────────────────────────────────────
+ r.get('/developers', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE,
+ title: 'The OpenVibe.Today API',
+ description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Today API', url: `${base()}/developers`, description: 'The JSON API, the ICS feeds and the Events topics OpenVibe.Today publishes and consumes.', siteUrl: base() })],
+ body: raw(views.developers()),
+ }));
+
+ // ── ICS feeds ───────────────────────────────────────────
+ r.get('/ics/:file', async (req, res) => {
+ const file = String(req.params.file || '');
+ if (!file.endsWith('.ics')) return res.status(404).type('text/plain').send('Not found');
+ const key = file.slice(0, -4);
+ const now = s.now();
+ const fromMs = now - 7 * DAY;
+ const toMs = now + 60 * DAY;
+ const nowDate = new Date(now).toISOString();
+ if (key.startsWith('@')) {
+ const username = key.slice(1);
+ if (!/^[A-Za-z0-9_][A-Za-z0-9_.-]{0,63}$/.test(username)) return res.status(404).type('text/plain').send('Not found');
+ const rows = (await events.listChannel(username, { from: new Date(fromMs).toISOString(), to: new Date(toMs).toISOString(), includeCancelled: true }))
+ .filter((e) => e.status !== 'cancelled' || new Date(e.updated_at).getTime() >= fromMs);
+ res.set('Content-Type', 'text/calendar; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.send(calendar({ name: `${SITE_NAME} — @${username}`, events: rows, now, baseUrl: base() }));
+ }
+ const feed = await feeds.findByToken(key);
+ if (!feed) return res.status(404).type('text/plain').send('Not found');
+ const rows = await agenda.windowFor({ subject: feed.subject, token: null, fromMs, toMs, now });
+ res.set('Content-Type', 'text/calendar; charset=utf-8');
+ res.set('Cache-Control', 'private, max-age=300');
+ return res.send(calendar({ name: `${SITE_NAME} — your agenda`, events: rows.map((x) => events.wire(x)), now, baseUrl: base() }));
+ });
+
+ // ── Discovery ───────────────────────────────────────────
+ r.use(createDiscoveryRoutes(ctx));
+
+ function notFound(req, res, title) {
+ return page(req, res, {
+ title,
+ body: raw(views.notFound()),
+ }, 404);
+ }
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'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 (today_at).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience openvibe.today. No Today 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 schedule, change or cancel an event here.
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// Today's routes are all a person acting for themself, or a public read. No capability is declared.
+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.Today'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.today, 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, 'today.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.today itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'today.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,83 @@
@@ -0,0 +1,83 @@
+'use strict';
+
+/**
+ * RFC 5545 calendars. Correct line endings (CRLF), TEXT escaping and folding at 75 octets without splitting a
+ * UTF-8 sequence; every VEVENT carries a stable UID, SEQUENCE, STATUS and LAST-MODIFIED, and times are UTC.
+ */
+const { wire } = require('./events/store');
+
+const CRLF = '\r\n';
+
+function escapeText(value) {
+ return String(value == null ? '' : value)
+ .replace(/\\/g, '\\\\')
+ .replace(/;/g, '\\;')
+ .replace(/,/g, '\\,')
+ .replace(/\r\n|\r|\n/g, '\\n');
+}
+
+/** Fold one content line at 75 octets per line, never splitting a UTF-8 code point. */
+function foldLine(line, limit = 75) {
+ const s = String(line);
+ if (Buffer.byteLength(s, 'utf8') <= limit) return s;
+ const out = [];
+ let current = '';
+ let bytes = 0;
+ for (const ch of s) {
+ const n = Buffer.byteLength(ch, 'utf8');
+ if (bytes + n > limit) {
+ out.push(current);
+ current = ` ${ch}`;
+ bytes = 1 + n;
+ } else {
+ current += ch;
+ bytes += n;
+ }
+ }
+ out.push(current);
+ return out.join(CRLF);
+}
+
+function stamp(value) {
+ const d = value instanceof Date ? value : new Date(value);
+ const p = (n, w = 2) => String(n).padStart(w, '0');
+ return `${p(d.getUTCFullYear(), 4)}${p(d.getUTCMonth() + 1)}${p(d.getUTCDate())}T${p(d.getUTCHours())}${p(d.getUTCMinutes())}${p(d.getUTCSeconds())}Z`;
+}
+
+function vevent(row, { now = Date.now(), baseUrl = 'https://openvibe.today' } = {}) {
+ const e = row.id ? row : wire(row);
+ const starts = new Date(e.starts_at);
+ const ends = e.ends_at ? new Date(e.ends_at) : new Date(starts.getTime() + 60 * 60_000);
+ const status = e.status === 'cancelled' ? 'CANCELLED' : 'CONFIRMED';
+ const url = e.url || `${String(baseUrl).replace(/\/+$/, '')}/e/${e.id}`;
+ const lines = [
+ 'BEGIN:VEVENT',
+ `UID:${e.id}@openvibe.today`,
+ `DTSTAMP:${stamp(now)}`,
+ `DTSTART:${stamp(starts)}`,
+ `DTEND:${stamp(ends)}`,
+ `SUMMARY:${escapeText(e.title)}`,
+ `DESCRIPTION:${escapeText(e.description || '')}`,
+ `URL:${url}`,
+ `SEQUENCE:${Number(e.seq) || 0}`,
+ `STATUS:${status}`,
+ `LAST-MODIFIED:${stamp(e.updated_at || now)}`,
+ 'END:VEVENT',
+ ];
+ return lines.map((l) => foldLine(l)).join(CRLF);
+}
+
+function calendar({ name, events, now = Date.now(), baseUrl = 'https://openvibe.today' }) {
+ const lines = [
+ 'BEGIN:VCALENDAR',
+ 'VERSION:2.0',
+ 'PRODID:-//OpenVibe//Today//EN',
+ 'CALSCALE:GREGORIAN',
+ `X-WR-CALNAME:${escapeText(name)}`,
+ ...events.map((e) => vevent(e, { now, baseUrl })),
+ 'END:VCALENDAR',
+ ];
+ return `${lines.join(CRLF)}${CRLF}`;
+}
+
+module.exports = { calendar, vevent, escapeText, foldLine, stamp, CRLF };
new file mode 100644
new file mode 100644
@@ -0,0 +1,41 @@
@@ -0,0 +1,41 @@
+'use strict';
+
+/**
+ * Account export and deletion → Today (ADR-033; openvibe-sdk/account-data). The rows a person owns are their
+ * events, the reminders they set, their feed token's row, their briefs and their idempotency keys. Every subject
+ * column stores the bare usr_ id.
+ *
+ * A feed token is a bearer capability for one calendar, so it is NOT exported: today_feeds exports only its
+ * subject and created_at. On deletion the row goes, so the old URL is 404 at once.
+ *
+ * The order matters for the orphan cleanup in extraErase: the person's events are erased with their subject rows,
+ * then reminders other people set on those events and still-pending outbox rows for those events go too.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'today_events', subject: 'owner_subject', value: (usr) => usr, file: 'events.json', erase: 'delete' },
+ { table: 'today_reminders', subject: 'subject', value: (usr) => usr, file: 'reminders.json', erase: 'delete' },
+ // The token itself is never exported; it is a bearer capability for the person's calendar.
+ { table: 'today_feeds', subject: 'subject', value: (usr) => usr, file: 'feeds.json', columns: ['subject', 'created_at'], erase: 'delete' },
+ { table: 'today_briefs', subject: 'subject', value: (usr) => usr, file: 'briefs.json', erase: 'delete' },
+ { table: 'today_idempotency', subject: 'subject', value: (usr) => usr, file: 'idempotency.json', erase: 'delete' },
+];
+
+async function extraErase(t) {
+ await t.exec('DELETE FROM today_reminders WHERE event_id NOT IN (SELECT id FROM today_events)');
+ await t.exec("DELETE FROM today_outbox WHERE payload->>'event_id' NOT IN (SELECT id FROM today_events)");
+}
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'today',
+ tables: TABLES,
+ note: 'A feed token is a bearer capability for one calendar and is not exported.',
+ extraErase,
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS, extraErase };
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -0,0 +1,56 @@
+'use strict';
+
+/**
+ * The follow graph from OpenVibe.Network, read with the person's own token (GET /api/v1/me/follows), cached 60 s
+ * per subject. A followed channel's target_id is its owner's usr_ subject, which is what today_events.owner_subject
+ * stores. A failure returns no follows and `unavailable: true`: the agenda still answers with the person's own and
+ * reminded events.
+ */
+function createFollows({ config, fetchImpl = globalThis.fetch, now = () => Date.now(), log = console }) {
+ const cache = new Map(); // subject -> { at, expires, follows, unavailable }
+ const lastGood = new Map(); // subject -> the last successful follows, for a token feed with no Network token
+ const ttl = config.follows.cacheMs;
+
+ async function fetchFollows(token) {
+ const res = await fetchImpl(`${config.networkUrl}/api/v1/me/follows?type=channel&limit=200`, {
+ headers: { Accept: 'application/json', Authorization: `Bearer ${token}` },
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!res.ok) throw new Error(`follows ${res.status}`);
+ const body = await res.json().catch(() => null);
+ if (!body || !Array.isArray(body.items)) throw new Error('follows: no items');
+ const targets = [];
+ for (const item of body.items) {
+ if (item && item.target_type === 'channel' && typeof item.target_id === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(item.target_id)) targets.push(item.target_id);
+ }
+ return [...new Set(targets)];
+ }
+
+ async function forSubject(subject, token) {
+ const hit = cache.get(subject);
+ if (hit && ttl > 0 && hit.expires > now()) return { follows: hit.follows, unavailable: hit.unavailable };
+ try {
+ const follows = await fetchFollows(token);
+ cache.set(subject, { at: now(), expires: now() + ttl, follows, unavailable: false });
+ lastGood.set(subject, follows);
+ return { follows, unavailable: false };
+ } catch (err) {
+ log.warn(`[OpenVibe.Today] follows unavailable for ${subject}: ${(err && err.message) || err}`);
+ cache.set(subject, { at: now(), expires: now() + ttl, follows: [], unavailable: true });
+ return { follows: [], unavailable: true };
+ }
+ }
+
+ /** The last successful follows for a subject, even after the 60 s cache: a token feed has no person token. */
+ function peek(subject) {
+ return [...(lastGood.get(subject) || [])];
+ }
+
+ function clear(subject = null) {
+ if (subject) { cache.delete(subject); lastGood.delete(subject); } else { cache.clear(); lastGood.clear(); }
+ }
+
+ return { forSubject, peek, clear, cache, lastGood };
+}
+
+module.exports = { createFollows };
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
+'use strict';
+
+/**
+ * OpenVibe.Today — process entry. `node server/index.js`
+ * Listens on PORT (5100) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+
+/**
+ * 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.Today', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.keys.client.stop(),
+ () => ctx.publisher.stop(),
+ () => ctx.reminders.stop(),
+ () => ctx.brief.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.Today] ${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 TODAY_DRILL (or when it is not configured); tests call their runOnce().
+ ctx.publisher.start();
+ ctx.reminders.start();
+ ctx.brief.start();
+
+ // Subscribe to the two live topics and the two account topics at OpenVibe.Events (idempotent; off without the
+ // Events URL and secret). The consumer itself is mounted in server/app.js.
+ const subscriptions = config.drill ? null : ctx.consumer.startSubscriptions({ config, port: config.port, secret: config.events.secrets[0] || '' });
+ const extra = [() => { if (subscriptions) subscriptions.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Today] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,95 @@
@@ -0,0 +1,95 @@
+'use strict';
+
+/**
+ * Network notifications for a due reminder. A reminder row stores the person's usr_ subject; Network's push route
+ * wants its numeric user id, resolved in batches through POST /internal/identity/resolve-batch and cached. Both calls
+ * use this service's own client-credentials token for audience openvibe.network (identity.subject.resolve and
+ * network.notifications.push).
+ */
+const { serviceAuth } = require('openvibe-contracts');
+
+function createNotifier({ config, s, fetchImpl = globalThis.fetch, log = console }) {
+ const cache = new Map(); // subject -> { at, expires, userId }
+ const ttl = config.notifications.resolveCacheMs;
+ const canSend = Boolean(config.oauth.clientSecret);
+ const identityTokens = canSend ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetchImpl,
+ }) : null;
+ const pushTokens = canSend ? serviceAuth.createTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
+ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'network.notifications.push', fetchImpl,
+ }) : null;
+
+ async function resolveUserIds(subjects) {
+ const out = new Map();
+ const missing = [];
+ for (const subject of subjects) {
+ const hit = cache.get(subject);
+ if (hit && (ttl === 0 || hit.expires > s.now())) { out.set(subject, hit.userId); } else missing.push(subject);
+ }
+ const unique = [...new Set(missing)].filter((x) => typeof x === 'string' && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(x));
+ for (let i = 0; i < unique.length; i += 500) {
+ const batch = unique.slice(i, i + 500);
+ const res = await fetchImpl(`${config.networkInternalUrl}/internal/identity/resolve-batch`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await identityTokens.authHeaders()) },
+ body: JSON.stringify({ subject_ids: batch }),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) throw new Error(`identity resolve-batch ${res.status}`);
+ const body = await res.json().catch(() => null);
+ const results = (body && body.results) || {};
+ for (const subject of batch) {
+ const user = results[subject];
+ const userId = user && Number.isFinite(Number(user.network_user_id)) ? Number(user.network_user_id) : null;
+ cache.set(subject, { at: s.now(), expires: s.now() + ttl, userId });
+ out.set(subject, userId);
+ }
+ }
+ return out;
+ }
+
+ function titleFor(event, now) {
+ if (event.status === 'live') return `${event.title} is live now`;
+ const minutes = Math.max(0, Math.round((new Date(event.starts_at).getTime() - now) / 60_000));
+ return `${event.title} starts in ${minutes} min`;
+ }
+
+ async function sendReminder({ subject, event, now = s.now() }) {
+ if (!canSend) throw new Error('TODAY_OAUTH_CLIENT_SECRET is not set: Today cannot send a notification');
+ const ids = await resolveUserIds([subject]);
+ const userId = ids.get(subject);
+ if (!userId) {
+ log.warn(`[OpenVibe.Today] reminder for ${subject}: Network has no user id for that subject`);
+ return { ok: false, reason: 'no_user' };
+ }
+ const title = titleFor(event, now);
+ const body = {
+ user_id: userId,
+ type: 'TODAY_REMINDER',
+ category: event.kind === 'stream' ? 'stream' : 'system',
+ priority: 'normal',
+ icon: '⏰',
+ title,
+ service: 'today',
+ url: `${String(config.baseUrl).replace(/\/+$/, '')}/e/${event.id}`,
+ };
+ const res = await fetchImpl(`${config.networkInternalUrl}/internal/notifications/push`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await pushTokens.authHeaders()) },
+ body: JSON.stringify(body),
+ signal: AbortSignal.timeout(10_000),
+ });
+ if (!res.ok) throw new Error(`notification push ${res.status}`);
+ return { ok: true, user_id: userId, title };
+ }
+
+ function clear(subject = null) { if (subject) cache.delete(subject); else cache.clear(); }
+
+ return { sendReminder, resolveUserIds, clear, titleFor, canSend };
+}
+
+module.exports = { createNotifier };
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 (server/index.js), 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: 'today',
+ 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,50 @@
@@ -0,0 +1,50 @@
+'use strict';
+
+/**
+ * Due reminders: every 60 s, one Network notification each. The row is claimed with an atomic
+ * `UPDATE … SET sent_at = now() WHERE … AND sent_at IS NULL RETURNING`, so two processes never send twice. A send
+ * failure puts the claim back for the next tick; a cancelled or done event's reminder never sends.
+ */
+function createReminders({ config, s, events, notifier, log = console, intervalMs = 60_000 }) {
+ let timer = null;
+ const stats = { sent: 0, failed: 0, skipped: 0, last_error: null, last_run: null };
+
+ async function runOnce({ now = s.now() } = {}) {
+ const due = await events.dueReminders(now);
+ let sent = 0;
+ let failed = 0;
+ let skipped = 0;
+ for (const row of due) {
+ const claimed = await events.claimReminder(row.subject, row.event_id, now);
+ if (!claimed) { skipped++; continue; }
+ try {
+ const out = await notifier.sendReminder({ subject: row.subject, event: claimed.event, now });
+ if (out && out.ok) { sent++; stats.sent++; } else { skipped++; stats.skipped++; }
+ } catch (err) {
+ failed++;
+ stats.failed++;
+ stats.last_error = String((err && err.message) || err).slice(0, 300);
+ log.warn(`[OpenVibe.Today] reminder ${row.event_id} not sent: ${stats.last_error}`);
+ await events.releaseReminder(row.subject, row.event_id).catch(() => {});
+ }
+ }
+ stats.last_run = new Date(now).toISOString();
+ return { due: due.length, sent, failed, skipped };
+ }
+
+ function start() {
+ if (timer || config.drill) return false;
+ timer = setInterval(() => { runOnce().catch((err) => log.warn('[OpenVibe.Today] reminder tick failed:', err.message)); }, intervalMs);
+ if (timer.unref) timer.unref();
+ return true;
+ }
+
+ function stop() {
+ if (timer) clearInterval(timer);
+ timer = null;
+ }
+
+ return { runOnce, start, stop, stats: () => ({ ...stats }) };
+}
+
+module.exports = { createReminders };
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,119 @@
@@ -0,0 +1,119 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * This is Help's layout with Today's name, navigation 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 { html, raw, render } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Today';
+const TAGLINE = 'The network\'s time bus and calendar authority.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Schedule', href: '/schedule' },
+ { label: 'Your brief', href: '/brief' },
+ { 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: 'today',
+ 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: 'today', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? html`Signed in as <strong>${viewer.displayName || viewer.username || 'you'}</strong> · <a href="/me">Your agenda</a> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : html`<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => html`<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="${o.noReferrer ? 'no-referrer' : 'strict-origin-when-cross-origin'}">
+${appIcon.headTags({ site: 'today' })}
+<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/today.css')}">
+<script src="${ovServe.url('motion.js')}" defer></script>
+<script src="${asset('js/today.js')}" defer></script>
+${(o.styles || []).map((name) => `<link rel="stylesheet" href="${require('openvibe-shared/serve').url(name)}">`).join('\n')}
+${render(scripts)}
+<meta name="ov-boost" content="today@${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">${render(account)}</div></noscript>
+<main id="main" class="td ov-room" style="${rooms.style('today')}">
+${render(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, raw, html };
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -0,0 +1,275 @@
+'use strict';
+/**
+ * The Today room's pages (plan 2026-10-28 §5.3, v1: the authority, schedules, Remind, ICS and the brief). Server-
+ * rendered and whole without JavaScript: every action is a plain form or link. public/js/today.js adds the reader's
+ * own time beside each event's, picks the device's time zone on the schedule form, and copies the feed URL.
+ *
+ * home({ upcoming, now }) the front door: the day's ring, what is coming, three scenes
+ * channel({ username, rows, now }) a channel's schedule and its calendar
+ * event({ e, end, reminder, own, signedIn, path }) one event: when, Remind, Add to calendar, Watch, Cancel (owner)
+ * me({ name, agenda, reminders, feedUrl, now }) the person's agenda, reminders and private feed
+ * brief({ out, name, tz }) the day's brief
+ * schedule({ values, problem, zones }) the schedule form
+ * signIn({ title, text, next }) a page that needs an account
+ * developers() the API, the feeds and the topics
+ * notFound()
+ * Each returns HTML for the page body; server/http/pages.js sends it through the layout.
+ */
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+const rooms = require('openvibe-shared/rooms');
+const { esc } = require('../render/html');
+
+const ROOM = rooms.get('today');
+const DAY = 24 * 60 * 60_000;
+
+/** Parts of an instant in a zone (UTC when the zone is unknown). */
+function parts(iso, tz) {
+ const d = new Date(iso);
+ const f = (zone) => Object.fromEntries(new Intl.DateTimeFormat('en-US', { timeZone: zone, weekday: 'short', month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit', timeZoneName: 'short' })
+ .formatToParts(d).map((p) => [p.type, p.value]));
+ try { return f(tz || 'UTC'); } catch { return f('UTC'); }
+}
+const timeText = (p) => `${p.hour}:${p.minute} ${p.dayPeriod || ''}`.trim();
+function dayKey(iso, tz) {
+ try { return new Intl.DateTimeFormat('en-CA', { timeZone: tz || 'UTC', year: 'numeric', month: '2-digit', day: '2-digit' }).format(new Date(iso)); } catch { return String(iso).slice(0, 10); }
+}
+function dayLabel(iso, tz, now) {
+ const k = dayKey(iso, tz);
+ if (k === dayKey(new Date(now).toISOString(), tz)) return 'Today';
+ if (k === dayKey(new Date(now + DAY).toISOString(), tz)) return 'Tomorrow';
+ const p = parts(iso, tz);
+ return `${p.weekday}, ${p.month} ${p.day}`;
+}
+/** "8:00 PM – 9:30 PM PDT" in the event's zone. */
+function range(e, end) {
+ const a = parts(e.starts_at, e.tz);
+ const b = parts(end, e.tz);
+ return `${timeText(a)} – ${timeText(b)} ${b.timeZoneName}`;
+}
+const statusPill = (st) => (st === 'live' ? '<span class="td-pill is-live">Live now</span>'
+ : st === 'cancelled' ? '<span class="td-pill is-off">Cancelled</span>'
+ : st === 'done' ? '<span class="td-pill is-done">Ended</span>' : '');
+
+/** A date tile: MON / 28. */
+function tile(iso, tz) {
+ const p = parts(iso, tz);
+ return `<span class="td-tile" aria-hidden="true"><span class="td-tile-m">${esc(p.month)}</span><span class="td-tile-d">${esc(p.day)}</span></span>`;
+}
+
+/** One event as a row in a list. */
+function row(e, { now, showChannel = true } = {}) {
+ const p = parts(e.starts_at, e.tz);
+ const tags = [e.own ? '<span class="td-tag">Yours</span>' : '', e.reminded ? '<span class="td-tag is-remind">Reminder set</span>' : '', e.visibility === 'followers' ? '<span class="td-tag">Followers</span>' : ''].join('');
+ return `<li class="td-ev td-ev-${esc(e.status)}">
+<a class="td-ev-link" href="/e/${esc(e.id)}">
+${tile(e.starts_at, e.tz)}
+<span class="td-ev-body"><span class="td-ev-title">${esc(e.title)}</span>
+<span class="td-ev-meta"><time datetime="${esc(e.starts_at)}" data-local="time">${esc(timeText(p))} ${esc(p.timeZoneName)}</time>${showChannel && e.channel ? `<span>@${esc(e.channel)}</span>` : ''}${e.kind === 'event' ? '<span>Event</span>' : ''}</span></span>
+<span class="td-ev-end">${statusPill(e.status)}${tags}</span>
+</a></li>`;
+}
+
+/** Events grouped by day (in each event's zone), soonest first. */
+function days(rows, { now, empty, showChannel = true } = {}) {
+ if (!rows.length) return `<div class="td-empty">${kit.icon('calendar')}<p>${esc(empty)}</p></div>`;
+ const groups = [];
+ for (const e of rows) {
+ const label = dayLabel(e.starts_at, e.tz, now);
+ const g = groups[groups.length - 1];
+ if (g && g.label === label) g.rows.push(e); else groups.push({ label, rows: [e] });
+ }
+ return groups.map((g) => `<section class="td-day" aria-label="${esc(g.label)}"><h3 class="td-day-h">${esc(g.label)}</h3><ul class="td-evs">${g.rows.map((e) => row(e, { now, showChannel })).join('')}</ul></section>`).join('');
+}
+
+/** The day's ring: 24 hours, today's events as dots at their hour, and "now" as a hand. Drawn in UTC; today.js turns it
+ * to the reader's own clock (the dots move to their local hours, the hand follows the time). */
+function ring(rows, now) {
+ const today = dayKey(new Date(now).toISOString(), 'UTC');
+ const hourOf = (iso) => { const d = new Date(iso); return d.getUTCHours() + d.getUTCMinutes() / 60; };
+ const dots = rows.filter((e) => e.status !== 'cancelled' && dayKey(e.starts_at, 'UTC') === today).slice(0, 12)
+ .map((e, i) => `<i class="td-ring-dot${e.status === 'live' ? ' is-live' : ''}" data-at="${esc(e.starts_at)}" style="--a:${(hourOf(e.starts_at) / 24 * 360).toFixed(1)}deg;--i:${i}" title="${esc(e.title)}"></i>`);
+ // A quiet day still shows the ring's shape: a few sample hours, marked as examples.
+ const sample = dots.length ? '' : [9, 13.5, 19, 21].map((h, i) => `<i class="td-ring-dot is-sample" style="--a:${(h / 24 * 360).toFixed(1)}deg;--i:${i}"></i>`).join('');
+ const ticks = Array.from({ length: 24 }, (_, h) => `<b class="td-ring-tick${h % 6 === 0 ? ' is-major' : ''}" style="--a:${h * 15}deg"></b>`).join('');
+ const nowDeg = (hourOf(new Date(now).toISOString()) / 24 * 360).toFixed(1);
+ return `<div class="td-ring" aria-hidden="true" data-ring style="--now:${nowDeg}deg">
+<span class="td-ring-face"></span>${ticks}${dots.join('')}${sample}
+<span class="td-ring-hand"></span><span class="td-ring-hub"></span>
+<span class="td-ring-label td-ring-l0">00</span><span class="td-ring-label td-ring-l6">06</span><span class="td-ring-label td-ring-l12">12</span><span class="td-ring-label td-ring-l18">18</span>
+</div>`;
+}
+
+function home({ upcoming = [], now = Date.now() } = {}) {
+ const channels = new Set(upcoming.map((e) => e.channel).filter(Boolean));
+ const live = upcoming.filter((e) => e.status === 'live');
+ const body = sc.room({
+ room: 'today',
+ hero: {
+ title: ROOM.line,
+ lede: 'Streams, events and reminders across OpenVibe on one calendar. Schedule a stream and your followers hear about it, set a reminder and it finds you on whatever site you are on, and subscribe once to see it all in your own calendar app.',
+ start: { href: '/schedule', label: 'Schedule a stream' },
+ chips: [{ label: 'Your agenda', href: '/me' }, { label: 'Your brief', href: '/brief' }, { label: 'Calendar feeds', href: '/developers#feeds' }],
+ motif: ring(upcoming, now),
+ },
+ proof: [
+ { value: live.length, label: live.length === 1 ? 'live now' : 'live now' },
+ { value: upcoming.length, label: upcoming.length === 1 ? 'thing coming up' : 'things coming up' },
+ { value: channels.size, label: channels.size === 1 ? 'channel scheduling' : 'channels scheduling' },
+ ].filter((p) => p.value),
+ html: `<section class="td-coming" id="td-upcoming" aria-labelledby="td-coming-h"><div class="td-head-row"><h2 id="td-coming-h">Coming up</h2><a class="td-more" href="/developers#feeds">Subscribe${kit.icon('chevron')}</a></div>
+${days(upcoming, { now, empty: 'Nothing is scheduled yet. Be the first: schedule a stream and it shows here, on your channel and in your followers’ calendars.' })}</section>`,
+ scenes: [
+ { title: 'A reminder that finds you', text: 'Tap Remind on anything coming up. Ten minutes before, it arrives in the bell on whichever OpenVibe site you have open, and it waits politely through your quiet hours.',
+ html: `<div class="td-demo td-demo-remind"><div class="td-demo-card">${tile(new Date(now + DAY).toISOString(), 'UTC')}<span><b>Night owls: Seattle walk</b><small>goosely · 8:00 PM</small></span><span class="td-demo-btn">Remind me</span></div>
+<div class="td-demo-toast">${kit.icon('calendar')}<span><b>Night owls: Seattle walk starts in 10 min</b><small>Today · openvibe.today</small></span></div></div>`,
+ action: { href: '/me', label: 'Your agenda' } },
+ { title: 'In the calendar you already use', text: 'Every channel has a public calendar, and you get a private one with everything you follow. Subscribe once in Google Calendar, Apple Calendar or Outlook, and changes and cancellations follow on their own.',
+ html: `<div class="td-demo td-demo-cal" aria-hidden="true"><div class="td-cal-head"><span>Fri</span><span>Sat</span><span>Sun</span></div><div class="td-cal-grid"><i style="--c:1;--r:3">Robot build, part 2</i><i style="--c:2;--r:5" class="is-room">Night owls</i><i style="--c:3;--r:2">Speedrun Sunday</i></div></div>`,
+ action: { href: '/developers#feeds', label: 'Calendar feeds' } },
+ { title: 'Schedule once, your followers know', text: 'Put a stream on your schedule and it shows on your channel, in your followers’ bells and in their calendars. Cancel it and it disappears everywhere.',
+ html: `<div class="td-demo td-demo-sched"><div class="td-demo-card is-form"><span class="td-demo-k">Stream</span><b>Robot build, part 2</b><small>Fri, 8:00 PM PDT · 2 hours · Public</small><span class="td-demo-btn">Schedule it</span></div>
+<div class="td-demo-toast">${kit.icon('live')}<span><b>carol scheduled a stream</b><small>Robot build, part 2 · Fri, 8:00 PM PDT</small></span></div></div>`,
+ action: { href: '/schedule', label: 'Schedule a stream' } },
+ ],
+ explore: { title: 'More on Today', items: [
+ { title: 'Your agenda', text: 'Your events, reminders and follows', href: '/me', icon: 'ov:calendar' },
+ { title: 'Your brief', text: 'Today in one page', href: '/brief', icon: 'ov:news' },
+ { title: 'The API', text: 'JSON, calendar feeds and topics', href: '/developers', icon: 'ov:code' },
+ ] },
+ faq: { items: [
+ ['How does a reminder reach me?', 'As an OpenVibe notification: it shows in the bell on every OpenVibe site you have open, and by email or push if you chose that for its kind. Your quiet hours hold it until they end.'],
+ ['Can I see this in my own calendar app?', 'Yes. Every channel has a public calendar feed, and your agenda has a private one. Subscribe in any app that reads iCalendar (.ics); updates and cancellations follow.'],
+ ['Who sees what I schedule?', 'A public stream is on your channel, here and in feeds. A followers-only one is seen only by the people who follow you. Followers hear about at most three new schedules from you a day.'],
+ ] },
+ });
+ return `<div class="td-wrap" id="td-home">${body}</div>`;
+}
+
+function channel({ username, rows = [], now = Date.now() } = {}) {
+ const feed = `/ics/@${encodeURIComponent(username)}.ics`;
+ return `<div class="td-wrap td-page-pad" id="td-channel" data-channel="${esc(username)}">
+<header class="td-page-h"><p class="td-kicker">Schedule</p><h1>@${esc(username)}</h1>
+<p class="td-lede">What this channel has planned, and a calendar you can subscribe to.</p>
+<div class="td-acts">${kit.button({ href: `https://openvibe.live/@${encodeURIComponent(username)}`, label: 'Watch on Live', kind: 'primary' })}
+<a class="ovk-btn ovk-btn--quiet" href="webcal://openvibe.today${esc(feed)}" data-feed="${esc(feed)}">${kit.icon('calendar')}Subscribe</a>
+<a class="ovk-btn ovk-btn--quiet" href="${esc(feed)}" download>Download .ics</a></div></header>
+${days(rows, { now, showChannel: false, empty: 'Nothing public is planned on this channel yet.' })}
+</div>`;
+}
+
+const MINUTES = [[0, 'When it starts'], [10, '10 minutes before'], [30, '30 minutes before'], [60, '1 hour before'], [1440, '1 day before']];
+
+function event({ e, end, reminder = null, own = false, signedIn = false, path = '/' } = {}) {
+ const p = parts(e.starts_at, e.tz);
+ const done = e.status === 'cancelled' || e.status === 'done';
+ const remindForm = done ? '' : !signedIn
+ ? `<a class="ovk-btn ovk-btn--primary" href="/auth/login?next=${encodeURIComponent(path)}">${kit.icon('bell')}Sign in to get a reminder</a>`
+ : reminder
+ ? `<form method="post" action="/e/${esc(e.id)}/remind/remove" class="td-inline"><span class="td-set">${kit.icon('check')}Reminder set: ${esc((MINUTES.find(([m]) => m === reminder.minutes_before) || [0, `${reminder.minutes_before} minutes before`])[1].toLowerCase())}</span><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Remove</button></form>`
+ : `<form method="post" action="/e/${esc(e.id)}/remind" class="td-inline td-remind"><label class="td-sr" for="td-min">When</label><select id="td-min" name="minutes_before">${MINUTES.map(([m, l]) => `<option value="${m}"${m === 10 ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select><button class="ovk-btn ovk-btn--primary" type="submit">${kit.icon('bell')}Remind me</button></form>`;
+ const watch = e.channel ? kit.button({ href: `https://openvibe.live/@${encodeURIComponent(e.channel)}`, label: e.status === 'live' ? 'Watch now' : `@${e.channel} on Live`, kind: e.status === 'live' ? 'primary' : 'quiet' }) : '';
+ return `<div class="td-wrap td-page-pad"><article class="td-event${done ? ' is-over' : ''}" data-event-id="${esc(e.id)}">
+<header class="td-event-h">${tile(e.starts_at, e.tz)}
+<div><p class="td-kicker">${e.kind === 'stream' ? 'Stream' : 'Event'}${e.channel ? ` · <a href="/@${esc(e.channel)}">@${esc(e.channel)}</a>` : ''}</p>
+<h1>${esc(e.title)} ${statusPill(e.status)}</h1>
+<p class="td-when"><time datetime="${esc(e.starts_at)}">${esc(`${p.weekday}, ${p.month} ${p.day}, ${p.year}`)}</time> · ${esc(range(e, end))}</p>
+<p class="td-local" data-local="range" data-start="${esc(e.starts_at)}" data-end="${esc(end)}" hidden></p></div></header>
+${e.description ? `<p class="td-desc">${esc(e.description)}</p>` : ''}
+<div class="td-acts">${remindForm}<a class="ovk-btn ovk-btn--quiet" href="/e/${esc(e.id)}.ics" download>${kit.icon('calendar')}Add to calendar</a>${watch}</div>
+${own && !done ? `<form method="post" action="/e/${esc(e.id)}/cancel" class="td-owner" data-confirm="Cancel this ${e.kind === 'stream' ? 'stream' : 'event'}? Followers' calendars and bells will drop it."><p>This is yours. Cancelling removes it from your channel, from calendars that subscribe, and from your followers' bells.</p><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">Cancel it</button></form>` : ''}
+<dl class="td-facts"><div><dt>Who can see it</dt><dd>${e.visibility === 'followers' ? 'Followers only' : 'Everyone'}</dd></div><div><dt>Time zone</dt><dd>${esc(e.tz.replace(/_/g, ' '))}</dd></div>${e.channel ? `<div><dt>Calendar</dt><dd><a href="/ics/@${esc(encodeURIComponent(e.channel))}.ics">@${esc(e.channel)}'s feed</a></dd></div>` : ''}<div><dt>Changed</dt><dd><time datetime="${esc(e.updated_at)}" data-local="ago">${esc(String(e.updated_at).slice(0, 16).replace('T', ' '))} UTC</time></dd></div></dl>
+</article></div>`;
+}
+
+function me({ name, agenda = { events: [] }, reminders = [], feedUrl = '', now = Date.now() } = {}) {
+ const webcal = String(feedUrl).replace(/^https?:/, 'webcal:');
+ return `<div class="td-wrap td-page-pad" id="td-me">
+<header class="td-page-h"><p class="td-kicker">Your agenda</p><h1>The next two weeks</h1>
+<p class="td-lede">Your own events, the ones you set reminders on, and the channels you follow, ${esc(name || 'you')}.</p>
+<div class="td-acts">${kit.button({ href: '/schedule', label: 'Schedule a stream', kind: 'primary' })}${kit.button({ href: '/brief', label: 'Your brief', kind: 'quiet' })}</div></header>
+${agenda.follows_unavailable ? '<p class="td-note" role="status">OpenVibe.Network did not answer, so the channels you follow are missing for a moment. Your own and reminded events are all here.</p>' : ''}
+<div class="td-me-grid"><div>${days(agenda.events || [], { now, empty: 'Nothing in the next two weeks. Follow a channel on Live, or set a reminder on something coming up.' })}</div>
+<aside class="td-side">
+<section class="td-card" aria-labelledby="td-rem-h"><h2 id="td-rem-h">${kit.icon('bell')}Reminders</h2>
+${reminders.length ? `<ul class="td-rems">${reminders.map((x) => `<li><a href="/e/${esc(x.event_id)}">${esc(x.event.title)}</a><small><time datetime="${esc(x.remind_at)}" data-local="time">${esc(timeText(parts(x.remind_at, x.event.tz)))} ${esc(parts(x.remind_at, x.event.tz).timeZoneName)}</time>, ${esc(dayLabel(x.remind_at, x.event.tz, now).toLowerCase())}</small></li>`).join('')}</ul>` : '<p class="td-fine">None yet. Open anything coming up and tap Remind me.</p>'}
+</section>
+<section class="td-card" aria-labelledby="td-feed-h"><h2 id="td-feed-h">${kit.icon('calendar')}Your calendar feed</h2>
+<p class="td-fine">Everything above, in Google Calendar, Apple Calendar or Outlook. Anyone with this link can read it, so keep it to yourself.</p>
+<div class="td-feed"><input id="td-feed-url" type="text" readonly value="${esc(feedUrl)}" aria-label="Your private calendar link"><button type="button" class="ovk-btn ovk-btn--quiet ovk-btn--small" data-copy="#td-feed-url" hidden>Copy</button></div>
+<div class="td-acts"><a class="ovk-btn ovk-btn--primary ovk-btn--small" href="${esc(webcal)}">Subscribe</a>
+<form method="post" action="/me/feed/rotate" class="td-inline" data-confirm="Make a new link? Calendars using the old one stop updating."><button class="ovk-btn ovk-btn--quiet ovk-btn--small" type="submit">New link</button></form></div>
+</section></aside></div>
+</div>`;
+}
+
+function brief({ out, name, tz = 'UTC' } = {}) {
+ const ACT = { remind: 'Remind', watch: 'Watch', buy: 'Buy from', approve: 'Approve', open: 'Open' };
+ const sections = (out && out.sections) || [];
+ return `<div class="td-wrap td-page-pad td-brief" id="td-brief">
+<header class="td-page-h"><p class="td-kicker">Your brief</p><h1 data-typed>${esc(out.day)}</h1>
+<p class="td-lede">What is left of today for ${esc(name || 'you')}${tz !== 'UTC' ? `, in ${esc(tz.replace(/_/g, ' '))}` : ''}.</p></header>
+${sections.length ? `<div class="td-brief-grid">${sections.map((sec) => `<section class="td-card td-brief-sec" id="td-brief-${esc(sec.id)}"><h2>${esc(sec.title)}</h2><ul>${sec.items.map((it) => `<li><a href="${esc(it.url)}">${esc(it.text)}</a>${it.action && ACT[it.action] ? `<a class="td-chip" href="${esc(it.url)}">${esc(ACT[it.action])}</a>` : ''}</li>`).join('')}</ul></section>`).join('')}</div>`
+ : `<div class="td-empty">${kit.icon('book')}<p>A quiet day so far. Schedule a stream, or set a reminder on something coming up, and it shows here tomorrow morning.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div>`}
+<p class="td-fine">Built each morning from your agenda, your reminders and what streamers talked about yesterday on The Rundown. Every line links to where it came from.</p>
+</div>`;
+}
+
+const ZONES = ['UTC', 'America/Los_Angeles', 'America/Denver', 'America/Chicago', 'America/New_York', 'America/Halifax', 'America/Sao_Paulo', 'Europe/London', 'Europe/Dublin', 'Europe/Lisbon', 'Europe/Paris', 'Europe/Berlin', 'Europe/Madrid', 'Europe/Rome', 'Europe/Amsterdam', 'Europe/Stockholm', 'Europe/Warsaw', 'Europe/Athens', 'Europe/Istanbul', 'Africa/Lagos', 'Africa/Johannesburg', 'Asia/Dubai', 'Asia/Kolkata', 'Asia/Bangkok', 'Asia/Singapore', 'Asia/Shanghai', 'Asia/Seoul', 'Asia/Tokyo', 'Australia/Perth', 'Australia/Sydney', 'Pacific/Auckland', 'Pacific/Honolulu'];
+const LENGTHS = [[30, '30 minutes'], [60, '1 hour'], [90, '1½ hours'], [120, '2 hours'], [180, '3 hours'], [240, '4 hours'], [360, '6 hours']];
+
+function schedule({ values = {}, problem = null } = {}) {
+ const v = { kind: 'stream', visibility: 'public', length: '120', tz: 'UTC', ...values };
+ const zones = ZONES.includes(v.tz) ? ZONES : [v.tz, ...ZONES];
+ const radio = (name, val, label, hint) => `<label class="td-opt"><input type="radio" name="${name}" value="${val}"${String(v[name]) === val ? ' checked' : ''}><span><b>${esc(label)}</b>${hint ? `<small>${esc(hint)}</small>` : ''}</span></label>`;
+ return `<div class="td-wrap td-page-pad td-narrow">
+<header class="td-page-h"><p class="td-kicker">Schedule</p><h1>Put it on the calendar</h1>
+<p class="td-lede">It shows here, on your channel and in calendars that subscribe. Your followers hear about it in their bell.</p></header>
+<form id="td-schedule-form" class="td-card td-form" method="post" action="/schedule">
+${problem ? `<p class="td-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+<fieldset class="td-opts"><legend>What is it?</legend>${radio('kind', 'stream', 'A stream', 'On your Live channel')}${radio('kind', 'event', 'An event', 'Anything else with a time')}</fieldset>
+<label class="td-field"><span>Title</span><input name="title" type="text" maxlength="120" required value="${esc(v.title || '')}" placeholder="Robot build, part 2"></label>
+<div class="td-row"><label class="td-field"><span>Starts</span><input name="starts_at" type="datetime-local" required value="${esc(v.starts_at || '')}"></label>
+<label class="td-field"><span>How long</span><select name="length">${LENGTHS.map(([m, l]) => `<option value="${m}"${String(v.length) === String(m) ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label></div>
+<label class="td-field"><span>Time zone</span><select name="tz" data-tz>${zones.map((z) => `<option value="${esc(z)}"${z === v.tz ? ' selected' : ''}>${esc(z.replace(/_/g, ' '))}</option>`).join('')}</select><small>The time above is read in this zone. Everyone sees it in their own.</small></label>
+<label class="td-field"><span>Description <i>optional</i></span><textarea name="description" rows="3" maxlength="1000" placeholder="What you are doing, who is joining">${esc(v.description || '')}</textarea></label>
+<fieldset class="td-opts"><legend>Who can see it?</legend>${radio('visibility', 'public', 'Everyone', 'Here, on your channel and in feeds')}${radio('visibility', 'followers', 'Followers', 'Only people who follow you')}</fieldset>
+<div class="td-acts"><button class="ovk-btn ovk-btn--primary" type="submit">Schedule it</button><a class="ovk-btn ovk-btn--quiet" href="/me">Back to your agenda</a></div>
+</form></div>`;
+}
+
+function signIn({ title, text, next }) {
+ return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty td-signin">${kit.icon('calendar')}<h1>${esc(title)}</h1><p>${esc(text)}</p>${kit.button({ href: `/auth/login?next=${encodeURIComponent(next)}`, label: 'Sign in with OpenVibe', kind: 'primary' })}</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="td-wrap td-page-pad" id="td-developers">
+<header class="td-page-h"><p class="td-kicker">Developers</p><h1>The Today API</h1>
+<p class="td-lede">JSON under <code>/api/v1</code>, calendars under <code>/ics/</code>, and three topics on OpenVibe.Events. A person is the session cookie, or a Network user token as a Bearer.</p></header>
+<section><h2>Read</h2><dl class="td-eps">
+${ep('GET', '/api/v1/events?channel=&from=&to=&limit=', 'Public events, soonest first (at most 100; cached 60 s).')}
+${ep('GET', '/api/v1/events/:id', 'One event. A cancelled one answers with <code>status: "cancelled"</code>; a followers-only one is 404 unless it is yours.')}
+${ep('GET', '/api/v1/me/agenda?days=14', 'Your events, reminded events and followed channels’ events (up to 60 days).')}
+${ep('GET', '/api/v1/me/brief', 'Today’s brief: sections of cited lines.')}
+</dl></section>
+<section><h2>Write (signed in)</h2><dl class="td-eps">
+${ep('POST', '/api/v1/events', '<code>{ kind, title, starts_at, ends_at?, tz, description?, visibility? }</code>. <code>Idempotency-Key</code> is honoured; at most 60 future events per person.')}
+${ep('PATCH', '/api/v1/events/:id', 'Change the title, times, zone, description or visibility (the calendar sequence goes up).')}
+${ep('DELETE', '/api/v1/events/:id', 'Cancel it (again is fine).')}
+${ep('POST', '/api/v1/events/:id/remind', '<code>{ minutes_before: 0–1440 }</code>; <code>DELETE</code> removes it.')}
+</dl></section>
+<section id="feeds"><h2>Calendar feeds</h2><dl class="td-eps">
+${ep('GET', '/ics/@<username>.ics', 'A channel’s public calendar (RFC 5545). Subscribe with <code>webcal://openvibe.today/ics/@<username>.ics</code>.')}
+${ep('GET', '/e/:id.ics', 'One event, for "Add to calendar".')}
+${ep('GET', '/ics/<token>.ics', 'Your private agenda; the link is on <a href="/me">your agenda</a>, and a new one replaces it at once.')}
+</dl></section>
+<section><h2>Events</h2><p class="td-fine">Today publishes <code>today.event.scheduled</code>, <code>today.event.updated</code> and <code>today.event.cancelled</code>, and listens for <code>live.stream.started</code> and <code>live.stream.ended</code> to mark a stream live and then ended. A refusal is <code>400 { code: "today.invalid", field, message }</code>; others are <code>today.too_many</code>, <code>today.cross_site</code>, <code>today.not_found</code>.</p></section>
+</div>`;
+}
+
+function notFound() {
+ return `<div class="td-wrap td-page-pad td-narrow"><div class="td-empty">${kit.icon('calendar')}<h1>Not on the calendar</h1><p>There is nothing at this address. It may have been cancelled, or it is for followers only.</p>${kit.button({ href: '/', label: 'See what is coming up', kind: 'quiet' })}</div></div>`;
+}
+
+module.exports = { home, channel, event, me, brief, schedule, signIn, developers, notFound, ZONES, LENGTHS, parts, dayKey };
new file mode 100644
new file mode 100644
@@ -0,0 +1,121 @@
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * ADR-033: Today's part of an account export (events, reminders, feed row without its token, briefs, idempotency
+ * keys) and of a deletion, applied through /internal/events with a stand-in Network. A deletion erases the person's
+ * rows, other people's reminders on their events and pending outbox rows for those events, and confirms with counts;
+ * a redelivery changes nothing.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = 'usr_01JZ0000000000000000000AAA';
+const B = 'usr_01JZ0000000000000000000BBB';
+const EXP = 'exp_01JZ0000000000000000000EXP';
+const DEL = 'del_01JZ0000000000000000000DEX';
+const SECRET = `fixture_${'account'.repeat(6)}`;
+const WRONG = `fixture_${'mismatch'.repeat(6)}`;
+
+const exportEvent = { event_id: 'evt_01JZ0000000000000000000E01', event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: 'evt_01JZ0000000000000000000D01', 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_today', 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: 'today', clientSecret: 'today-secret' });
+ // The mock Network's people use generated subjects; the account events name fixed fixtures, so the rows are
+ // inserted directly with those subjects.
+ const t = await boot({ env: { TODAY_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const db = t.ctx.s.db;
+ const at = new Date(Date.now() + 2 * 60 * 60_000).toISOString();
+ await db.exec(`INSERT INTO today_events (id, owner_subject, kind, channel, title, description, starts_at, ends_at, tz, status, visibility, url, seq)
+ VALUES ('tev_01JZ000000000000000000AA', $1, 'stream', 'a', 'A stream', NULL, $2::timestamptz, NULL, 'UTC', 'scheduled', 'public', 'https://openvibe.live/@a', 0),
+ ('tev_01JZ000000000000000000BB', $3, 'stream', 'b', 'B stream', NULL, $2::timestamptz, NULL, 'UTC', 'scheduled', 'public', 'https://openvibe.live/@b', 0)`, [A, at, B]);
+ await db.exec(`INSERT INTO today_reminders (subject, event_id, remind_at, created_at) VALUES
+ ($1, 'tev_01JZ000000000000000000BB', $2::timestamptz, now()),
+ ($3, 'tev_01JZ000000000000000000AA', $2::timestamptz, now()),
+ ($3, 'tev_01JZ000000000000000000BB', $2::timestamptz, now())`, [A, at, B]);
+ await db.exec(`INSERT INTO today_feeds (subject, token, created_at) VALUES ($1, 'feedtokenA_fixture', now()), ($2, 'feedtokenB_fixture', now())`, [A, B]);
+ await db.exec(`INSERT INTO today_briefs (subject, day, body, created_at) VALUES ($1, '2026-10-28'::date, '[]'::jsonb, now()), ($2, '2026-10-28'::date, '[]'::jsonb, now())`, [A, B]);
+ await db.exec(`INSERT INTO today_idempotency (subject, key, event_id, created_at) VALUES ($1, 'a-key', 'tev_01JZ000000000000000000AA', now())`, [A]);
+ await db.exec(`INSERT INTO today_outbox (event_id, topic, payload, created_at) VALUES ('evt_01JZ0000000000000000000A1', 'today.event.scheduled', '{"event_id":"tev_01JZ000000000000000000AA"}'::jsonb, now())`);
+
+ await check('an export carries only A\'s rows, and never the feed token', 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_today');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['briefs.json', 'events.json', 'feeds.json', 'idempotency.json', 'reminders.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('A stream'));
+ assert.ok(!all.includes('B stream'), 'nobody else\'s event');
+ assert.ok(!all.includes('feedtokenA_fixture'), 'the feed token is never exported');
+ assert.ok(!all.includes('feedtokenB_fixture'));
+ const events = part.body.files.find((f) => f.name === 'events.json').content;
+ assert.deepStrictEqual(events.map((e) => e.id), ['tev_01JZ000000000000000000AA']);
+ const reminders = part.body.files.find((f) => f.name === 'reminders.json').content;
+ assert.deepStrictEqual(reminders.map((r) => r.event_id), ['tev_01JZ000000000000000000BB']);
+ });
+
+ await check('a deletion erases A\'s rows, other people\'s reminders on A\'s events, and confirms with counts', 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(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [A]), 0);
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE subject = $1', [A]), 0);
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1', ['tev_01JZ000000000000000000AA']), 0, 'B\'s reminder on A\'s event is an orphan and goes');
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_feeds WHERE subject = $1', [A]), 0);
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_briefs WHERE subject = $1', [A]), 0);
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_idempotency WHERE subject = $1', [A]), 0);
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [B]), 1, 'B\'s event stays');
+ assert.strictEqual(await db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE subject = $1', [B]), 1, 'B\'s own reminder stays');
+ 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.today_events >= 1);
+ assert.ok(confirmation.body.erased.today_reminders >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing; a new row made afterwards stays', async () => {
+ await db.exec(`INSERT INTO today_events (id, owner_subject, kind, channel, title, starts_at, tz, status, visibility, seq) VALUES ('tev_01JZ000000000000000000AC', $1, 'event', NULL, 'written later', now() + interval '1 hour', 'UTC', 'scheduled', 'public', 0)`, [A]);
+ 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(await db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE owner_subject = $1', [A]), 1);
+ });
+
+ 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,87 @@
@@ -0,0 +1,87 @@
+'use strict';
+/**
+ * The agenda: the caller's own events, the events they set a reminder on and the public or followers-only events of
+ * the channels they follow. Follows come from Network with the person's token; when Network is down the agenda still
+ * answers with own and reminded events and follows_unavailable: true.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ const at = (mins) => new Date(Date.now() + mins * 60_000).toISOString();
+ const create = async (user, title, opts = {}) => {
+ const r = await t.get('/api/v1/events', { as: user, json: { kind: opts.kind || 'stream', title, starts_at: opts.starts_at || at(60), tz: 'UTC', ...(opts.visibility ? { visibility: opts.visibility } : {}) } });
+ assert.strictEqual(r.status, 201, r.text);
+ return r.json();
+ };
+
+ let own;
+ let reminded;
+ let followed;
+ let followedPrivate;
+
+ await check('own + reminded + followed channels\' events, and nothing else', async () => {
+ own = await create(alice, 'My own stream', { starts_at: at(30) });
+ reminded = await create(bob, 'Bob reminded', { starts_at: at(90) });
+ followed = await create(carol, 'Carol public', { starts_at: at(120) });
+ followedPrivate = await create(carol, 'Carol followers', { starts_at: at(150), visibility: 'followers' });
+ const unfollowed = await create(bob, 'Bob not followed', { starts_at: at(180) });
+ await t.get(`/api/v1/events/${reminded.id}/remind`, { as: alice, json: { minutes_before: 5 } });
+ t.network.setFollow(alice, carol.subject);
+ const r = await t.get('/api/v1/me/agenda?days=14', { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ const b = r.json();
+ assert.strictEqual(b.days, 14);
+ assert.strictEqual(b.follows_unavailable, false);
+ const ids = b.events.map((e) => e.id);
+ assert.ok(ids.includes(own.id), 'own');
+ assert.ok(ids.includes(reminded.id), 'reminded');
+ assert.ok(ids.includes(followed.id), 'followed public');
+ assert.ok(ids.includes(followedPrivate.id), 'followed followers-only');
+ assert.ok(!ids.includes(unfollowed.id), 'not followed and not reminded');
+ assert.strictEqual(b.events.find((e) => e.id === own.id).own, true);
+ assert.strictEqual(b.events.find((e) => e.id === reminded.id).reminded, true);
+ assert.deepStrictEqual(ids, [...ids].sort((x, y) => {
+ const a = b.events.find((e) => e.id === x); const c = b.events.find((e) => e.id === y);
+ return new Date(a.starts_at) - new Date(c.starts_at);
+ }));
+ });
+
+ await check('an anonymous caller is 401 and days is validated', async () => {
+ assert.strictEqual((await t.get('/api/v1/me/agenda')).status, 401);
+ assert.strictEqual((await t.get('/api/v1/me/agenda?days=0', { as: alice })).status, 400);
+ assert.strictEqual((await t.get('/api/v1/me/agenda?days=61', { as: alice })).status, 400);
+ assert.strictEqual((await t.get('/api/v1/me/agenda?days=60', { as: alice })).status, 200);
+ });
+
+ await check('Network down: own and reminded events still answer, follows_unavailable true', async () => {
+ const dave = t.network.addUser('dave');
+ const mine = await create(dave, 'Dave own', { starts_at: at(45) });
+ t.network.setFollow(dave, carol.subject);
+ t.network.setDown(true);
+ const r = await t.get('/api/v1/me/agenda', { as: dave });
+ assert.strictEqual(r.status, 200, r.text);
+ const b = r.json();
+ assert.strictEqual(b.follows_unavailable, true);
+ assert.deepStrictEqual(b.events.map((e) => e.id), [mine.id]);
+ t.network.setDown(false);
+ });
+
+ await check('the reminders API lists upcoming reminders only', async () => {
+ const r = await t.get('/api/v1/me/reminders', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.json().reminders.some((x) => x.event_id === reminded.id));
+ const removed = await t.get(`/api/v1/events/${reminded.id}/remind`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(removed.status, 200);
+ assert.strictEqual(removed.json().removed, true);
+ const after = await t.get('/api/v1/me/reminders', { as: alice });
+ assert.ok(!after.json().reminders.some((x) => x.event_id === reminded.id));
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+/**
+ * The brief: sections from real rows (later today, today's reminders), up to three Rundown items, the Rundown
+ * section omitted when Live fails, and one stored brief per subject per day.
+ */
+const assert = require('assert');
+const http = require('http');
+const { boot, check, done } = require('./helpers/boot');
+
+async function startLive() {
+ let fail = false;
+ const server = http.createServer((req, res) => {
+ if (req.url !== '/api/rundown') { res.writeHead(404); res.end('{}'); return; }
+ if (fail) { res.writeHead(500); res.end('{}'); return; }
+ const now = Date.now();
+ const yesterday = new Date(now - 24 * 60 * 60_000).toISOString();
+ const today = new Date(now).toISOString();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ lead: { type: 'segment', stream_id: 11, said_at: yesterday, take: 'The lead take', user: { username: 'carl', display_name: 'Carl' }, topic: { slug: 'lead-topic', title: 'Lead topic' } },
+ streams: [
+ { stream_id: 12, said_at: yesterday, episode: { stream_id: 12, title: 'Episode twelve', aired_at: yesterday, user: { username: 'dana', display_name: 'Dana' } } },
+ { stream_id: 13, said_at: yesterday, episode: { stream_id: 13, title: 'Episode thirteen', aired_at: yesterday, user: { username: 'erin', display_name: 'Erin' } } },
+ { stream_id: 14, said_at: today, episode: { stream_id: 14, title: 'Today episode', aired_at: today, user: { username: 'frank' } } },
+ ],
+ trending: [{ slug: 'trend', title: 'A trend', headline: 'Trending headline', streamers: 3, last_seen_at: yesterday }],
+ }));
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ return { url: `http://127.0.0.1:${server.address().port}`, fail(v) { fail = v; }, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const live = await startLive();
+ const t = await boot({ env: { OV_LIVE_INTERNAL_URL: live.url } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const now = Date.now();
+ const at = (mins) => new Date(now + mins * 60_000).toISOString();
+ // Choose a zone in which now and the next three hours are the same calendar day, so "later today" is stable
+ // whatever time of day the suite runs.
+ const { dayIn } = require('../server/brief');
+ const tz = ['UTC', 'Europe/Berlin', 'America/New_York', 'Asia/Tokyo', 'Australia/Sydney', 'Pacific/Auckland', 'America/Los_Angeles']
+ .find((z) => dayIn(z, now) === dayIn(z, now + 3 * 60 * 60_000)) || 'UTC';
+
+ const own = (await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'My later stream', starts_at: at(120), tz } })).json();
+ const tomorrow = (await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Tomorrow thing', starts_at: at(30 * 60), tz } })).json();
+ const bobEvent = (await t.get('/api/v1/events', { as: bob, json: { kind: 'event', title: 'Bob reminder', starts_at: at(180), tz } })).json();
+ await t.get(`/api/v1/events/${bobEvent.id}/remind`, { as: alice, json: { minutes_before: 30 } });
+
+ await check('sections come from the agenda, today\'s reminders and the Rundown', async () => {
+ const brief = await t.ctx.brief.buildBrief(alice.subject, { now, tz });
+ assert.match(brief.day, /^\d{4}-\d{2}-\d{2}$/);
+ const byId = Object.fromEntries(brief.sections.map((s) => [s.id, s]));
+ assert.ok(byId['later-today'], 'Later today');
+ assert.ok(byId['later-today'].items.some((i) => i.text.includes('My later stream')));
+ assert.ok(!byId['later-today'].items.some((i) => i.text.includes('Tomorrow thing')), 'tomorrow is not today');
+ assert.ok(byId['later-today'].items.every((i) => i.action === 'remind'));
+ assert.ok(byId['later-today'].items.every((i) => i.url.startsWith('https://openvibe.today/e/')));
+ assert.ok(byId['your-reminders'], 'Your reminders');
+ assert.ok(byId['your-reminders'].items.some((i) => i.text.includes('Bob reminder')));
+ assert.ok(byId.yesterday, 'Yesterday on OpenVibe');
+ assert.ok(byId.yesterday.items.length <= 3);
+ assert.ok(byId.yesterday.items.some((i) => i.url === 'https://openvibe.live/rundown/t/lead-topic'));
+ assert.ok(byId.yesterday.items.some((i) => i.url.includes('/rundown/e/12')));
+ assert.ok(!byId.yesterday.items.some((i) => i.url.includes('/rundown/e/14')), 'today\'s item is not yesterday');
+ assert.ok(byId.yesterday.items.every((i) => i.action === null));
+ });
+
+ await check('GET /me/brief builds on demand and stores one brief per subject per day', async () => {
+ const r = await t.get(`/api/v1/me/brief?tz=${encodeURIComponent(tz)}`, { as: alice });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.ok(r.json().sections.some((s) => s.id === 'later-today'));
+ const again = await t.get(`/api/v1/me/brief?tz=${encodeURIComponent(tz)}`, { as: alice });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs WHERE subject = $1', [alice.subject]), 1);
+ assert.strictEqual((await t.get('/api/v1/me/brief?tz=Nope/Zone', { as: alice })).status, 400);
+ assert.strictEqual((await t.get('/api/v1/me/brief')).status, 401);
+ });
+
+ await check('the Rundown section is omitted when Live fails', async () => {
+ const carol = t.network.addUser('carol');
+ await t.get('/api/v1/events', { as: carol, json: { kind: 'event', title: 'Carol today', starts_at: at(60), tz } });
+ live.fail(true);
+ try {
+ const brief = await t.ctx.brief.buildBrief(carol.subject, { now, tz });
+ assert.ok(brief.sections.some((s) => s.id === 'later-today'));
+ assert.ok(!brief.sections.some((s) => s.id === 'yesterday'), 'no fake Rundown section');
+ } finally {
+ live.fail(false);
+ }
+ });
+
+ await check('the daily job builds for subjects active in the last 30 days', async () => {
+ const six = new Date(Date.UTC(new Date(now).getUTCFullYear(), new Date(now).getUTCMonth(), new Date(now).getUTCDate(), 6, 0, 0));
+ const r = await t.ctx.brief.runOnce({ now: six.getTime(), force: true });
+ assert.ok(r.subjects >= 2, `active subjects: ${r.subjects}`);
+ assert.ok(r.built >= 2);
+ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs');
+ assert.ok(n >= 2);
+ // The same run again does not add a second brief for the same day.
+ const before = Number(n);
+ await t.ctx.brief.runOnce({ now: six.getTime(), force: true });
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_briefs')), before);
+ });
+
+ await t.close();
+ await live.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * POST /internal/events: a signed live.stream.started turns the channel's scheduled event (start within 2 h of now)
+ * live, live.stream.ended makes a live one done, a replayed delivery changes nothing, and a bad signature is refused.
+ */
+const assert = require('assert');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+const { boot, check, done } = require('./helpers/boot');
+
+const SECRET = `fixture_${'consumer'.repeat(6)}`;
+const bodyOf = (event) => JSON.stringify({ event, seq: 1 });
+const started = (id) => ({ event_id: 'evt_01M61F7R3WXM5T5ET9CRGNHEP1', event_type: 'live.stream.started', source: 'live', payload: { stream_id: 7, channel: { username: 'alice' }, title: 'Hello', started_at: new Date().toISOString() } });
+const ended = (id) => ({ event_id: 'evt_01M61F7R3WXM5T5ET9CRGNHEP2', event_type: 'live.stream.ended', source: 'live', payload: { stream_id: 7, channel: { username: 'alice' }, title: 'Hello', started_at: new Date().toISOString(), ended_at: new Date().toISOString(), duration_seconds: 60 } });
+
+(async () => {
+ const t = await boot({ env: { TODAY_EVENTS_SECRET: SECRET } });
+ const alice = t.network.addUser('alice');
+ const starts = new Date(Date.now() + 30 * 60_000).toISOString();
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Hello', starts_at: starts, tz: 'UTC' } });
+ assert.strictEqual(created.status, 201);
+ const id = created.json().id;
+
+ const post = (b, extra = {}) => t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(b, SECRET), ...extra } });
+
+ await check('a signed live.stream.started makes the scheduled event live', async () => {
+ const b = bodyOf(started(id));
+ const r = await post(b);
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'live');
+ const e = await t.get(`/api/v1/events/${id}`);
+ assert.strictEqual(e.json().status, 'live');
+ assert.strictEqual(e.json().seq, 1);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_processed WHERE event_id = $1', [started(id).event_id]), 1);
+ });
+
+ await check('a replayed delivery changes nothing', async () => {
+ const b = bodyOf(started(id));
+ const r = await post(b);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.json().duplicate, true);
+ const e = await t.get(`/api/v1/events/${id}`);
+ assert.strictEqual(e.json().status, 'live');
+ assert.strictEqual(e.json().seq, 1, 'no second bump');
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_processed WHERE event_id = $1', [started(id).event_id]), 1);
+ });
+
+ await check('a signed live.stream.ended makes the live event done', async () => {
+ const r = await post(bodyOf(ended(id)));
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().outcome, 'done');
+ const e = await t.get(`/api/v1/events/${id}`);
+ assert.strictEqual(e.json().status, 'done');
+ assert.strictEqual(e.json().seq, 2);
+ });
+
+ await check('a bad signature is refused (401) and a forwarded request (403)', async () => {
+ const b = bodyOf(started(id));
+ const bad = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', ...signDeliveryHeaders(b, `${'wrong'.repeat(8)}`) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(b, SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ const missing = await t.get('/internal/events', { method: 'POST', body: b, headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(missing.status, 401);
+ });
+
+ await check('with no secret configured the route answers 503', async () => {
+ const t2 = await boot();
+ const r = await t2.get('/internal/events', { method: 'POST', body: '{}', headers: { 'content-type': 'application/json' } });
+ assert.strictEqual(r.status, 503);
+ await t2.close();
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,76 @@
@@ -0,0 +1,76 @@
+'use strict';
+/**
+ * The public crawl artifacts: /robots.txt, /sitemap.xml, /llms.txt, /llms-full.txt and the home page's JSON-LD.
+ * Each is served with the right status and content type, lists public pages only, and takes lastmod from STATUS.json.
+ */
+const assert = require('assert');
+const fs = require('fs');
+const path = require('path');
+const { boot, check, done } = require('./helpers/boot');
+
+const STATUS_UPDATED = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'STATUS.json'), 'utf8')).updated;
+const PRIVATE = ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal'];
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('robots.txt: text/plain, private paths disallowed, public pages crawlable, sitemap named', async () => {
+ const r = await t.get('/robots.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ for (const d of ['/auth/', '/api/', '/me', '/brief', '/schedule', '/internal']) assert.ok(r.text.includes(`Disallow: ${d}`), `missing Disallow: ${d}`);
+ for (const d of ['/developers']) assert.ok(!r.text.includes(`Disallow: ${d}`), `${d} must stay crawlable`);
+ assert.ok(r.text.includes('Sitemap: https://openvibe.today/sitemap.xml'), 'sitemap not named');
+ assert.ok(/^User-agent: \*$/m.test(r.text));
+ });
+
+ await check('sitemap.xml: application/xml, real entries, lastmod from STATUS.json, nothing private', async () => {
+ const r = await t.get('/sitemap.xml');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^application\/xml/);
+ assert.match(r.headers.get('cache-control') || '', /public, max-age=\d+/);
+ for (const p of ['/', '/developers']) assert.ok(r.text.includes(`<loc>https://openvibe.today${p}</loc>`), `no ${p} entry`);
+ const lastmods = [...r.text.matchAll(/<lastmod>([^<]+)<\/lastmod>/g)].map((m) => m[1]);
+ assert.ok(lastmods.length > 0);
+ assert.ok(lastmods.every((d) => d === STATUS_UPDATED), `lastmod must come from STATUS.json (${STATUS_UPDATED}), got ${[...new Set(lastmods)].join(', ')}`);
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.today${p}`), `private path in sitemap: ${p}`);
+ });
+
+ await check('llms.txt: text/plain, real links, nothing private', async () => {
+ const r = await t.get('/llms.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.ok(r.text.startsWith('# OpenVibe.Today'));
+ assert.ok(r.text.includes('(https://openvibe.today/developers)'));
+ assert.ok(r.text.includes('https://openvibe.today/sitemap.xml'));
+ assert.ok(r.text.includes('(https://openvibe.today/llms-full.txt)'));
+ for (const p of ['/auth/', '/me', '/brief']) assert.ok(!r.text.includes(`https://openvibe.today${p}`), `private path in llms.txt: ${p}`);
+ });
+
+ await check('llms-full.txt: every page the sitemap lists', async () => {
+ const r = await t.get('/llms-full.txt');
+ assert.strictEqual(r.status, 200);
+ assert.match(r.headers.get('content-type'), /^text\/plain/);
+ assert.ok(r.text.startsWith('# OpenVibe.Today'));
+ const sitemap = await t.get('/sitemap.xml');
+ const locs = [...sitemap.text.matchAll(/<loc>([^<]+)<\/loc>/g)].map((m) => m[1]);
+ for (const loc of locs) assert.ok(r.text.includes(loc), `llms-full.txt is missing ${loc}`);
+ for (const p of PRIVATE) assert.ok(!r.text.includes(`https://openvibe.today${p}`));
+ });
+
+ await check('home page: WebSite, WebApplication and WebPage JSON-LD', async () => {
+ const r = await t.get('/');
+ const blocks = [...r.text.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+ const types = blocks.map((b) => b['@type']);
+ assert.ok(types.includes('WebSite'), types.join(','));
+ assert.ok(types.includes('WebApplication'), types.join(','));
+ assert.ok(types.includes('WebPage'), types.join(','));
+ assert.strictEqual(blocks.find((b) => b['@type'] === 'WebSite').url, 'https://openvibe.today');
+ assert.strictEqual(blocks.find((b) => b['@type'] === 'WebPage').url, 'https://openvibe.today/');
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,164 @@
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * The events API: create (validation, the stream channel from the token, Idempotency-Key), patch (seq + 1),
+ * cancel (idempotent), the per-owner cap, followers-only visibility, the cross-site refusal, and the outbox row
+ * written in the same transaction as the event (a failed outbox insert leaves neither).
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const iso = (ms) => new Date(ms).toISOString();
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const soon = () => iso(Date.now() + 60 * 60_000);
+
+ await check('a stream takes its channel and URL from the token, never from the body', async () => {
+ const r = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', channel: 'evil', url: 'https://evil.example', title: 'Live soon', starts_at: soon(), tz: 'Europe/Berlin' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const e = r.json();
+ assert.match(e.id, /^tev_[0-9A-HJKMNP-TV-Z]{20}$/);
+ assert.strictEqual(e.channel, 'alice');
+ assert.strictEqual(e.url, 'https://openvibe.live/@alice');
+ assert.strictEqual(e.status, 'scheduled');
+ assert.strictEqual(e.seq, 0);
+ assert.strictEqual(e.owner_subject, alice.subject);
+ });
+
+ await check('validation errors are 400 today.invalid with the field', async () => {
+ const cases = [
+ [{ kind: 'nope', title: 'x', starts_at: soon(), tz: 'UTC' }, 'kind'],
+ [{ kind: 'event', title: '', starts_at: soon(), tz: 'UTC' }, 'title'],
+ [{ kind: 'event', title: 'x'.repeat(121), starts_at: soon(), tz: 'UTC' }, 'title'],
+ [{ kind: 'event', title: 'x', starts_at: iso(Date.now() - 10 * 60_000), tz: 'UTC' }, 'starts_at'],
+ [{ kind: 'event', title: 'x', starts_at: iso(Date.now() + 367 * 24 * 60 * 60_000), tz: 'UTC' }, 'starts_at'],
+ [{ kind: 'event', title: 'x', starts_at: soon(), ends_at: iso(Date.now() + 30 * 60_000), tz: 'UTC' }, 'ends_at'],
+ [{ kind: 'event', title: 'x', starts_at: soon(), ends_at: iso(Date.now() + 26 * 60 * 60_000), tz: 'UTC' }, 'ends_at'],
+ [{ kind: 'event', title: 'x', starts_at: soon(), tz: 'Not/AZone' }, 'tz'],
+ [{ kind: 'event', title: 'x', starts_at: soon(), tz: 'UTC', description: 'd'.repeat(1001) }, 'description'],
+ [{ kind: 'event', title: 'x', starts_at: 'tomorrow', tz: 'UTC' }, 'starts_at'],
+ ];
+ for (const [body, field] of cases) {
+ const r = await t.get('/api/v1/events', { as: alice, json: body });
+ assert.strictEqual(r.status, 400, `${field}: ${r.text}`);
+ const b = r.json();
+ assert.strictEqual(b.code, 'today.invalid');
+ assert.strictEqual(b.field, field, `${field}: ${r.text}`);
+ assert.ok(b.message);
+ }
+ });
+
+ await check('a repeated Idempotency-Key returns the first event, never a second one', async () => {
+ const headers = { 'Idempotency-Key': 'fixture-key-1' };
+ const body = { kind: 'event', title: 'Idempotent', starts_at: soon(), tz: 'UTC' };
+ const a = await t.get('/api/v1/events', { as: alice, json: body, headers });
+ const b = await t.get('/api/v1/events', { as: alice, json: body, headers });
+ assert.strictEqual(a.status, 201);
+ assert.strictEqual(b.status, 200);
+ assert.strictEqual(a.json().id, b.json().id);
+ const n = await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE title = $1', ['Idempotent']);
+ assert.strictEqual(n, 1);
+ const long = await t.get('/api/v1/events', { as: alice, json: body, headers: { 'Idempotency-Key': 'k'.repeat(101) } });
+ assert.strictEqual(long.status, 400);
+ assert.strictEqual(long.json().field, 'idempotency_key');
+ });
+
+ await check('PATCH bumps seq and writes today.event.updated in the same transaction', async () => {
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Before', starts_at: soon(), tz: 'UTC' } });
+ const id = created.json().id;
+ const r = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'PATCH', json: { title: 'After', visibility: 'followers' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().title, 'After');
+ assert.strictEqual(r.json().visibility, 'followers');
+ assert.strictEqual(r.json().seq, 1);
+ const rows = await t.ctx.s.db.many('SELECT topic, payload FROM today_outbox WHERE payload->>$1 = $2 ORDER BY created_at', ['event_id', id]);
+ assert.deepStrictEqual(rows.map((x) => x.topic), ['today.event.scheduled', 'today.event.updated']);
+ assert.strictEqual(rows[1].payload.seq, 1);
+ });
+
+ await check('a followers-only event is 404 to everyone but its owner and absent from the public list', async () => {
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Followers only', starts_at: soon(), tz: 'UTC', visibility: 'followers' } });
+ const id = created.json().id;
+ assert.strictEqual((await t.get(`/api/v1/events/${id}`)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: alice })).status, 200);
+ const list = await t.get('/api/v1/events?limit=100');
+ assert.ok(!list.json().events.some((e) => e.id === id));
+ });
+
+ await check('a session write from another origin is 403 today.cross_site', async () => {
+ const r = await t.get('/api/v1/events', { as: alice, origin: 'https://evil.example', json: { kind: 'event', title: 'Cross', starts_at: soon(), tz: 'UTC' } });
+ assert.strictEqual(r.status, 403);
+ assert.strictEqual(r.json().code, 'today.cross_site');
+ const same = await t.get('/api/v1/events', { as: alice, origin: 'https://openvibe.today', json: { kind: 'event', title: 'Same', starts_at: soon(), tz: 'UTC' } });
+ assert.strictEqual(same.status, 201);
+ const bearer = await t.get('/api/v1/events', { bearer: t.network.userToken(alice), origin: 'https://evil.example', json: { kind: 'event', title: 'Bearer', starts_at: soon(), tz: 'UTC' } });
+ assert.strictEqual(bearer.status, 201, 'a Bearer token is not a browser session');
+ });
+
+ await check('DELETE cancels idempotently, bumps seq once, drops reminders and writes one cancelled row', async () => {
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'To cancel', starts_at: soon(), tz: 'UTC' } });
+ const id = created.json().id;
+ assert.strictEqual((await t.get(`/api/v1/events/${id}/remind`, { as: bob, json: { minutes_before: 5 } })).status, 200);
+ const a = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(a.status, 200, a.text);
+ assert.strictEqual(a.json().status, 'cancelled');
+ assert.strictEqual(a.json().seq, 1);
+ const b = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(b.status, 200);
+ assert.strictEqual(b.json().seq, 1);
+ const cancelled = await t.ctx.s.db.many("SELECT * FROM today_outbox WHERE topic = 'today.event.cancelled' AND payload->>'event_id' = $1", [id]);
+ assert.strictEqual(cancelled.length, 1);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1', [id]), 0);
+ const get = await t.get(`/api/v1/events/${id}`);
+ assert.strictEqual(get.status, 200);
+ assert.strictEqual(get.json().status, 'cancelled');
+ });
+
+ await check('PATCH is refused once cancelled, and another owner is 404', async () => {
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Locked', starts_at: soon(), tz: 'UTC' } });
+ const id = created.json().id;
+ assert.strictEqual((await t.get(`/api/v1/events/${id}`, { as: bob, method: 'PATCH', json: { title: 'mine now' } })).status, 404);
+ await t.get(`/api/v1/events/${id}`, { as: alice, method: 'DELETE' });
+ const r = await t.get(`/api/v1/events/${id}`, { as: alice, method: 'PATCH', json: { title: 'nope' } });
+ assert.strictEqual(r.status, 409);
+ assert.strictEqual(r.json().code, 'today.locked');
+ });
+
+ await check('at most 60 scheduled future events per owner (409 today.too_many)', async () => {
+ const u = t.network.addUser('capped');
+ const at = soon();
+ const created = [];
+ for (let i = 0; i < 60; i++) {
+ const r = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: `Event ${i}`, starts_at: at, tz: 'UTC' } });
+ assert.strictEqual(r.status, 201, r.text);
+ created.push(r.json().id);
+ }
+ const over = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'One too many', starts_at: at, tz: 'UTC' } });
+ assert.strictEqual(over.status, 409);
+ assert.strictEqual(over.json().code, 'today.too_many');
+ // A cancelled event no longer counts, so the owner can schedule again.
+ await t.get(`/api/v1/events/${created[0]}`, { as: u, method: 'DELETE' });
+ const again = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'Room again', starts_at: at, tz: 'UTC' } });
+ assert.strictEqual(again.status, 201);
+ });
+
+ await check('a failed outbox insert leaves neither the event nor the outbox row', async () => {
+ const u = t.network.addUser('atomic');
+ await t.ctx.s.db.exec('DELETE FROM today_outbox');
+ await t.ctx.s.db.exec("ALTER TABLE today_outbox ADD CONSTRAINT outbox_never CHECK (topic <> 'today.event.scheduled')");
+ try {
+ const r = await t.get('/api/v1/events', { as: u, json: { kind: 'event', title: 'Atomic', starts_at: soon(), tz: 'UTC' } });
+ assert.strictEqual(r.status, 500);
+ } finally {
+ await t.ctx.s.db.exec('ALTER TABLE today_outbox DROP CONSTRAINT outbox_never');
+ }
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_events WHERE title = $1', ['Atomic']), 0);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_outbox WHERE payload->>$1 = $2', ['title', 'Atomic']), 0);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.TODAY_RATE_LIMIT_PER_MIN = process.env.TODAY_RATE_LIMIT_PER_MIN || '100000';
+process.env.TODAY_API_RATE_LIMIT_PER_MIN = process.env.TODAY_API_RATE_LIMIT_PER_MIN || '100000';
+/**
+ * Boots OpenVibe.Today on a temp database with a mock Network/Events (test/helpers/mocks.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 (today_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+
+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 env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.today', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'today', OV_OAUTH_CLIENT_SECRET: 'today-secret', COOKIE_SECURE: 'false',
+ TODAY_EVENTS_URL: network.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) => `today_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, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ built.ctx.publisher.stop();
+ built.ctx.reminders.stop();
+ built.ctx.brief.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();
+ },
+ };
+ 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
+ * TODAY_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.TODAY_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'today', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network and the parts of OpenVibe.Events the tests use, with a real RS256 key
+ * pair.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `today`, refresh_token,
+ * client_credentials); /oauth/revoke; GET /api/v1/me/follows with a person's token;
+ * POST /internal/identity/resolve-batch; POST /internal/notifications/push.
+ * Events POST /api/v1/events records each envelope once (a repeated event_id is a duplicate); failEvents(n)
+ * makes the next n calls 503 so the outbox test can watch a backoff and a retry.
+ *
+ * Every request is recorded in `.requests`, published events in `.publishedEvents`, pushed notifications in
+ * `.notifications`, and the follows graph in `.state.follows`.
+ */
+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(),
+ follows: new Map(), requests: [], tokenRequests: [], publishedEvents: [], notifications: [], eventsFail: 0,
+ 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 !== 'today' || body.client_secret !== 'today-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:today', 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>');
+ const type = url.searchParams.get('type');
+ const items = [...(st.follows.get(u.subject) || new Set())]
+ .filter(() => !type || type === 'channel')
+ .map((target) => ({ follower: u.subject, target_type: 'channel', target_id: target, notify_email: true, notify_push: true, created_at: new Date().toISOString() }));
+ return json(200, { items, next_cursor: null });
+ }
+
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ const results = {};
+ for (const sid of Array.isArray(body.subject_ids) ? body.subject_ids : []) {
+ const u = [...st.users.values()].find((x) => x.subject === sid);
+ results[sid] = u ? { subject: { type: 'user', id: u.subject }, network_user_id: u.id, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
+ }
+ return json(200, { results });
+ }
+
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ st.notifications.push(body);
+ return json(200, { ok: true, notification: { id: `ntf_${crypto.randomBytes(8).toString('hex')}`, ...body } });
+ }
+
+ if (url.pathname === '/api/v1/events' && req.method === 'POST') {
+ const auth = String(req.headers.authorization || '');
+ if (!auth.startsWith('Bearer ')) return problem(json, 401, 'token.missing', 'service token required');
+ if (st.eventsFail > 0) { st.eventsFail--; return problem(json, 503, 'events.down', 'Events is restarting'); }
+ const body = JSON.parse(raw.toString('utf8') || '{}');
+ const event = body && body.events ? body.events[0] : body;
+ const seen = st.publishedEvents.find((e) => e.event_id === event.event_id);
+ if (seen) return json(200, { event_id: event.event_id, cursor: seen.cursor, duplicate: true });
+ const cursor = `cur_${crypto.randomBytes(8).toString('hex')}`;
+ st.publishedEvents.push({ ...event, cursor });
+ return json(201, { event_id: event.event_id, cursor, duplicate: false });
+ }
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ publishedEvents: st.publishedEvents, notifications: st.notifications,
+ 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; },
+ setFollow(user, targetSubject, active = true) {
+ if (!st.follows.has(user.subject)) st.follows.set(user.subject, new Set());
+ if (active) st.follows.get(user.subject).add(targetSubject); else st.follows.get(user.subject).delete(targetSubject);
+ },
+ setDown(v) { st.down = v; },
+ failEvents(n = 1) { st.eventsFail = n; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * RFC 5545: CRLF, 75-octet folding with multibyte text, TEXT escaping, UID/SEQUENCE/STATUS, a cancelled event as
+ * CANCELLED for 7 days then gone, a private token feed that rotation invalidates at once, and a channel's public
+ * calendar.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const CRLF = '\r\n';
+function unfold(text) {
+ return String(text).replace(/\r\n[ \t]/g, '');
+}
+function lines(text) { return String(text).split(CRLF); }
+
+(async () => {
+ let clock = Date.now();
+ const t = await boot({ now: () => clock });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const starts = () => new Date(clock + 24 * 60 * 60_000).toISOString();
+
+ let eventId = null;
+ await check('channel calendar: CRLF, folding ≤ 75 octets, escaping, UID/SEQUENCE/STATUS', async () => {
+ const title = `A, B; C\\D — ${'é'.repeat(80)}`;
+ const description = `line one\nline two, with; punctuation\\`;
+ const r = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title, description, starts_at: starts(), tz: 'Europe/Berlin' } });
+ assert.strictEqual(r.status, 201, r.text);
+ eventId = r.json().id;
+ const ics = await t.get('/ics/@alice.ics');
+ assert.strictEqual(ics.status, 200);
+ assert.strictEqual(ics.headers.get('content-type'), 'text/calendar; charset=utf-8');
+ assert.strictEqual(ics.headers.get('cache-control'), 'public, max-age=300');
+ assert.ok(!/[^\r]\n/.test(ics.text), 'every line ends CRLF');
+ assert.ok(ics.text.endsWith(CRLF));
+ for (const line of lines(ics.text)) assert.ok(Buffer.byteLength(line, 'utf8') <= 75, `folded line ${Buffer.byteLength(line, 'utf8')} octets: ${line.slice(0, 20)}`);
+ const body = unfold(ics.text);
+ for (const line of ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//OpenVibe//Today//EN', 'CALSCALE:GREGORIAN', 'X-WR-CALNAME:OpenVibe.Today — @alice']) assert.ok(body.includes(line), line);
+ assert.ok(body.includes(`UID:${eventId}@openvibe.today`));
+ assert.match(body, /DTSTAMP:\d{8}T\d{6}Z/);
+ assert.match(body, /DTSTART:\d{8}T\d{6}Z/);
+ assert.match(body, /DTEND:\d{8}T\d{6}Z/);
+ assert.ok(body.includes('SEQUENCE:0'));
+ assert.ok(body.includes('STATUS:CONFIRMED'));
+ assert.match(body, /LAST-MODIFIED:\d{8}T\d{6}Z/);
+ // TEXT escaping: comma, semicolon, backslash and the newline, with the multibyte title intact.
+ assert.ok(body.includes('SUMMARY:A\\, B\\; C\\\\D — ' + 'é'.repeat(80)), 'the title is escaped and intact');
+ assert.ok(body.includes('DESCRIPTION:line one\\nline two\\, with\\; punctuation\\\\'), 'the description is escaped');
+ // No end given: DTEND is one hour after DTSTART.
+ const dtstart = /DTSTART:(\d{8}T\d{6}Z)/.exec(body)[1];
+ const dtend = /DTEND:(\d{8}T\d{6}Z)/.exec(body)[1];
+ const parse = (s) => Date.UTC(+s.slice(0, 4), +s.slice(4, 6) - 1, +s.slice(6, 8), +s.slice(9, 11), +s.slice(11, 13), +s.slice(13, 15));
+ assert.strictEqual(parse(dtend) - parse(dtstart), 60 * 60_000);
+ });
+
+ await check('a patch raises SEQUENCE in every feed', async () => {
+ const r = await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'PATCH', json: { title: 'Changed' } });
+ assert.strictEqual(r.status, 200);
+ const ics = await t.get('/ics/@alice.ics');
+ assert.ok(unfold(ics.text).includes('SEQUENCE:1'));
+ assert.strictEqual((await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'PATCH', json: { title: 'Changed again' } })).json().seq, 2);
+ });
+
+ await check('a cancelled event is STATUS:CANCELLED for 7 days, then dropped', async () => {
+ const r = await t.get(`/api/v1/events/${eventId}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(r.status, 200);
+ let ics = await t.get('/ics/@alice.ics');
+ assert.ok(unfold(ics.text).includes('STATUS:CANCELLED'), 'still in the feed to remove it');
+ assert.ok(unfold(ics.text).includes('SEQUENCE:3'));
+ clock += 8 * 24 * 60 * 60_000;
+ ics = await t.get('/ics/@alice.ics');
+ assert.ok(!unfold(ics.text).includes(eventId), 'dropped after 7 days');
+ assert.ok(!unfold(ics.text).includes('STATUS:CANCELLED'));
+ });
+
+ await check('the private token feed is the owner\'s, and rotation makes the old URL 404 at once', async () => {
+ clock = Date.now();
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Private agenda item', starts_at: starts(), tz: 'UTC' } });
+ assert.strictEqual(created.status, 201);
+ const f = await t.get('/api/v1/me/feed', { as: alice });
+ assert.strictEqual(f.status, 200, f.text);
+ const path = new URL(f.json().url).pathname;
+ const ics = await t.get(path);
+ assert.strictEqual(ics.status, 200);
+ assert.strictEqual(ics.headers.get('cache-control'), 'private, max-age=300');
+ assert.ok(unfold(ics.text).includes('Private agenda item'));
+ const rotated = await t.get('/api/v1/me/feed/rotate', { as: alice, method: 'POST' });
+ assert.strictEqual(rotated.status, 200);
+ assert.notStrictEqual(rotated.json().url, f.json().url);
+ assert.strictEqual((await t.get(path)).status, 404, 'the old URL is gone');
+ const fresh = await t.get(new URL(rotated.json().url).pathname);
+ assert.strictEqual(fresh.status, 200);
+ assert.ok(unfold(fresh.text).includes('Private agenda item'));
+ // Someone else's token feed is not this person's.
+ const bf = await t.get('/api/v1/me/feed', { as: bob });
+ assert.notStrictEqual(new URL(bf.json().url).pathname, new URL(rotated.json().url).pathname);
+ });
+
+ await check('the private feed includes followed channels once Today has read the person\'s follows', async () => {
+ const carol = t.network.addUser('carol');
+ t.network.setFollow(alice, carol.subject);
+ t.ctx.follows.clear(alice.subject); // a follow made after the 60 s cache was read
+ const current = await t.get('/api/v1/me/feed', { as: alice });
+ const path = new URL(current.json().url).pathname;
+ const made = await t.get('/api/v1/events', { as: carol, json: { kind: 'stream', title: 'Followed channel stream', starts_at: starts(), tz: 'UTC' } });
+ assert.strictEqual(made.status, 201);
+ const ics = await t.get(path);
+ assert.strictEqual(ics.status, 200);
+ assert.ok(unfold(ics.text).includes('Followed channel stream'));
+ });
+
+ await check('the channel feed carries public events only', async () => {
+ const hidden = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Followers only', starts_at: starts(), tz: 'UTC', visibility: 'followers' } });
+ assert.strictEqual(hidden.status, 201);
+ const ics = await t.get('/ics/@alice.ics');
+ assert.ok(unfold(ics.text).includes('Private agenda item'), 'the public one is there');
+ assert.ok(!unfold(ics.text).includes('Followers only'), 'the followers-only one is not');
+ assert.strictEqual((await t.get('/ics/@nobody.ics')).status, 200);
+ assert.strictEqual((await t.get('/ics/@..bad.ics')).status, 404);
+ assert.strictEqual((await t.get('/ics/no-such-token.ics')).status, 404);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+/**
+ * The outbox publisher: each row is sent once under the evt_ id fixed when it was written, a failure records the
+ * attempt and backs off, a later retry publishes the same fixed id, and a new publisher over the same rows does not
+ * duplicate anything.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { createPublisher } = require('../server/events/publisher');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const at = new Date(Date.now() + 60 * 60_000).toISOString();
+ const make = async (title) => (await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title, starts_at: at, tz: 'UTC' } })).json();
+
+ await check('each row is published once, with its fixed evt_ id and the payload Today defined', async () => {
+ const a = await make('First');
+ const b = await make('Second');
+ const rows = await t.ctx.s.db.many('SELECT * FROM today_outbox ORDER BY created_at');
+ assert.strictEqual(rows.length, 2);
+ for (const row of rows) assert.match(row.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
+ const r = await t.ctx.publisher.runOnce();
+ assert.deepStrictEqual(r, { configured: true, claimed: 2, published: 2, failed: 0 });
+ const token = t.network.tokenRequests.find((x) => x.audience === 'openvibe.events');
+ assert.ok(token, 'an Events token was requested');
+ assert.strictEqual(token.scope, 'events.event.publish', 'OpenVibe.Events\' publish capability');
+ assert.strictEqual(t.network.publishedEvents.length, 2);
+ const ids = new Set(t.network.publishedEvents.map((e) => e.event_id));
+ for (const row of rows) assert.ok(ids.has(row.event_id));
+ const first = t.network.publishedEvents.find((e) => e.payload.event_id === a.id);
+ assert.strictEqual(first.event_type, 'today.event.scheduled');
+ assert.strictEqual(first.source, 'today');
+ assert.deepStrictEqual(first.subject, { type: 'user', id: alice.subject });
+ assert.deepStrictEqual(Object.keys(first.payload).sort(), ['channel', 'ends_at', 'event_id', 'kind', 'seq', 'starts_at', 'status', 'title', 'tz', 'url', 'visibility']);
+ assert.strictEqual(first.payload.title, 'First');
+ assert.strictEqual(first.payload.channel, 'alice');
+ const again = await t.ctx.publisher.runOnce();
+ assert.strictEqual(again.claimed, 0);
+ assert.strictEqual(t.network.publishedEvents.length, 2, 'nothing sent twice');
+ assert.ok(b.id);
+ });
+
+ await check('a failure records attempts/last_error, backs off, then retries the same id', async () => {
+ const e = await make('Retry me');
+ t.network.failEvents(1);
+ const failed = await t.ctx.publisher.runOnce();
+ assert.strictEqual(failed.failed, 1);
+ let row = await t.ctx.s.db.maybe("SELECT * FROM today_outbox WHERE payload->>'event_id' = $1", [e.id]);
+ assert.strictEqual(row.attempts, 1);
+ assert.match(row.last_error, /events 503/);
+ assert.strictEqual(row.published_at, null);
+ assert.ok(new Date(row.next_attempt_at).getTime() > Date.now(), 'backed off into the future');
+ assert.strictEqual((await t.ctx.publisher.runOnce()).claimed, 0, 'not due yet');
+ await t.ctx.s.db.exec("UPDATE today_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);
+ const retried = await t.ctx.publisher.runOnce();
+ assert.deepStrictEqual(retried, { configured: true, claimed: 1, published: 1, failed: 0 });
+ row = await t.ctx.s.db.maybe('SELECT * FROM today_outbox WHERE event_id = $1', [row.event_id]);
+ assert.ok(row.published_at);
+ assert.strictEqual(row.attempts, 1, 'the attempt that failed is recorded');
+ const event = t.network.publishedEvents.find((x) => x.event_id === row.event_id);
+ assert.strictEqual(event.payload.event_id, e.id);
+ });
+
+ await check('a new publisher over the same rows does not duplicate', async () => {
+ const before = t.network.publishedEvents.length;
+ const restarted = createPublisher({ config: t.config, s: t.ctx.s, fetchImpl: globalThis.fetch, log: { warn() {}, log() {}, error() {} } });
+ assert.strictEqual((await restarted.runOnce()).claimed, 0);
+ assert.strictEqual(t.network.publishedEvents.length, before);
+ assert.strictEqual(await t.ctx.publisher.pending(), 0);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+'use strict';
+/**
+ * Server-rendered pages: the public schedule, a channel, one event with Event JSON-LD, the signed-in agenda, brief,
+ * the plain schedule form (POST → /e/:id), developers, 404, cache headers and content-hashed assets.
+ */
+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 bob = t.network.addUser('bob');
+ const at = new Date(Date.now() + 2 * 60 * 60_000).toISOString();
+ const created = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Public stream', description: 'A description', starts_at: at, ends_at: new Date(Date.now() + 3 * 60 * 60_000).toISOString(), tz: 'Europe/Berlin' } });
+ assert.strictEqual(created.status, 201);
+ const event = created.json();
+
+ await check('home: semantic, no-JavaScript navigation, the upcoming event, 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="td-home"', 'id="td-upcoming"', 'id="main"', '<noscript><nav', '<footer id="ov-footer"', 'Public stream']) 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('a channel page lists its public events and links the ICS feed', async () => {
+ const r = await t.get('/@alice');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ assert.ok(r.text.includes('id="td-channel"'));
+ assert.ok(r.text.includes('Public stream'));
+ assert.ok(r.text.includes('/ics/@alice.ics'));
+ assert.strictEqual((await t.get('/@..bad')).status, 404);
+ });
+
+ await check('one event page carries schema.org Event JSON-LD', async () => {
+ const r = await t.get(`/e/${event.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ const ev = jsonLd(r.text).find((x) => x['@type'] === 'Event');
+ assert.ok(ev, 'Event JSON-LD missing');
+ assert.strictEqual(ev.name, 'Public stream');
+ assert.strictEqual(ev.startDate, event.starts_at);
+ assert.strictEqual(ev.endDate, event.ends_at);
+ assert.strictEqual(ev.eventStatus, 'https://schema.org/EventScheduled');
+ assert.strictEqual(ev.eventAttendanceMode, 'https://schema.org/OnlineEventAttendanceMode');
+ assert.strictEqual(ev.location['@type'], 'VirtualLocation');
+ assert.strictEqual(ev.location.url, 'https://openvibe.live/@alice');
+ assert.ok(ev.organizer);
+ assert.strictEqual((await t.get('/e/tev_DOESNOTEXIST0000000')).status, 404);
+ });
+
+ await check('a cancelled event answers with EventCancelled, and a followers-only event is private', async () => {
+ const hidden = await t.get('/api/v1/events', { as: alice, json: { kind: 'event', title: 'Hidden', starts_at: at, tz: 'UTC', visibility: 'followers' } });
+ const hid = hidden.json();
+ assert.strictEqual((await t.get(`/e/${hid.id}`)).status, 404);
+ assert.strictEqual((await t.get(`/e/${hid.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/e/${hid.id}`, { as: alice })).status, 200);
+ const cancelled = await t.get(`/api/v1/events/${event.id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(cancelled.status, 200);
+ const r = await t.get(`/e/${event.id}`);
+ const ev = jsonLd(r.text).find((x) => x['@type'] === 'Event');
+ assert.strictEqual(ev.eventStatus, 'https://schema.org/EventCancelled');
+ });
+
+ await check('/me is 401 signed out and shows the agenda, reminders and feed URL signed in', async () => {
+ assert.strictEqual((await t.get('/me')).status, 401);
+ const agendaItem = await t.get('/api/v1/events', { as: alice, json: { kind: 'stream', title: 'Agenda item', starts_at: new Date(Date.now() + 4 * 60 * 60_000).toISOString(), tz: 'Europe/Berlin' } });
+ assert.strictEqual(agendaItem.status, 201);
+ const r = await t.get('/me', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ assert.ok(r.text.includes('id="td-me"'));
+ assert.ok(r.text.includes('Agenda item'));
+ assert.match(r.text, /\/ics\/[A-Za-z0-9_-]{43}\.ics/);
+ assert.ok(r.text.includes('action="/me/feed/rotate"'));
+ const rotated = await t.get('/me/feed/rotate', { as: alice, method: 'POST', origin: 'https://openvibe.today' });
+ assert.strictEqual(rotated.status, 303);
+ assert.strictEqual(rotated.headers.get('location'), '/me');
+ const cross = await t.get('/me/feed/rotate', { as: alice, method: 'POST', origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ });
+
+ await check('/brief is 401 signed out and renders sections signed in', async () => {
+ assert.strictEqual((await t.get('/brief')).status, 401);
+ const r = await t.get('/brief', { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.ok(r.text.includes('id="td-brief"'));
+ assert.ok(r.text.includes('Later today'));
+ });
+
+ await check('/schedule: signed out 401; the plain form POSTs and redirects to /e/:id', async () => {
+ assert.strictEqual((await t.get('/schedule')).status, 401);
+ const form = await t.get('/schedule', { as: alice });
+ assert.strictEqual(form.status, 200);
+ assert.ok(form.text.includes('id="td-schedule-form"'));
+ assert.ok(form.text.includes('method="post" action="/schedule"'));
+ const local = new Date(Date.now() + 3 * 60 * 60_000).toISOString().slice(0, 16);
+ const r = await t.get('/schedule', { as: alice, form: { kind: 'stream', title: 'From the form', starts_at: local, tz: 'Europe/Berlin', description: 'Form text', visibility: 'public' } });
+ assert.strictEqual(r.status, 303, r.text.slice(0, 300));
+ assert.match(r.headers.get('location'), /^\/e\/tev_/);
+ const page = await t.get(r.headers.get('location'));
+ assert.strictEqual(page.status, 200);
+ assert.ok(page.text.includes('From the form'));
+ const cross = await t.get('/schedule', { as: alice, origin: 'https://evil.example', form: { kind: 'event', title: 'Cross', starts_at: local, tz: 'UTC' } });
+ assert.strictEqual(cross.status, 403);
+ const bad = await t.get('/schedule', { as: alice, form: { kind: 'event', title: '', starts_at: local, tz: 'UTC' } });
+ assert.strictEqual(bad.status, 400);
+ assert.ok(bad.text.includes('id="td-schedule-form"'));
+ });
+
+ await check('/developers and the 404 page', 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="td-developers"'));
+ assert.ok(dev.text.includes('/api/v1/events'));
+ 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('Not on the calendar'));
+ });
+
+ await check('assets are content-hashed and served with the shared cache policy', async () => {
+ const v = assetVersion('css/today.css');
+ const hashed = await t.get(`/css/today.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/today.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/today.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -0,0 +1,94 @@
+'use strict';
+/**
+ * Due reminders: exactly one Network notification each, two concurrent runs never double-send, and a reminder on a
+ * cancelled event never sends. The row is claimed with UPDATE … WHERE sent_at IS NULL RETURNING before the push.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const at = new Date(Date.now() + 10 * 60_000).toISOString();
+
+ async function eventAndReminder({ title = 'Soon', kind = 'stream', user = alice, minutes = 10 } = {}) {
+ const e = await t.get('/api/v1/events', { as: user, json: { kind, title, starts_at: at, tz: 'Europe/Berlin' } });
+ assert.strictEqual(e.status, 201, e.text);
+ const r = await t.get(`/api/v1/events/${e.json().id}/remind`, { as: bob, json: { minutes_before: minutes } });
+ assert.strictEqual(r.status, 200, r.text);
+ await t.ctx.s.db.exec("UPDATE today_reminders SET remind_at = now() - interval '1 minute' WHERE event_id = $1", [e.json().id]);
+ return e.json();
+ }
+
+ await check('a due reminder sends one notification with the Today fields', async () => {
+ const e = await eventAndReminder({ title: 'Morning show' });
+ const r = await t.ctx.reminders.runOnce();
+ assert.deepStrictEqual({ due: r.due, sent: r.sent, failed: r.failed }, { due: 1, sent: 1, failed: 0 });
+ assert.strictEqual(t.network.notifications.length, 1);
+ const n = t.network.notifications[0];
+ assert.strictEqual(n.type, 'TODAY_REMINDER');
+ assert.strictEqual(n.category, 'stream');
+ assert.strictEqual(n.service, 'today');
+ assert.match(n.title, /^Morning show starts in \d+ min$/);
+ assert.strictEqual(n.url, `https://openvibe.today/e/${e.id}`);
+ assert.strictEqual(n.user_id, bob.id);
+ assert.ok(await t.ctx.s.db.value('SELECT sent_at IS NOT NULL FROM today_reminders WHERE event_id = $1 AND subject = $2', [e.id, bob.subject]));
+ });
+
+ await check('two concurrent runs never double-send', async () => {
+ const before = t.network.notifications.length;
+ const e = await eventAndReminder({ title: 'Concurrent' });
+ const [a, b] = await Promise.all([t.ctx.reminders.runOnce(), t.ctx.reminders.runOnce()]);
+ assert.strictEqual(a.sent + b.sent, 1, `one send, got ${a.sent} + ${b.sent}`);
+ assert.strictEqual(t.network.notifications.length, before + 1);
+ assert.strictEqual(t.network.notifications.filter((x) => x.title.startsWith('Concurrent')).length, 1);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM today_reminders WHERE event_id = $1 AND sent_at IS NOT NULL', [e.id]), 1);
+ });
+
+ await check('an event that is live sends "is live now"', async () => {
+ const e = await eventAndReminder({ title: 'Live now', kind: 'event' });
+ await t.ctx.s.db.exec("UPDATE today_events SET status = 'live' WHERE id = $1", [e.id]);
+ await t.ctx.reminders.runOnce();
+ const n = t.network.notifications.find((x) => x.title.startsWith('Live now'));
+ assert.ok(n);
+ assert.strictEqual(n.title, 'Live now is live now');
+ assert.strictEqual(n.category, 'system');
+ });
+
+ await check('a cancelled event\'s reminder never sends, even if a row survives a race', async () => {
+ const before = t.network.notifications.length;
+ const e = await eventAndReminder({ title: 'Cancelled' });
+ await t.get(`/api/v1/events/${e.id}`, { as: alice, method: 'DELETE' });
+ // A reminder row that raced the cancellation: insert it by hand to prove the claim checks the event status.
+ await t.ctx.s.db.exec(
+ `INSERT INTO today_reminders (subject, event_id, remind_at, created_at) VALUES ($1, $2, now() - interval '1 minute', now())
+ ON CONFLICT (subject, event_id) DO UPDATE SET remind_at = EXCLUDED.remind_at, sent_at = NULL`,
+ [bob.subject, e.id],
+ );
+ const r = await t.ctx.reminders.runOnce();
+ assert.strictEqual(r.due, 0, 'a cancelled event is not due');
+ assert.strictEqual(t.network.notifications.length, before);
+ });
+
+ await check('a failed push releases the claim so the next run retries', async () => {
+ const e = await eventAndReminder({ title: 'Retry push' });
+ const orig = t.network.notifications.length;
+ const { createReminders } = require('../server/reminders');
+ const failing = createReminders({
+ config: t.config, s: t.ctx.s, events: t.ctx.events,
+ notifier: { sendReminder: async () => { throw new Error('push down'); } },
+ log: { warn() {} },
+ });
+ const first = await failing.runOnce();
+ assert.strictEqual(first.failed, 1);
+ assert.strictEqual(await t.ctx.s.db.value('SELECT sent_at IS NULL FROM today_reminders WHERE event_id = $1 AND subject = $2', [e.id, bob.subject]), true);
+ const retry = await t.ctx.reminders.runOnce();
+ assert.strictEqual(retry.sent, 1);
+ assert.strictEqual(t.network.notifications.length, orig + 1);
+ assert.strictEqual(t.network.notifications.at(-1).title.startsWith('Retry push'), true);
+ });
+
+ 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 and an
+ * in-process mock of OpenVibe.Network; none needs the network or a running site.
+ *
+ * npm test # everything
+ * npm test -- events # 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: 60000, 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,149 @@
@@ -0,0 +1,149 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id today, cookies today_at/today_rt/today_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', async () => {
+ const r = await t.get('/auth/login?next=/brief');
+ 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'), 'today');
+ 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'), /today_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=/brief');
+ 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'), '/brief');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /today_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /today_rt=[^;]+;.*HttpOnly/i);
+ const at = set.match(/today_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `today_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(/today_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://openvibe.today' };
+ assert.strictEqual(sanitizeNext('/me?tab=1', config), '/me?tab=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.today/e/x?y=1', config), '/e/x?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.today.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, 'today');
+ 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 ['/', '/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: 'today' });
+ 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: `today_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `today_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: `today_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: 'today_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'today_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(!/today_at=;|today_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=/brief', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/brief');
+ assert.match(String(same.headers.get('set-cookie') || ''), /today_at=;/);
+ });
+
+ await t.close();
+ done();
+})();