openvibers/openvibe.cam Commits

OpenVibe.Cam v1

OP OpenVibers committed 16 days ago · c59511aca1 · 51 files, +7171 −0


Changes

ddiff --git a/.env.example b/.env.example
nnew file mode 100644
iindex 0000000..423ef4b
---- /dev/null
++++ b/.env.example
@@@ -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
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..e3cdaae
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -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
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..5a60c40
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,10 @@
++node_modules/
++/data/
++dist/
++.env
++.env.*
++!.env.example
++.npm-cache/
++*.log
++*.pem
++*.key
ddiff --git a/CHANGELOG.md b/CHANGELOG.md
nnew file mode 100644
iindex 0000000..1bd5410
---- /dev/null
++++ b/CHANGELOG.md
@@@ -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.
ddiff --git a/LICENSE b/LICENSE
nnew file mode 100644
iindex 0000000..0ad25db
---- /dev/null
++++ b/LICENSE
@@@ -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/>.
ddiff --git a/README.md b/README.md
nnew file mode 100644
iindex 0000000..22571d5
---- /dev/null
++++ b/README.md
@@@ -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).
ddiff --git a/SECURITY.md b/SECURITY.md
nnew file mode 100644
iindex 0000000..18477cc
---- /dev/null
++++ b/SECURITY.md
@@@ -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.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..a03fba2
---- /dev/null
++++ b/STATUS.json
@@@ -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"
++}
ddiff --git a/deploy/nginx/openvibe.cam.conf b/deploy/nginx/openvibe.cam.conf
nnew file mode 100644
iindex 0000000..887bd19
---- /dev/null
++++ b/deploy/nginx/openvibe.cam.conf
@@@ -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;
++ }
++}
ddiff --git a/deploy/systemd/openvibe-cam.service b/deploy/systemd/openvibe-cam.service
nnew file mode 100644
iindex 0000000..d9a378f
---- /dev/null
++++ b/deploy/systemd/openvibe-cam.service
@@@ -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
ddiff --git a/migrations/0001_initial.sql b/migrations/0001_initial.sql
nnew file mode 100644
iindex 0000000..2f53d91
---- /dev/null
++++ b/migrations/0001_initial.sql
@@@ -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);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..33756cb
---- /dev/null
++++ b/package-lock.json
@@@ -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"
++ }
++ }
++ }
++}
ddiff --git a/package.json b/package.json
nnew file mode 100644
iindex 0000000..985d426
---- /dev/null
++++ b/package.json
@@@ -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"
++ }
++}
ddiff --git a/public/css/cam.css b/public/css/cam.css
nnew file mode 100644
iindex 0000000..ed02504
---- /dev/null
++++ b/public/css/cam.css
@@@ -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; }
ddiff --git a/public/img/cam-grid.svg b/public/img/cam-grid.svg
nnew file mode 100644
iindex 0000000..8739eef
---- /dev/null
++++ b/public/img/cam-grid.svg
@@@ -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>
ddiff --git a/public/img/cam-shutter.svg b/public/img/cam-shutter.svg
nnew file mode 100644
iindex 0000000..788b05b
---- /dev/null
++++ b/public/img/cam-shutter.svg
@@@ -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>
ddiff --git a/public/js/cam.js b/public/js/cam.js
nnew file mode 100644
iindex 0000000..02da2b4
---- /dev/null
++++ b/public/js/cam.js
@@@ -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(); });
++ });
++})();
ddiff --git a/scripts/seed-proof.js b/scripts/seed-proof.js
nnew file mode 100644
iindex 0000000..8e9e5e7
---- /dev/null
++++ b/scripts/seed-proof.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..ad99818
---- /dev/null
++++ b/server/app.js
@@@ -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 };
ddiff --git a/server/auth/keys.js b/server/auth/keys.js
nnew file mode 100644
iindex 0000000..e6ca151
---- /dev/null
++++ b/server/auth/keys.js
@@@ -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 };
ddiff --git a/server/auth/sso.js b/server/auth/sso.js
nnew file mode 100644
iindex 0000000..130bfad
---- /dev/null
++++ b/server/auth/sso.js
@@@ -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 };
ddiff --git a/server/cameras.js b/server/cameras.js
nnew file mode 100644
iindex 0000000..7dcebef
---- /dev/null
++++ b/server/cameras.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..e3c453b
---- /dev/null
++++ b/server/config.js
@@@ -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 };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..b56f711
---- /dev/null
++++ b/server/db.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
nnew file mode 100644
iindex 0000000..7739d09
---- /dev/null
++++ b/server/http/api.js
@@@ -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 };
ddiff --git a/server/http/discovery.js b/server/http/discovery.js
nnew file mode 100644
iindex 0000000..6d99de9
---- /dev/null
++++ b/server/http/discovery.js
@@@ -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 };
ddiff --git a/server/http/pages.js b/server/http/pages.js
nnew file mode 100644
iindex 0000000..70d6a24
---- /dev/null
++++ b/server/http/pages.js
@@@ -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 };
ddiff --git a/server/http/principal.js b/server/http/principal.js
nnew file mode 100644
iindex 0000000..92f141e
---- /dev/null
++++ b/server/http/principal.js
@@@ -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 };
ddiff --git a/server/http/router.js b/server/http/router.js
nnew file mode 100644
iindex 0000000..e6e845d
---- /dev/null
++++ b/server/http/router.js
@@@ -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 };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
nnew file mode 100644
iindex 0000000..a8c44c0
---- /dev/null
++++ b/server/identity/account-data.js
@@@ -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 };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..fcaf346
---- /dev/null
++++ b/server/index.js
@@@ -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 };
ddiff --git a/server/observability.js b/server/observability.js
nnew file mode 100644
iindex 0000000..6f7d391
---- /dev/null
++++ b/server/observability.js
@@@ -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 };
ddiff --git a/server/render/html.js b/server/render/html.js
nnew file mode 100644
iindex 0000000..2769b87
---- /dev/null
++++ b/server/render/html.js
@@@ -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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 };
ddiff --git a/server/render/layout.js b/server/render/layout.js
nnew file mode 100644
iindex 0000000..d1a4189
---- /dev/null
++++ b/server/render/layout.js
@@@ -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 };
ddiff --git a/server/store.js b/server/store.js
nnew file mode 100644
iindex 0000000..a00e67d
---- /dev/null
++++ b/server/store.js
@@@ -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,
++};
ddiff --git a/server/upstreams.js b/server/upstreams.js
nnew file mode 100644
iindex 0000000..18a0ead
---- /dev/null
++++ b/server/upstreams.js
@@@ -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 };
ddiff --git a/server/views/index.js b/server/views/index.js
nnew file mode 100644
iindex 0000000..739513a
---- /dev/null
++++ b/server/views/index.js
@@@ -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 &lt;viewer's Network token&gt;</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/&lt;id&gt;</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,
++};
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
nnew file mode 100644
iindex 0000000..de497cf
---- /dev/null
++++ b/test/account-data.test.js
@@@ -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();
++})();
ddiff --git a/test/cameras.test.js b/test/cameras.test.js
nnew file mode 100644
iindex 0000000..ce91b80
---- /dev/null
++++ b/test/cameras.test.js
@@@ -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();
++})();
ddiff --git a/test/grants.test.js b/test/grants.test.js
nnew file mode 100644
iindex 0000000..119e33f
---- /dev/null
++++ b/test/grants.test.js
@@@ -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();
++})();
ddiff --git a/test/helpers/boot.js b/test/helpers/boot.js
nnew file mode 100644
iindex 0000000..3673172
---- /dev/null
++++ b/test/helpers/boot.js
@@@ -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 };
ddiff --git a/test/helpers/db.js b/test/helpers/db.js
nnew file mode 100644
iindex 0000000..8f34304
---- /dev/null
++++ b/test/helpers/db.js
@@@ -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 };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
nnew file mode 100644
iindex 0000000..99bc99b
---- /dev/null
++++ b/test/helpers/mocks.js
@@@ -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 };
ddiff --git a/test/helpers/upstreams.js b/test/helpers/upstreams.js
nnew file mode 100644
iindex 0000000..08e08db
---- /dev/null
++++ b/test/helpers/upstreams.js
@@@ -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 };
ddiff --git a/test/migration.test.js b/test/migration.test.js
nnew file mode 100644
iindex 0000000..7e3a2e5
---- /dev/null
++++ b/test/migration.test.js
@@@ -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();
++})();
ddiff --git a/test/pages.test.js b/test/pages.test.js
nnew file mode 100644
iindex 0000000..c742ba5
---- /dev/null
++++ b/test/pages.test.js
@@@ -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();
++})();
ddiff --git a/test/photos.test.js b/test/photos.test.js
nnew file mode 100644
iindex 0000000..ae63583
---- /dev/null
++++ b/test/photos.test.js
@@@ -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();
++})();
ddiff --git a/test/run.js b/test/run.js
nnew file mode 100644
iindex 0000000..58588b0
---- /dev/null
++++ b/test/run.js
@@@ -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); });
ddiff --git a/test/seed-proof.test.js b/test/seed-proof.test.js
nnew file mode 100644
iindex 0000000..d6d4455
---- /dev/null
++++ b/test/seed-proof.test.js
@@@ -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();
++})();
ddiff --git a/test/sso.test.js b/test/sso.test.js
nnew file mode 100644
iindex 0000000..d6a9d29
---- /dev/null
++++ b/test/sso.test.js
@@@ -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();
++})();
ddiff --git a/test/stills.test.js b/test/stills.test.js
nnew file mode 100644
iindex 0000000..3f52dc5
---- /dev/null
++++ b/test/stills.test.js
@@@ -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();
++})();