openvibers/openvibe.cam Commits
OpenVibe.Cam v1
Changes
nnew file mode 100644@@@ -0,0 +1,54 @@++# OpenVibe.Cam — environment (production: /etc/openvibe/cam.env). Only names are committed; every value is set on++# the host. A secret is never printed, logged or committed.++++# ── The service ──++NODE_ENV=production++PORT=5160++HOST=127.0.0.1++BASE_URL=https://openvibe.cam++TRUST_PROXY=2++CAM_DRILL=0++++# ── PostgreSQL (ADR-035) and Valkey ──++DATABASE_URL=postgres://cam:[email protected]:6432/cam++DATABASE_DIRECT_URL=postgres://cam_owner:[email protected]:5432/cam++# CAM_PGLITE_DIR=/opt/openvibe.cam/data/pglite++# VALKEY_URL=redis://127.0.0.1:6379++VALKEY_PREFIX=ov:cam:++++# ── OpenVibe.Network: SSO (OAuth client cam, PKCE S256) and JWKS ──++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=cam++OV_OAUTH_CLIENT_SECRET=...++OV_OAUTH_REDIRECT_URI=https://openvibe.cam/auth/callback++OV_SESSION_AUDIENCE=openvibe.network++CAM_AUDIENCE=openvibe.cam++COOKIE_SECURE=true++++# ── OpenVibe.Events → /internal/events (account export/deletion) ──++CAM_EVENTS_SECRET=...++CAM_EVENTS_URL=https://openvibe.events++CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/events++CAM_EVENTS_INTERVAL_MS=5000++CAM_EVENTS_SCOPE=events.subscription.manage++++# ── The existing public APIs Cam uses ──++OV_MEDIA_URL=https://openvibe.media++OV_LIVE_URL=https://openvibe.live++OV_PICS_URL=https://openvibe.pics++CAM_UPSTREAM_TIMEOUT_MS=8000++++# ── Photos, stills, cameras and grants ──++CAM_PHOTO_MAX_BYTES=12582912++CAM_STILL_MAX_BYTES=8388608++CAM_STILL_WIDTH=640++CAM_MAX_CAMERAS=20++CAM_GRANT_DAYS=7++CAM_GRANT_MAX_DAYS=90++CAM_GRANTS_PER_CAMERA=50++CAM_LIMITS_MINUTE=120++CAM_LIMITS_HOUR=3000++# CAM_API_RATE_LIMIT_PER_MIN=240++# CAM_RATE_LIMIT_PER_MIN=300nnew file mode 100644@@@ -0,0 +1,17 @@++name: ci++on:++ push:++ branches: [main]++ pull_request:++jobs:++ # install, node --check and npm test: the shared test job (Track Q).++ test:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176++ with:++ syntax-dirs: server test scripts++ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk++ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.++ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'++ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).++ security:++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176nnew file mode 100644@@@ -0,0 +1,10 @@++node_modules/++/data/++dist/++.env++.env.*++!.env.example++.npm-cache/++*.log++*.pem++*.keynnew file mode 100644@@@ -0,0 +1,37 @@++# Changelog++++What changed in OpenVibe.Cam, newest first.++++## 0.1.0 — 2026-10-28++++The first version: the camera app, my cameras and revocable viewer grants, and the API Live's cam button calls.++++- **The camera app.** `/app` runs a live preview in the browser; the timer, the 3-shot burst, the grid and the++ colour/light/softness filter are all on the device. The picture is posted as raw bytes to `POST /api/v1/photos`,++ forwarded to OpenVibe.Pics with the person's own Network token, and recorded as a Pics slug, page link, direct++ link, filter name and caption. No image bytes, no face template and no classification are stored here.++- **My cameras.** `cam_cameras` pairs a phone, a browser, a Pi or an IP camera. A Live stream id is read from++ OpenVibe.Live's public API and must be on the owner's channel; the slot defaults to the stream's managed slot.++ The owner can rename it, set the slot and status, and unpair it. `/my/cameras/:id` is the owner's page.++- **Viewer grants.** `cam_grants` names one canonical viewer and an expiry (7 days by default, 90 at most), with a++ partial unique index for one unrevoked grant per camera and viewer. `@username` is resolved through++ OpenVibe.Network with `identity.subject.resolve`; a subject id works without Network. A re-grant extends the row;++ a revocation writes `revoked_at` and the wall checks it on every read, so the next request is refused.++- **The private wall.** `/w/:id` and `GET /api/v1/cameras/:id/wall` answer the owner and an active grantee, and 404++ to everyone else. Staff have no bypass. A grantee never sees the owner's note. An attached photo is unlisted and++ appears on the wall; a photo without a camera is private.++- **The Live cam button.** `POST /api/v1/stills` reads OpenVibe.Media's public `GET /live/:slot/frame.jpg`, accepts++ only a JPEG (never an offline SVG card or HTML), uploads it to OpenVibe.Pics as the viewer, records the still and++ returns `share.text`, the exact line Live posts in the stream's chat. A still is unlisted by default and private++ on request.++- **The service.** Network sign-in as OAuth client `cam` (PKCE S256, `cam_at`/`cam_rt`/`cam_oauth`), truthful++ readiness, Prometheus metrics on loopback, discovery, deploy files, and server-rendered pages with stable `cm-`++ ids. `public/css/cam.css` is a one-comment placeholder for the main session.++- **Account data.** Export and deletion cover cameras, grants given and received, photos and stills; a deleted++ owner's camera and its grants go, a deleted viewer's grants go and the owner's camera stays. Pictures themselves++ are OpenVibe.Pics' and are erased on the same Network event.++- **The first proof.** `scripts/seed-proof.js` pairs a test camera, grants a viewer, opens the wall, revokes the++ grant and proves the next wall request is refused.++- **Tests.** Migration and constraints, pairing and stream ownership, grants and their refusals, the still path,++ the photo upload, pages and cache headers, SSO, and account export/deletion, each in its own process on a++ temporary PGlite database with a mock Network and a Media/Live/Pics fixture server.nnew file mode 100644@@@ -0,0 +1,661 @@++ GNU AFFERO GENERAL PUBLIC LICENSE++ Version 3, 19 November 2007++++ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>++ Everyone is permitted to copy and distribute verbatim copies++ of this license document, but changing it is not allowed.++++ Preamble++++ The GNU Affero General Public License is a free, copyleft license for++software and other kinds of works, specifically designed to ensure++cooperation with the community in the case of network server software.++++ The licenses for most software and other practical works are designed++to take away your freedom to share and change the works. By contrast,++our General Public Licenses are intended to guarantee your freedom to++share and change all versions of a program--to make sure it remains free++software for all its users.++++ When we speak of free software, we are referring to freedom, not++price. Our General Public Licenses are designed to make sure that you++have the freedom to distribute copies of free software (and charge for++them if you wish), that you receive source code or can get it if you++want it, that you can change the software or use pieces of it in new++free programs, and that you know you can do these things.++++ Developers that use our General Public Licenses protect your rights++with two steps: (1) assert copyright on the software, and (2) offer++you this License which gives you legal permission to copy, distribute++and/or modify the software.++++ A secondary benefit of defending all users' freedom is that++improvements made in alternate versions of the program, if they++receive widespread use, become available for other developers to++incorporate. Many developers of free software are heartened and++encouraged by the resulting cooperation. However, in the case of++software used on network servers, this result may fail to come about.++The GNU General Public License permits making a modified version and++letting the public access it on a server without ever releasing its++source code to the public.++++ The GNU Affero General Public License is designed specifically to++ensure that, in such cases, the modified source code becomes available++to the community. It requires the operator of a network server to++provide the source code of the modified version running there to the++users of that server. Therefore, public use of a modified version, on++a publicly accessible server, gives the public access to the source++code of the modified version.++++ An older license, called the Affero General Public License and++published by Affero, was designed to accomplish similar goals. This is++a different license, not a version of the Affero GPL, but Affero has++released a new version of the Affero GPL which permits relicensing under++this license.++++ The precise terms and conditions for copying, distribution and++modification follow.++++ TERMS AND CONDITIONS++++ 0. Definitions.++++ "This License" refers to version 3 of the GNU Affero General Public License.++++ "Copyright" also means copyright-like laws that apply to other kinds of++works, such as semiconductor masks.++++ "The Program" refers to any copyrightable work licensed under this++License. Each licensee is addressed as "you". "Licensees" and++"recipients" may be individuals or organizations.++++ To "modify" a work means to copy from or adapt all or part of the work++in a fashion requiring copyright permission, other than the making of an++exact copy. The resulting work is called a "modified version" of the++earlier work or a work "based on" the earlier work.++++ A "covered work" means either the unmodified Program or a work based++on the Program.++++ To "propagate" a work means to do anything with it that, without++permission, would make you directly or secondarily liable for++infringement under applicable copyright law, except executing it on a++computer or modifying a private copy. Propagation includes copying,++distribution (with or without modification), making available to the++public, and in some countries other activities as well.++++ To "convey" a work means any kind of propagation that enables other++parties to make or receive copies. Mere interaction with a user through++a computer network, with no transfer of a copy, is not conveying.++++ An interactive user interface displays "Appropriate Legal Notices"++to the extent that it includes a convenient and prominently visible++feature that (1) displays an appropriate copyright notice, and (2)++tells the user that there is no warranty for the work (except to the++extent that warranties are provided), that licensees may convey the++work under this License, and how to view a copy of this License. If++the interface presents a list of user commands or options, such as a++menu, a prominent item in the list meets this criterion.++++ 1. Source Code.++++ The "source code" for a work means the preferred form of the work++for making modifications to it. "Object code" means any non-source++form of a work.++++ A "Standard Interface" means an interface that either is an official++standard defined by a recognized standards body, or, in the case of++interfaces specified for a particular programming language, one that++is widely used among developers working in that language.++++ The "System Libraries" of an executable work include anything, other++than the work as a whole, that (a) is included in the normal form of++packaging a Major Component, but which is not part of that Major++Component, and (b) serves only to enable use of the work with that++Major Component, or to implement a Standard Interface for which an++implementation is available to the public in source code form. A++"Major Component", in this context, means a major essential component++(kernel, window system, and so on) of the specific operating system++(if any) on which the executable work runs, or a compiler used to++produce the work, or an object code interpreter used to run it.++++ The "Corresponding Source" for a work in object code form means all++the source code needed to generate, install, and (for an executable++work) run the object code and to modify the work, including scripts to++control those activities. However, it does not include the work's++System Libraries, or general-purpose tools or generally available free++programs which are used unmodified in performing those activities but++which are not part of the work. For example, Corresponding Source++includes interface definition files associated with source files for++the work, and the source code for shared libraries and dynamically++linked subprograms that the work is specifically designed to require,++such as by intimate data communication or control flow between those++subprograms and other parts of the work.++++ The Corresponding Source need not include anything that users++can regenerate automatically from other parts of the Corresponding++Source.++++ The Corresponding Source for a work in source code form is that++same work.++++ 2. Basic Permissions.++++ All rights granted under this License are granted for the term of++copyright on the Program, and are irrevocable provided the stated++conditions are met. This License explicitly affirms your unlimited++permission to run the unmodified Program. The output from running a++covered work is covered by this License only if the output, given its++content, constitutes a covered work. This License acknowledges your++rights of fair use or other equivalent, as provided by copyright law.++++ You may make, run and propagate covered works that you do not++convey, without conditions so long as your license otherwise remains++in force. You may convey covered works to others for the sole purpose++of having them make modifications exclusively for you, or provide you++with facilities for running those works, provided that you comply with++the terms of this License in conveying all material for which you do++not control copyright. Those thus making or running the covered works++for you must do so exclusively on your behalf, under your direction++and control, on terms that prohibit them from making any copies of++your copyrighted material outside their relationship with you.++++ Conveying under any other circumstances is permitted solely under++the conditions stated below. Sublicensing is not allowed; section 10++makes it unnecessary.++++ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.++++ No covered work shall be deemed part of an effective technological++measure under any applicable law fulfilling obligations under article++11 of the WIPO copyright treaty adopted on 20 December 1996, or++similar laws prohibiting or restricting circumvention of such++measures.++++ When you convey a covered work, you waive any legal power to forbid++circumvention of technological measures to the extent such circumvention++is effected by exercising rights under this License with respect to++the covered work, and you disclaim any intention to limit operation or++modification of the work as a means of enforcing, against the work's++users, your or third parties' legal rights to forbid circumvention of++technological measures.++++ 4. Conveying Verbatim Copies.++++ You may convey verbatim copies of the Program's source code as you++receive it, in any medium, provided that you conspicuously and++appropriately publish on each copy an appropriate copyright notice;++keep intact all notices stating that this License and any++non-permissive terms added in accord with section 7 apply to the code;++keep intact all notices of the absence of any warranty; and give all++recipients a copy of this License along with the Program.++++ You may charge any price or no price for each copy that you convey,++and you may offer support or warranty protection for a fee.++++ 5. Conveying Modified Source Versions.++++ You may convey a work based on the Program, or the modifications to++produce it from the Program, in the form of source code under the++terms of section 4, provided that you also meet all of these conditions:++++ a) The work must carry prominent notices stating that you modified++ it, and giving a relevant date.++++ b) The work must carry prominent notices stating that it is++ released under this License and any conditions added under section++ 7. This requirement modifies the requirement in section 4 to++ "keep intact all notices".++++ c) You must license the entire work, as a whole, under this++ License to anyone who comes into possession of a copy. This++ License will therefore apply, along with any applicable section 7++ additional terms, to the whole of the work, and all its parts,++ regardless of how they are packaged. This License gives no++ permission to license the work in any other way, but it does not++ invalidate such permission if you have separately received it.++++ d) If the work has interactive user interfaces, each must display++ Appropriate Legal Notices; however, if the Program has interactive++ interfaces that do not display Appropriate Legal Notices, your++ work need not make them do so.++++ A compilation of a covered work with other separate and independent++works, which are not by their nature extensions of the covered work,++and which are not combined with it such as to form a larger program,++in or on a volume of a storage or distribution medium, is called an++"aggregate" if the compilation and its resulting copyright are not++used to limit the access or legal rights of the compilation's users++beyond what the individual works permit. Inclusion of a covered work++in an aggregate does not cause this License to apply to the other++parts of the aggregate.++++ 6. Conveying Non-Source Forms.++++ You may convey a covered work in object code form under the terms++of sections 4 and 5, provided that you also convey the++machine-readable Corresponding Source under the terms of this License,++in one of these ways:++++ a) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by the++ Corresponding Source fixed on a durable physical medium++ customarily used for software interchange.++++ b) Convey the object code in, or embodied in, a physical product++ (including a physical distribution medium), accompanied by a++ written offer, valid for at least three years and valid for as++ long as you offer spare parts or customer support for that product++ model, to give anyone who possesses the object code either (1) a++ copy of the Corresponding Source for all the software in the++ product that is covered by this License, on a durable physical++ medium customarily used for software interchange, for a price no++ more than your reasonable cost of physically performing this++ conveying of source, or (2) access to copy the++ Corresponding Source from a network server at no charge.++++ c) Convey individual copies of the object code with a copy of the++ written offer to provide the Corresponding Source. This++ alternative is allowed only occasionally and noncommercially, and++ only if you received the object code with such an offer, in accord++ with subsection 6b.++++ d) Convey the object code by offering access from a designated++ place (gratis or for a charge), and offer equivalent access to the++ Corresponding Source in the same way through the same place at no++ further charge. You need not require recipients to copy the++ Corresponding Source along with the object code. If the place to++ copy the object code is a network server, the Corresponding Source++ may be on a different server (operated by you or a third party)++ that supports equivalent copying facilities, provided you maintain++ clear directions next to the object code saying where to find the++ Corresponding Source. Regardless of what server hosts the++ Corresponding Source, you remain obligated to ensure that it is++ available for as long as needed to satisfy these requirements.++++ e) Convey the object code using peer-to-peer transmission, provided++ you inform other peers where the object code and Corresponding++ Source of the work are being offered to the general public at no++ charge under subsection 6d.++++ A separable portion of the object code, whose source code is excluded++from the Corresponding Source as a System Library, need not be++included in conveying the object code work.++++ A "User Product" is either (1) a "consumer product", which means any++tangible personal property which is normally used for personal, family,++or household purposes, or (2) anything designed or sold for incorporation++into a dwelling. In determining whether a product is a consumer product,++doubtful cases shall be resolved in favor of coverage. For a particular++product received by a particular user, "normally used" refers to a++typical or common use of that class of product, regardless of the status++of the particular user or of the way in which the particular user++actually uses, or expects or is expected to use, the product. A product++is a consumer product regardless of whether the product has substantial++commercial, industrial or non-consumer uses, unless such uses represent++the only significant mode of use of the product.++++ "Installation Information" for a User Product means any methods,++procedures, authorization keys, or other information required to install++and execute modified versions of a covered work in that User Product from++a modified version of its Corresponding Source. The information must++suffice to ensure that the continued functioning of the modified object++code is in no case prevented or interfered with solely because++modification has been made.++++ If you convey an object code work under this section in, or with, or++specifically for use in, a User Product, and the conveying occurs as++part of a transaction in which the right of possession and use of the++User Product is transferred to the recipient in perpetuity or for a++fixed term (regardless of how the transaction is characterized), the++Corresponding Source conveyed under this section must be accompanied++by the Installation Information. But this requirement does not apply++if neither you nor any third party retains the ability to install++modified object code on the User Product (for example, the work has++been installed in ROM).++++ The requirement to provide Installation Information does not include a++requirement to continue to provide support service, warranty, or updates++for a work that has been modified or installed by the recipient, or for++the User Product in which it has been modified or installed. Access to a++network may be denied when the modification itself materially and++adversely affects the operation of the network or violates the rules and++protocols for communication across the network.++++ Corresponding Source conveyed, and Installation Information provided,++in accord with this section must be in a format that is publicly++documented (and with an implementation available to the public in++source code form), and must require no special password or key for++unpacking, reading or copying.++++ 7. Additional Terms.++++ "Additional permissions" are terms that supplement the terms of this++License by making exceptions from one or more of its conditions.++Additional permissions that are applicable to the entire Program shall++be treated as though they were included in this License, to the extent++that they are valid under applicable law. If additional permissions++apply only to part of the Program, that part may be used separately++under those permissions, but the entire Program remains governed by++this License without regard to the additional permissions.++++ When you convey a copy of a covered work, you may at your option++remove any additional permissions from that copy, or from any part of++it. (Additional permissions may be written to require their own++removal in certain cases when you modify the work.) You may place++additional permissions on material, added by you to a covered work,++for which you have or can give appropriate copyright permission.++++ Notwithstanding any other provision of this License, for material you++add to a covered work, you may (if authorized by the copyright holders of++that material) supplement the terms of this License with terms:++++ a) Disclaiming warranty or limiting liability differently from the++ terms of sections 15 and 16 of this License; or++++ b) Requiring preservation of specified reasonable legal notices or++ author attributions in that material or in the Appropriate Legal++ Notices displayed by works containing it; or++++ c) Prohibiting misrepresentation of the origin of that material, or++ requiring that modified versions of such material be marked in++ reasonable ways as different from the original version; or++++ d) Limiting the use for publicity purposes of names of licensors or++ authors of the material; or++++ e) Declining to grant rights under trademark law for use of some++ trade names, trademarks, or service marks; or++++ f) Requiring indemnification of licensors and authors of that++ material by anyone who conveys the material (or modified versions of++ it) with contractual assumptions of liability to the recipient, for++ any liability that these contractual assumptions directly impose on++ those licensors and authors.++++ All other non-permissive additional terms are considered "further++restrictions" within the meaning of section 10. If the Program as you++received it, or any part of it, contains a notice stating that it is++governed by this License along with a term that is a further++restriction, you may remove that term. If a license document contains++a further restriction but permits relicensing or conveying under this++License, you may add to a covered work material governed by the terms++of that license document, provided that the further restriction does++not survive such relicensing or conveying.++++ If you add terms to a covered work in accord with this section, you++must place, in the relevant source files, a statement of the++additional terms that apply to those files, or a notice indicating++where to find the applicable terms.++++ Additional terms, permissive or non-permissive, may be stated in the++form of a separately written license, or stated as exceptions;++the above requirements apply either way.++++ 8. Termination.++++ You may not propagate or modify a covered work except as expressly++provided under this License. Any attempt otherwise to propagate or++modify it is void, and will automatically terminate your rights under++this License (including any patent licenses granted under the third++paragraph of section 11).++++ However, if you cease all violation of this License, then your++license from a particular copyright holder is reinstated (a)++provisionally, unless and until the copyright holder explicitly and++finally terminates your license, and (b) permanently, if the copyright++holder fails to notify you of the violation by some reasonable means++prior to 60 days after the cessation.++++ Moreover, your license from a particular copyright holder is++reinstated permanently if the copyright holder notifies you of the++violation by some reasonable means, this is the first time you have++received notice of violation of this License (for any work) from that++copyright holder, and you cure the violation prior to 30 days after++your receipt of the notice.++++ Termination of your rights under this section does not terminate the++licenses of parties who have received copies or rights from you under++this License. If your rights have been terminated and not permanently++reinstated, you do not qualify to receive new licenses for the same++material under section 10.++++ 9. Acceptance Not Required for Having Copies.++++ You are not required to accept this License in order to receive or++run a copy of the Program. Ancillary propagation of a covered work++occurring solely as a consequence of using peer-to-peer transmission++to receive a copy likewise does not require acceptance. However,++nothing other than this License grants you permission to propagate or++modify any covered work. These actions infringe copyright if you do++not accept this License. Therefore, by modifying or propagating a++covered work, you indicate your acceptance of this License to do so.++++ 10. Automatic Licensing of Downstream Recipients.++++ Each time you convey a covered work, the recipient automatically++receives a license from the original licensors, to run, modify and++propagate that work, subject to this License. You are not responsible++for enforcing compliance by third parties with this License.++++ An "entity transaction" is a transaction transferring control of an++organization, or substantially all assets of one, or subdividing an++organization, or merging organizations. If propagation of a covered++work results from an entity transaction, each party to that++transaction who receives a copy of the work also receives whatever++licenses to the work the party's predecessor in interest had or could++give under the previous paragraph, plus a right to possession of the++Corresponding Source of the work from the predecessor in interest, if++the predecessor has it or can get it with reasonable efforts.++++ You may not impose any further restrictions on the exercise of the++rights granted or affirmed under this License. For example, you may++not impose a license fee, royalty, or other charge for exercise of++rights granted under this License, and you may not initiate litigation++(including a cross-claim or counterclaim in a lawsuit) alleging that++any patent claim is infringed by making, using, selling, offering for++sale, or importing the Program or any portion of it.++++ 11. Patents.++++ A "contributor" is a copyright holder who authorizes use under this++License of the Program or a work on which the Program is based. The++work thus licensed is called the contributor's "contributor version".++++ A contributor's "essential patent claims" are all patent claims++owned or controlled by the contributor, whether already acquired or++hereafter acquired, that would be infringed by some manner, permitted++by this License, of making, using, or selling its contributor version,++but do not include claims that would be infringed only as a++consequence of further modification of the contributor version. For++purposes of this definition, "control" includes the right to grant++patent sublicenses in a manner consistent with the requirements of++this License.++++ Each contributor grants you a non-exclusive, worldwide, royalty-free++patent license under the contributor's essential patent claims, to++make, use, sell, offer for sale, import and otherwise run, modify and++propagate the contents of its contributor version.++++ In the following three paragraphs, a "patent license" is any express++agreement or commitment, however denominated, not to enforce a patent++(such as an express permission to practice a patent or covenant not to++sue for patent infringement). To "grant" such a patent license to a++party means to make such an agreement or commitment not to enforce a++patent against the party.++++ If you convey a covered work, knowingly relying on a patent license,++and the Corresponding Source of the work is not available for anyone++to copy, free of charge and under the terms of this License, through a++publicly available network server or other readily accessible means,++then you must either (1) cause the Corresponding Source to be so++available, or (2) arrange to deprive yourself of the benefit of the++patent license for this particular work, or (3) arrange, in a manner++consistent with the requirements of this License, to extend the patent++license to downstream recipients. "Knowingly relying" means you have++actual knowledge that, but for the patent license, your conveying the++covered work in a country, or your recipient's use of the covered work++in a country, would infringe one or more identifiable patents in that++country that you have reason to believe are valid.++++ If, pursuant to or in connection with a single transaction or++arrangement, you convey, or propagate by procuring conveyance of, a++covered work, and grant a patent license to some of the parties++receiving the covered work authorizing them to use, propagate, modify++or convey a specific copy of the covered work, then the patent license++you grant is automatically extended to all recipients of the covered++work and works based on it.++++ A patent license is "discriminatory" if it does not include within++the scope of its coverage, prohibits the exercise of, or is++conditioned on the non-exercise of one or more of the rights that are++specifically granted under this License. You may not convey a covered++work if you are a party to an arrangement with a third party that is++in the business of distributing software, under which you make payment++to the third party based on the extent of your activity of conveying++the work, and under which the third party grants, to any of the++parties who would receive the covered work from you, a discriminatory++patent license (a) in connection with copies of the covered work++conveyed by you (or copies made from those copies), or (b) primarily++for and in connection with specific products or compilations that++contain the covered work, unless you entered into that arrangement,++or that patent license was granted, prior to 28 March 2007.++++ Nothing in this License shall be construed as excluding or limiting++any implied license or other defenses to infringement that may++otherwise be available to you under applicable patent law.++++ 12. No Surrender of Others' Freedom.++++ If conditions are imposed on you (whether by court order, agreement or++otherwise) that contradict the conditions of this License, they do not++excuse you from the conditions of this License. If you cannot convey a++covered work so as to satisfy simultaneously your obligations under this++License and any other pertinent obligations, then as a consequence you may++not convey it at all. For example, if you agree to terms that obligate you++to collect a royalty for further conveying from those to whom you convey++the Program, the only way you could satisfy both those terms and this++License would be to refrain entirely from conveying the Program.++++ 13. Remote Network Interaction; Use with the GNU General Public License.++++ Notwithstanding any other provision of this License, if you modify the++Program, your modified version must prominently offer all users++interacting with it remotely through a computer network (if your version++supports such interaction) an opportunity to receive the Corresponding++Source of your version by providing access to the Corresponding Source++from a network server at no charge, through some standard or customary++means of facilitating copying of software. This Corresponding Source++shall include the Corresponding Source for any work covered by version 3++of the GNU General Public License that is incorporated pursuant to the++following paragraph.++++ Notwithstanding any other provision of this License, you have++permission to link or combine any covered work with a work licensed++under version 3 of the GNU General Public License into a single++combined work, and to convey the resulting work. The terms of this++License will continue to apply to the part which is the covered work,++but the work with which it is combined will remain governed by version++3 of the GNU General Public License.++++ 14. Revised Versions of this License.++++ The Free Software Foundation may publish revised and/or new versions of++the GNU Affero General Public License from time to time. Such new versions++will be similar in spirit to the present version, but may differ in detail to++address new problems or concerns.++++ Each version is given a distinguishing version number. If the++Program specifies that a certain numbered version of the GNU Affero General++Public License "or any later version" applies to it, you have the++option of following the terms and conditions either of that numbered++version or of any later version published by the Free Software++Foundation. If the Program does not specify a version number of the++GNU Affero General Public License, you may choose any version ever published++by the Free Software Foundation.++++ If the Program specifies that a proxy can decide which future++versions of the GNU Affero General Public License can be used, that proxy's++public statement of acceptance of a version permanently authorizes you++to choose that version for the Program.++++ Later license versions may give you additional or different++permissions. However, no additional obligations are imposed on any++author or copyright holder as a result of your choosing to follow a++later version.++++ 15. Disclaimer of Warranty.++++ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY++APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT++HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY++OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,++THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR++PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM++IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF++ALL NECESSARY SERVICING, REPAIR OR CORRECTION.++++ 16. Limitation of Liability.++++ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING++WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS++THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY++GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE++USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF++DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD++PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),++EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF++SUCH DAMAGES.++++ 17. Interpretation of Sections 15 and 16.++++ If the disclaimer of warranty and limitation of liability provided++above cannot be given local legal effect according to their terms,++reviewing courts shall apply local law that most closely approximates++an absolute waiver of all civil liability in connection with the++Program, unless a warranty or assumption of liability accompanies a++copy of the Program in return for a fee.++++ END OF TERMS AND CONDITIONS++++ How to Apply These Terms to Your New Programs++++ If you develop a new program, and you want it to be of the greatest++possible use to the public, the best way to achieve this is to make it++free software which everyone can redistribute and change under these terms.++++ To do so, attach the following notices to the program. It is safest++to attach them to the start of each source file to most effectively++state the exclusion of warranty; and each file should have at least++the "copyright" line and a pointer to where the full notice is found.++++ <one line to give the program's name and a brief idea of what it does.>++ Copyright (C) <year> <name of author>++++ This program is free software: you can redistribute it and/or modify++ it under the terms of the GNU Affero General Public License as published++ by the Free Software Foundation, either version 3 of the License, or++ (at your option) any later version.++++ This program is distributed in the hope that it will be useful,++ but WITHOUT ANY WARRANTY; without even the implied warranty of++ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the++ GNU Affero General Public License for more details.++++ You should have received a copy of the GNU Affero General Public License++ along with this program. If not, see <https://www.gnu.org/licenses/>.++++Also add information on how to contact you by electronic and paper mail.++++ If your software can interact with users remotely through a computer++network, you should also make sure that it provides a way for users to++get its source. For example, if your program is a web application, its++interface could display a "Source" link that leads users to an archive++of the code. There are many ways you could offer source, and different++solutions will be better for different programs; see section 13 for the++specific requirements.++++ You should also get your employer (if you work as a programmer) or school,++if any, to sign a "copyright disclaimer" for the program, if necessary.++For more information on this, and how to apply and follow the GNU AGPL, see++<https://www.gnu.org/licenses/>.nnew file mode 100644@@@ -0,0 +1,185 @@++# OpenVibe.Cam++++> Your camera, your cameras, and the moment Live's cam button takes.++++**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).++**Domain:** `openvibe.cam` · **Port:** 5160 · **Service id:** `cam` · **Env prefix:** `CAM`++**License:** AGPL-3.0-only (same as every OpenVibe service).++++OpenVibe.Cam is the camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo++or a still is saved to OpenVibe.Pics; Cam keeps the link, the filter and the caption, never the bytes. A camera is++private to its owner, and a viewer sees it only while the grant the owner gave is active and not revoked.++++## Purpose++++- **The camera app.** A browser preview with a timer, a 3-shot burst, a grid and on-device colour, light and++ softness. The filter runs on the canvas in the person's browser; the server never applies an effect. The picture++ is posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-side++ image analysis and no face recognition.++- **My cameras.** Pair an old phone, a browser, a Pi or an IP camera you own. If a Live stream id is given, Cam++ reads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to the++ stream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to++ everyone else, including staff.++- **Revocable viewer grants.** A grant names one canonical subject and an expiry: seven days by default, ninety at++ most. `@username` is resolved through OpenVibe.Network (`identity.subject.resolve`); a subject id works without++ it. A re-grant extends the active grant instead of making a second one. Revocation writes a timestamp, and the++ wall checks it on every read, so the next request is refused.++- **The Live cam button.** Live calls `POST /api/v1/stills` with the viewer's own Network token. Cam fetches++ OpenVibe.Media's public `GET /live/:slot/frame.jpg` (only `image/jpeg` is accepted), uploads the JPEG to++ OpenVibe.Pics as that person, and returns the still and the exact line Live posts in the stream chat.++++## Owns++++| Table | Holds | Migration |++|---|---|---|++| `cam_cameras` | `cam_` + ULID, owner `usr_…`, owner username, name, source `browser\|phone\|pi\|ip`, status `paired\|live\|offline`, Live `stream_id` (unique per owner), `slot`, the owner's note, paired/last-seen times | `migrations/0001_initial.sql` |++| `cam_grants` | `cmg_` + ULID, camera, owner, `viewer_subject`, viewer username, `granted_at`, `expires_at`, `revoked_at`, `revoked_by`; one unrevoked grant per camera and viewer | `0001_initial.sql` |++| `cam_photos` | `cmp_` + ULID, subject, optional camera, filter label, caption, visibility `private\|unlisted`, Pics slug, page URL, direct URL | `0001_initial.sql` |++| `cam_stills` | `cms_` + ULID, subject, stream id, slot, caption, visibility `unlisted\|private`, Pics slug, page URL, direct URL | `0001_initial.sql` |++| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |++| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |++++There is no image, blob or byte column in any Cam table. The `pics_slug` and links are the picture's address in++OpenVibe.Pics; neither Cam nor its database can serve the bytes.++++## Does not own++++- **The picture bytes, resizing, metadata stripping and takedowns** — OpenVibe.Pics (OpenVibe.MediaHub). Cam reads++ its `201` and stores the slug and links.++- **The live frame** — OpenVibe.Media's `/live/:sel/frame.jpg`. Cam never runs ffmpeg or caches a frame.++- **Streams, chat and the streamer's second camera** — OpenVibe.Live and OpenVibe.Chat. The `OVCamPip` streamer++ camera is Live's; Live also posts the returned still line to the stream's chat with its own chat authority.++- **Identity, usernames and accounts** — OpenVibe.Network. Cam verifies sessions against its JWKS and resolves a++ viewer's `@username` with `identity.subject.resolve`.++- **Search** — OpenVibe.Search. v1 makes nothing public, so Cam queues no Search document.++- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.++++## Depends on++++Environment variable names are in [.env.example](.env.example); no value is committed.++++- **OpenVibe.Network** — sign-in (OAuth client `cam`, PKCE S256, cookies `cam_at`/`cam_rt`/`cam_oauth`), JWKS,++ token exchange/refresh/revoke, account export/deletion, and `identity.subject.resolve` for `@username`:++ `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`, `CAM_AUDIENCE`.++- **OpenVibe.Media** — the public live frame, `GET /live/:sel/frame.jpg?w=640` (`media.derivative.read` is the++ planned capability; the route needs no token today): `OV_MEDIA_URL`.++- **OpenVibe.Live** — `GET /api/streams/:id`, the public read used to check that a paired stream is on the owner's++ channel (`live.stream.read`: `OV_LIVE_URL`).++- **OpenVibe.Pics (OpenVibe.MediaHub)** — `POST /api/v1/images`, called with the signed-in person's own Network++ token; Cam never holds a service token for Pics (`OV_PICS_URL`). The route is `pics.image.upload` in the person's++ own right; MediaHub refuses an app or service token there.++- **OpenVibe.Events** — account deliveries: `CAM_EVENTS_SECRET`, `CAM_EVENTS_URL`, `CAM_EVENTS_ENDPOINT`.++- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in++ `CAM_PGLITE_DIR` for development.++- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).++- **Packages** — `openvibe-contracts` v0.234.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same tags++ OpenVibe.Meme pins.++++## API++++Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `cam_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.cam` (403 `cam.cross_site`). A signed-in account also gets++per-actor minute/hour limits (`CAM_LIMITS_MINUTE`, `CAM_LIMITS_HOUR`).++++| Route | What it does |++|---|---|++| `POST /photos` | a person; raw picture body (`image/jpeg`, `image/png`, `image/webp`), optional `X-Filter`, `X-Caption`, `X-Camera`, `X-Filename` (URI-encoded); forwarded to Pics |++| `GET /photos` · `GET /photos/:id` | your photos |++| `GET /cameras` | your cameras with active grant counts |++| `POST /cameras` | `{ name, source, stream_id?, slot?, notes? }`; a `stream_id` is read from Live and must be yours |++| `GET /cameras/:id` · `PATCH /cameras/:id` · `DELETE /cameras/:id` | the owner: read, change name/source/status/slot/notes, unpair |++| `GET /cameras/:id/wall` | the owner or an active grantee; a stranger gets 404 |++| `GET /cameras/:id/grants` | the owner: every grant, including expired and revoked |++| `POST /cameras/:id/grants` | the owner: `{ viewer_username }` or `{ viewer_subject }`, `{ days }` 1–90 |++| `DELETE /cameras/:id/grants/:grantId` | the owner: revoke at once |++| `POST /stills` | a person; `{ slot, caption?, stream_id?, w?, visibility? }`; Media's frame → Pics → a still and the chat line |++| `GET /stills` | your stills |++| `GET /stills/:id` | an unlisted still by its link; a private one only for its owner |++++Validation refusals are `400 { code: "cam.invalid", message }`; a blocked caption is `400 { code:++"cam.blocked_words" }`. Other codes: `cam.sign_in_required`, `cam.person_required`, `cam.cross_site`,++`cam.not_found`, `cam.camera_exists`, `cam.camera_limit`, `cam.grant_limit`, `cam.grant_not_found`,++`cam.stream_not_found`, `cam.not_your_stream`, `cam.stream_offline`, `cam.frame_unavailable`, `cam.frame_busy`,++`cam.pics.refused`, `cam.pics.unavailable`, `cam.too_large`, `cam.not_an_image`, `cam.viewer_not_found`,++`cam.identity_unavailable`, `cam.rate_limited`.++++## The Live cam button++++Live is the caller. It holds the viewer's Network token in its own session; it sends that token to Cam as a Bearer++(server-side), never a Cam service token:++++1. The watch page sends `{ slot, stream_id, caption }` to Live's server.++2. Live calls `POST https://openvibe.cam/api/v1/stills` with `Authorization: Bearer <viewer's Network token>`.++3. Cam reads `GET https://openvibe.media/live/:slot/frame.jpg?w=640`; a 404 is `cam.stream_offline`, and an SVG++ offline card served with 200 is refused as `cam.frame_unavailable`.++4. Cam calls `POST https://openvibe.pics/api/v1/images` with the same token, the JPEG as the body, `X-Title` and++ `X-Filename`. Pics re-encodes and strips metadata.++5. Cam answers `201 { still, frame_url, share: { url, caption, text } }`. Live posts `share.text` to the stream's++ chat and shows `share.url` (or `still.pics_direct`).++++Cam needs no app/service token for this, and Live needs no new Cam grant. A still is `unlisted` by default; pass++`"visibility": "private"` to keep it to its owner. Media caches one frame per slot for five seconds and rate-limits++by address, so a burst of button presses can answer `429 cam.frame_busy`.++++## Pages++++All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with++semantic HTML and stable `cm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn++for a signed-in person, a wall or a still is `private, no-store`.++++| Page | What it is |++|---|---|++| `/` | the shutter hero, the three scenes, the privacy rules |++| `/app` | the camera app: preview, timer, burst, grid, filter, caption, and your recent photos |++| `/my` | pair a camera, your cameras, your photos and stills (signed in) |++| `/my/cameras/:id` | one camera: its frame, edit form, viewers, revoke buttons, unpair |++| `/w/:id` | the private wall an owner or an active grantee sees |++| `/s/:id` | one still of a live stream, with its Pics link |++| `/developers` | the API and the Live cam button contract |++| 404 | the not-found page |++++`public/css/cam.css` is a one-comment placeholder: the pages are complete without it, and the main session designs++it. `public/js/cam.js` adds the live preview, the timer, the burst, the grid and the on-device filter. The two++pictures in `public/img/` are data-free SVG placeholders. `robots.txt`, `sitemap.xml`, `llms.txt` and++`llms-full.txt` list the three public pages and never a wall, a grant or a still.++++## The first proof++++`scripts/seed-proof.js` performs the part that needs no network:++++- pair an owned test camera (`Proof camera`, slot `proof`) for `CAM_PROOF_OWNER`;++- grant `CAM_PROOF_VIEWER` for seven days;++- open the wall as that viewer;++- revoke the grant;++- ask for the wall again and assert it is refused.++++Re-running reuses the camera and makes a fresh grant. The photo and the still need a signed-in person's Network++token and are done through `/app` or the API; both go to OpenVibe.Pics.++++## 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 5160, loopback; DATABASE_URL or an embedded PGlite in development++CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js++```++++The tests boot the app with a mock Network, a Media/Live/Pics fixture server, and a temporary database: the++migration and its constraints, pairing and stream ownership, grants (username resolution, extension, revocation,++expiry, staff have no bypass), the still's Media → Pics path and its refusals, the photo upload and its limits, the++pages and cache headers, sign-in, and account export/deletion.++++## Deploy++++`deploy/systemd/openvibe-cam.service` (127.0.0.1:5160, `/etc/openvibe/cam.env`) and++`deploy/nginx/openvibe.cam.conf` (TLS, www → apex, `/auth/me` on the API zone, `/my/` on the API zone, `/internal`++and `/metrics` 404, a 16 MB body cap for a photo). Not deployed by this change.++++## License++++AGPL-3.0-only. See [LICENSE](LICENSE).nnew file mode 100644@@@ -0,0 +1,42 @@++# 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 cameras, grants, pictures 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.cam, openvibe.media, openvibe.pics 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.Cam holds++++- A paired camera: its name, source, status, Live stream id and slot, the owner's username and note, and times. It++ is private to its owner; there is no public camera page in v1.++- A viewer grant: the camera, the owner's subject, the viewer's subject and username, its grant and expiry times,++ and a revocation time and author. It is not a secret, but it is the only thing that opens a wall.++- A photo or a still: the person's subject, a camera or stream/slot, the filter name and caption, and OpenVibe.Pics'++ slug, page link and direct link. **No picture bytes, no face template and no identity classification** are stored++ here. The picture itself is OpenVibe.Pics' and follows Pics' own access rules, re-encoding and takedowns.++- Network session tokens only in httpOnly cookies (`cam_at`, `cam_rt`). The token is forwarded to OpenVibe.Pics when++ the person saves a picture; it is never stored in the database, rendered into a page or logged.++++A wall answers 404 whether the camera does not exist or the caller has no active grant, so a stranger cannot probe++for one. Staff have no bypass: a Network `global_mod` without a grant is refused like anyone else. A session write++must come from `openvibe.cam` (Sec-Fetch-Site same-origin, or an Origin equal to ours); a cross-site form cannot++pair a camera, grant a viewer, revoke a grant or upload a photo. The `/internal/events` account route is loopback++only and refuses a proxied request; nginx answers 404 for `/internal` and `/metrics`.++++There is no face recognition, no nudity or violence classifier, no AI call and no recording in v1. The camera is++same-origin only (`Permissions-Policy: camera=(self), microphone=()`), and the filter runs in the browser.++++## Supported versions++++Only the current `main` branch, which is what runs in production, receives fixes.nnew file mode 100644@@@ -0,0 +1,62 @@++{++ "repository": "OpenVibers/OpenVibe.Cam",++ "version": "0.1.0",++ "stage": "product",++ "code": true,++ "wave": "W21",++ "plan": "OpenVibe.Cam: the camera app, private cameras with revocable viewer grants, and the still Live's cam button takes — a photo and a still saved to OpenVibe.Pics by reference, with no picture bytes and no face recognition in Cam",++ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",++ "port": "5160",++ "domain": "openvibe.cam",++ "contracts": "openvibe-contracts v0.234.0",++ "sdk": "openvibe-sdk v0.70.0",++ "shared": "openvibe-shared v4.31.0",++ "packages": [++ "openvibe-contracts v0.234.0",++ "openvibe-sdk v0.70.0",++ "openvibe-shared v4.31.0"++ ],++ "summary": "OpenVibe.Cam is the camera app, the private wall for cameras a person owns, and the API Live's cam button calls. The camera preview, timer, burst, grid and filter run in the browser; a photo is posted to Cam and forwarded to OpenVibe.Pics with the person's own Network token. A camera is private: its owner sees it, and a viewer sees it only while a grant naming them is unrevoked and unexpired, checked on every read, so a revocation is refused at once. Live's cam button calls Cam with the viewer's token; Cam reads OpenVibe.Media's public live frame, saves the JPEG to Pics as that person, and returns the line Live posts in the stream's chat. Cam keeps a Pics slug, links, filter and caption — never the picture bytes — and there is no face recognition.",++ "works": [++ "Cameras: pair a phone, browser, Pi or IP camera; a Live stream id is read from Live and must be on the owner's channel; the slot defaults to the stream's managed slot",++ "The owner's camera page: rename, source, status, slot and note; unpair; the private wall at /w/<id>",++ "Viewer grants: one canonical subject and an expiry (1–90 days), @username resolved through Network identity.subject.resolve, one unrevoked grant per camera and viewer, extension instead of duplication",++ "Revocation is immediate: the wall checks revoked_at and expires_at on every read and answers 404 to a stranger, including staff with no grant",++ "The camera app: live preview, timer, 3-shot burst, grid, on-device colour/light/softness, caption; the picture goes to Pics",++ "The Live cam button: POST /api/v1/stills reads Media's GET /live/:slot/frame.jpg, uploads the JPEG to Pics with the viewer's own token, and returns the chat line",++ "Photos and stills are stored by reference (Pics slug, page link, direct link, filter, caption); no image bytes, no face template and no classification",++ "A still is unlisted by default (the chat link opens it) or private to its owner; a photo on a camera wall is unlisted, otherwise private",++ "Sign-in with OpenVibe.Network (PKCE S256, cam_at/cam_rt/cam_oauth), truthful readiness and loopback metrics",++ "Account export and deletion over cameras, grants given and received, photos and stills",++ "Server-rendered pages with stable cm- ids/classes, cache headers, discovery artifacts and deploy files"++ ],++ "notYet": [++ "The public camera directory, map, categories and follow-a-cam: v2; v1 makes nothing public and queues no Search document",++ "Timelapses, the AI highlight of the day and Vision alerts: v2; Media has no timelapse",++ "Face to face (random/group video, CAM-3): blocked on the owner's go/no-go after the safety gate and legal review",++ "Video capture in the camera app: MediaHub's POST /api/v1/videos chunked upload is the path; v1 stores photos and stills only",++ "Streamerless pairing of an IP camera: ingest is Live's RTMP path; v1 pairs and reads a Live slot",++ "A Search relay: nothing is public in v1, so there is no cam_outbox",++ "A staff moderation screen: staff have no bypass of camera privacy; a takedown is a report to Pics or Network"++ ],++ "capabilitiesRegistered": false,++ "serviceManifestRegistered": false,++ "deployed": false,++ "placeholderInSites": false,++ "features": [++ "camera app",++ "photos to Pics",++ "my cameras",++ "private wall",++ "viewer grants",++ "grant revocation",++ "Live cam button stills",++ "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-cam.service and deploy/nginx/openvibe.cam.conf",++ "updated": "2026-10-28"++}nnew file mode 100644@@@ -0,0 +1,115 @@++## ═══════════════════════════════════════════════════════════════++## openvibe.cam — Nginx configuration (REFERENCE)++##++## Deployed to /etc/nginx/sites-available/openvibe.cam.conf (ov-vhost-install) — keep them in sync and reload++## nginx after changing it. The Node app (port 5160, systemd unit openvibe-cam) renders every page and serves++## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the camera writes, and++## compresses text.++##++## No proxy_cache: the app decides cacheability per response (public for the three public pages; private, no-store++## for a camera wall, a still, or anything drawn for a signed-in person). A photo is at most 12 MB; the body cap++## leaves room for its headers. Never log request bodies here.++## ═══════════════════════════════════════════════════════════════++++limit_req_zone $binary_remote_addr zone=cam_auth:10m rate=10r/m;++limit_req_zone $binary_remote_addr zone=cam_api:10m rate=5r/s;++++server {++ listen 80;++ listen [::]:80;++ server_name openvibe.cam www.openvibe.cam;++++ location /.well-known/acme-challenge/ { root /var/www/certbot; }++ location / { return 301 https://openvibe.cam$request_uri; }++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name www.openvibe.cam;++++ ssl_certificate /etc/letsencrypt/live/openvibe.cam/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.cam/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ return 301 https://openvibe.cam$request_uri;++}++++server {++ listen 443 ssl;++ listen [::]:443 ssl;++ http2 on;++ server_name openvibe.cam;++++ ssl_certificate /etc/letsencrypt/live/openvibe.cam/fullchain.pem;++ ssl_certificate_key /etc/letsencrypt/live/openvibe.cam/privkey.pem;++ ssl_protocols TLSv1.2 TLSv1.3;++++ access_log /var/log/nginx/openvibe.cam.access.log;++ error_log /var/log/nginx/openvibe.cam.error.log;++++ # A photo is capped at 12 MB (CAM_PHOTO_MAX_BYTES); leave room for headers.++ client_max_body_size 16m;++++ add_header X-Content-Type-Options nosniff always;++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;++ add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=()" always;++++ gzip on;++ gzip_proxied any;++ gzip_vary on;++ gzip_comp_level 5;++ gzip_min_length 1024;++ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;++++ proxy_http_version 1.1;++ proxy_set_header Host $host;++ proxy_set_header X-Real-IP $remote_addr;++ proxy_set_header X-Forwarded-For $remote_addr;++ proxy_set_header CF-Connecting-IP $remote_addr;++ proxy_set_header X-Forwarded-Proto $scheme;++++ # ── Network SSO session endpoints ───────────────────────++ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.++ location = /auth/me {++ limit_req zone=cam_api burst=30 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5160;++ }++++ location /auth/ {++ limit_req zone=cam_auth burst=10 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5160;++ }++++ # ── API, camera pages and their forms ───────────────────++ location /api/ {++ limit_req zone=cam_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5160;++ }++++ location /my/ {++ limit_req zone=cam_api burst=20 nodelay;++ limit_req_status 429;++ proxy_pass http://127.0.0.1:5160;++ }++++ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5160/metrics) ──++ location ~* ^/metrics(/|$) { return 404; }++++ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──++ # Events posts to http://127.0.0.1:5160/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:5160;++ }++++ location ~ /\.(?!well-known) {++ deny all;++ }++}nnew file mode 100644@@@ -0,0 +1,31 @@++[Unit]++Description=OpenVibe.Cam — the camera app, private cameras and viewer grants, and the still Live's cam button takes.++# Network (sign-in, JWKS, identity resolve, account events) should be up first, but Cam degrades honestly without it:++# the public pages 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.cam++EnvironmentFile=/etc/openvibe/cam.env++Environment=NODE_ENV=production++Environment=PORT=5160++Environment=HOST=127.0.0.1++Environment=PATH=/usr/local/bin:/usr/bin:/bin++ExecStart=/usr/bin/env node /opt/openvibe.cam/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.Cam'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,143 @@++-- phase: expand++-- OpenVibe.Cam: paired cameras, revocable viewer grants, and the photos and stills whose bytes live in++-- OpenVibe.Pics. A camera row is private to its owner; a grant names one viewer and an expiry, and a revocation is++-- a timestamp checked on every read. Nothing here is a credential and no picture bytes are stored.++++CREATE TABLE cam_cameras (++ id text COLLATE "C" PRIMARY KEY, -- cam_ + 26 Crockford base32 characters (ULID)++ owner text COLLATE "C" NOT NULL, -- usr_…++ owner_username text, -- the owner's Network username when it was paired++ name text NOT NULL,++ source text COLLATE "C" NOT NULL DEFAULT 'phone',-- browser | phone | pi | ip++ status text COLLATE "C" NOT NULL DEFAULT 'paired',-- paired | live | offline++ stream_id bigint, -- OpenVibe.Live stream id, when the camera is a Live slot++ slot text COLLATE "C", -- a Live slot id, slug or @username for Media's frame++ notes text, -- the owner's own note; never rendered for a viewer++ paired_at timestamptz NOT NULL DEFAULT now(),++ last_seen_at timestamptz, -- when the wall last asked for its frame++ created_at timestamptz NOT NULL DEFAULT now(),++ updated_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT cam_cameras_id CHECK (id ~ '^cam_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_cameras_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_cameras_name CHECK (length(name) BETWEEN 1 AND 80),++ CONSTRAINT cam_cameras_source CHECK (source IN ('browser', 'phone', 'pi', 'ip')),++ CONSTRAINT cam_cameras_status CHECK (status IN ('paired', 'live', 'offline')),++ CONSTRAINT cam_cameras_stream CHECK (stream_id IS NULL OR stream_id > 0),++ CONSTRAINT cam_cameras_slot CHECK (slot IS NULL OR slot ~ '^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$')++);++CREATE UNIQUE INDEX cam_cameras_stream_unique ON cam_cameras (owner, stream_id) WHERE stream_id IS NOT NULL;++CREATE INDEX cam_cameras_by_owner ON cam_cameras (owner, created_at DESC);++++-- One viewer, one expiry, one revocation. An active grant is revoked_at IS NULL and expires_at in the future; the++-- store compares both against its clock on every read, so a revocation takes effect at once. A revoked row stays as++-- the record that the grant existed. A re-grant reuses the row that was never revoked.++CREATE TABLE cam_grants (++ id text COLLATE "C" PRIMARY KEY, -- cmg_ + ULID++ camera_id text COLLATE "C" NOT NULL REFERENCES cam_cameras (id) ON DELETE CASCADE,++ owner text COLLATE "C" NOT NULL, -- the person who granted it (usr_…)++ viewer_subject text COLLATE "C" NOT NULL, -- usr_…++ viewer_username text, -- as resolved when the grant was made++ granted_at timestamptz NOT NULL DEFAULT now(),++ expires_at timestamptz NOT NULL,++ revoked_at timestamptz,++ revoked_by text COLLATE "C", -- usr_… of the owner, or service:cam++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT cam_grants_id CHECK (id ~ '^cmg_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_grants_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_grants_viewer CHECK (viewer_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_grants_revoked_by CHECK (revoked_by IS NULL OR length(revoked_by) BETWEEN 1 AND 64),++ CONSTRAINT cam_grants_expiry CHECK (expires_at > granted_at),++ CONSTRAINT cam_grants_revoked CHECK (revoked_at IS NULL OR revoked_at >= granted_at)++);++CREATE UNIQUE INDEX cam_grants_one_active ON cam_grants (camera_id, viewer_subject) WHERE revoked_at IS NULL;++CREATE INDEX cam_grants_by_camera ON cam_grants (camera_id, granted_at DESC);++CREATE INDEX cam_grants_by_viewer ON cam_grants (viewer_subject, expires_at DESC);++++-- A photo the camera app saved. The bytes went to OpenVibe.Pics; this row keeps its slug and public links. A photo++-- attached to a camera is unlisted so the owner and an active viewer see it on the wall; otherwise it is private.++CREATE TABLE cam_photos (++ id text COLLATE "C" PRIMARY KEY, -- cmp_ + ULID++ subject text COLLATE "C" NOT NULL, -- usr_…++ camera_id text COLLATE "C" REFERENCES cam_cameras (id) ON DELETE SET NULL,++ filter text COLLATE "C" NOT NULL DEFAULT 'none', -- an on-device look; the server applies nothing++ caption text,++ visibility text COLLATE "C" NOT NULL DEFAULT 'private', -- private | unlisted++ pics_slug text COLLATE "C" NOT NULL,++ pics_url text NOT NULL, -- https://openvibe.pics/i/<slug>++ pics_direct text NOT NULL, -- https://openvibe.pics/i/<slug>.<ext>++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT cam_photos_id CHECK (id ~ '^cmp_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_photos_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_photos_filter CHECK (filter IN ('none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur')),++ CONSTRAINT cam_photos_caption CHECK (caption IS NULL OR length(caption) BETWEEN 1 AND 280),++ CONSTRAINT cam_photos_visibility CHECK (visibility IN ('private', 'unlisted')),++ CONSTRAINT cam_photos_slug CHECK (length(pics_slug) BETWEEN 1 AND 64),++ CONSTRAINT cam_photos_url CHECK (pics_url ~ '^https?://'),++ CONSTRAINT cam_photos_direct CHECK (pics_direct ~ '^https?://')++);++CREATE INDEX cam_photos_by_subject ON cam_photos (subject, created_at DESC);++CREATE INDEX cam_photos_by_camera ON cam_photos (camera_id, created_at DESC);++++-- A still of a Live stream, from the cam button. Also a Pics picture by reference; unlisted by default so the link++-- posted in chat opens it, private when the person chooses. Never a recording.++CREATE TABLE cam_stills (++ id text COLLATE "C" PRIMARY KEY, -- cms_ + ULID++ subject text COLLATE "C" NOT NULL, -- usr_… of the viewer who snapped it++ stream_id bigint,++ slot text COLLATE "C" NOT NULL,++ caption text,++ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted',-- unlisted | private++ pics_slug text COLLATE "C" NOT NULL,++ pics_url text NOT NULL,++ pics_direct text NOT NULL,++ created_at timestamptz NOT NULL DEFAULT now(),++ CONSTRAINT cam_stills_id CHECK (id ~ '^cms_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_stills_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),++ CONSTRAINT cam_stills_stream CHECK (stream_id IS NULL OR stream_id > 0),++ CONSTRAINT cam_stills_slot CHECK (slot ~ '^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$'),++ CONSTRAINT cam_stills_caption CHECK (caption IS NULL OR length(caption) BETWEEN 1 AND 280),++ CONSTRAINT cam_stills_visibility CHECK (visibility IN ('unlisted', 'private')),++ CONSTRAINT cam_stills_slug CHECK (length(pics_slug) BETWEEN 1 AND 64),++ CONSTRAINT cam_stills_url CHECK (pics_url ~ '^https?://'),++ CONSTRAINT cam_stills_direct CHECK (pics_direct ~ '^https?://')++);++CREATE INDEX cam_stills_by_subject ON cam_stills (subject, created_at DESC);++++-- 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-cam",++ "version": "0.1.0",++ "lockfileVersion": 3,++ "requires": true,++ "packages": {++ "": {++ "name": "openvibe-cam",++ "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.237.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.237.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.237.0",++ "integrity": "sha512-xMAwlCHx0j4FzhFNKASnuveqlkjhwHtJG4TfvqO+jDCGIa6xnK7654aQIBB4P7xnwaGtW7Ae71OmmK2cQfrm3Q==",++ "license": "AGPL-3.0-only",++ "dependencies": {++ "ajv": "^8.17.1",++ "ajv-formats": "^3.0.1"++ },++ "bin": {++ "openvibe-contracts-check": "bin/check-service.js"++ },++ "engines": {++ "node": ">=20"++ }++ },++ "node_modules/openvibe-sdk": {++ "version": "0.70.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",++ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",++ "license": "MIT",++ "bin": {++ "openvibe-download": "bin/openvibe-download.js"++ },++ "engines": {++ "node": ">=20"++ },++ "peerDependencies": {++ "@electric-sql/pglite": "^0.5.8",++ "iovalkey": "^0.4.0",++ "pg": "^8.23.0"++ },++ "peerDependenciesMeta": {++ "@electric-sql/pglite": {++ "optional": true++ },++ "iovalkey": {++ "optional": true++ },++ "pg": {++ "optional": true++ }++ }++ },++ "node_modules/openvibe-shared": {++ "version": "4.31.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",++ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",++ "license": "MIT",++ "dependencies": {++ "jsonwebtoken": "^9.0.0"++ },++ "bin": {++ "openvibe-build-assets": "scripts/build-assets.js"++ }++ },++ "node_modules/parseurl": {++ "version": "1.3.3",++ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",++ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/path-to-regexp": {++ "version": "0.1.13",++ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",++ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",++ "license": "MIT"++ },++ "node_modules/pg": {++ "version": "8.23.1",++ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",++ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",++ "license": "MIT",++ "dependencies": {++ "pg-connection-string": "^2.14.1",++ "pg-pool": "^3.14.0",++ "pg-protocol": "^1.16.1",++ "pg-types": "2.2.0",++ "pgpass": "1.0.5"++ },++ "engines": {++ "node": ">= 16.0.0"++ },++ "optionalDependencies": {++ "pg-cloudflare": "^1.4.1"++ },++ "peerDependencies": {++ "pg-native": ">=3.0.1"++ },++ "peerDependenciesMeta": {++ "pg-native": {++ "optional": true++ }++ }++ },++ "node_modules/pg-cloudflare": {++ "version": "1.4.1",++ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",++ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",++ "license": "MIT",++ "optional": true++ },++ "node_modules/pg-connection-string": {++ "version": "2.14.1",++ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",++ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",++ "license": "MIT"++ },++ "node_modules/pg-int8": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",++ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",++ "license": "ISC",++ "engines": {++ "node": ">=4.0.0"++ }++ },++ "node_modules/pg-pool": {++ "version": "3.14.0",++ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",++ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",++ "license": "MIT",++ "peerDependencies": {++ "pg": ">=8.0"++ }++ },++ "node_modules/pg-protocol": {++ "version": "1.16.1",++ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",++ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",++ "license": "MIT"++ },++ "node_modules/pg-types": {++ "version": "2.2.0",++ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",++ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",++ "license": "MIT",++ "dependencies": {++ "pg-int8": "1.0.1",++ "postgres-array": "~2.0.0",++ "postgres-bytea": "~1.0.0",++ "postgres-date": "~1.0.4",++ "postgres-interval": "^1.1.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/pgpass": {++ "version": "1.0.5",++ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",++ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",++ "license": "MIT",++ "dependencies": {++ "split2": "^4.1.0"++ }++ },++ "node_modules/postgres-array": {++ "version": "2.0.0",++ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",++ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/postgres-bytea": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",++ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-date": {++ "version": "1.0.7",++ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",++ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/postgres-interval": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",++ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",++ "license": "MIT",++ "dependencies": {++ "xtend": "^4.0.0"++ },++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/proxy-addr": {++ "version": "2.0.8",++ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",++ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",++ "license": "MIT",++ "dependencies": {++ "forwarded": "0.2.0",++ "ipaddr.js": "1.9.1"++ },++ "engines": {++ "node": ">= 0.10"++ },++ "funding": {++ "type": "opencollective",++ "url": "https://opencollective.com/express"++ }++ },++ "node_modules/qs": {++ "version": "6.16.2",++ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",++ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",++ "license": "BSD-3-Clause",++ "dependencies": {++ "es-define-property": "^1.0.1",++ "side-channel": "^1.1.1"++ },++ "engines": {++ "node": ">=0.6"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/range-parser": {++ "version": "1.2.1",++ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",++ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/raw-body": {++ "version": "2.5.3",++ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",++ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",++ "license": "MIT",++ "dependencies": {++ "bytes": "~3.1.2",++ "http-errors": "~2.0.1",++ "iconv-lite": "~0.4.24",++ "unpipe": "~1.0.0"++ },++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/redis-errors": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",++ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",++ "license": "MIT",++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/redis-parser": {++ "version": "3.0.0",++ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",++ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",++ "license": "MIT",++ "dependencies": {++ "redis-errors": "^1.0.0"++ },++ "engines": {++ "node": ">=4"++ }++ },++ "node_modules/require-from-string": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",++ "license": "MIT",++ "engines": {++ "node": ">=0.10.0"++ }++ },++ "node_modules/safe-buffer": {++ "version": "5.2.1",++ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",++ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",++ "funding": [++ {++ "type": "github",++ "url": "https://github.com/sponsors/feross"++ },++ {++ "type": "patreon",++ "url": "https://www.patreon.com/feross"++ },++ {++ "type": "consulting",++ "url": "https://feross.org/support"++ }++ ],++ "license": "MIT"++ },++ "node_modules/safer-buffer": {++ "version": "2.1.2",++ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",++ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",++ "license": "MIT"++ },++ "node_modules/semver": {++ "version": "7.8.5",++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",++ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",++ "license": "ISC",++ "bin": {++ "semver": "bin/semver.js"++ },++ "engines": {++ "node": ">=10"++ }++ },++ "node_modules/send": {++ "version": "0.19.2",++ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",++ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",++ "license": "MIT",++ "dependencies": {++ "debug": "2.6.9",++ "depd": "2.0.0",++ "destroy": "1.2.0",++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "etag": "~1.8.1",++ "fresh": "~0.5.2",++ "http-errors": "~2.0.1",++ "mime": "1.6.0",++ "ms": "2.1.3",++ "on-finished": "~2.4.1",++ "range-parser": "~1.2.1",++ "statuses": "~2.0.2"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/send/node_modules/ms": {++ "version": "2.1.3",++ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",++ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",++ "license": "MIT"++ },++ "node_modules/serve-static": {++ "version": "1.16.3",++ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",++ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",++ "license": "MIT",++ "dependencies": {++ "encodeurl": "~2.0.0",++ "escape-html": "~1.0.3",++ "parseurl": "~1.3.3",++ "send": "~0.19.1"++ },++ "engines": {++ "node": ">= 0.8.0"++ }++ },++ "node_modules/setprototypeof": {++ "version": "1.2.0",++ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",++ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",++ "license": "ISC"++ },++ "node_modules/side-channel": {++ "version": "1.1.1",++ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",++ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4",++ "side-channel-list": "^1.0.1",++ "side-channel-map": "^1.0.1",++ "side-channel-weakmap": "^1.0.2"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-list": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",++ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",++ "license": "MIT",++ "dependencies": {++ "es-errors": "^1.3.0",++ "object-inspect": "^1.13.4"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-map": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",++ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/side-channel-weakmap": {++ "version": "1.0.2",++ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",++ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",++ "license": "MIT",++ "dependencies": {++ "call-bound": "^1.0.2",++ "es-errors": "^1.3.0",++ "get-intrinsic": "^1.2.5",++ "object-inspect": "^1.13.3",++ "side-channel-map": "^1.0.1"++ },++ "engines": {++ "node": ">= 0.4"++ },++ "funding": {++ "url": "https://github.com/sponsors/ljharb"++ }++ },++ "node_modules/split2": {++ "version": "4.2.0",++ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",++ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",++ "license": "ISC",++ "engines": {++ "node": ">= 10.x"++ }++ },++ "node_modules/standard-as-callback": {++ "version": "2.1.0",++ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",++ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",++ "license": "MIT"++ },++ "node_modules/statuses": {++ "version": "2.0.2",++ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",++ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/toidentifier": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",++ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",++ "license": "MIT",++ "engines": {++ "node": ">=0.6"++ }++ },++ "node_modules/type-is": {++ "version": "1.6.18",++ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",++ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",++ "license": "MIT",++ "dependencies": {++ "media-typer": "0.3.0",++ "mime-types": "~2.1.24"++ },++ "engines": {++ "node": ">= 0.6"++ }++ },++ "node_modules/unpipe": {++ "version": "1.0.0",++ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",++ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/utils-merge": {++ "version": "1.0.1",++ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",++ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.4.0"++ }++ },++ "node_modules/vary": {++ "version": "1.1.2",++ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",++ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",++ "license": "MIT",++ "engines": {++ "node": ">= 0.8"++ }++ },++ "node_modules/xtend": {++ "version": "4.0.2",++ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",++ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",++ "license": "MIT",++ "engines": {++ "node": ">=0.4"++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,37 @@++{++ "name": "openvibe-cam",++ "version": "0.1.0",++ "private": true,++ "description": "OpenVibe.Cam — the camera app, your cameras and viewer grants, and the still Live's cam button takes.",++ "main": "server/index.js",++ "scripts": {++ "start": "node server/index.js",++ "dev": "NODE_ENV=development node server/index.js",++ "seed:proof": "node scripts/seed-proof.js",++ "test": "node test/run.js",++ "test:pg": "CAM_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.237.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,130 @@++/* OpenVibe.Cam: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent++ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone++ first; nothing scrolls sideways. Nothing is public without its owner's grant. */++*, *::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; }++.cm { display: block; }++.cm a { color: var(--room-strong); }++.cm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }++.cm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }++.cm-narrow { max-width: 620px; }++.cm-page-pad { padding-top: clamp(28px, 6vw, 64px); }++.cm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }++.cm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }++.cm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }++.cm-page-h h1, .cm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }++.cm-lede, .cm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }++.cm-fine, .cm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }++.cm-section { margin-top: clamp(28px, 4vw, 44px); }++.cm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }++.cm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }++.cm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }++.cm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }++.cm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }++.cm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }++.cm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }++.cm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }++.cm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.cm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }++.cm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }++.cm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }++.cm-empty p { margin: 0; line-height: 1.5; }++.cm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }++++++.cm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.cm-form { display: grid; gap: 14px; }++.cm-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }++.cm-field input, .cm-field select, .cm-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }++.cm-field input:focus, .cm-field select:focus, .cm-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }++.cm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }++.cm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }++.cm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++.cm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }++.cm-eps { display: grid; gap: 10px; margin: 12px 0 0; }++.cm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.cm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }++.cm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }++++/* Selects and file inputs share the field look */++.cm-field select, .cm-field input[type=file] { width: 100%; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; font: inherit; }++.cm-field select:focus-visible, .cm-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }++.cm-btn[disabled] { opacity: .5; cursor: not-allowed; }++.cm-btn[aria-pressed=true] { background: color-mix(in srgb, var(--room-accent) 22%, transparent); border-color: var(--room-accent); }++.cm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }++.cm-note { padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); }++.cm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }++++/* The motif: an iris opening onto a contact sheet */++.cm-shutter { position: relative; width: min(100%, 420px); aspect-ratio: 1; margin: 0 auto; color: var(--room-accent); }++.cm-iris { position: absolute; inset: 8% 30% 30% 8%; width: 62%; height: 62%; }++html.ov-motion .cm-iris path { transform-box: view-box; animation: cm-iris 6s cubic-bezier(.4,0,.2,1) infinite; }++@keyframes cm-iris { 0%, 100% { opacity: .75; } 45%, 55% { opacity: .2; } }++.cm-frame-grid { position: absolute; right: 4%; bottom: 6%; width: 52%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++.cm-frame { aspect-ratio: 4 / 3; border-radius: 6px; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, transparent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }++.cm-frame-1, .cm-frame-5 { background: color-mix(in srgb, var(--room-accent) 32%, var(--bg-card, #0f1729)); }++html.ov-motion .cm-frame { animation: cm-pop .5s cubic-bezier(.2,.7,.2,1) both; }++html.ov-motion .cm-frame-2 { animation-delay: .08s; } html.ov-motion .cm-frame-3 { animation-delay: .16s; } html.ov-motion .cm-frame-4 { animation-delay: .24s; }++html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6 { animation-delay: .4s; }++@keyframes cm-pop { from { opacity: 0; transform: translateY(6px) scale(.96); } }++@media (max-width: 759px) { .cm-shutter { width: 220px; } }++++/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */++.cm-camera { display: grid; gap: 14px; margin-top: 18px; }++@media (min-width: 960px) { .cm-camera { grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); align-items: start; } .cm-camera > .cm-status, .cm-camera > .cm-field, .cm-camera > noscript { grid-column: 2; } }++.cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06), 0 22px 50px -30px rgba(0, 0, 0, .8); }++.cm-stage video, .cm-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }++.cm-stage::after { content: ''; position: absolute; inset: 14px; border-radius: 12px; pointer-events: none;++ background: linear-gradient(currentColor, currentColor) top left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top left / 2px 22px no-repeat,++ linear-gradient(currentColor, currentColor) top right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top right / 2px 22px no-repeat,++ linear-gradient(currentColor, currentColor) bottom left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom left / 2px 22px no-repeat,++ linear-gradient(currentColor, currentColor) bottom right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom right / 2px 22px no-repeat;++ color: rgba(255, 255, 255, .55); }++.cm-grid-overlay { position: absolute; inset: 0; pointer-events: none;++ background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)),++ linear-gradient(transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)); }++.cm-grid-overlay[hidden] { display: none; }++.cm-controls { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; align-content: start; }++.cm-controls .cm-field { grid-column: 1 / -1; }++.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { grid-column: 1 / -1; min-height: 48px; justify-content: center; }++.cm-controls [data-cm-shoot]:not([disabled]) { background: var(--accent-strong, var(--room-accent)); color: var(--on-accent-strong, #fff); border-color: transparent; }++.cm-status { margin: 0; font-variant-numeric: tabular-nums; color: var(--text-secondary, #96a7c2); }++++/* Photos, stills and cameras */++.cm-photos, .cm-stills, .cm-cameras { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); }++.cm-photo, .cm-still { display: grid; gap: 6px; min-width: 0; }++.cm-photo img, .cm-still img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }++.cm-still img { aspect-ratio: 16 / 9; }++.cm-photo-link, .cm-still-link { display: block; border-radius: 14px; }++.cm-photo-link:hover img, .cm-still-link:hover img { border-color: var(--room-accent); }++.cm-photo-link:focus-visible, .cm-still-link:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }++.cm-photo-meta, .cm-still-meta { font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }++.cm-camera-link { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); text-decoration: none; color: inherit; }++.cm-camera-link:hover { border-color: var(--room-accent); }++.cm-camera-name { font-weight: 800; }++.cm-camera-meta, .cm-camera-grants { font-size: .84rem; color: var(--text-secondary, #96a7c2); }++.cm-still-large { margin: 16px 0; }++.cm-still-large img { display: block; width: 100%; height: auto; border-radius: 18px; border: 1px solid var(--border, #1f2d47); }++.cm-still-large figcaption { margin-top: 8px; color: var(--text-secondary, #96a7c2); }++.cm-wall-preview { margin-top: 18px; padding: 16px; border: 1px dashed var(--border, #1f2d47); border-radius: 18px; }++++/* Rules, steps and the viewers table */++.cm-rules { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }++.cm-rules li { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); line-height: 1.5; }++.cm-steps { margin: 12px 0 0; padding-left: 1.3em; display: grid; gap: 8px; line-height: 1.55; }++.cm-steps li::marker { color: var(--room-strong); font-weight: 800; }++.cm-table-wrap { overflow-x: auto; margin-top: 10px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; }++.cm-grants { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }++.cm-grants th, .cm-grants td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); white-space: nowrap; }++.cm-grants tr:last-child td { border-bottom: 0; }++.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }++@media (prefers-reduced-motion: reduce) { .cm-iris path, .cm-frame { animation: none !important; } }++++/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */++@media (max-width: 759px) { .cm-frame-grid { display: none; } .cm-iris { opacity: .3; } }++/* The controls read top to bottom: power and shutter, the settings, then the two toggles side by side */++.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { order: 1; }++.cm-controls .cm-field { order: 2; }++.cm-controls [data-cm-burst], .cm-controls [data-cm-grid-toggle] { order: 3; justify-content: center; }nnew file mode 100644@@@ -0,0 +1,10 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" role="img" aria-label="A camera grid">++ <rect width="320" height="180" fill="#0b0d12"/>++ <g stroke="#00c7d2" stroke-width="2" opacity=".75" fill="none">++ <rect x="28" y="24" width="120" height="60" rx="8"/>++ <rect x="172" y="24" width="120" height="60" rx="8"/>++ <rect x="28" y="96" width="120" height="60" rx="8"/>++ <rect x="172" y="96" width="120" height="60" rx="8"/>++ <path d="M160 12v156M12 90h296" opacity=".3" stroke-dasharray="4 6"/>++ </g>++</svg>nnew file mode 100644@@@ -0,0 +1,19 @@++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" role="img" aria-label="A shutter iris">++ <defs>++ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">++ <stop offset="0" stop-color="#00c7d2"/>++ <stop offset="1" stop-color="#005b66"/>++ </linearGradient>++ </defs>++ <rect width="320" height="180" fill="#0b0d12"/>++ <g transform="translate(160 90)">++ <circle r="56" fill="none" stroke="url(#g)" stroke-width="3" opacity=".7"/>++ <g fill="none" stroke="url(#g)" stroke-width="9" stroke-linecap="round">++ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(0)"/>++ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(90)"/>++ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(180)"/>++ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(270)"/>++ </g>++ <circle r="15" fill="url(#g)"/>++ </g>++</svg>nnew file mode 100644@@@ -0,0 +1,150 @@++/* OpenVibe.Cam: the camera app's enhancement. The page is complete without it; with it, the preview runs, the++ * timer, burst and grid work, and the on-device filter is applied to the canvas before the picture is posted to++ * /api/v1/photos. The picture goes to OpenVibe.Pics; Cam never receives it as anything but a request body. */++(function () {++ 'use strict';++++ var root = document.getElementById('cm-camera');++ if (!root) return;++ var video = root.querySelector('[data-cm-preview]');++ var canvas = root.querySelector('[data-cm-canvas]');++ var statusEl = root.querySelector('[data-cm-status]');++ var startBtn = root.querySelector('[data-cm-start]');++ var shootBtn = root.querySelector('[data-cm-shoot]');++ var timerSel = root.querySelector('[data-cm-timer]');++ var burstBtn = root.querySelector('[data-cm-burst]');++ var filterSel = root.querySelector('[data-cm-filter]');++ var captionEl = root.querySelector('[data-cm-caption]');++ var gridBtn = root.querySelector('[data-cm-grid-toggle]');++ var gridEl = root.querySelector('[data-cm-grid]');++ var fileInput = root.querySelector('[data-cm-file]');++ var gallery = document.getElementById('cm-gallery');++ var stream = null;++ var busy = false;++++ var FILTERS = {++ none: 'none',++ mono: 'grayscale(1) contrast(1.08)',++ warm: 'sepia(.35) saturate(1.15)',++ cool: 'hue-rotate(-10deg) saturate(1.1)',++ fade: 'saturate(.8) contrast(.92) brightness(1.06)',++ vivid: 'saturate(1.45) contrast(1.06)',++ blur: 'blur(1.5px)'++ };++++ function say(text) { if (statusEl) statusEl.textContent = text; }++++ function start() {++ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {++ say('This browser has no camera input. Pick a picture below instead.');++ return;++ }++ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }).then(function (s) {++ stream = s;++ video.srcObject = s;++ return video.play();++ }).then(function () {++ shootBtn.disabled = false;++ startBtn.textContent = 'Camera on';++ say('The camera is on. Nothing is recorded.');++ }).catch(function () {++ say('The camera was not allowed. Allow it in the browser, or pick a picture below.');++ });++ }++++ function drawFiltered() {++ var w = video.videoWidth || 1280;++ var h = video.videoHeight || 720;++ canvas.width = w;++ canvas.height = h;++ var ctx = canvas.getContext('2d');++ ctx.filter = FILTERS[filterSel.value] || 'none';++ ctx.drawImage(video, 0, 0, w, h);++ }++++ function addToGallery(p) {++ if (!gallery || !p || !p.pics_direct) return;++ var list = gallery.querySelector('.cm-photos');++ if (!list) {++ list = document.createElement('ul');++ list.className = 'cm-photos';++ gallery.appendChild(list);++ }++ var li = document.createElement('li');++ li.className = 'cm-photo';++ var a = document.createElement('a');++ a.className = 'cm-photo-link';++ a.href = p.pics_url;++ a.rel = 'noopener';++ var img = document.createElement('img');++ img.src = p.pics_direct;++ img.alt = p.caption || 'A photo on OpenVibe.Cam';++ img.loading = 'lazy';++ a.appendChild(img);++ li.appendChild(a);++ list.insertBefore(li, list.firstChild);++ }++++ function upload(blob, type, filterName) {++ if (busy) return Promise.resolve(null);++ busy = true;++ var headers = { 'Content-Type': type, 'X-Filter': filterName || 'none', 'X-Filename': encodeURIComponent('cam-' + Date.now() + '.jpg') };++ var caption = captionEl && captionEl.value ? captionEl.value : '';++ if (caption) headers['X-Caption'] = encodeURIComponent(caption);++ return fetch('/api/v1/photos', { method: 'POST', credentials: 'same-origin', headers: headers, body: blob })++ .then(function (res) {++ return res.json().catch(function () { return {}; }).then(function (body) {++ if (res.status === 401 || res.status === 403) { say('Sign in to save this photo to OpenVibe.Pics.'); return null; }++ if (!res.ok) { say((body && body.detail) || 'The photo was not saved.'); return null; }++ say('Saved to OpenVibe.Pics.');++ addToGallery(body);++ return body;++ });++ })++ .catch(function () { say('The photo was not saved: the network did not answer.'); return null; })++ .then(function (out) { busy = false; return out; });++ }++++ function snap() {++ if (!stream) { say('Turn the camera on first.'); return; }++ drawFiltered();++ canvas.toBlob(function (blob) { if (blob) upload(blob, 'image/jpeg', filterSel.value); }, 'image/jpeg', 0.92);++ }++++ function countdown(n) {++ if (n <= 0) { snap(); return; }++ say(n + '…');++ setTimeout(function () { countdown(n - 1); }, 1000);++ }++++ function shoot() {++ var n = parseInt(timerSel.value, 10) || 0;++ if (n > 0) countdown(n); else snap();++ }++++ function burst() {++ if (!stream) { say('Turn the camera on first.'); return; }++ say('Burst of three…');++ for (var i = 0; i < 3; i++) setTimeout(snap, i * 450);++ }++++ startBtn.addEventListener('click', start);++ shootBtn.addEventListener('click', shoot);++ burstBtn.addEventListener('click', burst);++ if (gridBtn) gridBtn.addEventListener('click', function () {++ if (!gridEl) return;++ var show = gridEl.hidden;++ gridEl.hidden = !show;++ gridBtn.setAttribute('aria-pressed', show ? 'true' : 'false');++ });++ if (fileInput) fileInput.addEventListener('change', function () {++ var f = fileInput.files && fileInput.files[0];++ if (!f) return;++ if (!/^image\//.test(f.type)) { say('That is not a picture.'); return; }++ upload(f, f.type, 'none');++ fileInput.value = '';++ });++ window.addEventListener('pagehide', function () {++ if (stream) stream.getTracks().forEach(function (t) { t.stop(); });++ });++})();nnew file mode 100644@@@ -0,0 +1,88 @@++#!/usr/bin/env node++'use strict';++++/**++ * The first proof, the half that needs no network: pair an owned test camera, grant a viewer for a week, open the++ * private wall as that viewer, revoke the grant, and show that the very next wall request is refused.++ *++ * CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js++ *++ * The other two halves need a signed-in person's Network token and are done through the site or the API:++ * a photo: POST /api/v1/photos (raw image body) with the cam_at cookie++ * a still: POST /api/v1/stills { "slot": "whip", "caption": "…" } with the cam_at cookie++ * Both go to OpenVibe.Pics; Cam stores only the link. Re-running this script reuses the proof camera and makes a++ * fresh grant after the previous one was revoked.++ */++const { ids } = require('openvibe-contracts');++const { load } = require('../server/config');++const { openStore } = require('../server/db');++const { createStore } = require('../server/store');++++const NAME = 'Proof camera';++const SLOT = 'proof';++++function subject(v, what) {++ if (!ids.isSubjectId('user', String(v || ''))) {++ throw new Error(`${what} must be a canonical subject (usr_ + 26 Crockford characters); pass it in the environment`);++ }++ return String(v);++}++++async function runProof({ config, s, owner, viewer, log = console } = {}) {++ if (!config) config = load();++ if (!s) s = await openStore(config, { log });++ const ownerSubject = subject(owner, 'CAM_PROOF_OWNER');++ const viewerSubject = subject(viewer, 'CAM_PROOF_VIEWER');++ if (ownerSubject === viewerSubject) throw new Error('the owner and the viewer must be different people');++ const cameras = createStore(s, { config });++++ let camera = (await cameras.camerasOf(ownerSubject)).find((c) => c.name === NAME && c.slot === SLOT) || null;++ if (!camera) {++ const created = await cameras.createCamera({ owner: ownerSubject, ownerUsername: 'proof-owner', name: NAME, source: 'phone', slot: SLOT, notes: 'The first proof; safe to unpair.' });++ if (!created.ok) throw new Error(`could not pair the proof camera: ${created.message}`);++ camera = created.camera;++ log.log(`[seed-proof] paired ${camera.id} (${NAME}, slot ${SLOT}) for ${ownerSubject}`);++ } else {++ log.log(`[seed-proof] ${camera.id} already paired; using it`);++ }++++ const granted = await cameras.grantCamera(camera.id, { owner: ownerSubject, viewerSubject, viewerUsername: 'proof-viewer', days: 7 });++ if (!granted.ok) throw new Error(`could not grant the viewer: ${granted.message}`);++ log.log(`[seed-proof] granted ${viewerSubject} until ${granted.grant.expires_at} (${granted.grant.id})`);++++ const wall = await cameras.wall(camera.id, viewerSubject);++ if (!wall || wall.owner) throw new Error('the viewer could not open the wall after the grant; nothing is proven');++ log.log(`[seed-proof] the viewer opened the wall: ${wall.photos.length} photo(s), frame ${camera.slot ? `live/${camera.slot}/frame.jpg` : 'not set'}`);++++ const revoked = await cameras.revokeGrant(camera.id, granted.grant.id, { owner: ownerSubject });++ if (!revoked.ok || !revoked.revoked) throw new Error('the grant was not revoked');++ log.log(`[seed-proof] revoked ${granted.grant.id}`);++++ const refused = await cameras.wall(camera.id, viewerSubject);++ if (refused) throw new Error('the wall still opened after the revocation; the proof fails');++ log.log('[seed-proof] the next wall request was refused, as it must be');++ log.log('[seed-proof] the photo and the still need a signed-in person: POST /api/v1/photos and POST /api/v1/stills');++ return { camera, grant: granted.grant, wall, revoked: revoked.grant, refused: true };++}++++async function main() {++ const owner = process.env.CAM_PROOF_OWNER;++ const viewer = process.env.CAM_PROOF_VIEWER;++ if (!owner || !viewer) {++ console.error('[seed-proof] set CAM_PROOF_OWNER and CAM_PROOF_VIEWER to two canonical usr_ ids');++ process.exitCode = 1;++ return;++ }++ const config = load();++ const s = await openStore(config, { log: console });++ try { await runProof({ config, s, owner, viewer }); } finally { await s.close(); }++}++++if (require.main === module) {++ main().catch((err) => {++ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);++ process.exitCode = 1;++ });++}++++module.exports = { runProof, NAME, SLOT };nnew file mode 100644@@@ -0,0 +1,171 @@++'use strict';++++/**++ * OpenVibe.Cam — Express app factory; server/index.js listens, tests build their own instance with a temp database++ * and a mock Network/Media/Live/Pics.++ *++ * /, /app, /my, /my/cameras/:id, /w/:id, /s/:id, /developers the pages (http/pages.js)++ * /api/v1/* the API (http/api.js)++ * /auth/* Network SSO with PKCE (auth/sso.js)++ * /api/health, /api/ready, /release.json, /metrics (loopback only)++ * /internal/events account export/deletion (loopback only)++ */++const path = require('path');++const express = require('express');++const helmet = require('helmet');++const cookieParser = require('cookie-parser');++const rateLimit = require('express-rate-limit');++const contracts = require('openvibe-contracts');++const cache = require('openvibe-shared/cache-policy');++++const configLib = require('./config');++const { openStore } = require('./db');++const { createKeyStore } = require('./auth/keys');++const { createSso } = require('./auth/sso');++const { createPrincipal } = require('./http/principal');++const { createApi } = require('./http/api');++const { createPageRoutes } = require('./http/pages');++const { createServiceReadiness } = require('./observability');++const { assetVersion, send } = require('./render/layout');++const { raw } = require('./render/html');++++const accountDataLib = require('./identity/account-data');++const { createStore: createCamStore } = require('./store');++const { createUpstreams } = require('./upstreams');++const { createNetworkSender } = require('openvibe-sdk/account-data');++++const PUBLIC_DIR = path.join(__dirname, '..', 'public');++const VERSION = require('../package.json').version;++const { createHttpTelemetry } = require('openvibe-sdk/service');++const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');++++/**++ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend,++ * 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 cameras = createCamStore(s, { config });++ const upstreams = createUpstreams({ config, fetchImpl, log });++++ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own++ // client-credentials token; a test injects a stand-in through opts.accountSend.++ const accountData = accountDataLib.create({ db: s.db, log });++ const accountSend = opts.accountSend || (config.oauth.clientSecret++ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })++ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Cam cannot answer account events'); });++++ const ctx = { config, s, keys, sso, principal, cameras, upstreams, accountData, accountSend, log };++++ const app = express();++ app.disable('x-powered-by');++ app.set('trust proxy', config.trustProxy);++ const release = require('openvibe-shared/release').createRelease({ service: 'cam', root: path.join(__dirname, '..') });++ require('./render/layout').setRelease(release.release);++ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'cam', release: release.release });++ app.locals.metrics = metrics.registry;++ ctx.telemetry = createHttpTelemetry({ service: 'cam', 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:', 'blob:', 'https://openvibe.network', 'https://openvibe.media', 'https://openvibe.pics'],++ mediaSrc: ["'self'", 'blob:'],++ 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' },++ }));++ // The camera app is same-origin only; there is no microphone in v1.++ app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(), payment=()'); next(); });++ app.use(cookieParser());++++ // ── Machine endpoints ───────────────────────────────────++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-cam', name: 'OpenVibe.Cam', 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: 'cam', service: 'cam', host: 'openvibe.cam', name: 'OpenVibe.Cam', profile: 'ugc' })); }++++ // ── OpenVibe.Events → this service (loopback only) ──────++ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.++ // nginx answers 404 for /internal/.++ app.use('/internal/events', accountData.consumer({++ secrets: config.events.secrets,++ send: accountSend,++ now: s.now,++ onEvent: async (ev) => `ignored:${ev.event_type}`,++ }));++++ // ── Static assets (content-hashed ?v= → immutable) ──────++ app.use('/shared', require('openvibe-shared/serve').handler());++ app.use(express.static(PUBLIC_DIR, {++ index: false, redirect: false,++ setHeaders(res, filePath) {++ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');++ const v = res.req && res.req.query && res.req.query.v;++ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));++ },++ }));++++ // ── API ─────────────────────────────────────────────────++ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CAM_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.CAM_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.Cam]', err && err.message ? err.message.slice(0, 300) : err);++ if (res.headersSent) return;++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });++ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');++ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });++ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');++ });++++ return { app, ctx };++}++++module.exports = { createApp };nnew file mode 100644@@@ -0,0 +1,84 @@++'use strict';++++/**++ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and++ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token++ * (sign-in) and a person's Bearer token (the API).++ *++ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the++ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves++ * this module: token.no_key answers 'signing key not loaded yet'.++ */++const sdk = require('openvibe-sdk/auth');++++const KEY_UNAVAILABLE = 'signing key not loaded yet';++const DOES_NOT_VERIFY = 'token does not verify';++++function publicReason(err) {++ const code = err && err.code;++ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;++ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;++}++++function jwksUrl(config) {++ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';++}++++function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {++ const url = jwksUrl(config);++ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });++ let mirror = null;++++ async function refreshMirror() {++ try {++ const ks = await client.keys();++ const first = ks && ks[0];++ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;++ } catch (err) { log.warn(`[OpenVibe.Cam] 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.Cam] 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.Cam] 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 `cam`.++ *++ * 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 Meme's SSO module with the client id and cookie names changed. The person's Network access token lives++ * in an httpOnly cookie (cam_at) no script can read; the refresh token (cam_rt) is httpOnly too. Neither is ever++ * stored server-side, rendered or logged. The same token is what Cam forwards to OpenVibe.Pics when the person++ * saves a photo or a still — it is never kept in the database.++ */++const crypto = require('crypto');++const express = require('express');++const cache = require('openvibe-shared/cache-policy');++++const ACCESS_COOKIE = 'cam_at';++const REFRESH_COOKIE = 'cam_rt';++const HINT_COOKIE = 'ov_sso_hint';++const FLOW_COOKIE = 'cam_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 Cam session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,++ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.++ */++ async function verifySession(token) {++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });++ const c = v.claims;++ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };++ return v;++ }++++ function viewerFromClaims(claims, token) {++ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;++ const role = typeof claims.role === 'string' ? claims.role : 'user';++ return {++ kind: 'user',++ subject,++ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,++ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),++ role,++ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,++ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,++ token,++ };++ }++++ async function refresh(req, res) {++ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);++ return req.sessionRefresh;++ }++++ async function doRefresh(req, res) {++ const rt = req.cookies && req.cookies[REFRESH_COOKIE];++ if (!rt) return null;++ try {++ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });++ setSession(res, data);++ const v = await verifySession(data.access_token);++ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;++ } catch (err) {++ if (err.status && err.status < 500) clearSession(res);++ return null;++ }++ }++++ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */++ function middleware() {++ return async (req, res, next) => {++ req.viewer = { kind: 'anonymous' };++ const token = req.cookies && req.cookies[ACCESS_COOKIE];++ if (!token) return next();++ await keys.ensure();++ const v = await verifySession(token);++ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }++ if (v.expired) {++ const fresh = await refresh(req, res);++ if (fresh) { req.viewer = fresh; return next(); }++ }++ if (keys.loaded()) clearSession(res);++ next();++ };++ }++++ async function asViewer(req, res, fn) {++ try {++ return await fn(req.viewer.token);++ } catch (err) {++ if (!err || err.status !== 401) throw err;++ const fresh = await refresh(req, res);++ if (!fresh) throw err;++ req.viewer = fresh;++ return fn(fresh.token);++ }++ }++++ function routes() {++ const r = express.Router();++ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });++++ r.get('/login', (req, res) => {++ const silent = Boolean(req.query.silent) && req.query.silent !== '0';++ const state = crypto.randomBytes(16).toString('hex');++ const { verifier, challenge } = pkcePair();++ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ res.redirect(authorizeUrl(config, { state, challenge, silent }));++ });++++ r.get('/callback', async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ let flow = null;++ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }++ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });++ const next = flow ? sanitizeNext(flow.next, config) : '/';++ const { code, state, error } = req.query;++ if (error) {++ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);++ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);++ }++ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'++ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {++ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');++ }++ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');++ try {++ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });++ setSession(res, data);++ res.set('Clear-Site-Data', '"cache"');++ return res.redirect(next);++ } catch (err) {++ log.warn('[OpenVibe.Cam] 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.Cam?</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,62 @@++'use strict';++++/**++ * Pairing and sharing, shared by the JSON API and the plain forms so the two cannot drift.++ *++ * pairCamera({ cameras, upstreams, principal, body })++ * Validates a camera and, when a Live stream id is given, reads it from OpenVibe.Live and refuses one on++ * somebody else's channel. The slot defaults to the stream's managed slot slug, its managed id, or @username.++ *++ * resolveViewer({ upstreams, body })++ * A viewer is a canonical subject, or an @username resolved through Network (identity.subject.resolve) with++ * Cam's service token. A grant is never made against a name Cam did not resolve.++ *++ * A refusal is { ok: false, status, code, message } and the caller turns it into problem+json or a page message.++ */++const { toSubject } = require('./store');++++const refusal = (status, code, message) => ({ ok: false, status, code, message });++++async function pairCamera({ cameras, upstreams, principal, body }) {++ const b = body && typeof body === 'object' ? body : {};++ const streamId = b.stream_id == null || b.stream_id === '' ? null : Number(b.stream_id);++ let slot = b.slot == null || b.slot === '' ? null : String(b.slot);++ let ownerUsername = principal.username || null;++++ if (streamId != null) {++ if (!Number.isInteger(streamId) || streamId <= 0) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number');++ let stream;++ try { stream = await upstreams.liveStream(streamId); } catch (err) {++ return { ok: false, status: err.status || 502, code: err.code || 'cam.live.unavailable', message: err.message || 'OpenVibe.Live did not answer' };++ }++ if (!stream) return refusal(404, 'cam.stream_not_found', 'No such live stream.');++ const channelName = String((stream.channel && stream.channel.username) || stream.username || '');++ if (!principal.username || channelName.toLowerCase() !== String(principal.username).toLowerCase()) {++ return refusal(403, 'cam.not_your_stream', 'That Live stream is not on your channel.');++ }++ ownerUsername = channelName || ownerUsername;++ if (!slot) slot = stream.managed_stream_slug || (stream.managed_stream_id != null ? String(stream.managed_stream_id) : `@${ownerUsername}`);++ }++++ return await cameras.createCamera({++ owner: principal.subject, ownerUsername, name: b.name, source: b.source || 'phone',++ streamId, slot, notes: b.notes,++ });++}++++async function resolveViewer({ upstreams, body }) {++ const b = body && typeof body === 'object' ? body : {};++ let subject = b.viewer_subject == null || b.viewer_subject === '' ? null : toSubject(b.viewer_subject);++ let username = b.viewer_username == null || b.viewer_username === '' ? null : String(b.viewer_username).trim().replace(/^@/, '');++ if (subject) return { ok: true, subject, username };++ if (!username) return refusal(400, 'cam.invalid', 'viewer_username or viewer_subject is required');++ const got = await upstreams.resolveUsername(username);++ if (!got.ok) {++ if (got.code === 'invalid') return refusal(400, 'cam.invalid', 'viewer_username is not a Network username');++ if (got.code === 'not_found') return refusal(404, 'cam.viewer_not_found', 'Nobody holds that OpenVibe username.');++ return refusal(503, 'cam.identity_unavailable', 'OpenVibe.Network could not resolve that username just now; send viewer_subject instead.');++ }++ return { ok: true, subject: got.subject, username: got.username || username };++}++++module.exports = { pairCamera, resolveViewer, refusal };nnew file mode 100644@@@ -0,0 +1,96 @@++'use strict';++++/**++ * OpenVibe.Cam configuration. Every value comes from the environment (production: /etc/openvibe/cam.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);++const size = (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, 5160);++ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.cam' : `http://localhost:${port}`));++ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');++++ return {++ service: 'cam',++ port,++ host: env.HOST || '127.0.0.1',++ nodeEnv,++ isProduction,++ baseUrl,++ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,++ drill: env.CAM_DRILL === '1' || env.CAM_DRILL === 'true',++ limits: {++ minute: Math.max(1, int(env.CAM_LIMITS_MINUTE, 120)),++ hour: Math.max(1, int(env.CAM_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 (CAM_PGLITE_DIR++ // overrides the directory).++ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CAM_PGLITE_DIR || '' },++ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:cam:' },++++ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), its JWKS, and the service token Cam uses++ // only to resolve a viewer's @username when a camera is shared (identity.subject.resolve).++ networkUrl,++ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),++ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ audience: env.CAM_AUDIENCE || 'openvibe.cam',++ oauth: {++ clientId: env.OV_OAUTH_CLIENT_ID || 'cam',++ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',++ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,++ scope: 'profile',++ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',++ },++ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },++++ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery++ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).++ events: {++ secrets: String(env.CAM_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),++ url: trim(env.CAM_EVENTS_URL || env.EVENTS_URL || ''),++ endpoint: env.CAM_EVENTS_ENDPOINT || '',++ intervalMs: Math.max(500, int(env.CAM_EVENTS_INTERVAL_MS, 5000)),++ scope: env.CAM_EVENTS_SCOPE || 'events.subscription.manage',++ },++++ // The existing public APIs Cam uses. No service grant is needed for any of them:++ // OpenVibe.Media GET /live/:sel/frame.jpg a near-realtime JPEG of a live slot (public, CORS-open)++ // OpenVibe.Live GET /api/streams/:id a stream and its channel (public; presence is not ownership)++ // OpenVibe.Pics POST /api/v1/images called with the signed-in person's own Network token++ upstreams: {++ media: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),++ live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),++ pics: trim(env.OV_PICS_URL || 'https://openvibe.pics'),++ timeoutMs: Math.max(500, int(env.CAM_UPSTREAM_TIMEOUT_MS, 8000)),++ },++++ // A photo the camera app uploads, and the still Live's cam button takes. Neither is ever stored here:++ // the bytes go to OpenVibe.Pics, and Cam keeps the Pics slug and links.++ photos: { maxBytes: size(env.CAM_PHOTO_MAX_BYTES, 12 * 1024 * 1024) },++ stills: {++ maxBytes: size(env.CAM_STILL_MAX_BYTES, 8 * 1024 * 1024),++ defaultWidth: Math.min(1920, Math.max(160, int(env.CAM_STILL_WIDTH, 640))),++ },++++ // A camera is private until its owner grants a viewer. Grants expire (7 days by default, 90 at most) and a++ // revocation is checked on every read, so it takes effect at once.++ cameras: {++ maxPerOwner: Math.min(200, Math.max(1, int(env.CAM_MAX_CAMERAS, 20))),++ grantDays: Math.min(90, Math.max(1, int(env.CAM_GRANT_DAYS, 7))),++ grantMaxDays: Math.min(90, Math.max(1, int(env.CAM_GRANT_MAX_DAYS, 90))),++ grantsPerCamera: Math.min(500, Math.max(1, int(env.CAM_GRANTS_PER_CAMERA, 50))),++ },++ };++}++++module.exports = { load };nnew file mode 100644@@@ -0,0 +1,48 @@++'use strict';++++/**++ * OpenVibe.Cam's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.++ * Nothing written here is ever a secret and no picture bytes are stored: a camera is a pair record, a grant is++ * two subjects and an expiry, and a photo or still is a Pics slug and its public links.++ */++const fs = require('fs');++const path = require('path');++const { createDb } = require('openvibe-sdk/db');++const { ids } = require('openvibe-contracts');++++const MIGRATIONS = path.join(__dirname, '..', 'migrations');++const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');++++async function openDb(config, { log = console, registry } = {}) {++ if (!config.db.url) {++ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');++ const dir = config.db.pgliteDir || DEV_PGLITE;++ log.warn(`[OpenVibe.Cam] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);++ fs.mkdirSync(dir, { recursive: true });++ const db = createDb({ pglite: dir, service: 'cam', 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: 'cam-migrate', max: 1, log });++ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }++ return createDb({ url: config.db.url, service: 'cam', registry, log });++}++++/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */++function createStore(db, { now = () => Date.now() } = {}) {++ return {++ db,++ now,++ iso: () => new Date(now()).toISOString(),++ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,++ tx: async (fn) => await db.tx((t) => fn(t)),++ close: () => db.close(),++ };++}++++async function openStore(config, { now, log } = {}) {++ return createStore(await openDb(config, { log }), { now });++}++++module.exports = { openDb, openStore, createStore, MIGRATIONS };nnew file mode 100644@@@ -0,0 +1,291 @@++'use strict';++++/**++ * /api/v1 — OpenVibe.Cam's JSON API.++ *++ * POST /photos a person: raw image bytes → OpenVibe.Pics, recorded here++ * GET /photos your photos++ * GET /photos/:id one of your photos++ * GET /cameras your paired cameras++ * POST /cameras pair one (a Live stream id is checked against Live)++ * GET /cameras/:id one of your cameras++ * PATCH /cameras/:id name, source, status, slot, notes++ * DELETE /cameras/:id unpair it++ * GET /cameras/:id/wall owner or an active viewer++ * GET /cameras/:id/grants owner++ * POST /cameras/:id/grants owner: @username or subject, and days++ * DELETE /cameras/:id/grants/:grantId owner: revoke at once++ * POST /stills the Live cam button: fetch Media's frame, save to Pics++ * GET /stills your stills++ * GET /stills/:id one still (unlisted: the link; private: its owner)++ *++ * Errors are application/problem+json with a `code`. A person is the cam_at session cookie or a Network user++ * token as a Bearer; a session write must come from openvibe.cam itself (403 cam.cross_site). No route needs an app++ * or service token: the still is saved to the viewer's own Pics with the viewer's own token.++ */++const express = require('express');++const { http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { sameOrigin } = require('./principal');++const { pairCamera, resolveViewer } = require('../cameras');++const { frameUrl } = require('../views');++++function createApi(ctx) {++ const { config, s, principal, cameras, upstreams } = ctx;++ const r = asyncRouter();++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(principal.middleware);++++ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and++ // hour budget from config, and an app or anonymous caller is left to the address limiter. A second process++ // has its own counters; VALKEY_URL would make them shared (the readiness check says so).++ const buckets = new Map();++ function windowCount(key, windowMs, limit) {++ const now = Date.now();++ let b = buckets.get(key);++ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }++ b.n += 1;++ return b.n <= limit;++ }++ r.use((req, res, next) => {++ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {++ const who = `user:${req.principal.subject}`;++ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);++ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);++ if (!okMinute || !okHour) return problem(res, 429, 'cam.rate_limited', 'too many requests from this account; retry shortly');++ if (buckets.size > 20_000) {++ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);++ }++ }++ return next();++ });++++ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });++ const invalid = (res, detail) => problem(res, 400, 'cam.invalid', detail);++++ function person(req, res) {++ const p = req.principal;++ if (p.kind === 'anonymous') { problem(res, 401, 'cam.sign_in_required', 'Sign in at openvibe.cam, or send a person\'s Network token as a Bearer.'); return null; }++ if (p.kind !== 'user') { problem(res, 403, 'cam.person_required', 'A camera and a picture are a person\'s; an app or service token cannot act here.'); return null; }++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {++ problem(res, 403, 'cam.cross_site', 'A signed-in request that changes something must come from openvibe.cam itself.');++ return null;++ }++ return p;++ }++ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);++++ function upstreamProblem(res, err) {++ const status = err && Number.isFinite(Number(err.status)) ? Number(err.status) : 502;++ const safe = status >= 400 && status < 600 ? status : 502;++ return problem(res, safe, (err && err.code) || 'cam.upstream', (err && err.message) || 'an upstream service did not answer');++ }++++ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);++ const decoded = (req, name, max) => { try { return decodeURIComponent(String(req.get(name) || '')).slice(0, max); } catch { return null; } };++++ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));++++ // A JSON body everywhere except the photo upload, whose body is the picture itself.++ const jsonBody = express.json({ limit: '64kb' });++ r.use((req, res, next) => ((req.method === 'POST' && req.path === '/photos') ? next() : jsonBody(req, res, next)));++++ // ── Cameras ─────────────────────────────────────────────++ r.get('/cameras', requirePerson, async (req, res) => {++ const list = await cameras.camerasOf(req.principal.subject);++ return res.json({ count: list.length, cameras: list });++ });++++ r.post('/cameras', requirePerson, async (req, res) => {++ const out = await pairCamera({ cameras, upstreams, principal: req.principal, body: req.body || {} });++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ res.set('Location', `/api/v1/cameras/${out.camera.id}`);++ return res.status(201).json({ ...out.camera, frame_url: frameUrl(config, out.camera) });++ });++++ async function ownCamera(req, res) {++ const row = await cameras.cameraById(String(req.params.id || ''));++ if (!row || row.owner !== req.principal.subject) {++ problem(res, 404, 'cam.not_found', 'No such camera.');++ return null;++ }++ return row;++ }++++ r.get('/cameras/:id', requirePerson, async (req, res) => {++ const row = await ownCamera(req, res);++ if (!row) return undefined;++ const camera = cameras.wireCamera(row);++ return res.json({ ...camera, frame_url: frameUrl(config, camera) });++ });++++ r.patch('/cameras/:id', requirePerson, async (req, res) => {++ const row = await ownCamera(req, res);++ if (!row) return undefined;++ const out = await cameras.patchCamera(row.id, req.principal.subject, req.body || {});++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ return res.json({ ...out.camera, frame_url: frameUrl(config, out.camera) });++ });++++ r.delete('/cameras/:id', requirePerson, async (req, res) => {++ const row = await ownCamera(req, res);++ if (!row) return undefined;++ const out = await cameras.removeCamera(row.id, req.principal.subject);++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ return res.status(204).end();++ });++++ r.get('/cameras/:id/wall', requirePerson, async (req, res) => {++ const wall = await cameras.wall(String(req.params.id || ''), req.principal.subject);++ if (!wall) return problem(res, 404, 'cam.not_found', 'No such camera, or you have no grant for it.');++ const camera = { ...cameras.wireCamera(wall.camera), ...(wall.owner ? {} : { notes: undefined }) };++ if (!wall.owner) delete camera.notes;++ return res.json({++ camera,++ owner: wall.owner,++ grant: wall.grant,++ frame_url: frameUrl(config, camera),++ photos: wall.photos,++ });++ });++++ // ── Viewer grants ───────────────────────────────────────++ r.get('/cameras/:id/grants', requirePerson, async (req, res) => {++ const out = await cameras.grantsOf(String(req.params.id || ''), req.principal.subject);++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ return res.json({ count: out.grants.length, grants: out.grants });++ });++++ r.post('/cameras/:id/grants', requirePerson, async (req, res) => {++ const camera = await ownCamera(req, res);++ if (!camera) return undefined;++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const viewer = await resolveViewer({ upstreams, body: b });++ if (!viewer.ok) return problem(res, viewer.status, viewer.code, viewer.message);++ const out = await cameras.grantCamera(camera.id, {++ owner: req.principal.subject, viewerSubject: viewer.subject, viewerUsername: viewer.username,++ days: b.days == null || b.days === '' ? null : Number(b.days),++ });++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ res.set('Location', `/api/v1/cameras/${camera.id}/grants`);++ return res.status(out.changed ? 201 : 200).json(out.grant);++ });++++ r.delete('/cameras/:id/grants/:grantId', requirePerson, async (req, res) => {++ const camera = await ownCamera(req, res);++ if (!camera) return undefined;++ const out = await cameras.revokeGrant(camera.id, String(req.params.grantId || ''), { owner: req.principal.subject });++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ return res.json({ revoked: out.revoked, already: out.already, grant: out.grant });++ });++++ // ── Photos: the raw picture is the body ─────────────────++ const rawBody = express.raw({ type: () => true, limit: config.photos.maxBytes + 1024 });++ r.post('/photos', requirePerson, (req, res, next) => {++ rawBody(req, res, (err) => {++ if (err && err.type === 'entity.too.large') return problem(res, 413, 'cam.too_large', `A photo can be up to ${Math.round(config.photos.maxBytes / 1024 / 1024)} MB.`);++ if (err) return next(err);++ return next();++ });++ }, async (req, res) => {++ const type = String(req.get('content-type') || '').split(';')[0].trim().toLowerCase();++ if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) {++ return problem(res, 415, 'cam.not_an_image', 'Send the picture as the body, with an image/jpeg, image/png or image/webp Content-Type.');++ }++ const bytes = Buffer.isBuffer(req.body) ? req.body : null;++ if (!bytes || !bytes.length) return invalid(res, 'The picture is empty.');++ if (bytes.length > config.photos.maxBytes) return problem(res, 413, 'cam.too_large', `A photo can be up to ${Math.round(config.photos.maxBytes / 1024 / 1024)} MB.`);++ const filter = String(req.get('x-filter') || 'none').toLowerCase();++ if (!cameras.FILTERS.includes(filter)) return invalid(res, `X-Filter must be one of ${cameras.FILTERS.join(', ')}`);++ const caption = decoded(req, 'x-caption', 280);++ if (caption === null) return invalid(res, 'X-Caption must be URI-encoded text');++ const cameraId = String(req.get('x-camera') || '').trim() || null;++ const filename = decoded(req, 'x-filename', 200) || `cam-${Date.now()}.jpg`;++ let stored;++ try {++ stored = await upstreams.picsUpload({++ token: req.principal.token, bytes, contentType: type,++ title: caption || 'A photo on OpenVibe.Cam', filename,++ });++ } catch (err) { return upstreamProblem(res, err); }++ const out = await cameras.createPhoto({++ subject: req.principal.subject, cameraId, filter, caption: caption || null, pics: stored,++ });++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ res.set('Location', `/api/v1/photos/${out.photo.id}`);++ return res.status(201).json(out.photo);++ });++++ r.get('/photos', requirePerson, async (req, res) => {++ const photos = await cameras.photosOf(req.principal.subject, { limit: asInt(req.query.limit, 60) });++ return res.json({ count: photos.length, photos });++ });++++ r.get('/photos/:id', requirePerson, async (req, res) => {++ const photo = await cameras.photoById(String(req.params.id || ''));++ if (!photo || photo.subject !== req.principal.subject) return problem(res, 404, 'cam.not_found', 'No such photo.');++ return res.json(photo);++ });++++ // ── Stills: the Live cam button ─────────────────────────++ r.post('/stills', requirePerson, async (req, res) => {++ const b = req.body && typeof req.body === 'object' ? req.body : {};++ const slot = String(b.slot == null ? '' : b.slot).trim();++ if (!cameras.SLOT_RE.test(slot)) return invalid(res, 'slot must be a Live slot id, slug or @username');++ const w = b.w == null || b.w === '' ? config.stills.defaultWidth : Number(b.w);++ if (!Number.isFinite(w) || w < 160 || w > 1920) return invalid(res, 'w must be from 160 to 1920');++ const streamId = b.stream_id == null || b.stream_id === '' ? null : Number(b.stream_id);++ if (streamId != null && (!Number.isInteger(streamId) || streamId <= 0)) return invalid(res, 'stream_id must be a positive whole number');++ const visibility = b.visibility == null || b.visibility === '' ? 'unlisted' : String(b.visibility);++ if (!cameras.STILL_VISIBILITIES.includes(visibility)) return invalid(res, `visibility must be one of ${cameras.STILL_VISIBILITIES.join(', ')}`);++ const caption = b.caption == null ? '' : String(b.caption);++++ let frame;++ try { frame = await upstreams.liveFrame({ slot, w: Math.round(w), maxBytes: config.stills.maxBytes }); } catch (err) { return upstreamProblem(res, err); }++ if (!frame.ok) {++ if (frame.code === 'offline') return problem(res, 404, 'cam.stream_offline', 'That slot is offline right now.');++ if (frame.code === 'busy') return problem(res, 429, 'cam.frame_busy', 'OpenVibe.Media is busy grabbing that frame; try again in a second.');++ if (frame.code === 'too_large') return problem(res, 413, 'cam.too_large', 'That frame is too large to save.');++ return problem(res, 502, 'cam.frame_unavailable', 'OpenVibe.Media did not return a JPEG frame.');++ }++ let stored;++ try {++ stored = await upstreams.picsUpload({++ token: req.principal.token, bytes: frame.bytes, contentType: 'image/jpeg',++ title: caption || `A still from ${slot}`, filename: `still-${slot.replace(/[^A-Za-z0-9_.-]/g, '')}-${Date.now()}.jpg`,++ });++ } catch (err) { return upstreamProblem(res, err); }++ const out = await cameras.createStill({++ subject: req.principal.subject, streamId, slot, caption, visibility, pics: stored,++ });++ if (!out.ok) return problem(res, out.status, out.code, out.message);++ const shareUrl = `${String(config.baseUrl).replace(/\/+$/, '')}/s/${out.still.id}`;++ const text = (out.still.caption ? `${out.still.caption} — ${shareUrl}` : `A still from ${out.still.slot}: ${shareUrl}`).slice(0, 500);++ res.set('Location', `/api/v1/stills/${out.still.id}`);++ return res.status(201).json({++ still: out.still,++ frame_url: `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(slot)}/frame.jpg?w=${Math.round(w)}`,++ share: { url: shareUrl, caption: out.still.caption, text },++ });++ });++++ r.get('/stills', requirePerson, async (req, res) => {++ const stills = await cameras.stillsOf(req.principal.subject, { limit: asInt(req.query.limit, 60) });++ return res.json({ count: stills.length, stills });++ });++++ r.get('/stills/:id', async (req, res) => {++ const still = await cameras.stillById(String(req.params.id || ''));++ if (!still) return problem(res, 404, 'cam.not_found', 'No such still.');++ const mine = req.principal.kind === 'user' && req.principal.subject === still.subject;++ if (still.visibility !== 'unlisted' && !mine) return problem(res, 404, 'cam.not_found', 'No such still.');++ const out = { ...still, mine };++ if (!mine) delete out.subject;++ return res.json(out);++ });++++ return r;++}++++module.exports = { createApi };nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++++/**++ * Crawl artifacts for openvibe.cam: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Only the three public++ * pages are listed. A camera wall, a grant and a still are not for crawlers — the sitemap never names one, and++ * robots.txt disallows the three paths that could carry one.++ */++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.Cam';++const DESCRIPTION = 'OpenVibe.Cam is the camera app: take a photo with on-device filters, pair a camera you own, share its private wall with one viewer for a week, and take a still from a Live stream for its chat.';++const DISALLOW = ['/auth/', '/api/', '/my/', '/w/', '/s/', '/internal', '/metrics'];++++function siteUpdated() {++ try {++ const m = String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated || '').match(/^(\d{4}-\d{2}-\d{2})/);++ return m ? m[1] : null;++ } 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: 'MultimediaApplication', keywords: 'openvibe cam, camera app, private camera, viewer grant, live still, on-device filters' }),++ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),++ ];++}++++function publicPages() {++ return [++ { path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Cam home', text: 'The camera app, your private cameras and viewer grants, and a still from a Live stream.' },++ { path: '/app', changefreq: 'weekly', priority: 0.9, title: 'The camera app', text: 'A photo with on-device colour, light and softness, a timer, a burst and a grid; the picture is saved to OpenVibe.Pics.' },++ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Cam API', text: 'The JSON API, the viewer-grant rules and the Live cam button contract.' },++ ];++}++++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.cam: the three public pages are for crawlers; a camera wall, a grant, a still and the API 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.Cam is the camera app: photos with on-device filters saved to OpenVibe.Pics, private cameras with revocable viewer grants, and a still from a Live stream for its chat. A camera is private; there is no public directory in v1.',++ details: 'GET /api/v1/cameras lists a signed-in person\'s cameras; POST /api/v1/cameras pairs one; POST /api/v1/cameras/:id/grants names a viewer and a number of days; DELETE /api/v1/cameras/:id/grants/:grantId revokes it at once. POST /api/v1/stills takes a frame from OpenVibe.Media\'s public GET /live/:sel/frame.jpg and saves it to OpenVibe.Pics as the person. No route is public except GET /api/v1/stills/:id for an unlisted still.',++ sections: [++ { title: 'Start here', links: [++ { title: 'OpenVibe.Cam', url: abs('/'), note: 'the camera app and the privacy rules' },++ { title: 'The camera app', url: abs('/app') },++ { title: 'The API', url: abs('/developers') },++ ] },++ { 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.Live', url: 'https://openvibe.live', note: 'the stream the cam button takes a still from' },++ { title: 'OpenVibe.Media', url: 'https://openvibe.media', note: 'the live frame endpoint' },++ { title: 'OpenVibe.Pics', url: 'https://openvibe.pics', note: 'where a photo and a still are stored' },++ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and usernames' },++ ] },++ ],++ }));++ });++++ r.get('/llms-full.txt', (_req, res) => {++ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({++ site: SITE_NAME,++ summary: 'Every public page of OpenVibe.Cam. No camera, grant or still is listed: those are private or link-only.',++ base: site,++ maxBytes: 256 * 1024,++ sections: [{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) }],++ }));++ });++++ 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,214 @@++'use strict';++++/**++ * Cam's server-rendered pages. They are complete without JavaScript: forms are plain POSTs and every link is a real++ * link. The camera preview, timer, burst, grid and filters are an enhancement in public/js/cam.js.++ *++ * / the hero, the three scenes, the privacy rules++ * /app the camera app, with your recent photos when signed in++ * /my pair a camera, your cameras, photos, stills (signed in)++ * /my/cameras/:id one camera's owner page: edit, grants, unpair++ * /w/:id the private wall an owner or an active grantee sees++ * /s/:id one still of a live stream++ * /developers the API and the Live cam button contract++ * 404 the not-found page++ *++ * Public pages are served `public, max-age=60` with Vary: Cookie; every page drawn for a signed-in person, a wall++ * and a still are `private, no-store`.++ */++const express = require('express');++const seo = require('openvibe-shared/seo');++const { asyncRouter } = require('./router');++const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');++const { sameOrigin } = require('./principal');++const { pairCamera, resolveViewer } = require('../cameras');++const { html, raw } = require('../render/html');++const { send } = require('../render/layout');++const views = require('../views');++++const PUBLIC_CACHE = 'public, max-age=60';++++function createPageRoutes(ctx) {++ const { config, s, cameras, upstreams } = 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 notFromHere = (req, res) => page(req, res, {++ title: 'Not from this site',++ body: raw(html`<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty"><h1>That came from somewhere else</h1><p class="cm-muted">Use the form on <a href="/">OpenVibe.Cam</a>.</p></div></div>`),++ }, 403);++ const signedOut = (req, res, title, text, next) => page(req, res, {++ title, body: raw(views.signIn({ title, text, next })),++ }, 401);++++ // ── Home ─────────────────────────────────────────────────++ r.get('/', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: null,++ description: 'OpenVibe.Cam is the camera app: on-device filters, private cameras with revocable viewer grants, and a still from a Live stream for its chat.',++ jsonLd: homeJsonLd(config),++ body: raw(views.home({ signedIn: Boolean(signedIn(req)) })),++ }));++++ // ── The camera app ───────────────────────────────────────++ r.get('/app', async (req, res) => {++ const photos = signedIn(req) ? await cameras.photosOf(req.viewer.subject, { limit: 12 }) : [];++ return page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'The camera app',++ description: 'Take a photo with on-device colour, light and blur, a timer, a burst and a grid; save it to OpenVibe.Pics.',++ body: raw(views.app({ signedIn: Boolean(signedIn(req)), photos })),++ });++ });++++ // ── My cameras ───────────────────────────────────────────++ async function renderMy(req, res, { problem = null, status = 200 } = {}) {++ const [list, photos, stills, counts] = await Promise.all([++ cameras.camerasOf(req.viewer.subject), cameras.photosOf(req.viewer.subject), cameras.stillsOf(req.viewer.subject), cameras.countsFor(req.viewer.subject),++ ]);++ return page(req, res, {++ title: 'My cameras',++ description: 'Pair a camera you own, grant one viewer for a week, and revoke it at once.',++ body: raw(views.my({ viewer: req.viewer, cameras: list, photos, stills, counts, problem })),++ }, status);++ }++++ r.get('/my', async (req, res) => {++ if (!signedIn(req)) return signedOut(req, res, 'My cameras', 'A camera belongs to your OpenVibe account, so you can pair it, grant a viewer and revoke it later.', '/my');++ return renderMy(req, res);++ });++++ r.post('/my/cameras', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fmy');++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const out = await pairCamera({ cameras, upstreams, principal: { subject: req.viewer.subject, username: req.viewer.username }, body: req.body || {} });++ if (!out.ok) return renderMy(req, res, { problem: out.message, status: out.status || 400 });++ return res.redirect(303, `/my/cameras/${encodeURIComponent(out.camera.id)}`);++ });++++ async function ownerCamera(req, res) {++ const row = await cameras.cameraById(String(req.params.id || ''));++ if (!row || row.owner !== req.viewer.subject) return null;++ return row;++ }++++ async function renderCamera(req, res, row, { problem = null, status = 200 } = {}) {++ const grants = await cameras.grantsOf(row.id, req.viewer.subject);++ const wall = await cameras.wall(row.id, req.viewer.subject);++ const camera = cameras.wireCamera(row);++ return page(req, res, {++ title: camera.name,++ description: `Manage ${camera.name}: its slot, its viewers and its wall.`,++ body: raw(views.camera({++ camera, grants: grants.ok ? grants.grants : [], photos: wall ? wall.photos : [],++ frameUrl: views.frameUrl(config, camera), problem,++ })),++ }, status);++ }++++ r.get('/my/cameras/:id', async (req, res) => {++ if (!signedIn(req)) return signedOut(req, res, 'My cameras', 'Sign in to manage this camera.', `/my/cameras/${req.params.id}`);++ const row = await ownerCamera(req, res);++ if (!row) {++ const access = await cameras.accessTo(String(req.params.id || ''), req.viewer.subject);++ if (access && !access.owner) return res.redirect(303, `/w/${encodeURIComponent(access.camera.id)}`);++ return notFound(req, res);++ }++ return renderCamera(req, res, row);++ });++++ r.post('/my/cameras/:id', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const row = await ownerCamera(req, res);++ if (!row) return notFound(req, res);++ const out = await cameras.patchCamera(row.id, req.viewer.subject, req.body || {});++ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });++ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}`);++ });++++ r.post('/my/cameras/:id/delete', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fmy');++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const row = await ownerCamera(req, res);++ if (!row) return notFound(req, res);++ await cameras.removeCamera(row.id, req.viewer.subject);++ return res.redirect(303, '/my');++ });++++ r.post('/my/cameras/:id/grants', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const row = await ownerCamera(req, res);++ if (!row) return notFound(req, res);++ const viewer = await resolveViewer({ upstreams, body: req.body || {} });++ if (!viewer.ok) return renderCamera(req, res, row, { problem: viewer.message, status: viewer.status || 400 });++ const out = await cameras.grantCamera(row.id, {++ owner: req.viewer.subject, viewerSubject: viewer.subject, viewerUsername: viewer.username, days: req.body && req.body.days,++ });++ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });++ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}?granted=1`);++ });++++ r.post('/my/cameras/:id/grants/:grantId/revoke', async (req, res) => {++ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);++ const row = await ownerCamera(req, res);++ if (!row) return notFound(req, res);++ const out = await cameras.revokeGrant(row.id, String(req.params.grantId || ''), { owner: req.viewer.subject });++ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });++ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}?revoked=1`);++ });++++ // ── The private wall ─────────────────────────────────────++ r.get('/w/:id', async (req, res) => {++ if (!signedIn(req)) return signedOut(req, res, 'A private camera', 'This wall opens only for its owner and the viewers they granted. Sign in to see whether one of them is you.', `/w/${req.params.id}`);++ const wall = await cameras.wall(String(req.params.id || ''), req.viewer.subject);++ if (!wall) return notFound(req, res);++ const camera = cameras.wireCamera(wall.camera);++ if (!wall.owner) delete camera.notes;++ return page(req, res, {++ title: camera.name,++ description: 'A private camera wall on OpenVibe.Cam.',++ body: raw(views.wall({ camera, access: { owner: wall.owner, grant: wall.grant }, photos: wall.photos, frameUrl: views.frameUrl(config, camera) })),++ });++ });++++ // ── One still ────────────────────────────────────────────++ r.get('/s/:id', async (req, res) => {++ const still = await cameras.stillById(String(req.params.id || ''));++ if (!still) return notFound(req, res);++ const mine = Boolean(signedIn(req) && req.viewer.subject === still.subject);++ if (still.visibility !== 'unlisted' && !mine) return notFound(req, res);++ return page(req, res, {++ title: still.caption || `A still from ${still.slot}`,++ description: still.caption ? `${still.caption} — a still from a live stream on OpenVibe.Cam.` : 'A still from a live stream on OpenVibe.Cam.',++ jsonLd: [seo.jsonLd.webPage({++ name: still.caption || `A still from ${still.slot}`,++ url: `${base()}/s/${still.id}`,++ description: still.caption || `A still from ${still.slot} on OpenVibe.Cam.`,++ siteUrl: base(),++ })],++ body: raw(views.still({ still, mine })),++ });++ });++++ // ── Developers ───────────────────────────────────────────++ r.get('/developers', (req, res) => page(req, res, {++ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Cam API',++ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',++ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Cam API', url: `${base()}/developers`, description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base() })],++ body: raw(views.developers()),++ }));++++ // ── Discovery and 404 ────────────────────────────────────++ r.use(createDiscoveryRoutes(ctx));++++ function notFound(req, res) {++ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);++ }++++ return r;++}++++module.exports = { createPageRoutes };nnew file mode 100644@@@ -0,0 +1,104 @@++'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 (cam_at).++ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }++ * a Network app, agent or service token for audience openvibe.cam. No Cam route needs one in v1: the Live cam++ * button calls POST /api/v1/stills with the viewer's own Network token, because OpenVibe.Pics uploads are a++ * person's (an app or service token is refused there). 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 pair a camera, grant a++ * viewer, revoke a grant or upload a photo.++ */++const { serviceAuth, http, ids } = require('openvibe-contracts');++++const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;++const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;++++// Cam's routes are a person acting for themself and a public read of a shared still. No capability.++const CAPABILITIES = [];++++function decodePayload(token) {++ const parts = String(token || '').split('.');++ if (parts.length !== 3) return null;++ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }++}++++function roleClaim(claims) {++ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';++}++function usernameClaim(claims) {++ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;++}++++function requesterOfSub(sub) {++ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;++ return sub;++}++++/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */++function sameOrigin(req, baseUrl) {++ const site = String(req.get('sec-fetch-site') || '');++ if (site) return site === 'same-origin';++ const origin = req.get('origin');++ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;++ return false;++}++++function createPrincipal({ config, keys }) {++ async function resolve(req) {++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) {++ const token = header.slice(7).trim();++ const payload = decodePayload(token);++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {++ const publicKey = await keys.pemForToken(token);++ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };++ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };++ }++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });++ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };++ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };++ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };++ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };++ }++ const viewer = req.viewer;++ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {++ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };++ }++ return { principal: { kind: 'anonymous' } };++ }++++ async function middleware(req, res, next) {++ const r = await resolve(req);++ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });++ req.principal = r.principal;++ return next();++ }++++ function require_(cap) {++ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Cam'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.cam, 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, 'cam.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.cam itself.', ctx: req.ov });++ return next();++ }++ return http.sendProblem(res, 403, 'cam.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,32 @@++'use strict';++++/**++ * Account export and deletion → Cam (ADR-033; openvibe-sdk/account-data). A person's cameras, the grants they gave++ * and the grants they received, their photos and their stills are theirs to export and erase.++ *++ * A deletion of a camera's owner removes the camera and (by the foreign key) every grant on it. A deletion of a++ * viewer removes the grants naming them and leaves the owner's camera alone. Cam never holds the picture bytes: a++ * photo or still row is a Pics slug and its public links, and OpenVibe.Pics erases the picture itself on the same++ * event. The database stores bare usr_ ids and no tokens.++ */++const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');++++const TABLES = [++ { table: 'cam_cameras', subject: 'owner', file: 'cameras.json', erase: 'delete' },++ { table: 'cam_grants', subject: 'owner', file: 'grants-given.json', erase: 'delete' },++ { table: 'cam_grants', subject: 'viewer_subject', file: 'grants-received.json', erase: 'delete' },++ { table: 'cam_photos', subject: 'subject', file: 'photos.json', erase: 'delete' },++ { table: 'cam_stills', subject: 'subject', file: 'stills.json', erase: 'delete' },++];++++function create({ db, log = console } = {}) {++ return createAccountData({++ db,++ service: 'cam',++ tables: TABLES,++ note: 'A camera is private to its owner; a grant is the only way in. Pictures live in OpenVibe.Pics; Cam keeps only the link.',++ log,++ });++}++++module.exports = { create, TABLES, TOPICS };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++++/**++ * OpenVibe.Cam — process entry. `node server/index.js`++ * Listens on PORT (5160) behind nginx (deploy/).++ */++const { createApp } = require('./app');++const { gracefulStop } = require('openvibe-sdk/service');++const { startTelemetryRetention } = require('openvibe-sdk/telemetry');++const { startSubscriptions } = require('openvibe-sdk/account-data');++++/**++ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the++ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.++ */++function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {++ return gracefulStop({++ name: 'OpenVibe.Cam', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,++ close: [++ () => { for (const t of timers) clearInterval(t); },++ () => ctx.keys.client.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.Cam] ${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();++++ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the++ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.++ const subs = config.drill ? null : startSubscriptions({++ eventsUrl: config.events.url,++ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,++ secret: config.events.secrets[0] || '',++ networkInternalUrl: config.networkInternalUrl,++ clientId: config.oauth.clientId,++ clientSecret: config.oauth.clientSecret,++ log: ctx.log || console,++ });++ const extra = [() => { if (subs) subs.stop(); }];++ createLifecycle({ server, ctx, extra });++ return { server, ctx };++}++++if (require.main === module) {++ start().catch((err) => { console.error('[OpenVibe.Cam] failed to start:', err); process.exit(1); });++}++++module.exports = { start, createLifecycle };nnew file mode 100644@@@ -0,0 +1,43 @@++'use strict';++++/**++ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,++ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has++ * signed in to yet is not degraded only because the first fetch had not happened.++ */++const { jwksStatus } = require('openvibe-sdk/auth');++const { createReadiness } = require('openvibe-shared/ready');++++function createServiceReadiness({ s, config, valkey = null, release = null }) {++ return createReadiness({++ service: 'cam',++ 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 and sharing by @username cannot complete') },++ {++ name: 'identity_resolve', required: false,++ check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: a grant needs a viewer subject'),++ },++ ],++ });++}++++module.exports = { createServiceReadiness };nnew file mode 100644@@@ -0,0 +1,32 @@++'use strict';++++/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }++const raw = (s) => new Raw(s);++function html(strings, ...values) {++ let out = strings[0];++ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });++ return raw(out);++}++function render(v) {++ if (v == null || v === false) return '';++ if (v instanceof Raw) return v.s;++ if (Array.isArray(v)) return v.map(render).join('');++ return esc(v);++}++++const code = (s) => html`<code>${s}</code>`;++const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');++++function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {++ if (!rows.length) return html`<p class="muted">${empty}</p>`;++ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;++}++++const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;++const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;++++module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };nnew file mode 100644@@@ -0,0 +1,118 @@++'use strict';++++/**++ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.++ * This is Meme's layout with Cam's name, navigation, room accent and assets.++ */++const crypto = require('crypto');++const fs = require('fs');++const path = require('path');++const appIcon = require('openvibe-shared/app-icon');++const shell = require('openvibe-shared/shell');++const rooms = require('openvibe-shared/rooms');++const ovServe = require('openvibe-shared/serve');++const cache = require('openvibe-shared/cache-policy');++const { render, esc } = require('./html');++++const NETWORK_URL = 'https://openvibe.network';++const SITE_NAME = 'OpenVibe.Cam';++const TAGLINE = 'Your camera, your cameras, and the moment Live\'s cam button takes.';++const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');++const NAV = [++ { label: 'Home', href: '/' },++ { label: 'Camera', href: '/app' },++ { label: 'My cameras', href: '/my' },++ { 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, scripts++ */++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: 'cam',++ 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: 'cam', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };++ const account = signedIn++ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`++ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;++ const crumbs = (o.crumbs || []).length++ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`++ : '';++ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);++ return shell.page({++ name: SITE_NAME,++ lang: 'en',++ title: o.title || `${SITE_NAME}: ${TAGLINE}`,++ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',++ siteName: SITE_NAME,++ description: o.description || `${SITE_NAME} — ${TAGLINE}`,++ canonical,++ robots: o.index ? 'index, follow' : 'noindex, nofollow',++ jsonLd: (o.jsonLd || []).filter(Boolean),++ home: '/',++ navLinks: NAV,++ navbar: nav,++ footer,++ head: `<meta name="referrer" content="strict-origin-when-cross-origin">++${appIcon.headTags({ site: 'cam' })}++<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/cam.css')}">++<script src="${ovServe.url('motion.js')}" defer></script>++<script src="${asset('js/cam.js')}" defer></script>++${render(scripts)}++<meta name="ov-boost" content="cam@${require('./html').esc(RELEASE)}">++<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,++ body: `<a class="skip" href="#main">Skip to content</a>++<div id="navbar-mount"></div>++<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>++<main id="main" class="cm ov-room" style="${rooms.style('cam')}">++${crumbs}++${render(o.body)}++</main>++<script>++window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};++document.addEventListener('DOMContentLoaded', function () {++ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }++});++</script>`,++ });++}++++/** Send a page; a page rendered for a signed-in person is private and never cached. */++function send(res, status, o) {++ const personal = o.viewer && o.viewer.kind === 'user';++ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));++ res.set('Vary', 'Cookie');++ res.status(status).type('html').send(renderPage(o));++}++++module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };nnew file mode 100644@@@ -0,0 +1,386 @@++'use strict';++++/**++ * Cam's data: paired cameras, revocable viewer grants, photos and stills.++ *++ * A camera is private. Its owner sees it; anybody else needs a grant that is not revoked and has not expired, and++ * the check runs on every read, so a revocation takes effect at once. A photo or a still is never bytes here: the++ * picture went to OpenVibe.Pics and this row keeps the Pics slug and links.++ */++const SOURCES = Object.freeze(['browser', 'phone', 'pi', 'ip']);++const STATUSES = Object.freeze(['paired', 'live', 'offline']);++const FILTERS = Object.freeze(['none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur']);++const STILL_VISIBILITIES = Object.freeze(['unlisted', 'private']);++const PHOTO_VISIBILITIES = Object.freeze(['private', 'unlisted']);++const CAPTION_MAX = 280;++const NAME_MAX = 80;++const NOTES_MAX = 500;++const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const BLOCKED_WORDS = Object.freeze(['nigger', 'faggot', 'retard', 'kys', 'rape', 'pedo', 'nazi']);++++const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));++const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));++const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });++++function cleanLine(v, max) {++ return String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);++}++++function controlChars(v) {++ return /[\u0000-\u001f\u007f]/.test(String(v == null ? '' : v));++}++++function blocked(text) {++ return BLOCKED_WORDS.some((w) => new RegExp(`(^|[^a-z0-9])${w}([^a-z0-9]|$)`, 'i').test(String(text)));++}++++/** A caption is plain text: 0–280 characters, no control characters, the short blocked-word list. */++function caption(raw) {++ if (raw == null || raw === '') return { ok: true, value: null };++ if (controlChars(raw)) return refusal(400, 'cam.invalid', 'A caption is plain text: no control characters', 'caption');++ const value = cleanLine(raw, CAPTION_MAX + 1);++ if (value.length > CAPTION_MAX) return refusal(400, 'cam.invalid', `A caption is at most ${CAPTION_MAX} characters`, 'caption');++ if (blocked(value)) return refusal(400, 'cam.blocked_words', 'That wording is not allowed here', 'caption');++ return { ok: true, value: value || null };++}++++function toSubject(raw) {++ const v = String(raw == null ? '' : raw).trim();++ return SUBJECT_RE.test(v) ? v : null;++}++++function wireCamera(row) {++ if (!row) return null;++ return {++ id: row.id,++ name: row.name,++ source: row.source,++ status: row.status,++ stream_id: nOrNull(row.stream_id),++ slot: row.slot == null ? null : row.slot,++ notes: row.notes == null ? null : row.notes,++ owner_username: row.owner_username == null ? null : row.owner_username,++ paired_at: iso(row.paired_at),++ last_seen_at: iso(row.last_seen_at),++ created_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ ...(row.active_grants != null ? { active_grants: Number(row.active_grants) || 0 } : {}),++ };++}++++function wireGrant(row, { now = Date.now() } = {}) {++ if (!row) return null;++ const revoked = row.revoked_at != null;++ const expired = Date.parse(row.expires_at) <= now;++ return {++ id: row.id,++ camera_id: row.camera_id,++ viewer_subject: row.viewer_subject,++ viewer_username: row.viewer_username == null ? null : row.viewer_username,++ granted_at: iso(row.granted_at),++ expires_at: iso(row.expires_at),++ revoked_at: iso(row.revoked_at),++ active: !revoked && !expired,++ };++}++++function wirePhoto(row, { owner = false } = {}) {++ if (!row) return null;++ return {++ id: row.id,++ camera_id: row.camera_id == null ? null : row.camera_id,++ filter: row.filter || 'none',++ caption: row.caption == null ? null : row.caption,++ visibility: row.visibility,++ pics_slug: row.pics_slug,++ pics_url: row.pics_url,++ pics_direct: row.pics_direct,++ created_at: iso(row.created_at),++ ...(owner ? { subject: row.subject } : {}),++ };++}++++function wireStill(row, { owner = false } = {}) {++ if (!row) return null;++ return {++ id: row.id,++ stream_id: nOrNull(row.stream_id),++ slot: row.slot,++ caption: row.caption == null ? null : row.caption,++ visibility: row.visibility,++ pics_slug: row.pics_slug,++ pics_url: row.pics_url,++ pics_direct: row.pics_direct,++ created_at: iso(row.created_at),++ ...(owner ? { subject: row.subject } : {}),++ };++}++++function createStore(s, { config }) {++ const db = s.db;++ const q = (t) => t || db;++ const clock = () => s.iso();++ const daysFromNow = (days) => new Date(s.now() + Number(days) * 86_400_000).toISOString();++++ async function cameraById(id, t) {++ return await q(t).maybe('SELECT * FROM cam_cameras WHERE id = $1', [String(id || '')]);++ }++++ async function camerasOf(owner) {++ const rows = await db.many(++ `SELECT c.*,++ (SELECT COUNT(*)::int FROM cam_grants g++ WHERE g.camera_id = c.id AND g.revoked_at IS NULL AND g.expires_at > $2::timestamptz) AS active_grants++ FROM cam_cameras c WHERE c.owner = $1 ORDER BY c.created_at DESC, c.id DESC`,++ [owner, clock()],++ );++ return rows.map(wireCamera);++ }++++ async function createCamera({ owner, ownerUsername = null, name, source = 'phone', streamId = null, slot = null, notes = null }) {++ if (!SUBJECT_RE.test(String(owner || ''))) return refusal(400, 'cam.invalid', 'A camera belongs to a signed-in person', 'owner');++ const cleanName = cleanLine(name, NAME_MAX + 1);++ if (!cleanName) return refusal(400, 'cam.invalid', 'Give the camera a name', 'name');++ if (cleanName.length > NAME_MAX) return refusal(400, 'cam.invalid', `A name is at most ${NAME_MAX} characters`, 'name');++ if (controlChars(name)) return refusal(400, 'cam.invalid', 'A name is plain text: no control characters', 'name');++ if (!SOURCES.includes(source)) return refusal(400, 'cam.invalid', `source must be one of ${SOURCES.join(', ')}`, 'source');++ if (streamId != null && (!Number.isInteger(Number(streamId)) || Number(streamId) <= 0)) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number', 'stream_id');++ if (slot != null && !SLOT_RE.test(String(slot))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');++ const cleanNotes = notes == null || notes === '' ? null : cleanLine(notes, NOTES_MAX + 1);++ if (cleanNotes && cleanNotes.length > NOTES_MAX) return refusal(400, 'cam.invalid', `Notes are at most ${NOTES_MAX} characters`, 'notes');++ if (cleanNotes && controlChars(notes)) return refusal(400, 'cam.invalid', 'Notes are plain text: no control characters', 'notes');++++ const count = Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [owner]));++ if (count >= config.cameras.maxPerOwner) return refusal(409, 'cam.camera_limit', `You can pair ${config.cameras.maxPerOwner} cameras; unpair one first.`);++++ const id = s.newId('cam');++ try {++ const row = await db.maybe(++ `INSERT INTO cam_cameras (id, owner, owner_username, name, source, status, stream_id, slot, notes, paired_at, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, 'paired', $6, $7, $8, $9::timestamptz, $9::timestamptz, $9::timestamptz)++ RETURNING *`,++ [id, owner, ownerUsername == null ? null : String(ownerUsername).slice(0, 64), cleanName, source, streamId == null ? null : Number(streamId), slot == null ? null : String(slot), cleanNotes, clock()],++ );++ return { ok: true, camera: wireCamera(row) };++ } catch (err) {++ if (err && err.code === '23505') return refusal(409, 'cam.camera_exists', 'That Live stream is already paired.');++ throw err;++ }++ }++++ async function patchCamera(id, owner, body) {++ const row = await cameraById(id);++ if (!row || row.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');++ const b = body && typeof body === 'object' ? body : {};++ const allowed = ['name', 'source', 'slot', 'notes', 'status'];++ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));++ if (unknown.length) return refusal(400, 'cam.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);++ const sets = [];++ const args = [id];++ if (b.name !== undefined) {++ const name = cleanLine(b.name, NAME_MAX + 1);++ if (!name || name.length > NAME_MAX || controlChars(b.name)) return refusal(400, 'cam.invalid', `name must be 1 to ${NAME_MAX} characters`, 'name');++ args.push(name); sets.push(`name = $${args.length}`);++ }++ if (b.source !== undefined) {++ if (!SOURCES.includes(b.source)) return refusal(400, 'cam.invalid', `source must be one of ${SOURCES.join(', ')}`, 'source');++ args.push(b.source); sets.push(`source = $${args.length}`);++ }++ if (b.status !== undefined) {++ if (!STATUSES.includes(b.status)) return refusal(400, 'cam.invalid', `status must be one of ${STATUSES.join(', ')}`, 'status');++ args.push(b.status); sets.push(`status = $${args.length}`);++ }++ if (b.slot !== undefined) {++ if (b.slot != null && b.slot !== '' && !SLOT_RE.test(String(b.slot))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');++ args.push(b.slot == null || b.slot === '' ? null : String(b.slot)); sets.push(`slot = $${args.length}`);++ }++ if (b.notes !== undefined) {++ const notes = b.notes == null || b.notes === '' ? null : cleanLine(b.notes, NOTES_MAX + 1);++ if (notes && (notes.length > NOTES_MAX || controlChars(b.notes))) return refusal(400, 'cam.invalid', `Notes are at most ${NOTES_MAX} characters`, 'notes');++ args.push(notes); sets.push(`notes = $${args.length}`);++ }++ if (!sets.length) return refusal(400, 'cam.invalid', 'Send a name, source, status, slot or notes', null);++ args.push(clock());++ const updated = await db.maybe(`UPDATE cam_cameras SET ${sets.join(', ')}, updated_at = $${args.length}::timestamptz WHERE id = $1 RETURNING *`, args);++ return { ok: true, camera: wireCamera(updated) };++ }++++ async function removeCamera(id, owner) {++ const row = await cameraById(id);++ if (!row || row.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');++ await db.exec('DELETE FROM cam_cameras WHERE id = $1', [row.id]);++ return { ok: true, id: row.id };++ }++++ /** A new grant, or a longer one when this viewer already has an active grant. */++ async function grantCamera(cameraId, { owner, viewerSubject, viewerUsername = null, days = null }) {++ const camera = await cameraById(cameraId);++ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');++ const wanted = toSubject(viewerSubject);++ if (!wanted) return refusal(400, 'cam.invalid', 'viewer_subject must be a person\'s canonical subject (usr_…)', 'viewer_subject');++ if (wanted === owner) return refusal(400, 'cam.invalid', 'A camera cannot be granted to its owner.', 'viewer_subject');++ const n = days == null || days === '' ? config.cameras.grantDays : Number(days);++ if (!Number.isInteger(n) || n < 1 || n > config.cameras.grantMaxDays) return refusal(400, 'cam.invalid', `days must be a whole number from 1 to ${config.cameras.grantMaxDays}`, 'days');++++ const activeCount = Number(await db.value(++ 'SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1 AND revoked_at IS NULL AND expires_at > $2::timestamptz',++ [camera.id, clock()],++ ));++ const existing = await db.maybe(++ 'SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL ORDER BY granted_at DESC LIMIT 1',++ [camera.id, wanted],++ );++ if (!existing && activeCount >= config.cameras.grantsPerCamera) {++ return refusal(409, 'cam.grant_limit', `A camera can have ${config.cameras.grantsPerCamera} viewers at once.`);++ }++ const expiresAt = daysFromNow(n);++ const username = viewerUsername == null ? null : cleanLine(viewerUsername, 64) || null;++ const wasActive = Boolean(existing && Date.parse(existing.expires_at) > s.now());++ let row;++ try {++ row = existing++ ? await db.maybe('UPDATE cam_grants SET expires_at = $2::timestamptz, viewer_username = COALESCE($3, viewer_username) WHERE id = $1 RETURNING *', [existing.id, expiresAt, username])++ : await db.maybe(++ `INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, viewer_username, granted_at, expires_at, created_at)++ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $6::timestamptz) RETURNING *`,++ [s.newId('cmg'), camera.id, owner, wanted, username, clock(), expiresAt],++ );++ } catch (err) {++ // Two tabs granting the same viewer at once: the partial unique index picks one; the caller sees the grant.++ if (err && err.code === '23505') {++ const found = await db.maybe('SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL', [camera.id, wanted]);++ if (found) return { ok: true, grant: wireGrant(found, { now: s.now() }), changed: false, days: n };++ }++ throw err;++ }++ return { ok: true, grant: wireGrant(row, { now: s.now() }), changed: !wasActive, days: n };++ }++++ async function grantsOf(cameraId, owner) {++ const camera = await cameraById(cameraId);++ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');++ const rows = await db.many('SELECT * FROM cam_grants WHERE camera_id = $1 ORDER BY granted_at DESC, id DESC', [camera.id]);++ return { ok: true, grants: rows.map((r) => wireGrant(r, { now: s.now() })) };++ }++++ async function revokeGrant(cameraId, grantId, { owner }) {++ const camera = await cameraById(cameraId);++ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');++ const row = await db.maybe('SELECT * FROM cam_grants WHERE id = $1 AND camera_id = $2', [String(grantId || ''), camera.id]);++ if (!row) return refusal(404, 'cam.grant_not_found', 'No such viewer grant.');++ if (row.revoked_at != null) return { ok: true, revoked: false, already: true, grant: wireGrant(row, { now: s.now() }) };++ const updated = await db.maybe('UPDATE cam_grants SET revoked_at = $2::timestamptz, revoked_by = $3 WHERE id = $1 RETURNING *', [row.id, clock(), owner]);++ return { ok: true, revoked: true, already: false, grant: wireGrant(updated, { now: s.now() }) };++ }++++ async function activeGrant(cameraId, subject) {++ if (!SUBJECT_RE.test(String(subject || ''))) return null;++ return await db.maybe(++ 'SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL AND expires_at > $3::timestamptz ORDER BY granted_at DESC LIMIT 1',++ [String(cameraId), subject, clock()],++ );++ }++++ /** Owner or an active viewer; null means "not found" to the caller, never "forbidden". */++ async function accessTo(cameraId, subject) {++ const camera = await cameraById(cameraId);++ if (!camera) return null;++ if (camera.owner === subject) return { camera, owner: true, grant: null };++ const grant = await activeGrant(camera.id, subject);++ return grant ? { camera, owner: false, grant: wireGrant(grant, { now: s.now() }) } : null;++ }++++ async function wall(cameraId, subject) {++ const access = await accessTo(cameraId, subject);++ if (!access) return null;++ const rows = await db.many(++ `SELECT * FROM cam_photos WHERE camera_id = $1 ${access.owner ? '' : "AND visibility = 'unlisted'"} ORDER BY created_at DESC, id DESC LIMIT 60`,++ [access.camera.id],++ );++ return { ...access, photos: rows.map((r) => wirePhoto(r, { owner: access.owner })) };++ }++++ /** A photo row after its bytes have gone to Pics. A photo on a camera wall is unlisted; otherwise it is private. */++ async function createPhoto({ subject, cameraId = null, filter = 'none', caption: rawCaption = null, pics }) {++ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(400, 'cam.invalid', 'A photo belongs to a signed-in person', 'subject');++ let camera = null;++ if (cameraId != null && cameraId !== '') {++ camera = await cameraById(cameraId);++ if (!camera || camera.owner !== subject) return refusal(404, 'cam.not_found', 'No such camera of yours.');++ }++ const cleanFilter = FILTERS.includes(filter) ? filter : 'none';++ const cap = caption(rawCaption);++ if (!cap.ok) return cap;++ if (!pics || !pics.slug || !pics.page) return refusal(400, 'cam.invalid', 'The picture was not stored', 'pics');++ const row = await db.maybe(++ `INSERT INTO cam_photos (id, subject, camera_id, filter, caption, visibility, pics_slug, pics_url, pics_direct, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz) RETURNING *`,++ [s.newId('cmp'), subject, camera ? camera.id : null, cleanFilter, cap.value, camera ? 'unlisted' : 'private', pics.slug, pics.page, pics.direct || pics.page, clock()],++ );++ return { ok: true, photo: wirePhoto(row, { owner: true }) };++ }++++ async function photoById(id) {++ return wirePhoto(await db.maybe('SELECT * FROM cam_photos WHERE id = $1', [String(id || '')]), { owner: true });++ }++++ async function photosOf(subject, { limit = 60 } = {}) {++ const rows = await db.many('SELECT * FROM cam_photos WHERE subject = $1 ORDER BY created_at DESC, id DESC LIMIT $2',++ [subject, Math.min(200, Math.max(1, Number(limit) || 60))]);++ return rows.map((r) => wirePhoto(r, { owner: true }));++ }++++ /** A still's bytes also went to Pics; a still is unlisted by default so the link in chat opens it. */++ async function createStill({ subject, streamId = null, slot, caption: rawCaption = null, visibility = 'unlisted', pics }) {++ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(400, 'cam.invalid', 'A still belongs to a signed-in person', 'subject');++ if (!SLOT_RE.test(String(slot || ''))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');++ if (streamId != null && (!Number.isInteger(Number(streamId)) || Number(streamId) <= 0)) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number', 'stream_id');++ if (!STILL_VISIBILITIES.includes(visibility)) return refusal(400, 'cam.invalid', `visibility must be one of ${STILL_VISIBILITIES.join(', ')}`, 'visibility');++ const cap = caption(rawCaption);++ if (!cap.ok) return cap;++ if (!pics || !pics.slug || !pics.page) return refusal(400, 'cam.invalid', 'The picture was not stored', 'pics');++ const row = await db.maybe(++ `INSERT INTO cam_stills (id, subject, stream_id, slot, caption, visibility, pics_slug, pics_url, pics_direct, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz) RETURNING *`,++ [s.newId('cms'), subject, streamId == null ? null : Number(streamId), String(slot), cap.value, visibility, pics.slug, pics.page, pics.direct || pics.page, clock()],++ );++ return { ok: true, still: wireStill(row, { owner: true }) };++ }++++ async function stillById(id) {++ return wireStill(await db.maybe('SELECT * FROM cam_stills WHERE id = $1', [String(id || '')]), { owner: true });++ }++++ async function stillsOf(subject, { limit = 60 } = {}) {++ const rows = await db.many('SELECT * FROM cam_stills WHERE subject = $1 ORDER BY created_at DESC, id DESC LIMIT $2',++ [subject, Math.min(200, Math.max(1, Number(limit) || 60))]);++ return rows.map((r) => wireStill(r, { owner: true }));++ }++++ async function countsFor(subject) {++ const row = await db.maybe(++ `SELECT++ (SELECT COUNT(*)::int FROM cam_cameras WHERE owner = $1) AS cameras,++ (SELECT COUNT(*)::int FROM cam_photos WHERE subject = $1) AS photos,++ (SELECT COUNT(*)::int FROM cam_stills WHERE subject = $1) AS stills`,++ [subject],++ );++ return { cameras: Number(row.cameras) || 0, photos: Number(row.photos) || 0, stills: Number(row.stills) || 0 };++ }++++ return {++ wireCamera, wireGrant, wirePhoto, wireStill,++ cameraById, camerasOf, createCamera, patchCamera, removeCamera,++ grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,++ createPhoto, photoById, photosOf,++ createStill, stillById, stillsOf,++ countsFor,++ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, CAPTION_MAX, NAME_MAX, SLOT_RE, SUBJECT_RE, BLOCKED_WORDS,++ };++}++++module.exports = {++ createStore, caption, toSubject, wireCamera, wireGrant, wirePhoto, wireStill,++ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, BLOCKED_WORDS, SLOT_RE, SUBJECT_RE,++};nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++++/**++ * The existing APIs Cam uses. No picture bytes are stored here and no service grant is needed to read a frame:++ *++ * OpenVibe.Media GET /live/:sel/frame.jpg?w=640 the public, CORS-open near-realtime JPEG of a live slot++ * OpenVibe.Live GET /api/streams/:id the public stream row and its channel (to check ownership)++ * OpenVibe.Pics POST /api/v1/images with the signed-in person's own Network token as the body++ * OpenVibe.Network GET /internal/identity/resolve with Cam's service token (identity.subject.resolve) only when++ * the owner shares a camera with @username++ *++ * A 404 from Live means no such stream. Media answers an offline card (SVG) with a 404 for a slot that is not live:++ * that is never uploaded to Pics. A failed call is not cached and no token or body is logged.++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const USERNAME_RE = /^[A-Za-z0-9_.-]{1,40}$/;++++class UpstreamError extends Error {++ constructor(code, detail, { status = 502, cause = null } = {}) { super(detail); this.code = code; this.status = status; this.cause = cause; }++}++++function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {++ const { media, live, pics, timeoutMs } = config.upstreams;++ const base = (u) => String(u).replace(/\/+$/, '');++ const ua = () => `OpenVibe.Cam/0.1 (+${config.baseUrl})`;++++ async function call(url, { method = 'GET', headers = {}, body = null, timeout = timeoutMs, redirect = 'follow' } = {}) {++ try {++ return await fetchImpl(url, {++ method, redirect, headers: { Accept: 'application/json', 'User-Agent': ua(), ...headers }, body,++ signal: AbortSignal.timeout(timeout),++ });++ } catch (err) {++ throw new UpstreamError('cam.upstream.unreachable', 'the upstream service did not answer', { cause: err });++ }++ }++++ /** The public Live stream row, or null when it does not exist. */++ async function liveStream(id) {++ const n = Number(id);++ if (!Number.isInteger(n) || n <= 0) throw new UpstreamError('cam.live.invalid', 'stream_id must be a positive whole number', { status: 400 });++ const res = await call(`${base(live)}/api/streams/${n}`);++ if (res.status === 404) return null;++ if (!res.ok) throw new UpstreamError('cam.live.unavailable', `OpenVibe.Live answered ${res.status}`);++ const data = await res.json().catch(() => null);++ if (!data || !data.stream) throw new UpstreamError('cam.live.unavailable', 'OpenVibe.Live answered no stream');++ return data.stream;++ }++++ /** Media's live frame. Only image/jpeg is returned; an offline card or an HTML error is never a picture. */++ async function liveFrame({ slot, w, maxBytes }) {++ const sel = String(slot || '');++ const q = new URLSearchParams();++ if (w != null) q.set('w', String(w));++ const url = `${base(media)}/live/${encodeURIComponent(sel)}/frame.jpg${q.toString() ? `?${q.toString()}` : ''}`;++ let res;++ try {++ res = await call(url, { headers: { Accept: 'image/jpeg' } });++ } catch (err) {++ throw new UpstreamError('cam.frame_unavailable', 'OpenVibe.Media did not answer', { cause: err });++ }++ if (res.status === 404 || res.status === 410) return { ok: false, code: 'offline', status: res.status };++ if (res.status === 429) return { ok: false, code: 'busy', status: 429 };++ if (!res.ok) return { ok: false, code: 'status', status: res.status };++ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();++ if (type !== 'image/jpeg') return { ok: false, code: 'not_jpeg', status: res.status, type };++ const buf = Buffer.from(await res.arrayBuffer());++ if (!buf.length) return { ok: false, code: 'empty', status: res.status };++ if (buf.length > maxBytes) return { ok: false, code: 'too_large', status: res.status, bytes: buf.length };++ return { ok: true, bytes: buf, contentType: 'image/jpeg', bytes_length: buf.length };++ }++++ /**++ * Upload a picture to OpenVibe.Pics as the person. `token` is the person's Network token from their Cam session;++ * it is sent only here and is never stored. Pics re-encodes the picture and strips its metadata.++ */++ async function picsUpload({ token, bytes, contentType, title = null, filename = null }) {++ if (!token) throw new UpstreamError('cam.pics.refused', 'a person\'s Network token is needed to save a picture', { status: 401 });++ const headers = { 'Content-Type': String(contentType || 'image/jpeg'), Accept: 'application/json', Authorization: `Bearer ${token}` };++ if (title) headers['X-Title'] = encodeURIComponent(String(title).slice(0, 200));++ if (filename) headers['X-Filename'] = encodeURIComponent(String(filename).slice(0, 200));++ let res;++ try {++ res = await call(`${base(pics)}/api/v1/images`, { method: 'POST', headers, body: bytes, timeout: Math.max(timeoutMs, 15_000) });++ } catch (err) {++ throw new UpstreamError('cam.pics.unavailable', 'OpenVibe.Pics did not answer', { cause: err });++ }++ const data = await res.json().catch(() => null);++ if (!res.ok || !data || !data.slug) {++ const code = res.status === 401 || res.status === 403 ? 'cam.pics.refused'++ : res.status === 413 ? 'cam.pics.too_large'++ : res.status === 415 ? 'cam.pics.not_an_image' : 'cam.pics.unavailable';++ throw new UpstreamError(code, (data && (data.detail || data.code)) ? String(data.detail || data.code).slice(0, 200) : `OpenVibe.Pics answered ${res.status}`, { status: res.status >= 400 && res.status < 500 ? res.status : 502 });++ }++ const links = data.links && typeof data.links === 'object' ? data.links : {};++ return {++ slug: String(data.slug),++ page: String(links.page || `${base(pics)}/i/${data.slug}`),++ direct: String(links.direct || `${base(pics)}/i/${data.slug}.${data.ext || 'jpg'}`),++ thumb: links.thumb ? String(links.thumb) : null,++ mime_type: data.mime_type || contentType,++ width: Number(data.width) || null,++ height: Number(data.height) || null,++ };++ }++++ const tokens = config.oauth.clientSecret++ ? createServiceTokenClient({++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,++ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetch: fetchImpl,++ })++ : null;++++ /** Resolve @username to a canonical subject with Cam's service token. Disabled without the OAuth client secret. */++ async function resolveUsername(username) {++ const name = String(username || '').trim().replace(/^@/, '');++ if (!USERNAME_RE.test(name)) return { ok: false, code: 'invalid' };++ if (!tokens) return { ok: false, code: 'unavailable' };++ let token;++ try { token = await tokens.getToken(); } catch (err) {++ log.warn(`[OpenVibe.Cam] service token for identity.subject.resolve failed: ${(err && err.message) || err}`);++ return { ok: false, code: 'unavailable' };++ }++ let res;++ try {++ res = await call(`${config.networkInternalUrl}/internal/identity/resolve?username=${encodeURIComponent(name)}`, {++ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, timeout: timeoutMs,++ });++ } catch {++ return { ok: false, code: 'unavailable' };++ }++ if (res.status === 404) return { ok: false, code: 'not_found' };++ if (res.status === 401 || res.status === 403) return { ok: false, code: 'refused' };++ if (!res.ok) return { ok: false, code: 'unavailable' };++ const data = await res.json().catch(() => null);++ const subject = data && data.subject && data.subject.type === 'user' ? String(data.subject.id || '') : '';++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || data.banned || data.deleted) return { ok: false, code: 'not_found' };++ return { ok: true, subject, username: data.username || name, display_name: data.display_name || data.username || name };++ }++++ return { liveStream, liveFrame, picsUpload, resolveUsername, UpstreamError, base };++}++++module.exports = { createUpstreams, UpstreamError, USERNAME_RE };nnew file mode 100644@@@ -0,0 +1,320 @@++'use strict';++++/**++ * The Cam room's pages (plan 2026-10-28 §5.13). They are plain semantic placeholders with stable `cm-` ids and++ * classes, complete without JavaScript: every action is a real form or link. public/css/cam.css is a one-comment++ * placeholder the main session replaces; public/js/cam.js adds the live preview, the timer, the burst and the++ * on-device filter, and posts the frame to /api/v1/photos.++ *++ * home({ signedIn }) the hero, the three scenes, the privacy rules++ * app({ signedIn, photos }) the camera app++ * my({ viewer, cameras, photos, stills, counts }) pair a camera, your cameras, photos and stills++ * camera({ camera, grants, photos, frameUrl }) one camera's owner page: edit, grants, unpair++ * wall({ camera, access, photos, frameUrl }) the private wall an owner or a grantee sees++ * still({ still, mine }) one still of a live stream++ * developers() the API and the Live cam button contract++ * signIn({ title, text, next }) a page that needs an account++ * notFound()++ */++const { esc, time } = require('../render/html');++const kit = require('openvibe-shared/kit');++const sc = require('openvibe-shared/showcase');++++const SOURCE_LABEL = { browser: 'Browser', phone: 'Phone', pi: 'Pi', ip: 'IP camera' };++const STATUS_LABEL = { paired: 'Paired', live: 'Live', offline: 'Offline' };++const FILTER_LABEL = { none: 'None', mono: 'Mono', warm: 'Warm', cool: 'Cool', fade: 'Fade', vivid: 'Vivid', blur: 'Soft' };++++const sourceLabel = (s) => esc(SOURCE_LABEL[s] || s);++const statusBadge = (s) => `<span class="cm-status cm-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;++const day = (iso) => esc(String(iso || '').slice(0, 10));++++function frameUrl(config, camera) {++ if (!camera || !camera.slot) return null;++ return `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(camera.slot)}/frame.jpg?w=640`;++}++++/** A shutter iris opening onto a grid of frames, and a photo flying into a gallery. */++function shutterMotif() {++ const frames = [1, 2, 3, 4, 5, 6].map((n) => `<span class="cm-frame cm-frame-${n}"></span>`).join('');++ return `<div class="cm-shutter" aria-hidden="true">++<svg class="cm-iris" viewBox="0 0 200 200"><circle cx="100" cy="100" r="82" fill="none" stroke="currentColor" stroke-width="2" opacity=".45"/>++${[0, 45, 90, 135].map((a) => `<path d="M100 30 A70 70 0 0 1 170 100" transform="rotate(${a} 100 100)" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" opacity=".75"/>`).join('')}++<circle cx="100" cy="100" r="26" fill="currentColor" opacity=".9"/></svg>++<div class="cm-frame-grid">${frames}</div>++</div>`;++}++++function photoCard(p, { owner = false } = {}) {++ return `<li class="cm-photo" data-photo="${esc(p.id)}">++<a class="cm-photo-link" href="${esc(p.pics_url)}" rel="noopener"><img src="${esc(p.pics_direct)}" alt="${esc(p.caption || 'A photo on OpenVibe.Cam')}" loading="lazy" width="320" height="240"></a>++<span class="cm-photo-meta">${esc(FILTER_LABEL[p.filter] || p.filter || 'None')}${p.caption ? ` · ${esc(p.caption)}` : ''}${owner ? ` · ${day(p.created_at)}` : ''}</span>++</li>`;++}++++function stillCard(s) {++ return `<li class="cm-still" data-still="${esc(s.id)}">++<a class="cm-still-link" href="/s/${esc(s.id)}"><img src="${esc(s.pics_direct)}" alt="${esc(s.caption || 'A still from a live stream')}" loading="lazy" width="320" height="180"></a>++<span class="cm-still-meta">${esc(s.slot)}${s.caption ? ` · ${esc(s.caption)}` : ''} · ${day(s.created_at)}</span>++</li>`;++}++++function cameraCard(c) {++ return `<li class="cm-camera-card" data-camera="${esc(c.id)}">++<a class="cm-camera-link" href="/my/cameras/${esc(c.id)}">++<span class="cm-camera-name">${esc(c.name)}</span>++<span class="cm-camera-meta">${sourceLabel(c.source)} · ${statusBadge(c.status)}${c.slot ? ` · <code>${esc(c.slot)}</code>` : ' · no live slot yet'}</span>++<span class="cm-camera-grants">${Number(c.active_grants) || 0} ${Number(c.active_grants) === 1 ? 'viewer' : 'viewers'}</span>++</a></li>`;++}++++function empty(text, href = '/app', label = 'Open the camera') {++ return `<div class="cm-empty"><p>${esc(text)}</p><a class="cm-btn cm-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;++}++++function home({ signedIn = false } = {}) {++ const room = sc.room({++ room: 'cam',++ hero: {++ title: 'Every camera has a moment',++ lede: 'Take a photo with the filters you choose, keep a camera of your own behind a private wall, and share it with one person for a week — or snap a still from a Live stream and send it to the chat.',++ start: { href: '/app', label: 'Open the camera' },++ chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],++ proof: [{ value: '0', label: 'public cameras' }],++ motif: shutterMotif(),++ },++ scenes: [++ {++ title: 'A still from a stream',++ text: 'The cam button takes the frame OpenVibe.Media already serves, saves it to your OpenVibe.Pics as a still with your caption, and hands the chat line back to Live. Nothing is recorded and nothing is analysed.',++ html: `<figure class="cm-demo-still"><img src="/img/cam-shutter.svg" alt="" width="320" height="180"><figcaption>“the exact second the robot fell over”</figcaption></figure>`,++ action: { href: '/developers', label: 'How the cam button calls Cam' },++ },++ {++ title: 'A camera behind a grant',++ text: 'Pair a phone, a Pi or an IP camera you own. Its wall is private; you name one viewer, choose a week, and revoke it at any time. The next request is refused — there is no cached copy of a grant.',++ html: `<div class="cm-demo-grant"><span class="cm-demo-cam">Front door</span><span class="cm-demo-arrow">→</span><span class="cm-demo-viewer">@ada</span><span class="cm-demo-days">7 days</span></div>`,++ action: { href: '/my', label: 'Pair a camera' },++ },++ {++ title: 'A photo to Pics',++ text: 'The camera app runs the colour, light and blur on your device. The picture goes to OpenVibe.Pics; Cam keeps the link and the filter name, never the bytes.',++ html: `<figure class="cm-demo-photo"><img src="/img/cam-shutter.svg" alt="" width="320" height="240"><figcaption>Warm · saved to Pics</figcaption></figure>`,++ action: { href: '/app', label: 'Take a photo' },++ },++ ],++ faq: {++ items: [++ ['Is my camera public?', 'No. A camera is private to its owner. A viewer sees its wall only while a grant you gave is active, and a revocation is checked on every read. v1 has no public directory.'],++ ['Where are my photos and stills?', 'In OpenVibe.Pics, as your own pictures. Cam stores the Pics slug and links, the filter name and your caption — never the image bytes.'],++ ['Does Cam recognise faces?', 'No. There is no face recognition, no identity classification and no AI analysis of a picture. Filters are colour and light, applied in your browser.'],++ ['What does the cam button on Live do?', 'It calls Cam with your own Network token. Cam fetches the live frame from OpenVibe.Media, saves it to your Pics as a still and returns the line Live posts in the stream chat.'],++ ],++ },++ });++ const live = `<section class="cm-section" id="cm-rules" aria-labelledby="cm-rules-h">++<div class="cm-head-row"><h2 id="cm-rules-h">The privacy rules, up front</h2>${signedIn ? '<a class="cm-more" href="/my">Your cameras' + kit.icon('chevron') + '</a>' : '<a class="cm-more" href="/auth/login?next=%2Fmy">Sign in' + kit.icon('chevron') + '</a>'}</div>++<ul class="cm-rules">++<li><strong>Private by default.</strong> A paired camera has no public page. A grant is the only way in.</li>++<li><strong>A grant is a person and an expiry.</strong> Seven days by default, ninety at most. Revoking it refuses the next request.</li>++<li><strong>No face recognition.</strong> Nothing identifies anyone; effects never leave your device.</li>++<li><strong>Nothing public without its owner's grant.</strong> v1 has no camera directory, no timelapse and no public wall.</li>++</ul>++</section>`;++ return `<div class="cm-wrap cm-home" id="cm-home">${room}${live}</div>`;++}++++function app({ signedIn = false, photos = [] } = {}) {++ const options = Object.entries(FILTER_LABEL).map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('');++ return `<div class="cm-wrap" id="cm-app">++<header class="cm-page-h"><p class="cm-kicker">The camera app</p><h1>Take a photo</h1>++<p class="cm-lede">The preview, the timer, the burst and every filter run in your browser. The picture you keep goes to OpenVibe.Pics; Cam stores the link, the filter and your caption.</p></header>++${signedIn ? '' : `<p class="cm-note">You can try the camera now. <a href="/auth/login?next=%2Fapp">Sign in</a> to save a photo to your Pics.</p>`}++<section class="cm-camera" id="cm-camera" data-cm-camera>++<div class="cm-stage">++<video data-cm-preview playsinline muted aria-label="Camera preview"></video>++<canvas data-cm-canvas hidden></canvas>++<div class="cm-grid-overlay" data-cm-grid hidden aria-hidden="true"></div>++</div>++<div class="cm-controls">++<button class="cm-btn" type="button" data-cm-start>Turn the camera on</button>++<button class="cm-btn cm-btn-quiet" type="button" data-cm-shoot disabled>Take the photo</button>++<label class="cm-field"><span>Timer</span><select data-cm-timer><option value="0">Off</option><option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option></select></label>++<button class="cm-btn cm-btn-quiet" type="button" data-cm-burst>Burst of 3</button>++<label class="cm-field"><span>Filter</span><select data-cm-filter>${options}</select></label>++<label class="cm-field"><span>Caption</span><input data-cm-caption type="text" maxlength="280" placeholder="A line for the photo"></label>++<button class="cm-btn cm-btn-quiet" type="button" data-cm-grid-toggle aria-pressed="false">Grid</button>++</div>++<p class="cm-status" data-cm-status role="status">The camera is off.</p>++<label class="cm-field"><span>Or pick a picture</span><input data-cm-file type="file" accept="image/jpeg,image/png,image/webp"></label>++<noscript><p class="cm-fine">The live preview needs JavaScript. You can still use the API at <code>POST /api/v1/photos</code>.</p></noscript>++</section>++<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">++<div class="cm-head-row"><h2 id="cm-gallery-h">Your photos</h2>${signedIn ? `<span class="cm-count">${photos.length} recent</span>` : ''}</div>++${signedIn ? (photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : empty('No photo yet. Take one above; it will appear here once Pics has it.', '/app', 'Take a photo')) : empty('Sign in to see the photos you saved to Pics.', '/auth/login?next=%2Fapp', 'Sign in with OpenVibe')}++</section>++</div>`;++}++++function my({ viewer, cameras = [], photos = [], stills = [], counts = {}, problem = null } = {}) {++ return `<div class="cm-wrap" id="cm-my">++<header class="cm-page-h"><p class="cm-kicker">My cameras</p><h1>Your cameras, your viewers</h1>++<p class="cm-lede">Pair a camera you own. Its wall is private; a viewer sees it only while the grant you gave is active. ${Number(counts.cameras) || cameras.length} ${Number(counts.cameras) === 1 ? 'camera' : 'cameras'}, ${Number(counts.photos) || photos.length} ${Number(counts.photos) === 1 ? 'photo' : 'photos'}, ${Number(counts.stills) || stills.length} ${Number(counts.stills) === 1 ? 'still' : 'stills'}.</p></header>++${problem ? `<p class="cm-note is-bad" role="alert">${esc(problem)}</p>` : ''}++++<section class="cm-section" id="cm-pair" aria-labelledby="cm-pair-h">++<h2 id="cm-pair-h">Pair a camera</h2>++<form class="cm-form cm-card" method="post" action="/my/cameras">++<label class="cm-field"><span>Name</span><input name="name" type="text" maxlength="80" required placeholder="Front door"></label>++<label class="cm-field"><span>What is it?</span><select name="source"><option value="phone">An old phone</option><option value="browser">A browser</option><option value="pi">A Pi</option><option value="ip">An IP camera</option></select></label>++<label class="cm-field"><span>Live stream id (optional)</span><input name="stream_id" type="number" min="1" step="1" inputmode="numeric" placeholder="1234"></label>++<label class="cm-field"><span>Live slot, slug or @username (optional)</span><input name="slot" type="text" maxlength="80" placeholder="whip or @ada"></label>++<label class="cm-field"><span>Note (only you see it)</span><textarea name="notes" rows="2" maxlength="500"></textarea></label>++<button class="cm-btn" type="submit">Pair the camera</button>++<p class="cm-fine">A stream id is checked against OpenVibe.Live and must be a stream on your own channel. The slot is what OpenVibe.Media's frame endpoint reads.</p>++</form>++</section>++++<section class="cm-section" id="cm-cameras" aria-labelledby="cm-cameras-h">++<div class="cm-head-row"><h2 id="cm-cameras-h">Paired</h2><span class="cm-count">${cameras.length}</span></div>++${cameras.length ? `<ul class="cm-cameras">${cameras.map(cameraCard).join('')}</ul>` : empty('No camera is paired yet. An old phone is the fastest one: pair it, then grant one viewer.', '#cm-pair', 'Pair one above')}++</section>++++<section class="cm-section" id="cm-photos" aria-labelledby="cm-photos-h">++<div class="cm-head-row"><h2 id="cm-photos-h">Your photos</h2><a class="cm-more" href="/app">Take one${kit.icon('chevron')}</a></div>++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">No photo yet.</p>'}++</section>++++<section class="cm-section" id="cm-stills" aria-labelledby="cm-stills-h">++<div class="cm-head-row"><h2 id="cm-stills-h">Your stills</h2><span class="cm-count">${stills.length}</span></div>++${stills.length ? `<ul class="cm-stills">${stills.map(stillCard).join('')}</ul>` : '<p class="cm-muted">A still comes from a Live stream, through the cam button or <code>POST /api/v1/stills</code>.</p>'}++</section>++</div>`;++}++++function camera({ camera: c, grants = [], photos = [], frameUrl: frame = null, problem = null } = {}) {++ const active = grants.filter((g) => g.active);++ return `<div class="cm-wrap" id="cm-camera-owner" data-camera="${esc(c.id)}">++<header class="cm-page-h"><p class="cm-kicker">${sourceLabel(c.source)} · ${statusBadge(c.status)}</p><h1>${esc(c.name)}</h1>++<p class="cm-lede">${c.slot ? `The wall shows <code>${esc(c.slot)}</code>'s current frame from OpenVibe.Media.` : 'No live slot yet: set one below and the wall will show its frame.'} ${active.length} active ${active.length === 1 ? 'grant' : 'grants'}.</p></header>++${problem ? `<p class="cm-note is-bad" role="alert">${esc(problem)}</p>` : ''}++++<section class="cm-wall-preview" id="cm-frame">++${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640" height="360" loading="lazy">` : '<div class="cm-empty"><p>No frame yet.</p><span class="cm-fine">A slot is needed.</span></div>'}++</section>++++<section class="cm-section" id="cm-edit" aria-labelledby="cm-edit-h">++<h2 id="cm-edit-h">The camera</h2>++<form class="cm-form cm-card" method="post" action="/my/cameras/${esc(c.id)}">++<label class="cm-field"><span>Name</span><input name="name" type="text" maxlength="80" value="${esc(c.name)}" required></label>++<label class="cm-field"><span>What is it?</span><select name="source">${Object.entries(SOURCE_LABEL).map(([v, l]) => `<option value="${esc(v)}"${c.source === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>++<label class="cm-field"><span>Status</span><select name="status">${Object.entries(STATUS_LABEL).map(([v, l]) => `<option value="${esc(v)}"${c.status === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>++<label class="cm-field"><span>Slot, slug or @username</span><input name="slot" type="text" maxlength="80" value="${esc(c.slot || '')}"></label>++<label class="cm-field"><span>Note (only you see it)</span><textarea name="notes" rows="2" maxlength="500">${esc(c.notes || '')}</textarea></label>++<div class="cm-actions"><button class="cm-btn" type="submit">Save the camera</button></div>++</form>++<form class="cm-form cm-inline" method="post" action="/my/cameras/${esc(c.id)}/delete"><button class="cm-btn cm-btn-quiet" type="submit">Unpair this camera</button></form>++</section>++++<section class="cm-section" id="cm-grants" aria-labelledby="cm-grants-h">++<h2 id="cm-grants-h">Viewers</h2>++<form class="cm-form cm-card" method="post" action="/my/cameras/${esc(c.id)}/grants">++<label class="cm-field"><span>Viewer's OpenVibe username</span><input name="viewer_username" type="text" maxlength="40" required placeholder="ada"></label>++<label class="cm-field"><span>For how long?</span><select name="days"><option value="1">1 day</option><option value="7" selected>7 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label>++<button class="cm-btn" type="submit">Grant a viewer</button>++<p class="cm-fine">The name is resolved to its canonical subject through OpenVibe.Network. A grant never becomes public; it lets that one person open the wall until it expires or you revoke it.</p>++</form>++${grants.length ? `<div class="cm-table-wrap"><table class="cm-grants"><thead><tr><th scope="col">Viewer</th><th scope="col">Expires</th><th scope="col">State</th><th scope="col"><span class="cm-sr">Action</span></th></tr></thead><tbody>${grants.map((g) => `<tr data-grant="${esc(g.id)}"><td>${g.viewer_username ? `@${esc(g.viewer_username)}` : `<code>${esc(g.viewer_subject)}</code>`}</td><td>${day(g.expires_at)}</td><td>${g.active ? '<span class="cm-status cm-status-live">Active</span>' : g.revoked_at ? '<span class="cm-status cm-status-offline">Revoked</span>' : '<span class="cm-status cm-status-offline">Expired</span>'}</td><td>${g.active ? `<form method="post" action="/my/cameras/${esc(c.id)}/grants/${esc(g.id)}/revoke"><button class="cm-btn cm-btn-small cm-btn-quiet" type="submit">Revoke</button></form>` : ''}</td></tr>`).join('')}</tbody></table></div>` : '<p class="cm-muted">Nobody has a grant. Share with one person when you are ready.</p>'}++</section>++++<section class="cm-section" id="cm-camera-photos" aria-labelledby="cm-camera-photos-h">++<h2 id="cm-camera-photos-h">On the wall</h2>++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">A photo taken with this camera chosen appears here and on the wall for an active viewer.</p>'}++</section>++</div>`;++}++++function wall({ camera: c, access, photos = [], frameUrl: frame = null } = {}) {++ const owner = access && access.owner;++ return `<div class="cm-wrap" id="cm-wall" data-camera="${esc(c.id)}">++<header class="cm-page-h"><p class="cm-kicker">${owner ? 'Your private wall' : `Shared by @${esc(c.owner_username || 'its owner')}`}</p><h1>${esc(c.name)}</h1>++<p class="cm-lede">${owner ? 'Only you and the viewers you granted can open this page.' : `Your grant is active until ${day(access && access.grant && access.grant.expires_at)}. The owner can revoke it at any time.`}</p></header>++<section class="cm-wall-preview" id="cm-wall-frame">++${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640" height="360">` : '<div class="cm-empty"><p>This camera has no live slot yet.</p></div>'}++</section>++<section class="cm-section" id="cm-wall-photos" aria-labelledby="cm-wall-photos-h">++<h2 id="cm-wall-photos-h">Photos on this wall</h2>++${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner })).join('')}</ul>` : '<p class="cm-muted">The owner has not put a photo on this wall.</p>'}++</section>++</div>`;++}++++function still({ still: s, mine = false } = {}) {++ return `<div class="cm-wrap" id="cm-still" data-still="${esc(s.id)}">++<header class="cm-page-h"><p class="cm-kicker">A still from ${esc(s.slot)}</p><h1>${esc(s.caption || 'A moment from the stream')}</h1>++<p class="cm-lede">${mine ? 'Yours. ' : ''}Saved to OpenVibe.Pics on ${day(s.created_at)}. This is one frame, not a recording.</p></header>++<figure class="cm-still-large"><img src="${esc(s.pics_direct)}" alt="${esc(s.caption || 'A still from a live stream')}" width="960" height="540"><figcaption>${esc(s.caption || '')}</figcaption></figure>++<dl class="cm-facts"><div><dt>Slot</dt><dd><code>${esc(s.slot)}</code></dd></div><div><dt>Saved</dt><dd>${day(s.created_at)}</dd></div><div><dt>Picture</dt><dd><a href="${esc(s.pics_url)}" rel="noopener">OpenVibe.Pics${kit.icon('chevron')}</a></dd></div></dl>++<div class="cm-actions"><a class="cm-btn" href="/app">Take a photo</a><a class="cm-btn cm-btn-quiet" href="/">Back to Cam</a></div>++</div>`;++}++++function signIn({ title, text, next }) {++ return `<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="cm-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;++}++++function developers() {++ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;++ return `<div class="cm-wrap" id="cm-developers">++<header class="cm-page-h"><p class="cm-kicker">Developers</p><h1>The Cam API</h1>++<p class="cm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>cam_at</code> session cookie or a Network user token as a Bearer. No route needs an app or service token.</p></header>++++<section><h2>The camera app</h2><dl class="cm-eps">++${ep('POST', '/api/v1/photos', 'A person; the picture is the raw body (<code>image/jpeg</code>, <code>image/png</code> or <code>image/webp</code>), with optional <code>X-Filter</code>, <code>X-Caption</code> and <code>X-Camera</code> headers (URI-encoded). Cam uploads it to OpenVibe.Pics and stores the link.')}++${ep('GET', '/api/v1/photos', 'Your photos, newest first.')}++${ep('GET', '/api/v1/photos/:id', 'One of your photos.')}++</dl></section>++++<section><h2>My cameras and viewer grants</h2><dl class="cm-eps">++${ep('GET', '/api/v1/cameras', 'Your paired cameras with their active grant count.')}++${ep('POST', '/api/v1/cameras', '<code>{ name, source, stream_id?, slot?, notes? }</code>. A stream id is read from Live and must be on your channel.')}++${ep('PATCH', '/api/v1/cameras/:id', '<code>{ name?, source?, status?, slot?, notes? }</code>; the owner only.')}++${ep('DELETE', '/api/v1/cameras/:id', 'Unpair it; every grant on it goes. The owner only.')}++${ep('GET', '/api/v1/cameras/:id/wall', 'The private wall: owner, or a viewer whose grant is active. A stranger gets 404, never a hint that it exists.')}++${ep('GET', '/api/v1/cameras/:id/grants', 'The grants you gave, including expired and revoked ones. The owner only.')}++${ep('POST', '/api/v1/cameras/:id/grants', '<code>{ viewer_username }</code> or <code>{ viewer_subject }</code>, and <code>{ days }</code> (1–90, default 7). An existing active grant is extended, never duplicated.')}++${ep('DELETE', '/api/v1/cameras/:id/grants/:grantId', 'Revoke at once: the next wall request is refused.')}++</dl></section>++++<section id="cm-live-button"><h2>The Live cam button</h2>++<p class="cm-fine">Live calls Cam with the viewer's own Network token — the token in the viewer's Live session — because the picture is saved to that person's OpenVibe.Pics. Cam needs no service token from Live.</p>++<ol class="cm-steps">++<li>Live receives <code>{ slot, stream_id, caption }</code> from its watch page and calls <code>POST https://openvibe.cam/api/v1/stills</code> with <code>Authorization: Bearer <viewer's Network token></code>.</li>++<li>Cam reads <code>GET https://openvibe.media/live/:slot/frame.jpg?w=640</code>. A 404 means the slot is offline; SVG offline cards and HTML errors are never uploaded.</li>++<li>Cam calls <code>POST https://openvibe.pics/api/v1/images</code> with the viewer's token, the JPEG as the body, <code>X-Title</code> and <code>X-Filename</code>.</li>++<li>Cam answers <code>201 { still, share: { url, caption, text } }</code>. Live posts <code>share.text</code> to the stream's chat with its existing chat authority and shows the still.</li>++</ol>++<pre class="cm-code"><code>POST /api/v1/stills++{ "slot": "whip", "stream_id": 1234, "caption": "the exact second the robot fell over" }</code></pre>++<p class="cm-fine">A still is <code>unlisted</code> by default: anyone with the link opens <code>/s/<id></code>; it is not listed anywhere. Pass <code>"visibility": "private"</code> to keep it to yourself. Media's frame endpoint returns at most one frame every five seconds per slot and is rate-limited; a busy frame is <code>429 cam.frame_busy</code>.</p>++</section>++++<section><h2>Stills</h2><dl class="cm-eps">++${ep('POST', '/api/v1/stills', '<code>{ slot, caption?, stream_id?, w?, visibility? }</code>. A person; fetches Media\'s frame and uploads it to Pics as them.')}++${ep('GET', '/api/v1/stills', 'Your stills, newest first.')}++${ep('GET', '/api/v1/stills/:id', 'One still; an unlisted one is readable by anyone with the link, a private one only by its owner.')}++</dl></section>++++<section id="cm-privacy"><h2>Privacy</h2>++<p class="cm-fine">A camera is private. Staff have no bypass; a global moderator without a grant is refused like anyone else. Cam stores no image bytes, no face template and no identity classification. It stores a camera's name and slot, a grant's two subjects and expiry, and a picture's Pics slug, links, filter name and caption. Account export and deletion cover cameras, grants, photos and stills; the picture itself is erased by OpenVibe.Pics on the same Network event.</p></section>++</div>`;++}++++function notFound() {++ return `<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty" id="cm-not-found"><p class="cm-kicker">404</p><h1>This camera is not here</h1><p>It may have been unpaired, your grant may have expired or been revoked, or the address was never a camera. A private wall answers the same whether it exists or not.</p><a class="cm-btn" href="/">Back to Cam</a></div></div>`;++}++++module.exports = {++ home, app, my, camera, wall, still, signIn, developers, notFound,++ frameUrl, sourceLabel, statusBadge, photoCard, stillCard, cameraCard,++ SOURCE_LABEL, STATUS_LABEL, FILTER_LABEL,++};nnew file mode 100644@@@ -0,0 +1,130 @@++'use strict';++/**++ * ADR-033: Cam's part of an account export (cameras, grants given and received, photos, stills) and of a deletion,++ * applied through /internal/events with a stand-in Network. Deleting a camera's owner removes the camera and its++ * grants; deleting a viewer removes the grants naming them and leaves the owner's camera. Another person's rows stay.++ * A redelivery changes nothing; a bad signature and a proxied request are refused.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { createNetworkSender } = require('openvibe-sdk/account-data');++const { signDeliveryHeaders } = require('openvibe-sdk/events');++++const A = ids.newId('user');++const B = ids.newId('user');++const EXP = `exp_${ids.ulid()}`;++const DEL = `del_${ids.ulid()}`;++const SECRET = `fixture_${'account'.repeat(6)}`;++const WRONG = `fixture_${'mismatch'.repeat(6)}`;++++const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };++const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };++const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });++++async function startNetworkStub() {++ const calls = [];++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };++ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_cam', 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: 'cam', clientSecret: 'cam-secret' });++ const t = await boot({ env: { CAM_EVENTS_SECRET: SECRET }, accountSend: sender });++ try {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const camA = `cam_${ids.ulid()}`;++ const camB = `cam_${ids.ulid()}`;++ await db.exec(`INSERT INTO cam_cameras (id, owner, owner_username, name, paired_at, created_at, updated_at) VALUES++ ($1, $2, 'a', 'A camera', $4, $4, $4), ($3, $5, 'b', 'B camera', $4, $4, $4)`, [camA, A, camB, at, B]);++ const grantAB = `cmg_${ids.ulid()}`; // A gave B++ const grantBA = `cmg_${ids.ulid()}`; // B gave A++ await db.exec(`INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, granted_at, expires_at, created_at) VALUES++ ($1, $2, $3, $5, $7, $8, $7), ($4, $6, $5, $3, $7, $8, $7)`, [grantAB, camA, A, grantBA, B, camB, at, new Date(Date.now() + 86400000).toISOString()]);++ const photoA = `cmp_${ids.ulid()}`;++ const photoB = `cmp_${ids.ulid()}`;++ await db.exec(`INSERT INTO cam_photos (id, subject, filter, visibility, pics_slug, pics_url, pics_direct, created_at) VALUES++ ($1, $2, 'warm', 'private', 'picA1234', 'https://openvibe.pics/i/picA1234', 'https://openvibe.pics/i/picA1234.jpg', $4),++ ($3, $5, 'mono', 'private', 'picB1234', 'https://openvibe.pics/i/picB1234', 'https://openvibe.pics/i/picB1234.jpg', $4)`, [photoA, A, photoB, at, B]);++ const stillA = `cms_${ids.ulid()}`;++ const stillB = `cms_${ids.ulid()}`;++ await db.exec(`INSERT INTO cam_stills (id, subject, slot, visibility, pics_slug, pics_url, pics_direct, created_at) VALUES++ ($1, $2, 'whip', 'unlisted', 'picC1234', 'https://openvibe.pics/i/picC1234', 'https://openvibe.pics/i/picC1234.jpg', $4),++ ($3, $5, 'whip', 'unlisted', 'picD1234', 'https://openvibe.pics/i/picD1234', 'https://openvibe.pics/i/picD1234.jpg', $4)`, [stillA, A, stillB, at, B]);++++ await check('an export carries only A\'s cameras, grants, photos and stills', 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_cam');++ assert.strictEqual(part.body.subject, A);++ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['cameras.json', 'grants-given.json', 'grants-received.json', 'photos.json', 'stills.json']);++ const all = JSON.stringify(part.body);++ assert.ok(all.includes('A camera'));++ assert.ok(!all.includes('B camera'), 'nobody else\'s camera');++ assert.ok(all.includes('picA1234'));++ assert.ok(!all.includes('picB1234'), 'nobody else\'s photo');++ assert.ok(all.includes('picC1234'));++ assert.ok(!all.includes('picD1234'), 'nobody else\'s still');++ const cameras = part.body.files.find((f) => f.name === 'cameras.json').content;++ assert.deepStrictEqual(cameras.map((c) => c.id), [camA]);++ const given = part.body.files.find((f) => f.name === 'grants-given.json').content;++ assert.deepStrictEqual(given.map((g) => g.id), [grantAB]);++ const received = part.body.files.find((f) => f.name === 'grants-received.json').content;++ assert.deepStrictEqual(received.map((g) => g.id), [grantBA]);++ });++++ await check('a deletion erases A\'s rows and confirms with counts; B and B\'s camera stay', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().outcome, 'erased');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_stills WHERE subject = $1', [A])), 0);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_stills WHERE subject = $1', [B])), 1);++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_grants')), 0, 'both grants named A');++ 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.cam_cameras >= 1);++ assert.ok(confirmation.body.erased.cam_photos >= 1);++ assert.ok(confirmation.body.erased.cam_stills >= 1);++ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));++ });++++ await check('a redelivery changes nothing', async () => {++ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });++ assert.strictEqual(res.status, 200);++ assert.strictEqual(res.json().outcome, 'unchanged');++ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [A])), 0);++ });++++ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {++ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });++ assert.strictEqual(bad.status, 401);++ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });++ assert.strictEqual(forwarded.status, 403);++ });++ } finally {++ await t.close();++ await stub.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,101 @@++'use strict';++/**++ * Pairing: a camera is a person's, a Live stream id is read from Live and must be on the caller's channel, the++ * slot defaults to the stream's managed slot, and only the owner can read, change or unpair it.++ */++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');++ try {++ let camera = null;++++ await check('a camera pairs without a stream, private and owned by the caller', async () => {++ const r = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', source: 'phone' } });++ assert.strictEqual(r.status, 201, r.text);++ camera = r.json();++ assert.match(camera.id, /^cam_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(camera.owner_username, 'alice');++ assert.strictEqual(camera.source, 'phone');++ assert.strictEqual(camera.status, 'paired');++ assert.strictEqual(camera.slot, null);++ assert.strictEqual(r.headers.get('location'), `/api/v1/cameras/${camera.id}`);++ });++++ await check('a Live stream id is read from Live and its managed slot becomes the camera slot', async () => {++ const r = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Desk cam', source: 'browser', stream_id: 1234 } });++ assert.strictEqual(r.status, 201, r.text);++ const c = r.json();++ assert.strictEqual(c.stream_id, 1234);++ assert.strictEqual(c.slot, 'whip');++ assert.match(c.frame_url, /\/live\/whip\/frame\.jpg\?w=640$/);++ assert.ok(t.upstreams.requestsFor('/api/streams/1234').length >= 1);++ });++++ await check('somebody else\'s stream is refused; an unknown stream is 404', async () => {++ const other = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Not mine', stream_id: 2222 } });++ assert.strictEqual(other.status, 403);++ assert.strictEqual(other.json().code, 'cam.not_your_stream');++ const missing = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Nowhere', stream_id: 9999 } });++ assert.strictEqual(missing.status, 404);++ assert.strictEqual(missing.json().code, 'cam.stream_not_found');++ });++++ await check('a name, a source and a slot are validated; a repeated stream is a 409', async () => {++ const noName = await t.get('/api/v1/cameras', { as: alice, json: { source: 'phone' } });++ assert.strictEqual(noName.status, 400);++ const badSource = await t.get('/api/v1/cameras', { as: alice, json: { name: 'X', source: 'webcam' } });++ assert.strictEqual(badSource.status, 400);++ const badSlot = await t.get('/api/v1/cameras', { as: alice, json: { name: 'X', slot: 'not a slot!' } });++ assert.strictEqual(badSlot.status, 400);++ const again = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Desk again', stream_id: 1234 } });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(again.json().code, 'cam.camera_exists');++ });++++ await check('the list is the caller\'s own; another person sees none', async () => {++ const mine = await t.get('/api/v1/cameras', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json().count, 2);++ assert.ok(mine.json().cameras.every((c) => c.owner_username === 'alice'));++ const theirs = await t.get('/api/v1/cameras', { as: bob });++ assert.strictEqual(theirs.json().count, 0);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: bob })).status, 404);++ });++++ await check('the owner can rename, set the slot and the status; another person cannot', async () => {++ const r = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'PATCH', json: { name: 'Porch', status: 'offline', slot: '@alice' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().name, 'Porch');++ assert.strictEqual(r.json().status, 'offline');++ assert.strictEqual(r.json().slot, '@alice');++ const no = await t.get(`/api/v1/cameras/${camera.id}`, { as: bob, method: 'PATCH', json: { name: 'Stolen' } });++ assert.strictEqual(no.status, 404);++ const unknown = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'PATCH', json: { owner: bob.subject } });++ assert.strictEqual(unknown.status, 400);++ });++++ await check('anonymous, cross-site and app-token writes are refused', async () => {++ const anon = await t.get('/api/v1/cameras', { json: { name: 'Anon' } });++ assert.strictEqual(anon.status, 401);++ assert.strictEqual(anon.json().code, 'cam.sign_in_required');++ const cross = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Cross' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'cam.cross_site');++ });++++ await check('unpairing removes it and its wall; the owner only', async () => {++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: bob, method: 'DELETE' })).status, 404);++ const r = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(r.status, 204);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: alice })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: alice })).status, 404);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,112 @@++'use strict';++/**++ * Viewer grants: one person, one expiry, one revocation. A wall opens for the owner and an active grantee only; a++ * stranger and even a global moderator without a grant get the same 404. A revoked or expired grant is refused on++ * the next read, with no cached copy in between. A grant by @username is resolved through Network; a subject works++ * without Network. Notes on the camera are the owner's and never appear on a grantee's wall.++ */++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 mod = t.network.addUser('mod', { role: 'global_mod' });++ let camera = null;++ let firstGrant = null;++ try {++ await check('the owner pairs a camera and grants @bob for seven days', async () => {++ camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip', notes: 'the gate code is not in here' } })).json();++ const r = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' } });++ assert.strictEqual(r.status, 201, r.text);++ firstGrant = r.json();++ assert.match(firstGrant.id, /^cmg_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(firstGrant.viewer_subject, bob.subject);++ assert.strictEqual(firstGrant.viewer_username, 'bob');++ assert.strictEqual(firstGrant.active, true);++ const days = Math.round((Date.parse(firstGrant.expires_at) - Date.parse(firstGrant.granted_at)) / 86400000);++ assert.strictEqual(days, 7);++ assert.ok(t.network.tokenRequests.some((x) => x.grant_type === 'client_credentials'));++ assert.ok(t.network.requests.some((x) => x.path === '/internal/identity/resolve'));++ });++++ await check('bob sees the private wall; alice sees it as owner; carol and a moderator do not', async () => {++ const b = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob });++ assert.strictEqual(b.status, 200, b.text);++ assert.strictEqual(b.json().owner, false);++ assert.strictEqual(b.json().grant.id, firstGrant.id);++ assert.match(b.json().frame_url, /\/live\/whip\/frame\.jpg/);++ assert.ok(!('notes' in b.json().camera), 'the owner\'s note is not a viewer\'s');++ const a = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: alice });++ assert.strictEqual(a.status, 200);++ assert.strictEqual(a.json().owner, true);++ assert.strictEqual(a.json().camera.notes, 'the gate code is not in here');++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: mod })).status, 404, 'staff have no bypass');++ });++++ await check('a second grant extends the active one instead of making a second row', async () => {++ const r = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: '@bob', days: 30 } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().id, firstGrant.id);++ const rows = await t.ctx.s.db.many('SELECT * FROM cam_grants WHERE camera_id = $1', [camera.id]);++ assert.strictEqual(rows.length, 1);++ const days = Math.round((Date.parse(rows[0].expires_at) - Date.parse(rows[0].granted_at)) / 86400000);++ assert.strictEqual(days, 30);++ });++++ await check('the owner lists the grants; nobody else can', async () => {++ const mine = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json().count, 1);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: bob })).status, 404);++ });++++ await check('revoking refuses the very next wall request', async () => {++ const r = await t.get(`/api/v1/cameras/${camera.id}/grants/${firstGrant.id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().revoked, true);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);++ const again = await t.get(`/api/v1/cameras/${camera.id}/grants/${firstGrant.id}`, { as: alice, method: 'DELETE' });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().revoked, false);++ assert.strictEqual(again.json().already, true);++ });++++ await check('a grant that has expired is refused, even though it was never revoked', async () => {++ const second = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob', days: 1 } });++ assert.strictEqual(second.status, 201);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 200);++ await t.ctx.s.db.exec("UPDATE cam_grants SET granted_at = now() - interval '2 days', expires_at = now() - interval '1 day' WHERE id = $1", [second.json().id]);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);++ const list = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice });++ assert.strictEqual(list.json().grants.find((g) => g.id === second.json().id).active, false);++ });++++ await check('a subject grant needs no Network; an unknown name is 404; a bad number of days is 400', async () => {++ t.network.setResolveDown(true);++ const bySubject = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_subject: carol.subject, days: 2 } });++ assert.strictEqual(bySubject.status, 201, bySubject.text);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 200);++ t.network.setResolveDown(false);++ const unknown = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'nobody' } });++ assert.strictEqual(unknown.status, 404);++ assert.strictEqual(unknown.json().code, 'cam.viewer_not_found');++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob', days: 0 } })).status, 400);++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_subject: alice.subject } })).json().code, 'cam.invalid');++ });++++ await check('a grant is refused anonymously, cross-site, and for somebody else\'s camera', async () => {++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { json: { viewer_username: 'bob' } })).status, 401);++ const cross = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'cam.cross_site');++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: bob, json: { viewer_username: 'alice' } })).status, 404);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,98 @@++'use strict';++// Many requests per test file: the per-address limits would answer 429 part-way.++process.env.CAM_RATE_LIMIT_PER_MIN = process.env.CAM_RATE_LIMIT_PER_MIN || '100000';++process.env.CAM_API_RATE_LIMIT_PER_MIN = process.env.CAM_API_RATE_LIMIT_PER_MIN || '100000';++process.env.CAM_LIMITS_MINUTE = process.env.CAM_LIMITS_MINUTE || '100000';++process.env.CAM_LIMITS_HOUR = process.env.CAM_LIMITS_HOUR || '100000';++/**++ * Boots OpenVibe.Cam on a temp database with a mock Network (test/helpers/mocks.js) and Media/Live/Pics++ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client. Every test file gets its own.++ *++ * const t = await boot();++ * t.get(path, { as: user, bearer, json, form, body, headers, method, cookie, origin })++ * t.signIn(user) → cookie header value (cam_at = a Network user token, as after /auth/callback)++ * t.logs(), t.dbDump()++ */++const http = require('http');++const { startNetwork } = require('./mocks');++const { startUpstreams } = require('./upstreams');++++const captured = [];++for (const m of ['log', 'info', 'warn', 'error']) {++ const orig = console[m].bind(console);++ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };++}++++async function boot(opts = {}) {++ const network = await startNetwork(opts.network || {});++ const upstreams = await startUpstreams(opts.upstreams || {});++ const env = {++ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.cam', TRUST_PROXY: '1',++ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,++ OV_OAUTH_CLIENT_ID: 'cam', OV_OAUTH_CLIENT_SECRET: 'cam-secret', COOKIE_SECURE: 'false',++ CAM_DRILL: '1',++ CAM_EVENTS_URL: network.url,++ OV_MEDIA_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_PICS_URL: upstreams.url,++ ...(opts.env || {}),++ };++ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];++ const configLib = require('../../server/config');++ const { createApp } = require('../../server/app');++ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };++++ const config = configLib.load(env);++ const { createStore } = require('../../server/db');++ const testdb = await require('./db').testDb();++ const store = createStore(testdb.db, { now: opts.now });++ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });++ await built.ctx.keys.ensure();++ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });++ const base = `http://127.0.0.1:${server.address().port}`;++++ const signIn = (user) => `cam_at=${network.userToken(user)}`;++++ async function get(p, o = {}) {++ const headers = { ...(o.headers || {}) };++ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');++ if (o.cookie) headers.cookie = o.cookie;++ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;++ let body = o.body;++ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }++ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }++ const method = o.method || (body ? 'POST' : 'GET');++ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;++ if (o.origin) headers.origin = o.origin;++ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });++ const buf = Buffer.from(await res.arrayBuffer());++ const text = buf.toString('utf8');++ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };++ }++++ async function dbDump() {++ const db = built.ctx.s.db;++ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);++ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');++ }++++ const t = {++ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,++ logs: () => captured.join('\n'),++ async close() {++ await new Promise((r) => server.close(r));++ built.ctx.keys.client.stop();++ await built.ctx.telemetry.stop().catch(() => {});++ await testdb.close();++ await network.close();++ await upstreams.close();++ },++ };++ return t;++}++++let failures = 0;++async function check(name, fn) {++ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }++}++function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }++++module.exports = { boot, check, done };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++/**++ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with++ * CAM_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.CAM_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'cam', max });++++module.exports = { testDb, pgAvailable };nnew file mode 100644@@@ -0,0 +1,133 @@++'use strict';++/**++ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.++ *++ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `cam`, refresh_token,++ * client_credentials); /oauth/revoke; /internal/identity/resolve?username=; a user card.++ *++ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.++ */++const http = require('http');++const crypto = require('crypto');++const { serviceAuth, ids } = require('openvibe-contracts');++++function listen(handler) {++ return new Promise((resolve) => {++ const sockets = new Set();++ const server = http.createServer((req, res) => {++ const chunks = [];++ req.on('data', (c) => chunks.push(c));++ req.on('end', () => {++ const raw = Buffer.concat(chunks);++ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };++ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });++ });++ });++ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });++ server.listen(0, '127.0.0.1', () => resolve({++ server, url: `http://127.0.0.1:${server.address().port}`,++ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),++ }));++ });++}++++const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');++function signJwt(claims, privateKey) {++ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;++}++++async function startNetwork() {++ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });++ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });++ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });++ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, resolveDown: 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.toLowerCase(), 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);++ }++++ 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 !== 'cam' || body.client_secret !== 'cam-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:cam', 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 === '/internal/identity/resolve' && req.method === 'GET') {++ if (st.resolveDown) return problem(json, 503, 'identity.down', 'identity is restarting');++ const auth = String(req.headers.authorization || '');++ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';++ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };++ if (!v.ok) return problem(json, 401, 'token.invalid', 'a service token with identity.subject.resolve is required');++ if (!Array.isArray(v.claims.cap) || !v.claims.cap.includes('identity.subject.resolve')) return problem(json, 403, 'token.forbidden', 'identity.subject.resolve is not granted');++ const name = String(url.searchParams.get('username') || '').replace(/^@/, '').toLowerCase();++ const u = st.byUsername.get(name);++ if (!u) return problem(json, 404, 'identity.subject_not_found', 'no subject for that name');++ return json(200, { subject: { type: 'user', id: u.subject }, network_user_id: u.id, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });++ }++++ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') return json(200, { items: [], next_cursor: null });++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });++ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });++++ return json(404, { error: 'not found' });++ });++ issuer = srv.url;++++ return {++ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,++ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,++ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },++ setDown(v) { st.down = v; }, setResolveDown(v) { st.resolveDown = v; },++ close: srv.close,++ };++}++++module.exports = { startNetwork, signJwt };nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++++/**++ * In-process stand-ins for OpenVibe.Media, OpenVibe.Live and OpenVibe.Pics on one HTTP server. Boot points++ * OV_MEDIA_URL, OV_LIVE_URL and OV_PICS_URL at it.++ *++ * GET /api/streams/:id a stream with its channel (ownership check)++ * GET /live/:sel/frame.jpg a JPEG frame; state.frames[sel] = { status: 404 } makes it offline++ * POST /api/v1/images Pics upload; records the person's token and returns a slug and links++ *++ * A test changes state.frames[slot] or state.pics.status to exercise an offline slot, a busy frame or a Pics++ * refusal, and reads state.uploads / requestsFor() to see what Cam sent.++ */++const http = require('http');++++const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);++++const DEFAULT_STREAMS = {++ 1234: { id: 1234, managed_stream_id: 77, managed_stream_slug: 'whip', is_live: true, username: 'alice', title: 'Alice live', channel: { username: 'alice' } },++ 2222: { id: 2222, managed_stream_id: 88, managed_stream_slug: 'bob-cam', is_live: false, username: 'bob', title: 'Bob offline', channel: { username: 'bob' } },++};++++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); }),++ }));++ });++}++++async function startUpstreams() {++ const state = {++ streams: JSON.parse(JSON.stringify(DEFAULT_STREAMS)),++ frames: {},++ pics: { status: 201 },++ uploads: [],++ requests: [],++ };++++ const srv = await listen(async (req, raw, json, res) => {++ const url = new URL(req.url, 'http://x');++ const p = url.pathname;++ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, bytes: raw.length });++++ const stream = p.match(/^\/api\/streams\/(\d+)$/);++ if (stream) {++ const row = state.streams[stream[1]];++ if (!row) return json(404, { error: 'Stream not found' });++ return json(200, { stream: row });++ }++++ const frame = p.match(/^\/live\/([^/]+)\/frame\.jpg$/);++ if (frame) {++ const sel = decodeURIComponent(frame[1]);++ const conf = state.frames[sel] || { status: 200 };++ if (conf.status === 404) {++ res.writeHead(404, { 'Content-Type': 'image/svg+xml' });++ return res.end('<svg xmlns="http://www.w3.org/2000/svg"><text>offline</text></svg>');++ }++ if (conf.status === 429) return json(429, { error: 'Too many requests' });++ if (conf.status === 500) return json(500, { error: 'Failed to grab frame' });++ const bytes = conf.bytes || JPEG;++ const type = conf.type || 'image/jpeg';++ res.writeHead(conf.status || 200, { 'Content-Type': type, 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=5' });++ return res.end(bytes);++ }++++ if (p === '/api/v1/images' && req.method === 'POST') {++ const status = state.pics.status || 201;++ const auth = String(req.headers.authorization || '');++ if (status === 401) return json(401, { code: 'token.required', detail: 'Sign in at openvibe.pics.' });++ if (status === 413) return json(413, { code: 'pics.too_large', detail: 'A picture can be up to 20 MB.' });++ if (status === 415) return json(415, { code: 'pics.not_an_image', detail: 'Send the picture as the body.' });++ if (!auth.startsWith('Bearer ')) return json(401, { code: 'token.required', detail: 'a person\'s token is required' });++ const slug = `pic${state.uploads.length + 1}abcd`.slice(0, 8);++ state.uploads.push({++ auth, contentType: String(req.headers['content-type'] || ''), title: req.headers['x-title'] || null,++ filename: req.headers['x-filename'] || null, bytes: raw.length, slug,++ });++ return json(201, {++ id: `pic_${slug}`, slug, mime_type: 'image/jpeg', ext: 'jpg', width: 640, height: 360, bytes: raw.length,++ links: { page: `https://openvibe.pics/i/${slug}`, direct: `https://openvibe.pics/i/${slug}.jpg`, thumb: `https://openvibe.pics/i/${slug}/thumb.webp` },++ }, { 'Content-Type': 'application/json' });++ }++++ return json(404, { error: 'not found' });++ });++++ // The page link is relative in this fixture; Cam keeps it as Pics returned it.++ return {++ url: srv.url,++ state,++ close: srv.close,++ requestsFor(path) { return state.requests.filter((r) => r.path === path); },++ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },++ setFrame(slot, conf) { state.frames[slot] = conf; },++ setPics(status) { state.pics.status = status; },++ uploads() { return state.uploads; },++ };++}++++module.exports = { startUpstreams, JPEG, DEFAULT_STREAMS };nnew file mode 100644@@@ -0,0 +1,73 @@++'use strict';++/**++ * The expand migration applies in one pass and creates exactly Cam's tables plus the SDK's account receipts and++ * telemetry. No Cam table has a column for picture bytes; a picture is a Pics slug and two links.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const t = await boot();++ try {++ await check('0001_initial.sql applies and creates the Cam tables', async () => {++ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);++ for (const table of ['cam_cameras', 'cam_grants', 'cam_photos', 'cam_stills', 'account_data_events', 'telemetry_samples']) {++ assert.ok(names.includes(table), `missing ${table}`);++ }++ });++++ await check('no Cam table has a picture, blob or byte column', async () => {++ const cols = (await t.ctx.s.db.many("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name LIKE 'cam_%'")).map((r) => `${r.table_name}.${r.column_name}`);++ assert.ok(!cols.some((c) => /(^|\.)(image|photo|blob|bytes|data)$/i.test(c)), cols.join(','));++ for (const table of ['cam_photos', 'cam_stills']) {++ const names = cols.filter((c) => c.startsWith(`${table}.`));++ assert.ok(names.some((c) => c.endsWith('.pics_slug')), `${table} keeps a Pics slug`);++ assert.ok(names.some((c) => c.endsWith('.pics_url')), `${table} keeps a Pics link`);++ }++ });++++ await check('a camera refuses a bad id, source, status or slot, and a duplicate stream', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const owner = ids.newId('user');++ const bad = (fields) => db.exec(`INSERT INTO cam_cameras (id, owner, name, source, status, slot, paired_at, created_at, updated_at)++ VALUES ($1, $2, 'A camera', $3, $4, $5, $6, $6, $6)`, [fields.id || `cam_${ids.ulid()}`, owner, fields.source || 'phone', fields.status || 'paired', fields.slot || null, at]);++ await assert.rejects(bad({ id: 'bad' }), /cam_cameras_id/);++ await assert.rejects(bad({ source: 'webcam' }), /cam_cameras_source/);++ await assert.rejects(bad({ status: 'on' }), /cam_cameras_status/);++ await assert.rejects(bad({ slot: 'not a slot!' }), /cam_cameras_slot/);++ await db.exec("INSERT INTO cam_cameras (id, owner, name, stream_id, paired_at, created_at, updated_at) VALUES ($1, $2, 'Once', 42, $3, $3, $3)", [`cam_${ids.ulid()}`, owner, at]);++ await assert.rejects(db.exec("INSERT INTO cam_cameras (id, owner, name, stream_id, paired_at, created_at, updated_at) VALUES ($1, $2, 'Twice', 42, $3, $3, $3)", [`cam_${ids.ulid()}`, owner, at]), /duplicate key|unique/i);++ });++++ await check('one active grant per camera and viewer; a revoked one may be followed by a new one', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const owner = ids.newId('user');++ const viewer = ids.newId('user');++ const cameraId = `cam_${ids.ulid()}`;++ await db.exec("INSERT INTO cam_cameras (id, owner, name, paired_at, created_at, updated_at) VALUES ($1, $2, 'Door', $3, $3, $3)", [cameraId, owner, at]);++ const grant = (id, revoke = null) => db.exec(`INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, granted_at, expires_at, revoked_at, created_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $5)`, [id, cameraId, owner, viewer, at, new Date(Date.now() + 86400000).toISOString(), revoke]);++ await grant(`cmg_${ids.ulid()}`);++ await assert.rejects(grant(`cmg_${ids.ulid()}`), /duplicate key|unique/i);++ const revoked = `cmg_${ids.ulid()}`;++ await db.exec('UPDATE cam_grants SET revoked_at = $2 WHERE camera_id = $1', [cameraId, at]);++ await grant(revoked, at);++ });++++ await check('an unknown source, filter or still visibility is refused by the tables', async () => {++ const db = t.ctx.s.db;++ const at = new Date().toISOString();++ const subject = ids.newId('user');++ await assert.rejects(db.exec(`INSERT INTO cam_photos (id, subject, filter, visibility, pics_slug, pics_url, pics_direct, created_at)++ VALUES ($1, $2, 'sepia', 'private', 'abc12345', 'https://openvibe.pics/i/abc12345', 'https://openvibe.pics/i/abc12345.jpg', $3)`, [`cmp_${ids.ulid()}`, subject, at]), /cam_photos_filter/);++ await assert.rejects(db.exec(`INSERT INTO cam_stills (id, subject, slot, visibility, pics_slug, pics_url, pics_direct, created_at)++ VALUES ($1, $2, 'whip', 'public', 'abc12345', 'https://openvibe.pics/i/abc12345', 'https://openvibe.pics/i/abc12345.jpg', $3)`, [`cms_${ids.ulid()}`, subject, at]), /cam_stills_visibility/);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,154 @@++'use strict';++/**++ * Server-rendered pages: the home and its privacy rules, the camera app, My cameras, one camera's owner page, the++ * private wall, a still, developers, 404, the cache headers, the permissions policy and the discovery artifacts.++ * Public pages are public, max-age=60; every wall, still and signed-in page is private, no-store.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { assetVersion } = require('../server/render/layout');++++function jsonLd(html) {++ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));++}++++(async () => {++ const t = await boot();++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ let camera = null;++ let grant = null;++ let still = null;++ try {++ await check('home: semantic, the privacy rules, a public cache and 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="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-shutter.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);++ assert.match(r.text, /<main id="main"/);++ assert.match(r.text, /<footer id="ov-footer"/);++ 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; the permissions policy allows only the camera', 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');++ assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(), geolocation=(), payment=()');++ });++++ await check('/app has the camera controls and the gallery', async () => {++ const r = await t.get('/app');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid']) assert.ok(r.text.includes(marker), marker);++ assert.ok(r.text.includes('Sign in'));++ });++++ await check('/my needs an account; signed in it has the pair form and no camera yet', async () => {++ const anon = await t.get('/my');++ assert.strictEqual(anon.status, 401);++ assert.ok(anon.text.includes('Sign in with OpenVibe'));++ const mine = await t.get('/my', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');++ for (const marker of ['id="cm-my"', 'id="cm-pair"', 'action="/my/cameras"', 'name="stream_id"', 'id="cm-cameras"', 'id="cm-photos"', 'id="cm-stills"']) assert.ok(mine.text.includes(marker), marker);++ });++++ await check('the pair form creates a camera and redirects to its owner page', async () => {++ const r = await t.get('/my/cameras', { as: alice, form: { name: 'Front door', source: 'phone', slot: 'whip' } });++ assert.strictEqual(r.status, 303, r.text);++ assert.match(r.headers.get('location'), /^\/my\/cameras\/cam_/);++ camera = (await t.get('/api/v1/cameras', { as: alice })).json().cameras[0];++ assert.strictEqual(camera.name, 'Front door');++ assert.strictEqual(camera.slot, 'whip');++ });++++ await check('the owner page shows the frame, the edit form and the grant form; a stranger gets 404', async () => {++ const r = await t.get(`/my/cameras/${camera.id}`, { as: alice });++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ for (const marker of ['id="cm-camera-owner"', 'id="cm-edit"', 'id="cm-grants"', `action="/my/cameras/${camera.id}/grants"`, `action="/my/cameras/${camera.id}/delete"`, '/live/whip/frame.jpg']) assert.ok(r.text.includes(marker), marker);++ assert.strictEqual((await t.get(`/my/cameras/${camera.id}`, { as: carol })).status, 404);++ });++++ await check('the grant form resolves @bob; the wall opens for bob and not for carol', async () => {++ const r = await t.get(`/my/cameras/${camera.id}/grants`, { as: alice, form: { viewer_username: 'bob', days: '7' } });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(r.headers.get('location'), `/my/cameras/${camera.id}?granted=1`);++ grant = (await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice })).json().grants[0];++ const wall = await t.get(`/w/${camera.id}`, { as: bob });++ assert.strictEqual(wall.status, 200);++ assert.strictEqual(wall.headers.get('cache-control'), 'private, no-store');++ for (const marker of ['id="cm-wall"', 'id="cm-wall-frame"', 'Shared by @alice', '/live/whip/frame.jpg', 'id="cm-wall-photos"']) assert.ok(wall.text.includes(marker), marker);++ assert.strictEqual((await t.get(`/w/${camera.id}`, { as: carol })).status, 404);++ assert.strictEqual((await t.get(`/w/${camera.id}`)).status, 401);++ });++++ await check('revoking from the owner page refuses the wall at once', async () => {++ const r = await t.get(`/my/cameras/${camera.id}/grants/${grant.id}/revoke`, { as: alice, method: 'POST' });++ assert.strictEqual(r.status, 303);++ assert.strictEqual(r.headers.get('location'), `/my/cameras/${camera.id}?revoked=1`);++ assert.strictEqual((await t.get(`/w/${camera.id}`, { as: bob })).status, 404);++ });++++ await check('one still page shows the picture and its caption; a private one is 404', async () => {++ still = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'the robot fell over' } })).json().still;++ const r = await t.get(`/s/${still.id}`);++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');++ for (const marker of ['id="cm-still"', 'the robot fell over', still.pics_direct, '/app']) assert.ok(r.text.includes(marker), marker);++ const priv = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', visibility: 'private' } })).json().still;++ assert.strictEqual((await t.get(`/s/${priv.id}`)).status, 404);++ assert.strictEqual((await t.get(`/s/${priv.id}`, { as: alice })).status, 200);++ });++++ await check('/developers names the API and the Live cam button contract', async () => {++ const r = await t.get('/developers');++ assert.strictEqual(r.status, 200);++ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');++ for (const marker of ['id="cm-developers"', 'POST /api/v1/stills', 'POST /api/v1/photos', '/live/:slot/frame.jpg', 'openvibe.pics/api/v1/images', 'id="cm-live-button"', '/api/v1/cameras/:id/grants']) assert.ok(r.text.includes(marker), marker);++ });++++ await check('a cross-site form is refused; the 404 page is semantic', async () => {++ const cross = await t.get('/my/cameras', { as: alice, form: { name: 'Cross' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ const nf = await t.get('/no/such/page');++ assert.strictEqual(nf.status, 404);++ assert.match(nf.headers.get('content-type'), /^text\/html/);++ assert.ok(nf.text.includes('id="cm-not-found"'));++ });++++ await check('assets are content-hashed; robots keeps the walls and stills out; the sitemap lists only public pages', async () => {++ const v = assetVersion('css/cam.css');++ const hashed = await t.get(`/css/cam.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/cam.css?v=deadbeefdeadbeef');++ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');++ const js = await t.get('/js/cam.js');++ assert.strictEqual(js.status, 200);++ assert.match(js.headers.get('content-type'), /javascript/);++ const robots = await t.get('/robots.txt');++ assert.match(robots.text, /Disallow: \/w\//);++ assert.match(robots.text, /Disallow: \/s\//);++ assert.match(robots.text, /Disallow: \/api\//);++ const map = await t.get('/sitemap.xml');++ assert.strictEqual(map.status, 200);++ assert.ok(map.text.includes('https://openvibe.cam/app'));++ assert.ok(!map.text.includes('/w/'));++ assert.ok(!map.text.includes('/s/'));++ const llms = await t.get('/llms.txt');++ assert.strictEqual(llms.status, 200);++ assert.ok(llms.text.includes('OpenVibe.Cam'));++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,117 @@++'use strict';++/**++ * The camera app's photo: the raw picture is the request body, Cam forwards it to OpenVibe.Pics with the person's++ * own Network token, and stores the Pics slug, links, filter and caption. A photo without a camera is private; a++ * photo on a camera wall is unlisted and an active grantee sees it. The image bytes are never stored in Cam.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { JPEG } = require('./helpers/upstreams');++++(async () => {++ const t = await boot({ env: { CAM_PHOTO_MAX_BYTES: '64' } });++ const alice = t.network.addUser('alice');++ const bob = t.network.addUser('bob');++ const carol = t.network.addUser('carol');++ try {++ let photo = null;++ let camera = null;++++ await check('a photo uploads to Pics and is recorded with its filter and caption', async () => {++ const r = await t.get('/api/v1/photos', {++ as: alice, body: JPEG,++ headers: { 'content-type': 'image/jpeg', 'x-filter': 'warm', 'x-caption': encodeURIComponent('a warm porch') },++ });++ assert.strictEqual(r.status, 201, r.text);++ photo = r.json();++ assert.match(photo.id, /^cmp_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(photo.filter, 'warm');++ assert.strictEqual(photo.caption, 'a warm porch');++ assert.strictEqual(photo.visibility, 'private');++ assert.strictEqual(photo.camera_id, null);++ assert.strictEqual(photo.subject, alice.subject);++ const upload = t.upstreams.uploads()[0];++ assert.match(upload.auth, /^Bearer /);++ assert.strictEqual(upload.contentType, 'image/jpeg');++ assert.strictEqual(upload.bytes, JPEG.length);++ assert.strictEqual(upload.title, encodeURIComponent('a warm porch'));++ });++++ await check('the photo row has no bytes; the picture lives in Pics', async () => {++ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'cam_photos'")).map((r) => r.column_name);++ assert.ok(!cols.some((c) => /image|blob|bytes|photo|data/i.test(c)), cols.join(','));++ const row = await t.ctx.s.db.maybe('SELECT pics_slug, pics_url, pics_direct FROM cam_photos WHERE id = $1', [photo.id]);++ assert.ok(row.pics_slug);++ assert.ok(row.pics_url.startsWith('https://openvibe.pics/'));++ });++++ await check('your photos are yours; another person gets 404', async () => {++ const mine = await t.get('/api/v1/photos', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.strictEqual(mine.json().count, 1);++ assert.strictEqual((await t.get(`/api/v1/photos/${photo.id}`, { as: alice })).status, 200);++ assert.strictEqual((await t.get(`/api/v1/photos/${photo.id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get('/api/v1/photos', { as: bob })).json().count, 0);++ });++++ await check('a photo attached to a camera is unlisted and appears on the wall for an active grantee', async () => {++ camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip' } })).json();++ const r = await t.get('/api/v1/photos', {++ as: alice, body: JPEG,++ headers: { 'content-type': 'image/jpeg', 'x-filter': 'none', 'x-camera': camera.id },++ });++ assert.strictEqual(r.status, 201, r.text);++ const attached = r.json();++ assert.strictEqual(attached.camera_id, camera.id);++ assert.strictEqual(attached.visibility, 'unlisted');++++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);++ const grant = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' } });++ assert.strictEqual(grant.status, 201);++ const wall = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob });++ assert.strictEqual(wall.status, 200);++ assert.strictEqual(wall.json().photos.length, 1);++ assert.strictEqual(wall.json().photos[0].id, attached.id);++ assert.ok(!('subject' in wall.json().photos[0]));++ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 404);++ });++++ await check('a photo cannot be attached to somebody else\'s camera', async () => {++ const r = await t.get('/api/v1/photos', {++ as: bob, body: JPEG,++ headers: { 'content-type': 'image/jpeg', 'x-camera': camera.id },++ });++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.json().code, 'cam.not_found');++ });++++ await check('the content type, filter, caption, size and sign-in are enforced', async () => {++ const notImage = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'text/plain' } });++ assert.strictEqual(notImage.status, 415);++ assert.strictEqual(notImage.json().code, 'cam.not_an_image');++ const badFilter = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg', 'x-filter': 'sepia' } });++ assert.strictEqual(badFilter.status, 400);++ const blocked = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg', 'x-caption': encodeURIComponent('kys') } });++ assert.strictEqual(blocked.status, 400);++ assert.strictEqual(blocked.json().code, 'cam.blocked_words');++ const tooLarge = await t.get('/api/v1/photos', { as: alice, body: Buffer.alloc(100, 1), headers: { 'content-type': 'image/jpeg' } });++ assert.strictEqual(tooLarge.status, 413);++ const anonymous = await t.get('/api/v1/photos', { body: JPEG, headers: { 'content-type': 'image/jpeg' } });++ assert.strictEqual(anonymous.status, 401);++ const cross = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg' }, origin: 'https://evil.example' });++ assert.strictEqual(cross.status, 403);++ assert.strictEqual(cross.json().code, 'cam.cross_site');++ });++++ await check('a Pics refusal is a problem, not a photo', async () => {++ t.upstreams.setPics(401);++ const r = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg' } });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'cam.pics.refused');++ t.upstreams.setPics(201);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,20 @@++#!/usr/bin/env node++/**++ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an++ * in-process mock of OpenVibe.Network, and in-process Media/Live/Pics fixtures; none needs the network.++ *++ * npm test # everything++ * npm test -- cameras # only files whose name contains one of the words++ * npm test -- --strict # a skipped test fails the run too++ */++'use strict';++const { run } = require('openvibe-shared/test-runner');++++// VERBOSE=1 also prints every file's output, then the summary again.++run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {++ if (process.env.VERBOSE) {++ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);++ console.log(`\n${r.summary}`);++ }++ process.exit(r.exitCode);++}, (err) => { console.error(err); process.exit(1); });nnew file mode 100644@@@ -0,0 +1,45 @@++'use strict';++/**++ * The first proof, without the network: pair the proof camera for one owner, grant a second person for seven days,++ * open the wall as that viewer, revoke the grant, and see the next wall request refused. Re-running reuses the++ * camera and makes a fresh grant after the previous one was revoked.++ */++const assert = require('assert');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const { runProof, NAME, SLOT } = require('../scripts/seed-proof');++++(async () => {++ const t = await boot();++ const owner = ids.newId('user');++ const viewer = ids.newId('user');++ try {++ await check('the proof pairs a camera, grants a viewer, opens the wall, revokes and is refused', async () => {++ const out = await runProof({ config: t.config, s: t.ctx.s, owner, viewer, log: { log() {} } });++ assert.strictEqual(out.camera.name, NAME);++ assert.strictEqual(out.camera.slot, SLOT);++ assert.strictEqual(out.camera.owner_username, 'proof-owner');++ assert.strictEqual(out.grant.viewer_subject, viewer);++ assert.strictEqual(out.wall.owner, false);++ assert.strictEqual(out.revoked.revoked_at != null, true);++ assert.strictEqual(out.refused, true);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1', [out.camera.id])), 1);++ });++++ await check('a second run reuses the camera and grants again after the revocation', async () => {++ const again = await runProof({ config: t.config, s: t.ctx.s, owner, viewer, log: { log() {} } });++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1 AND name = $2', [owner, NAME])), 1);++ assert.notStrictEqual(again.grant.id, null);++ assert.strictEqual(again.refused, true);++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1', [again.camera.id])), 2);++ });++++ await check('the proof refuses the same person twice and a non-subject', async () => {++ await assert.rejects(runProof({ config: t.config, s: t.ctx.s, owner, viewer: owner, log: { log() {} } }), /different people/);++ await assert.rejects(runProof({ config: t.config, s: t.ctx.s, owner: 'nobody', viewer, log: { log() {} } }), /CAM_PROOF_OWNER/);++ });++ } finally {++ await t.close();++ }++ done();++})();nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++/**++ * Sign-in with PKCE S256 (client id cam, cookies cam_at/cam_rt/cam_oauth), the session-token rules (a FedCM++ * assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback metrics,++ * pages without JavaScript, and cross-site sign-out.++ */++const assert = require('assert');++const crypto = require('crypto');++const { boot, check, done } = require('./helpers/boot');++const { sanitizeNext } = require('../server/auth/sso');++++(async () => {++ const t = await boot();++ const user = t.network.addUser('kim');++++ await check('login redirects to Network with state and an S256 code challenge, client id cam', async () => {++ const r = await t.get('/auth/login?next=/my');++ 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'), 'cam');++ 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'), /cam_oauth=.*Path=\/auth.*HttpOnly/i);++ });++++ await check('callback exchanges the code with the matching verifier and sets httpOnly cam_at/cam_rt', async () => {++ const login = await t.get('/auth/login?next=/my');++ 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'), '/my');++ const set = r.headers.get('set-cookie');++ assert.match(set, /cam_at=[^;]+;.*HttpOnly/i);++ assert.match(set, /cam_rt=[^;]+;.*HttpOnly/i);++ assert.match(set, /cam_oauth=;/);++ const at = set.match(/cam_at=([^;]+)/)[1];++ const me = await t.get('/auth/me', { cookie: `cam_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'));++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).status, 400);++ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));++ assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 400);++ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).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(/cam_oauth=([^;]+)/)[1]);++ assert.strictEqual(JSON.parse(flow).next, '/');++ const config = { baseUrl: 'https://openvibe.cam' };++ assert.strictEqual(sanitizeNext('/my?x=1', config), '/my?x=1');++ assert.strictEqual(sanitizeNext('https://openvibe.cam/w/cam_x?y=1', config), '/w/cam_x?y=1');++ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.cam.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);++ });++++ await check('readiness is truthful: db required; JWKS, the OAuth client, identity resolve 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.identity_resolve.status, 'ok');++ 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, 'cam');++ 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 ['/', '/app', '/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: 'cam' });++ 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: `cam_at=${fedcm}` })).status, 401);++ const page = await t.get('/', { cookie: `cam_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: `cam_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: 'cam_at=garbage' })).status, 401);++ assert.strictEqual((await t.get('/auth/me', { cookie: 'cam_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(!/cam_at=;|cam_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));++ assert.match(cross.text, /method="post"/);++ assert.strictEqual((await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);++ const same = await t.get('/auth/logout?next=/my', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(same.status, 303);++ assert.strictEqual(same.headers.get('location'), '/my');++ assert.match(String(same.headers.get('set-cookie') || ''), /cam_at=;/);++ });++++ await t.close();++ done();++})();nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++/**++ * The still Live's cam button takes: Cam reads Media's public GET /live/:slot/frame.jpg, uploads the JPEG to++ * OpenVibe.Pics with the viewer's own Network token, records the Pics slug and links, and returns the line Live++ * posts in chat. An offline slot, a non-JPEG card, a busy frame and a Pics refusal are distinct problems. A still is++ * unlisted by default (the chat link opens it) and private when its owner says so.++ */++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');++ let still = null;++ try {++ await check('a still fetches the Media frame and saves it to Pics as the viewer', async () => {++ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', stream_id: 1234, caption: 'the exact second the robot fell over' } });++ assert.strictEqual(r.status, 201, r.text);++ const b = r.json();++ still = b.still;++ assert.match(still.id, /^cms_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.strictEqual(still.slot, 'whip');++ assert.strictEqual(still.stream_id, 1234);++ assert.strictEqual(still.visibility, 'unlisted');++ assert.strictEqual(still.caption, 'the exact second the robot fell over');++ assert.match(still.pics_slug, /^pic\d+abcd$/);++ assert.match(still.pics_url, /^https:\/\/openvibe\.pics\/i\//);++ assert.strictEqual(r.headers.get('location'), `/api/v1/stills/${still.id}`);++ assert.strictEqual(b.share.url, `https://openvibe.cam/s/${still.id}`);++ assert.strictEqual(b.share.text, `the exact second the robot fell over — https://openvibe.cam/s/${still.id}`);++++ const frameReq = t.upstreams.lastFor('/live/whip/frame.jpg');++ assert.ok(frameReq, 'Media frame was read');++ assert.strictEqual(frameReq.query.w, '640');++ const upload = t.upstreams.uploads()[0];++ assert.match(upload.auth, /^Bearer /);++ assert.strictEqual(upload.contentType, 'image/jpeg');++ assert.strictEqual(upload.title, encodeURIComponent('the exact second the robot fell over'));++ assert.match(upload.filename, /^still-whip-\d+\.jpg$/);++ assert.ok(upload.bytes > 0);++ });++++ await check('the still row holds a Pics slug and links, never the picture bytes', async () => {++ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'cam_stills'")).map((r) => r.column_name);++ assert.ok(!cols.some((c) => /image|blob|bytes|photo|data/i.test(c)), cols.join(','));++ const row = await t.ctx.s.db.maybe('SELECT pics_slug, pics_url, pics_direct FROM cam_stills WHERE id = $1', [still.id]);++ assert.strictEqual(row.pics_slug, still.pics_slug);++ assert.ok(row.pics_url.startsWith('https://openvibe.pics/'));++ });++++ await check('an unlisted still opens by its link, with no author; a private one is its owner\'s only', async () => {++ const anon = await t.get(`/api/v1/stills/${still.id}`);++ assert.strictEqual(anon.status, 200);++ assert.strictEqual(anon.json().mine, false);++ assert.ok(!('subject' in anon.json()));++ const mine = await t.get(`/api/v1/stills/${still.id}`, { as: alice });++ assert.strictEqual(mine.json().mine, true);++ assert.strictEqual(mine.json().subject, alice.subject);++++ const priv = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'only me', visibility: 'private' } })).json().still;++ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`)).status, 404);++ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`, { as: bob })).status, 404);++ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`, { as: alice })).status, 200);++ });++++ await check('an offline slot is 404 and nothing is uploaded to Pics', async () => {++ const before = t.upstreams.uploads().length;++ t.upstreams.setFrame('whip', { status: 404 });++ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });++ assert.strictEqual(r.status, 404);++ assert.strictEqual(r.json().code, 'cam.stream_offline');++ assert.strictEqual(t.upstreams.uploads().length, before);++ t.upstreams.setFrame('whip', { status: 200 });++ });++++ await check('an SVG offline card served with 200 is not a picture; a busy frame is 429', async () => {++ t.upstreams.setFrame('whip', { status: 200, type: 'image/svg+xml' });++ const svg = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });++ assert.strictEqual(svg.status, 502);++ assert.strictEqual(svg.json().code, 'cam.frame_unavailable');++ t.upstreams.setFrame('whip', { status: 429 });++ const busy = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });++ assert.strictEqual(busy.status, 429);++ assert.strictEqual(busy.json().code, 'cam.frame_busy');++ t.upstreams.setFrame('whip', { status: 200, type: 'image/jpeg' });++ });++++ await check('a Pics refusal is a problem, not a still', async () => {++ t.upstreams.setPics(401);++ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });++ assert.strictEqual(r.status, 401);++ assert.strictEqual(r.json().code, 'cam.pics.refused');++ t.upstreams.setPics(201);++ });++++ await check('slot, caption, width and visibility are validated; anonymous is refused', async () => {++ assert.strictEqual((await t.get('/api/v1/stills', { json: { slot: 'whip' } })).status, 401);++ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'not a slot!' } })).status, 400);++ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', w: 4000 } })).status, 400);++ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', visibility: 'public' } })).status, 400);++ const blocked = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'this is kys' } });++ assert.strictEqual(blocked.status, 400);++ assert.strictEqual(blocked.json().code, 'cam.blocked_words');++ const long = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'x'.repeat(281) } });++ assert.strictEqual(long.status, 400);++ });++++ await check('your stills are listed; another person\'s are not', async () => {++ const mine = await t.get('/api/v1/stills', { as: alice });++ assert.strictEqual(mine.status, 200);++ assert.ok(mine.json().count >= 2);++ assert.ok(mine.json().stills.every((s) => s.id.startsWith('cms_')));++ assert.strictEqual((await t.get('/api/v1/stills', { as: bob })).json().count, 0);++ });++ } finally {++ await t.close();++ }++ done();++})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+# OpenVibe.Cam — environment (production: /etc/openvibe/cam.env). Only names are committed; every value is set on
+# the host. A secret is never printed, logged or committed.
+
+# ── The service ──
+NODE_ENV=production
+PORT=5160
+HOST=127.0.0.1
+BASE_URL=https://openvibe.cam
+TRUST_PROXY=2
+CAM_DRILL=0
+
+# ── PostgreSQL (ADR-035) and Valkey ──
+DATABASE_URL=postgres://cam:[email protected]:6432/cam
+DATABASE_DIRECT_URL=postgres://cam_owner:[email protected]:5432/cam
+# CAM_PGLITE_DIR=/opt/openvibe.cam/data/pglite
+# VALKEY_URL=redis://127.0.0.1:6379
+VALKEY_PREFIX=ov:cam:
+
+# ── OpenVibe.Network: SSO (OAuth client cam, PKCE S256) and JWKS ──
+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=cam
+OV_OAUTH_CLIENT_SECRET=...
+OV_OAUTH_REDIRECT_URI=https://openvibe.cam/auth/callback
+OV_SESSION_AUDIENCE=openvibe.network
+CAM_AUDIENCE=openvibe.cam
+COOKIE_SECURE=true
+
+# ── OpenVibe.Events → /internal/events (account export/deletion) ──
+CAM_EVENTS_SECRET=...
+CAM_EVENTS_URL=https://openvibe.events
+CAM_EVENTS_ENDPOINT=https://openvibe.cam/internal/events
+CAM_EVENTS_INTERVAL_MS=5000
+CAM_EVENTS_SCOPE=events.subscription.manage
+
+# ── The existing public APIs Cam uses ──
+OV_MEDIA_URL=https://openvibe.media
+OV_LIVE_URL=https://openvibe.live
+OV_PICS_URL=https://openvibe.pics
+CAM_UPSTREAM_TIMEOUT_MS=8000
+
+# ── Photos, stills, cameras and grants ──
+CAM_PHOTO_MAX_BYTES=12582912
+CAM_STILL_MAX_BYTES=8388608
+CAM_STILL_WIDTH=640
+CAM_MAX_CAMERAS=20
+CAM_GRANT_DAYS=7
+CAM_GRANT_MAX_DAYS=90
+CAM_GRANTS_PER_CAMERA=50
+CAM_LIMITS_MINUTE=120
+CAM_LIMITS_HOUR=3000
+# CAM_API_RATE_LIMIT_PER_MIN=240
+# CAM_RATE_LIMIT_PER_MIN=300
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+name: ci
+on:
+ push:
+ branches: [main]
+ pull_request:
+jobs:
+ # install, node --check and npm test: the shared test job (Track Q).
+ test:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/test.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
+ with:
+ syntax-dirs: server test scripts
+ # ADR-035: PostgreSQL 18 + PgBouncer (transaction mode) + Valkey 9 containers (openvibe-sdk
+ # scripts/test-services.sh). npm test runs every file on PGlite; test:pg runs them all through PgBouncer.
+ test: 'eval "$(node_modules/openvibe-sdk/scripts/test-services.sh up)" && npm test && npm run test:pg'
+ # Secret scan (gitleaks) and dependency audit, shared by every OpenVibers repo (Track Q).
+ security:
+ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@423fe519b5fe9594cc81b670e5ba5cb9f21f6176
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+node_modules/
+/data/
+dist/
+.env
+.env.*
+!.env.example
+.npm-cache/
+*.log
+*.pem
+*.key
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+# Changelog
+
+What changed in OpenVibe.Cam, newest first.
+
+## 0.1.0 — 2026-10-28
+
+The first version: the camera app, my cameras and revocable viewer grants, and the API Live's cam button calls.
+
+- **The camera app.** `/app` runs a live preview in the browser; the timer, the 3-shot burst, the grid and the
+ colour/light/softness filter are all on the device. The picture is posted as raw bytes to `POST /api/v1/photos`,
+ forwarded to OpenVibe.Pics with the person's own Network token, and recorded as a Pics slug, page link, direct
+ link, filter name and caption. No image bytes, no face template and no classification are stored here.
+- **My cameras.** `cam_cameras` pairs a phone, a browser, a Pi or an IP camera. A Live stream id is read from
+ OpenVibe.Live's public API and must be on the owner's channel; the slot defaults to the stream's managed slot.
+ The owner can rename it, set the slot and status, and unpair it. `/my/cameras/:id` is the owner's page.
+- **Viewer grants.** `cam_grants` names one canonical viewer and an expiry (7 days by default, 90 at most), with a
+ partial unique index for one unrevoked grant per camera and viewer. `@username` is resolved through
+ OpenVibe.Network with `identity.subject.resolve`; a subject id works without Network. A re-grant extends the row;
+ a revocation writes `revoked_at` and the wall checks it on every read, so the next request is refused.
+- **The private wall.** `/w/:id` and `GET /api/v1/cameras/:id/wall` answer the owner and an active grantee, and 404
+ to everyone else. Staff have no bypass. A grantee never sees the owner's note. An attached photo is unlisted and
+ appears on the wall; a photo without a camera is private.
+- **The Live cam button.** `POST /api/v1/stills` reads OpenVibe.Media's public `GET /live/:slot/frame.jpg`, accepts
+ only a JPEG (never an offline SVG card or HTML), uploads it to OpenVibe.Pics as the viewer, records the still and
+ returns `share.text`, the exact line Live posts in the stream's chat. A still is unlisted by default and private
+ on request.
+- **The service.** Network sign-in as OAuth client `cam` (PKCE S256, `cam_at`/`cam_rt`/`cam_oauth`), truthful
+ readiness, Prometheus metrics on loopback, discovery, deploy files, and server-rendered pages with stable `cm-`
+ ids. `public/css/cam.css` is a one-comment placeholder for the main session.
+- **Account data.** Export and deletion cover cameras, grants given and received, photos and stills; a deleted
+ owner's camera and its grants go, a deleted viewer's grants go and the owner's camera stays. Pictures themselves
+ are OpenVibe.Pics' and are erased on the same Network event.
+- **The first proof.** `scripts/seed-proof.js` pairs a test camera, grants a viewer, opens the wall, revokes the
+ grant and proves the next wall request is refused.
+- **Tests.** Migration and constraints, pairing and stream ownership, grants and their refusals, the still path,
+ the photo upload, pages and cache headers, SSO, and account export/deletion, each in its own process on a
+ temporary PGlite database with a mock Network and a Media/Live/Pics fixture server.
new file mode 100644
new file mode 100644
@@ -0,0 +1,661 @@
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU Affero General Public License is a free, copyleft license for
+software and other kinds of works, specifically designed to ensure
+cooperation with the community in the case of network server software.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+our General Public Licenses are intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ Developers that use our General Public Licenses protect your rights
+with two steps: (1) assert copyright on the software, and (2) offer
+you this License which gives you legal permission to copy, distribute
+and/or modify the software.
+
+ A secondary benefit of defending all users' freedom is that
+improvements made in alternate versions of the program, if they
+receive widespread use, become available for other developers to
+incorporate. Many developers of free software are heartened and
+encouraged by the resulting cooperation. However, in the case of
+software used on network servers, this result may fail to come about.
+The GNU General Public License permits making a modified version and
+letting the public access it on a server without ever releasing its
+source code to the public.
+
+ The GNU Affero General Public License is designed specifically to
+ensure that, in such cases, the modified source code becomes available
+to the community. It requires the operator of a network server to
+provide the source code of the modified version running there to the
+users of that server. Therefore, public use of a modified version, on
+a publicly accessible server, gives the public access to the source
+code of the modified version.
+
+ An older license, called the Affero General Public License and
+published by Affero, was designed to accomplish similar goals. This is
+a different license, not a version of the Affero GPL, but Affero has
+released a new version of the Affero GPL which permits relicensing under
+this license.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU Affero General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Remote Network Interaction; Use with the GNU General Public License.
+
+ Notwithstanding any other provision of this License, if you modify the
+Program, your modified version must prominently offer all users
+interacting with it remotely through a computer network (if your version
+supports such interaction) an opportunity to receive the Corresponding
+Source of your version by providing access to the Corresponding Source
+from a network server at no charge, through some standard or customary
+means of facilitating copying of software. This Corresponding Source
+shall include the Corresponding Source for any work covered by version 3
+of the GNU General Public License that is incorporated pursuant to the
+following paragraph.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the work with which it is combined will remain governed by version
+3 of the GNU General Public License.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU Affero General Public License from time to time. Such new versions
+will be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU Affero General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU Affero General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU Affero General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU Affero General Public License as published
+ by the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU Affero General Public License for more details.
+
+ You should have received a copy of the GNU Affero General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If your software can interact with users remotely through a computer
+network, you should also make sure that it provides a way for users to
+get its source. For example, if your program is a web application, its
+interface could display a "Source" link that leads users to an archive
+of the code. There are many ways you could offer source, and different
+solutions will be better for different programs; see section 13 for the
+specific requirements.
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU AGPL, see
+<https://www.gnu.org/licenses/>.
new file mode 100644
new file mode 100644
@@ -0,0 +1,185 @@
@@ -0,0 +1,185 @@
+# OpenVibe.Cam
+
+> Your camera, your cameras, and the moment Live's cam button takes.
+
+**Status:** v1 written and tested (see [STATUS.json](STATUS.json)).
+**Domain:** `openvibe.cam` · **Port:** 5160 · **Service id:** `cam` · **Env prefix:** `CAM`
+**License:** AGPL-3.0-only (same as every OpenVibe service).
+
+OpenVibe.Cam is the camera app, the private wall for cameras you own, and the API Live's cam button calls. A photo
+or a still is saved to OpenVibe.Pics; Cam keeps the link, the filter and the caption, never the bytes. A camera is
+private to its owner, and a viewer sees it only while the grant the owner gave is active and not revoked.
+
+## Purpose
+
+- **The camera app.** A browser preview with a timer, a 3-shot burst, a grid and on-device colour, light and
+ softness. The filter runs on the canvas in the person's browser; the server never applies an effect. The picture
+ is posted to Cam, which forwards it to OpenVibe.Pics with the person's own Network token. There is no server-side
+ image analysis and no face recognition.
+- **My cameras.** Pair an old phone, a browser, a Pi or an IP camera you own. If a Live stream id is given, Cam
+ reads it from OpenVibe.Live's public API and refuses a stream on somebody else's channel; the slot defaults to the
+ stream's managed slot. Its wall is private: `/w/<id>` answers its owner and an active grantee, and 404 to
+ everyone else, including staff.
+- **Revocable viewer grants.** A grant names one canonical subject and an expiry: seven days by default, ninety at
+ most. `@username` is resolved through OpenVibe.Network (`identity.subject.resolve`); a subject id works without
+ it. A re-grant extends the active grant instead of making a second one. Revocation writes a timestamp, and the
+ wall checks it on every read, so the next request is refused.
+- **The Live cam button.** Live calls `POST /api/v1/stills` with the viewer's own Network token. Cam fetches
+ OpenVibe.Media's public `GET /live/:slot/frame.jpg` (only `image/jpeg` is accepted), uploads the JPEG to
+ OpenVibe.Pics as that person, and returns the still and the exact line Live posts in the stream chat.
+
+## Owns
+
+| Table | Holds | Migration |
+|---|---|---|
+| `cam_cameras` | `cam_` + ULID, owner `usr_…`, owner username, name, source `browser\|phone\|pi\|ip`, status `paired\|live\|offline`, Live `stream_id` (unique per owner), `slot`, the owner's note, paired/last-seen times | `migrations/0001_initial.sql` |
+| `cam_grants` | `cmg_` + ULID, camera, owner, `viewer_subject`, viewer username, `granted_at`, `expires_at`, `revoked_at`, `revoked_by`; one unrevoked grant per camera and viewer | `0001_initial.sql` |
+| `cam_photos` | `cmp_` + ULID, subject, optional camera, filter label, caption, visibility `private\|unlisted`, Pics slug, page URL, direct URL | `0001_initial.sql` |
+| `cam_stills` | `cms_` + ULID, subject, stream id, slot, caption, visibility `unlisted\|private`, Pics slug, page URL, direct URL | `0001_initial.sql` |
+| `account_data_events` | openvibe-sdk/account-data's export/deletion receipts | `0001_initial.sql` |
+| `telemetry_samples` | aggregated request telemetry, pruned after 14 days | `0001_initial.sql` |
+
+There is no image, blob or byte column in any Cam table. The `pics_slug` and links are the picture's address in
+OpenVibe.Pics; neither Cam nor its database can serve the bytes.
+
+## Does not own
+
+- **The picture bytes, resizing, metadata stripping and takedowns** — OpenVibe.Pics (OpenVibe.MediaHub). Cam reads
+ its `201` and stores the slug and links.
+- **The live frame** — OpenVibe.Media's `/live/:sel/frame.jpg`. Cam never runs ffmpeg or caches a frame.
+- **Streams, chat and the streamer's second camera** — OpenVibe.Live and OpenVibe.Chat. The `OVCamPip` streamer
+ camera is Live's; Live also posts the returned still line to the stream's chat with its own chat authority.
+- **Identity, usernames and accounts** — OpenVibe.Network. Cam verifies sessions against its JWKS and resolves a
+ viewer's `@username` with `identity.subject.resolve`.
+- **Search** — OpenVibe.Search. v1 makes nothing public, so Cam queues no Search document.
+- **Events, the host and the database** — OpenVibe.Events, OpenVibe.Host.
+
+## Depends on
+
+Environment variable names are in [.env.example](.env.example); no value is committed.
+
+- **OpenVibe.Network** — sign-in (OAuth client `cam`, PKCE S256, cookies `cam_at`/`cam_rt`/`cam_oauth`), JWKS,
+ token exchange/refresh/revoke, account export/deletion, and `identity.subject.resolve` for `@username`:
+ `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`, `CAM_AUDIENCE`.
+- **OpenVibe.Media** — the public live frame, `GET /live/:sel/frame.jpg?w=640` (`media.derivative.read` is the
+ planned capability; the route needs no token today): `OV_MEDIA_URL`.
+- **OpenVibe.Live** — `GET /api/streams/:id`, the public read used to check that a paired stream is on the owner's
+ channel (`live.stream.read`: `OV_LIVE_URL`).
+- **OpenVibe.Pics (OpenVibe.MediaHub)** — `POST /api/v1/images`, called with the signed-in person's own Network
+ token; Cam never holds a service token for Pics (`OV_PICS_URL`). The route is `pics.image.upload` in the person's
+ own right; MediaHub refuses an app or service token there.
+- **OpenVibe.Events** — account deliveries: `CAM_EVENTS_SECRET`, `CAM_EVENTS_URL`, `CAM_EVENTS_ENDPOINT`.
+- **PostgreSQL** — `DATABASE_URL` (through PgBouncer) and `DATABASE_DIRECT_URL` (migrations); embedded PGlite in
+ `CAM_PGLITE_DIR` for development.
+- **Valkey** — optional `VALKEY_URL`, `VALKEY_PREFIX` (readiness lists it; the limits in v1 count in-process).
+- **Packages** — `openvibe-contracts` v0.234.0, `openvibe-sdk` v0.70.0, `openvibe-shared` v4.31.0, the same tags
+ OpenVibe.Meme pins.
+
+## API
+
+Everything is JSON under `/api/v1`; errors are `application/problem+json` with a `code`. A person is the `cam_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.cam` (403 `cam.cross_site`). A signed-in account also gets
+per-actor minute/hour limits (`CAM_LIMITS_MINUTE`, `CAM_LIMITS_HOUR`).
+
+| Route | What it does |
+|---|---|
+| `POST /photos` | a person; raw picture body (`image/jpeg`, `image/png`, `image/webp`), optional `X-Filter`, `X-Caption`, `X-Camera`, `X-Filename` (URI-encoded); forwarded to Pics |
+| `GET /photos` · `GET /photos/:id` | your photos |
+| `GET /cameras` | your cameras with active grant counts |
+| `POST /cameras` | `{ name, source, stream_id?, slot?, notes? }`; a `stream_id` is read from Live and must be yours |
+| `GET /cameras/:id` · `PATCH /cameras/:id` · `DELETE /cameras/:id` | the owner: read, change name/source/status/slot/notes, unpair |
+| `GET /cameras/:id/wall` | the owner or an active grantee; a stranger gets 404 |
+| `GET /cameras/:id/grants` | the owner: every grant, including expired and revoked |
+| `POST /cameras/:id/grants` | the owner: `{ viewer_username }` or `{ viewer_subject }`, `{ days }` 1–90 |
+| `DELETE /cameras/:id/grants/:grantId` | the owner: revoke at once |
+| `POST /stills` | a person; `{ slot, caption?, stream_id?, w?, visibility? }`; Media's frame → Pics → a still and the chat line |
+| `GET /stills` | your stills |
+| `GET /stills/:id` | an unlisted still by its link; a private one only for its owner |
+
+Validation refusals are `400 { code: "cam.invalid", message }`; a blocked caption is `400 { code:
+"cam.blocked_words" }`. Other codes: `cam.sign_in_required`, `cam.person_required`, `cam.cross_site`,
+`cam.not_found`, `cam.camera_exists`, `cam.camera_limit`, `cam.grant_limit`, `cam.grant_not_found`,
+`cam.stream_not_found`, `cam.not_your_stream`, `cam.stream_offline`, `cam.frame_unavailable`, `cam.frame_busy`,
+`cam.pics.refused`, `cam.pics.unavailable`, `cam.too_large`, `cam.not_an_image`, `cam.viewer_not_found`,
+`cam.identity_unavailable`, `cam.rate_limited`.
+
+## The Live cam button
+
+Live is the caller. It holds the viewer's Network token in its own session; it sends that token to Cam as a Bearer
+(server-side), never a Cam service token:
+
+1. The watch page sends `{ slot, stream_id, caption }` to Live's server.
+2. Live calls `POST https://openvibe.cam/api/v1/stills` with `Authorization: Bearer <viewer's Network token>`.
+3. Cam reads `GET https://openvibe.media/live/:slot/frame.jpg?w=640`; a 404 is `cam.stream_offline`, and an SVG
+ offline card served with 200 is refused as `cam.frame_unavailable`.
+4. Cam calls `POST https://openvibe.pics/api/v1/images` with the same token, the JPEG as the body, `X-Title` and
+ `X-Filename`. Pics re-encodes and strips metadata.
+5. Cam answers `201 { still, frame_url, share: { url, caption, text } }`. Live posts `share.text` to the stream's
+ chat and shows `share.url` (or `still.pics_direct`).
+
+Cam needs no app/service token for this, and Live needs no new Cam grant. A still is `unlisted` by default; pass
+`"visibility": "private"` to keep it to its owner. Media caches one frame per slot for five seconds and rate-limits
+by address, so a burst of button presses can answer `429 cam.frame_busy`.
+
+## Pages
+
+All server-rendered through the OpenVibe Frame (`openvibe-shared/shell`), readable without JavaScript, with
+semantic HTML and stable `cm-` ids/classes. Public pages are `public, max-age=60` with `Vary: Cookie`; a page drawn
+for a signed-in person, a wall or a still is `private, no-store`.
+
+| Page | What it is |
+|---|---|
+| `/` | the shutter hero, the three scenes, the privacy rules |
+| `/app` | the camera app: preview, timer, burst, grid, filter, caption, and your recent photos |
+| `/my` | pair a camera, your cameras, your photos and stills (signed in) |
+| `/my/cameras/:id` | one camera: its frame, edit form, viewers, revoke buttons, unpair |
+| `/w/:id` | the private wall an owner or an active grantee sees |
+| `/s/:id` | one still of a live stream, with its Pics link |
+| `/developers` | the API and the Live cam button contract |
+| 404 | the not-found page |
+
+`public/css/cam.css` is a one-comment placeholder: the pages are complete without it, and the main session designs
+it. `public/js/cam.js` adds the live preview, the timer, the burst, the grid and the on-device filter. The two
+pictures in `public/img/` are data-free SVG placeholders. `robots.txt`, `sitemap.xml`, `llms.txt` and
+`llms-full.txt` list the three public pages and never a wall, a grant or a still.
+
+## The first proof
+
+`scripts/seed-proof.js` performs the part that needs no network:
+
+- pair an owned test camera (`Proof camera`, slot `proof`) for `CAM_PROOF_OWNER`;
+- grant `CAM_PROOF_VIEWER` for seven days;
+- open the wall as that viewer;
+- revoke the grant;
+- ask for the wall again and assert it is refused.
+
+Re-running reuses the camera and makes a fresh grant. The photo and the still need a signed-in person's Network
+token and are done through `/app` or the API; both go to OpenVibe.Pics.
+
+## 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 5160, loopback; DATABASE_URL or an embedded PGlite in development
+CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js
+```
+
+The tests boot the app with a mock Network, a Media/Live/Pics fixture server, and a temporary database: the
+migration and its constraints, pairing and stream ownership, grants (username resolution, extension, revocation,
+expiry, staff have no bypass), the still's Media → Pics path and its refusals, the photo upload and its limits, the
+pages and cache headers, sign-in, and account export/deletion.
+
+## Deploy
+
+`deploy/systemd/openvibe-cam.service` (127.0.0.1:5160, `/etc/openvibe/cam.env`) and
+`deploy/nginx/openvibe.cam.conf` (TLS, www → apex, `/auth/me` on the API zone, `/my/` on the API zone, `/internal`
+and `/metrics` 404, a 16 MB body cap for a photo). Not deployed by this change.
+
+## License
+
+AGPL-3.0-only. See [LICENSE](LICENSE).
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -0,0 +1,42 @@
+# 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 cameras, grants, pictures 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.cam, openvibe.media, openvibe.pics 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.Cam holds
+
+- A paired camera: its name, source, status, Live stream id and slot, the owner's username and note, and times. It
+ is private to its owner; there is no public camera page in v1.
+- A viewer grant: the camera, the owner's subject, the viewer's subject and username, its grant and expiry times,
+ and a revocation time and author. It is not a secret, but it is the only thing that opens a wall.
+- A photo or a still: the person's subject, a camera or stream/slot, the filter name and caption, and OpenVibe.Pics'
+ slug, page link and direct link. **No picture bytes, no face template and no identity classification** are stored
+ here. The picture itself is OpenVibe.Pics' and follows Pics' own access rules, re-encoding and takedowns.
+- Network session tokens only in httpOnly cookies (`cam_at`, `cam_rt`). The token is forwarded to OpenVibe.Pics when
+ the person saves a picture; it is never stored in the database, rendered into a page or logged.
+
+A wall answers 404 whether the camera does not exist or the caller has no active grant, so a stranger cannot probe
+for one. Staff have no bypass: a Network `global_mod` without a grant is refused like anyone else. A session write
+must come from `openvibe.cam` (Sec-Fetch-Site same-origin, or an Origin equal to ours); a cross-site form cannot
+pair a camera, grant a viewer, revoke a grant or upload a photo. The `/internal/events` account route is loopback
+only and refuses a proxied request; nginx answers 404 for `/internal` and `/metrics`.
+
+There is no face recognition, no nudity or violence classifier, no AI call and no recording in v1. The camera is
+same-origin only (`Permissions-Policy: camera=(self), microphone=()`), and the filter runs in the browser.
+
+## 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,62 @@
@@ -0,0 +1,62 @@
+{
+ "repository": "OpenVibers/OpenVibe.Cam",
+ "version": "0.1.0",
+ "stage": "product",
+ "code": true,
+ "wave": "W21",
+ "plan": "OpenVibe.Cam: the camera app, private cameras with revocable viewer grants, and the still Live's cam button takes — a photo and a still saved to OpenVibe.Pics by reference, with no picture bytes and no face recognition in Cam",
+ "runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
+ "port": "5160",
+ "domain": "openvibe.cam",
+ "contracts": "openvibe-contracts v0.234.0",
+ "sdk": "openvibe-sdk v0.70.0",
+ "shared": "openvibe-shared v4.31.0",
+ "packages": [
+ "openvibe-contracts v0.234.0",
+ "openvibe-sdk v0.70.0",
+ "openvibe-shared v4.31.0"
+ ],
+ "summary": "OpenVibe.Cam is the camera app, the private wall for cameras a person owns, and the API Live's cam button calls. The camera preview, timer, burst, grid and filter run in the browser; a photo is posted to Cam and forwarded to OpenVibe.Pics with the person's own Network token. A camera is private: its owner sees it, and a viewer sees it only while a grant naming them is unrevoked and unexpired, checked on every read, so a revocation is refused at once. Live's cam button calls Cam with the viewer's token; Cam reads OpenVibe.Media's public live frame, saves the JPEG to Pics as that person, and returns the line Live posts in the stream's chat. Cam keeps a Pics slug, links, filter and caption — never the picture bytes — and there is no face recognition.",
+ "works": [
+ "Cameras: pair a phone, browser, Pi or IP camera; a Live stream id is read from Live and must be on the owner's channel; the slot defaults to the stream's managed slot",
+ "The owner's camera page: rename, source, status, slot and note; unpair; the private wall at /w/<id>",
+ "Viewer grants: one canonical subject and an expiry (1–90 days), @username resolved through Network identity.subject.resolve, one unrevoked grant per camera and viewer, extension instead of duplication",
+ "Revocation is immediate: the wall checks revoked_at and expires_at on every read and answers 404 to a stranger, including staff with no grant",
+ "The camera app: live preview, timer, 3-shot burst, grid, on-device colour/light/softness, caption; the picture goes to Pics",
+ "The Live cam button: POST /api/v1/stills reads Media's GET /live/:slot/frame.jpg, uploads the JPEG to Pics with the viewer's own token, and returns the chat line",
+ "Photos and stills are stored by reference (Pics slug, page link, direct link, filter, caption); no image bytes, no face template and no classification",
+ "A still is unlisted by default (the chat link opens it) or private to its owner; a photo on a camera wall is unlisted, otherwise private",
+ "Sign-in with OpenVibe.Network (PKCE S256, cam_at/cam_rt/cam_oauth), truthful readiness and loopback metrics",
+ "Account export and deletion over cameras, grants given and received, photos and stills",
+ "Server-rendered pages with stable cm- ids/classes, cache headers, discovery artifacts and deploy files"
+ ],
+ "notYet": [
+ "The public camera directory, map, categories and follow-a-cam: v2; v1 makes nothing public and queues no Search document",
+ "Timelapses, the AI highlight of the day and Vision alerts: v2; Media has no timelapse",
+ "Face to face (random/group video, CAM-3): blocked on the owner's go/no-go after the safety gate and legal review",
+ "Video capture in the camera app: MediaHub's POST /api/v1/videos chunked upload is the path; v1 stores photos and stills only",
+ "Streamerless pairing of an IP camera: ingest is Live's RTMP path; v1 pairs and reads a Live slot",
+ "A Search relay: nothing is public in v1, so there is no cam_outbox",
+ "A staff moderation screen: staff have no bypass of camera privacy; a takedown is a report to Pics or Network"
+ ],
+ "capabilitiesRegistered": false,
+ "serviceManifestRegistered": false,
+ "deployed": false,
+ "placeholderInSites": false,
+ "features": [
+ "camera app",
+ "photos to Pics",
+ "my cameras",
+ "private wall",
+ "viewer grants",
+ "grant revocation",
+ "Live cam button stills",
+ "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-cam.service and deploy/nginx/openvibe.cam.conf",
+ "updated": "2026-10-28"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+## ═══════════════════════════════════════════════════════════════
+## openvibe.cam — Nginx configuration (REFERENCE)
+##
+## Deployed to /etc/nginx/sites-available/openvibe.cam.conf (ov-vhost-install) — keep them in sync and reload
+## nginx after changing it. The Node app (port 5160, systemd unit openvibe-cam) renders every page and serves
+## the API; nginx terminates TLS, redirects www → apex, rate-limits sign-in, the API and the camera writes, and
+## compresses text.
+##
+## No proxy_cache: the app decides cacheability per response (public for the three public pages; private, no-store
+## for a camera wall, a still, or anything drawn for a signed-in person). A photo is at most 12 MB; the body cap
+## leaves room for its headers. Never log request bodies here.
+## ═══════════════════════════════════════════════════════════════
+
+limit_req_zone $binary_remote_addr zone=cam_auth:10m rate=10r/m;
+limit_req_zone $binary_remote_addr zone=cam_api:10m rate=5r/s;
+
+server {
+ listen 80;
+ listen [::]:80;
+ server_name openvibe.cam www.openvibe.cam;
+
+ location /.well-known/acme-challenge/ { root /var/www/certbot; }
+ location / { return 301 https://openvibe.cam$request_uri; }
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name www.openvibe.cam;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.cam/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.cam/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ return 301 https://openvibe.cam$request_uri;
+}
+
+server {
+ listen 443 ssl;
+ listen [::]:443 ssl;
+ http2 on;
+ server_name openvibe.cam;
+
+ ssl_certificate /etc/letsencrypt/live/openvibe.cam/fullchain.pem;
+ ssl_certificate_key /etc/letsencrypt/live/openvibe.cam/privkey.pem;
+ ssl_protocols TLSv1.2 TLSv1.3;
+
+ access_log /var/log/nginx/openvibe.cam.access.log;
+ error_log /var/log/nginx/openvibe.cam.error.log;
+
+ # A photo is capped at 12 MB (CAM_PHOTO_MAX_BYTES); leave room for headers.
+ client_max_body_size 16m;
+
+ add_header X-Content-Type-Options nosniff always;
+ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
+ add_header Permissions-Policy "camera=(self), microphone=(), geolocation=(), payment=()" always;
+
+ gzip on;
+ gzip_proxied any;
+ gzip_vary on;
+ gzip_comp_level 5;
+ gzip_min_length 1024;
+ gzip_types text/css application/javascript text/javascript application/json application/problem+json image/svg+xml text/plain application/xml;
+
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $remote_addr;
+ proxy_set_header CF-Connecting-IP $remote_addr;
+ proxy_set_header X-Forwarded-Proto $scheme;
+
+ # ── Network SSO session endpoints ───────────────────────
+ # The navbar's session probe runs on every page view: it gets the API's budget, not sign-in's.
+ location = /auth/me {
+ limit_req zone=cam_api burst=30 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5160;
+ }
+
+ location /auth/ {
+ limit_req zone=cam_auth burst=10 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5160;
+ }
+
+ # ── API, camera pages and their forms ───────────────────
+ location /api/ {
+ limit_req zone=cam_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5160;
+ }
+
+ location /my/ {
+ limit_req zone=cam_api burst=20 nodelay;
+ limit_req_status 429;
+ proxy_pass http://127.0.0.1:5160;
+ }
+
+ # ── Prometheus metrics: host-local scrapers only (http://127.0.0.1:5160/metrics) ──
+ location ~* ^/metrics(/|$) { return 404; }
+
+ # ── OpenVibe.Events' account delivery route: loopback only (POST /internal/events) ──
+ # Events posts to http://127.0.0.1:5160/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:5160;
+ }
+
+ location ~ /\.(?!well-known) {
+ deny all;
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,31 @@
@@ -0,0 +1,31 @@
+[Unit]
+Description=OpenVibe.Cam — the camera app, private cameras and viewer grants, and the still Live's cam button takes.
+# Network (sign-in, JWKS, identity resolve, account events) should be up first, but Cam degrades honestly without it:
+# the public pages 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.cam
+EnvironmentFile=/etc/openvibe/cam.env
+Environment=NODE_ENV=production
+Environment=PORT=5160
+Environment=HOST=127.0.0.1
+Environment=PATH=/usr/local/bin:/usr/bin:/bin
+ExecStart=/usr/bin/env node /opt/openvibe.cam/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.Cam'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,143 @@
@@ -0,0 +1,143 @@
+-- phase: expand
+-- OpenVibe.Cam: paired cameras, revocable viewer grants, and the photos and stills whose bytes live in
+-- OpenVibe.Pics. A camera row is private to its owner; a grant names one viewer and an expiry, and a revocation is
+-- a timestamp checked on every read. Nothing here is a credential and no picture bytes are stored.
+
+CREATE TABLE cam_cameras (
+ id text COLLATE "C" PRIMARY KEY, -- cam_ + 26 Crockford base32 characters (ULID)
+ owner text COLLATE "C" NOT NULL, -- usr_…
+ owner_username text, -- the owner's Network username when it was paired
+ name text NOT NULL,
+ source text COLLATE "C" NOT NULL DEFAULT 'phone',-- browser | phone | pi | ip
+ status text COLLATE "C" NOT NULL DEFAULT 'paired',-- paired | live | offline
+ stream_id bigint, -- OpenVibe.Live stream id, when the camera is a Live slot
+ slot text COLLATE "C", -- a Live slot id, slug or @username for Media's frame
+ notes text, -- the owner's own note; never rendered for a viewer
+ paired_at timestamptz NOT NULL DEFAULT now(),
+ last_seen_at timestamptz, -- when the wall last asked for its frame
+ created_at timestamptz NOT NULL DEFAULT now(),
+ updated_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT cam_cameras_id CHECK (id ~ '^cam_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_cameras_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_cameras_name CHECK (length(name) BETWEEN 1 AND 80),
+ CONSTRAINT cam_cameras_source CHECK (source IN ('browser', 'phone', 'pi', 'ip')),
+ CONSTRAINT cam_cameras_status CHECK (status IN ('paired', 'live', 'offline')),
+ CONSTRAINT cam_cameras_stream CHECK (stream_id IS NULL OR stream_id > 0),
+ CONSTRAINT cam_cameras_slot CHECK (slot IS NULL OR slot ~ '^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$')
+);
+CREATE UNIQUE INDEX cam_cameras_stream_unique ON cam_cameras (owner, stream_id) WHERE stream_id IS NOT NULL;
+CREATE INDEX cam_cameras_by_owner ON cam_cameras (owner, created_at DESC);
+
+-- One viewer, one expiry, one revocation. An active grant is revoked_at IS NULL and expires_at in the future; the
+-- store compares both against its clock on every read, so a revocation takes effect at once. A revoked row stays as
+-- the record that the grant existed. A re-grant reuses the row that was never revoked.
+CREATE TABLE cam_grants (
+ id text COLLATE "C" PRIMARY KEY, -- cmg_ + ULID
+ camera_id text COLLATE "C" NOT NULL REFERENCES cam_cameras (id) ON DELETE CASCADE,
+ owner text COLLATE "C" NOT NULL, -- the person who granted it (usr_…)
+ viewer_subject text COLLATE "C" NOT NULL, -- usr_…
+ viewer_username text, -- as resolved when the grant was made
+ granted_at timestamptz NOT NULL DEFAULT now(),
+ expires_at timestamptz NOT NULL,
+ revoked_at timestamptz,
+ revoked_by text COLLATE "C", -- usr_… of the owner, or service:cam
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT cam_grants_id CHECK (id ~ '^cmg_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_grants_owner CHECK (owner ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_grants_viewer CHECK (viewer_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_grants_revoked_by CHECK (revoked_by IS NULL OR length(revoked_by) BETWEEN 1 AND 64),
+ CONSTRAINT cam_grants_expiry CHECK (expires_at > granted_at),
+ CONSTRAINT cam_grants_revoked CHECK (revoked_at IS NULL OR revoked_at >= granted_at)
+);
+CREATE UNIQUE INDEX cam_grants_one_active ON cam_grants (camera_id, viewer_subject) WHERE revoked_at IS NULL;
+CREATE INDEX cam_grants_by_camera ON cam_grants (camera_id, granted_at DESC);
+CREATE INDEX cam_grants_by_viewer ON cam_grants (viewer_subject, expires_at DESC);
+
+-- A photo the camera app saved. The bytes went to OpenVibe.Pics; this row keeps its slug and public links. A photo
+-- attached to a camera is unlisted so the owner and an active viewer see it on the wall; otherwise it is private.
+CREATE TABLE cam_photos (
+ id text COLLATE "C" PRIMARY KEY, -- cmp_ + ULID
+ subject text COLLATE "C" NOT NULL, -- usr_…
+ camera_id text COLLATE "C" REFERENCES cam_cameras (id) ON DELETE SET NULL,
+ filter text COLLATE "C" NOT NULL DEFAULT 'none', -- an on-device look; the server applies nothing
+ caption text,
+ visibility text COLLATE "C" NOT NULL DEFAULT 'private', -- private | unlisted
+ pics_slug text COLLATE "C" NOT NULL,
+ pics_url text NOT NULL, -- https://openvibe.pics/i/<slug>
+ pics_direct text NOT NULL, -- https://openvibe.pics/i/<slug>.<ext>
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT cam_photos_id CHECK (id ~ '^cmp_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_photos_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_photos_filter CHECK (filter IN ('none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur')),
+ CONSTRAINT cam_photos_caption CHECK (caption IS NULL OR length(caption) BETWEEN 1 AND 280),
+ CONSTRAINT cam_photos_visibility CHECK (visibility IN ('private', 'unlisted')),
+ CONSTRAINT cam_photos_slug CHECK (length(pics_slug) BETWEEN 1 AND 64),
+ CONSTRAINT cam_photos_url CHECK (pics_url ~ '^https?://'),
+ CONSTRAINT cam_photos_direct CHECK (pics_direct ~ '^https?://')
+);
+CREATE INDEX cam_photos_by_subject ON cam_photos (subject, created_at DESC);
+CREATE INDEX cam_photos_by_camera ON cam_photos (camera_id, created_at DESC);
+
+-- A still of a Live stream, from the cam button. Also a Pics picture by reference; unlisted by default so the link
+-- posted in chat opens it, private when the person chooses. Never a recording.
+CREATE TABLE cam_stills (
+ id text COLLATE "C" PRIMARY KEY, -- cms_ + ULID
+ subject text COLLATE "C" NOT NULL, -- usr_… of the viewer who snapped it
+ stream_id bigint,
+ slot text COLLATE "C" NOT NULL,
+ caption text,
+ visibility text COLLATE "C" NOT NULL DEFAULT 'unlisted',-- unlisted | private
+ pics_slug text COLLATE "C" NOT NULL,
+ pics_url text NOT NULL,
+ pics_direct text NOT NULL,
+ created_at timestamptz NOT NULL DEFAULT now(),
+ CONSTRAINT cam_stills_id CHECK (id ~ '^cms_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_stills_subject CHECK (subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'),
+ CONSTRAINT cam_stills_stream CHECK (stream_id IS NULL OR stream_id > 0),
+ CONSTRAINT cam_stills_slot CHECK (slot ~ '^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$'),
+ CONSTRAINT cam_stills_caption CHECK (caption IS NULL OR length(caption) BETWEEN 1 AND 280),
+ CONSTRAINT cam_stills_visibility CHECK (visibility IN ('unlisted', 'private')),
+ CONSTRAINT cam_stills_slug CHECK (length(pics_slug) BETWEEN 1 AND 64),
+ CONSTRAINT cam_stills_url CHECK (pics_url ~ '^https?://'),
+ CONSTRAINT cam_stills_direct CHECK (pics_direct ~ '^https?://')
+);
+CREATE INDEX cam_stills_by_subject ON cam_stills (subject, created_at DESC);
+
+-- 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-cam",
+ "version": "0.1.0",
+ "lockfileVersion": 3,
+ "requires": true,
+ "packages": {
+ "": {
+ "name": "openvibe-cam",
+ "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.237.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.237.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.237.0",
+ "integrity": "sha512-xMAwlCHx0j4FzhFNKASnuveqlkjhwHtJG4TfvqO+jDCGIa6xnK7654aQIBB4P7xnwaGtW7Ae71OmmK2cQfrm3Q==",
+ "license": "AGPL-3.0-only",
+ "dependencies": {
+ "ajv": "^8.17.1",
+ "ajv-formats": "^3.0.1"
+ },
+ "bin": {
+ "openvibe-contracts-check": "bin/check-service.js"
+ },
+ "engines": {
+ "node": ">=20"
+ }
+ },
+ "node_modules/openvibe-sdk": {
+ "version": "0.70.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.70.0",
+ "integrity": "sha512-iMNxlnRm0fmyj60nHiC/lKAaG3JAqiJgu42lShnkby4yF4dQJRG3G0trrqOoULZKBalrYHb/gjauRwNQYN/tog==",
+ "license": "MIT",
+ "bin": {
+ "openvibe-download": "bin/openvibe-download.js"
+ },
+ "engines": {
+ "node": ">=20"
+ },
+ "peerDependencies": {
+ "@electric-sql/pglite": "^0.5.8",
+ "iovalkey": "^0.4.0",
+ "pg": "^8.23.0"
+ },
+ "peerDependenciesMeta": {
+ "@electric-sql/pglite": {
+ "optional": true
+ },
+ "iovalkey": {
+ "optional": true
+ },
+ "pg": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/openvibe-shared": {
+ "version": "4.31.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.31.0",
+ "integrity": "sha512-Zk1t1RGsiszXJEyW3x/cVCYC6U1qyLXID43l/Ehv2Py5gGglz5CfgYlPcDGI7n3QACsCWoOIGZuMBvlyfxyXEA==",
+ "license": "MIT",
+ "dependencies": {
+ "jsonwebtoken": "^9.0.0"
+ },
+ "bin": {
+ "openvibe-build-assets": "scripts/build-assets.js"
+ }
+ },
+ "node_modules/parseurl": {
+ "version": "1.3.3",
+ "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+ "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/path-to-regexp": {
+ "version": "0.1.13",
+ "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+ "license": "MIT"
+ },
+ "node_modules/pg": {
+ "version": "8.23.1",
+ "resolved": "https://registry.npmjs.org/pg/-/pg-8.23.1.tgz",
+ "integrity": "sha512-aL96AHANtWjPLDOLqnhx+ngp9+UK7ETEU8VJrDCGvsSSi/mGLcWYsS6Herg7lmaBJe4uwrfqsa7gTEFaSizDoQ==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-connection-string": "^2.14.1",
+ "pg-pool": "^3.14.0",
+ "pg-protocol": "^1.16.1",
+ "pg-types": "2.2.0",
+ "pgpass": "1.0.5"
+ },
+ "engines": {
+ "node": ">= 16.0.0"
+ },
+ "optionalDependencies": {
+ "pg-cloudflare": "^1.4.1"
+ },
+ "peerDependencies": {
+ "pg-native": ">=3.0.1"
+ },
+ "peerDependenciesMeta": {
+ "pg-native": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/pg-cloudflare": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.4.1.tgz",
+ "integrity": "sha512-6PQbsFWZcp9EmJEwy5cGQ2La+AMWpP46lgbb8X+U/XsHIUweYDNCpeuKck5RxL2MdVFi7krbbEi5nX4Zh7JhrQ==",
+ "license": "MIT",
+ "optional": true
+ },
+ "node_modules/pg-connection-string": {
+ "version": "2.14.1",
+ "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.14.1.tgz",
+ "integrity": "sha512-qR3kGNPBLpCNtz0evbKA0Y/MRFXwSSdT+pTJvYp/bXTcReZbvX1kzF0IyTc1QnxqF7AZbOeBhNL8R5mYQZV/MA==",
+ "license": "MIT"
+ },
+ "node_modules/pg-int8": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+ "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+ "license": "ISC",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/pg-pool": {
+ "version": "3.14.0",
+ "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.14.0.tgz",
+ "integrity": "sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==",
+ "license": "MIT",
+ "peerDependencies": {
+ "pg": ">=8.0"
+ }
+ },
+ "node_modules/pg-protocol": {
+ "version": "1.16.1",
+ "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.16.1.tgz",
+ "integrity": "sha512-p9VOFMiHB/ZbJATetbg+99PxssTVSQRnyuPSQ67mN1+1KBOjZaZ83ZQzltnxPhJwSsC3nwVjJ10DVJlerbFzLg==",
+ "license": "MIT"
+ },
+ "node_modules/pg-types": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+ "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+ "license": "MIT",
+ "dependencies": {
+ "pg-int8": "1.0.1",
+ "postgres-array": "~2.0.0",
+ "postgres-bytea": "~1.0.0",
+ "postgres-date": "~1.0.4",
+ "postgres-interval": "^1.1.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/pgpass": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+ "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+ "license": "MIT",
+ "dependencies": {
+ "split2": "^4.1.0"
+ }
+ },
+ "node_modules/postgres-array": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+ "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/postgres-bytea": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+ "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-date": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+ "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/postgres-interval": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+ "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "xtend": "^4.0.0"
+ },
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/proxy-addr": {
+ "version": "2.0.8",
+ "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
+ "integrity": "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ==",
+ "license": "MIT",
+ "dependencies": {
+ "forwarded": "0.2.0",
+ "ipaddr.js": "1.9.1"
+ },
+ "engines": {
+ "node": ">= 0.10"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/express"
+ }
+ },
+ "node_modules/qs": {
+ "version": "6.16.2",
+ "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.2.tgz",
+ "integrity": "sha512-VYdnB5S7/0IeXzBacUtwNAaM6d7Y/PgYnNC7bJCI6meqA/OgnsQbZMtVW8Dt2D1PHgbHGtYmhSoEIgvt7xZ2ZA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "es-define-property": "^1.0.1",
+ "side-channel": "^1.1.1"
+ },
+ "engines": {
+ "node": ">=0.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/range-parser": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+ "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/raw-body": {
+ "version": "2.5.3",
+ "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+ "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "~3.1.2",
+ "http-errors": "~2.0.1",
+ "iconv-lite": "~0.4.24",
+ "unpipe": "~1.0.0"
+ },
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/redis-errors": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
+ "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/redis-parser": {
+ "version": "3.0.0",
+ "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
+ "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+ "license": "MIT",
+ "dependencies": {
+ "redis-errors": "^1.0.0"
+ },
+ "engines": {
+ "node": ">=4"
+ }
+ },
+ "node_modules/require-from-string": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
+ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/safer-buffer": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/send": {
+ "version": "0.19.2",
+ "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+ "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+ "license": "MIT",
+ "dependencies": {
+ "debug": "2.6.9",
+ "depd": "2.0.0",
+ "destroy": "1.2.0",
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "etag": "~1.8.1",
+ "fresh": "~0.5.2",
+ "http-errors": "~2.0.1",
+ "mime": "1.6.0",
+ "ms": "2.1.3",
+ "on-finished": "~2.4.1",
+ "range-parser": "~1.2.1",
+ "statuses": "~2.0.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/send/node_modules/ms": {
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+ "license": "MIT"
+ },
+ "node_modules/serve-static": {
+ "version": "1.16.3",
+ "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+ "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+ "license": "MIT",
+ "dependencies": {
+ "encodeurl": "~2.0.0",
+ "escape-html": "~1.0.3",
+ "parseurl": "~1.3.3",
+ "send": "~0.19.1"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/setprototypeof": {
+ "version": "1.2.0",
+ "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+ "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+ "license": "ISC"
+ },
+ "node_modules/side-channel": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz",
+ "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4",
+ "side-channel-list": "^1.0.1",
+ "side-channel-map": "^1.0.1",
+ "side-channel-weakmap": "^1.0.2"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-list": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+ "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+ "license": "MIT",
+ "dependencies": {
+ "es-errors": "^1.3.0",
+ "object-inspect": "^1.13.4"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-map": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+ "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/side-channel-weakmap": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+ "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+ "license": "MIT",
+ "dependencies": {
+ "call-bound": "^1.0.2",
+ "es-errors": "^1.3.0",
+ "get-intrinsic": "^1.2.5",
+ "object-inspect": "^1.13.3",
+ "side-channel-map": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 0.4"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/split2": {
+ "version": "4.2.0",
+ "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+ "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+ "license": "ISC",
+ "engines": {
+ "node": ">= 10.x"
+ }
+ },
+ "node_modules/standard-as-callback": {
+ "version": "2.1.0",
+ "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
+ "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+ "license": "MIT"
+ },
+ "node_modules/statuses": {
+ "version": "2.0.2",
+ "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+ "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/toidentifier": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+ "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.6"
+ }
+ },
+ "node_modules/type-is": {
+ "version": "1.6.18",
+ "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+ "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+ "license": "MIT",
+ "dependencies": {
+ "media-typer": "0.3.0",
+ "mime-types": "~2.1.24"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/unpipe": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+ "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/utils-merge": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+ "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.4.0"
+ }
+ },
+ "node_modules/vary": {
+ "version": "1.1.2",
+ "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+ "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
+ "node_modules/xtend": {
+ "version": "4.0.2",
+ "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+ "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.4"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+{
+ "name": "openvibe-cam",
+ "version": "0.1.0",
+ "private": true,
+ "description": "OpenVibe.Cam — the camera app, your cameras and viewer grants, and the still Live's cam button takes.",
+ "main": "server/index.js",
+ "scripts": {
+ "start": "node server/index.js",
+ "dev": "NODE_ENV=development node server/index.js",
+ "seed:proof": "node scripts/seed-proof.js",
+ "test": "node test/run.js",
+ "test:pg": "CAM_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.237.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,130 @@
@@ -0,0 +1,130 @@
+/* OpenVibe.Cam: the room's own pieces. Colours come from the theme (--bg-*, --text-*, --border) and the room's accent
+ (.ov-room: --room-accent, --room-strong, --room-fill, --room-on), so every theme, light or dark, wears it. Phone
+ first; nothing scrolls sideways. Nothing is public without its owner's grant. */
+*, *::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; }
+.cm { display: block; }
+.cm a { color: var(--room-strong); }
+.cm .ovk-i { width: 1em; height: 1em; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; flex: none; }
+.cm-wrap { max-width: 1180px; margin: 0 auto; padding: 0 16px 64px; }
+.cm-narrow { max-width: 620px; }
+.cm-page-pad { padding-top: clamp(28px, 6vw, 64px); }
+.cm h2 { margin: 0; font-size: 1.2rem; font-weight: 800; letter-spacing: -.01em; }
+.cm-kicker { margin: 0 0 4px; font-size: .74rem; font-weight: 800; letter-spacing: .1em; text-transform: uppercase; color: var(--room-strong); }
+.cm-page-h { padding-top: clamp(20px, 4vw, 40px); margin-bottom: 22px; }
+.cm-page-h h1, .cm-entry-h h1 { margin: 0; font-size: clamp(1.9rem, 5vw, 3rem); font-weight: 800; letter-spacing: -.03em; line-height: 1.06; overflow-wrap: anywhere; }
+.cm-lede, .cm-summary { margin: 10px 0 0; max-width: 700px; color: var(--text-secondary, #96a7c2); line-height: 1.55; }
+.cm-fine, .cm-muted { color: var(--text-muted, #7386a3); font-size: .88rem; line-height: 1.55; }
+.cm-section { margin-top: clamp(28px, 4vw, 44px); }
+.cm-head-row { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
+.cm-count { color: var(--text-muted, #7386a3); font-size: .86rem; }
+.cm-more { display: inline-flex; align-items: center; gap: 2px; font-weight: 700; font-size: .9rem; text-decoration: none; }
+.cm-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 18px; }
+.cm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 42px; padding: 0 18px; border: 0; border-radius: 999px; background: var(--room-fill); color: var(--room-on) !important; font: inherit; font-weight: 700; font-size: .95rem; text-decoration: none; cursor: pointer; }
+.cm-btn-quiet { background: transparent; color: var(--text-primary, #e6edf7) !important; border: 1px solid var(--border, #1f2d47); }
+.cm-btn-small { min-height: 34px; padding: 0 14px; font-size: .86rem; }
+.cm-btn.is-mine { background: color-mix(in srgb, var(--room-accent) 22%, transparent); color: var(--room-strong) !important; }
+.cm-btn:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.cm-note { margin: 14px 0; padding: 11px 14px; border-radius: 14px; font-weight: 600; background: color-mix(in srgb, var(--room-accent) 12%, transparent); border: 1px solid color-mix(in srgb, var(--room-accent) 30%, transparent); }
+.cm-note.is-bad { background: color-mix(in srgb, var(--error, #ef4444) 12%, transparent); border-color: color-mix(in srgb, var(--error, #ef4444) 35%, transparent); }
+.cm-empty { display: grid; justify-items: start; gap: 10px; padding: 22px; border-radius: 18px; border: 1px dashed var(--border, #1f2d47); color: var(--text-secondary, #96a7c2); }
+.cm-empty p { margin: 0; line-height: 1.5; }
+.cm-empty h1 { margin: 0; font-size: 1.6rem; color: var(--text-primary, #e6edf7); }
+
+
+.cm-card { padding: 18px; border-radius: 18px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.cm-form { display: grid; gap: 14px; }
+.cm-field { display: grid; gap: 6px; font-size: .82rem; font-weight: 700; color: var(--text-secondary, #96a7c2); min-width: 0; }
+.cm-field input, .cm-field select, .cm-field textarea { width: 100%; min-height: 42px; padding: 9px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-input, var(--bg-primary, #0a0f1c)); color: var(--text-primary, #e6edf7); font: inherit; font-size: .95rem; font-weight: 500; }
+.cm-field input:focus, .cm-field select:focus, .cm-field textarea:focus { outline: 2px solid var(--room-accent); outline-offset: 1px; border-color: transparent; }
+.cm-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; margin: 20px 0 0; }
+.cm-facts > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); min-width: 0; }
+.cm-facts dt { font-size: .72rem; font-weight: 800; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+.cm-facts dd { margin: 4px 0 0; font-weight: 700; overflow-wrap: anywhere; }
+.cm-eps { display: grid; gap: 10px; margin: 12px 0 0; }
+.cm-eps > div { padding: 12px 14px; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.cm-eps dt code { font-weight: 800; color: var(--room-strong); overflow-wrap: anywhere; }
+.cm-eps dd { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); line-height: 1.5; }
+
+/* Selects and file inputs share the field look */
+.cm-field select, .cm-field input[type=file] { width: 100%; padding: 10px 12px; border-radius: 12px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); color: inherit; font: inherit; }
+.cm-field select:focus-visible, .cm-field input:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 1px; }
+.cm-btn[disabled] { opacity: .5; cursor: not-allowed; }
+.cm-btn[aria-pressed=true] { background: color-mix(in srgb, var(--room-accent) 22%, transparent); border-color: var(--room-accent); }
+.cm-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
+.cm-note { padding: 12px 14px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; background: var(--bg-card, #0f1729); }
+.cm-count { min-width: 28px; padding: 2px 9px; border-radius: 999px; background: color-mix(in srgb, var(--room-accent) 16%, transparent); color: var(--room-strong); font-weight: 800; font-size: .8rem; text-align: center; font-variant-numeric: tabular-nums; }
+
+/* The motif: an iris opening onto a contact sheet */
+.cm-shutter { position: relative; width: min(100%, 420px); aspect-ratio: 1; margin: 0 auto; color: var(--room-accent); }
+.cm-iris { position: absolute; inset: 8% 30% 30% 8%; width: 62%; height: 62%; }
+html.ov-motion .cm-iris path { transform-box: view-box; animation: cm-iris 6s cubic-bezier(.4,0,.2,1) infinite; }
+@keyframes cm-iris { 0%, 100% { opacity: .75; } 45%, 55% { opacity: .2; } }
+.cm-frame-grid { position: absolute; right: 4%; bottom: 6%; width: 52%; display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+.cm-frame { aspect-ratio: 4 / 3; border-radius: 6px; border: 1px solid color-mix(in srgb, var(--room-accent) 45%, transparent); background: color-mix(in srgb, var(--room-accent) 10%, var(--bg-card, #0f1729)); }
+.cm-frame-1, .cm-frame-5 { background: color-mix(in srgb, var(--room-accent) 32%, var(--bg-card, #0f1729)); }
+html.ov-motion .cm-frame { animation: cm-pop .5s cubic-bezier(.2,.7,.2,1) both; }
+html.ov-motion .cm-frame-2 { animation-delay: .08s; } html.ov-motion .cm-frame-3 { animation-delay: .16s; } html.ov-motion .cm-frame-4 { animation-delay: .24s; }
+html.ov-motion .cm-frame-5 { animation-delay: .32s; } html.ov-motion .cm-frame-6 { animation-delay: .4s; }
+@keyframes cm-pop { from { opacity: 0; transform: translateY(6px) scale(.96); } }
+@media (max-width: 759px) { .cm-shutter { width: 220px; } }
+
+/* The camera: a dark viewfinder with thirds, the controls beside it on a wide screen */
+.cm-camera { display: grid; gap: 14px; margin-top: 18px; }
+@media (min-width: 960px) { .cm-camera { grid-template-columns: minmax(0, 1.5fr) minmax(280px, 1fr); align-items: start; } .cm-camera > .cm-status, .cm-camera > .cm-field, .cm-camera > noscript { grid-column: 2; } }
+.cm-stage { position: relative; overflow: hidden; aspect-ratio: 4 / 3; border-radius: 22px; background: #05070c; box-shadow: inset 0 0 0 1px rgba(255, 255, 255, .06), 0 22px 50px -30px rgba(0, 0, 0, .8); }
+.cm-stage video, .cm-stage canvas { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; }
+.cm-stage::after { content: ''; position: absolute; inset: 14px; border-radius: 12px; pointer-events: none;
+ background: linear-gradient(currentColor, currentColor) top left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top left / 2px 22px no-repeat,
+ linear-gradient(currentColor, currentColor) top right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) top right / 2px 22px no-repeat,
+ linear-gradient(currentColor, currentColor) bottom left / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom left / 2px 22px no-repeat,
+ linear-gradient(currentColor, currentColor) bottom right / 22px 2px no-repeat, linear-gradient(currentColor, currentColor) bottom right / 2px 22px no-repeat;
+ color: rgba(255, 255, 255, .55); }
+.cm-grid-overlay { position: absolute; inset: 0; pointer-events: none;
+ background: linear-gradient(90deg, transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)),
+ linear-gradient(transparent calc(33.333% - .5px), rgba(255, 255, 255, .35) calc(33.333% - .5px) calc(33.333% + .5px), transparent calc(33.333% + .5px) calc(66.666% - .5px), rgba(255, 255, 255, .35) calc(66.666% - .5px) calc(66.666% + .5px), transparent calc(66.666% + .5px)); }
+.cm-grid-overlay[hidden] { display: none; }
+.cm-controls { display: grid; gap: 10px; grid-template-columns: 1fr 1fr; align-content: start; }
+.cm-controls .cm-field { grid-column: 1 / -1; }
+.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { grid-column: 1 / -1; min-height: 48px; justify-content: center; }
+.cm-controls [data-cm-shoot]:not([disabled]) { background: var(--accent-strong, var(--room-accent)); color: var(--on-accent-strong, #fff); border-color: transparent; }
+.cm-status { margin: 0; font-variant-numeric: tabular-nums; color: var(--text-secondary, #96a7c2); }
+
+/* Photos, stills and cameras */
+.cm-photos, .cm-stills, .cm-cameras { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 14px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr)); }
+.cm-photo, .cm-still { display: grid; gap: 6px; min-width: 0; }
+.cm-photo img, .cm-still img { display: block; width: 100%; height: auto; aspect-ratio: 4 / 3; object-fit: cover; border-radius: 14px; background: var(--bg-card, #0f1729); border: 1px solid var(--border, #1f2d47); }
+.cm-still img { aspect-ratio: 16 / 9; }
+.cm-photo-link, .cm-still-link { display: block; border-radius: 14px; }
+.cm-photo-link:hover img, .cm-still-link:hover img { border-color: var(--room-accent); }
+.cm-photo-link:focus-visible, .cm-still-link:focus-visible { outline: 2px solid var(--room-accent); outline-offset: 2px; }
+.cm-photo-meta, .cm-still-meta { font-size: .8rem; color: var(--text-muted, #7386a3); overflow-wrap: anywhere; }
+.cm-camera-link { display: grid; gap: 4px; padding: 14px 16px; border: 1px solid var(--border, #1f2d47); border-radius: 16px; background: var(--bg-card, #0f1729); text-decoration: none; color: inherit; }
+.cm-camera-link:hover { border-color: var(--room-accent); }
+.cm-camera-name { font-weight: 800; }
+.cm-camera-meta, .cm-camera-grants { font-size: .84rem; color: var(--text-secondary, #96a7c2); }
+.cm-still-large { margin: 16px 0; }
+.cm-still-large img { display: block; width: 100%; height: auto; border-radius: 18px; border: 1px solid var(--border, #1f2d47); }
+.cm-still-large figcaption { margin-top: 8px; color: var(--text-secondary, #96a7c2); }
+.cm-wall-preview { margin-top: 18px; padding: 16px; border: 1px dashed var(--border, #1f2d47); border-radius: 18px; }
+
+/* Rules, steps and the viewers table */
+.cm-rules { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr)); }
+.cm-rules li { padding: 14px 16px; border-radius: 14px; border: 1px solid var(--border, #1f2d47); background: var(--bg-card, #0f1729); line-height: 1.5; }
+.cm-steps { margin: 12px 0 0; padding-left: 1.3em; display: grid; gap: 8px; line-height: 1.55; }
+.cm-steps li::marker { color: var(--room-strong); font-weight: 800; }
+.cm-table-wrap { overflow-x: auto; margin-top: 10px; border: 1px solid var(--border, #1f2d47); border-radius: 14px; }
+.cm-grants { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
+.cm-grants th, .cm-grants td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--border, #1f2d47); white-space: nowrap; }
+.cm-grants tr:last-child td { border-bottom: 0; }
+.cm-grants th { font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--text-muted, #7386a3); }
+@media (prefers-reduced-motion: reduce) { .cm-iris path, .cm-frame { animation: none !important; } }
+
+/* Phones: the motif is a faint corner mark behind the hero text, so it drops the contact sheet and fades the iris */
+@media (max-width: 759px) { .cm-frame-grid { display: none; } .cm-iris { opacity: .3; } }
+/* The controls read top to bottom: power and shutter, the settings, then the two toggles side by side */
+.cm-controls [data-cm-start], .cm-controls [data-cm-shoot] { order: 1; }
+.cm-controls .cm-field { order: 2; }
+.cm-controls [data-cm-burst], .cm-controls [data-cm-grid-toggle] { order: 3; justify-content: center; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -0,0 +1,10 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" role="img" aria-label="A camera grid">
+ <rect width="320" height="180" fill="#0b0d12"/>
+ <g stroke="#00c7d2" stroke-width="2" opacity=".75" fill="none">
+ <rect x="28" y="24" width="120" height="60" rx="8"/>
+ <rect x="172" y="24" width="120" height="60" rx="8"/>
+ <rect x="28" y="96" width="120" height="60" rx="8"/>
+ <rect x="172" y="96" width="120" height="60" rx="8"/>
+ <path d="M160 12v156M12 90h296" opacity=".3" stroke-dasharray="4 6"/>
+ </g>
+</svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 320 180" role="img" aria-label="A shutter iris">
+ <defs>
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
+ <stop offset="0" stop-color="#00c7d2"/>
+ <stop offset="1" stop-color="#005b66"/>
+ </linearGradient>
+ </defs>
+ <rect width="320" height="180" fill="#0b0d12"/>
+ <g transform="translate(160 90)">
+ <circle r="56" fill="none" stroke="url(#g)" stroke-width="3" opacity=".7"/>
+ <g fill="none" stroke="url(#g)" stroke-width="9" stroke-linecap="round">
+ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(0)"/>
+ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(90)"/>
+ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(180)"/>
+ <path d="M0 -46 A46 46 0 0 1 46 0" transform="rotate(270)"/>
+ </g>
+ <circle r="15" fill="url(#g)"/>
+ </g>
+</svg>
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+/* OpenVibe.Cam: the camera app's enhancement. The page is complete without it; with it, the preview runs, the
+ * timer, burst and grid work, and the on-device filter is applied to the canvas before the picture is posted to
+ * /api/v1/photos. The picture goes to OpenVibe.Pics; Cam never receives it as anything but a request body. */
+(function () {
+ 'use strict';
+
+ var root = document.getElementById('cm-camera');
+ if (!root) return;
+ var video = root.querySelector('[data-cm-preview]');
+ var canvas = root.querySelector('[data-cm-canvas]');
+ var statusEl = root.querySelector('[data-cm-status]');
+ var startBtn = root.querySelector('[data-cm-start]');
+ var shootBtn = root.querySelector('[data-cm-shoot]');
+ var timerSel = root.querySelector('[data-cm-timer]');
+ var burstBtn = root.querySelector('[data-cm-burst]');
+ var filterSel = root.querySelector('[data-cm-filter]');
+ var captionEl = root.querySelector('[data-cm-caption]');
+ var gridBtn = root.querySelector('[data-cm-grid-toggle]');
+ var gridEl = root.querySelector('[data-cm-grid]');
+ var fileInput = root.querySelector('[data-cm-file]');
+ var gallery = document.getElementById('cm-gallery');
+ var stream = null;
+ var busy = false;
+
+ var FILTERS = {
+ none: 'none',
+ mono: 'grayscale(1) contrast(1.08)',
+ warm: 'sepia(.35) saturate(1.15)',
+ cool: 'hue-rotate(-10deg) saturate(1.1)',
+ fade: 'saturate(.8) contrast(.92) brightness(1.06)',
+ vivid: 'saturate(1.45) contrast(1.06)',
+ blur: 'blur(1.5px)'
+ };
+
+ function say(text) { if (statusEl) statusEl.textContent = text; }
+
+ function start() {
+ if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
+ say('This browser has no camera input. Pick a picture below instead.');
+ return;
+ }
+ navigator.mediaDevices.getUserMedia({ video: { facingMode: 'user' }, audio: false }).then(function (s) {
+ stream = s;
+ video.srcObject = s;
+ return video.play();
+ }).then(function () {
+ shootBtn.disabled = false;
+ startBtn.textContent = 'Camera on';
+ say('The camera is on. Nothing is recorded.');
+ }).catch(function () {
+ say('The camera was not allowed. Allow it in the browser, or pick a picture below.');
+ });
+ }
+
+ function drawFiltered() {
+ var w = video.videoWidth || 1280;
+ var h = video.videoHeight || 720;
+ canvas.width = w;
+ canvas.height = h;
+ var ctx = canvas.getContext('2d');
+ ctx.filter = FILTERS[filterSel.value] || 'none';
+ ctx.drawImage(video, 0, 0, w, h);
+ }
+
+ function addToGallery(p) {
+ if (!gallery || !p || !p.pics_direct) return;
+ var list = gallery.querySelector('.cm-photos');
+ if (!list) {
+ list = document.createElement('ul');
+ list.className = 'cm-photos';
+ gallery.appendChild(list);
+ }
+ var li = document.createElement('li');
+ li.className = 'cm-photo';
+ var a = document.createElement('a');
+ a.className = 'cm-photo-link';
+ a.href = p.pics_url;
+ a.rel = 'noopener';
+ var img = document.createElement('img');
+ img.src = p.pics_direct;
+ img.alt = p.caption || 'A photo on OpenVibe.Cam';
+ img.loading = 'lazy';
+ a.appendChild(img);
+ li.appendChild(a);
+ list.insertBefore(li, list.firstChild);
+ }
+
+ function upload(blob, type, filterName) {
+ if (busy) return Promise.resolve(null);
+ busy = true;
+ var headers = { 'Content-Type': type, 'X-Filter': filterName || 'none', 'X-Filename': encodeURIComponent('cam-' + Date.now() + '.jpg') };
+ var caption = captionEl && captionEl.value ? captionEl.value : '';
+ if (caption) headers['X-Caption'] = encodeURIComponent(caption);
+ return fetch('/api/v1/photos', { method: 'POST', credentials: 'same-origin', headers: headers, body: blob })
+ .then(function (res) {
+ return res.json().catch(function () { return {}; }).then(function (body) {
+ if (res.status === 401 || res.status === 403) { say('Sign in to save this photo to OpenVibe.Pics.'); return null; }
+ if (!res.ok) { say((body && body.detail) || 'The photo was not saved.'); return null; }
+ say('Saved to OpenVibe.Pics.');
+ addToGallery(body);
+ return body;
+ });
+ })
+ .catch(function () { say('The photo was not saved: the network did not answer.'); return null; })
+ .then(function (out) { busy = false; return out; });
+ }
+
+ function snap() {
+ if (!stream) { say('Turn the camera on first.'); return; }
+ drawFiltered();
+ canvas.toBlob(function (blob) { if (blob) upload(blob, 'image/jpeg', filterSel.value); }, 'image/jpeg', 0.92);
+ }
+
+ function countdown(n) {
+ if (n <= 0) { snap(); return; }
+ say(n + '…');
+ setTimeout(function () { countdown(n - 1); }, 1000);
+ }
+
+ function shoot() {
+ var n = parseInt(timerSel.value, 10) || 0;
+ if (n > 0) countdown(n); else snap();
+ }
+
+ function burst() {
+ if (!stream) { say('Turn the camera on first.'); return; }
+ say('Burst of three…');
+ for (var i = 0; i < 3; i++) setTimeout(snap, i * 450);
+ }
+
+ startBtn.addEventListener('click', start);
+ shootBtn.addEventListener('click', shoot);
+ burstBtn.addEventListener('click', burst);
+ if (gridBtn) gridBtn.addEventListener('click', function () {
+ if (!gridEl) return;
+ var show = gridEl.hidden;
+ gridEl.hidden = !show;
+ gridBtn.setAttribute('aria-pressed', show ? 'true' : 'false');
+ });
+ if (fileInput) fileInput.addEventListener('change', function () {
+ var f = fileInput.files && fileInput.files[0];
+ if (!f) return;
+ if (!/^image\//.test(f.type)) { say('That is not a picture.'); return; }
+ upload(f, f.type, 'none');
+ fileInput.value = '';
+ });
+ window.addEventListener('pagehide', function () {
+ if (stream) stream.getTracks().forEach(function (t) { t.stop(); });
+ });
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
+#!/usr/bin/env node
+'use strict';
+
+/**
+ * The first proof, the half that needs no network: pair an owned test camera, grant a viewer for a week, open the
+ * private wall as that viewer, revoke the grant, and show that the very next wall request is refused.
+ *
+ * CAM_PROOF_OWNER=usr_… CAM_PROOF_VIEWER=usr_… node scripts/seed-proof.js
+ *
+ * The other two halves need a signed-in person's Network token and are done through the site or the API:
+ * a photo: POST /api/v1/photos (raw image body) with the cam_at cookie
+ * a still: POST /api/v1/stills { "slot": "whip", "caption": "…" } with the cam_at cookie
+ * Both go to OpenVibe.Pics; Cam stores only the link. Re-running this script reuses the proof camera and makes a
+ * fresh grant after the previous one was revoked.
+ */
+const { ids } = require('openvibe-contracts');
+const { load } = require('../server/config');
+const { openStore } = require('../server/db');
+const { createStore } = require('../server/store');
+
+const NAME = 'Proof camera';
+const SLOT = 'proof';
+
+function subject(v, what) {
+ if (!ids.isSubjectId('user', String(v || ''))) {
+ throw new Error(`${what} must be a canonical subject (usr_ + 26 Crockford characters); pass it in the environment`);
+ }
+ return String(v);
+}
+
+async function runProof({ config, s, owner, viewer, log = console } = {}) {
+ if (!config) config = load();
+ if (!s) s = await openStore(config, { log });
+ const ownerSubject = subject(owner, 'CAM_PROOF_OWNER');
+ const viewerSubject = subject(viewer, 'CAM_PROOF_VIEWER');
+ if (ownerSubject === viewerSubject) throw new Error('the owner and the viewer must be different people');
+ const cameras = createStore(s, { config });
+
+ let camera = (await cameras.camerasOf(ownerSubject)).find((c) => c.name === NAME && c.slot === SLOT) || null;
+ if (!camera) {
+ const created = await cameras.createCamera({ owner: ownerSubject, ownerUsername: 'proof-owner', name: NAME, source: 'phone', slot: SLOT, notes: 'The first proof; safe to unpair.' });
+ if (!created.ok) throw new Error(`could not pair the proof camera: ${created.message}`);
+ camera = created.camera;
+ log.log(`[seed-proof] paired ${camera.id} (${NAME}, slot ${SLOT}) for ${ownerSubject}`);
+ } else {
+ log.log(`[seed-proof] ${camera.id} already paired; using it`);
+ }
+
+ const granted = await cameras.grantCamera(camera.id, { owner: ownerSubject, viewerSubject, viewerUsername: 'proof-viewer', days: 7 });
+ if (!granted.ok) throw new Error(`could not grant the viewer: ${granted.message}`);
+ log.log(`[seed-proof] granted ${viewerSubject} until ${granted.grant.expires_at} (${granted.grant.id})`);
+
+ const wall = await cameras.wall(camera.id, viewerSubject);
+ if (!wall || wall.owner) throw new Error('the viewer could not open the wall after the grant; nothing is proven');
+ log.log(`[seed-proof] the viewer opened the wall: ${wall.photos.length} photo(s), frame ${camera.slot ? `live/${camera.slot}/frame.jpg` : 'not set'}`);
+
+ const revoked = await cameras.revokeGrant(camera.id, granted.grant.id, { owner: ownerSubject });
+ if (!revoked.ok || !revoked.revoked) throw new Error('the grant was not revoked');
+ log.log(`[seed-proof] revoked ${granted.grant.id}`);
+
+ const refused = await cameras.wall(camera.id, viewerSubject);
+ if (refused) throw new Error('the wall still opened after the revocation; the proof fails');
+ log.log('[seed-proof] the next wall request was refused, as it must be');
+ log.log('[seed-proof] the photo and the still need a signed-in person: POST /api/v1/photos and POST /api/v1/stills');
+ return { camera, grant: granted.grant, wall, revoked: revoked.grant, refused: true };
+}
+
+async function main() {
+ const owner = process.env.CAM_PROOF_OWNER;
+ const viewer = process.env.CAM_PROOF_VIEWER;
+ if (!owner || !viewer) {
+ console.error('[seed-proof] set CAM_PROOF_OWNER and CAM_PROOF_VIEWER to two canonical usr_ ids');
+ process.exitCode = 1;
+ return;
+ }
+ const config = load();
+ const s = await openStore(config, { log: console });
+ try { await runProof({ config, s, owner, viewer }); } finally { await s.close(); }
+}
+
+if (require.main === module) {
+ main().catch((err) => {
+ console.error(`[seed-proof] ${err && err.message ? err.message : err}`);
+ process.exitCode = 1;
+ });
+}
+
+module.exports = { runProof, NAME, SLOT };
new file mode 100644
new file mode 100644
@@ -0,0 +1,171 @@
@@ -0,0 +1,171 @@
+'use strict';
+
+/**
+ * OpenVibe.Cam — Express app factory; server/index.js listens, tests build their own instance with a temp database
+ * and a mock Network/Media/Live/Pics.
+ *
+ * /, /app, /my, /my/cameras/:id, /w/:id, /s/:id, /developers the pages (http/pages.js)
+ * /api/v1/* the API (http/api.js)
+ * /auth/* Network SSO with PKCE (auth/sso.js)
+ * /api/health, /api/ready, /release.json, /metrics (loopback only)
+ * /internal/events account export/deletion (loopback only)
+ */
+const path = require('path');
+const express = require('express');
+const helmet = require('helmet');
+const cookieParser = require('cookie-parser');
+const rateLimit = require('express-rate-limit');
+const contracts = require('openvibe-contracts');
+const cache = require('openvibe-shared/cache-policy');
+
+const configLib = require('./config');
+const { openStore } = require('./db');
+const { createKeyStore } = require('./auth/keys');
+const { createSso } = require('./auth/sso');
+const { createPrincipal } = require('./http/principal');
+const { createApi } = require('./http/api');
+const { createPageRoutes } = require('./http/pages');
+const { createServiceReadiness } = require('./observability');
+const { assetVersion, send } = require('./render/layout');
+const { raw } = require('./render/html');
+
+const accountDataLib = require('./identity/account-data');
+const { createStore: createCamStore } = require('./store');
+const { createUpstreams } = require('./upstreams');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+
+const PUBLIC_DIR = path.join(__dirname, '..', 'public');
+const VERSION = require('../package.json').version;
+const { createHttpTelemetry } = require('openvibe-sdk/service');
+const { createPgTelemetrySink } = require('openvibe-sdk/telemetry');
+
+/**
+ * opts: config, store, now (clock), fetchImpl (Network and the public upstreams), log, valkey, accountSend,
+ * 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 cameras = createCamStore(s, { config });
+ const upstreams = createUpstreams({ config, fetchImpl, log });
+
+ // Account export and deletion (ADR-033). The sender posts to Network's internal routes with this service's own
+ // client-credentials token; a test injects a stand-in through opts.accountSend.
+ const accountData = accountDataLib.create({ db: s.db, log });
+ const accountSend = opts.accountSend || (config.oauth.clientSecret
+ ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl })
+ : async () => { throw new Error('OV_OAUTH_CLIENT_SECRET is not set: Cam cannot answer account events'); });
+
+ const ctx = { config, s, keys, sso, principal, cameras, upstreams, accountData, accountSend, log };
+
+ const app = express();
+ app.disable('x-powered-by');
+ app.set('trust proxy', config.trustProxy);
+ const release = require('openvibe-shared/release').createRelease({ service: 'cam', root: path.join(__dirname, '..') });
+ require('./render/layout').setRelease(release.release);
+ const metrics = require('openvibe-shared/metrics').instrument(app, { service: 'cam', release: release.release });
+ app.locals.metrics = metrics.registry;
+ ctx.telemetry = createHttpTelemetry({ service: 'cam', 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:', 'blob:', 'https://openvibe.network', 'https://openvibe.media', 'https://openvibe.pics'],
+ mediaSrc: ["'self'", 'blob:'],
+ 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' },
+ }));
+ // The camera app is same-origin only; there is no microphone in v1.
+ app.use((_req, res, next) => { res.set('Permissions-Policy', 'camera=(self), microphone=(), geolocation=(), payment=()'); next(); });
+ app.use(cookieParser());
+
+ // ── Machine endpoints ───────────────────────────────────
+ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-cam', name: 'OpenVibe.Cam', 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: 'cam', service: 'cam', host: 'openvibe.cam', name: 'OpenVibe.Cam', profile: 'ugc' })); }
+
+ // ── OpenVibe.Events → this service (loopback only) ──────
+ // Account export/deletion; the consumer reads the raw body itself, so it is mounted before any body parser.
+ // nginx answers 404 for /internal/.
+ app.use('/internal/events', accountData.consumer({
+ secrets: config.events.secrets,
+ send: accountSend,
+ now: s.now,
+ onEvent: async (ev) => `ignored:${ev.event_type}`,
+ }));
+
+ // ── Static assets (content-hashed ?v= → immutable) ──────
+ app.use('/shared', require('openvibe-shared/serve').handler());
+ app.use(express.static(PUBLIC_DIR, {
+ index: false, redirect: false,
+ setHeaders(res, filePath) {
+ const rel = path.relative(PUBLIC_DIR, filePath).split(path.sep).join('/');
+ const v = res.req && res.req.query && res.req.query.v;
+ res.setHeader('Cache-Control', cache.assetHeaders(rel, { hashed: !!v && v === assetVersion(rel) }));
+ },
+ }));
+
+ // ── API ─────────────────────────────────────────────────
+ app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.CAM_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.CAM_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.Cam]', err && err.message ? err.message.slice(0, 300) : err);
+ if (res.headersSent) return;
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ if (err && err.type === 'entity.parse.failed') return contracts.http.sendProblem(res, 400, 'request.malformed_json', { ctx: req.ov });
+ if (err && err.type === 'entity.too.large') return req.path.startsWith('/api/') ? contracts.http.sendProblem(res, 413, 'request.too_large', { ctx: req.ov }) : res.status(413).type('text/plain').send('That request was too large.');
+ if (req.path.startsWith('/api/')) return contracts.http.sendProblem(res, 500, 'internal.error', { detail: 'Internal error', ctx: req.ov });
+ res.status(500).type('text/plain').send('Something went wrong on our side. Try again in a moment.');
+ });
+
+ return { app, ctx };
+}
+
+module.exports = { createApp };
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -0,0 +1,84 @@
+'use strict';
+
+/**
+ * The Network's RS256 signing key, fetched once from GET /api/.well-known/jwks on the configured internal URL and
+ * kept fresh by openvibe-sdk/auth's JWKS client. Tokens are verified offline against it: the person's session token
+ * (sign-in) and a person's Bearer token (the API).
+ *
+ * keys.get() / keys.ensure() / keys.loaded() for readiness and the SSO middleware, and verification through the
+ * SDK's verifyUserToken / verifyAppToken with `jwks: <url>`. Nothing the SDK says about a failed key fetch leaves
+ * this module: token.no_key answers 'signing key not loaded yet'.
+ */
+const sdk = require('openvibe-sdk/auth');
+
+const KEY_UNAVAILABLE = 'signing key not loaded yet';
+const DOES_NOT_VERIFY = 'token does not verify';
+
+function publicReason(err) {
+ const code = err && err.code;
+ if (!code || code === 'token.no_key') return code ? KEY_UNAVAILABLE : DOES_NOT_VERIFY;
+ return (err.message && String(err.message).slice(0, 200)) || DOES_NOT_VERIFY;
+}
+
+function jwksUrl(config) {
+ return (config.networkInternalUrl || config.networkUrl) + '/api/.well-known/jwks';
+}
+
+function createKeyStore({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const url = jwksUrl(config);
+ const client = sdk.jwksClient(url, { fetch: fetchImpl, log });
+ let mirror = null;
+
+ async function refreshMirror() {
+ try {
+ const ks = await client.keys();
+ const first = ks && ks[0];
+ mirror = first ? { pem: first.key.export({ type: 'spki', format: 'pem' }), count: ks.length } : null;
+ } catch (err) { log.warn(`[OpenVibe.Cam] 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.Cam] 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.Cam] 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 `cam`.
+ *
+ * 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 Meme's SSO module with the client id and cookie names changed. The person's Network access token lives
+ * in an httpOnly cookie (cam_at) no script can read; the refresh token (cam_rt) is httpOnly too. Neither is ever
+ * stored server-side, rendered or logged. The same token is what Cam forwards to OpenVibe.Pics when the person
+ * saves a photo or a still — it is never kept in the database.
+ */
+const crypto = require('crypto');
+const express = require('express');
+const cache = require('openvibe-shared/cache-policy');
+
+const ACCESS_COOKIE = 'cam_at';
+const REFRESH_COOKIE = 'cam_rt';
+const HINT_COOKIE = 'ov_sso_hint';
+const FLOW_COOKIE = 'cam_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 Cam session is a Network SESSION token: audience openvibe.network and a person. FedCM assertions,
+ * app/service tokens and internal tokens are signed with the same key and issuer; none may become a session here.
+ */
+ async function verifySession(token) {
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience, now: now() });
+ const c = v.claims;
+ if (c && (c.typ === 'fedcm' || c.actor_type !== undefined)) return { ok: false, reason: 'not a session token' };
+ return v;
+ }
+
+ function viewerFromClaims(claims, token) {
+ const subject = typeof claims.subject_id === 'string' ? claims.subject_id : null;
+ const role = typeof claims.role === 'string' ? claims.role : 'user';
+ return {
+ kind: 'user',
+ subject,
+ username: typeof claims.username === 'string' ? claims.username.slice(0, 64) : null,
+ displayName: typeof claims.display_name === 'string' ? claims.display_name.slice(0, 80) : (typeof claims.username === 'string' ? claims.username.slice(0, 64) : 'you'),
+ role,
+ avatarUrl: typeof claims.avatar_url === 'string' && /^https:\/\/[^\s"'<>]{1,500}$/.test(claims.avatar_url) ? claims.avatar_url : null,
+ profileColor: typeof claims.profile_color === 'string' && /^#[0-9a-fA-F]{3,8}$/.test(claims.profile_color) ? claims.profile_color : null,
+ token,
+ };
+ }
+
+ async function refresh(req, res) {
+ if (!req.sessionRefresh) req.sessionRefresh = await doRefresh(req, res);
+ return req.sessionRefresh;
+ }
+
+ async function doRefresh(req, res) {
+ const rt = req.cookies && req.cookies[REFRESH_COOKIE];
+ if (!rt) return null;
+ try {
+ const data = await tokenGrant({ grant_type: 'refresh_token', refresh_token: rt });
+ setSession(res, data);
+ const v = await verifySession(data.access_token);
+ return v.ok ? viewerFromClaims(v.claims, data.access_token) : null;
+ } catch (err) {
+ if (err.status && err.status < 500) clearSession(res);
+ return null;
+ }
+ }
+
+ /** req.viewer for every page: the signed-in person, refreshed when their token expired. */
+ function middleware() {
+ return async (req, res, next) => {
+ req.viewer = { kind: 'anonymous' };
+ const token = req.cookies && req.cookies[ACCESS_COOKIE];
+ if (!token) return next();
+ await keys.ensure();
+ const v = await verifySession(token);
+ if (v.ok) { req.viewer = viewerFromClaims(v.claims, token); return next(); }
+ if (v.expired) {
+ const fresh = await refresh(req, res);
+ if (fresh) { req.viewer = fresh; return next(); }
+ }
+ if (keys.loaded()) clearSession(res);
+ next();
+ };
+ }
+
+ async function asViewer(req, res, fn) {
+ try {
+ return await fn(req.viewer.token);
+ } catch (err) {
+ if (!err || err.status !== 401) throw err;
+ const fresh = await refresh(req, res);
+ if (!fresh) throw err;
+ req.viewer = fresh;
+ return fn(fresh.token);
+ }
+ }
+
+ function routes() {
+ const r = express.Router();
+ const flowOpts = () => ({ ...cookieBase(), path: '/auth', maxAge: 10 * 60 * 1000 });
+
+ r.get('/login', (req, res) => {
+ const silent = Boolean(req.query.silent) && req.query.silent !== '0';
+ const state = crypto.randomBytes(16).toString('hex');
+ const { verifier, challenge } = pkcePair();
+ res.cookie(FLOW_COOKIE, JSON.stringify({ state, verifier, next: sanitizeNext(req.query.next, config), silent }), flowOpts());
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ res.redirect(authorizeUrl(config, { state, challenge, silent }));
+ });
+
+ r.get('/callback', async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ let flow = null;
+ try { flow = JSON.parse(req.cookies[FLOW_COOKIE] || 'null'); } catch { flow = null; }
+ res.clearCookie(FLOW_COOKIE, { ...cookieBase(), path: '/auth' });
+ const next = flow ? sanitizeNext(flow.next, config) : '/';
+ const { code, state, error } = req.query;
+ if (error) {
+ if ((flow && flow.silent) || error === 'login_required' || error === 'interaction_required') return res.redirect(next);
+ return res.status(400).type('text/plain').send(`Sign-in was not completed (${String(error).slice(0, 60)}).`);
+ }
+ if (!flow || typeof flow.state !== 'string' || typeof flow.verifier !== 'string' || typeof state !== 'string'
+ || state.length !== flow.state.length || !crypto.timingSafeEqual(Buffer.from(state), Buffer.from(flow.state))) {
+ return res.status(400).type('text/plain').send('Sign-in state did not match. Please try signing in again.');
+ }
+ if (!code) return res.status(400).type('text/plain').send('The Network sent no authorization code.');
+ try {
+ const data = await tokenGrant({ grant_type: 'authorization_code', code: String(code), redirect_uri: config.oauth.redirectUri, code_verifier: flow.verifier });
+ setSession(res, data);
+ res.set('Clear-Site-Data', '"cache"');
+ return res.redirect(next);
+ } catch (err) {
+ log.warn('[OpenVibe.Cam] 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.Cam?</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,62 @@
@@ -0,0 +1,62 @@
+'use strict';
+
+/**
+ * Pairing and sharing, shared by the JSON API and the plain forms so the two cannot drift.
+ *
+ * pairCamera({ cameras, upstreams, principal, body })
+ * Validates a camera and, when a Live stream id is given, reads it from OpenVibe.Live and refuses one on
+ * somebody else's channel. The slot defaults to the stream's managed slot slug, its managed id, or @username.
+ *
+ * resolveViewer({ upstreams, body })
+ * A viewer is a canonical subject, or an @username resolved through Network (identity.subject.resolve) with
+ * Cam's service token. A grant is never made against a name Cam did not resolve.
+ *
+ * A refusal is { ok: false, status, code, message } and the caller turns it into problem+json or a page message.
+ */
+const { toSubject } = require('./store');
+
+const refusal = (status, code, message) => ({ ok: false, status, code, message });
+
+async function pairCamera({ cameras, upstreams, principal, body }) {
+ const b = body && typeof body === 'object' ? body : {};
+ const streamId = b.stream_id == null || b.stream_id === '' ? null : Number(b.stream_id);
+ let slot = b.slot == null || b.slot === '' ? null : String(b.slot);
+ let ownerUsername = principal.username || null;
+
+ if (streamId != null) {
+ if (!Number.isInteger(streamId) || streamId <= 0) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number');
+ let stream;
+ try { stream = await upstreams.liveStream(streamId); } catch (err) {
+ return { ok: false, status: err.status || 502, code: err.code || 'cam.live.unavailable', message: err.message || 'OpenVibe.Live did not answer' };
+ }
+ if (!stream) return refusal(404, 'cam.stream_not_found', 'No such live stream.');
+ const channelName = String((stream.channel && stream.channel.username) || stream.username || '');
+ if (!principal.username || channelName.toLowerCase() !== String(principal.username).toLowerCase()) {
+ return refusal(403, 'cam.not_your_stream', 'That Live stream is not on your channel.');
+ }
+ ownerUsername = channelName || ownerUsername;
+ if (!slot) slot = stream.managed_stream_slug || (stream.managed_stream_id != null ? String(stream.managed_stream_id) : `@${ownerUsername}`);
+ }
+
+ return await cameras.createCamera({
+ owner: principal.subject, ownerUsername, name: b.name, source: b.source || 'phone',
+ streamId, slot, notes: b.notes,
+ });
+}
+
+async function resolveViewer({ upstreams, body }) {
+ const b = body && typeof body === 'object' ? body : {};
+ let subject = b.viewer_subject == null || b.viewer_subject === '' ? null : toSubject(b.viewer_subject);
+ let username = b.viewer_username == null || b.viewer_username === '' ? null : String(b.viewer_username).trim().replace(/^@/, '');
+ if (subject) return { ok: true, subject, username };
+ if (!username) return refusal(400, 'cam.invalid', 'viewer_username or viewer_subject is required');
+ const got = await upstreams.resolveUsername(username);
+ if (!got.ok) {
+ if (got.code === 'invalid') return refusal(400, 'cam.invalid', 'viewer_username is not a Network username');
+ if (got.code === 'not_found') return refusal(404, 'cam.viewer_not_found', 'Nobody holds that OpenVibe username.');
+ return refusal(503, 'cam.identity_unavailable', 'OpenVibe.Network could not resolve that username just now; send viewer_subject instead.');
+ }
+ return { ok: true, subject: got.subject, username: got.username || username };
+}
+
+module.exports = { pairCamera, resolveViewer, refusal };
new file mode 100644
new file mode 100644
@@ -0,0 +1,96 @@
@@ -0,0 +1,96 @@
+'use strict';
+
+/**
+ * OpenVibe.Cam configuration. Every value comes from the environment (production: /etc/openvibe/cam.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);
+const size = (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, 5160);
+ const baseUrl = trim(env.BASE_URL || (isProduction ? 'https://openvibe.cam' : `http://localhost:${port}`));
+ const networkUrl = trim(env.OV_NETWORK_URL || 'https://openvibe.network');
+
+ return {
+ service: 'cam',
+ port,
+ host: env.HOST || '127.0.0.1',
+ nodeEnv,
+ isProduction,
+ baseUrl,
+ trustProxy: env.TRUST_PROXY != null ? Number(env.TRUST_PROXY) : 2,
+ drill: env.CAM_DRILL === '1' || env.CAM_DRILL === 'true',
+ limits: {
+ minute: Math.max(1, int(env.CAM_LIMITS_MINUTE, 120)),
+ hour: Math.max(1, int(env.CAM_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 (CAM_PGLITE_DIR
+ // overrides the directory).
+ db: { url: env.DATABASE_URL || '', directUrl: env.DATABASE_DIRECT_URL || '', pgliteDir: env.CAM_PGLITE_DIR || '' },
+ valkey: { url: env.VALKEY_URL || '', prefix: env.VALKEY_PREFIX || 'ov:cam:' },
+
+ // OpenVibe.Network: SSO (OAuth2 authorization server with PKCE), its JWKS, and the service token Cam uses
+ // only to resolve a viewer's @username when a camera is shared (identity.subject.resolve).
+ networkUrl,
+ networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
+ networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ audience: env.CAM_AUDIENCE || 'openvibe.cam',
+ oauth: {
+ clientId: env.OV_OAUTH_CLIENT_ID || 'cam',
+ clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
+ redirectUri: env.OV_OAUTH_REDIRECT_URI || `${baseUrl}/auth/callback`,
+ scope: 'profile',
+ sessionAudience: env.OV_SESSION_AUDIENCE || 'openvibe.network',
+ },
+ cookies: { secure: env.COOKIE_SECURE ? env.COOKIE_SECURE === 'true' : isProduction },
+
+ // OpenVibe.Events → this service's account export/deletion route. The secret signs a delivery
+ // (comma-separated for rotation, 32+ characters each); unset turns POST /internal/events off (503).
+ events: {
+ secrets: String(env.CAM_EVENTS_SECRET || '').split(',').map((x) => x.trim()).filter(Boolean),
+ url: trim(env.CAM_EVENTS_URL || env.EVENTS_URL || ''),
+ endpoint: env.CAM_EVENTS_ENDPOINT || '',
+ intervalMs: Math.max(500, int(env.CAM_EVENTS_INTERVAL_MS, 5000)),
+ scope: env.CAM_EVENTS_SCOPE || 'events.subscription.manage',
+ },
+
+ // The existing public APIs Cam uses. No service grant is needed for any of them:
+ // OpenVibe.Media GET /live/:sel/frame.jpg a near-realtime JPEG of a live slot (public, CORS-open)
+ // OpenVibe.Live GET /api/streams/:id a stream and its channel (public; presence is not ownership)
+ // OpenVibe.Pics POST /api/v1/images called with the signed-in person's own Network token
+ upstreams: {
+ media: trim(env.OV_MEDIA_URL || 'https://openvibe.media'),
+ live: trim(env.OV_LIVE_URL || 'https://openvibe.live'),
+ pics: trim(env.OV_PICS_URL || 'https://openvibe.pics'),
+ timeoutMs: Math.max(500, int(env.CAM_UPSTREAM_TIMEOUT_MS, 8000)),
+ },
+
+ // A photo the camera app uploads, and the still Live's cam button takes. Neither is ever stored here:
+ // the bytes go to OpenVibe.Pics, and Cam keeps the Pics slug and links.
+ photos: { maxBytes: size(env.CAM_PHOTO_MAX_BYTES, 12 * 1024 * 1024) },
+ stills: {
+ maxBytes: size(env.CAM_STILL_MAX_BYTES, 8 * 1024 * 1024),
+ defaultWidth: Math.min(1920, Math.max(160, int(env.CAM_STILL_WIDTH, 640))),
+ },
+
+ // A camera is private until its owner grants a viewer. Grants expire (7 days by default, 90 at most) and a
+ // revocation is checked on every read, so it takes effect at once.
+ cameras: {
+ maxPerOwner: Math.min(200, Math.max(1, int(env.CAM_MAX_CAMERAS, 20))),
+ grantDays: Math.min(90, Math.max(1, int(env.CAM_GRANT_DAYS, 7))),
+ grantMaxDays: Math.min(90, Math.max(1, int(env.CAM_GRANT_MAX_DAYS, 90))),
+ grantsPerCamera: Math.min(500, Math.max(1, int(env.CAM_GRANTS_PER_CAMERA, 50))),
+ },
+ };
+}
+
+module.exports = { load };
new file mode 100644
new file mode 100644
@@ -0,0 +1,48 @@
@@ -0,0 +1,48 @@
+'use strict';
+
+/**
+ * OpenVibe.Cam's own PostgreSQL database (ADR-035): the schema is migrations/NNNN_*.sql, applied at boot.
+ * Nothing written here is ever a secret and no picture bytes are stored: a camera is a pair record, a grant is
+ * two subjects and an expiry, and a photo or still is a Pics slug and its public links.
+ */
+const fs = require('fs');
+const path = require('path');
+const { createDb } = require('openvibe-sdk/db');
+const { ids } = require('openvibe-contracts');
+
+const MIGRATIONS = path.join(__dirname, '..', 'migrations');
+const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
+
+async function openDb(config, { log = console, registry } = {}) {
+ if (!config.db.url) {
+ if (config.isProduction) throw new Error('DATABASE_URL is not set: production serves from PostgreSQL (OpenVibe.Host roles/data add-service.sh today)');
+ const dir = config.db.pgliteDir || DEV_PGLITE;
+ log.warn(`[OpenVibe.Cam] DATABASE_URL unset: embedded PGlite database in ${dir} (development only, one process)`);
+ fs.mkdirSync(dir, { recursive: true });
+ const db = createDb({ pglite: dir, service: 'cam', 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: 'cam-migrate', max: 1, log });
+ try { await owner.migrate({ dir: MIGRATIONS, log }); } finally { await owner.close(); }
+ return createDb({ url: config.db.url, service: 'cam', registry, log });
+}
+
+/** The store every module takes: the db handle, a clock (epoch ms; tests inject one), ids and transactions. */
+function createStore(db, { now = () => Date.now() } = {}) {
+ return {
+ db,
+ now,
+ iso: () => new Date(now()).toISOString(),
+ newId: (prefix) => `${prefix}_${ids.ulid(now())}`,
+ tx: async (fn) => await db.tx((t) => fn(t)),
+ close: () => db.close(),
+ };
+}
+
+async function openStore(config, { now, log } = {}) {
+ return createStore(await openDb(config, { log }), { now });
+}
+
+module.exports = { openDb, openStore, createStore, MIGRATIONS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,291 @@
@@ -0,0 +1,291 @@
+'use strict';
+
+/**
+ * /api/v1 — OpenVibe.Cam's JSON API.
+ *
+ * POST /photos a person: raw image bytes → OpenVibe.Pics, recorded here
+ * GET /photos your photos
+ * GET /photos/:id one of your photos
+ * GET /cameras your paired cameras
+ * POST /cameras pair one (a Live stream id is checked against Live)
+ * GET /cameras/:id one of your cameras
+ * PATCH /cameras/:id name, source, status, slot, notes
+ * DELETE /cameras/:id unpair it
+ * GET /cameras/:id/wall owner or an active viewer
+ * GET /cameras/:id/grants owner
+ * POST /cameras/:id/grants owner: @username or subject, and days
+ * DELETE /cameras/:id/grants/:grantId owner: revoke at once
+ * POST /stills the Live cam button: fetch Media's frame, save to Pics
+ * GET /stills your stills
+ * GET /stills/:id one still (unlisted: the link; private: its owner)
+ *
+ * Errors are application/problem+json with a `code`. A person is the cam_at session cookie or a Network user
+ * token as a Bearer; a session write must come from openvibe.cam itself (403 cam.cross_site). No route needs an app
+ * or service token: the still is saved to the viewer's own Pics with the viewer's own token.
+ */
+const express = require('express');
+const { http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { sameOrigin } = require('./principal');
+const { pairCamera, resolveViewer } = require('../cameras');
+const { frameUrl } = require('../views');
+
+function createApi(ctx) {
+ const { config, s, principal, cameras, upstreams } = ctx;
+ const r = asyncRouter();
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(principal.middleware);
+
+ // Per-actor limits, on top of the per-address limit in server/app.js: a signed-in person gets the minute and
+ // hour budget from config, and an app or anonymous caller is left to the address limiter. A second process
+ // has its own counters; VALKEY_URL would make them shared (the readiness check says so).
+ const buckets = new Map();
+ function windowCount(key, windowMs, limit) {
+ const now = Date.now();
+ let b = buckets.get(key);
+ if (!b || now - b.start >= windowMs) { b = { start: now, n: 0 }; buckets.set(key, b); }
+ b.n += 1;
+ return b.n <= limit;
+ }
+ r.use((req, res, next) => {
+ if (req.principal && req.principal.kind === 'user' && req.principal.subject) {
+ const who = `user:${req.principal.subject}`;
+ const okMinute = windowCount(`${who}:m`, 60_000, config.limits.minute);
+ const okHour = windowCount(`${who}:h`, 60 * 60_000, config.limits.hour);
+ if (!okMinute || !okHour) return problem(res, 429, 'cam.rate_limited', 'too many requests from this account; retry shortly');
+ if (buckets.size > 20_000) {
+ for (const [k, b] of buckets) if (Date.now() - b.start > 2 * 60 * 60_000) buckets.delete(k);
+ }
+ }
+ return next();
+ });
+
+ const problem = (res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: res.req && res.req.ov });
+ const invalid = (res, detail) => problem(res, 400, 'cam.invalid', detail);
+
+ function person(req, res) {
+ const p = req.principal;
+ if (p.kind === 'anonymous') { problem(res, 401, 'cam.sign_in_required', 'Sign in at openvibe.cam, or send a person\'s Network token as a Bearer.'); return null; }
+ if (p.kind !== 'user') { problem(res, 403, 'cam.person_required', 'A camera and a picture are a person\'s; an app or service token cannot act here.'); return null; }
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) {
+ problem(res, 403, 'cam.cross_site', 'A signed-in request that changes something must come from openvibe.cam itself.');
+ return null;
+ }
+ return p;
+ }
+ const requirePerson = (req, res, next) => (person(req, res) ? next() : undefined);
+
+ function upstreamProblem(res, err) {
+ const status = err && Number.isFinite(Number(err.status)) ? Number(err.status) : 502;
+ const safe = status >= 400 && status < 600 ? status : 502;
+ return problem(res, safe, (err && err.code) || 'cam.upstream', (err && err.message) || 'an upstream service did not answer');
+ }
+
+ const asInt = (v, def) => (Number.isFinite(parseInt(v, 10)) ? parseInt(v, 10) : def);
+ const decoded = (req, name, max) => { try { return decodeURIComponent(String(req.get(name) || '')).slice(0, max); } catch { return null; } };
+
+ r.get('/ping', (_req, res) => res.json({ ok: true, service: config.service }));
+
+ // A JSON body everywhere except the photo upload, whose body is the picture itself.
+ const jsonBody = express.json({ limit: '64kb' });
+ r.use((req, res, next) => ((req.method === 'POST' && req.path === '/photos') ? next() : jsonBody(req, res, next)));
+
+ // ── Cameras ─────────────────────────────────────────────
+ r.get('/cameras', requirePerson, async (req, res) => {
+ const list = await cameras.camerasOf(req.principal.subject);
+ return res.json({ count: list.length, cameras: list });
+ });
+
+ r.post('/cameras', requirePerson, async (req, res) => {
+ const out = await pairCamera({ cameras, upstreams, principal: req.principal, body: req.body || {} });
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ res.set('Location', `/api/v1/cameras/${out.camera.id}`);
+ return res.status(201).json({ ...out.camera, frame_url: frameUrl(config, out.camera) });
+ });
+
+ async function ownCamera(req, res) {
+ const row = await cameras.cameraById(String(req.params.id || ''));
+ if (!row || row.owner !== req.principal.subject) {
+ problem(res, 404, 'cam.not_found', 'No such camera.');
+ return null;
+ }
+ return row;
+ }
+
+ r.get('/cameras/:id', requirePerson, async (req, res) => {
+ const row = await ownCamera(req, res);
+ if (!row) return undefined;
+ const camera = cameras.wireCamera(row);
+ return res.json({ ...camera, frame_url: frameUrl(config, camera) });
+ });
+
+ r.patch('/cameras/:id', requirePerson, async (req, res) => {
+ const row = await ownCamera(req, res);
+ if (!row) return undefined;
+ const out = await cameras.patchCamera(row.id, req.principal.subject, req.body || {});
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ return res.json({ ...out.camera, frame_url: frameUrl(config, out.camera) });
+ });
+
+ r.delete('/cameras/:id', requirePerson, async (req, res) => {
+ const row = await ownCamera(req, res);
+ if (!row) return undefined;
+ const out = await cameras.removeCamera(row.id, req.principal.subject);
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ return res.status(204).end();
+ });
+
+ r.get('/cameras/:id/wall', requirePerson, async (req, res) => {
+ const wall = await cameras.wall(String(req.params.id || ''), req.principal.subject);
+ if (!wall) return problem(res, 404, 'cam.not_found', 'No such camera, or you have no grant for it.');
+ const camera = { ...cameras.wireCamera(wall.camera), ...(wall.owner ? {} : { notes: undefined }) };
+ if (!wall.owner) delete camera.notes;
+ return res.json({
+ camera,
+ owner: wall.owner,
+ grant: wall.grant,
+ frame_url: frameUrl(config, camera),
+ photos: wall.photos,
+ });
+ });
+
+ // ── Viewer grants ───────────────────────────────────────
+ r.get('/cameras/:id/grants', requirePerson, async (req, res) => {
+ const out = await cameras.grantsOf(String(req.params.id || ''), req.principal.subject);
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ return res.json({ count: out.grants.length, grants: out.grants });
+ });
+
+ r.post('/cameras/:id/grants', requirePerson, async (req, res) => {
+ const camera = await ownCamera(req, res);
+ if (!camera) return undefined;
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const viewer = await resolveViewer({ upstreams, body: b });
+ if (!viewer.ok) return problem(res, viewer.status, viewer.code, viewer.message);
+ const out = await cameras.grantCamera(camera.id, {
+ owner: req.principal.subject, viewerSubject: viewer.subject, viewerUsername: viewer.username,
+ days: b.days == null || b.days === '' ? null : Number(b.days),
+ });
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ res.set('Location', `/api/v1/cameras/${camera.id}/grants`);
+ return res.status(out.changed ? 201 : 200).json(out.grant);
+ });
+
+ r.delete('/cameras/:id/grants/:grantId', requirePerson, async (req, res) => {
+ const camera = await ownCamera(req, res);
+ if (!camera) return undefined;
+ const out = await cameras.revokeGrant(camera.id, String(req.params.grantId || ''), { owner: req.principal.subject });
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ return res.json({ revoked: out.revoked, already: out.already, grant: out.grant });
+ });
+
+ // ── Photos: the raw picture is the body ─────────────────
+ const rawBody = express.raw({ type: () => true, limit: config.photos.maxBytes + 1024 });
+ r.post('/photos', requirePerson, (req, res, next) => {
+ rawBody(req, res, (err) => {
+ if (err && err.type === 'entity.too.large') return problem(res, 413, 'cam.too_large', `A photo can be up to ${Math.round(config.photos.maxBytes / 1024 / 1024)} MB.`);
+ if (err) return next(err);
+ return next();
+ });
+ }, async (req, res) => {
+ const type = String(req.get('content-type') || '').split(';')[0].trim().toLowerCase();
+ if (!['image/jpeg', 'image/png', 'image/webp'].includes(type)) {
+ return problem(res, 415, 'cam.not_an_image', 'Send the picture as the body, with an image/jpeg, image/png or image/webp Content-Type.');
+ }
+ const bytes = Buffer.isBuffer(req.body) ? req.body : null;
+ if (!bytes || !bytes.length) return invalid(res, 'The picture is empty.');
+ if (bytes.length > config.photos.maxBytes) return problem(res, 413, 'cam.too_large', `A photo can be up to ${Math.round(config.photos.maxBytes / 1024 / 1024)} MB.`);
+ const filter = String(req.get('x-filter') || 'none').toLowerCase();
+ if (!cameras.FILTERS.includes(filter)) return invalid(res, `X-Filter must be one of ${cameras.FILTERS.join(', ')}`);
+ const caption = decoded(req, 'x-caption', 280);
+ if (caption === null) return invalid(res, 'X-Caption must be URI-encoded text');
+ const cameraId = String(req.get('x-camera') || '').trim() || null;
+ const filename = decoded(req, 'x-filename', 200) || `cam-${Date.now()}.jpg`;
+ let stored;
+ try {
+ stored = await upstreams.picsUpload({
+ token: req.principal.token, bytes, contentType: type,
+ title: caption || 'A photo on OpenVibe.Cam', filename,
+ });
+ } catch (err) { return upstreamProblem(res, err); }
+ const out = await cameras.createPhoto({
+ subject: req.principal.subject, cameraId, filter, caption: caption || null, pics: stored,
+ });
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ res.set('Location', `/api/v1/photos/${out.photo.id}`);
+ return res.status(201).json(out.photo);
+ });
+
+ r.get('/photos', requirePerson, async (req, res) => {
+ const photos = await cameras.photosOf(req.principal.subject, { limit: asInt(req.query.limit, 60) });
+ return res.json({ count: photos.length, photos });
+ });
+
+ r.get('/photos/:id', requirePerson, async (req, res) => {
+ const photo = await cameras.photoById(String(req.params.id || ''));
+ if (!photo || photo.subject !== req.principal.subject) return problem(res, 404, 'cam.not_found', 'No such photo.');
+ return res.json(photo);
+ });
+
+ // ── Stills: the Live cam button ─────────────────────────
+ r.post('/stills', requirePerson, async (req, res) => {
+ const b = req.body && typeof req.body === 'object' ? req.body : {};
+ const slot = String(b.slot == null ? '' : b.slot).trim();
+ if (!cameras.SLOT_RE.test(slot)) return invalid(res, 'slot must be a Live slot id, slug or @username');
+ const w = b.w == null || b.w === '' ? config.stills.defaultWidth : Number(b.w);
+ if (!Number.isFinite(w) || w < 160 || w > 1920) return invalid(res, 'w must be from 160 to 1920');
+ const streamId = b.stream_id == null || b.stream_id === '' ? null : Number(b.stream_id);
+ if (streamId != null && (!Number.isInteger(streamId) || streamId <= 0)) return invalid(res, 'stream_id must be a positive whole number');
+ const visibility = b.visibility == null || b.visibility === '' ? 'unlisted' : String(b.visibility);
+ if (!cameras.STILL_VISIBILITIES.includes(visibility)) return invalid(res, `visibility must be one of ${cameras.STILL_VISIBILITIES.join(', ')}`);
+ const caption = b.caption == null ? '' : String(b.caption);
+
+ let frame;
+ try { frame = await upstreams.liveFrame({ slot, w: Math.round(w), maxBytes: config.stills.maxBytes }); } catch (err) { return upstreamProblem(res, err); }
+ if (!frame.ok) {
+ if (frame.code === 'offline') return problem(res, 404, 'cam.stream_offline', 'That slot is offline right now.');
+ if (frame.code === 'busy') return problem(res, 429, 'cam.frame_busy', 'OpenVibe.Media is busy grabbing that frame; try again in a second.');
+ if (frame.code === 'too_large') return problem(res, 413, 'cam.too_large', 'That frame is too large to save.');
+ return problem(res, 502, 'cam.frame_unavailable', 'OpenVibe.Media did not return a JPEG frame.');
+ }
+ let stored;
+ try {
+ stored = await upstreams.picsUpload({
+ token: req.principal.token, bytes: frame.bytes, contentType: 'image/jpeg',
+ title: caption || `A still from ${slot}`, filename: `still-${slot.replace(/[^A-Za-z0-9_.-]/g, '')}-${Date.now()}.jpg`,
+ });
+ } catch (err) { return upstreamProblem(res, err); }
+ const out = await cameras.createStill({
+ subject: req.principal.subject, streamId, slot, caption, visibility, pics: stored,
+ });
+ if (!out.ok) return problem(res, out.status, out.code, out.message);
+ const shareUrl = `${String(config.baseUrl).replace(/\/+$/, '')}/s/${out.still.id}`;
+ const text = (out.still.caption ? `${out.still.caption} — ${shareUrl}` : `A still from ${out.still.slot}: ${shareUrl}`).slice(0, 500);
+ res.set('Location', `/api/v1/stills/${out.still.id}`);
+ return res.status(201).json({
+ still: out.still,
+ frame_url: `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(slot)}/frame.jpg?w=${Math.round(w)}`,
+ share: { url: shareUrl, caption: out.still.caption, text },
+ });
+ });
+
+ r.get('/stills', requirePerson, async (req, res) => {
+ const stills = await cameras.stillsOf(req.principal.subject, { limit: asInt(req.query.limit, 60) });
+ return res.json({ count: stills.length, stills });
+ });
+
+ r.get('/stills/:id', async (req, res) => {
+ const still = await cameras.stillById(String(req.params.id || ''));
+ if (!still) return problem(res, 404, 'cam.not_found', 'No such still.');
+ const mine = req.principal.kind === 'user' && req.principal.subject === still.subject;
+ if (still.visibility !== 'unlisted' && !mine) return problem(res, 404, 'cam.not_found', 'No such still.');
+ const out = { ...still, mine };
+ if (!mine) delete out.subject;
+ 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.cam: robots.txt, sitemap.xml, llms.txt and llms-full.txt. Only the three public
+ * pages are listed. A camera wall, a grant and a still are not for crawlers — the sitemap never names one, and
+ * robots.txt disallows the three paths that could carry one.
+ */
+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.Cam';
+const DESCRIPTION = 'OpenVibe.Cam is the camera app: take a photo with on-device filters, pair a camera you own, share its private wall with one viewer for a week, and take a still from a Live stream for its chat.';
+const DISALLOW = ['/auth/', '/api/', '/my/', '/w/', '/s/', '/internal', '/metrics'];
+
+function siteUpdated() {
+ try {
+ const m = String(JSON.parse(fs.readFileSync(path.join(__dirname, '..', '..', 'STATUS.json'), 'utf8')).updated || '').match(/^(\d{4}-\d{2}-\d{2})/);
+ return m ? m[1] : null;
+ } 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: 'MultimediaApplication', keywords: 'openvibe cam, camera app, private camera, viewer grant, live still, on-device filters' }),
+ seo.jsonLd.webPage({ name: SITE_NAME, url: `${site}/`, description: DESCRIPTION, siteUrl: site }),
+ ];
+}
+
+function publicPages() {
+ return [
+ { path: '/', changefreq: 'weekly', priority: 1.0, title: 'OpenVibe.Cam home', text: 'The camera app, your private cameras and viewer grants, and a still from a Live stream.' },
+ { path: '/app', changefreq: 'weekly', priority: 0.9, title: 'The camera app', text: 'A photo with on-device colour, light and softness, a timer, a burst and a grid; the picture is saved to OpenVibe.Pics.' },
+ { path: '/developers', changefreq: 'weekly', priority: 0.6, title: 'The OpenVibe.Cam API', text: 'The JSON API, the viewer-grant rules and the Live cam button contract.' },
+ ];
+}
+
+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.cam: the three public pages are for crawlers; a camera wall, a grant, a still and the API 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.Cam is the camera app: photos with on-device filters saved to OpenVibe.Pics, private cameras with revocable viewer grants, and a still from a Live stream for its chat. A camera is private; there is no public directory in v1.',
+ details: 'GET /api/v1/cameras lists a signed-in person\'s cameras; POST /api/v1/cameras pairs one; POST /api/v1/cameras/:id/grants names a viewer and a number of days; DELETE /api/v1/cameras/:id/grants/:grantId revokes it at once. POST /api/v1/stills takes a frame from OpenVibe.Media\'s public GET /live/:sel/frame.jpg and saves it to OpenVibe.Pics as the person. No route is public except GET /api/v1/stills/:id for an unlisted still.',
+ sections: [
+ { title: 'Start here', links: [
+ { title: 'OpenVibe.Cam', url: abs('/'), note: 'the camera app and the privacy rules' },
+ { title: 'The camera app', url: abs('/app') },
+ { title: 'The API', url: abs('/developers') },
+ ] },
+ { 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.Live', url: 'https://openvibe.live', note: 'the stream the cam button takes a still from' },
+ { title: 'OpenVibe.Media', url: 'https://openvibe.media', note: 'the live frame endpoint' },
+ { title: 'OpenVibe.Pics', url: 'https://openvibe.pics', note: 'where a photo and a still are stored' },
+ { title: 'OpenVibe.Network', url: 'https://openvibe.network', note: 'accounts and usernames' },
+ ] },
+ ],
+ }));
+ });
+
+ r.get('/llms-full.txt', (_req, res) => {
+ res.type('text/plain').set('Cache-Control', TEXT).send(seo.llmsFull({
+ site: SITE_NAME,
+ summary: 'Every public page of OpenVibe.Cam. No camera, grant or still is listed: those are private or link-only.',
+ base: site,
+ maxBytes: 256 * 1024,
+ sections: [{ title: 'Pages', pages: pages.map((p) => ({ url: p.path, title: p.title, text: p.text })) }],
+ }));
+ });
+
+ 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,214 @@
@@ -0,0 +1,214 @@
+'use strict';
+
+/**
+ * Cam's server-rendered pages. They are complete without JavaScript: forms are plain POSTs and every link is a real
+ * link. The camera preview, timer, burst, grid and filters are an enhancement in public/js/cam.js.
+ *
+ * / the hero, the three scenes, the privacy rules
+ * /app the camera app, with your recent photos when signed in
+ * /my pair a camera, your cameras, photos, stills (signed in)
+ * /my/cameras/:id one camera's owner page: edit, grants, unpair
+ * /w/:id the private wall an owner or an active grantee sees
+ * /s/:id one still of a live stream
+ * /developers the API and the Live cam button contract
+ * 404 the not-found page
+ *
+ * Public pages are served `public, max-age=60` with Vary: Cookie; every page drawn for a signed-in person, a wall
+ * and a still are `private, no-store`.
+ */
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const { asyncRouter } = require('./router');
+const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
+const { sameOrigin } = require('./principal');
+const { pairCamera, resolveViewer } = require('../cameras');
+const { html, raw } = require('../render/html');
+const { send } = require('../render/layout');
+const views = require('../views');
+
+const PUBLIC_CACHE = 'public, max-age=60';
+
+function createPageRoutes(ctx) {
+ const { config, s, cameras, upstreams } = 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 notFromHere = (req, res) => page(req, res, {
+ title: 'Not from this site',
+ body: raw(html`<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty"><h1>That came from somewhere else</h1><p class="cm-muted">Use the form on <a href="/">OpenVibe.Cam</a>.</p></div></div>`),
+ }, 403);
+ const signedOut = (req, res, title, text, next) => page(req, res, {
+ title, body: raw(views.signIn({ title, text, next })),
+ }, 401);
+
+ // ── Home ─────────────────────────────────────────────────
+ r.get('/', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: null,
+ description: 'OpenVibe.Cam is the camera app: on-device filters, private cameras with revocable viewer grants, and a still from a Live stream for its chat.',
+ jsonLd: homeJsonLd(config),
+ body: raw(views.home({ signedIn: Boolean(signedIn(req)) })),
+ }));
+
+ // ── The camera app ───────────────────────────────────────
+ r.get('/app', async (req, res) => {
+ const photos = signedIn(req) ? await cameras.photosOf(req.viewer.subject, { limit: 12 }) : [];
+ return page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'The camera app',
+ description: 'Take a photo with on-device colour, light and blur, a timer, a burst and a grid; save it to OpenVibe.Pics.',
+ body: raw(views.app({ signedIn: Boolean(signedIn(req)), photos })),
+ });
+ });
+
+ // ── My cameras ───────────────────────────────────────────
+ async function renderMy(req, res, { problem = null, status = 200 } = {}) {
+ const [list, photos, stills, counts] = await Promise.all([
+ cameras.camerasOf(req.viewer.subject), cameras.photosOf(req.viewer.subject), cameras.stillsOf(req.viewer.subject), cameras.countsFor(req.viewer.subject),
+ ]);
+ return page(req, res, {
+ title: 'My cameras',
+ description: 'Pair a camera you own, grant one viewer for a week, and revoke it at once.',
+ body: raw(views.my({ viewer: req.viewer, cameras: list, photos, stills, counts, problem })),
+ }, status);
+ }
+
+ r.get('/my', async (req, res) => {
+ if (!signedIn(req)) return signedOut(req, res, 'My cameras', 'A camera belongs to your OpenVibe account, so you can pair it, grant a viewer and revoke it later.', '/my');
+ return renderMy(req, res);
+ });
+
+ r.post('/my/cameras', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fmy');
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const out = await pairCamera({ cameras, upstreams, principal: { subject: req.viewer.subject, username: req.viewer.username }, body: req.body || {} });
+ if (!out.ok) return renderMy(req, res, { problem: out.message, status: out.status || 400 });
+ return res.redirect(303, `/my/cameras/${encodeURIComponent(out.camera.id)}`);
+ });
+
+ async function ownerCamera(req, res) {
+ const row = await cameras.cameraById(String(req.params.id || ''));
+ if (!row || row.owner !== req.viewer.subject) return null;
+ return row;
+ }
+
+ async function renderCamera(req, res, row, { problem = null, status = 200 } = {}) {
+ const grants = await cameras.grantsOf(row.id, req.viewer.subject);
+ const wall = await cameras.wall(row.id, req.viewer.subject);
+ const camera = cameras.wireCamera(row);
+ return page(req, res, {
+ title: camera.name,
+ description: `Manage ${camera.name}: its slot, its viewers and its wall.`,
+ body: raw(views.camera({
+ camera, grants: grants.ok ? grants.grants : [], photos: wall ? wall.photos : [],
+ frameUrl: views.frameUrl(config, camera), problem,
+ })),
+ }, status);
+ }
+
+ r.get('/my/cameras/:id', async (req, res) => {
+ if (!signedIn(req)) return signedOut(req, res, 'My cameras', 'Sign in to manage this camera.', `/my/cameras/${req.params.id}`);
+ const row = await ownerCamera(req, res);
+ if (!row) {
+ const access = await cameras.accessTo(String(req.params.id || ''), req.viewer.subject);
+ if (access && !access.owner) return res.redirect(303, `/w/${encodeURIComponent(access.camera.id)}`);
+ return notFound(req, res);
+ }
+ return renderCamera(req, res, row);
+ });
+
+ r.post('/my/cameras/:id', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const row = await ownerCamera(req, res);
+ if (!row) return notFound(req, res);
+ const out = await cameras.patchCamera(row.id, req.viewer.subject, req.body || {});
+ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });
+ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}`);
+ });
+
+ r.post('/my/cameras/:id/delete', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fmy');
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const row = await ownerCamera(req, res);
+ if (!row) return notFound(req, res);
+ await cameras.removeCamera(row.id, req.viewer.subject);
+ return res.redirect(303, '/my');
+ });
+
+ r.post('/my/cameras/:id/grants', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const row = await ownerCamera(req, res);
+ if (!row) return notFound(req, res);
+ const viewer = await resolveViewer({ upstreams, body: req.body || {} });
+ if (!viewer.ok) return renderCamera(req, res, row, { problem: viewer.message, status: viewer.status || 400 });
+ const out = await cameras.grantCamera(row.id, {
+ owner: req.viewer.subject, viewerSubject: viewer.subject, viewerUsername: viewer.username, days: req.body && req.body.days,
+ });
+ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });
+ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}?granted=1`);
+ });
+
+ r.post('/my/cameras/:id/grants/:grantId/revoke', async (req, res) => {
+ if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(`/my/cameras/${req.params.id}`)}`);
+ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
+ const row = await ownerCamera(req, res);
+ if (!row) return notFound(req, res);
+ const out = await cameras.revokeGrant(row.id, String(req.params.grantId || ''), { owner: req.viewer.subject });
+ if (!out.ok) return renderCamera(req, res, row, { problem: out.message, status: out.status || 400 });
+ return res.redirect(303, `/my/cameras/${encodeURIComponent(row.id)}?revoked=1`);
+ });
+
+ // ── The private wall ─────────────────────────────────────
+ r.get('/w/:id', async (req, res) => {
+ if (!signedIn(req)) return signedOut(req, res, 'A private camera', 'This wall opens only for its owner and the viewers they granted. Sign in to see whether one of them is you.', `/w/${req.params.id}`);
+ const wall = await cameras.wall(String(req.params.id || ''), req.viewer.subject);
+ if (!wall) return notFound(req, res);
+ const camera = cameras.wireCamera(wall.camera);
+ if (!wall.owner) delete camera.notes;
+ return page(req, res, {
+ title: camera.name,
+ description: 'A private camera wall on OpenVibe.Cam.',
+ body: raw(views.wall({ camera, access: { owner: wall.owner, grant: wall.grant }, photos: wall.photos, frameUrl: views.frameUrl(config, camera) })),
+ });
+ });
+
+ // ── One still ────────────────────────────────────────────
+ r.get('/s/:id', async (req, res) => {
+ const still = await cameras.stillById(String(req.params.id || ''));
+ if (!still) return notFound(req, res);
+ const mine = Boolean(signedIn(req) && req.viewer.subject === still.subject);
+ if (still.visibility !== 'unlisted' && !mine) return notFound(req, res);
+ return page(req, res, {
+ title: still.caption || `A still from ${still.slot}`,
+ description: still.caption ? `${still.caption} — a still from a live stream on OpenVibe.Cam.` : 'A still from a live stream on OpenVibe.Cam.',
+ jsonLd: [seo.jsonLd.webPage({
+ name: still.caption || `A still from ${still.slot}`,
+ url: `${base()}/s/${still.id}`,
+ description: still.caption || `A still from ${still.slot} on OpenVibe.Cam.`,
+ siteUrl: base(),
+ })],
+ body: raw(views.still({ still, mine })),
+ });
+ });
+
+ // ── Developers ───────────────────────────────────────────
+ r.get('/developers', (req, res) => page(req, res, {
+ index: true, cache: PUBLIC_CACHE, title: 'The OpenVibe.Cam API',
+ description: 'The JSON API, the viewer-grant rules and the Live cam button contract.',
+ jsonLd: [seo.jsonLd.webPage({ name: 'The OpenVibe.Cam API', url: `${base()}/developers`, description: 'The JSON API, the viewer-grant rules and the Live cam button contract.', siteUrl: base() })],
+ body: raw(views.developers()),
+ }));
+
+ // ── Discovery and 404 ────────────────────────────────────
+ r.use(createDiscoveryRoutes(ctx));
+
+ function notFound(req, res) {
+ return page(req, res, { title: 'Not found', body: raw(views.notFound()) }, 404);
+ }
+
+ return r;
+}
+
+module.exports = { createPageRoutes };
new file mode 100644
new file mode 100644
@@ -0,0 +1,104 @@
@@ -0,0 +1,104 @@
+'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 (cam_at).
+ * { kind: 'app', requester: 'app:…' | 'agent:…' | 'service:…', project, claims }
+ * a Network app, agent or service token for audience openvibe.cam. No Cam route needs one in v1: the Live cam
+ * button calls POST /api/v1/stills with the viewer's own Network token, because OpenVibe.Pics uploads are a
+ * person's (an app or service token is refused there). 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 pair a camera, grant a
+ * viewer, revoke a grant or upload a photo.
+ */
+const { serviceAuth, http, ids } = require('openvibe-contracts');
+
+const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
+const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+// Cam's routes are a person acting for themself and a public read of a shared still. No capability.
+const CAPABILITIES = [];
+
+function decodePayload(token) {
+ const parts = String(token || '').split('.');
+ if (parts.length !== 3) return null;
+ try { return JSON.parse(Buffer.from(parts[1], 'base64url').toString('utf8')); } catch { return null; }
+}
+
+function roleClaim(claims) {
+ return typeof claims.role === 'string' && claims.role ? claims.role.slice(0, 32) : 'user';
+}
+function usernameClaim(claims) {
+ return typeof claims.username === 'string' ? claims.username.slice(0, 64) : null;
+}
+
+function requesterOfSub(sub) {
+ if (sub.startsWith('svc:')) return `service:${sub.slice(4)}`;
+ return sub;
+}
+
+/** Same-origin for a signed-in write: the browser's fetch metadata, else the Origin header. */
+function sameOrigin(req, baseUrl) {
+ const site = String(req.get('sec-fetch-site') || '');
+ if (site) return site === 'same-origin';
+ const origin = req.get('origin');
+ if (origin) return origin === baseUrl || origin === `${req.protocol}://${req.get('host')}`;
+ return false;
+}
+
+function createPrincipal({ config, keys }) {
+ async function resolve(req) {
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) {
+ const token = header.slice(7).trim();
+ const payload = decodePayload(token);
+ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
+ const publicKey = await keys.pemForToken(token);
+ if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
+ const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
+ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
+ }
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
+ if (!v.ok) return { error: [401, v.expired ? 'token.expired' : 'token.invalid', v.reason] };
+ if (v.claims.typ === 'fedcm' || v.claims.actor_type !== undefined) return { error: [401, 'token.invalid', 'not a person\'s token'] };
+ if (!ids.isSubjectId('user', v.claims.subject_id)) return { error: [403, 'identity.no_subject', 'this account has no canonical subject yet; sign in again'] };
+ return { principal: { kind: 'user', subject: v.claims.subject_id, requester: `user:${v.claims.subject_id}`, project: null, role: roleClaim(v.claims), username: usernameClaim(v.claims), token, viaSession: false } };
+ }
+ const viewer = req.viewer;
+ if (viewer && viewer.kind === 'user' && ids.isSubjectId('user', viewer.subject)) {
+ return { principal: { kind: 'user', subject: viewer.subject, requester: `user:${viewer.subject}`, project: null, role: viewer.role || 'user', username: viewer.username || null, token: viewer.token, viaSession: true } };
+ }
+ return { principal: { kind: 'anonymous' } };
+ }
+
+ async function middleware(req, res, next) {
+ const r = await resolve(req);
+ if (r.error) return http.sendProblem(res, r.error[0], r.error[1], { detail: r.error[2], ctx: req.ov });
+ req.principal = r.principal;
+ return next();
+ }
+
+ function require_(cap) {
+ if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.Cam'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.cam, 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, 'cam.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.cam itself.', ctx: req.ov });
+ return next();
+ }
+ return http.sendProblem(res, 403, 'cam.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,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/**
+ * Account export and deletion → Cam (ADR-033; openvibe-sdk/account-data). A person's cameras, the grants they gave
+ * and the grants they received, their photos and their stills are theirs to export and erase.
+ *
+ * A deletion of a camera's owner removes the camera and (by the foreign key) every grant on it. A deletion of a
+ * viewer removes the grants naming them and leaves the owner's camera alone. Cam never holds the picture bytes: a
+ * photo or still row is a Pics slug and its public links, and OpenVibe.Pics erases the picture itself on the same
+ * event. The database stores bare usr_ ids and no tokens.
+ */
+const { createAccountData, TOPICS } = require('openvibe-sdk/account-data');
+
+const TABLES = [
+ { table: 'cam_cameras', subject: 'owner', file: 'cameras.json', erase: 'delete' },
+ { table: 'cam_grants', subject: 'owner', file: 'grants-given.json', erase: 'delete' },
+ { table: 'cam_grants', subject: 'viewer_subject', file: 'grants-received.json', erase: 'delete' },
+ { table: 'cam_photos', subject: 'subject', file: 'photos.json', erase: 'delete' },
+ { table: 'cam_stills', subject: 'subject', file: 'stills.json', erase: 'delete' },
+];
+
+function create({ db, log = console } = {}) {
+ return createAccountData({
+ db,
+ service: 'cam',
+ tables: TABLES,
+ note: 'A camera is private to its owner; a grant is the only way in. Pictures live in OpenVibe.Pics; Cam keeps only the link.',
+ log,
+ });
+}
+
+module.exports = { create, TABLES, TOPICS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+
+/**
+ * OpenVibe.Cam — process entry. `node server/index.js`
+ * Listens on PORT (5160) behind nginx (deploy/).
+ */
+const { createApp } = require('./app');
+const { gracefulStop } = require('openvibe-sdk/service');
+const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
+const { startSubscriptions } = require('openvibe-sdk/account-data');
+
+/**
+ * The process stop (openvibe-sdk/service): the HTTP drain runs, then the JWKS refresher stops, the jobs stop, the
+ * store closes and the Events subscriptions stop. Exported so a test can inject `exit` and `signals: false`.
+ */
+function createLifecycle({ server, ctx, exit, signals, timers = [], extra = [] }) {
+ return gracefulStop({
+ name: 'OpenVibe.Cam', server, deadlineExitCode: 0, exit, signals, deadlineMs: 10_000,
+ close: [
+ () => { for (const t of timers) clearInterval(t); },
+ () => ctx.keys.client.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.Cam] ${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();
+
+ // The two account topics (export_requested, deleted) at OpenVibe.Events: idempotent; off without the URL, the
+ // delivery secret and the client secret. The consumer itself is mounted in server/app.js.
+ const subs = config.drill ? null : startSubscriptions({
+ eventsUrl: config.events.url,
+ endpoint: config.events.endpoint || `http://127.0.0.1:${config.port}/internal/events`,
+ secret: config.events.secrets[0] || '',
+ networkInternalUrl: config.networkInternalUrl,
+ clientId: config.oauth.clientId,
+ clientSecret: config.oauth.clientSecret,
+ log: ctx.log || console,
+ });
+ const extra = [() => { if (subs) subs.stop(); }];
+ createLifecycle({ server, ctx, extra });
+ return { server, ctx };
+}
+
+if (require.main === module) {
+ start().catch((err) => { console.error('[OpenVibe.Cam] failed to start:', err); process.exit(1); });
+}
+
+module.exports = { start, createLifecycle };
new file mode 100644
new file mode 100644
@@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
+'use strict';
+
+/**
+ * Truthful readiness for GET /api/ready (openvibe-shared/ready): the database is required; the Network signing key,
+ * the OAuth client and Valkey are optional. The key store is warmed right after listen, so a service nobody has
+ * signed in to yet is not degraded only because the first fetch had not happened.
+ */
+const { jwksStatus } = require('openvibe-sdk/auth');
+const { createReadiness } = require('openvibe-shared/ready');
+
+function createServiceReadiness({ s, config, valkey = null, release = null }) {
+ return createReadiness({
+ service: 'cam',
+ 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 and sharing by @username cannot complete') },
+ {
+ name: 'identity_resolve', required: false,
+ check: () => (config.oauth.clientSecret ? true : 'OV_OAUTH_CLIENT_SECRET unset: a grant needs a viewer subject'),
+ },
+ ],
+ });
+}
+
+module.exports = { createServiceReadiness };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
+'use strict';
+
+/** Escaping and small building blocks for server-rendered pages. Everything dynamic goes through esc(). */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+class Raw { constructor(s) { this.s = String(s); } toString() { return this.s; } }
+const raw = (s) => new Raw(s);
+function html(strings, ...values) {
+ let out = strings[0];
+ values.forEach((v, i) => { out += render(v) + strings[i + 1]; });
+ return raw(out);
+}
+function render(v) {
+ if (v == null || v === false) return '';
+ if (v instanceof Raw) return v.s;
+ if (Array.isArray(v)) return v.map(render).join('');
+ return esc(v);
+}
+
+const code = (s) => html`<code>${s}</code>`;
+const time = (iso) => (iso ? html`<time datetime="${iso}">${String(iso).replace('T', ' ').replace(/\.\d+Z$/, ' UTC').replace(/Z$/, ' UTC')}</time>` : '—');
+
+function table(headers, rows, { cls = '', empty = 'Nothing here yet.' } = {}) {
+ if (!rows.length) return html`<p class="muted">${empty}</p>`;
+ return html`<div class="table-wrap"><table class="${cls}"><thead><tr>${headers.map((h) => html`<th scope="col">${h}</th>`)}</tr></thead><tbody>${rows.map((r) => html`<tr>${r.map((c) => html`<td>${c}</td>`)}</tr>`)}</tbody></table></div>`;
+}
+
+const notice = (text, kind = 'info') => html`<div class="notice ${kind}" role="status">${text}</div>`;
+const badge = (text, kind = '') => html`<span class="badge ${kind}">${text}</span>`;
+
+module.exports = { esc, html, raw, Raw, render, code, time, table, notice, badge };
new file mode 100644
new file mode 100644
@@ -0,0 +1,118 @@
@@ -0,0 +1,118 @@
+'use strict';
+
+/**
+ * Page shell. Every page is server-rendered through openvibe-shared/shell and is complete without JavaScript.
+ * This is Meme's layout with Cam's name, navigation, room accent and assets.
+ */
+const crypto = require('crypto');
+const fs = require('fs');
+const path = require('path');
+const appIcon = require('openvibe-shared/app-icon');
+const shell = require('openvibe-shared/shell');
+const rooms = require('openvibe-shared/rooms');
+const ovServe = require('openvibe-shared/serve');
+const cache = require('openvibe-shared/cache-policy');
+const { render, esc } = require('./html');
+
+const NETWORK_URL = 'https://openvibe.network';
+const SITE_NAME = 'OpenVibe.Cam';
+const TAGLINE = 'Your camera, your cameras, and the moment Live\'s cam button takes.';
+const PUBLIC_DIR = path.join(__dirname, '..', '..', 'public');
+const NAV = [
+ { label: 'Home', href: '/' },
+ { label: 'Camera', href: '/app' },
+ { label: 'My cameras', href: '/my' },
+ { 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, scripts
+ */
+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: 'cam',
+ 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: 'cam', variant: 'full', mount: '#ov-footer', brandName: SITE_NAME };
+ const account = signedIn
+ ? `Signed in as <strong>${esc(viewer.displayName || viewer.username || 'you')}</strong> · <a href="/auth/logout?next=${loginNext}">Sign out</a>`
+ : `<a href="/auth/login?next=${loginNext}">Sign in with OpenVibe</a>`;
+ const crumbs = (o.crumbs || []).length
+ ? `<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => `${i ? ' / ' : ''}${c.href ? `<a href="${c.href}">${c.label}</a>` : c.label}`).join('')}</nav>`
+ : '';
+ const scripts = (o.scripts || []).map((rel) => `<script src="${asset(rel)}" defer></script>`);
+ return shell.page({
+ name: SITE_NAME,
+ lang: 'en',
+ title: o.title || `${SITE_NAME}: ${TAGLINE}`,
+ titleSuffix: o.title ? ` · ${SITE_NAME}` : '',
+ siteName: SITE_NAME,
+ description: o.description || `${SITE_NAME} — ${TAGLINE}`,
+ canonical,
+ robots: o.index ? 'index, follow' : 'noindex, nofollow',
+ jsonLd: (o.jsonLd || []).filter(Boolean),
+ home: '/',
+ navLinks: NAV,
+ navbar: nav,
+ footer,
+ head: `<meta name="referrer" content="strict-origin-when-cross-origin">
+${appIcon.headTags({ site: 'cam' })}
+<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/cam.css')}">
+<script src="${ovServe.url('motion.js')}" defer></script>
+<script src="${asset('js/cam.js')}" defer></script>
+${render(scripts)}
+<meta name="ov-boost" content="cam@${require('./html').esc(RELEASE)}">
+<script src="${require('openvibe-shared/serve').url('boost.js')}" data-main="#main" defer></script>`,
+ body: `<a class="skip" href="#main">Skip to content</a>
+<div id="navbar-mount"></div>
+<noscript><div class="account-bar" role="navigation" aria-label="Account">${account}</div></noscript>
+<main id="main" class="cm ov-room" style="${rooms.style('cam')}">
+${crumbs}
+${render(o.body)}
+</main>
+<script>
+window.__OV_PAGE = ${JSON.stringify({ navbar: nav, footer }).replace(/</g, '\\u003c')};
+document.addEventListener('DOMContentLoaded', function () {
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init(window.__OV_PAGE.footer); } catch (e) { /* the Frame is optional */ }
+});
+</script>`,
+ });
+}
+
+/** Send a page; a page rendered for a signed-in person is private and never cached. */
+function send(res, status, o) {
+ const personal = o.viewer && o.viewer.kind === 'user';
+ if (!res.get('Cache-Control')) res.set('Cache-Control', personal ? cache.htmlHeaders({ private: true }) : (o.cache || cache.htmlHeaders({ private: true })));
+ res.set('Vary', 'Cookie');
+ res.status(status).type('html').send(renderPage(o));
+}
+
+module.exports = { renderPage, send, asset, assetVersion, setRelease, SITE_NAME, TAGLINE, NETWORK_URL, NAV };
new file mode 100644
new file mode 100644
@@ -0,0 +1,386 @@
@@ -0,0 +1,386 @@
+'use strict';
+
+/**
+ * Cam's data: paired cameras, revocable viewer grants, photos and stills.
+ *
+ * A camera is private. Its owner sees it; anybody else needs a grant that is not revoked and has not expired, and
+ * the check runs on every read, so a revocation takes effect at once. A photo or a still is never bytes here: the
+ * picture went to OpenVibe.Pics and this row keeps the Pics slug and links.
+ */
+const SOURCES = Object.freeze(['browser', 'phone', 'pi', 'ip']);
+const STATUSES = Object.freeze(['paired', 'live', 'offline']);
+const FILTERS = Object.freeze(['none', 'mono', 'warm', 'cool', 'fade', 'vivid', 'blur']);
+const STILL_VISIBILITIES = Object.freeze(['unlisted', 'private']);
+const PHOTO_VISIBILITIES = Object.freeze(['private', 'unlisted']);
+const CAPTION_MAX = 280;
+const NAME_MAX = 80;
+const NOTES_MAX = 500;
+const SLOT_RE = /^@?[A-Za-z0-9][A-Za-z0-9_.-]{0,79}$/;
+const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const BLOCKED_WORDS = Object.freeze(['nigger', 'faggot', 'retard', 'kys', 'rape', 'pedo', 'nazi']);
+
+const iso = (v) => (v == null ? null : (v instanceof Date ? v.toISOString() : String(v)));
+const nOrNull = (v) => (v == null || v === '' ? null : (Number.isFinite(Number(v)) ? Number(v) : null));
+const refusal = (status, code, message, field) => ({ ok: false, status, code, message, ...(field ? { field } : {}) });
+
+function cleanLine(v, max) {
+ return String(v == null ? '' : v).replace(/\s+/g, ' ').trim().slice(0, max);
+}
+
+function controlChars(v) {
+ return /[\u0000-\u001f\u007f]/.test(String(v == null ? '' : v));
+}
+
+function blocked(text) {
+ return BLOCKED_WORDS.some((w) => new RegExp(`(^|[^a-z0-9])${w}([^a-z0-9]|$)`, 'i').test(String(text)));
+}
+
+/** A caption is plain text: 0–280 characters, no control characters, the short blocked-word list. */
+function caption(raw) {
+ if (raw == null || raw === '') return { ok: true, value: null };
+ if (controlChars(raw)) return refusal(400, 'cam.invalid', 'A caption is plain text: no control characters', 'caption');
+ const value = cleanLine(raw, CAPTION_MAX + 1);
+ if (value.length > CAPTION_MAX) return refusal(400, 'cam.invalid', `A caption is at most ${CAPTION_MAX} characters`, 'caption');
+ if (blocked(value)) return refusal(400, 'cam.blocked_words', 'That wording is not allowed here', 'caption');
+ return { ok: true, value: value || null };
+}
+
+function toSubject(raw) {
+ const v = String(raw == null ? '' : raw).trim();
+ return SUBJECT_RE.test(v) ? v : null;
+}
+
+function wireCamera(row) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ name: row.name,
+ source: row.source,
+ status: row.status,
+ stream_id: nOrNull(row.stream_id),
+ slot: row.slot == null ? null : row.slot,
+ notes: row.notes == null ? null : row.notes,
+ owner_username: row.owner_username == null ? null : row.owner_username,
+ paired_at: iso(row.paired_at),
+ last_seen_at: iso(row.last_seen_at),
+ created_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ ...(row.active_grants != null ? { active_grants: Number(row.active_grants) || 0 } : {}),
+ };
+}
+
+function wireGrant(row, { now = Date.now() } = {}) {
+ if (!row) return null;
+ const revoked = row.revoked_at != null;
+ const expired = Date.parse(row.expires_at) <= now;
+ return {
+ id: row.id,
+ camera_id: row.camera_id,
+ viewer_subject: row.viewer_subject,
+ viewer_username: row.viewer_username == null ? null : row.viewer_username,
+ granted_at: iso(row.granted_at),
+ expires_at: iso(row.expires_at),
+ revoked_at: iso(row.revoked_at),
+ active: !revoked && !expired,
+ };
+}
+
+function wirePhoto(row, { owner = false } = {}) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ camera_id: row.camera_id == null ? null : row.camera_id,
+ filter: row.filter || 'none',
+ caption: row.caption == null ? null : row.caption,
+ visibility: row.visibility,
+ pics_slug: row.pics_slug,
+ pics_url: row.pics_url,
+ pics_direct: row.pics_direct,
+ created_at: iso(row.created_at),
+ ...(owner ? { subject: row.subject } : {}),
+ };
+}
+
+function wireStill(row, { owner = false } = {}) {
+ if (!row) return null;
+ return {
+ id: row.id,
+ stream_id: nOrNull(row.stream_id),
+ slot: row.slot,
+ caption: row.caption == null ? null : row.caption,
+ visibility: row.visibility,
+ pics_slug: row.pics_slug,
+ pics_url: row.pics_url,
+ pics_direct: row.pics_direct,
+ created_at: iso(row.created_at),
+ ...(owner ? { subject: row.subject } : {}),
+ };
+}
+
+function createStore(s, { config }) {
+ const db = s.db;
+ const q = (t) => t || db;
+ const clock = () => s.iso();
+ const daysFromNow = (days) => new Date(s.now() + Number(days) * 86_400_000).toISOString();
+
+ async function cameraById(id, t) {
+ return await q(t).maybe('SELECT * FROM cam_cameras WHERE id = $1', [String(id || '')]);
+ }
+
+ async function camerasOf(owner) {
+ const rows = await db.many(
+ `SELECT c.*,
+ (SELECT COUNT(*)::int FROM cam_grants g
+ WHERE g.camera_id = c.id AND g.revoked_at IS NULL AND g.expires_at > $2::timestamptz) AS active_grants
+ FROM cam_cameras c WHERE c.owner = $1 ORDER BY c.created_at DESC, c.id DESC`,
+ [owner, clock()],
+ );
+ return rows.map(wireCamera);
+ }
+
+ async function createCamera({ owner, ownerUsername = null, name, source = 'phone', streamId = null, slot = null, notes = null }) {
+ if (!SUBJECT_RE.test(String(owner || ''))) return refusal(400, 'cam.invalid', 'A camera belongs to a signed-in person', 'owner');
+ const cleanName = cleanLine(name, NAME_MAX + 1);
+ if (!cleanName) return refusal(400, 'cam.invalid', 'Give the camera a name', 'name');
+ if (cleanName.length > NAME_MAX) return refusal(400, 'cam.invalid', `A name is at most ${NAME_MAX} characters`, 'name');
+ if (controlChars(name)) return refusal(400, 'cam.invalid', 'A name is plain text: no control characters', 'name');
+ if (!SOURCES.includes(source)) return refusal(400, 'cam.invalid', `source must be one of ${SOURCES.join(', ')}`, 'source');
+ if (streamId != null && (!Number.isInteger(Number(streamId)) || Number(streamId) <= 0)) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number', 'stream_id');
+ if (slot != null && !SLOT_RE.test(String(slot))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');
+ const cleanNotes = notes == null || notes === '' ? null : cleanLine(notes, NOTES_MAX + 1);
+ if (cleanNotes && cleanNotes.length > NOTES_MAX) return refusal(400, 'cam.invalid', `Notes are at most ${NOTES_MAX} characters`, 'notes');
+ if (cleanNotes && controlChars(notes)) return refusal(400, 'cam.invalid', 'Notes are plain text: no control characters', 'notes');
+
+ const count = Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [owner]));
+ if (count >= config.cameras.maxPerOwner) return refusal(409, 'cam.camera_limit', `You can pair ${config.cameras.maxPerOwner} cameras; unpair one first.`);
+
+ const id = s.newId('cam');
+ try {
+ const row = await db.maybe(
+ `INSERT INTO cam_cameras (id, owner, owner_username, name, source, status, stream_id, slot, notes, paired_at, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, 'paired', $6, $7, $8, $9::timestamptz, $9::timestamptz, $9::timestamptz)
+ RETURNING *`,
+ [id, owner, ownerUsername == null ? null : String(ownerUsername).slice(0, 64), cleanName, source, streamId == null ? null : Number(streamId), slot == null ? null : String(slot), cleanNotes, clock()],
+ );
+ return { ok: true, camera: wireCamera(row) };
+ } catch (err) {
+ if (err && err.code === '23505') return refusal(409, 'cam.camera_exists', 'That Live stream is already paired.');
+ throw err;
+ }
+ }
+
+ async function patchCamera(id, owner, body) {
+ const row = await cameraById(id);
+ if (!row || row.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
+ const b = body && typeof body === 'object' ? body : {};
+ const allowed = ['name', 'source', 'slot', 'notes', 'status'];
+ const unknown = Object.keys(b).filter((k) => !allowed.includes(k));
+ if (unknown.length) return refusal(400, 'cam.invalid', `Unknown field: ${unknown[0]}`, unknown[0]);
+ const sets = [];
+ const args = [id];
+ if (b.name !== undefined) {
+ const name = cleanLine(b.name, NAME_MAX + 1);
+ if (!name || name.length > NAME_MAX || controlChars(b.name)) return refusal(400, 'cam.invalid', `name must be 1 to ${NAME_MAX} characters`, 'name');
+ args.push(name); sets.push(`name = $${args.length}`);
+ }
+ if (b.source !== undefined) {
+ if (!SOURCES.includes(b.source)) return refusal(400, 'cam.invalid', `source must be one of ${SOURCES.join(', ')}`, 'source');
+ args.push(b.source); sets.push(`source = $${args.length}`);
+ }
+ if (b.status !== undefined) {
+ if (!STATUSES.includes(b.status)) return refusal(400, 'cam.invalid', `status must be one of ${STATUSES.join(', ')}`, 'status');
+ args.push(b.status); sets.push(`status = $${args.length}`);
+ }
+ if (b.slot !== undefined) {
+ if (b.slot != null && b.slot !== '' && !SLOT_RE.test(String(b.slot))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');
+ args.push(b.slot == null || b.slot === '' ? null : String(b.slot)); sets.push(`slot = $${args.length}`);
+ }
+ if (b.notes !== undefined) {
+ const notes = b.notes == null || b.notes === '' ? null : cleanLine(b.notes, NOTES_MAX + 1);
+ if (notes && (notes.length > NOTES_MAX || controlChars(b.notes))) return refusal(400, 'cam.invalid', `Notes are at most ${NOTES_MAX} characters`, 'notes');
+ args.push(notes); sets.push(`notes = $${args.length}`);
+ }
+ if (!sets.length) return refusal(400, 'cam.invalid', 'Send a name, source, status, slot or notes', null);
+ args.push(clock());
+ const updated = await db.maybe(`UPDATE cam_cameras SET ${sets.join(', ')}, updated_at = $${args.length}::timestamptz WHERE id = $1 RETURNING *`, args);
+ return { ok: true, camera: wireCamera(updated) };
+ }
+
+ async function removeCamera(id, owner) {
+ const row = await cameraById(id);
+ if (!row || row.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
+ await db.exec('DELETE FROM cam_cameras WHERE id = $1', [row.id]);
+ return { ok: true, id: row.id };
+ }
+
+ /** A new grant, or a longer one when this viewer already has an active grant. */
+ async function grantCamera(cameraId, { owner, viewerSubject, viewerUsername = null, days = null }) {
+ const camera = await cameraById(cameraId);
+ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
+ const wanted = toSubject(viewerSubject);
+ if (!wanted) return refusal(400, 'cam.invalid', 'viewer_subject must be a person\'s canonical subject (usr_…)', 'viewer_subject');
+ if (wanted === owner) return refusal(400, 'cam.invalid', 'A camera cannot be granted to its owner.', 'viewer_subject');
+ const n = days == null || days === '' ? config.cameras.grantDays : Number(days);
+ if (!Number.isInteger(n) || n < 1 || n > config.cameras.grantMaxDays) return refusal(400, 'cam.invalid', `days must be a whole number from 1 to ${config.cameras.grantMaxDays}`, 'days');
+
+ const activeCount = Number(await db.value(
+ 'SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1 AND revoked_at IS NULL AND expires_at > $2::timestamptz',
+ [camera.id, clock()],
+ ));
+ const existing = await db.maybe(
+ 'SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL ORDER BY granted_at DESC LIMIT 1',
+ [camera.id, wanted],
+ );
+ if (!existing && activeCount >= config.cameras.grantsPerCamera) {
+ return refusal(409, 'cam.grant_limit', `A camera can have ${config.cameras.grantsPerCamera} viewers at once.`);
+ }
+ const expiresAt = daysFromNow(n);
+ const username = viewerUsername == null ? null : cleanLine(viewerUsername, 64) || null;
+ const wasActive = Boolean(existing && Date.parse(existing.expires_at) > s.now());
+ let row;
+ try {
+ row = existing
+ ? await db.maybe('UPDATE cam_grants SET expires_at = $2::timestamptz, viewer_username = COALESCE($3, viewer_username) WHERE id = $1 RETURNING *', [existing.id, expiresAt, username])
+ : await db.maybe(
+ `INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, viewer_username, granted_at, expires_at, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6::timestamptz, $7::timestamptz, $6::timestamptz) RETURNING *`,
+ [s.newId('cmg'), camera.id, owner, wanted, username, clock(), expiresAt],
+ );
+ } catch (err) {
+ // Two tabs granting the same viewer at once: the partial unique index picks one; the caller sees the grant.
+ if (err && err.code === '23505') {
+ const found = await db.maybe('SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL', [camera.id, wanted]);
+ if (found) return { ok: true, grant: wireGrant(found, { now: s.now() }), changed: false, days: n };
+ }
+ throw err;
+ }
+ return { ok: true, grant: wireGrant(row, { now: s.now() }), changed: !wasActive, days: n };
+ }
+
+ async function grantsOf(cameraId, owner) {
+ const camera = await cameraById(cameraId);
+ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
+ const rows = await db.many('SELECT * FROM cam_grants WHERE camera_id = $1 ORDER BY granted_at DESC, id DESC', [camera.id]);
+ return { ok: true, grants: rows.map((r) => wireGrant(r, { now: s.now() })) };
+ }
+
+ async function revokeGrant(cameraId, grantId, { owner }) {
+ const camera = await cameraById(cameraId);
+ if (!camera || camera.owner !== owner) return refusal(404, 'cam.not_found', 'No such camera.');
+ const row = await db.maybe('SELECT * FROM cam_grants WHERE id = $1 AND camera_id = $2', [String(grantId || ''), camera.id]);
+ if (!row) return refusal(404, 'cam.grant_not_found', 'No such viewer grant.');
+ if (row.revoked_at != null) return { ok: true, revoked: false, already: true, grant: wireGrant(row, { now: s.now() }) };
+ const updated = await db.maybe('UPDATE cam_grants SET revoked_at = $2::timestamptz, revoked_by = $3 WHERE id = $1 RETURNING *', [row.id, clock(), owner]);
+ return { ok: true, revoked: true, already: false, grant: wireGrant(updated, { now: s.now() }) };
+ }
+
+ async function activeGrant(cameraId, subject) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return null;
+ return await db.maybe(
+ 'SELECT * FROM cam_grants WHERE camera_id = $1 AND viewer_subject = $2 AND revoked_at IS NULL AND expires_at > $3::timestamptz ORDER BY granted_at DESC LIMIT 1',
+ [String(cameraId), subject, clock()],
+ );
+ }
+
+ /** Owner or an active viewer; null means "not found" to the caller, never "forbidden". */
+ async function accessTo(cameraId, subject) {
+ const camera = await cameraById(cameraId);
+ if (!camera) return null;
+ if (camera.owner === subject) return { camera, owner: true, grant: null };
+ const grant = await activeGrant(camera.id, subject);
+ return grant ? { camera, owner: false, grant: wireGrant(grant, { now: s.now() }) } : null;
+ }
+
+ async function wall(cameraId, subject) {
+ const access = await accessTo(cameraId, subject);
+ if (!access) return null;
+ const rows = await db.many(
+ `SELECT * FROM cam_photos WHERE camera_id = $1 ${access.owner ? '' : "AND visibility = 'unlisted'"} ORDER BY created_at DESC, id DESC LIMIT 60`,
+ [access.camera.id],
+ );
+ return { ...access, photos: rows.map((r) => wirePhoto(r, { owner: access.owner })) };
+ }
+
+ /** A photo row after its bytes have gone to Pics. A photo on a camera wall is unlisted; otherwise it is private. */
+ async function createPhoto({ subject, cameraId = null, filter = 'none', caption: rawCaption = null, pics }) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(400, 'cam.invalid', 'A photo belongs to a signed-in person', 'subject');
+ let camera = null;
+ if (cameraId != null && cameraId !== '') {
+ camera = await cameraById(cameraId);
+ if (!camera || camera.owner !== subject) return refusal(404, 'cam.not_found', 'No such camera of yours.');
+ }
+ const cleanFilter = FILTERS.includes(filter) ? filter : 'none';
+ const cap = caption(rawCaption);
+ if (!cap.ok) return cap;
+ if (!pics || !pics.slug || !pics.page) return refusal(400, 'cam.invalid', 'The picture was not stored', 'pics');
+ const row = await db.maybe(
+ `INSERT INTO cam_photos (id, subject, camera_id, filter, caption, visibility, pics_slug, pics_url, pics_direct, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz) RETURNING *`,
+ [s.newId('cmp'), subject, camera ? camera.id : null, cleanFilter, cap.value, camera ? 'unlisted' : 'private', pics.slug, pics.page, pics.direct || pics.page, clock()],
+ );
+ return { ok: true, photo: wirePhoto(row, { owner: true }) };
+ }
+
+ async function photoById(id) {
+ return wirePhoto(await db.maybe('SELECT * FROM cam_photos WHERE id = $1', [String(id || '')]), { owner: true });
+ }
+
+ async function photosOf(subject, { limit = 60 } = {}) {
+ const rows = await db.many('SELECT * FROM cam_photos WHERE subject = $1 ORDER BY created_at DESC, id DESC LIMIT $2',
+ [subject, Math.min(200, Math.max(1, Number(limit) || 60))]);
+ return rows.map((r) => wirePhoto(r, { owner: true }));
+ }
+
+ /** A still's bytes also went to Pics; a still is unlisted by default so the link in chat opens it. */
+ async function createStill({ subject, streamId = null, slot, caption: rawCaption = null, visibility = 'unlisted', pics }) {
+ if (!SUBJECT_RE.test(String(subject || ''))) return refusal(400, 'cam.invalid', 'A still belongs to a signed-in person', 'subject');
+ if (!SLOT_RE.test(String(slot || ''))) return refusal(400, 'cam.invalid', 'slot must be a Live slot id, slug or @username', 'slot');
+ if (streamId != null && (!Number.isInteger(Number(streamId)) || Number(streamId) <= 0)) return refusal(400, 'cam.invalid', 'stream_id must be a positive whole number', 'stream_id');
+ if (!STILL_VISIBILITIES.includes(visibility)) return refusal(400, 'cam.invalid', `visibility must be one of ${STILL_VISIBILITIES.join(', ')}`, 'visibility');
+ const cap = caption(rawCaption);
+ if (!cap.ok) return cap;
+ if (!pics || !pics.slug || !pics.page) return refusal(400, 'cam.invalid', 'The picture was not stored', 'pics');
+ const row = await db.maybe(
+ `INSERT INTO cam_stills (id, subject, stream_id, slot, caption, visibility, pics_slug, pics_url, pics_direct, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz) RETURNING *`,
+ [s.newId('cms'), subject, streamId == null ? null : Number(streamId), String(slot), cap.value, visibility, pics.slug, pics.page, pics.direct || pics.page, clock()],
+ );
+ return { ok: true, still: wireStill(row, { owner: true }) };
+ }
+
+ async function stillById(id) {
+ return wireStill(await db.maybe('SELECT * FROM cam_stills WHERE id = $1', [String(id || '')]), { owner: true });
+ }
+
+ async function stillsOf(subject, { limit = 60 } = {}) {
+ const rows = await db.many('SELECT * FROM cam_stills WHERE subject = $1 ORDER BY created_at DESC, id DESC LIMIT $2',
+ [subject, Math.min(200, Math.max(1, Number(limit) || 60))]);
+ return rows.map((r) => wireStill(r, { owner: true }));
+ }
+
+ async function countsFor(subject) {
+ const row = await db.maybe(
+ `SELECT
+ (SELECT COUNT(*)::int FROM cam_cameras WHERE owner = $1) AS cameras,
+ (SELECT COUNT(*)::int FROM cam_photos WHERE subject = $1) AS photos,
+ (SELECT COUNT(*)::int FROM cam_stills WHERE subject = $1) AS stills`,
+ [subject],
+ );
+ return { cameras: Number(row.cameras) || 0, photos: Number(row.photos) || 0, stills: Number(row.stills) || 0 };
+ }
+
+ return {
+ wireCamera, wireGrant, wirePhoto, wireStill,
+ cameraById, camerasOf, createCamera, patchCamera, removeCamera,
+ grantCamera, grantsOf, revokeGrant, activeGrant, accessTo, wall,
+ createPhoto, photoById, photosOf,
+ createStill, stillById, stillsOf,
+ countsFor,
+ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, CAPTION_MAX, NAME_MAX, SLOT_RE, SUBJECT_RE, BLOCKED_WORDS,
+ };
+}
+
+module.exports = {
+ createStore, caption, toSubject, wireCamera, wireGrant, wirePhoto, wireStill,
+ SOURCES, STATUSES, FILTERS, STILL_VISIBILITIES, PHOTO_VISIBILITIES, BLOCKED_WORDS, SLOT_RE, SUBJECT_RE,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+
+/**
+ * The existing APIs Cam uses. No picture bytes are stored here and no service grant is needed to read a frame:
+ *
+ * OpenVibe.Media GET /live/:sel/frame.jpg?w=640 the public, CORS-open near-realtime JPEG of a live slot
+ * OpenVibe.Live GET /api/streams/:id the public stream row and its channel (to check ownership)
+ * OpenVibe.Pics POST /api/v1/images with the signed-in person's own Network token as the body
+ * OpenVibe.Network GET /internal/identity/resolve with Cam's service token (identity.subject.resolve) only when
+ * the owner shares a camera with @username
+ *
+ * A 404 from Live means no such stream. Media answers an offline card (SVG) with a 404 for a slot that is not live:
+ * that is never uploaded to Pics. A failed call is not cached and no token or body is logged.
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const USERNAME_RE = /^[A-Za-z0-9_.-]{1,40}$/;
+
+class UpstreamError extends Error {
+ constructor(code, detail, { status = 502, cause = null } = {}) { super(detail); this.code = code; this.status = status; this.cause = cause; }
+}
+
+function createUpstreams({ config, fetchImpl = globalThis.fetch, log = console }) {
+ const { media, live, pics, timeoutMs } = config.upstreams;
+ const base = (u) => String(u).replace(/\/+$/, '');
+ const ua = () => `OpenVibe.Cam/0.1 (+${config.baseUrl})`;
+
+ async function call(url, { method = 'GET', headers = {}, body = null, timeout = timeoutMs, redirect = 'follow' } = {}) {
+ try {
+ return await fetchImpl(url, {
+ method, redirect, headers: { Accept: 'application/json', 'User-Agent': ua(), ...headers }, body,
+ signal: AbortSignal.timeout(timeout),
+ });
+ } catch (err) {
+ throw new UpstreamError('cam.upstream.unreachable', 'the upstream service did not answer', { cause: err });
+ }
+ }
+
+ /** The public Live stream row, or null when it does not exist. */
+ async function liveStream(id) {
+ const n = Number(id);
+ if (!Number.isInteger(n) || n <= 0) throw new UpstreamError('cam.live.invalid', 'stream_id must be a positive whole number', { status: 400 });
+ const res = await call(`${base(live)}/api/streams/${n}`);
+ if (res.status === 404) return null;
+ if (!res.ok) throw new UpstreamError('cam.live.unavailable', `OpenVibe.Live answered ${res.status}`);
+ const data = await res.json().catch(() => null);
+ if (!data || !data.stream) throw new UpstreamError('cam.live.unavailable', 'OpenVibe.Live answered no stream');
+ return data.stream;
+ }
+
+ /** Media's live frame. Only image/jpeg is returned; an offline card or an HTML error is never a picture. */
+ async function liveFrame({ slot, w, maxBytes }) {
+ const sel = String(slot || '');
+ const q = new URLSearchParams();
+ if (w != null) q.set('w', String(w));
+ const url = `${base(media)}/live/${encodeURIComponent(sel)}/frame.jpg${q.toString() ? `?${q.toString()}` : ''}`;
+ let res;
+ try {
+ res = await call(url, { headers: { Accept: 'image/jpeg' } });
+ } catch (err) {
+ throw new UpstreamError('cam.frame_unavailable', 'OpenVibe.Media did not answer', { cause: err });
+ }
+ if (res.status === 404 || res.status === 410) return { ok: false, code: 'offline', status: res.status };
+ if (res.status === 429) return { ok: false, code: 'busy', status: 429 };
+ if (!res.ok) return { ok: false, code: 'status', status: res.status };
+ const type = String(res.headers.get('content-type') || '').split(';')[0].trim().toLowerCase();
+ if (type !== 'image/jpeg') return { ok: false, code: 'not_jpeg', status: res.status, type };
+ const buf = Buffer.from(await res.arrayBuffer());
+ if (!buf.length) return { ok: false, code: 'empty', status: res.status };
+ if (buf.length > maxBytes) return { ok: false, code: 'too_large', status: res.status, bytes: buf.length };
+ return { ok: true, bytes: buf, contentType: 'image/jpeg', bytes_length: buf.length };
+ }
+
+ /**
+ * Upload a picture to OpenVibe.Pics as the person. `token` is the person's Network token from their Cam session;
+ * it is sent only here and is never stored. Pics re-encodes the picture and strips its metadata.
+ */
+ async function picsUpload({ token, bytes, contentType, title = null, filename = null }) {
+ if (!token) throw new UpstreamError('cam.pics.refused', 'a person\'s Network token is needed to save a picture', { status: 401 });
+ const headers = { 'Content-Type': String(contentType || 'image/jpeg'), Accept: 'application/json', Authorization: `Bearer ${token}` };
+ if (title) headers['X-Title'] = encodeURIComponent(String(title).slice(0, 200));
+ if (filename) headers['X-Filename'] = encodeURIComponent(String(filename).slice(0, 200));
+ let res;
+ try {
+ res = await call(`${base(pics)}/api/v1/images`, { method: 'POST', headers, body: bytes, timeout: Math.max(timeoutMs, 15_000) });
+ } catch (err) {
+ throw new UpstreamError('cam.pics.unavailable', 'OpenVibe.Pics did not answer', { cause: err });
+ }
+ const data = await res.json().catch(() => null);
+ if (!res.ok || !data || !data.slug) {
+ const code = res.status === 401 || res.status === 403 ? 'cam.pics.refused'
+ : res.status === 413 ? 'cam.pics.too_large'
+ : res.status === 415 ? 'cam.pics.not_an_image' : 'cam.pics.unavailable';
+ throw new UpstreamError(code, (data && (data.detail || data.code)) ? String(data.detail || data.code).slice(0, 200) : `OpenVibe.Pics answered ${res.status}`, { status: res.status >= 400 && res.status < 500 ? res.status : 502 });
+ }
+ const links = data.links && typeof data.links === 'object' ? data.links : {};
+ return {
+ slug: String(data.slug),
+ page: String(links.page || `${base(pics)}/i/${data.slug}`),
+ direct: String(links.direct || `${base(pics)}/i/${data.slug}.${data.ext || 'jpg'}`),
+ thumb: links.thumb ? String(links.thumb) : null,
+ mime_type: data.mime_type || contentType,
+ width: Number(data.width) || null,
+ height: Number(data.height) || null,
+ };
+ }
+
+ const tokens = config.oauth.clientSecret
+ ? createServiceTokenClient({
+ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
+ audience: 'openvibe.network', scope: 'identity.subject.resolve', fetch: fetchImpl,
+ })
+ : null;
+
+ /** Resolve @username to a canonical subject with Cam's service token. Disabled without the OAuth client secret. */
+ async function resolveUsername(username) {
+ const name = String(username || '').trim().replace(/^@/, '');
+ if (!USERNAME_RE.test(name)) return { ok: false, code: 'invalid' };
+ if (!tokens) return { ok: false, code: 'unavailable' };
+ let token;
+ try { token = await tokens.getToken(); } catch (err) {
+ log.warn(`[OpenVibe.Cam] service token for identity.subject.resolve failed: ${(err && err.message) || err}`);
+ return { ok: false, code: 'unavailable' };
+ }
+ let res;
+ try {
+ res = await call(`${config.networkInternalUrl}/internal/identity/resolve?username=${encodeURIComponent(name)}`, {
+ headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' }, timeout: timeoutMs,
+ });
+ } catch {
+ return { ok: false, code: 'unavailable' };
+ }
+ if (res.status === 404) return { ok: false, code: 'not_found' };
+ if (res.status === 401 || res.status === 403) return { ok: false, code: 'refused' };
+ if (!res.ok) return { ok: false, code: 'unavailable' };
+ const data = await res.json().catch(() => null);
+ const subject = data && data.subject && data.subject.type === 'user' ? String(data.subject.id || '') : '';
+ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject) || data.banned || data.deleted) return { ok: false, code: 'not_found' };
+ return { ok: true, subject, username: data.username || name, display_name: data.display_name || data.username || name };
+ }
+
+ return { liveStream, liveFrame, picsUpload, resolveUsername, UpstreamError, base };
+}
+
+module.exports = { createUpstreams, UpstreamError, USERNAME_RE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,320 @@
@@ -0,0 +1,320 @@
+'use strict';
+
+/**
+ * The Cam room's pages (plan 2026-10-28 §5.13). They are plain semantic placeholders with stable `cm-` ids and
+ * classes, complete without JavaScript: every action is a real form or link. public/css/cam.css is a one-comment
+ * placeholder the main session replaces; public/js/cam.js adds the live preview, the timer, the burst and the
+ * on-device filter, and posts the frame to /api/v1/photos.
+ *
+ * home({ signedIn }) the hero, the three scenes, the privacy rules
+ * app({ signedIn, photos }) the camera app
+ * my({ viewer, cameras, photos, stills, counts }) pair a camera, your cameras, photos and stills
+ * camera({ camera, grants, photos, frameUrl }) one camera's owner page: edit, grants, unpair
+ * wall({ camera, access, photos, frameUrl }) the private wall an owner or a grantee sees
+ * still({ still, mine }) one still of a live stream
+ * developers() the API and the Live cam button contract
+ * signIn({ title, text, next }) a page that needs an account
+ * notFound()
+ */
+const { esc, time } = require('../render/html');
+const kit = require('openvibe-shared/kit');
+const sc = require('openvibe-shared/showcase');
+
+const SOURCE_LABEL = { browser: 'Browser', phone: 'Phone', pi: 'Pi', ip: 'IP camera' };
+const STATUS_LABEL = { paired: 'Paired', live: 'Live', offline: 'Offline' };
+const FILTER_LABEL = { none: 'None', mono: 'Mono', warm: 'Warm', cool: 'Cool', fade: 'Fade', vivid: 'Vivid', blur: 'Soft' };
+
+const sourceLabel = (s) => esc(SOURCE_LABEL[s] || s);
+const statusBadge = (s) => `<span class="cm-status cm-status-${esc(s)}">${esc(STATUS_LABEL[s] || s)}</span>`;
+const day = (iso) => esc(String(iso || '').slice(0, 10));
+
+function frameUrl(config, camera) {
+ if (!camera || !camera.slot) return null;
+ return `${String(config.upstreams.media).replace(/\/+$/, '')}/live/${encodeURIComponent(camera.slot)}/frame.jpg?w=640`;
+}
+
+/** A shutter iris opening onto a grid of frames, and a photo flying into a gallery. */
+function shutterMotif() {
+ const frames = [1, 2, 3, 4, 5, 6].map((n) => `<span class="cm-frame cm-frame-${n}"></span>`).join('');
+ return `<div class="cm-shutter" aria-hidden="true">
+<svg class="cm-iris" viewBox="0 0 200 200"><circle cx="100" cy="100" r="82" fill="none" stroke="currentColor" stroke-width="2" opacity=".45"/>
+${[0, 45, 90, 135].map((a) => `<path d="M100 30 A70 70 0 0 1 170 100" transform="rotate(${a} 100 100)" fill="none" stroke="currentColor" stroke-width="9" stroke-linecap="round" opacity=".75"/>`).join('')}
+<circle cx="100" cy="100" r="26" fill="currentColor" opacity=".9"/></svg>
+<div class="cm-frame-grid">${frames}</div>
+</div>`;
+}
+
+function photoCard(p, { owner = false } = {}) {
+ return `<li class="cm-photo" data-photo="${esc(p.id)}">
+<a class="cm-photo-link" href="${esc(p.pics_url)}" rel="noopener"><img src="${esc(p.pics_direct)}" alt="${esc(p.caption || 'A photo on OpenVibe.Cam')}" loading="lazy" width="320" height="240"></a>
+<span class="cm-photo-meta">${esc(FILTER_LABEL[p.filter] || p.filter || 'None')}${p.caption ? ` · ${esc(p.caption)}` : ''}${owner ? ` · ${day(p.created_at)}` : ''}</span>
+</li>`;
+}
+
+function stillCard(s) {
+ return `<li class="cm-still" data-still="${esc(s.id)}">
+<a class="cm-still-link" href="/s/${esc(s.id)}"><img src="${esc(s.pics_direct)}" alt="${esc(s.caption || 'A still from a live stream')}" loading="lazy" width="320" height="180"></a>
+<span class="cm-still-meta">${esc(s.slot)}${s.caption ? ` · ${esc(s.caption)}` : ''} · ${day(s.created_at)}</span>
+</li>`;
+}
+
+function cameraCard(c) {
+ return `<li class="cm-camera-card" data-camera="${esc(c.id)}">
+<a class="cm-camera-link" href="/my/cameras/${esc(c.id)}">
+<span class="cm-camera-name">${esc(c.name)}</span>
+<span class="cm-camera-meta">${sourceLabel(c.source)} · ${statusBadge(c.status)}${c.slot ? ` · <code>${esc(c.slot)}</code>` : ' · no live slot yet'}</span>
+<span class="cm-camera-grants">${Number(c.active_grants) || 0} ${Number(c.active_grants) === 1 ? 'viewer' : 'viewers'}</span>
+</a></li>`;
+}
+
+function empty(text, href = '/app', label = 'Open the camera') {
+ return `<div class="cm-empty"><p>${esc(text)}</p><a class="cm-btn cm-btn-quiet" href="${esc(href)}">${esc(label)}</a></div>`;
+}
+
+function home({ signedIn = false } = {}) {
+ const room = sc.room({
+ room: 'cam',
+ hero: {
+ title: 'Every camera has a moment',
+ lede: 'Take a photo with the filters you choose, keep a camera of your own behind a private wall, and share it with one person for a week — or snap a still from a Live stream and send it to the chat.',
+ start: { href: '/app', label: 'Open the camera' },
+ chips: [{ label: 'My cameras', href: '/my' }, { label: 'The API', href: '/developers' }],
+ proof: [{ value: '0', label: 'public cameras' }],
+ motif: shutterMotif(),
+ },
+ scenes: [
+ {
+ title: 'A still from a stream',
+ text: 'The cam button takes the frame OpenVibe.Media already serves, saves it to your OpenVibe.Pics as a still with your caption, and hands the chat line back to Live. Nothing is recorded and nothing is analysed.',
+ html: `<figure class="cm-demo-still"><img src="/img/cam-shutter.svg" alt="" width="320" height="180"><figcaption>“the exact second the robot fell over”</figcaption></figure>`,
+ action: { href: '/developers', label: 'How the cam button calls Cam' },
+ },
+ {
+ title: 'A camera behind a grant',
+ text: 'Pair a phone, a Pi or an IP camera you own. Its wall is private; you name one viewer, choose a week, and revoke it at any time. The next request is refused — there is no cached copy of a grant.',
+ html: `<div class="cm-demo-grant"><span class="cm-demo-cam">Front door</span><span class="cm-demo-arrow">→</span><span class="cm-demo-viewer">@ada</span><span class="cm-demo-days">7 days</span></div>`,
+ action: { href: '/my', label: 'Pair a camera' },
+ },
+ {
+ title: 'A photo to Pics',
+ text: 'The camera app runs the colour, light and blur on your device. The picture goes to OpenVibe.Pics; Cam keeps the link and the filter name, never the bytes.',
+ html: `<figure class="cm-demo-photo"><img src="/img/cam-shutter.svg" alt="" width="320" height="240"><figcaption>Warm · saved to Pics</figcaption></figure>`,
+ action: { href: '/app', label: 'Take a photo' },
+ },
+ ],
+ faq: {
+ items: [
+ ['Is my camera public?', 'No. A camera is private to its owner. A viewer sees its wall only while a grant you gave is active, and a revocation is checked on every read. v1 has no public directory.'],
+ ['Where are my photos and stills?', 'In OpenVibe.Pics, as your own pictures. Cam stores the Pics slug and links, the filter name and your caption — never the image bytes.'],
+ ['Does Cam recognise faces?', 'No. There is no face recognition, no identity classification and no AI analysis of a picture. Filters are colour and light, applied in your browser.'],
+ ['What does the cam button on Live do?', 'It calls Cam with your own Network token. Cam fetches the live frame from OpenVibe.Media, saves it to your Pics as a still and returns the line Live posts in the stream chat.'],
+ ],
+ },
+ });
+ const live = `<section class="cm-section" id="cm-rules" aria-labelledby="cm-rules-h">
+<div class="cm-head-row"><h2 id="cm-rules-h">The privacy rules, up front</h2>${signedIn ? '<a class="cm-more" href="/my">Your cameras' + kit.icon('chevron') + '</a>' : '<a class="cm-more" href="/auth/login?next=%2Fmy">Sign in' + kit.icon('chevron') + '</a>'}</div>
+<ul class="cm-rules">
+<li><strong>Private by default.</strong> A paired camera has no public page. A grant is the only way in.</li>
+<li><strong>A grant is a person and an expiry.</strong> Seven days by default, ninety at most. Revoking it refuses the next request.</li>
+<li><strong>No face recognition.</strong> Nothing identifies anyone; effects never leave your device.</li>
+<li><strong>Nothing public without its owner's grant.</strong> v1 has no camera directory, no timelapse and no public wall.</li>
+</ul>
+</section>`;
+ return `<div class="cm-wrap cm-home" id="cm-home">${room}${live}</div>`;
+}
+
+function app({ signedIn = false, photos = [] } = {}) {
+ const options = Object.entries(FILTER_LABEL).map(([v, label]) => `<option value="${esc(v)}">${esc(label)}</option>`).join('');
+ return `<div class="cm-wrap" id="cm-app">
+<header class="cm-page-h"><p class="cm-kicker">The camera app</p><h1>Take a photo</h1>
+<p class="cm-lede">The preview, the timer, the burst and every filter run in your browser. The picture you keep goes to OpenVibe.Pics; Cam stores the link, the filter and your caption.</p></header>
+${signedIn ? '' : `<p class="cm-note">You can try the camera now. <a href="/auth/login?next=%2Fapp">Sign in</a> to save a photo to your Pics.</p>`}
+<section class="cm-camera" id="cm-camera" data-cm-camera>
+<div class="cm-stage">
+<video data-cm-preview playsinline muted aria-label="Camera preview"></video>
+<canvas data-cm-canvas hidden></canvas>
+<div class="cm-grid-overlay" data-cm-grid hidden aria-hidden="true"></div>
+</div>
+<div class="cm-controls">
+<button class="cm-btn" type="button" data-cm-start>Turn the camera on</button>
+<button class="cm-btn cm-btn-quiet" type="button" data-cm-shoot disabled>Take the photo</button>
+<label class="cm-field"><span>Timer</span><select data-cm-timer><option value="0">Off</option><option value="3">3 seconds</option><option value="5">5 seconds</option><option value="10">10 seconds</option></select></label>
+<button class="cm-btn cm-btn-quiet" type="button" data-cm-burst>Burst of 3</button>
+<label class="cm-field"><span>Filter</span><select data-cm-filter>${options}</select></label>
+<label class="cm-field"><span>Caption</span><input data-cm-caption type="text" maxlength="280" placeholder="A line for the photo"></label>
+<button class="cm-btn cm-btn-quiet" type="button" data-cm-grid-toggle aria-pressed="false">Grid</button>
+</div>
+<p class="cm-status" data-cm-status role="status">The camera is off.</p>
+<label class="cm-field"><span>Or pick a picture</span><input data-cm-file type="file" accept="image/jpeg,image/png,image/webp"></label>
+<noscript><p class="cm-fine">The live preview needs JavaScript. You can still use the API at <code>POST /api/v1/photos</code>.</p></noscript>
+</section>
+<section class="cm-section" id="cm-gallery" aria-labelledby="cm-gallery-h">
+<div class="cm-head-row"><h2 id="cm-gallery-h">Your photos</h2>${signedIn ? `<span class="cm-count">${photos.length} recent</span>` : ''}</div>
+${signedIn ? (photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : empty('No photo yet. Take one above; it will appear here once Pics has it.', '/app', 'Take a photo')) : empty('Sign in to see the photos you saved to Pics.', '/auth/login?next=%2Fapp', 'Sign in with OpenVibe')}
+</section>
+</div>`;
+}
+
+function my({ viewer, cameras = [], photos = [], stills = [], counts = {}, problem = null } = {}) {
+ return `<div class="cm-wrap" id="cm-my">
+<header class="cm-page-h"><p class="cm-kicker">My cameras</p><h1>Your cameras, your viewers</h1>
+<p class="cm-lede">Pair a camera you own. Its wall is private; a viewer sees it only while the grant you gave is active. ${Number(counts.cameras) || cameras.length} ${Number(counts.cameras) === 1 ? 'camera' : 'cameras'}, ${Number(counts.photos) || photos.length} ${Number(counts.photos) === 1 ? 'photo' : 'photos'}, ${Number(counts.stills) || stills.length} ${Number(counts.stills) === 1 ? 'still' : 'stills'}.</p></header>
+${problem ? `<p class="cm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+
+<section class="cm-section" id="cm-pair" aria-labelledby="cm-pair-h">
+<h2 id="cm-pair-h">Pair a camera</h2>
+<form class="cm-form cm-card" method="post" action="/my/cameras">
+<label class="cm-field"><span>Name</span><input name="name" type="text" maxlength="80" required placeholder="Front door"></label>
+<label class="cm-field"><span>What is it?</span><select name="source"><option value="phone">An old phone</option><option value="browser">A browser</option><option value="pi">A Pi</option><option value="ip">An IP camera</option></select></label>
+<label class="cm-field"><span>Live stream id (optional)</span><input name="stream_id" type="number" min="1" step="1" inputmode="numeric" placeholder="1234"></label>
+<label class="cm-field"><span>Live slot, slug or @username (optional)</span><input name="slot" type="text" maxlength="80" placeholder="whip or @ada"></label>
+<label class="cm-field"><span>Note (only you see it)</span><textarea name="notes" rows="2" maxlength="500"></textarea></label>
+<button class="cm-btn" type="submit">Pair the camera</button>
+<p class="cm-fine">A stream id is checked against OpenVibe.Live and must be a stream on your own channel. The slot is what OpenVibe.Media's frame endpoint reads.</p>
+</form>
+</section>
+
+<section class="cm-section" id="cm-cameras" aria-labelledby="cm-cameras-h">
+<div class="cm-head-row"><h2 id="cm-cameras-h">Paired</h2><span class="cm-count">${cameras.length}</span></div>
+${cameras.length ? `<ul class="cm-cameras">${cameras.map(cameraCard).join('')}</ul>` : empty('No camera is paired yet. An old phone is the fastest one: pair it, then grant one viewer.', '#cm-pair', 'Pair one above')}
+</section>
+
+<section class="cm-section" id="cm-photos" aria-labelledby="cm-photos-h">
+<div class="cm-head-row"><h2 id="cm-photos-h">Your photos</h2><a class="cm-more" href="/app">Take one${kit.icon('chevron')}</a></div>
+${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">No photo yet.</p>'}
+</section>
+
+<section class="cm-section" id="cm-stills" aria-labelledby="cm-stills-h">
+<div class="cm-head-row"><h2 id="cm-stills-h">Your stills</h2><span class="cm-count">${stills.length}</span></div>
+${stills.length ? `<ul class="cm-stills">${stills.map(stillCard).join('')}</ul>` : '<p class="cm-muted">A still comes from a Live stream, through the cam button or <code>POST /api/v1/stills</code>.</p>'}
+</section>
+</div>`;
+}
+
+function camera({ camera: c, grants = [], photos = [], frameUrl: frame = null, problem = null } = {}) {
+ const active = grants.filter((g) => g.active);
+ return `<div class="cm-wrap" id="cm-camera-owner" data-camera="${esc(c.id)}">
+<header class="cm-page-h"><p class="cm-kicker">${sourceLabel(c.source)} · ${statusBadge(c.status)}</p><h1>${esc(c.name)}</h1>
+<p class="cm-lede">${c.slot ? `The wall shows <code>${esc(c.slot)}</code>'s current frame from OpenVibe.Media.` : 'No live slot yet: set one below and the wall will show its frame.'} ${active.length} active ${active.length === 1 ? 'grant' : 'grants'}.</p></header>
+${problem ? `<p class="cm-note is-bad" role="alert">${esc(problem)}</p>` : ''}
+
+<section class="cm-wall-preview" id="cm-frame">
+${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640" height="360" loading="lazy">` : '<div class="cm-empty"><p>No frame yet.</p><span class="cm-fine">A slot is needed.</span></div>'}
+</section>
+
+<section class="cm-section" id="cm-edit" aria-labelledby="cm-edit-h">
+<h2 id="cm-edit-h">The camera</h2>
+<form class="cm-form cm-card" method="post" action="/my/cameras/${esc(c.id)}">
+<label class="cm-field"><span>Name</span><input name="name" type="text" maxlength="80" value="${esc(c.name)}" required></label>
+<label class="cm-field"><span>What is it?</span><select name="source">${Object.entries(SOURCE_LABEL).map(([v, l]) => `<option value="${esc(v)}"${c.source === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
+<label class="cm-field"><span>Status</span><select name="status">${Object.entries(STATUS_LABEL).map(([v, l]) => `<option value="${esc(v)}"${c.status === v ? ' selected' : ''}>${esc(l)}</option>`).join('')}</select></label>
+<label class="cm-field"><span>Slot, slug or @username</span><input name="slot" type="text" maxlength="80" value="${esc(c.slot || '')}"></label>
+<label class="cm-field"><span>Note (only you see it)</span><textarea name="notes" rows="2" maxlength="500">${esc(c.notes || '')}</textarea></label>
+<div class="cm-actions"><button class="cm-btn" type="submit">Save the camera</button></div>
+</form>
+<form class="cm-form cm-inline" method="post" action="/my/cameras/${esc(c.id)}/delete"><button class="cm-btn cm-btn-quiet" type="submit">Unpair this camera</button></form>
+</section>
+
+<section class="cm-section" id="cm-grants" aria-labelledby="cm-grants-h">
+<h2 id="cm-grants-h">Viewers</h2>
+<form class="cm-form cm-card" method="post" action="/my/cameras/${esc(c.id)}/grants">
+<label class="cm-field"><span>Viewer's OpenVibe username</span><input name="viewer_username" type="text" maxlength="40" required placeholder="ada"></label>
+<label class="cm-field"><span>For how long?</span><select name="days"><option value="1">1 day</option><option value="7" selected>7 days</option><option value="30">30 days</option><option value="90">90 days</option></select></label>
+<button class="cm-btn" type="submit">Grant a viewer</button>
+<p class="cm-fine">The name is resolved to its canonical subject through OpenVibe.Network. A grant never becomes public; it lets that one person open the wall until it expires or you revoke it.</p>
+</form>
+${grants.length ? `<div class="cm-table-wrap"><table class="cm-grants"><thead><tr><th scope="col">Viewer</th><th scope="col">Expires</th><th scope="col">State</th><th scope="col"><span class="cm-sr">Action</span></th></tr></thead><tbody>${grants.map((g) => `<tr data-grant="${esc(g.id)}"><td>${g.viewer_username ? `@${esc(g.viewer_username)}` : `<code>${esc(g.viewer_subject)}</code>`}</td><td>${day(g.expires_at)}</td><td>${g.active ? '<span class="cm-status cm-status-live">Active</span>' : g.revoked_at ? '<span class="cm-status cm-status-offline">Revoked</span>' : '<span class="cm-status cm-status-offline">Expired</span>'}</td><td>${g.active ? `<form method="post" action="/my/cameras/${esc(c.id)}/grants/${esc(g.id)}/revoke"><button class="cm-btn cm-btn-small cm-btn-quiet" type="submit">Revoke</button></form>` : ''}</td></tr>`).join('')}</tbody></table></div>` : '<p class="cm-muted">Nobody has a grant. Share with one person when you are ready.</p>'}
+</section>
+
+<section class="cm-section" id="cm-camera-photos" aria-labelledby="cm-camera-photos-h">
+<h2 id="cm-camera-photos-h">On the wall</h2>
+${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner: true })).join('')}</ul>` : '<p class="cm-muted">A photo taken with this camera chosen appears here and on the wall for an active viewer.</p>'}
+</section>
+</div>`;
+}
+
+function wall({ camera: c, access, photos = [], frameUrl: frame = null } = {}) {
+ const owner = access && access.owner;
+ return `<div class="cm-wrap" id="cm-wall" data-camera="${esc(c.id)}">
+<header class="cm-page-h"><p class="cm-kicker">${owner ? 'Your private wall' : `Shared by @${esc(c.owner_username || 'its owner')}`}</p><h1>${esc(c.name)}</h1>
+<p class="cm-lede">${owner ? 'Only you and the viewers you granted can open this page.' : `Your grant is active until ${day(access && access.grant && access.grant.expires_at)}. The owner can revoke it at any time.`}</p></header>
+<section class="cm-wall-preview" id="cm-wall-frame">
+${frame ? `<img src="${esc(frame)}" alt="The camera's current frame" width="640" height="360">` : '<div class="cm-empty"><p>This camera has no live slot yet.</p></div>'}
+</section>
+<section class="cm-section" id="cm-wall-photos" aria-labelledby="cm-wall-photos-h">
+<h2 id="cm-wall-photos-h">Photos on this wall</h2>
+${photos.length ? `<ul class="cm-photos">${photos.map((p) => photoCard(p, { owner })).join('')}</ul>` : '<p class="cm-muted">The owner has not put a photo on this wall.</p>'}
+</section>
+</div>`;
+}
+
+function still({ still: s, mine = false } = {}) {
+ return `<div class="cm-wrap" id="cm-still" data-still="${esc(s.id)}">
+<header class="cm-page-h"><p class="cm-kicker">A still from ${esc(s.slot)}</p><h1>${esc(s.caption || 'A moment from the stream')}</h1>
+<p class="cm-lede">${mine ? 'Yours. ' : ''}Saved to OpenVibe.Pics on ${day(s.created_at)}. This is one frame, not a recording.</p></header>
+<figure class="cm-still-large"><img src="${esc(s.pics_direct)}" alt="${esc(s.caption || 'A still from a live stream')}" width="960" height="540"><figcaption>${esc(s.caption || '')}</figcaption></figure>
+<dl class="cm-facts"><div><dt>Slot</dt><dd><code>${esc(s.slot)}</code></dd></div><div><dt>Saved</dt><dd>${day(s.created_at)}</dd></div><div><dt>Picture</dt><dd><a href="${esc(s.pics_url)}" rel="noopener">OpenVibe.Pics${kit.icon('chevron')}</a></dd></div></dl>
+<div class="cm-actions"><a class="cm-btn" href="/app">Take a photo</a><a class="cm-btn cm-btn-quiet" href="/">Back to Cam</a></div>
+</div>`;
+}
+
+function signIn({ title, text, next }) {
+ return `<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty"><h1>${esc(title)}</h1><p>${esc(text)}</p><a class="cm-btn" href="/auth/login?next=${encodeURIComponent(next)}">Sign in with OpenVibe</a></div></div>`;
+}
+
+function developers() {
+ const ep = (m, p, d) => `<div><dt><code>${esc(m)} ${esc(p)}</code></dt><dd>${d}</dd></div>`;
+ return `<div class="cm-wrap" id="cm-developers">
+<header class="cm-page-h"><p class="cm-kicker">Developers</p><h1>The Cam API</h1>
+<p class="cm-lede">JSON under <code>/api/v1</code>, with <code>application/problem+json</code> errors. A person is the <code>cam_at</code> session cookie or a Network user token as a Bearer. No route needs an app or service token.</p></header>
+
+<section><h2>The camera app</h2><dl class="cm-eps">
+${ep('POST', '/api/v1/photos', 'A person; the picture is the raw body (<code>image/jpeg</code>, <code>image/png</code> or <code>image/webp</code>), with optional <code>X-Filter</code>, <code>X-Caption</code> and <code>X-Camera</code> headers (URI-encoded). Cam uploads it to OpenVibe.Pics and stores the link.')}
+${ep('GET', '/api/v1/photos', 'Your photos, newest first.')}
+${ep('GET', '/api/v1/photos/:id', 'One of your photos.')}
+</dl></section>
+
+<section><h2>My cameras and viewer grants</h2><dl class="cm-eps">
+${ep('GET', '/api/v1/cameras', 'Your paired cameras with their active grant count.')}
+${ep('POST', '/api/v1/cameras', '<code>{ name, source, stream_id?, slot?, notes? }</code>. A stream id is read from Live and must be on your channel.')}
+${ep('PATCH', '/api/v1/cameras/:id', '<code>{ name?, source?, status?, slot?, notes? }</code>; the owner only.')}
+${ep('DELETE', '/api/v1/cameras/:id', 'Unpair it; every grant on it goes. The owner only.')}
+${ep('GET', '/api/v1/cameras/:id/wall', 'The private wall: owner, or a viewer whose grant is active. A stranger gets 404, never a hint that it exists.')}
+${ep('GET', '/api/v1/cameras/:id/grants', 'The grants you gave, including expired and revoked ones. The owner only.')}
+${ep('POST', '/api/v1/cameras/:id/grants', '<code>{ viewer_username }</code> or <code>{ viewer_subject }</code>, and <code>{ days }</code> (1–90, default 7). An existing active grant is extended, never duplicated.')}
+${ep('DELETE', '/api/v1/cameras/:id/grants/:grantId', 'Revoke at once: the next wall request is refused.')}
+</dl></section>
+
+<section id="cm-live-button"><h2>The Live cam button</h2>
+<p class="cm-fine">Live calls Cam with the viewer's own Network token — the token in the viewer's Live session — because the picture is saved to that person's OpenVibe.Pics. Cam needs no service token from Live.</p>
+<ol class="cm-steps">
+<li>Live receives <code>{ slot, stream_id, caption }</code> from its watch page and calls <code>POST https://openvibe.cam/api/v1/stills</code> with <code>Authorization: Bearer <viewer's Network token></code>.</li>
+<li>Cam reads <code>GET https://openvibe.media/live/:slot/frame.jpg?w=640</code>. A 404 means the slot is offline; SVG offline cards and HTML errors are never uploaded.</li>
+<li>Cam calls <code>POST https://openvibe.pics/api/v1/images</code> with the viewer's token, the JPEG as the body, <code>X-Title</code> and <code>X-Filename</code>.</li>
+<li>Cam answers <code>201 { still, share: { url, caption, text } }</code>. Live posts <code>share.text</code> to the stream's chat with its existing chat authority and shows the still.</li>
+</ol>
+<pre class="cm-code"><code>POST /api/v1/stills
+{ "slot": "whip", "stream_id": 1234, "caption": "the exact second the robot fell over" }</code></pre>
+<p class="cm-fine">A still is <code>unlisted</code> by default: anyone with the link opens <code>/s/<id></code>; it is not listed anywhere. Pass <code>"visibility": "private"</code> to keep it to yourself. Media's frame endpoint returns at most one frame every five seconds per slot and is rate-limited; a busy frame is <code>429 cam.frame_busy</code>.</p>
+</section>
+
+<section><h2>Stills</h2><dl class="cm-eps">
+${ep('POST', '/api/v1/stills', '<code>{ slot, caption?, stream_id?, w?, visibility? }</code>. A person; fetches Media\'s frame and uploads it to Pics as them.')}
+${ep('GET', '/api/v1/stills', 'Your stills, newest first.')}
+${ep('GET', '/api/v1/stills/:id', 'One still; an unlisted one is readable by anyone with the link, a private one only by its owner.')}
+</dl></section>
+
+<section id="cm-privacy"><h2>Privacy</h2>
+<p class="cm-fine">A camera is private. Staff have no bypass; a global moderator without a grant is refused like anyone else. Cam stores no image bytes, no face template and no identity classification. It stores a camera's name and slot, a grant's two subjects and expiry, and a picture's Pics slug, links, filter name and caption. Account export and deletion cover cameras, grants, photos and stills; the picture itself is erased by OpenVibe.Pics on the same Network event.</p></section>
+</div>`;
+}
+
+function notFound() {
+ return `<div class="cm-wrap cm-page-pad cm-narrow"><div class="cm-empty" id="cm-not-found"><p class="cm-kicker">404</p><h1>This camera is not here</h1><p>It may have been unpaired, your grant may have expired or been revoked, or the address was never a camera. A private wall answers the same whether it exists or not.</p><a class="cm-btn" href="/">Back to Cam</a></div></div>`;
+}
+
+module.exports = {
+ home, app, my, camera, wall, still, signIn, developers, notFound,
+ frameUrl, sourceLabel, statusBadge, photoCard, stillCard, cameraCard,
+ SOURCE_LABEL, STATUS_LABEL, FILTER_LABEL,
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,130 @@
@@ -0,0 +1,130 @@
+'use strict';
+/**
+ * ADR-033: Cam's part of an account export (cameras, grants given and received, photos, stills) and of a deletion,
+ * applied through /internal/events with a stand-in Network. Deleting a camera's owner removes the camera and its
+ * grants; deleting a viewer removes the grants naming them and leaves the owner's camera. Another person's rows stay.
+ * A redelivery changes nothing; a bad signature and a proxied request are refused.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { createNetworkSender } = require('openvibe-sdk/account-data');
+const { signDeliveryHeaders } = require('openvibe-sdk/events');
+
+const A = ids.newId('user');
+const B = ids.newId('user');
+const EXP = `exp_${ids.ulid()}`;
+const DEL = `del_${ids.ulid()}`;
+const SECRET = `fixture_${'account'.repeat(6)}`;
+const WRONG = `fixture_${'mismatch'.repeat(6)}`;
+
+const exportEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.export_requested', source: 'network', payload: { export_id: EXP, subject: A } };
+const deleteEvent = { event_id: `evt_${ids.ulid()}`, event_type: 'network.account.deleted', source: 'network', payload: { deletion_id: DEL, subject: A, aliases: [] } };
+const bodyOf = (ev) => JSON.stringify({ event: ev, seq: 1 });
+
+async function startNetworkStub() {
+ const calls = [];
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
+ if (req.url === '/oauth/token') return json(200, { access_token: 'tok_cam', 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: 'cam', clientSecret: 'cam-secret' });
+ const t = await boot({ env: { CAM_EVENTS_SECRET: SECRET }, accountSend: sender });
+ try {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const camA = `cam_${ids.ulid()}`;
+ const camB = `cam_${ids.ulid()}`;
+ await db.exec(`INSERT INTO cam_cameras (id, owner, owner_username, name, paired_at, created_at, updated_at) VALUES
+ ($1, $2, 'a', 'A camera', $4, $4, $4), ($3, $5, 'b', 'B camera', $4, $4, $4)`, [camA, A, camB, at, B]);
+ const grantAB = `cmg_${ids.ulid()}`; // A gave B
+ const grantBA = `cmg_${ids.ulid()}`; // B gave A
+ await db.exec(`INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, granted_at, expires_at, created_at) VALUES
+ ($1, $2, $3, $5, $7, $8, $7), ($4, $6, $5, $3, $7, $8, $7)`, [grantAB, camA, A, grantBA, B, camB, at, new Date(Date.now() + 86400000).toISOString()]);
+ const photoA = `cmp_${ids.ulid()}`;
+ const photoB = `cmp_${ids.ulid()}`;
+ await db.exec(`INSERT INTO cam_photos (id, subject, filter, visibility, pics_slug, pics_url, pics_direct, created_at) VALUES
+ ($1, $2, 'warm', 'private', 'picA1234', 'https://openvibe.pics/i/picA1234', 'https://openvibe.pics/i/picA1234.jpg', $4),
+ ($3, $5, 'mono', 'private', 'picB1234', 'https://openvibe.pics/i/picB1234', 'https://openvibe.pics/i/picB1234.jpg', $4)`, [photoA, A, photoB, at, B]);
+ const stillA = `cms_${ids.ulid()}`;
+ const stillB = `cms_${ids.ulid()}`;
+ await db.exec(`INSERT INTO cam_stills (id, subject, slot, visibility, pics_slug, pics_url, pics_direct, created_at) VALUES
+ ($1, $2, 'whip', 'unlisted', 'picC1234', 'https://openvibe.pics/i/picC1234', 'https://openvibe.pics/i/picC1234.jpg', $4),
+ ($3, $5, 'whip', 'unlisted', 'picD1234', 'https://openvibe.pics/i/picD1234', 'https://openvibe.pics/i/picD1234.jpg', $4)`, [stillA, A, stillB, at, B]);
+
+ await check('an export carries only A\'s cameras, grants, photos and stills', 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_cam');
+ assert.strictEqual(part.body.subject, A);
+ assert.deepStrictEqual(part.body.files.map((f) => f.name).sort(), ['cameras.json', 'grants-given.json', 'grants-received.json', 'photos.json', 'stills.json']);
+ const all = JSON.stringify(part.body);
+ assert.ok(all.includes('A camera'));
+ assert.ok(!all.includes('B camera'), 'nobody else\'s camera');
+ assert.ok(all.includes('picA1234'));
+ assert.ok(!all.includes('picB1234'), 'nobody else\'s photo');
+ assert.ok(all.includes('picC1234'));
+ assert.ok(!all.includes('picD1234'), 'nobody else\'s still');
+ const cameras = part.body.files.find((f) => f.name === 'cameras.json').content;
+ assert.deepStrictEqual(cameras.map((c) => c.id), [camA]);
+ const given = part.body.files.find((f) => f.name === 'grants-given.json').content;
+ assert.deepStrictEqual(given.map((g) => g.id), [grantAB]);
+ const received = part.body.files.find((f) => f.name === 'grants-received.json').content;
+ assert.deepStrictEqual(received.map((g) => g.id), [grantBA]);
+ });
+
+ await check('a deletion erases A\'s rows and confirms with counts; B and B\'s camera stay', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().outcome, 'erased');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_stills WHERE subject = $1', [A])), 0);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_stills WHERE subject = $1', [B])), 1);
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_grants')), 0, 'both grants named A');
+ 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.cam_cameras >= 1);
+ assert.ok(confirmation.body.erased.cam_photos >= 1);
+ assert.ok(confirmation.body.erased.cam_stills >= 1);
+ assert.ok(!Number.isNaN(Date.parse(confirmation.body.completed_at)));
+ });
+
+ await check('a redelivery changes nothing', async () => {
+ const res = await t.get('/internal/events', { method: 'POST', body: bodyOf(deleteEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(deleteEvent), SECRET) } });
+ assert.strictEqual(res.status, 200);
+ assert.strictEqual(res.json().outcome, 'unchanged');
+ assert.strictEqual(Number(await db.value('SELECT COUNT(*)::int AS n FROM cam_photos WHERE subject = $1', [A])), 0);
+ });
+
+ await check('the internal route refuses a bad signature (401) and a forwarded request (403)', async () => {
+ const bad = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', ...signDeliveryHeaders(bodyOf(exportEvent), WRONG) } });
+ assert.strictEqual(bad.status, 401);
+ const forwarded = await t.get('/internal/events', { method: 'POST', body: bodyOf(exportEvent), headers: { 'Content-Type': 'application/json', 'X-Forwarded-For': '203.0.113.9', ...signDeliveryHeaders(bodyOf(exportEvent), SECRET) } });
+ assert.strictEqual(forwarded.status, 403);
+ });
+ } finally {
+ await t.close();
+ await stub.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,101 @@
@@ -0,0 +1,101 @@
+'use strict';
+/**
+ * Pairing: a camera is a person's, a Live stream id is read from Live and must be on the caller's channel, the
+ * slot defaults to the stream's managed slot, and only the owner can read, change or unpair it.
+ */
+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');
+ try {
+ let camera = null;
+
+ await check('a camera pairs without a stream, private and owned by the caller', async () => {
+ const r = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', source: 'phone' } });
+ assert.strictEqual(r.status, 201, r.text);
+ camera = r.json();
+ assert.match(camera.id, /^cam_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(camera.owner_username, 'alice');
+ assert.strictEqual(camera.source, 'phone');
+ assert.strictEqual(camera.status, 'paired');
+ assert.strictEqual(camera.slot, null);
+ assert.strictEqual(r.headers.get('location'), `/api/v1/cameras/${camera.id}`);
+ });
+
+ await check('a Live stream id is read from Live and its managed slot becomes the camera slot', async () => {
+ const r = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Desk cam', source: 'browser', stream_id: 1234 } });
+ assert.strictEqual(r.status, 201, r.text);
+ const c = r.json();
+ assert.strictEqual(c.stream_id, 1234);
+ assert.strictEqual(c.slot, 'whip');
+ assert.match(c.frame_url, /\/live\/whip\/frame\.jpg\?w=640$/);
+ assert.ok(t.upstreams.requestsFor('/api/streams/1234').length >= 1);
+ });
+
+ await check('somebody else\'s stream is refused; an unknown stream is 404', async () => {
+ const other = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Not mine', stream_id: 2222 } });
+ assert.strictEqual(other.status, 403);
+ assert.strictEqual(other.json().code, 'cam.not_your_stream');
+ const missing = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Nowhere', stream_id: 9999 } });
+ assert.strictEqual(missing.status, 404);
+ assert.strictEqual(missing.json().code, 'cam.stream_not_found');
+ });
+
+ await check('a name, a source and a slot are validated; a repeated stream is a 409', async () => {
+ const noName = await t.get('/api/v1/cameras', { as: alice, json: { source: 'phone' } });
+ assert.strictEqual(noName.status, 400);
+ const badSource = await t.get('/api/v1/cameras', { as: alice, json: { name: 'X', source: 'webcam' } });
+ assert.strictEqual(badSource.status, 400);
+ const badSlot = await t.get('/api/v1/cameras', { as: alice, json: { name: 'X', slot: 'not a slot!' } });
+ assert.strictEqual(badSlot.status, 400);
+ const again = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Desk again', stream_id: 1234 } });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(again.json().code, 'cam.camera_exists');
+ });
+
+ await check('the list is the caller\'s own; another person sees none', async () => {
+ const mine = await t.get('/api/v1/cameras', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json().count, 2);
+ assert.ok(mine.json().cameras.every((c) => c.owner_username === 'alice'));
+ const theirs = await t.get('/api/v1/cameras', { as: bob });
+ assert.strictEqual(theirs.json().count, 0);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: bob })).status, 404);
+ });
+
+ await check('the owner can rename, set the slot and the status; another person cannot', async () => {
+ const r = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'PATCH', json: { name: 'Porch', status: 'offline', slot: '@alice' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().name, 'Porch');
+ assert.strictEqual(r.json().status, 'offline');
+ assert.strictEqual(r.json().slot, '@alice');
+ const no = await t.get(`/api/v1/cameras/${camera.id}`, { as: bob, method: 'PATCH', json: { name: 'Stolen' } });
+ assert.strictEqual(no.status, 404);
+ const unknown = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'PATCH', json: { owner: bob.subject } });
+ assert.strictEqual(unknown.status, 400);
+ });
+
+ await check('anonymous, cross-site and app-token writes are refused', async () => {
+ const anon = await t.get('/api/v1/cameras', { json: { name: 'Anon' } });
+ assert.strictEqual(anon.status, 401);
+ assert.strictEqual(anon.json().code, 'cam.sign_in_required');
+ const cross = await t.get('/api/v1/cameras', { as: alice, json: { name: 'Cross' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'cam.cross_site');
+ });
+
+ await check('unpairing removes it and its wall; the owner only', async () => {
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: bob, method: 'DELETE' })).status, 404);
+ const r = await t.get(`/api/v1/cameras/${camera.id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(r.status, 204);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}`, { as: alice })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: alice })).status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,112 @@
@@ -0,0 +1,112 @@
+'use strict';
+/**
+ * Viewer grants: one person, one expiry, one revocation. A wall opens for the owner and an active grantee only; a
+ * stranger and even a global moderator without a grant get the same 404. A revoked or expired grant is refused on
+ * the next read, with no cached copy in between. A grant by @username is resolved through Network; a subject works
+ * without Network. Notes on the camera are the owner's and never appear on a grantee's wall.
+ */
+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 mod = t.network.addUser('mod', { role: 'global_mod' });
+ let camera = null;
+ let firstGrant = null;
+ try {
+ await check('the owner pairs a camera and grants @bob for seven days', async () => {
+ camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip', notes: 'the gate code is not in here' } })).json();
+ const r = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' } });
+ assert.strictEqual(r.status, 201, r.text);
+ firstGrant = r.json();
+ assert.match(firstGrant.id, /^cmg_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(firstGrant.viewer_subject, bob.subject);
+ assert.strictEqual(firstGrant.viewer_username, 'bob');
+ assert.strictEqual(firstGrant.active, true);
+ const days = Math.round((Date.parse(firstGrant.expires_at) - Date.parse(firstGrant.granted_at)) / 86400000);
+ assert.strictEqual(days, 7);
+ assert.ok(t.network.tokenRequests.some((x) => x.grant_type === 'client_credentials'));
+ assert.ok(t.network.requests.some((x) => x.path === '/internal/identity/resolve'));
+ });
+
+ await check('bob sees the private wall; alice sees it as owner; carol and a moderator do not', async () => {
+ const b = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob });
+ assert.strictEqual(b.status, 200, b.text);
+ assert.strictEqual(b.json().owner, false);
+ assert.strictEqual(b.json().grant.id, firstGrant.id);
+ assert.match(b.json().frame_url, /\/live\/whip\/frame\.jpg/);
+ assert.ok(!('notes' in b.json().camera), 'the owner\'s note is not a viewer\'s');
+ const a = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: alice });
+ assert.strictEqual(a.status, 200);
+ assert.strictEqual(a.json().owner, true);
+ assert.strictEqual(a.json().camera.notes, 'the gate code is not in here');
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: mod })).status, 404, 'staff have no bypass');
+ });
+
+ await check('a second grant extends the active one instead of making a second row', async () => {
+ const r = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: '@bob', days: 30 } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().id, firstGrant.id);
+ const rows = await t.ctx.s.db.many('SELECT * FROM cam_grants WHERE camera_id = $1', [camera.id]);
+ assert.strictEqual(rows.length, 1);
+ const days = Math.round((Date.parse(rows[0].expires_at) - Date.parse(rows[0].granted_at)) / 86400000);
+ assert.strictEqual(days, 30);
+ });
+
+ await check('the owner lists the grants; nobody else can', async () => {
+ const mine = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json().count, 1);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: bob })).status, 404);
+ });
+
+ await check('revoking refuses the very next wall request', async () => {
+ const r = await t.get(`/api/v1/cameras/${camera.id}/grants/${firstGrant.id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().revoked, true);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);
+ const again = await t.get(`/api/v1/cameras/${camera.id}/grants/${firstGrant.id}`, { as: alice, method: 'DELETE' });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().revoked, false);
+ assert.strictEqual(again.json().already, true);
+ });
+
+ await check('a grant that has expired is refused, even though it was never revoked', async () => {
+ const second = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob', days: 1 } });
+ assert.strictEqual(second.status, 201);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 200);
+ await t.ctx.s.db.exec("UPDATE cam_grants SET granted_at = now() - interval '2 days', expires_at = now() - interval '1 day' WHERE id = $1", [second.json().id]);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);
+ const list = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice });
+ assert.strictEqual(list.json().grants.find((g) => g.id === second.json().id).active, false);
+ });
+
+ await check('a subject grant needs no Network; an unknown name is 404; a bad number of days is 400', async () => {
+ t.network.setResolveDown(true);
+ const bySubject = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_subject: carol.subject, days: 2 } });
+ assert.strictEqual(bySubject.status, 201, bySubject.text);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 200);
+ t.network.setResolveDown(false);
+ const unknown = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'nobody' } });
+ assert.strictEqual(unknown.status, 404);
+ assert.strictEqual(unknown.json().code, 'cam.viewer_not_found');
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob', days: 0 } })).status, 400);
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_subject: alice.subject } })).json().code, 'cam.invalid');
+ });
+
+ await check('a grant is refused anonymously, cross-site, and for somebody else\'s camera', async () => {
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { json: { viewer_username: 'bob' } })).status, 401);
+ const cross = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'cam.cross_site');
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: bob, json: { viewer_username: 'alice' } })).status, 404);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,98 @@
@@ -0,0 +1,98 @@
+'use strict';
+// Many requests per test file: the per-address limits would answer 429 part-way.
+process.env.CAM_RATE_LIMIT_PER_MIN = process.env.CAM_RATE_LIMIT_PER_MIN || '100000';
+process.env.CAM_API_RATE_LIMIT_PER_MIN = process.env.CAM_API_RATE_LIMIT_PER_MIN || '100000';
+process.env.CAM_LIMITS_MINUTE = process.env.CAM_LIMITS_MINUTE || '100000';
+process.env.CAM_LIMITS_HOUR = process.env.CAM_LIMITS_HOUR || '100000';
+/**
+ * Boots OpenVibe.Cam on a temp database with a mock Network (test/helpers/mocks.js) and Media/Live/Pics
+ * (test/helpers/upstreams.js), captures log lines, and returns a small HTTP client. Every test file gets its own.
+ *
+ * const t = await boot();
+ * t.get(path, { as: user, bearer, json, form, body, headers, method, cookie, origin })
+ * t.signIn(user) → cookie header value (cam_at = a Network user token, as after /auth/callback)
+ * t.logs(), t.dbDump()
+ */
+const http = require('http');
+const { startNetwork } = require('./mocks');
+const { startUpstreams } = require('./upstreams');
+
+const captured = [];
+for (const m of ['log', 'info', 'warn', 'error']) {
+ const orig = console[m].bind(console);
+ console[m] = (...a) => { captured.push(a.map(String).join(' ')); if (process.env.VERBOSE) orig(...a); };
+}
+
+async function boot(opts = {}) {
+ const network = await startNetwork(opts.network || {});
+ const upstreams = await startUpstreams(opts.upstreams || {});
+ const env = {
+ NODE_ENV: 'test', PORT: '0', BASE_URL: 'https://openvibe.cam', TRUST_PROXY: '1',
+ OV_NETWORK_URL: network.url, OV_NETWORK_INTERNAL_URL: network.url,
+ OV_OAUTH_CLIENT_ID: 'cam', OV_OAUTH_CLIENT_SECRET: 'cam-secret', COOKIE_SECURE: 'false',
+ CAM_DRILL: '1',
+ CAM_EVENTS_URL: network.url,
+ OV_MEDIA_URL: upstreams.url, OV_LIVE_URL: upstreams.url, OV_PICS_URL: upstreams.url,
+ ...(opts.env || {}),
+ };
+ for (const [k, v] of Object.entries(opts.env || {})) if (v === null) delete env[k];
+ const configLib = require('../../server/config');
+ const { createApp } = require('../../server/app');
+ const log = { log: (...a) => captured.push(a.map(String).join(' ')), warn: (...a) => captured.push(a.map(String).join(' ')), error: (...a) => captured.push(a.map(String).join(' ')), info: (...a) => captured.push(a.map(String).join(' ')) };
+
+ const config = configLib.load(env);
+ const { createStore } = require('../../server/db');
+ const testdb = await require('./db').testDb();
+ const store = createStore(testdb.db, { now: opts.now });
+ const built = await createApp({ config, store, log, telemetrySink: opts.telemetrySink, accountSend: opts.accountSend });
+ await built.ctx.keys.ensure();
+ const server = await new Promise((resolve) => { const s = http.createServer(built.app); s.listen(0, '127.0.0.1', () => resolve(s)); });
+ const base = `http://127.0.0.1:${server.address().port}`;
+
+ const signIn = (user) => `cam_at=${network.userToken(user)}`;
+
+ async function get(p, o = {}) {
+ const headers = { ...(o.headers || {}) };
+ if (o.as) headers.cookie = [signIn(o.as), headers.cookie].filter(Boolean).join('; ');
+ if (o.cookie) headers.cookie = o.cookie;
+ if (o.bearer) headers.authorization = `Bearer ${o.bearer}`;
+ let body = o.body;
+ if (o.json !== undefined) { body = JSON.stringify(o.json); headers['content-type'] = 'application/json'; }
+ if (o.form) { body = new URLSearchParams({ ...o.form }).toString(); headers['content-type'] = 'application/x-www-form-urlencoded'; }
+ const method = o.method || (body ? 'POST' : 'GET');
+ if (o.as && method !== 'GET' && method !== 'HEAD' && !headers.origin && !headers['sec-fetch-site']) headers.origin = config.baseUrl;
+ if (o.origin) headers.origin = o.origin;
+ const res = await fetch(base + p, { method, headers, body, redirect: 'manual' });
+ const buf = Buffer.from(await res.arrayBuffer());
+ const text = buf.toString('utf8');
+ return { status: res.status, headers: res.headers, text, buffer: buf, json() { return JSON.parse(text); } };
+ }
+
+ async function dbDump() {
+ const db = built.ctx.s.db;
+ const tables = (await db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema()")).map((r) => r.name);
+ return (await Promise.all(tables.map(async (tname) => JSON.stringify(await db.many(`SELECT * FROM "${tname}"`))))).join('\n');
+ }
+
+ const t = {
+ base, network, upstreams, config, ctx: built.ctx, get, signIn, dbDump,
+ logs: () => captured.join('\n'),
+ async close() {
+ await new Promise((r) => server.close(r));
+ built.ctx.keys.client.stop();
+ await built.ctx.telemetry.stop().catch(() => {});
+ await testdb.close();
+ await network.close();
+ await upstreams.close();
+ },
+ };
+ return t;
+}
+
+let failures = 0;
+async function check(name, fn) {
+ try { await fn(); process.stdout.write(` ✓ ${name}\n`); } catch (e) { failures++; process.stdout.write(` ✗ ${name}\n ${(e.stack || String(e)).split('\n').slice(0, 8).join('\n ')}\n`); }
+}
+function done() { process.stdout.write(failures ? `\n${failures} failed\n` : '\nall passed\n'); process.exit(failures ? 1 : 0); }
+
+module.exports = { boot, check, done };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+/**
+ * A migrated database for one test run (ADR-035), from openvibe-sdk/testing: PGlite by default; with
+ * CAM_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.CAM_TEST_STORE || 'pglite', max = 4 } = {}) => createTestDb({ migrations: MIGRATIONS, store, service: 'cam', max });
+
+module.exports = { testDb, pgAvailable };
new file mode 100644
new file mode 100644
@@ -0,0 +1,133 @@
@@ -0,0 +1,133 @@
+'use strict';
+/**
+ * An in-process stand-in for OpenVibe.Network with a real RS256 key pair.
+ *
+ * Network JWKS; /oauth/token (authorization_code + PKCE S256 for client `cam`, refresh_token,
+ * client_credentials); /oauth/revoke; /internal/identity/resolve?username=; a user card.
+ *
+ * Every request is recorded in `.requests`, token exchanges in `.tokenRequests`.
+ */
+const http = require('http');
+const crypto = require('crypto');
+const { serviceAuth, ids } = require('openvibe-contracts');
+
+function listen(handler) {
+ return new Promise((resolve) => {
+ const sockets = new Set();
+ const server = http.createServer((req, res) => {
+ const chunks = [];
+ req.on('data', (c) => chunks.push(c));
+ req.on('end', () => {
+ const raw = Buffer.concat(chunks);
+ const json = (status, obj, headers = {}) => { res.writeHead(status, { 'Content-Type': 'application/json', ...headers }); res.end(JSON.stringify(obj)); };
+ Promise.resolve(handler(req, raw, json, res)).catch((err) => { json(500, { error: String(err && err.message) }); });
+ });
+ });
+ server.on('connection', (s) => { sockets.add(s); s.on('close', () => sockets.delete(s)); });
+ server.listen(0, '127.0.0.1', () => resolve({
+ server, url: `http://127.0.0.1:${server.address().port}`,
+ close: () => new Promise((r) => { for (const s of sockets) s.destroy(); server.close(r); }),
+ }));
+ });
+}
+
+const b64 = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
+function signJwt(claims, privateKey) {
+ const input = `${b64({ alg: 'RS256', typ: 'JWT' })}.${b64(claims)}`;
+ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), privateKey).toString('base64url')}`;
+}
+
+async function startNetwork() {
+ const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
+ const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
+ const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
+ const st = { users: new Map(), byUsername: new Map(), codes: new Map(), refresh: new Map(), requests: [], tokenRequests: [], down: false, resolveDown: 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.toLowerCase(), 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);
+ }
+
+ 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 !== 'cam' || body.client_secret !== 'cam-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:cam', 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 === '/internal/identity/resolve' && req.method === 'GET') {
+ if (st.resolveDown) return problem(json, 503, 'identity.down', 'identity is restarting');
+ const auth = String(req.headers.authorization || '');
+ const token = auth.startsWith('Bearer ') ? auth.slice(7) : '';
+ const v = token ? serviceAuth.verifyServiceToken(token, { publicKey: publicPem, issuer, audience: 'openvibe.network' }) : { ok: false };
+ if (!v.ok) return problem(json, 401, 'token.invalid', 'a service token with identity.subject.resolve is required');
+ if (!Array.isArray(v.claims.cap) || !v.claims.cap.includes('identity.subject.resolve')) return problem(json, 403, 'token.forbidden', 'identity.subject.resolve is not granted');
+ const name = String(url.searchParams.get('username') || '').replace(/^@/, '').toLowerCase();
+ const u = st.byUsername.get(name);
+ if (!u) return problem(json, 404, 'identity.subject_not_found', 'no subject for that name');
+ return json(200, { subject: { type: 'user', id: u.subject }, network_user_id: u.id, username: u.username, display_name: u.display_name, avatar_url: null, banned: false });
+ }
+
+ if (url.pathname === '/api/v1/me/follows' && req.method === 'GET') return json(200, { items: [], next_cursor: null });
+ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
+ if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
+
+ return json(404, { error: 'not found' });
+ });
+ issuer = srv.url;
+
+ return {
+ url: srv.url, publicPem, privatePem, state: st, requests: st.requests, tokenRequests: st.tokenRequests,
+ addUser, userToken, signJwt: (claims) => signJwt(claims, privatePem), issuer: () => issuer,
+ issueCode(user, challenge) { const code = `code_${crypto.randomBytes(8).toString('hex')}`; st.codes.set(code, { user, challenge }); return code; },
+ setDown(v) { st.down = v; }, setResolveDown(v) { st.resolveDown = v; },
+ close: srv.close,
+ };
+}
+
+module.exports = { startNetwork, signJwt };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+
+/**
+ * In-process stand-ins for OpenVibe.Media, OpenVibe.Live and OpenVibe.Pics on one HTTP server. Boot points
+ * OV_MEDIA_URL, OV_LIVE_URL and OV_PICS_URL at it.
+ *
+ * GET /api/streams/:id a stream with its channel (ownership check)
+ * GET /live/:sel/frame.jpg a JPEG frame; state.frames[sel] = { status: 404 } makes it offline
+ * POST /api/v1/images Pics upload; records the person's token and returns a slug and links
+ *
+ * A test changes state.frames[slot] or state.pics.status to exercise an offline slot, a busy frame or a Pics
+ * refusal, and reads state.uploads / requestsFor() to see what Cam sent.
+ */
+const http = require('http');
+
+const JPEG = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01, 0xff, 0xd9]);
+
+const DEFAULT_STREAMS = {
+ 1234: { id: 1234, managed_stream_id: 77, managed_stream_slug: 'whip', is_live: true, username: 'alice', title: 'Alice live', channel: { username: 'alice' } },
+ 2222: { id: 2222, managed_stream_id: 88, managed_stream_slug: 'bob-cam', is_live: false, username: 'bob', title: 'Bob offline', channel: { username: 'bob' } },
+};
+
+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); }),
+ }));
+ });
+}
+
+async function startUpstreams() {
+ const state = {
+ streams: JSON.parse(JSON.stringify(DEFAULT_STREAMS)),
+ frames: {},
+ pics: { status: 201 },
+ uploads: [],
+ requests: [],
+ };
+
+ const srv = await listen(async (req, raw, json, res) => {
+ const url = new URL(req.url, 'http://x');
+ const p = url.pathname;
+ state.requests.push({ method: req.method, path: p, query: Object.fromEntries(url.searchParams), headers: { ...req.headers }, bytes: raw.length });
+
+ const stream = p.match(/^\/api\/streams\/(\d+)$/);
+ if (stream) {
+ const row = state.streams[stream[1]];
+ if (!row) return json(404, { error: 'Stream not found' });
+ return json(200, { stream: row });
+ }
+
+ const frame = p.match(/^\/live\/([^/]+)\/frame\.jpg$/);
+ if (frame) {
+ const sel = decodeURIComponent(frame[1]);
+ const conf = state.frames[sel] || { status: 200 };
+ if (conf.status === 404) {
+ res.writeHead(404, { 'Content-Type': 'image/svg+xml' });
+ return res.end('<svg xmlns="http://www.w3.org/2000/svg"><text>offline</text></svg>');
+ }
+ if (conf.status === 429) return json(429, { error: 'Too many requests' });
+ if (conf.status === 500) return json(500, { error: 'Failed to grab frame' });
+ const bytes = conf.bytes || JPEG;
+ const type = conf.type || 'image/jpeg';
+ res.writeHead(conf.status || 200, { 'Content-Type': type, 'Content-Length': bytes.length, 'Cache-Control': 'public, max-age=5' });
+ return res.end(bytes);
+ }
+
+ if (p === '/api/v1/images' && req.method === 'POST') {
+ const status = state.pics.status || 201;
+ const auth = String(req.headers.authorization || '');
+ if (status === 401) return json(401, { code: 'token.required', detail: 'Sign in at openvibe.pics.' });
+ if (status === 413) return json(413, { code: 'pics.too_large', detail: 'A picture can be up to 20 MB.' });
+ if (status === 415) return json(415, { code: 'pics.not_an_image', detail: 'Send the picture as the body.' });
+ if (!auth.startsWith('Bearer ')) return json(401, { code: 'token.required', detail: 'a person\'s token is required' });
+ const slug = `pic${state.uploads.length + 1}abcd`.slice(0, 8);
+ state.uploads.push({
+ auth, contentType: String(req.headers['content-type'] || ''), title: req.headers['x-title'] || null,
+ filename: req.headers['x-filename'] || null, bytes: raw.length, slug,
+ });
+ return json(201, {
+ id: `pic_${slug}`, slug, mime_type: 'image/jpeg', ext: 'jpg', width: 640, height: 360, bytes: raw.length,
+ links: { page: `https://openvibe.pics/i/${slug}`, direct: `https://openvibe.pics/i/${slug}.jpg`, thumb: `https://openvibe.pics/i/${slug}/thumb.webp` },
+ }, { 'Content-Type': 'application/json' });
+ }
+
+ return json(404, { error: 'not found' });
+ });
+
+ // The page link is relative in this fixture; Cam keeps it as Pics returned it.
+ return {
+ url: srv.url,
+ state,
+ close: srv.close,
+ requestsFor(path) { return state.requests.filter((r) => r.path === path); },
+ lastFor(path) { return state.requests.filter((r) => r.path === path).pop() || null; },
+ setFrame(slot, conf) { state.frames[slot] = conf; },
+ setPics(status) { state.pics.status = status; },
+ uploads() { return state.uploads; },
+ };
+}
+
+module.exports = { startUpstreams, JPEG, DEFAULT_STREAMS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,73 @@
@@ -0,0 +1,73 @@
+'use strict';
+/**
+ * The expand migration applies in one pass and creates exactly Cam's tables plus the SDK's account receipts and
+ * telemetry. No Cam table has a column for picture bytes; a picture is a Pics slug and two links.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const t = await boot();
+ try {
+ await check('0001_initial.sql applies and creates the Cam tables', async () => {
+ const names = (await t.ctx.s.db.many("SELECT table_name AS name FROM information_schema.tables WHERE table_schema = current_schema() ORDER BY table_name")).map((r) => r.name);
+ for (const table of ['cam_cameras', 'cam_grants', 'cam_photos', 'cam_stills', 'account_data_events', 'telemetry_samples']) {
+ assert.ok(names.includes(table), `missing ${table}`);
+ }
+ });
+
+ await check('no Cam table has a picture, blob or byte column', async () => {
+ const cols = (await t.ctx.s.db.many("SELECT table_name, column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name LIKE 'cam_%'")).map((r) => `${r.table_name}.${r.column_name}`);
+ assert.ok(!cols.some((c) => /(^|\.)(image|photo|blob|bytes|data)$/i.test(c)), cols.join(','));
+ for (const table of ['cam_photos', 'cam_stills']) {
+ const names = cols.filter((c) => c.startsWith(`${table}.`));
+ assert.ok(names.some((c) => c.endsWith('.pics_slug')), `${table} keeps a Pics slug`);
+ assert.ok(names.some((c) => c.endsWith('.pics_url')), `${table} keeps a Pics link`);
+ }
+ });
+
+ await check('a camera refuses a bad id, source, status or slot, and a duplicate stream', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const owner = ids.newId('user');
+ const bad = (fields) => db.exec(`INSERT INTO cam_cameras (id, owner, name, source, status, slot, paired_at, created_at, updated_at)
+ VALUES ($1, $2, 'A camera', $3, $4, $5, $6, $6, $6)`, [fields.id || `cam_${ids.ulid()}`, owner, fields.source || 'phone', fields.status || 'paired', fields.slot || null, at]);
+ await assert.rejects(bad({ id: 'bad' }), /cam_cameras_id/);
+ await assert.rejects(bad({ source: 'webcam' }), /cam_cameras_source/);
+ await assert.rejects(bad({ status: 'on' }), /cam_cameras_status/);
+ await assert.rejects(bad({ slot: 'not a slot!' }), /cam_cameras_slot/);
+ await db.exec("INSERT INTO cam_cameras (id, owner, name, stream_id, paired_at, created_at, updated_at) VALUES ($1, $2, 'Once', 42, $3, $3, $3)", [`cam_${ids.ulid()}`, owner, at]);
+ await assert.rejects(db.exec("INSERT INTO cam_cameras (id, owner, name, stream_id, paired_at, created_at, updated_at) VALUES ($1, $2, 'Twice', 42, $3, $3, $3)", [`cam_${ids.ulid()}`, owner, at]), /duplicate key|unique/i);
+ });
+
+ await check('one active grant per camera and viewer; a revoked one may be followed by a new one', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const owner = ids.newId('user');
+ const viewer = ids.newId('user');
+ const cameraId = `cam_${ids.ulid()}`;
+ await db.exec("INSERT INTO cam_cameras (id, owner, name, paired_at, created_at, updated_at) VALUES ($1, $2, 'Door', $3, $3, $3)", [cameraId, owner, at]);
+ const grant = (id, revoke = null) => db.exec(`INSERT INTO cam_grants (id, camera_id, owner, viewer_subject, granted_at, expires_at, revoked_at, created_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $5)`, [id, cameraId, owner, viewer, at, new Date(Date.now() + 86400000).toISOString(), revoke]);
+ await grant(`cmg_${ids.ulid()}`);
+ await assert.rejects(grant(`cmg_${ids.ulid()}`), /duplicate key|unique/i);
+ const revoked = `cmg_${ids.ulid()}`;
+ await db.exec('UPDATE cam_grants SET revoked_at = $2 WHERE camera_id = $1', [cameraId, at]);
+ await grant(revoked, at);
+ });
+
+ await check('an unknown source, filter or still visibility is refused by the tables', async () => {
+ const db = t.ctx.s.db;
+ const at = new Date().toISOString();
+ const subject = ids.newId('user');
+ await assert.rejects(db.exec(`INSERT INTO cam_photos (id, subject, filter, visibility, pics_slug, pics_url, pics_direct, created_at)
+ VALUES ($1, $2, 'sepia', 'private', 'abc12345', 'https://openvibe.pics/i/abc12345', 'https://openvibe.pics/i/abc12345.jpg', $3)`, [`cmp_${ids.ulid()}`, subject, at]), /cam_photos_filter/);
+ await assert.rejects(db.exec(`INSERT INTO cam_stills (id, subject, slot, visibility, pics_slug, pics_url, pics_direct, created_at)
+ VALUES ($1, $2, 'whip', 'public', 'abc12345', 'https://openvibe.pics/i/abc12345', 'https://openvibe.pics/i/abc12345.jpg', $3)`, [`cms_${ids.ulid()}`, subject, at]), /cam_stills_visibility/);
+ });
+ } finally {
+ 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 home and its privacy rules, the camera app, My cameras, one camera's owner page, the
+ * private wall, a still, developers, 404, the cache headers, the permissions policy and the discovery artifacts.
+ * Public pages are public, max-age=60; every wall, still and signed-in page is private, no-store.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { assetVersion } = require('../server/render/layout');
+
+function jsonLd(html) {
+ return [...String(html).matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => JSON.parse(m[1]));
+}
+
+(async () => {
+ const t = await boot();
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ let camera = null;
+ let grant = null;
+ let still = null;
+ try {
+ await check('home: semantic, the privacy rules, a public cache and 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="cm-home"', 'class="sc-room', 'id="cm-rules"', 'Nothing public without its owner', 'No face recognition', '/img/cam-shutter.svg', 'href="/app"']) assert.ok(r.text.includes(marker), marker);
+ assert.match(r.text, /<main id="main"/);
+ assert.match(r.text, /<footer id="ov-footer"/);
+ 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; the permissions policy allows only the camera', 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');
+ assert.strictEqual(r.headers.get('permissions-policy'), 'camera=(self), microphone=(), geolocation=(), payment=()');
+ });
+
+ await check('/app has the camera controls and the gallery', async () => {
+ const r = await t.get('/app');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ for (const marker of ['id="cm-app"', 'id="cm-camera"', 'id="cm-gallery"', 'data-cm-preview', 'data-cm-shoot', 'data-cm-timer', 'data-cm-burst', 'data-cm-filter', 'data-cm-file', 'data-cm-grid']) assert.ok(r.text.includes(marker), marker);
+ assert.ok(r.text.includes('Sign in'));
+ });
+
+ await check('/my needs an account; signed in it has the pair form and no camera yet', async () => {
+ const anon = await t.get('/my');
+ assert.strictEqual(anon.status, 401);
+ assert.ok(anon.text.includes('Sign in with OpenVibe'));
+ const mine = await t.get('/my', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.headers.get('cache-control'), 'private, no-store');
+ for (const marker of ['id="cm-my"', 'id="cm-pair"', 'action="/my/cameras"', 'name="stream_id"', 'id="cm-cameras"', 'id="cm-photos"', 'id="cm-stills"']) assert.ok(mine.text.includes(marker), marker);
+ });
+
+ await check('the pair form creates a camera and redirects to its owner page', async () => {
+ const r = await t.get('/my/cameras', { as: alice, form: { name: 'Front door', source: 'phone', slot: 'whip' } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.match(r.headers.get('location'), /^\/my\/cameras\/cam_/);
+ camera = (await t.get('/api/v1/cameras', { as: alice })).json().cameras[0];
+ assert.strictEqual(camera.name, 'Front door');
+ assert.strictEqual(camera.slot, 'whip');
+ });
+
+ await check('the owner page shows the frame, the edit form and the grant form; a stranger gets 404', async () => {
+ const r = await t.get(`/my/cameras/${camera.id}`, { as: alice });
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ for (const marker of ['id="cm-camera-owner"', 'id="cm-edit"', 'id="cm-grants"', `action="/my/cameras/${camera.id}/grants"`, `action="/my/cameras/${camera.id}/delete"`, '/live/whip/frame.jpg']) assert.ok(r.text.includes(marker), marker);
+ assert.strictEqual((await t.get(`/my/cameras/${camera.id}`, { as: carol })).status, 404);
+ });
+
+ await check('the grant form resolves @bob; the wall opens for bob and not for carol', async () => {
+ const r = await t.get(`/my/cameras/${camera.id}/grants`, { as: alice, form: { viewer_username: 'bob', days: '7' } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(r.headers.get('location'), `/my/cameras/${camera.id}?granted=1`);
+ grant = (await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice })).json().grants[0];
+ const wall = await t.get(`/w/${camera.id}`, { as: bob });
+ assert.strictEqual(wall.status, 200);
+ assert.strictEqual(wall.headers.get('cache-control'), 'private, no-store');
+ for (const marker of ['id="cm-wall"', 'id="cm-wall-frame"', 'Shared by @alice', '/live/whip/frame.jpg', 'id="cm-wall-photos"']) assert.ok(wall.text.includes(marker), marker);
+ assert.strictEqual((await t.get(`/w/${camera.id}`, { as: carol })).status, 404);
+ assert.strictEqual((await t.get(`/w/${camera.id}`)).status, 401);
+ });
+
+ await check('revoking from the owner page refuses the wall at once', async () => {
+ const r = await t.get(`/my/cameras/${camera.id}/grants/${grant.id}/revoke`, { as: alice, method: 'POST' });
+ assert.strictEqual(r.status, 303);
+ assert.strictEqual(r.headers.get('location'), `/my/cameras/${camera.id}?revoked=1`);
+ assert.strictEqual((await t.get(`/w/${camera.id}`, { as: bob })).status, 404);
+ });
+
+ await check('one still page shows the picture and its caption; a private one is 404', async () => {
+ still = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'the robot fell over' } })).json().still;
+ const r = await t.get(`/s/${still.id}`);
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store');
+ for (const marker of ['id="cm-still"', 'the robot fell over', still.pics_direct, '/app']) assert.ok(r.text.includes(marker), marker);
+ const priv = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', visibility: 'private' } })).json().still;
+ assert.strictEqual((await t.get(`/s/${priv.id}`)).status, 404);
+ assert.strictEqual((await t.get(`/s/${priv.id}`, { as: alice })).status, 200);
+ });
+
+ await check('/developers names the API and the Live cam button contract', async () => {
+ const r = await t.get('/developers');
+ assert.strictEqual(r.status, 200);
+ assert.strictEqual(r.headers.get('cache-control'), 'public, max-age=60');
+ for (const marker of ['id="cm-developers"', 'POST /api/v1/stills', 'POST /api/v1/photos', '/live/:slot/frame.jpg', 'openvibe.pics/api/v1/images', 'id="cm-live-button"', '/api/v1/cameras/:id/grants']) assert.ok(r.text.includes(marker), marker);
+ });
+
+ await check('a cross-site form is refused; the 404 page is semantic', async () => {
+ const cross = await t.get('/my/cameras', { as: alice, form: { name: 'Cross' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ const nf = await t.get('/no/such/page');
+ assert.strictEqual(nf.status, 404);
+ assert.match(nf.headers.get('content-type'), /^text\/html/);
+ assert.ok(nf.text.includes('id="cm-not-found"'));
+ });
+
+ await check('assets are content-hashed; robots keeps the walls and stills out; the sitemap lists only public pages', async () => {
+ const v = assetVersion('css/cam.css');
+ const hashed = await t.get(`/css/cam.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/cam.css?v=deadbeefdeadbeef');
+ assert.strictEqual(wrong.headers.get('cache-control'), 'public, max-age=300, stale-while-revalidate=86400');
+ const js = await t.get('/js/cam.js');
+ assert.strictEqual(js.status, 200);
+ assert.match(js.headers.get('content-type'), /javascript/);
+ const robots = await t.get('/robots.txt');
+ assert.match(robots.text, /Disallow: \/w\//);
+ assert.match(robots.text, /Disallow: \/s\//);
+ assert.match(robots.text, /Disallow: \/api\//);
+ const map = await t.get('/sitemap.xml');
+ assert.strictEqual(map.status, 200);
+ assert.ok(map.text.includes('https://openvibe.cam/app'));
+ assert.ok(!map.text.includes('/w/'));
+ assert.ok(!map.text.includes('/s/'));
+ const llms = await t.get('/llms.txt');
+ assert.strictEqual(llms.status, 200);
+ assert.ok(llms.text.includes('OpenVibe.Cam'));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+'use strict';
+/**
+ * The camera app's photo: the raw picture is the request body, Cam forwards it to OpenVibe.Pics with the person's
+ * own Network token, and stores the Pics slug, links, filter and caption. A photo without a camera is private; a
+ * photo on a camera wall is unlisted and an active grantee sees it. The image bytes are never stored in Cam.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { JPEG } = require('./helpers/upstreams');
+
+(async () => {
+ const t = await boot({ env: { CAM_PHOTO_MAX_BYTES: '64' } });
+ const alice = t.network.addUser('alice');
+ const bob = t.network.addUser('bob');
+ const carol = t.network.addUser('carol');
+ try {
+ let photo = null;
+ let camera = null;
+
+ await check('a photo uploads to Pics and is recorded with its filter and caption', async () => {
+ const r = await t.get('/api/v1/photos', {
+ as: alice, body: JPEG,
+ headers: { 'content-type': 'image/jpeg', 'x-filter': 'warm', 'x-caption': encodeURIComponent('a warm porch') },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ photo = r.json();
+ assert.match(photo.id, /^cmp_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(photo.filter, 'warm');
+ assert.strictEqual(photo.caption, 'a warm porch');
+ assert.strictEqual(photo.visibility, 'private');
+ assert.strictEqual(photo.camera_id, null);
+ assert.strictEqual(photo.subject, alice.subject);
+ const upload = t.upstreams.uploads()[0];
+ assert.match(upload.auth, /^Bearer /);
+ assert.strictEqual(upload.contentType, 'image/jpeg');
+ assert.strictEqual(upload.bytes, JPEG.length);
+ assert.strictEqual(upload.title, encodeURIComponent('a warm porch'));
+ });
+
+ await check('the photo row has no bytes; the picture lives in Pics', async () => {
+ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'cam_photos'")).map((r) => r.column_name);
+ assert.ok(!cols.some((c) => /image|blob|bytes|photo|data/i.test(c)), cols.join(','));
+ const row = await t.ctx.s.db.maybe('SELECT pics_slug, pics_url, pics_direct FROM cam_photos WHERE id = $1', [photo.id]);
+ assert.ok(row.pics_slug);
+ assert.ok(row.pics_url.startsWith('https://openvibe.pics/'));
+ });
+
+ await check('your photos are yours; another person gets 404', async () => {
+ const mine = await t.get('/api/v1/photos', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.strictEqual(mine.json().count, 1);
+ assert.strictEqual((await t.get(`/api/v1/photos/${photo.id}`, { as: alice })).status, 200);
+ assert.strictEqual((await t.get(`/api/v1/photos/${photo.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get('/api/v1/photos', { as: bob })).json().count, 0);
+ });
+
+ await check('a photo attached to a camera is unlisted and appears on the wall for an active grantee', async () => {
+ camera = (await t.get('/api/v1/cameras', { as: alice, json: { name: 'Front door', slot: 'whip' } })).json();
+ const r = await t.get('/api/v1/photos', {
+ as: alice, body: JPEG,
+ headers: { 'content-type': 'image/jpeg', 'x-filter': 'none', 'x-camera': camera.id },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const attached = r.json();
+ assert.strictEqual(attached.camera_id, camera.id);
+ assert.strictEqual(attached.visibility, 'unlisted');
+
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob })).status, 404);
+ const grant = await t.get(`/api/v1/cameras/${camera.id}/grants`, { as: alice, json: { viewer_username: 'bob' } });
+ assert.strictEqual(grant.status, 201);
+ const wall = await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: bob });
+ assert.strictEqual(wall.status, 200);
+ assert.strictEqual(wall.json().photos.length, 1);
+ assert.strictEqual(wall.json().photos[0].id, attached.id);
+ assert.ok(!('subject' in wall.json().photos[0]));
+ assert.strictEqual((await t.get(`/api/v1/cameras/${camera.id}/wall`, { as: carol })).status, 404);
+ });
+
+ await check('a photo cannot be attached to somebody else\'s camera', async () => {
+ const r = await t.get('/api/v1/photos', {
+ as: bob, body: JPEG,
+ headers: { 'content-type': 'image/jpeg', 'x-camera': camera.id },
+ });
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.json().code, 'cam.not_found');
+ });
+
+ await check('the content type, filter, caption, size and sign-in are enforced', async () => {
+ const notImage = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'text/plain' } });
+ assert.strictEqual(notImage.status, 415);
+ assert.strictEqual(notImage.json().code, 'cam.not_an_image');
+ const badFilter = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg', 'x-filter': 'sepia' } });
+ assert.strictEqual(badFilter.status, 400);
+ const blocked = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg', 'x-caption': encodeURIComponent('kys') } });
+ assert.strictEqual(blocked.status, 400);
+ assert.strictEqual(blocked.json().code, 'cam.blocked_words');
+ const tooLarge = await t.get('/api/v1/photos', { as: alice, body: Buffer.alloc(100, 1), headers: { 'content-type': 'image/jpeg' } });
+ assert.strictEqual(tooLarge.status, 413);
+ const anonymous = await t.get('/api/v1/photos', { body: JPEG, headers: { 'content-type': 'image/jpeg' } });
+ assert.strictEqual(anonymous.status, 401);
+ const cross = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg' }, origin: 'https://evil.example' });
+ assert.strictEqual(cross.status, 403);
+ assert.strictEqual(cross.json().code, 'cam.cross_site');
+ });
+
+ await check('a Pics refusal is a problem, not a photo', async () => {
+ t.upstreams.setPics(401);
+ const r = await t.get('/api/v1/photos', { as: alice, body: JPEG, headers: { 'content-type': 'image/jpeg' } });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'cam.pics.refused');
+ t.upstreams.setPics(201);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,20 @@
@@ -0,0 +1,20 @@
+#!/usr/bin/env node
+/**
+ * Runs every test/*.test.js in its own process and fails if any fails. They use temp PGlite databases, an
+ * in-process mock of OpenVibe.Network, and in-process Media/Live/Pics fixtures; none needs the network.
+ *
+ * npm test # everything
+ * npm test -- cameras # only files whose name contains one of the words
+ * npm test -- --strict # a skipped test fails the run too
+ */
+'use strict';
+const { run } = require('openvibe-shared/test-runner');
+
+// VERBOSE=1 also prints every file's output, then the summary again.
+run({ dir: __dirname, timeoutMs: 90000, pad: 34, parallel: 1 }).then((r) => {
+ if (process.env.VERBOSE) {
+ for (const t of r.results) console.log(`\n── ${t.file} ──\n${t.output.trimEnd()}`);
+ console.log(`\n${r.summary}`);
+ }
+ process.exit(r.exitCode);
+}, (err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,45 @@
@@ -0,0 +1,45 @@
+'use strict';
+/**
+ * The first proof, without the network: pair the proof camera for one owner, grant a second person for seven days,
+ * open the wall as that viewer, revoke the grant, and see the next wall request refused. Re-running reuses the
+ * camera and makes a fresh grant after the previous one was revoked.
+ */
+const assert = require('assert');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const { runProof, NAME, SLOT } = require('../scripts/seed-proof');
+
+(async () => {
+ const t = await boot();
+ const owner = ids.newId('user');
+ const viewer = ids.newId('user');
+ try {
+ await check('the proof pairs a camera, grants a viewer, opens the wall, revokes and is refused', async () => {
+ const out = await runProof({ config: t.config, s: t.ctx.s, owner, viewer, log: { log() {} } });
+ assert.strictEqual(out.camera.name, NAME);
+ assert.strictEqual(out.camera.slot, SLOT);
+ assert.strictEqual(out.camera.owner_username, 'proof-owner');
+ assert.strictEqual(out.grant.viewer_subject, viewer);
+ assert.strictEqual(out.wall.owner, false);
+ assert.strictEqual(out.revoked.revoked_at != null, true);
+ assert.strictEqual(out.refused, true);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1', [out.camera.id])), 1);
+ });
+
+ await check('a second run reuses the camera and grants again after the revocation', async () => {
+ const again = await runProof({ config: t.config, s: t.ctx.s, owner, viewer, log: { log() {} } });
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_cameras WHERE owner = $1 AND name = $2', [owner, NAME])), 1);
+ assert.notStrictEqual(again.grant.id, null);
+ assert.strictEqual(again.refused, true);
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT COUNT(*)::int AS n FROM cam_grants WHERE camera_id = $1', [again.camera.id])), 2);
+ });
+
+ await check('the proof refuses the same person twice and a non-subject', async () => {
+ await assert.rejects(runProof({ config: t.config, s: t.ctx.s, owner, viewer: owner, log: { log() {} } }), /different people/);
+ await assert.rejects(runProof({ config: t.config, s: t.ctx.s, owner: 'nobody', viewer, log: { log() {} } }), /CAM_PROOF_OWNER/);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+/**
+ * Sign-in with PKCE S256 (client id cam, cookies cam_at/cam_rt/cam_oauth), the session-token rules (a FedCM
+ * assertion or an app token is not a session), the open-redirect guard, truthful readiness, loopback metrics,
+ * pages without JavaScript, and cross-site sign-out.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { boot, check, done } = require('./helpers/boot');
+const { sanitizeNext } = require('../server/auth/sso');
+
+(async () => {
+ const t = await boot();
+ const user = t.network.addUser('kim');
+
+ await check('login redirects to Network with state and an S256 code challenge, client id cam', async () => {
+ const r = await t.get('/auth/login?next=/my');
+ 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'), 'cam');
+ 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'), /cam_oauth=.*Path=\/auth.*HttpOnly/i);
+ });
+
+ await check('callback exchanges the code with the matching verifier and sets httpOnly cam_at/cam_rt', async () => {
+ const login = await t.get('/auth/login?next=/my');
+ 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'), '/my');
+ const set = r.headers.get('set-cookie');
+ assert.match(set, /cam_at=[^;]+;.*HttpOnly/i);
+ assert.match(set, /cam_rt=[^;]+;.*HttpOnly/i);
+ assert.match(set, /cam_oauth=;/);
+ const at = set.match(/cam_at=([^;]+)/)[1];
+ const me = await t.get('/auth/me', { cookie: `cam_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'));
+ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${'0'.repeat(32)}`, { cookie: flowCookie })).status, 400);
+ const other = t.network.issueCode(user, crypto.createHash('sha256').update('another-verifier').digest('base64url'));
+ assert.strictEqual((await t.get(`/auth/callback?code=${other}&state=${loc.searchParams.get('state')}`, { cookie: flowCookie })).status, 400);
+ assert.strictEqual((await t.get(`/auth/callback?code=${code}&state=${loc.searchParams.get('state')}`)).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(/cam_oauth=([^;]+)/)[1]);
+ assert.strictEqual(JSON.parse(flow).next, '/');
+ const config = { baseUrl: 'https://openvibe.cam' };
+ assert.strictEqual(sanitizeNext('/my?x=1', config), '/my?x=1');
+ assert.strictEqual(sanitizeNext('https://openvibe.cam/w/cam_x?y=1', config), '/w/cam_x?y=1');
+ for (const v of ['//evil.com', '/\\evil.com', 'https://evil.com/x', '/\t/evil.com', 'javascript:alert(1)', 'https://openvibe.cam.evil.com/x']) assert.strictEqual(sanitizeNext(v, config), '/', v);
+ });
+
+ await check('readiness is truthful: db required; JWKS, the OAuth client, identity resolve 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.identity_resolve.status, 'ok');
+ 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, 'cam');
+ 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 ['/', '/app', '/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: 'cam' });
+ 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: `cam_at=${fedcm}` })).status, 401);
+ const page = await t.get('/', { cookie: `cam_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: `cam_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: 'cam_at=garbage' })).status, 401);
+ assert.strictEqual((await t.get('/auth/me', { cookie: 'cam_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(!/cam_at=;|cam_at=deleted/i.test(String(cross.headers.get('set-cookie') || '')));
+ assert.match(cross.text, /method="post"/);
+ assert.strictEqual((await t.get('/auth/logout', { as: user, method: 'POST', headers: { 'sec-fetch-site': 'cross-site' } })).status, 403);
+ const same = await t.get('/auth/logout?next=/my', { as: user, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(same.status, 303);
+ assert.strictEqual(same.headers.get('location'), '/my');
+ assert.match(String(same.headers.get('set-cookie') || ''), /cam_at=;/);
+ });
+
+ await t.close();
+ done();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+/**
+ * The still Live's cam button takes: Cam reads Media's public GET /live/:slot/frame.jpg, uploads the JPEG to
+ * OpenVibe.Pics with the viewer's own Network token, records the Pics slug and links, and returns the line Live
+ * posts in chat. An offline slot, a non-JPEG card, a busy frame and a Pics refusal are distinct problems. A still is
+ * unlisted by default (the chat link opens it) and private when its owner says so.
+ */
+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');
+ let still = null;
+ try {
+ await check('a still fetches the Media frame and saves it to Pics as the viewer', async () => {
+ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', stream_id: 1234, caption: 'the exact second the robot fell over' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const b = r.json();
+ still = b.still;
+ assert.match(still.id, /^cms_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.strictEqual(still.slot, 'whip');
+ assert.strictEqual(still.stream_id, 1234);
+ assert.strictEqual(still.visibility, 'unlisted');
+ assert.strictEqual(still.caption, 'the exact second the robot fell over');
+ assert.match(still.pics_slug, /^pic\d+abcd$/);
+ assert.match(still.pics_url, /^https:\/\/openvibe\.pics\/i\//);
+ assert.strictEqual(r.headers.get('location'), `/api/v1/stills/${still.id}`);
+ assert.strictEqual(b.share.url, `https://openvibe.cam/s/${still.id}`);
+ assert.strictEqual(b.share.text, `the exact second the robot fell over — https://openvibe.cam/s/${still.id}`);
+
+ const frameReq = t.upstreams.lastFor('/live/whip/frame.jpg');
+ assert.ok(frameReq, 'Media frame was read');
+ assert.strictEqual(frameReq.query.w, '640');
+ const upload = t.upstreams.uploads()[0];
+ assert.match(upload.auth, /^Bearer /);
+ assert.strictEqual(upload.contentType, 'image/jpeg');
+ assert.strictEqual(upload.title, encodeURIComponent('the exact second the robot fell over'));
+ assert.match(upload.filename, /^still-whip-\d+\.jpg$/);
+ assert.ok(upload.bytes > 0);
+ });
+
+ await check('the still row holds a Pics slug and links, never the picture bytes', async () => {
+ const cols = (await t.ctx.s.db.many("SELECT column_name FROM information_schema.columns WHERE table_name = 'cam_stills'")).map((r) => r.column_name);
+ assert.ok(!cols.some((c) => /image|blob|bytes|photo|data/i.test(c)), cols.join(','));
+ const row = await t.ctx.s.db.maybe('SELECT pics_slug, pics_url, pics_direct FROM cam_stills WHERE id = $1', [still.id]);
+ assert.strictEqual(row.pics_slug, still.pics_slug);
+ assert.ok(row.pics_url.startsWith('https://openvibe.pics/'));
+ });
+
+ await check('an unlisted still opens by its link, with no author; a private one is its owner\'s only', async () => {
+ const anon = await t.get(`/api/v1/stills/${still.id}`);
+ assert.strictEqual(anon.status, 200);
+ assert.strictEqual(anon.json().mine, false);
+ assert.ok(!('subject' in anon.json()));
+ const mine = await t.get(`/api/v1/stills/${still.id}`, { as: alice });
+ assert.strictEqual(mine.json().mine, true);
+ assert.strictEqual(mine.json().subject, alice.subject);
+
+ const priv = (await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'only me', visibility: 'private' } })).json().still;
+ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`)).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`, { as: bob })).status, 404);
+ assert.strictEqual((await t.get(`/api/v1/stills/${priv.id}`, { as: alice })).status, 200);
+ });
+
+ await check('an offline slot is 404 and nothing is uploaded to Pics', async () => {
+ const before = t.upstreams.uploads().length;
+ t.upstreams.setFrame('whip', { status: 404 });
+ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });
+ assert.strictEqual(r.status, 404);
+ assert.strictEqual(r.json().code, 'cam.stream_offline');
+ assert.strictEqual(t.upstreams.uploads().length, before);
+ t.upstreams.setFrame('whip', { status: 200 });
+ });
+
+ await check('an SVG offline card served with 200 is not a picture; a busy frame is 429', async () => {
+ t.upstreams.setFrame('whip', { status: 200, type: 'image/svg+xml' });
+ const svg = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });
+ assert.strictEqual(svg.status, 502);
+ assert.strictEqual(svg.json().code, 'cam.frame_unavailable');
+ t.upstreams.setFrame('whip', { status: 429 });
+ const busy = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });
+ assert.strictEqual(busy.status, 429);
+ assert.strictEqual(busy.json().code, 'cam.frame_busy');
+ t.upstreams.setFrame('whip', { status: 200, type: 'image/jpeg' });
+ });
+
+ await check('a Pics refusal is a problem, not a still', async () => {
+ t.upstreams.setPics(401);
+ const r = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip' } });
+ assert.strictEqual(r.status, 401);
+ assert.strictEqual(r.json().code, 'cam.pics.refused');
+ t.upstreams.setPics(201);
+ });
+
+ await check('slot, caption, width and visibility are validated; anonymous is refused', async () => {
+ assert.strictEqual((await t.get('/api/v1/stills', { json: { slot: 'whip' } })).status, 401);
+ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'not a slot!' } })).status, 400);
+ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', w: 4000 } })).status, 400);
+ assert.strictEqual((await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', visibility: 'public' } })).status, 400);
+ const blocked = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'this is kys' } });
+ assert.strictEqual(blocked.status, 400);
+ assert.strictEqual(blocked.json().code, 'cam.blocked_words');
+ const long = await t.get('/api/v1/stills', { as: alice, json: { slot: 'whip', caption: 'x'.repeat(281) } });
+ assert.strictEqual(long.status, 400);
+ });
+
+ await check('your stills are listed; another person\'s are not', async () => {
+ const mine = await t.get('/api/v1/stills', { as: alice });
+ assert.strictEqual(mine.status, 200);
+ assert.ok(mine.json().count >= 2);
+ assert.ok(mine.json().stills.every((s) => s.id.startsWith('cms_')));
+ assert.strictEqual((await t.get('/api/v1/stills', { as: bob })).json().count, 0);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();