openvibers/openvibe.locker Commits

Build Locker v1 encrypted Drive and expiring Sends (#1)

OP OpenVibers committed 5 days ago · 84af2691cb · 41 files, +6486 −1

* Build Locker encrypted Drive, sharing and expiring Sends

* Count staged replacement ciphertext against each person's quota

* Add resumable ciphertext uploads below Cloudflare request limits

* Count streamed bodies when retrying a received upload part

* Pin openvibe-shared v4.156.0; the unit runs as this host's services do (ubuntu, /opt/openvibe.locker, /var/lib/openvibe-locker)

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
nnew file mode 100644
iindex 0000000..2a764e6
---- /dev/null
++++ b/.github/workflows/ci.yml
@@@ -0,0 +1,29 @@
++name: ci
++on:
++ push:
++ branches: [main]
++ pull_request:
++ workflow_dispatch:
++permissions:
++ contents: read
++jobs:
++ test:
++ runs-on: ubuntu-24.04
++ steps:
++ - uses: actions/checkout@v4
++ - uses: actions/setup-node@v4
++ with:
++ node-version: 22.22.1
++ cache: npm
++ - run: npm ci --no-audit --no-fund
++ - run: npm run check
++ - run: npm test
++ - run: npx playwright install --with-deps chromium
++ - run: npm run test:browser
++ - uses: actions/upload-artifact@v4
++ if: always()
++ with:
++ name: locker-local-screenshots
++ path: artifacts/
++ security:
++ uses: OpenVibers/OpenVibe.Shared/.github/workflows/security.yml@e80c595ff3d4bc46b7e0f41fdb040b985885cb30
ddiff --git a/.gitignore b/.gitignore
nnew file mode 100644
iindex 0000000..d99c57d
---- /dev/null
++++ b/.gitignore
@@@ -0,0 +1,5 @@
++node_modules/
++data/
++artifacts/
++.env
++*.local.js
ddiff --git a/README.md b/README.md
iindex 2e9ec2f..3946245 100644
---- a/README.md
++++ b/README.md
@@@ -1,2 +1,102 @@
# OpenVibe.Locker
--Private files and expiring Sends, encrypted on your device
++
++A front door for private files and expiring Sends. Node 22, Express/CommonJS and PostgreSQL via `openvibe-sdk/db`.
++The server stores ciphertext and opaque access metadata. It never seals, opens, indexes, previews, transcodes or
++runs AI on content. The cryptographic format and operations are the existing `locker.envelope.v1` and
++`openvibe-sdk/locker`; the device UI and Share sheet are Shared's `ov-locker.js`, `ov-locker-core.js` and `ov-share.js`.
++
++## Run and verify
++
++`npm ci`, then `npm start`. Without database settings, development uses PGlite in `data/pglite`.
++Network SSO and private Media storage need the service credentials configured by the operator; there is no fake
++production backend or sample content. `npm test` uses isolated PGlite databases, real WebCrypto and local adapters.
++`npm run test:browser` starts a local test instance, exercises the real browser worker and saves 390/1280 screenshots
++in both themes to `artifacts/`. `npm run check` checks syntax. Browser checks use Playwright's Chromium or local Chrome.
++
++Production requires PostgreSQL, a separate migration role, the Locker Network principal and the private Media
++Locker tenant. [Launch handoff](deploy/LAUNCH.md) names the exact configuration, port 5145, drill port 15145,
++`ov_locker`, `openvibe-locker.service` and `/etc/openvibe/locker.env`. No host or DNS changes are made here.
++
++## Pages
++
++- `/`: device lock status and the front door.
++- `/drive`: encrypted files, folders, rename and delete. Names appear only after the browser decrypts them.
++- `/files/:id`: open, download, rename, the people Share sheet, and Report.
++- `/sends`: create a link-locked file or note, choose 1 hour to 30 days and an optional download-attempt limit.
++- `/s/:id#k=<base64url>`: anonymous opening; the key never leaves the fragment. Each content request consumes a claim,
++ including disconnected downloads. GET metadata and HEAD do not consume one.
++- `/recovery`: Shared setup, passphrase or supported device passkey, and a one-time 24-word kit with print/save.
++- `/staff`: Network staff can review reports, disable items and restrict uploads.
++- `/docs`: Locked items on OpenVibe, the SDK example and API links. See [OpenAPI](public/openapi.json) and [llms.txt](public/llms.txt).
++
++If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.
++
++## Private payload
++
++The whole payload below is sealed by the existing SDK. This is an application payload inside the envelope, not a
++new cryptographic format. The server never parses it:
++
++1. Four bytes, unsigned big-endian, giving the JSON metadata length (at most 65536).
++2. UTF-8 JSON `{ version: 1, name, type, size, folder? }` (original filename, MIME type and true byte count).
++3. Exactly `size` content bytes. A folder has zero content bytes. Text Sends use UTF-8 and `text/plain`.
++
++`public/js/payload.js` is usable from Node and a browser. `prefix(meta)` gives the framing bytes; `pack(meta, stream)`
++and `unpack(stream)` work with WHATWG streams. Feed the packed stream to `locker.sealStream` with its exact plaintext
++size. The worker processes SDK chunks of 1 MiB into OPFS; no whole large file is buffered. Opening streams to an
++atomic file writer; final verification must succeed before the writable is committed. The bounded Blob fallback
++is 32 MiB. Opening a list label reads only its first authenticated chunk and cancels the remaining content stream.
++
++## Storage and access
++
++Single-request ciphertext routes accept `application/octet-stream` with Content-Length up to **90 MiB**. The server counts
++actual received bytes, bounds the header at 64 KiB and uses `openvibe-sdk/locker/server.inspectEnvelopePrefix` to
++validate shape and the exact expected ciphertext length. Drive starts with the owner's people slot only. Sends
++are link locks. Unknown plaintext metadata is rejected. The browser enforces the true file limit; the server can
++only enforce the corresponding padded size, since true sizes are encrypted.
++
++Browser uploads use resumable sessions, so every request fits Cloudflare's 100 MB limit:
++
++1. `POST /api/v1/uploads` with JSON `{ kind: "file" | "folder" | "send", bytes, parent?, expires_in?, download_limit? }`.
++ `bytes` is the entire encrypted envelope size; this reserves quota and returns `{ id, part_size: 33554432 }`.
++2. `PUT /api/v1/uploads/:id/parts/:n` with an octet-stream slice, numbered from zero. Each part is exactly
++ 32 MiB except the last, which is exactly the remaining bytes. Received parts are immutable and retryable.
++3. `GET /api/v1/uploads/:id` lists `received_parts`. Resume only the missing parts, including after a server restart.
++4. `POST /api/v1/uploads/:id/complete` checks all parts and the envelope, stores it in Media and returns the item.
++ Completion retries return the same result for 24 hours; GET also returns that result with `state: "complete"`.
++ A session expires after one hour without a new part. Expired staging files and reservations are removed by the due job.
++
++Use `kind: "replace"` or `"reseal"` with `item_id` and the item's `If-Match` revision on initiation **and completion**.
++Replace returns the updated item; reseal returns the receipt for the Share API below. Completion rechecks standing,
++revision and recipients. The worker retries lost part/completion responses and queries received parts before retrying.
++The encrypted OPFS file and Send key remain with that worker; closing the page requires starting a new browser upload.
++A server interruption during Media completion leaves the reservation for expiry cleanup; retry a new session after expiry.
++
++Accounts must be signed in, at least seven days old and not banned/deleted in Network's current
++`/internal/identity/resolve?subject_id=…&trust=1` response. Local moderation restrictions always apply.
++`LOCKER_ALLOWLIST_ONLY` can additionally restrict the launch cohort. PostgreSQL locks the account row to reserve
++quota before accepting an upload, including the full size of each staged replacement. Renaming or changing sharing needs enough free quota for the incoming encrypted copy until it commits. Defaults: 5 GiB per account, 2 GiB per file,
++60 uploads/hour, 20 Sends/hour. Server metadata contains the owner, IDs, padded/ciphertext sizes, times and access
++rules, never names or MIME types. Media sees only a private `file`, `application/octet-stream` and `locked.ovlk`.
++
++For sharing a large file, seal a new envelope in the browser for the owner and all remaining recipients, then:
++
++1. Complete a resumable `reseal` session with `item_id` and `If-Match: "<item revision>"`.
++ Envelopes up to 90 MiB can also use `POST /api/v1/files/:id/reseals`.
++2. `PUT /api/v1/files/:id/share` with `{ members: [{ subject, role: "viewer", key_slot }], envelope_ref }`, preferably
++ with the Share GET's ETag in `If-Match`. The SDK checks recipients and fresh-key removal; Locker checks that the
++ staged upload belongs to this owner, file and revision. The Media reference, quota and ACL commit together.
++3. An unconsumed receipt expires after one hour. Old objects and abandoned uploads are queued for erasure.
++
++The nginx vhost limits ordinary JSON requests to 64 KiB. Use staged receipts for sharing; only tiny envelopes fit inline. Large reseals carry a
++short receipt, never a giant JSON body. Names and every content byte are re-sealed locally; a departing member's
++old key cannot open the new envelope. Recipients may still keep a prior copy. Folder membership is organizational;
++sharing a folder does not grant its children. Transfer and the password-manager phase are outside v1.
++
++Expiry, user deletion and replacement queue durable Media erasure. A bounded due-based job retries failures; access
++ends at the due time even during a Media outage. `DELETE ?purge=1` is reserved to the Locker principal in Media and
++has no restore window. Reports carry a reason code, never a content key. Staff changes are recorded locally.
++
++ADR-033 export provides an opaque manifest with IDs and download routes; each envelope is downloadable separately
++while the account exists. Network owns the wrapped-key export. Account deletion denies all access immediately,
++queues ciphertext erasure, cancels pending deliveries and removes memberships. Append-only access/moderation
++history and an account deletion marker are retained as security evidence; they contain no plaintext or keys.
ddiff --git a/STATUS.json b/STATUS.json
nnew file mode 100644
iindex 0000000..d895574
---- /dev/null
++++ b/STATUS.json
@@@ -0,0 +1,25 @@
++{
++ "service": "OpenVibe.Locker",
++ "version": "0.1.0",
++ "status": "review",
++ "domain": "openvibe.locker",
++ "port": 5145,
++ "drill_port": 15145,
++ "database": "ov_locker",
++ "features": [
++ "encrypted-drive",
++ "folders",
++ "encrypted-rename",
++ "people-sharing",
++ "expiring-sends",
++ "device-unlock",
++ "recovery-kit",
++ "reports",
++ "account-data"
++ ],
++ "launch": "Not deployed. Requires companion PR review, service credentials, database roles and an Events subscription before launch.",
++ "repository": "OpenVibers/OpenVibe.Locker",
++ "stage": "alpha",
++ "deployed": false,
++ "updated": "2026-11-08"
++}
ddiff --git a/deploy/LAUNCH.md b/deploy/LAUNCH.md
nnew file mode 100644
iindex 0000000..4026cb0
---- /dev/null
++++ b/deploy/LAUNCH.md
@@@ -0,0 +1,42 @@
++# Operator launch handoff
++
++Nothing in this change deploys, changes DNS or accesses the host. Opus reviews the key-related changes before merging.
++
++1. Review and merge [SDK #141](https://github.com/OpenVibers/OpenVibe.SDK/pull/141), [Shared #347](https://github.com/OpenVibers/OpenVibe.Shared/pull/347), [Network #677](https://github.com/OpenVibers/OpenVibe.Network/pull/677) and [Media #357](https://github.com/OpenVibers/OpenVibe.Media/pull/357).
++ SDK v0.101.0 is pinned. Shared remains on the reviewed commit until v4.156.0 is tagged; check the release pin before launch.
++2. Provision PostgreSQL database `ov_locker` through the Host service-role workflow. Give the migration owner a direct
++ `DATABASE_DIRECT_URL` and runtime role a PgBouncer `DATABASE_URL`; only the migration role creates tables.
++3. Register the `locker` OAuth client with callback `https://openvibe.locker/auth/callback`. The Network PR seeds its
++ metadata and least-privilege grants. Use Network's normal credential setup; no credentials belong in this repository.
++4. Supply `/etc/openvibe/locker.env`: `BASE_URL=https://openvibe.locker`, the two database URLs,
++ `OV_OAUTH_CLIENT_SECRET`, `LOCKER_EVENTS_SECRET`, `OV_NETWORK_URL`, `OV_NETWORK_INTERNAL_URL`,
++ `OV_MEDIA_URL`, and `OV_EVENTS_URL`. `LOCKER_DATA_DIR` must have room for concurrent encrypted upload staging.
++ Restrict write access to the service. Media's multipart limit must accommodate at least 2304 MiB of padded ciphertext.
++5. Run the drill on `PORT=15145`, its own writable directory and drill database, then check `/api/health`, Network SSO,
++ key setup/unlock, a full browser upload/open, sharing and expiry erasure. Never point a drill at the live database.
++6. Install the reviewed unit as `openvibe-locker.service` and `deploy/nginx/openvibe.locker.conf` through `ov-vhost-install`.
++ It proxies to `127.0.0.1:5145`: part requests allow 33 MiB, legacy single-envelope routes 91 MiB (90 MiB in the app),
++ and everything else 64 KiB. Upload buffering is off with 600-second body/send timeouts; content downloads disable
++ response buffering and allow 1800-second reads. Completion also allows 1800 seconds for the Media handoff.
++ Do not log query strings, cookies, bearer tokens or request bodies. Never rewrite a Send fragment into a query.
++7. Verify both ADR-033 Events subscriptions at boot and the export/deletion grants together. Stop launch if the
++ delivery subscriptions, Media tenant, service grants or migrations are missing. No contents are indexed or sent to AI.
++
++Storage settings: `LOCKER_QUOTA_BYTES` defaults to 5 GiB and includes reserved ciphertext; `LOCKER_MAX_FILE_BYTES`
++defaults to 2 GiB of plaintext in the browser. Replacements reserve their full incoming size, so renaming/sharing requires temporary free quota. The server cannot see true sizes, so it enforces the corresponding
++maximum padded envelope size. `LOCKER_MIN_ACCOUNT_DAYS=7`, `LOCKER_UPLOADS_PER_HOUR=60`, `LOCKER_SENDS_PER_HOUR=20`.
++`LOCKER_ALLOWLIST_ONLY=true` additionally requires the subject in `LOCKER_UPLOAD_ALLOWLIST` (comma-separated).
++Network ban/deletion and age checks still apply. Local staff restrictions always apply.
++
++Cleanup selects due rows, runs bounded batches and schedules one timer for the next due time, capped at one minute.
++Media erasure failures remain in the outbox with backoff. Access ends immediately on expiry/disable/deletion even if
++Media is unavailable; bytes are erased when it recovers. Retention holds remain visible failures and never count as erased.
++Resumable uploads use 32 MiB parts and renew their one-hour deadline on each new part. One-hour abandoned uploads release their reservations and queue any remote object for deletion.
++
++Browser capabilities: OPFS keeps large encrypted upload staging out of heap. Without OPFS the UI limits uploads to
++32 MiB. Downloads over 32 MiB require File System Access for a writable destination and commit only after the final
++chunk verifies; smaller files work through a Blob download. Passkey setup requires WebAuthn PRF and recovery is always
++available through the saved kit. Only a credential id and PRF input are kept locally, bound to the account and origin.
++
++Known product boundary: folder sharing does not recursively grant access to children; share each file. Ownership
++transfer, password manager, blind fill, Photos and Scanner are later phases. A shared item is a viewer-only file.
ddiff --git a/deploy/nginx/openvibe.locker.conf b/deploy/nginx/openvibe.locker.conf
nnew file mode 100644
iindex 0000000..84c3896
---- /dev/null
++++ b/deploy/nginx/openvibe.locker.conf
@@@ -0,0 +1,94 @@
++## openvibe.locker — reference vhost for ov-vhost-install.
++## Locker answers pages and /api/ on 127.0.0.1:5145.
++# Log the method and path only. Fragments remain in the browser; query strings are never logged.
++log_format locker_noquery '$remote_addr - $remote_user [$time_local] '
++ '"$request_method $uri $server_protocol" $status $body_bytes_sent';
++limit_req_zone $binary_remote_addr zone=locker_auth:10m rate=10r/m;
++limit_req_zone $binary_remote_addr zone=locker_api:10m rate=5r/s;
++
++server {
++ listen 80;
++ listen [::]:80;
++ server_name openvibe.locker www.openvibe.locker;
++ access_log /var/log/nginx/openvibe.locker.access.log locker_noquery;
++ client_max_body_size 64k;
++ location /.well-known/acme-challenge/ { root /var/www/certbot; }
++ location / { return 301 https://openvibe.locker$request_uri; }
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name www.openvibe.locker;
++ access_log /var/log/nginx/openvibe.locker.access.log locker_noquery;
++ client_max_body_size 64k;
++ ssl_certificate /etc/letsencrypt/live/openvibe.locker/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.locker/privkey.pem;
++ return 301 https://openvibe.locker$request_uri;
++}
++
++server {
++ listen 443 ssl;
++ listen [::]:443 ssl;
++ http2 on;
++ server_name openvibe.locker;
++ ssl_certificate /etc/letsencrypt/live/openvibe.locker/fullchain.pem;
++ ssl_certificate_key /etc/letsencrypt/live/openvibe.locker/privkey.pem;
++ access_log /var/log/nginx/openvibe.locker.access.log locker_noquery;
++ error_log /var/log/nginx/openvibe.locker.error.log;
++ client_max_body_size 64k;
++ add_header X-Content-Type-Options nosniff always;
++ add_header Referrer-Policy no-referrer always;
++ add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
++ gzip on;
++ gzip_proxied any;
++ gzip_vary on;
++ gzip_comp_level 5;
++ gzip_min_length 1024;
++ gzip_types text/css application/javascript application/json application/problem+json text/plain;
++ 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 X-Forwarded-Proto $scheme;
++ location = /auth/me { limit_req zone=locker_api burst=30 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5145; }
++ location /auth/ { limit_req zone=locker_auth burst=10 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5145; }
++ location ~ ^/api/v1/uploads/[^/]+/parts/ {
++ limit_req zone=locker_api burst=20 nodelay;
++ limit_req_status 429;
++ client_max_body_size 33m;
++ proxy_request_buffering off;
++ client_body_timeout 600s;
++ proxy_send_timeout 600s;
++ proxy_pass http://127.0.0.1:5145;
++ }
++ location ~ ^/api/v1/(files|sends)/[^/]+/content$ {
++ limit_req zone=locker_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_buffering off;
++ proxy_read_timeout 1800s;
++ proxy_pass http://127.0.0.1:5145;
++ }
++ # The application enforces 90 MiB on these legacy single-envelope routes.
++ location ~ ^/api/v1/((files|sends)|files/[^/]+(/reseals)?)$ {
++ limit_req zone=locker_api burst=20 nodelay;
++ limit_req_status 429;
++ client_max_body_size 91m;
++ proxy_request_buffering off;
++ client_body_timeout 600s;
++ proxy_send_timeout 600s;
++ proxy_read_timeout 1800s;
++ proxy_pass http://127.0.0.1:5145;
++ }
++ location ~ ^/api/v1/uploads/[^/]+/complete$ {
++ limit_req zone=locker_api burst=20 nodelay;
++ limit_req_status 429;
++ proxy_read_timeout 1800s;
++ proxy_pass http://127.0.0.1:5145;
++ }
++ location /api/ { limit_req zone=locker_api burst=20 nodelay; limit_req_status 429; proxy_pass http://127.0.0.1:5145; }
++ location ~* ^/(metrics|internal)(/|$) { return 404; }
++ location ~ /\.(?!well-known) { deny all; }
++ location / { proxy_pass http://127.0.0.1:5145; }
++}
ddiff --git a/deploy/systemd/openvibe-locker.service b/deploy/systemd/openvibe-locker.service
nnew file mode 100644
iindex 0000000..419dc11
---- /dev/null
++++ b/deploy/systemd/openvibe-locker.service
@@@ -0,0 +1,29 @@
++[Unit]
++Description=OpenVibe Locker
++After=network-online.target
++Wants=network-online.target
++
++[Service]
++Type=simple
++User=ubuntu
++Group=ubuntu
++WorkingDirectory=/opt/openvibe.locker
++Environment=NODE_ENV=production
++Environment=PORT=5145
++Environment=HOST=127.0.0.1
++Environment=LOCKER_DATA_DIR=/var/lib/openvibe-locker
++EnvironmentFile=/etc/openvibe/locker.env
++ExecStart=/usr/bin/node server/index.js
++Restart=on-failure
++RestartSec=5
++TimeoutStopSec=1800
++KillSignal=SIGTERM
++NoNewPrivileges=true
++PrivateTmp=true
++ProtectSystem=strict
++ProtectHome=true
++ReadWritePaths=/var/lib/openvibe-locker
++UMask=0077
++
++[Install]
++WantedBy=multi-user.target
ddiff --git a/migrations/0001_locker.sql b/migrations/0001_locker.sql
nnew file mode 100644
iindex 0000000..23fa7fb
---- /dev/null
++++ b/migrations/0001_locker.sql
@@@ -0,0 +1,66 @@
++-- phase: expand
++CREATE TABLE locker_accounts (
++ subject text PRIMARY KEY,
++ restricted boolean NOT NULL DEFAULT false,
++ deleted boolean NOT NULL DEFAULT false,
++ used_bytes bigint NOT NULL DEFAULT 0 CHECK (used_bytes >= 0),
++ reserved_bytes bigint NOT NULL DEFAULT 0 CHECK (reserved_bytes >= 0)
++);
++CREATE TABLE locker_items (
++ id uuid PRIMARY KEY,
++ owner text NOT NULL REFERENCES locker_accounts(subject),
++ kind text NOT NULL CHECK (kind IN ('file', 'folder', 'send')),
++ parent uuid REFERENCES locker_items(id),
++ media_id text,
++ envelope_bytes bigint NOT NULL CHECK (envelope_bytes > 0),
++ lock text NOT NULL CHECK (lock IN ('people', 'password', 'link')),
++ header jsonb NOT NULL,
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL,
++ expires_at timestamptz,
++ download_limit integer CHECK (download_limit > 0),
++ downloads integer NOT NULL DEFAULT 0,
++ disabled boolean NOT NULL DEFAULT false,
++ deleted_at timestamptz,
++ revision integer NOT NULL DEFAULT 1,
++ CHECK ((kind = 'send') = (expires_at IS NOT NULL))
++);
++CREATE INDEX locker_items_owner ON locker_items(owner, created_at DESC);
++CREATE INDEX locker_items_due ON locker_items(expires_at) WHERE deleted_at IS NULL;
++CREATE TABLE locker_uploads (
++ id uuid PRIMARY KEY,
++ owner text NOT NULL REFERENCES locker_accounts(subject),
++ bytes bigint NOT NULL,
++ media_id text,
++ total_bytes bigint,
++ target uuid,
++ revision integer,
++ prefix bytea,
++ due_at timestamptz NOT NULL
++);
++CREATE TABLE locker_gc (
++ media_id text PRIMARY KEY,
++ due_at timestamptz NOT NULL,
++ attempts integer NOT NULL DEFAULT 0
++);
++CREATE INDEX locker_gc_due ON locker_gc(due_at);
++CREATE TABLE locker_rates (
++ bucket text PRIMARY KEY,
++ window_start bigint NOT NULL,
++ attempts integer NOT NULL
++);
++CREATE TABLE locker_reports (
++ id uuid PRIMARY KEY,
++ item_id uuid NOT NULL REFERENCES locker_items(id),
++ reporter text,
++ reason text NOT NULL CHECK (reason IN ('abuse', 'illegal', 'malware', 'other')),
++ created_at timestamptz NOT NULL,
++ resolved_at timestamptz
++);
++CREATE TABLE locker_moderation (
++ id uuid PRIMARY KEY,
++ actor text NOT NULL,
++ target text NOT NULL,
++ action text NOT NULL,
++ created_at timestamptz NOT NULL
++);
ddiff --git a/migrations/0002_resumable_uploads.sql b/migrations/0002_resumable_uploads.sql
nnew file mode 100644
iindex 0000000..49a3815
---- /dev/null
++++ b/migrations/0002_resumable_uploads.sql
@@@ -0,0 +1,18 @@
++-- phase: expand
++ALTER TABLE locker_uploads ADD COLUMN options jsonb;
++ALTER TABLE locker_uploads ADD COLUMN completing boolean NOT NULL DEFAULT false;
++CREATE TABLE locker_upload_parts (
++ upload_id uuid NOT NULL REFERENCES locker_uploads(id) ON DELETE CASCADE,
++ part integer NOT NULL CHECK (part >= 0),
++ bytes bigint NOT NULL CHECK (bytes > 0),
++ PRIMARY KEY(upload_id, part)
++);
++-- Completion retries after a lost HTTP response return the original item/receipt.
++CREATE TABLE locker_upload_results (
++ id uuid PRIMARY KEY,
++ owner text NOT NULL REFERENCES locker_accounts(subject),
++ result jsonb NOT NULL,
++ due_at timestamptz NOT NULL
++);
++CREATE INDEX locker_upload_results_due ON locker_upload_results(due_at);
++CREATE INDEX locker_uploads_due ON locker_uploads(due_at);
ddiff --git a/package-lock.json b/package-lock.json
nnew file mode 100644
iindex 0000000..663f93e
---- /dev/null
++++ b/package-lock.json
@@@ -0,0 +1,1575 @@
++{
++ "name": "openvibe-locker",
++ "version": "0.1.0",
++ "lockfileVersion": 3,
++ "requires": true,
++ "packages": {
++ "": {
++ "name": "openvibe-locker",
++ "version": "0.1.0",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "@electric-sql/pglite": "^0.5.8",
++ "express": "^4.21.2",
++ "helmet": "^8.1.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0",
++ "pg": "^8.23.0"
++ },
++ "devDependencies": {
++ "playwright": "^1.55.1"
++ },
++ "engines": {
++ "node": ">=22.0.0"
++ }
++ },
++ "node_modules/@electric-sql/pglite": {
++ "version": "0.5.15",
++ "resolved": "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.5.15.tgz",
++ "integrity": "sha512-ERZWgaRTxueGV4cvL1tfpAOzFCWTXoUtbCBqnf2FhHDVs/so9eAKaanvrwWF9Dntx7BYsmFQujVQ0/B4S8Mmiw==",
++ "license": "Apache-2.0"
++ },
++ "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/boolbase": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-2.0.0.tgz",
++ "integrity": "sha512-DkVaaQHymRhpYEYo9x1oo7Q7B0Y6KJUsjm3c9eTyFDby4MHLBTwZ6ZDWBel5zrYxj1WsZgC5oLpiz+93MluXeA==",
++ "license": "ISC",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "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/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-signature": {
++ "version": "1.0.7",
++ "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
++ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
++ "license": "MIT"
++ },
++ "node_modules/css-select": {
++ "version": "7.0.0",
++ "resolved": "https://registry.npmjs.org/css-select/-/css-select-7.0.0.tgz",
++ "integrity": "sha512-snmjEVXy+1LnwXdxhYvTMj1d9tOh4HxkA1YmoayVBeeyR2C14Pum7fcxJIm4SswYspVy866eYNwlH6xC3/VH5g==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0",
++ "css-what": "^8.0.0",
++ "domhandler": "^6.0.1",
++ "domutils": "^4.0.2",
++ "nth-check": "^3.0.1"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/css-what": {
++ "version": "8.0.0",
++ "resolved": "https://registry.npmjs.org/css-what/-/css-what-8.0.0.tgz",
++ "integrity": "sha512-DH0Bqq3DNp5tdOReuNyAA+Ev4Y2GS5FMbZpeTLP6C4CDi0h5nL0BmUPChXw3o/qbHLDWHl49sbNqQVY7bMSDdw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ },
++ "node_modules/cssomnom": {
++ "version": "0.1.11",
++ "resolved": "https://registry.npmjs.org/cssomnom/-/cssomnom-0.1.11.tgz",
++ "integrity": "sha512-P4RkO1Sq2Ual77xypEmAc4MkM5k2Ha4UwtBPCOpJmXculGCYPgzsU/xnOCy084esZaPePgGkkJg4Il/HE7GS5A==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=20.0.0"
++ }
++ },
++ "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/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/dom-serializer": {
++ "version": "3.1.1",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz",
++ "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0",
++ "entities": "^8.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/domelementtype": {
++ "version": "3.0.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz",
++ "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ }
++ },
++ "node_modules/domhandler": {
++ "version": "6.0.1",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz",
++ "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^3.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/domutils": {
++ "version": "4.0.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz",
++ "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^3.0.0",
++ "domelementtype": "^3.0.0",
++ "domhandler": "^6.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "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.1",
++ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.1.tgz",
++ "integrity": "sha512-Ev/hIyePLZtpL98x562lvPtenrUfGr0lfaf44HFZ+hDEwso7ccV9QAZrEVLg0hxNAzygaAFxy2dYaAKKM0G06A==",
++ "license": "MIT",
++ "engines": {
++ "node": ">= 0.8"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/entities": {
++ "version": "8.1.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz",
++ "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "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/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/html-escaper": {
++ "version": "3.0.3",
++ "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-3.0.3.tgz",
++ "integrity": "sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==",
++ "license": "MIT"
++ },
++ "node_modules/htmlparser2": {
++ "version": "10.1.0",
++ "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz",
++ "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==",
++ "funding": [
++ "https://github.com/fb55/htmlparser2?sponsor=1",
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3",
++ "domutils": "^3.2.2",
++ "entities": "^7.0.1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz",
++ "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==",
++ "license": "MIT",
++ "dependencies": {
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.2",
++ "entities": "^4.2.0"
++ },
++ "funding": {
++ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/dom-serializer/node_modules/entities": {
++ "version": "4.5.0",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
++ "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domelementtype": {
++ "version": "2.3.0",
++ "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz",
++ "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fb55"
++ }
++ ],
++ "license": "BSD-2-Clause"
++ },
++ "node_modules/htmlparser2/node_modules/domhandler": {
++ "version": "5.0.3",
++ "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz",
++ "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "domelementtype": "^2.3.0"
++ },
++ "engines": {
++ "node": ">= 4"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domhandler?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/domutils": {
++ "version": "3.2.2",
++ "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz",
++ "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "dom-serializer": "^2.0.0",
++ "domelementtype": "^2.3.0",
++ "domhandler": "^5.0.3"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/domutils?sponsor=1"
++ }
++ },
++ "node_modules/htmlparser2/node_modules/entities": {
++ "version": "7.0.1",
++ "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz",
++ "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==",
++ "license": "BSD-2-Clause",
++ "engines": {
++ "node": ">=0.12"
++ },
++ "funding": {
++ "url": "https://github.com/fb55/entities?sponsor=1"
++ }
++ },
++ "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/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/linkedom": {
++ "version": "0.18.15",
++ "resolved": "https://registry.npmjs.org/linkedom/-/linkedom-0.18.15.tgz",
++ "integrity": "sha512-o0w7eD/SvoCao+pUi6dzocCLuMR11g4Tv/et0W2OUzwWmUcewntSzyl+Ua/K+0yKym0Y/Mav3nFAaI5Av5jvZg==",
++ "license": "ISC",
++ "dependencies": {
++ "css-select": "^7.0.0",
++ "cssomnom": "^0.1.11",
++ "html-escaper": "^3.0.3",
++ "htmlparser2": "^10.1.0",
++ "uhyphen": "^0.2.0"
++ },
++ "engines": {
++ "node": ">=16"
++ },
++ "peerDependencies": {
++ "canvas": ">= 2"
++ },
++ "peerDependenciesMeta": {
++ "canvas": {
++ "optional": true
++ }
++ }
++ },
++ "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.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/nth-check": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-3.0.1.tgz",
++ "integrity": "sha512-GX0gsdbGVCgnRgbeGaubfjpBXyYRWOOCVeYh08bSQvDZqxz5ndXs1OTfAt/h36G1xvI94YIspsI0sVFqAV9+RQ==",
++ "license": "BSD-2-Clause",
++ "dependencies": {
++ "boolbase": "^2.0.0"
++ },
++ "engines": {
++ "node": ">=20.19.0"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/fb55/nth-check?sponsor=1"
++ }
++ },
++ "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.346.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
++ "integrity": "sha512-ncmROx6i7VQa6l1FitWZhPArFEYskrIbUWTdMHKty7c5m6OIYkjh8GRm9HxzfwH9vD89isTzQiX16yfU5NYHhw==",
++ "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.101.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",
++ "integrity": "sha512-W0WVhpc4cL0zJOW7IRAzz1polzR1MU571SMyDUEh9CeJWlocENlISbz9rr78AZmrcmqCvAn+25D3aLTNQpO7Ug==",
++ "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.156.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0",
++ "integrity": "sha512-2GCk8sILS2FH3mKu7boRwc73TxMJjsaC1kgOf4j3qpHJnENlRC52HWrFg7neF/0m4XsZL3x2EvMC0FSM4bTsKw==",
++ "license": "MIT",
++ "dependencies": {
++ "jsonwebtoken": "^9.0.0",
++ "linkedom": "^0.18.13"
++ },
++ "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/playwright": {
++ "version": "1.65.0",
++ "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.65.0.tgz",
++ "integrity": "sha512-F3AMWcR44qMBt4kq+6Kb6mX7bjGXshktKFOPULd7reED6ZJWevjbNRqysP7q+AATE9kdPEZBibex1BYsvpVoeA==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "dependencies": {
++ "playwright-core": "1.65.0"
++ },
++ "bin": {
++ "playwright": "cli.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "node_modules/playwright-core": {
++ "version": "1.65.0",
++ "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.65.0.tgz",
++ "integrity": "sha512-vNSKgH6YqtN7cdT/L334zo0a+YwkrpicyrHJlWWPIm/Q+Nn2SWInil01oT0mbu96U4GgoRtCYQD9CzJQgju+lA==",
++ "dev": true,
++ "license": "Apache-2.0",
++ "bin": {
++ "playwright-core": "cli.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
++ "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/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.6",
++ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.6.tgz",
++ "integrity": "sha512-9lpSSU4Mly3mrtruwLs1eLK6+gzbzqeBFwRNjIjE+9LuDpWdYctrQlQ3ej1Ilgi7gknpsTRnPoLP8DpCHqHIJw==",
++ "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/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/uhyphen": {
++ "version": "0.2.0",
++ "resolved": "https://registry.npmjs.org/uhyphen/-/uhyphen-0.2.0.tgz",
++ "integrity": "sha512-qz3o9CHXmJJPGBdqzab7qAYuW8kQGKNEuoHFYrBwV6hWIMcpAmxDLXojcHfFr9US1Pe6zUswEIJIbLI610fuqA==",
++ "license": "ISC"
++ },
++ "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..ab52a21
---- /dev/null
++++ b/package.json
@@@ -0,0 +1,29 @@
++{
++ "name": "openvibe-locker",
++ "version": "0.1.0",
++ "private": true,
++ "description": "Private files and expiring Sends, encrypted on your device",
++ "main": "server/index.js",
++ "license": "AGPL-3.0-only",
++ "engines": {
++ "node": ">=22.0.0"
++ },
++ "scripts": {
++ "start": "node server/index.js",
++ "test": "node --test --test-concurrency=1 test/*.test.js",
++ "check": "node scripts/check.js",
++ "test:browser": "node test/browser.js"
++ },
++ "dependencies": {
++ "express": "^4.21.2",
++ "helmet": "^8.1.0",
++ "pg": "^8.23.0",
++ "@electric-sql/pglite": "^0.5.8",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.346.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.101.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.156.0"
++ },
++ "devDependencies": {
++ "playwright": "^1.55.1"
++ }
++}
ddiff --git a/public/.well-known/agent.json b/public/.well-known/agent.json
nnew file mode 100644
iindex 0000000..11b260d
---- /dev/null
++++ b/public/.well-known/agent.json
@@@ -0,0 +1,8 @@
++{
++ "name": "OpenVibe.Locker",
++ "description": "Private files and expiring Sends; encrypt and decrypt with the Locker SDK on your device.",
++ "url": "https://openvibe.locker",
++ "documentation": "https://openvibe.locker/docs",
++ "openapi": "https://openvibe.locker/openapi.json",
++ "authentication": "Network user bearer token for uploads and Drive; no account for opening a Send"
++}
ddiff --git a/public/css/locker.css b/public/css/locker.css
nnew file mode 100644
iindex 0000000..e0027f9
---- /dev/null
++++ b/public/css/locker.css
@@@ -0,0 +1,10 @@
++:root{--lk-bg:#f4f5ef;--lk-panel:#fff;--lk-text:#17281f;--lk-muted:#526258;--lk-border:#d8ded5;--lk-accent:#b9ed8b;--lk-shadow:0 16px 60px #19302308}html{background:var(--lk-bg);color:var(--lk-text)}body{margin:0;font-family:Inter,ui-sans-serif,system-ui,sans-serif}.locker-page{max-width:1160px;margin:auto;padding:36px 28px 90px}.locker-page *{box-sizing:border-box}.locker-page h1,.locker-page h2,.locker-page p{margin-top:0}.locker-page h1{font-size:clamp(44px,7.2vw,92px);line-height:1;letter-spacing:-.065em;font-weight:580;margin-bottom:24px}.locker-page h2{font-size:26px;line-height:1.2;letter-spacing:-.035em;font-weight:580}.locker-page p{line-height:1.7;color:var(--lk-muted);max-width:70ch}.eyebrow,.number{font-size:11px;font-weight:700;letter-spacing:.13em;color:var(--lk-muted);display:block;margin-bottom:18px}.hero{display:grid;grid-template-columns:1.4fr 1fr;gap:35px;align-items:center;padding:38px 0 58px}.hero h1{font-size:clamp(72px,9vw,116px)}.hero em{font-family:Georgia,serif;font-weight:400;color:#4b7844}.hero .lead{font-size:19px;line-height:1.65;margin-bottom:28px}.actions{display:flex;gap:10px;align-items:center;flex-wrap:wrap}.locker-page button,.locker-page .button{display:inline-flex;align-items:center;justify-content:center;gap:20px;min-height:46px;border-radius:8px;border:1px solid var(--lk-border);padding:10px 18px;background:var(--lk-panel);color:var(--lk-text);font-family:inherit;font-size:14px;font-weight:600;line-height:1.4;text-decoration:none;cursor:pointer}.locker-page .primary{background:var(--lk-text);color:var(--lk-bg);border-color:var(--lk-text)}.locker-page button:hover,.locker-page .button:hover{filter:brightness(.94)}.locker-page button:disabled{cursor:default;opacity:.55}.locker-page :focus-visible{outline:3px solid #659947;outline-offset:3px}.panel{background:var(--lk-panel);border:1px solid var(--lk-border);border-radius:14px;padding:28px;box-shadow:var(--lk-shadow)}.device{display:flex;gap:24px;justify-content:space-between;align-items:center;margin:0 0 26px}.device h2{font-size:20px;margin-bottom:7px}.device p{font-size:14px;margin-bottom:0;overflow-wrap:anywhere}.device .eyebrow{font-size:10px;margin-bottom:10px}.grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}.feature{text-decoration:none;color:var(--lk-text);display:block;padding:32px}.feature h2{font-size:34px;margin:30px 0 16px}.link-label{display:inline-block;font-size:14px;font-weight:650;margin-top:15px}.plain{padding:42px 0 20px}.plain h2{font-size:28px}.locker-page a:not(.button):not(.feature){color:inherit;text-underline-offset:4px}.lock-art{min-height:340px;position:relative;border-radius:50%;background:radial-gradient(ellipse,#ccebb6aa,transparent 69%);display:flex;align-items:center;justify-content:center}.lock-body{position:absolute;top:140px;width:148px;height:134px;border-radius:22px;background:linear-gradient(135deg,#d7f1c3,#96c575);box-shadow:inset 1px 1px 3px #ffffffb0,16px 22px 34px #45693118;transform:rotate(-7deg)}.lock-body span{display:block;margin:48px auto;width:14px;height:39px;border-radius:7px;background:#25442c}.shackle{position:absolute;top:65px;width:106px;height:118px;border:17px solid #c0d3b5;border-bottom:0;border-radius:57px 57px 0 0;transform:rotate(-7deg);box-shadow:inset 3px 3px 3px #ffffffd0}.art-caption{position:absolute;bottom:12px;font-size:10px;font-weight:650;letter-spacing:.14em;color:#5e7950}.page-heading{padding:32px 0 26px}.page-heading p{font-size:17px}.page-heading .eyebrow{font-size:11px}.storage{margin:18px 0;font-size:14px;color:var(--lk-muted)}.section-heading{display:flex;justify-content:space-between;gap:18px;align-items:baseline}.upload-grid{display:grid;grid-template-columns:1.3fr 1fr;gap:30px;padding:15px 0 28px;border-bottom:1px solid var(--lk-border)}.locker-page form label{display:grid;gap:10px;font-size:14px;font-weight:600;margin:0 0 18px}.locker-page input,.locker-page select,.locker-page textarea{width:100%;min-width:0;min-height:46px;font:inherit;color:var(--lk-text);background:var(--lk-bg);border:1px solid var(--lk-border);border-radius:7px;padding:11px}.locker-page input[type=file]{font-size:12px}.locker-page textarea{resize:vertical}.item-row{display:flex;gap:20px;align-items:center;justify-content:space-between;border-bottom:1px solid var(--lk-border);padding:20px 0}.item-row:last-child{border-bottom:0}.item-row .title{font-weight:600;overflow-wrap:anywhere}.item-row p{font-size:13px;margin:7px 0 0}.item-row .actions{flex-shrink:0}.empty{padding:36px 0;margin:0}.warning{padding:15px 18px;border-left:3px solid #799a5d;background:var(--lk-bg);font-size:14px}.recovery{max-width:760px}.report{margin:36px 0;max-width:550px;border-top:1px solid var(--lk-border);padding-top:25px}.report .muted{font-size:13px;margin-top:14px}.prose{max-width:900px}.prose h2{margin-top:26px}.prose pre{overflow:auto;background:var(--lk-bg);border:1px solid var(--lk-border);padding:20px;border-radius:8px;font-size:12px;line-height:1.7;max-width:100%}#notice:not(:empty){padding:16px;margin-bottom:22px;border:1px solid var(--lk-border);background:var(--lk-panel);border-radius:8px;overflow-wrap:anywhere}#notice.error{border-color:#ba6a5c}#send-result:not(:empty){padding:20px 0}#send-result input{font-size:13px}#opened{overflow-wrap:anywhere}#opened pre{white-space:pre-wrap;font:inherit;line-height:1.65}#rename-form{margin-top:30px;max-width:540px}#progress{margin:18px 0 0;font-size:14px}#folders{margin-top:20px}
++@media(prefers-color-scheme:dark){:root{--lk-bg:#141c17;--lk-panel:#1c2720;--lk-text:#e5eddf;--lk-muted:#adbaac;--lk-border:#344237;--lk-shadow:none}.hero em{color:#b9df9d}.lock-art{background:radial-gradient(ellipse,#56784244,transparent 70%)}.art-caption{color:#aecb9b}}
++@media(max-width:700px){.locker-page{padding:22px 18px 60px}.hero{grid-template-columns:minmax(0,1fr);padding:28px 0;gap:0}.hero h1{font-size:80px}.hero .lead{font-size:17px}.lock-art{min-height:280px;max-width:330px;width:100%;margin:auto}.shackle{top:30px}.lock-body{top:104px}.art-caption{bottom:4px}.device{align-items:flex-start;flex-direction:column;gap:18px}.panel{padding:22px}.grid,.upload-grid{grid-template-columns:minmax(0,1fr)}.feature h2{font-size:30px}.page-heading{padding-top:24px}.item-row{align-items:flex-start;flex-direction:column;gap:12px}.section-heading{flex-wrap:wrap}.prose{padding:20px}.eyebrow,.number{font-size:12px}.device .eyebrow,.page-heading .eyebrow{font-size:12px}.art-caption{font-size:12px}.actions{width:100%}.locker-page h1{overflow-wrap:anywhere}}
++
++/* Follow the Shared theme selected by the person, including the first paint. */
++:root{--lk-bg:var(--bg-primary,#f4f5ef);--lk-panel:var(--bg-card,var(--bg-secondary,#fff));--lk-text:var(--text-primary,#17281f);--lk-muted:var(--text-secondary,#526258);--lk-border:var(--border,#d8ded5)}
++html body{background:var(--lk-bg);color:var(--lk-text)}.locker-page,.locker-page h1,.locker-page h2,.locker-page h3{color:var(--lk-text)}
++html[data-theme-mode=dark] .hero em{color:#b9df9d}html[data-theme-mode=light] .hero em{color:#426b36}
++html[data-theme-mode=dark] .art-caption{color:#aecb9b}html[data-theme-mode=light] .art-caption{color:#456233}
++.locker-page .ov-sel-trigger{min-height:46px}
ddiff --git a/public/icon.svg b/public/icon.svg
nnew file mode 100644
iindex 0000000..3f6130b
---- /dev/null
++++ b/public/icon.svg
@@@ -0,0 +1 @@
++<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="16" fill="#1c3023"/><path d="M22 29v-7a10 10 0 0 1 20 0v7" fill="none" stroke="#b9ed8b" stroke-width="5"/><rect x="16" y="27" width="32" height="25" rx="6" fill="#b9ed8b"/><path d="M32 36v8" stroke="#1c3023" stroke-width="4" stroke-linecap="round"/></svg>
ddiff --git a/public/js/app.js b/public/js/app.js
nnew file mode 100644
iindex 0000000..91ee9d5
---- /dev/null
++++ b/public/js/app.js
@@@ -0,0 +1,275 @@
++(async function () {
++ 'use strict';
++ const page = window.LockerPage;
++ const L = window.OVLocker;
++ const $ = (s) => document.querySelector(s);
++ const make = (tag, text, cls) => { const e = document.createElement(tag); if (text != null) e.textContent = text; if (cls) e.className = cls; return e; };
++ const notice = (message, bad = false) => { $('#notice').textContent = message || ''; $('#notice').classList.toggle('error', bad); };
++ const progress = (message) => { if ($('#progress')) $('#progress').textContent = message; };
++ const size = (bytes) => bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GiB` : bytes >= 1024 ** 2 ? `${(bytes / 1024 ** 2).toFixed(1)} MiB` : `${Math.ceil(bytes / 1024)} KiB`;
++ const action = (text, fn) => { const b = make('button', text); b.type = 'button'; b.addEventListener('click', () => run(b, fn)); return b; };
++ let me = null, items = [], currentFolder = new URL(location.href).searchParams.get('folder') || '', fileInfo;
++ const names = new Map();
++ async function api(url, options = {}) {
++ const r = await fetch(url, { credentials: 'same-origin', ...options, headers: { 'X-Requested-With': 'locker', ...(options.headers || {}) } });
++ if (!r.ok) { const body = await r.json().catch(() => ({})); throw Object.assign(new Error(body.detail || body.error || `Request failed (${r.status}).`), { status: r.status }); }
++ return r.status === 204 ? null : r.json();
++ }
++ async function run(control, fn) {
++ control.disabled = true; notice('');
++ try { await fn(); } catch (e) { if (e.name !== 'AbortError') notice(e.message, true); }
++ finally { control.disabled = false; }
++ }
++ function signedIn() { if (!me) throw new Error('Sign in to OpenVibe to use your locker.'); }
++ function uploadsAllowed() { signedIn(); if (!me.eligible) throw new Error(me.reason); }
++ function state() {
++ const unlocked = L.state().unlocked;
++ if ($('#lock-state')) $('#lock-state').textContent = unlocked ? 'Your locker is unlocked' : 'Your locker is locked';
++ if ($('#fingerprint')) $('#fingerprint').textContent = unlocked ? L.state().fingerprint : 'Unlock to open your files. Your keys stay here.';
++ document.querySelectorAll('[data-action=lock]').forEach((b) => { b.disabled = !unlocked; });
++ }
++ async function ownKeys() {
++ signedIn(); await L.ensure();
++ const keys = await L.publicKeys();
++ return [{ subject: { type: 'user', id: me.subject }, publicKey: keys.ecdh }];
++ }
++ async function recipients(header) {
++ const keys = await ownKeys();
++ for (const slot of header.slots.people) {
++ if (slot.subject.id === me.subject) continue;
++ const k = await L.keys(slot.subject);
++ if (!k) throw new Error('Someone on this file’s list no longer has a locker. Update its sharing list first.');
++ keys.push({ subject: slot.subject, publicKey: k.ecdh_public || k.ecdh });
++ }
++ return keys;
++ }
++ async function sealed(data) {
++ const worker = new Worker('/js/seal-worker.js');
++ const result = await new Promise((resolve, reject) => {
++ worker.onerror = () => { worker.terminate(); reject(new Error('The file worker could not finish.')); };
++ worker.onmessage = ({ data: out }) => {
++ if (out.type === 'progress') progress(`Locking on this device · ${size(out.bytes)}`);
++ if (out.type === 'error') { worker.terminate(); reject(new Error(out.message)); }
++ if (out.type === 'sealed') resolve(out);
++ };
++ worker.postMessage(data, data.stream ? [data.stream] : []);
++ });
++ return { ...result,
++ upload(options, revision) {
++ return new Promise((resolve, reject) => {
++ worker.onerror = () => reject(new Error('The upload worker could not finish.'));
++ worker.onmessage = ({ data: out }) => {
++ if (out.type === 'upload-progress') progress(`Uploading encrypted bytes · ${size(out.bytes)}`);
++ if (out.type === 'retry') progress('Connection interrupted. Resuming encrypted upload…');
++ if (out.type === 'error') reject(new Error(out.message));
++ if (out.type === 'uploaded') resolve(out.result);
++ };
++ worker.postMessage({ type: 'upload', options, revision });
++ });
++ },
++ release() { worker.postMessage({ type: 'release' }); },
++ };
++ }
++ async function uploadFile(file, opts = {}) {
++ uploadsAllowed();
++ if (file.size > me.max_file_bytes) throw new Error(`Choose a file up to ${size(me.max_file_bytes)}.`);
++ const send = opts.send;
++ const meta = { name: file.name || opts.name || 'Private note', type: file.type || 'application/octet-stream', size: file.size, folder: !!opts.folder };
++ const locked = await sealed({ file, meta, lock: send ? 'link' : 'people', keys: send ? undefined : await ownKeys() });
++ try {
++ progress('Uploading encrypted bytes…');
++ const item = await locked.upload(send ? { kind: 'send', expires_in: Number(opts.expires),
++ ...(opts.limit ? { download_limit: Number(opts.limit) } : {}) }
++ : { kind: opts.folder ? 'folder' : 'file', ...(opts.parent ? { parent: opts.parent } : {}) });
++ names.set(item.id, meta.name);
++ progress('Saved. Only encrypted bytes left this device.');
++ if (send) {
++ const key = btoa(String.fromCharCode(...locked.key)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '');
++ const url = `${location.origin}/s/${item.id}#k=${key}`;
++ const box = $('#send-result'); box.replaceChildren(make('h3', 'Your private link'));
++ const input = make('input'); input.value = url; input.readOnly = true; input.setAttribute('aria-label', 'Private Send link');
++ box.append(input, action('Copy full link', async () => { await navigator.clipboard.writeText(url); notice('Link copied.'); }),
++ make('p', 'Keep the full link now. We cannot recover its key for you.'));
++ }
++ await refresh();
++ } finally { locked.release(); }
++ }
++ async function openFile(id, send = false) {
++ const key = send ? L.keyFromLink() : null;
++ if (send && !key) throw new Error('This link is missing its key. Ask the sender for the complete link, including the part after #.');
++ if (!send) await L.ensure();
++ const r = await fetch(`/api/v1/${send ? 'sends' : 'files'}/${id}/content`, { credentials: 'same-origin' });
++ if (!r.ok) { const err = await r.json().catch(() => ({})); throw new Error(err.detail || 'The locked item could not be opened.'); }
++ return LockerPayload.unpack(await L.openStream(r.body, send ? { key } : undefined));
++ }
++ async function fileName(id) {
++ const opened = await openFile(id);
++ await opened.stream.cancel(); // Only the first authenticated chunk is needed for this encrypted label.
++ return opened.meta;
++ }
++ async function download(send) {
++ const info = await api(`/api/v1/${send ? 'sends' : 'files'}/${page.id}`);
++ if (send && !L.keyFromLink()) throw new Error('The full link needs its key after #. Ask the sender for the complete link.');
++ let handle;
++ // Pick the destination before consuming a limited Send. The atomic writable is committed only after final verification.
++ if (info.envelope_bytes > 32 * 1024 ** 2) {
++ if (!window.showSaveFilePicker) throw new Error('For files over 32 MiB, open this page in a browser that supports saving a streamed file.');
++ handle = await showSaveFilePicker({ suggestedName: names.get(page.id) || 'private-file' });
++ }
++ const { meta, stream } = await openFile(page.id, send);
++ progress('Opening and checking this file on your device…');
++ if (handle) {
++ const writer = await handle.createWritable();
++ try { await stream.pipeTo(writer); } catch (e) { await writer.abort().catch(() => {}); throw e; }
++ } else {
++ const reader = stream.getReader(); const chunks = []; let count = 0;
++ for (;;) { const p = await reader.read(); if (p.done) break; count += p.value.length; if (count > 32 * 1024 ** 2) { await reader.cancel(); throw new Error('Use a browser with streamed file saving for this file.'); } chunks.push(p.value); }
++ const blob = new Blob(chunks, { type: meta.type });
++ if (meta.type === 'text/plain' && meta.size <= 1024 ** 2 && send) {
++ const pre = make('pre', await blob.text()); $('#opened').replaceChildren(pre);
++ }
++ if (!meta.folder) {
++ const url = URL.createObjectURL(blob); const a = make('a', `Save ${meta.name}`); a.className = 'button'; a.href = url; a.download = meta.name;
++ $('#opened').append(a); a.click(); setTimeout(() => { URL.revokeObjectURL(url); a.removeAttribute('href'); }, 120000);
++ }
++ }
++ if ($('#file-name')) $('#file-name').textContent = meta.name;
++ $('#item-description').textContent = `${meta.name} · ${size(meta.size)} · Opened on this device`;
++ progress('Verified and opened.');
++ }
++ function showStorage() {
++ if ($('#account') && me) $('#account').textContent = `${size(me.used_bytes)} of ${size(me.quota_bytes)} used${me.reserved_bytes ? ` · ${size(me.reserved_bytes)} uploading` : ''} · Up to ${size(me.max_file_bytes)} per file${me.eligible ? '' : ` · ${me.reason}`}`;
++ }
++ async function refresh() {
++ if (!me) return;
++ if ($('#account')) { me = await api('/api/v1/me'); showStorage(); }
++ if (page.mode === 'file') {
++ fileInfo = await api(`/api/v1/files/${page.id}`);
++ document.querySelectorAll('[data-owner-only]').forEach((e) => { e.hidden = !fileInfo.can_edit; });
++ return;
++ }
++ if (!['drive', 'sends', 'shared'].includes(page.mode)) return;
++ if (page.mode === 'shared') {
++ const result = await api('/api/v1/shared-with-me');
++ items = result.items.map((r) => ({ id: r.ref.id, kind: 'file' }));
++ } else {
++ const result = await api(`/api/v1/${page.mode === 'sends' ? 'sends' : 'files'}`);
++ items = result.items;
++ // Follow the cursor so a full Drive is not silently hidden after the first page.
++ let cursor = result.next_cursor;
++ while (cursor) {
++ const more = await api(`/api/v1/${page.mode === 'sends' ? 'sends' : 'files'}?before=${encodeURIComponent(cursor)}`);
++ items.push(...more.items); cursor = more.items.length === 100 ? more.next_cursor : null;
++ }
++ }
++ drawItems();
++ if (['drive', 'shared'].includes(page.mode) && L.state().unlocked) {
++ for (const item of items) {
++ if (item.disabled || names.has(item.id)) continue;
++ try { const meta = await fileName(item.id); names.set(item.id, meta.name); } catch { /* A lost key leaves a locked label. */ }
++ }
++ drawItems();
++ }
++ }
++ function drawItems() {
++ const box = $('#items'); if (!box) return;
++ box.replaceChildren();
++ const visible = page.mode === 'drive' ? items.filter((i) => (i.parent || '') === currentFolder) : items;
++ if (!visible.length) box.append(make('p', page.mode === 'sends' ? 'No Sends yet. Create one above when you have something to share.' : 'No locked items here yet.', 'empty'));
++ for (const item of visible) {
++ const row = make('div', null, 'item-row'); const description = make('div');
++ const label = names.get(item.id) || (item.kind === 'send' ? 'A private Send' : item.kind === 'folder' ? 'Locked folder' : 'Locked file');
++ const link = make('a', label, 'title');
++ link.href = item.kind === 'folder' ? `/drive?folder=${item.id}` : item.kind === 'send' ? `/s/${item.id}` : `/files/${item.id}`;
++ description.append(link);
++ const details = item.kind === 'send' ? `Expires ${new Date(item.expires_at).toLocaleString()} · ${item.downloads}${item.download_limit ? ` of ${item.download_limit}` : ''} downloads` : item.envelope_bytes ? `${size(item.envelope_bytes)} encrypted` : 'Shared with you';
++ description.append(make('p', item.disabled ? 'Disabled by staff' : details));
++ const actions = make('div', null, 'actions');
++ if (page.mode !== 'shared') actions.append(action('Delete', async () => {
++ if (!confirm(`Delete ${label}?${item.kind === 'folder' ? ' Its files will return to Drive.' : ''}`)) return;
++ await api(`/api/v1/${item.kind === 'send' ? 'sends' : 'files'}/${item.id}`, { method: 'DELETE' }); await refresh();
++ }));
++ row.append(description, actions); box.append(row);
++ }
++ if ($('#upload-parent')) {
++ $('#upload-parent').replaceChildren(new Option('Drive', ''));
++ for (const item of items.filter((i) => i.kind === 'folder' && !i.disabled)) $('#upload-parent').append(new Option(names.get(item.id) || `Locked folder · ${item.id.slice(0, 8)}`, item.id));
++ $('#upload-parent').value = currentFolder;
++ const home = make('a', currentFolder ? '← Back to Drive' : 'Drive'); home.href = '/drive'; $('#folders').replaceChildren(home);
++ }
++ }
++ document.addEventListener('ov-locker:change', () => {
++ state();
++ if (L.state().unlocked) refresh().catch((e) => notice(e.message, true));
++ else {
++ names.clear(); if ($('#opened')) $('#opened').replaceChildren();
++ if ($('#file-name')) $('#file-name').textContent = 'A locked file.';
++ if ($('#item-description')) $('#item-description').textContent = 'Its name and content are encrypted.';
++ $('#rename-form')?.reset(); drawItems();
++ }
++ });
++ document.querySelectorAll('[data-action]').forEach((b) => b.addEventListener('click', () => run(b, async () => {
++ if (b.dataset.action === 'setup') { signedIn(); await L.setup(); }
++ if (b.dataset.action === 'unlock') { signedIn(); await L.ensure(); }
++ if (b.dataset.action === 'lock') L.forget();
++ if (b.dataset.action === 'download') await download(false);
++ if (b.dataset.action === 'open-send') await download(true);
++ if (b.dataset.action === 'share') {
++ signedIn(); await L.ensure();
++ await OVShare.open({ kind: 'files', id: page.id, url: `${location.origin}/files/${page.id}`, title: 'A locked item', envelope_url: `/api/v1/files/${page.id}/content`, headers: { 'X-Requested-With': 'locker' },
++ prepareEnvelope: async ({ keys }) => {
++ const info = await api(`/api/v1/files/${page.id}`);
++ const opened = await openFile(page.id);
++ const encrypted = await sealed({ stream: opened.stream, meta: opened.meta, lock: 'people', keys });
++ try {
++ return await encrypted.upload({ kind: 'reseal', item_id: page.id }, info.revision);
++ } finally { encrypted.release(); }
++ },
++ });
++ }
++ })));
++ function form(id, fn) {
++ const f = $(id); if (!f) return;
++ f.addEventListener('submit', (e) => { e.preventDefault(); run(e.submitter || f.querySelector('button.primary,button[type=submit]'), () => fn(new FormData(f), f)); });
++ }
++ form('#upload-form', async (data, f) => { const file = data.get('file'); if (!file?.name) throw new Error('Choose a file.'); await uploadFile(file, { parent: data.get('parent') }); f.reset(); });
++ form('#folder-form', async (data, f) => { const name = data.get('name').trim(); if (!name) throw new Error('Give the folder a name.'); await uploadFile(new File([], name), { folder: true, parent: currentFolder }); f.reset(); });
++ form('#send-form', async (data, f) => {
++ const file = data.get('file'), text = data.get('text');
++ if (!!file?.name === !!text) throw new Error('Choose one file or write one note.');
++ await uploadFile(file?.name ? file : new File([text], 'Private note.txt', { type: 'text/plain' }), { send: true, expires: data.get('expires'), limit: data.get('limit') }); f.reset();
++ });
++ form('#rename-form', async (data) => {
++ uploadsAllowed(); fileInfo = await api(`/api/v1/files/${page.id}`);
++ const keys = await recipients(fileInfo.header);
++ const opened = await openFile(page.id);
++ const name = data.get('name').trim(); if (!name) throw new Error('Give this item a name.');
++ const encrypted = await sealed({ stream: opened.stream, meta: { ...opened.meta, name }, lock: 'people', keys });
++ try {
++ await encrypted.upload({ kind: 'replace', item_id: page.id }, fileInfo.revision);
++ $('#file-name').textContent = name; progress('Renamed inside the lock.');
++ } finally { encrypted.release(); }
++ });
++ form('#report-form', async (data) => { await api(`/api/v1/${page.mode === 'send' ? 'sends' : 'files'}/${page.id}/report`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ reason: data.get('reason') }) }); notice('Report received. Thank you.'); });
++ state();
++ try {
++ me = await api('/api/v1/me'); window.OpenVibeLockerConfig.subject = me.subject;
++ showStorage();
++ await refresh();
++ if (page.mode === 'staff') {
++ const out = await api('/api/v1/staff/reports'); const box = $('#reports');
++ if (!out.reports.length) box.append(make('p', 'No reports.'));
++ for (const report of out.reports) {
++ const row = make('div', null, 'item-row'); row.append(make('p', `${report.reason} · ${report.item_id} · ${report.owner}`));
++ row.append(action('Disable item', () => api(`/api/v1/staff/items/${report.item_id}/disable`, { method: 'POST' })),
++ action('Restrict uploads', () => api(`/api/v1/staff/people/${report.owner}/restriction`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: '{"restricted":true}' })));
++ box.append(row);
++ }
++ }
++ } catch (e) {
++ if (e.status === 401 && !['send', 'docs'].includes(page.mode)) {
++ const a = make('a', 'Sign in to OpenVibe'); a.href = `/auth/login?next=${encodeURIComponent(location.pathname)}`;
++ $('#notice').replaceChildren(a);
++ } else if (e.status !== 401) notice(e.message, true);
++ }
++})();
ddiff --git a/public/js/payload.js b/public/js/payload.js
nnew file mode 100644
iindex 0000000..6501436
---- /dev/null
++++ b/public/js/payload.js
@@@ -0,0 +1,65 @@
++/* The private file description is part of the plaintext given to the existing Locker SDK. */
++(function (root) {
++ 'use strict';
++ const encoder = new TextEncoder();
++ function prefix(meta) {
++ const json = encoder.encode(JSON.stringify({ version: 1, ...meta }));
++ if (json.length > 65536) throw new Error('The file description is too long.');
++ const bytes = new Uint8Array(4 + json.length);
++ new DataView(bytes.buffer).setUint32(0, json.length);
++ bytes.set(json, 4);
++ return bytes;
++ }
++ function pack(meta, stream) {
++ const first = prefix(meta);
++ let sent = false, reader;
++ return new ReadableStream({
++ async pull(controller) {
++ if (!sent) { sent = true; controller.enqueue(first); return; }
++ reader ||= stream.getReader();
++ const chunk = await reader.read();
++ if (chunk.done) { reader.releaseLock(); controller.close(); } else controller.enqueue(chunk.value);
++ },
++ async cancel(reason) { if (reader) await reader.cancel(reason); else await stream.cancel(reason); },
++ });
++ }
++ async function unpack(stream) {
++ const reader = stream.getReader();
++ let buffer = new Uint8Array(0);
++ async function take(n) {
++ while (buffer.length < n) {
++ const part = await reader.read();
++ if (part.done) throw new Error('The locked file is incomplete.');
++ const next = new Uint8Array(buffer.length + part.value.length); next.set(buffer); next.set(part.value, buffer.length); buffer = next;
++ }
++ const out = buffer.slice(0, n); buffer = buffer.slice(n); return out;
++ }
++ try {
++ const len = new DataView((await take(4)).buffer).getUint32(0);
++ if (len < 2 || len > 65536) throw new Error('The locked file description is invalid.');
++ const meta = JSON.parse(new TextDecoder('utf-8', { fatal: true }).decode(await take(len)));
++ if (meta.version !== 1 || typeof meta.name !== 'string' || meta.name.length > 1024 || typeof meta.type !== 'string'
++ || !Number.isSafeInteger(meta.size) || meta.size < 0) throw new Error('The locked file description is invalid.');
++ let received = 0;
++ const content = new ReadableStream({
++ async pull(controller) {
++ const part = buffer.length ? { value: buffer, done: false } : await reader.read(); buffer = new Uint8Array(0);
++ if (part.done) {
++ reader.releaseLock();
++ if (received !== meta.size) throw new Error('The locked file size does not match.');
++ controller.close();
++ } else {
++ received += part.value.length;
++ if (received > meta.size) throw new Error('The locked file is longer than expected.');
++ controller.enqueue(part.value);
++ }
++ },
++ cancel: (reason) => reader.cancel(reason),
++ });
++ return { meta, stream: content };
++ } catch (e) { await reader.cancel(e).catch(() => {}); throw e; }
++ }
++ const api = { prefix, pack, unpack };
++ if (typeof module !== 'undefined') module.exports = api;
++ else root.LockerPayload = api;
++})(globalThis);
ddiff --git a/public/js/seal-worker.js b/public/js/seal-worker.js
nnew file mode 100644
iindex 0000000..7b1dca8
---- /dev/null
++++ b/public/js/seal-worker.js
@@@ -0,0 +1,84 @@
++'use strict';
++importScripts('/shared/ov-locker-core.js', '/js/payload.js');
++let rootDir, handle, name, sealedFile;
++const pause = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
++async function request(url, options = {}) {
++ const response = await fetch(url, { credentials: 'same-origin', ...options,
++ headers: { 'X-Requested-With': 'locker', ...options.headers } });
++ const data = await response.json();
++ if (!response.ok) throw Object.assign(new Error(data.detail || 'Upload failed.'), { status: response.status, code: data.code });
++ return data;
++}
++async function retry(action) {
++ for (let attempt = 0; ; attempt++) {
++ try { return await action(); }
++ catch (e) {
++ if (attempt >= 7 || (e.status && e.status < 500 && e.status !== 429 && e.code !== 'locker.upload.busy')) throw e;
++ self.postMessage({ type: 'retry' });
++ await pause(Math.min(1000 * 2 ** attempt, 15000));
++ }
++ }
++}
++async function upload(options, revision) {
++ if (!sealedFile) throw new Error('Lock the file before uploading.');
++ const headers = revision ? { 'If-Match': `"${revision}"` } : {};
++ // Do not retry session creation: a lost POST response must not reserve quota repeatedly.
++ // Parts and completion are idempotent; their status survives connection drops and server restarts.
++ const session = await request('/api/v1/uploads', { method: 'POST', headers: { ...headers, 'Content-Type': 'application/json' },
++ body: JSON.stringify({ ...options, bytes: sealedFile.size }) });
++ const base = `/api/v1/uploads/${session.id}`;
++ for (let n = 0; n < Math.ceil(sealedFile.size / session.part_size); n++) {
++ await retry(async () => {
++ const state = await request(base);
++ if (state.state === 'complete' || state.received_parts.includes(n)) return;
++ await request(`${base}/parts/${n}`, { method: 'PUT', headers: { 'Content-Type': 'application/octet-stream' },
++ body: sealedFile.slice(n * session.part_size, Math.min((n + 1) * session.part_size, sealedFile.size), 'application/octet-stream') });
++ });
++ self.postMessage({ type: 'upload-progress', bytes: Math.min((n + 1) * session.part_size, sealedFile.size) });
++ }
++ return retry(() => request(`${base}/complete`, { method: 'POST', headers }));
++}
++self.onmessage = async ({ data }) => {
++ if (data.type === 'release') {
++ if (rootDir && name) await rootDir.removeEntry(name).catch(() => {});
++ self.close(); return;
++ }
++ try {
++ if (data.type === 'upload') {
++ self.postMessage({ type: 'uploaded', result: await upload(data.options, data.revision) });
++ return;
++ }
++ const { meta, keys, lock } = data;
++ const source = data.stream || data.file.stream();
++ const packed = LockerPayload.pack(meta, source);
++ const size = LockerPayload.prefix(meta).length + meta.size;
++ // Platform CSPRNG, used only to retain the link key returned to this browser. All sealing is SDK code.
++ const contentKey = lock === 'link' ? crypto.getRandomValues(new Uint8Array(32)) : undefined;
++ const stream = OVLockerCore.sealStream(packed, { lock, keys, size, contentKey });
++ const parts = [];
++ let writer, written = 0;
++ if (navigator.storage?.getDirectory) {
++ rootDir = await navigator.storage.getDirectory();
++ name = `locker-${crypto.randomUUID()}.ovlk`;
++ handle = await rootDir.getFileHandle(name, { create: true });
++ writer = await handle.createWritable();
++ } else if (size > 32 * 1024 ** 2) throw new Error('This browser can lock files up to 32 MiB. Use a browser with private file storage for larger files.');
++ const reader = stream.getReader();
++ try {
++ for (;;) {
++ const part = await reader.read();
++ if (part.done) break;
++ if (writer) await writer.write(part.value); else parts.push(part.value);
++ written += part.value.length;
++ self.postMessage({ type: 'progress', bytes: written });
++ }
++ if (writer) await writer.close();
++ } catch (e) { if (writer) await writer.abort().catch(() => {}); throw e; }
++ const file = handle ? await handle.getFile() : new Blob(parts, { type: 'application/octet-stream' });
++ sealedFile = file;
++ self.postMessage({ type: 'sealed', key: contentKey || null });
++ } catch (e) {
++ if (rootDir && name) await rootDir.removeEntry(name).catch(() => {});
++ self.postMessage({ type: 'error', message: e.message });
++ }
++};
ddiff --git a/public/llms.txt b/public/llms.txt
nnew file mode 100644
iindex 0000000..4d14720
---- /dev/null
++++ b/public/llms.txt
@@@ -0,0 +1,28 @@
++# OpenVibe.Locker
++
++Private files and expiring Sends. Seal and open only on your device with openvibe-sdk/locker.
++The service stores opaque locker.envelope.v1 ciphertext in OpenVibe.Media's private locker namespace.
++Never send plaintext, filenames, passwords, recovery words, private keys or a Send's fragment key.
++
++- [Locked items on OpenVibe](/docs)
++- [OpenAPI](/openapi.json)
++- [Source, payload format and streaming examples](https://github.com/OpenVibers/OpenVibe.Locker)
++- [Locker SDK](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md)
++- [Sharing SDK](https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/share.md)
++
++Use a Network user bearer token. Uploads require a live account at least 7 days old, without a ban or local upload restriction.
++The default storage allowance is 5 GiB; a file may contain up to 2 GiB, with padding overhead bounded separately.
++For envelopes up to 90 MiB, POST /api/v1/files takes a people-locked envelope with the owner's slot. GET lists the owner's files; DELETE /api/v1/files/:id removes one.
++POST /api/v1/sends?expires_in=3600&download_limit=1 takes a link-locked envelope. The expiry range is 1 hour to 30 days.
++Anyone can GET /api/v1/sends/:id/content until expiry or the download-attempt limit. Keep the key in #k=, never in a query or request body.
++For people sharing, upload the new ciphertext to POST /api/v1/files/:id/reseals with If-Match, then PUT its envelope_ref and members to /api/v1/files/:id/share.
++GET /api/v1/account/export returns the ADR-033 manifest; download each envelope separately while authenticated.
++
++For larger envelopes (and all browser uploads), POST /api/v1/uploads with JSON {kind, bytes, parent?, expires_in?, download_limit?}.
++kind is file, folder or send. bytes is the complete ciphertext length. This reserves quota and returns {id, part_size:33554432}.
++PUT /api/v1/uploads/:id/parts/:n with application/octet-stream; zero-based parts are exactly 32 MiB except the last remaining bytes.
++GET /api/v1/uploads/:id returns received_parts; resume only missing parts. Successful parts extend the one-hour idle expiry.
++POST /api/v1/uploads/:id/complete validates the envelope, stores it in Media and returns the item. Retry completion safely for 24 hours.
++For replacement or sharing, initiate with kind replace or reseal plus item_id and If-Match. Send the same If-Match on completion.
++A reseal completion returns envelope_ref and header for PUT /api/v1/files/:id/share with the complete members list.
++Every ordinary JSON request is limited to 64 KiB at nginx. Prefer staged receipts over inline base64 envelopes.
ddiff --git a/public/openapi.json b/public/openapi.json
nnew file mode 100644
iindex 0000000..676b7e2
---- /dev/null
++++ b/public/openapi.json
@@@ -0,0 +1,2452 @@
++{
++ "openapi": "3.1.0",
++ "info": {
++ "title": "OpenVibe.Locker",
++ "version": "0.1.0",
++ "description": "Ciphertext in, ciphertext out. Network user bearer tokens for agents. Cookie writes also require same-origin Origin and X-Requested-With. Never send names, MIME types, plaintext, passwords, private keys or fragment keys outside an envelope."
++ },
++ "servers": [
++ {
++ "url": "https://openvibe.locker"
++ }
++ ],
++ "paths": {
++ "/api/v1/files": {
++ "get": {
++ "operationId": "listFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Opaque metadata, items and next_cursor",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "before",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "string"
++ }
++ },
++ {
++ "name": "limit",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100
++ }
++ }
++ ]
++ },
++ "post": {
++ "operationId": "createFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "201": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary",
++ "description": "Complete locker.envelope.v1 ciphertext; file details live inside the sealed payload."
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "kind",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "string",
++ "enum": [
++ "file",
++ "folder"
++ ]
++ }
++ },
++ {
++ "name": "parent",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "string",
++ "format": "uuid"
++ }
++ },
++ {
++ "name": "Content-Length",
++ "in": "header",
++ "required": true,
++ "schema": {
++ "type": "integer"
++ }
++ }
++ ],
++ "description": "Single-request envelopes are limited to 90 MiB. Use /api/v1/uploads for larger envelopes."
++ }
++ },
++ "/api/v1/files/{id}": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "get": {
++ "operationId": "getFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Opaque metadata; Sends reveal neither content nor a key",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "delete": {
++ "operationId": "deleteFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "204": {
++ "description": "Access removed; ciphertext queued for erasure",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "put": {
++ "operationId": "replaceEncryptedFile",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary",
++ "description": "Complete locker.envelope.v1 ciphertext; file details live inside the sealed payload."
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "If-Match",
++ "in": "header",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Single-request envelopes are limited to 90 MiB. Use /api/v1/uploads for larger envelopes."
++ }
++ },
++ "/api/v1/files/{id}/content": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "get": {
++ "operationId": "downloadFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Complete encrypted envelope, never decrypted",
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary"
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/files/{id}/report": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "post": {
++ "operationId": "reportFiles",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "202": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "reason": {
++ "type": "string",
++ "enum": [
++ "abuse",
++ "illegal",
++ "malware",
++ "other"
++ ]
++ }
++ },
++ "required": [
++ "reason"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/sends": {
++ "get": {
++ "operationId": "listSends",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Opaque metadata, items and next_cursor",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "before",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "string"
++ }
++ },
++ {
++ "name": "limit",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100
++ }
++ }
++ ]
++ },
++ "post": {
++ "operationId": "createSends",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "201": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary",
++ "description": "Complete locker.envelope.v1 ciphertext; file details live inside the sealed payload."
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "expires_in",
++ "in": "query",
++ "required": true,
++ "schema": {
++ "type": "integer",
++ "minimum": 3600,
++ "maximum": 2592000
++ }
++ },
++ {
++ "name": "download_limit",
++ "in": "query",
++ "required": false,
++ "schema": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100000
++ }
++ },
++ {
++ "name": "Content-Length",
++ "in": "header",
++ "required": true,
++ "schema": {
++ "type": "integer"
++ }
++ }
++ ],
++ "description": "Single-request envelopes are limited to 90 MiB. Use /api/v1/uploads for larger envelopes."
++ }
++ },
++ "/api/v1/sends/{id}": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "get": {
++ "operationId": "getSends",
++ "security": [],
++ "responses": {
++ "200": {
++ "description": "Opaque metadata; Sends reveal neither content nor a key",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "delete": {
++ "operationId": "deleteSends",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "204": {
++ "description": "Access removed; ciphertext queued for erasure",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/sends/{id}/content": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "get": {
++ "operationId": "downloadSends",
++ "security": [],
++ "responses": {
++ "200": {
++ "description": "Complete encrypted envelope, never decrypted",
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary"
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/sends/{id}/report": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "post": {
++ "operationId": "reportSends",
++ "security": [],
++ "responses": {
++ "202": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "reason": {
++ "type": "string",
++ "enum": [
++ "abuse",
++ "illegal",
++ "malware",
++ "other"
++ ]
++ }
++ },
++ "required": [
++ "reason"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/files/{id}/reseals": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "post": {
++ "operationId": "stagePeopleReseal",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "201": {
++ "description": "Returns envelope_ref and the opaque header; expires in one hour",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary",
++ "description": "Complete locker.envelope.v1 ciphertext; file details live inside the sealed payload."
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "If-Match",
++ "in": "header",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "description": "Single-request envelopes are limited to 90 MiB. Use /api/v1/uploads for larger envelopes."
++ }
++ },
++ "/api/v1/files/{id}/share": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "get": {
++ "operationId": "getSharing",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "put": {
++ "operationId": "updateSharing",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "audience": {
++ "type": "string",
++ "enum": [
++ "people",
++ "private"
++ ]
++ },
++ "members": {
++ "type": "array",
++ "maxItems": 99,
++ "items": {
++ "type": "object",
++ "required": [
++ "subject",
++ "role",
++ "key_slot"
++ ],
++ "properties": {
++ "subject": {
++ "type": "object",
++ "required": [
++ "type",
++ "id"
++ ],
++ "properties": {
++ "type": {
++ "const": "user"
++ },
++ "id": {
++ "type": "string"
++ }
++ }
++ },
++ "role": {
++ "const": "viewer"
++ },
++ "key_slot": {
++ "type": "object"
++ },
++ "expires_at": {
++ "type": "string",
++ "format": "date-time"
++ }
++ }
++ }
++ },
++ "envelope_ref": {
++ "type": "string",
++ "format": "uuid"
++ },
++ "envelope": {
++ "type": "string",
++ "contentEncoding": "base64"
++ },
++ "notify": {
++ "type": "boolean"
++ },
++ "lock": {
++ "const": "people"
++ }
++ },
++ "required": []
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "If-Match",
++ "in": "header",
++ "required": false,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ]
++ }
++ },
++ "/api/v1/shared-with-me": {
++ "get": {
++ "operationId": "sharedWithMe",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/share/people": {
++ "get": {
++ "operationId": "findPerson",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "parameters": [
++ {
++ "name": "name",
++ "in": "query",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ]
++ }
++ },
++ "/api/v1/me": {
++ "get": {
++ "operationId": "myUsageAndEligibility",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/account/export": {
++ "get": {
++ "operationId": "exportAccountManifest",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "ADR-033 part with opaque files; download content individually",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/account": {
++ "delete": {
++ "operationId": "deleteAccountLocker",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "confirmation": {
++ "const": "delete my locker"
++ }
++ },
++ "required": [
++ "confirmation"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/staff/reports": {
++ "get": {
++ "operationId": "staffReports",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Network global_mod or higher only",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/staff/items/{id}/disable": {
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "format": "uuid"
++ }
++ }
++ ],
++ "post": {
++ "operationId": "disableItem",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/staff/people/{subject}/restriction": {
++ "parameters": [
++ {
++ "name": "subject",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ }
++ }
++ ],
++ "put": {
++ "operationId": "restrictUploads",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Result",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "id": {
++ "type": "string"
++ },
++ "kind": {
++ "type": "string"
++ },
++ "envelope_bytes": {
++ "type": "integer"
++ },
++ "lock": {
++ "type": "string"
++ },
++ "revision": {
++ "type": "integer"
++ },
++ "downloads": {
++ "type": "integer"
++ },
++ "can_edit": {
++ "type": "boolean",
++ "readOnly": true,
++ "description": "Present on Drive details; only the owner can rename or change sharing."
++ }
++ }
++ }
++ }
++ }
++ },
++ "default": {
++ "description": "Problem response",
++ "content": {
++ "application/problem+json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "status": {
++ "type": "integer"
++ },
++ "code": {
++ "type": "string"
++ },
++ "detail": {
++ "type": "string"
++ }
++ }
++ }
++ }
++ }
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "additionalProperties": false,
++ "properties": {
++ "restricted": {
++ "type": "boolean"
++ }
++ },
++ "required": [
++ "restricted"
++ ]
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/me/keys/backup": {
++ "get": {
++ "summary": "Read your encrypted Network key backups through the authenticated session",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Network key directory response; private key material stays encrypted",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "401": {
++ "description": "Sign in required"
++ },
++ "403": {
++ "description": "Network requires proof or recent reauthentication"
++ }
++ }
++ }
++ },
++ "/api/v1/me/keys": {
++ "put": {
++ "summary": "Save public keys and encrypted backups in Network",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Network key directory response; private key material stays encrypted",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "401": {
++ "description": "Sign in required"
++ },
++ "403": {
++ "description": "Network requires proof or recent reauthentication"
++ }
++ },
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "description": "Network key publication shape, including public JWKs, fingerprint/self-signature and locker.envelope.v1 encrypted backups."
++ }
++ }
++ }
++ }
++ }
++ },
++ "/api/v1/people/{subject}/keys": {
++ "get": {
++ "summary": "Read a Network public key through the authenticated session",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Network key directory response; private key material stays encrypted",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "401": {
++ "description": "Sign in required"
++ },
++ "403": {
++ "description": "Network requires proof or recent reauthentication"
++ }
++ },
++ "parameters": [
++ {
++ "name": "subject",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string"
++ },
++ "description": "A user subject ID or @name"
++ }
++ ]
++ }
++ },
++ "/api/v1/uploads": {
++ "post": {
++ "summary": "Reserve quota for a resumable ciphertext upload",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "parameters": [
++ {
++ "name": "If-Match",
++ "in": "header",
++ "required": false,
++ "schema": {
++ "type": "string"
++ },
++ "description": "Required original item revision for replace/reseal initiation and completion, e.g. \"2\"."
++ }
++ ],
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/UploadStart"
++ }
++ }
++ }
++ },
++ "responses": {
++ "201": {
++ "description": "Reserved for one hour; each newly received part renews the deadline.",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/UploadSession"
++ }
++ }
++ }
++ },
++ "403": {
++ "description": "Account ineligible or restricted"
++ },
++ "413": {
++ "description": "File or quota limit exceeded"
++ },
++ "428": {
++ "description": "Replacement/reseal needs If-Match"
++ }
++ }
++ }
++ },
++ "/api/v1/uploads/{id}": {
++ "get": {
++ "summary": "List received parts or retrieve a completed result",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string",
++ "format": "uuid"
++ }
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Owner-only upload status",
++ "content": {
++ "application/json": {
++ "schema": {
++ "$ref": "#/components/schemas/UploadSession"
++ }
++ }
++ }
++ },
++ "404": {
++ "description": "Missing, expired or another account\u2019s upload"
++ }
++ }
++ }
++ },
++ "/api/v1/uploads/{id}/parts/{n}": {
++ "put": {
++ "summary": "Stream an immutable, retryable ciphertext part at its offset",
++ "description": "Zero-based parts are exactly 32 MiB except the final remaining bytes. Actual streamed bytes are counted. Missing Content-Length is supported. Received parts are never overwritten.",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string",
++ "format": "uuid"
++ }
++ },
++ {
++ "name": "n",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "integer",
++ "minimum": 0
++ }
++ }
++ ],
++ "requestBody": {
++ "required": true,
++ "content": {
++ "application/octet-stream": {
++ "schema": {
++ "type": "string",
++ "format": "binary"
++ }
++ }
++ }
++ },
++ "responses": {
++ "200": {
++ "description": "Part received or already received",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object",
++ "properties": {
++ "received": {
++ "type": "integer"
++ }
++ }
++ }
++ }
++ }
++ },
++ "400": {
++ "description": "Invalid part number or short body"
++ },
++ "413": {
++ "description": "Part exceeds its expected size"
++ },
++ "409": {
++ "description": "Upload is completing"
++ },
++ "404": {
++ "description": "Upload missing or expired"
++ }
++ }
++ }
++ },
++ "/api/v1/uploads/{id}/complete": {
++ "post": {
++ "summary": "Validate and commit a fully received envelope",
++ "description": "Checks all parts, envelope header, recipients and standing. Creates an item, replaces the If-Match item or returns a reseal receipt. Safe to retry: successful results are retained for 24 hours. A simultaneous completion returns locker.upload.busy (409); query status and retry. Invalid envelopes discard the session.",
++ "security": [
++ {
++ "bearerAuth": []
++ }
++ ],
++ "parameters": [
++ {
++ "name": "id",
++ "in": "path",
++ "required": true,
++ "schema": {
++ "type": "string",
++ "format": "uuid"
++ }
++ },
++ {
++ "name": "If-Match",
++ "in": "header",
++ "required": false,
++ "schema": {
++ "type": "string"
++ },
++ "description": "Required original item revision for replace/reseal initiation and completion, e.g. \"2\"."
++ }
++ ],
++ "responses": {
++ "200": {
++ "description": "Created/replaced item, or {envelope_ref, header} for reseal",
++ "content": {
++ "application/json": {
++ "schema": {
++ "type": "object"
++ }
++ }
++ }
++ },
++ "400": {
++ "description": "Invalid envelope or recipients"
++ },
++ "403": {
++ "description": "Account no longer eligible"
++ },
++ "409": {
++ "description": "Missing parts or completion still running"
++ },
++ "412": {
++ "description": "Item revision changed or If-Match differs"
++ },
++ "404": {
++ "description": "Upload missing or expired"
++ }
++ }
++ }
++ }
++ },
++ "components": {
++ "securitySchemes": {
++ "bearerAuth": {
++ "type": "http",
++ "scheme": "bearer"
++ }
++ },
++ "schemas": {
++ "UploadStart": {
++ "type": "object",
++ "additionalProperties": false,
++ "required": [
++ "kind",
++ "bytes"
++ ],
++ "properties": {
++ "kind": {
++ "type": "string",
++ "enum": [
++ "file",
++ "folder",
++ "send",
++ "replace",
++ "reseal"
++ ]
++ },
++ "bytes": {
++ "type": "integer",
++ "minimum": 8,
++ "description": "Complete ciphertext envelope length, including padding and framing."
++ },
++ "parent": {
++ "type": "string",
++ "format": "uuid"
++ },
++ "item_id": {
++ "type": "string",
++ "format": "uuid",
++ "description": "Required for replace and reseal."
++ },
++ "expires_in": {
++ "type": "integer",
++ "minimum": 3600,
++ "maximum": 2592000
++ },
++ "download_limit": {
++ "type": "integer",
++ "minimum": 1,
++ "maximum": 100000
++ }
++ }
++ },
++ "UploadSession": {
++ "type": "object",
++ "required": [
++ "id",
++ "part_size"
++ ],
++ "properties": {
++ "id": {
++ "type": "string",
++ "format": "uuid"
++ },
++ "part_size": {
++ "type": "integer",
++ "enum": [
++ 33554432
++ ]
++ },
++ "bytes": {
++ "type": "integer"
++ },
++ "received_parts": {
++ "type": "array",
++ "items": {
++ "type": "integer",
++ "minimum": 0
++ }
++ },
++ "state": {
++ "type": "string",
++ "enum": [
++ "open",
++ "completing",
++ "complete"
++ ]
++ },
++ "expires_at": {
++ "type": "string",
++ "format": "date-time"
++ },
++ "result": {
++ "type": "object",
++ "description": "Original item or reseal receipt, available for 24 hours after successful completion."
++ }
++ }
++ }
++ }
++ }
++}
ddiff --git a/public/robots.txt b/public/robots.txt
nnew file mode 100644
iindex 0000000..10c2729
---- /dev/null
++++ b/public/robots.txt
@@@ -0,0 +1,7 @@
++User-agent: *
++Disallow: /api/
++Disallow: /s/
++Disallow: /files/
++Disallow: /drive
++Disallow: /sends
++Disallow: /staff
ddiff --git a/scripts/check.js b/scripts/check.js
nnew file mode 100644
iindex 0000000..0ef099b
---- /dev/null
++++ b/scripts/check.js
@@@ -0,0 +1,13 @@
++'use strict';
++const fs = require('node:fs');
++const path = require('node:path');
++const { execFileSync } = require('node:child_process');
++function check(dir) {
++ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
++ const file = path.join(dir, e.name);
++ if (e.isDirectory()) check(file);
++ else if (file.endsWith('.js')) execFileSync(process.execPath, ['--check', file], { stdio: 'inherit' });
++ }
++}
++for (const dir of ['server', 'public/js', 'test', 'scripts']) if (fs.existsSync(dir)) check(dir);
++console.log('JavaScript syntax checked.');
ddiff --git a/server/account-data.js b/server/account-data.js
nnew file mode 100644
iindex 0000000..526a716
---- /dev/null
++++ b/server/account-data.js
@@@ -0,0 +1,40 @@
++'use strict';
++const { createAccountData } = require('openvibe-sdk/account-data');
++function createAccountDataForLocker(service) {
++ const { db } = service;
++ return createAccountData({ db, service: 'locker',
++ note: 'Content, filenames and types are encrypted. Download envelopes from /api/v1/files/:id/content while signed in, then open them with your keys. Deletion denies access immediately and queues encrypted Media copies for erasure.',
++ tables: [
++ { table: 'locker_items', subject: 'owner', file: 'items.json', columns: ['id', 'kind', 'parent', 'envelope_bytes', 'lock', 'created_at', 'expires_at', 'downloads', 'download_limit', 'deleted_at'], erase: { keep: 'Erased by extraErase after queuing ciphertext deletion.' } },
++ { table: 'locker_reports', subject: 'reporter', file: 'reports.json', erase: { anonymize: {} } },
++ ],
++ extraExport: async (_db, subject) => {
++ const members = await db.many('SELECT service,kind,resource_id,role,expires_at FROM share_members WHERE subject=$1::jsonb', [JSON.stringify({ type: 'user', id: subject })]);
++ return [{ name: 'shared-with-me.json', content: members }];
++ },
++ extraErase: async (t, subjects, counts) => {
++ for (const subject of subjects) {
++ const a = await service.account(subject, t);
++ await t.exec('UPDATE locker_accounts SET deleted=true,restricted=true WHERE subject=$1', [subject]);
++ const rows = await t.many('SELECT * FROM locker_items WHERE owner=$1 AND deleted_at IS NULL FOR UPDATE', [subject]);
++ for (const row of rows) await service.removeIn(t, row);
++ counts.add(counts.erased, 'items', rows.length);
++ const uploads = await t.many('SELECT * FROM locker_uploads WHERE owner=$1', [subject]);
++ for (const u of uploads) await service.queue(t, u.media_id);
++ // Keep the durable cleanup receipt until its temporary file and Media copy are erased.
++ await t.exec('UPDATE locker_uploads SET bytes=0,due_at=$2 WHERE owner=$1', [subject, service.iso()]);
++ await t.exec('UPDATE locker_accounts SET reserved_bytes=0 WHERE subject=$1', [subject]);
++ await t.exec('DELETE FROM locker_upload_results WHERE owner=$1', [subject]);
++ await t.exec('DELETE FROM locker_rates WHERE bucket=$1 OR bucket=$2', [`upload:${subject}`, `send:${subject}`]);
++ const ref = JSON.stringify({ type: 'user', id: subject });
++ await t.exec('DELETE FROM share_members WHERE subject=$1::jsonb OR (service=\'locker\' AND resource_id IN (SELECT id::text FROM locker_items WHERE owner=$2))', [ref, subject]);
++ await t.exec('DELETE FROM share_access_requests WHERE subject=$1::jsonb OR (service=\'locker\' AND resource_id IN (SELECT id::text FROM locker_items WHERE owner=$2))', [ref, subject]);
++ await t.exec('DELETE FROM share_transfers WHERE from_subject=$1::jsonb OR to_subject=$1::jsonb', [ref]);
++ await t.exec('DELETE FROM share_deliveries WHERE delivered_at IS NULL AND (body::text LIKE $1)', [`%${subject}%`]);
++ // share_log and staff actions are append-only security evidence; keep no content or keys in either.
++ counts.add(counts.retained, 'deletion_marker', a ? 1 : 0);
++ }
++ },
++ });
++}
++module.exports = { createAccountDataForLocker };
ddiff --git a/server/app.js b/server/app.js
nnew file mode 100644
iindex 0000000..496c482
---- /dev/null
++++ b/server/app.js
@@@ -0,0 +1,190 @@
++'use strict';
++const express = require('express');
++const helmet = require('helmet');
++const path = require('node:path');
++const { randomUUID } = require('node:crypto');
++const { Readable } = require('node:stream');
++const { pipeline } = require('node:stream/promises');
++const { staff } = require('openvibe-contracts');
++const { createSsoClient } = require('openvibe-sdk/sso');
++const { createNetworkSender } = require('openvibe-sdk/account-data');
++const { openDb } = require('./db');
++const { createNetwork } = require('./network');
++const { createMedia } = require('./media');
++const { createService, userId, ID } = require('./service');
++const { createSharing } = require('./sharing');
++const { createUploads, SINGLE_LIMIT } = require('./uploads');
++const { createAccountDataForLocker } = require('./account-data');
++const { fail, wrap } = require('./errors');
++async function createApp(options = {}) {
++ const config = options.config || require('./config').load();
++ const db = options.db || await openDb(config);
++ const network = options.network || createNetwork(config);
++ const media = options.media || createMedia(config);
++ const service = createService({ db, config, network, media, now: options.now });
++ const sharing = createSharing({ service, network });
++ const uploads = createUploads({ service, sharing });
++ const accountData = createAccountDataForLocker(service);
++ const sso = options.sso || createSsoClient({ site: 'locker', baseUrl: config.baseUrl, clientId: config.clientId, clientSecret: config.clientSecret,
++ networkUrl: config.networkUrl, networkInternalUrl: config.networkInternalUrl, secureCookies: config.secureCookies, httpOnlyAccess: true });
++ const app = express();
++ app.disable('x-powered-by'); app.set('trust proxy', config.trustProxy);
++ app.use(helmet({ contentSecurityPolicy: { directives: {
++ defaultSrc: ["'self'"], scriptSrc: ["'self'", "'unsafe-inline'", config.networkUrl], styleSrc: ["'self'", "'unsafe-inline'", config.networkUrl, 'https://fonts.googleapis.com'],
++ fontSrc: ["'self'", 'data:', 'https://fonts.gstatic.com'], imgSrc: ["'self'", 'data:', 'blob:', config.networkUrl],
++ connectSrc: ["'self'", config.networkUrl, 'https://openvibe.chat', 'https://billing.openvibe.network', 'https://openvibe.events'],
++ workerSrc: ["'self'"], frameSrc: [config.networkUrl], frameAncestors: ["'none'"], objectSrc: ["'none'"], baseUri: ["'self'"],
++ formAction: ["'self'", config.networkUrl], upgradeInsecureRequests: config.production ? [] : null,
++ } }, crossOriginEmbedderPolicy: false, referrerPolicy: { policy: 'no-referrer' } }));
++ app.use('/internal/events', accountData.consumer({ secrets: [config.eventsSecret].filter(Boolean),
++ send: options.sendAccountData || (config.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.clientId, clientSecret: config.clientSecret })
++ : async () => { throw new Error('Account delivery is not configured.'); }) }));
++ app.use('/auth', sso.router(express));
++ app.use(sso.optionalAuth());
++ app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-locker', version: require('../package.json').version }));
++ app.use('/api', (req, res, next) => {
++ res.set({ 'Cache-Control': 'private, no-store', Vary: 'Cookie, Authorization', 'X-Robots-Tag': 'noindex' });
++ if (!['GET', 'HEAD', 'OPTIONS'].includes(req.method) && !req.headers.authorization) {
++ if (req.headers.origin !== config.baseUrl || !req.headers['x-requested-with']) return next(Object.assign(new Error('Use this site to make changes.'), { status: 403, code: 'locker.csrf' }));
++ }
++ next();
++ });
++ app.use('/api', express.json({ limit: '12mb' }));
++ app.use(sharing.middleware);
++ const auth = (req) => userId(req.user);
++ const staffOnly = (req) => { auth(req); if (!staff.atLeast(req.user, 'global_mod')) fail(403, 'locker.staff.required', 'A Network staff role is required.'); };
++ app.get('/api/v1/me', wrap(async (req, res) => {
++ const subject = auth(req);
++ const a = await db.maybe('SELECT used_bytes,reserved_bytes,restricted FROM locker_accounts WHERE subject=$1', [subject]);
++ let eligible = true, reason = null;
++ try { await service.standing(req.user); if (a?.restricted) fail(403, 'locker.upload.restricted', 'Uploads are restricted for this account.'); }
++ catch (e) { eligible = false; reason = e.message; }
++ res.json({ subject, used_bytes: a?.used_bytes || 0, reserved_bytes: a?.reserved_bytes || 0, quota_bytes: config.quotaBytes,
++ max_file_bytes: config.maxFileBytes, eligible, reason, staff: staff.atLeast(req.user, 'global_mod') });
++ }));
++ // Keep SSO access tokens HTTP-only. Forward only these public-key / wrapped-backup endpoints,
++ // so unlocking does not depend on a third-party Network cookie being accepted by the browser.
++ const keys = (method, pathFor) => wrap(async (req, res) => {
++ auth(req);
++ res.json(await network.json({ method, path: pathFor(req), token: req.token, preserveProblem: true,
++ ...(method === 'PUT' ? { json: req.body } : {}) }));
++ });
++ app.get('/api/v1/me/keys/backup', keys('GET', () => '/api/v1/me/keys/backup'));
++ app.put('/api/v1/me/keys', keys('PUT', () => '/api/v1/me/keys'));
++ app.get('/api/v1/people/:subject/keys', keys('GET', (req) => `/api/v1/people/${encodeURIComponent(req.params.subject)}/keys`));
++ app.get('/reauth', (_req, res) => {
++ const returnTo = `${config.baseUrl}/auth/login?next=${encodeURIComponent('/recovery')}`;
++ res.redirect(`${config.networkUrl}/login?reauth=1&return=${encodeURIComponent(returnTo)}`);
++ });
++ const revisionOf = (req) => Number(String(req.headers['if-match'] || '').replaceAll('"', ''));
++ const singleLength = (req) => {
++ const bytes = Number(req.headers['content-length']);
++ if (!Number.isSafeInteger(bytes) || bytes < 8) fail(411, 'locker.length.required', 'Send Content-Length for the encrypted envelope.');
++ if (bytes > SINGLE_LIMIT) fail(413, 'locker.upload.chunked_required', 'Use /api/v1/uploads for envelopes over 90 MiB.');
++ return bytes;
++ };
++ app.post('/api/v1/uploads', wrap(async (req, res) => {
++ auth(req); res.status(201).json(await uploads.start(req.user, req.body, revisionOf(req)));
++ }));
++ app.get('/api/v1/uploads/:id', wrap(async (req, res) => {
++ auth(req); res.json(await uploads.status(req.user, req.params.id));
++ }));
++ app.put('/api/v1/uploads/:id/parts/:n', wrap(async (req, res) => {
++ auth(req);
++ if (req.headers['content-type'] !== 'application/octet-stream') fail(415, 'locker.envelope.required', 'Send encrypted bytes.');
++ const length = req.headers['content-length'] === undefined ? null : Number(req.headers['content-length']);
++ res.json(await uploads.part(req.user, req.params.id, req.params.n, req, length));
++ }));
++ app.post('/api/v1/uploads/:id/complete', wrap(async (req, res) => {
++ auth(req); res.json(await uploads.complete(req.user, req.params.id, revisionOf(req)));
++ }));
++ for (const [plural, kind] of [['files', 'file'], ['sends', 'send']]) {
++ const send = kind === 'send';
++ app.get(`/api/v1/${plural}`, wrap(async (req, res) => { auth(req); res.json(await service.list(req.user, kind, req.query)); }));
++ app.post(`/api/v1/${plural}`, wrap(async (req, res) => {
++ auth(req);
++ if (req.headers['content-type'] !== 'application/octet-stream') fail(415, 'locker.envelope.required', 'Send an application/octet-stream envelope.');
++ const allowed = send ? ['expires_in', 'download_limit'] : ['kind', 'parent'];
++ if (Object.keys(req.query).some((k) => !allowed.includes(k))) fail(400, 'locker.metadata.private', 'Filenames and content details belong inside the encrypted envelope.');
++ const out = await service.create(req.user, req, singleLength(req), { kind: send ? 'send' : req.query.kind || 'file', parent: req.query.parent || null,
++ expiresIn: Number(req.query.expires_in), downloadLimit: req.query.download_limit === undefined ? null : Number(req.query.download_limit) });
++ res.status(201).json(out);
++ }));
++ app.get(`/api/v1/${plural}/:id`, wrap(async (req, res) => {
++ const row = await service.view(req.user, req.params.id, send);
++ res.json({ ...service.wire(row), ...(send ? {} : { header: row.header, can_edit: row.owner === userId(req.user) }) });
++ }));
++ app.get(`/api/v1/${plural}/:id/content`, wrap(async (req, res) => {
++ if (req.method === 'HEAD') return res.status(405).end();
++ const { row, response } = await service.download(req.user, req.params.id, send);
++ res.set({ 'Content-Type': 'application/octet-stream', 'Content-Disposition': 'attachment; filename="locked.ovlk"', 'Content-Length': String(row.envelope_bytes) });
++ await pipeline(Readable.fromWeb(response.body), res);
++ }));
++ app.delete(`/api/v1/${plural}/:id`, wrap(async (req, res) => { auth(req); const r = await service.owned(req.user, req.params.id); if ((r.kind === 'send') !== send) fail(404, 'locker.not_found', 'Item not found.'); await service.remove(req.user, req.params.id); res.status(204).end(); }));
++ app.post(`/api/v1/${plural}/:id/report`, wrap(async (req, res) => {
++ const id = req.params.id;
++ if (!ID.test(id)) fail(404, 'locker.not_found', 'Item not found.');
++ // Reports remain available for a spent Send. There is no key or free-text content in a report.
++ const r = await db.maybe('SELECT id,kind FROM locker_items WHERE id=$1', [id]);
++ if (!r || (r.kind === 'send') !== send) fail(404, 'locker.not_found', 'Item not found.');
++ if (!send) await service.view(req.user, id);
++ await service.rate(`report:${req.ip}`, 10);
++ if (!['abuse', 'illegal', 'malware', 'other'].includes(req.body?.reason) || Object.keys(req.body).some((k) => k !== 'reason')) fail(400, 'locker.report.reason', 'Choose a report reason.');
++ await db.exec('INSERT INTO locker_reports(id,item_id,reporter,reason,created_at) VALUES ($1,$2,$3,$4,$5)', [randomUUID(), id, req.user ? auth(req) : null, req.body.reason, service.iso()]);
++ res.status(202).json({ reported: true });
++ }));
++ }
++ app.put('/api/v1/files/:id', wrap(async (req, res) => {
++ auth(req);
++ if (req.headers['content-type'] !== 'application/octet-stream') fail(415, 'locker.envelope.required', 'Send an encrypted envelope.');
++ const revision = Number(String(req.headers['if-match'] || '').replaceAll('"', ''));
++ if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');
++ res.json(await service.replace(req.user, req.params.id, req, singleLength(req), revision));
++ }));
++ app.post('/api/v1/files/:id/reseals', wrap(async (req, res) => {
++ auth(req);
++ if (req.headers['content-type'] !== 'application/octet-stream') fail(415, 'locker.envelope.required', 'Send an encrypted envelope.');
++ const revision = Number(String(req.headers['if-match'] || '').replaceAll('"', ''));
++ if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');
++ res.status(201).json(await sharing.stage(req.user, req.params.id, req, singleLength(req), revision));
++ }));
++ app.get('/api/v1/account/export', wrap(async (req, res) => res.json(await accountData.exportPart(auth(req)))));
++ app.delete('/api/v1/account', wrap(async (req, res) => {
++ const subject = auth(req);
++ if (req.body?.confirmation !== 'delete my locker') fail(400, 'locker.delete.confirm', 'Type delete my locker to confirm.');
++ res.json(await accountData.erase([subject]));
++ }));
++ app.get('/api/v1/staff/reports', wrap(async (req, res) => { staffOnly(req); res.json({ reports: await db.many('SELECT r.*,i.owner,i.disabled FROM locker_reports r JOIN locker_items i ON i.id=r.item_id ORDER BY r.created_at DESC LIMIT 100') }); }));
++ app.post('/api/v1/staff/items/:id/disable', wrap(async (req, res) => {
++ staffOnly(req);
++ if (!ID.test(req.params.id)) fail(404, 'locker.not_found', 'Item not found.');
++ await db.tx(async (t) => {
++ const item = await t.maybe('UPDATE locker_items SET disabled=true WHERE id=$1 RETURNING id', [req.params.id]);
++ if (!item) fail(404, 'locker.not_found', 'Item not found.');
++ await t.exec('UPDATE locker_reports SET resolved_at=$2 WHERE item_id=$1', [req.params.id, service.iso()]);
++ await t.exec('INSERT INTO locker_moderation VALUES ($1,$2,$3,$4,$5)', [randomUUID(), auth(req), req.params.id, 'disable', service.iso()]);
++ });
++ res.json({ disabled: true });
++ }));
++ app.put('/api/v1/staff/people/:subject/restriction', wrap(async (req, res) => {
++ staffOnly(req);
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(req.params.subject) || typeof req.body?.restricted !== 'boolean') fail(400, 'locker.restriction', 'Choose the account and restriction.');
++ await db.tx(async (t) => {
++ await service.account(req.params.subject, t);
++ await t.exec('UPDATE locker_accounts SET restricted=$2 WHERE subject=$1', [req.params.subject, req.body.restricted]);
++ await t.exec('INSERT INTO locker_moderation VALUES ($1,$2,$3,$4,$5)', [randomUUID(), auth(req), req.params.subject, req.body.restricted ? 'restrict' : 'unrestrict', service.iso()]);
++ });
++ res.json({ restricted: req.body.restricted });
++ }));
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ app.use(express.static(path.join(__dirname, '..', 'public'), { index: false, maxAge: '1h' }));
++ app.use(require('./pages').createPages({ config }));
++ app.use((err, _req, res, _next) => {
++ if (res.headersSent) return res.destroy();
++ const status = Number(err.status) || 500;
++ res.status(status).type('application/problem+json').json({ type: 'about:blank', status, code: err.code || 'locker.request.failed',
++ detail: status < 500 ? err.message : 'Locker could not complete this request. Please try again.' });
++ });
++ return { app, db, config, service, sharing, accountData };
++}
++module.exports = { createApp };
ddiff --git a/server/config.js b/server/config.js
nnew file mode 100644
iindex 0000000..fe8f848
---- /dev/null
++++ b/server/config.js
@@@ -0,0 +1,30 @@
++'use strict';
++const path = require('node:path');
++function load(env = process.env) {
++ const int = (key, fallback) => {
++ const n = env[key] === undefined ? fallback : Number(env[key]);
++ if (!Number.isSafeInteger(n) || n <= 0) throw new Error(`${key} must be a positive integer`);
++ return n;
++ };
++ const port = int('PORT', 5145);
++ const production = env.NODE_ENV === 'production';
++ return {
++ port, production, host: env.HOST || '127.0.0.1', baseUrl: env.BASE_URL || `http://localhost:${port}`,
++ trustProxy: env.TRUST_PROXY === undefined ? 1 : Number(env.TRUST_PROXY),
++ networkUrl: env.OV_NETWORK_URL || 'https://openvibe.network',
++ networkInternalUrl: env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000',
++ mediaUrl: env.OV_MEDIA_URL || 'https://openvibe.media',
++ eventsUrl: env.OV_EVENTS_URL || 'https://openvibe.events',
++ clientId: 'locker', clientSecret: env.OV_OAUTH_CLIENT_SECRET || '', eventsSecret: env.LOCKER_EVENTS_SECRET || '',
++ dbUrl: env.DATABASE_URL, dbDirectUrl: env.DATABASE_DIRECT_URL,
++ dataDir: path.resolve(env.LOCKER_DATA_DIR || 'data'),
++ quotaBytes: int('LOCKER_QUOTA_BYTES', 5 * 1024 ** 3),
++ maxFileBytes: int('LOCKER_MAX_FILE_BYTES', 2 * 1024 ** 3),
++ minAgeDays: int('LOCKER_MIN_ACCOUNT_DAYS', 7),
++ uploadsPerHour: int('LOCKER_UPLOADS_PER_HOUR', 60), sendsPerHour: int('LOCKER_SENDS_PER_HOUR', 20),
++ allowlistOnly: env.LOCKER_ALLOWLIST_ONLY === 'true',
++ allowlist: new Set((env.LOCKER_UPLOAD_ALLOWLIST || '').split(',').filter(Boolean)),
++ secureCookies: production,
++ };
++}
++module.exports = { load };
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..0f2b6c8
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,24 @@
++'use strict';
++const path = require('node:path');
++const { createDb } = require('openvibe-sdk/db');
++const { sharesSchema } = require('openvibe-sdk/share');
++const { ACCOUNT_DATA_SCHEMA } = require('openvibe-sdk/account-data');
++const migrations = path.join(__dirname, '..', 'migrations');
++async function migrate(db) {
++ await db.migrate({ dir: migrations });
++ await db.exec(sharesSchema());
++ await db.exec(ACCOUNT_DATA_SCHEMA);
++}
++async function openDb(config) {
++ if (!config.dbUrl) {
++ if (config.production) throw new Error('DATABASE_URL is required in production');
++ const db = createDb({ pglite: path.join(config.dataDir, 'pglite'), service: 'locker' });
++ await migrate(db);
++ return db;
++ }
++ if (!config.dbDirectUrl) throw new Error('DATABASE_DIRECT_URL is required for migrations');
++ const owner = createDb({ url: config.dbDirectUrl, service: 'locker-migrate', max: 1 });
++ try { await migrate(owner); } finally { await owner.close(); }
++ return createDb({ url: config.dbUrl, service: 'locker' });
++}
++module.exports = { openDb, migrate };
ddiff --git a/server/envelope.js b/server/envelope.js
nnew file mode 100644
iindex 0000000..a9806bc
---- /dev/null
++++ b/server/envelope.js
@@@ -0,0 +1,32 @@
++'use strict';
++const fs = require('node:fs/promises');
++const { inspectEnvelopePrefix } = require('openvibe-sdk/locker/server');
++const { fail } = require('./errors');
++const CHUNK = 1048576, MAX_HEADER = 65536;
++const fields = ['version', 'suite', 'chunk_size', 'padded_size', 'nonce_base', 'lock', 'slots'];
++function headerInfo(prefix, totalBytes, maxBytes) {
++ const bad = () => fail(400, 'locker.envelope.required', 'Upload a complete locker.envelope.v1 envelope.');
++ if (prefix.length < 8 || prefix.toString('ascii', 0, 4) !== 'OVLK') bad();
++ const length = prefix.readUInt32BE(4);
++ if (!length || length > MAX_HEADER || prefix.length < 8 + length) bad();
++ const info = inspectEnvelopePrefix(prefix, totalBytes);
++ if (!info.valid) bad();
++ const { header } = info;
++ if (Object.keys(header).some((k) => !fields.includes(k)) || !Number.isSafeInteger(header.padded_size)) bad();
++ const allowed = (o, keys) => o && Object.keys(o).every((k) => keys.includes(k));
++ if (header.lock === 'people') for (const slot of header.slots.people) {
++ if (!allowed(slot, ['subject','algorithm','recipient_key','ephemeral_public_key','salt','nonce','wrapped_key'])
++ || !allowed(slot.subject, ['type','id']) || !allowed(slot.ephemeral_public_key, ['kty','crv','x','y'])) bad();
++ }
++ if (header.padded_size > maxBytes) fail(413, 'locker.file.too_large', 'This envelope exceeds the configured file limit.');
++ return { header, offset: info.chunkOffset, expected: info.expectedBytes, prefix: prefix.subarray(0, info.chunkOffset) };
++}
++async function inspectFile(file, bytes, maxBytes) {
++ const fd = await fs.open(file, 'r');
++ try {
++ const prefix = Buffer.alloc(Math.min(bytes, MAX_HEADER + 8));
++ const { bytesRead } = await fd.read(prefix, 0, prefix.length, 0);
++ return headerInfo(prefix.subarray(0, bytesRead), bytes, maxBytes);
++ } finally { await fd.close(); }
++}
++module.exports = { inspectFile, headerInfo, MAX_HEADER, CHUNK };
ddiff --git a/server/errors.js b/server/errors.js
nnew file mode 100644
iindex 0000000..6912337
---- /dev/null
++++ b/server/errors.js
@@@ -0,0 +1,4 @@
++'use strict';
++const fail = (status, code, message) => { throw Object.assign(new Error(message), { status, code }); };
++const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res)).catch(next);
++module.exports = { fail, wrap };
ddiff --git a/server/index.js b/server/index.js
nnew file mode 100644
iindex 0000000..6a86a79
---- /dev/null
++++ b/server/index.js
@@@ -0,0 +1,16 @@
++'use strict';
++const { createApp } = require('./app');
++const { startJobs } = require('./jobs');
++const { startSubscriptions } = require('openvibe-sdk/account-data');
++(async () => {
++ const ctx = await createApp();
++ const { config, app, service, db } = ctx;
++ if (config.production && (!config.clientSecret || !config.eventsSecret)) throw new Error('Locker service credentials and Events delivery must be configured.');
++ const jobs = startJobs(service);
++ const subscriptions = config.clientSecret && config.eventsSecret ? startSubscriptions({ eventsUrl: config.eventsUrl, endpoint: `http://127.0.0.1:${config.port}/internal/events`,
++ secret: config.eventsSecret, networkInternalUrl: config.networkInternalUrl, clientId: config.clientId, clientSecret: config.clientSecret }) : null;
++ const server = app.listen(config.port, config.host, () => console.log(`OpenVibe.Locker listening on ${config.host}:${config.port}`));
++ server.requestTimeout = 30 * 60 * 1000;
++ const stop = () => { jobs.stop(); subscriptions?.stop(); server.close(() => db.close().then(() => process.exit(0))); };
++ process.once('SIGTERM', stop); process.once('SIGINT', stop);
++})().catch(() => { console.error('OpenVibe.Locker could not start. Check service configuration.'); process.exitCode = 1; });
ddiff --git a/server/jobs.js b/server/jobs.js
nnew file mode 100644
iindex 0000000..f719f71
---- /dev/null
++++ b/server/jobs.js
@@@ -0,0 +1,19 @@
++'use strict';
++function startJobs(service, { log = console } = {}) {
++ let timer = null, stopped = false, running = false;
++ const arm = (ms) => { if (!stopped) { clearTimeout(timer); timer = setTimeout(run, Math.max(20, ms)); timer.unref(); } };
++ async function run() {
++ if (running || stopped) return;
++ running = true;
++ try { await service.sweep(); await service.flushShares(); }
++ catch { log.warn('[Locker] cleanup will retry'); }
++ finally {
++ running = false;
++ const due = await service.nextDue().catch(() => null);
++ arm(due ? Math.min(60000, Math.max(20, Date.parse(due) - service.now())) : 60000);
++ }
++ }
++ arm(20);
++ return { kick() { arm(20); }, stop() { stopped = true; clearTimeout(timer); } };
++}
++module.exports = { startJobs };
ddiff --git a/server/media.js b/server/media.js
nnew file mode 100644
iindex 0000000..ac53e3e
---- /dev/null
++++ b/server/media.js
@@@ -0,0 +1,55 @@
++'use strict';
++const fs = require('node:fs/promises');
++const { serviceAuth } = require('openvibe-contracts');
++const { fail } = require('./errors');
++function createMedia(config, fetchImpl = fetch) {
++ const tokens = serviceAuth.createTokenClient({ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.clientId,
++ clientSecret: config.clientSecret, audience: 'openvibe.media', scope: 'media.object.upload media.object.read media.object.delete', fetchImpl });
++ const base = `${config.mediaUrl}/api/v2/locker/objects`;
++ async function call(method, path, { json, body, headers = {}, raw = false } = {}) {
++ for (let attempt = 0; attempt < 2; attempt++) {
++ const r = await fetchImpl(base + path, { method, headers: { ...(await tokens.authHeaders()), ...headers,
++ ...(json === undefined ? {} : { 'Content-Type': 'application/json' }) },
++ body: json === undefined ? body : JSON.stringify(json), signal: AbortSignal.timeout(120000) });
++ if (r.status === 401 && !attempt) { tokens.invalidate(); continue; }
++ if (!r.ok) fail(502, 'locker.media.unavailable', 'Encrypted storage could not complete the request.');
++ return raw ? r : r.json();
++ }
++ }
++ return {
++ async upload(file, bytes, owner, record) {
++ const init = await call('POST', '', { json: { kind: 'file', namespace: 'locker', visibility: 'private', mime_type: 'application/octet-stream',
++ size_bytes: bytes, filename: 'locked.ovlk', multipart: true, part_size: 8 * 1024 ** 2 }, headers: { 'X-OV-Subject': owner } });
++ if (!/^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(init.id || '')) fail(502, 'locker.media.invalid', 'Encrypted storage returned an invalid reference.');
++ await record(init.id); // Persist before sending bytes; a crashed upload is collected later.
++ const session = init.upload?.multipart || {};
++ const partSize = Number(session.part_size);
++ const uploadId = session.upload_id;
++ if (!uploadId || !Number.isSafeInteger(partSize) || partSize < 1 || partSize > 64 * 1024 ** 2) fail(502, 'locker.media.invalid', 'Encrypted storage returned an invalid upload session.');
++ const fd = await fs.open(file, 'r');
++ try {
++ for (let offset = 0, n = 1; offset < bytes; n++) {
++ const b = Buffer.alloc(Math.min(partSize, bytes - offset));
++ let got = 0;
++ while (got < b.length) { const read = await fd.read(b, got, b.length - got, offset + got); if (!read.bytesRead) throw new Error('Incomplete ciphertext'); got += read.bytesRead; }
++ await call('PUT', `/${init.id}/multipart/${uploadId}/parts/${n}`, { body: b, headers: { 'Content-Type': 'application/octet-stream' } });
++ offset += got;
++ }
++ } finally { await fd.close(); }
++ await call('POST', `/${init.id}/multipart/${uploadId}/complete`, { json: {} });
++ return init.id;
++ },
++ async read(id) {
++ // Keep private signed URLs on the server; never redirect a limited Send to Media.
++ const { url } = await call('GET', `/${encodeURIComponent(id)}/download?format=json`);
++ const target = new URL(url);
++ if (target.origin !== new URL(config.mediaUrl).origin) fail(502, 'locker.media.origin', 'Unexpected encrypted storage origin.');
++ // Media may redirect its private signed URL to a signed storage-provider URL; no service credentials follow.
++ const r = await fetchImpl(target, { signal: AbortSignal.timeout(30 * 60 * 1000), redirect: 'follow' });
++ if (!r.ok || !r.body) fail(502, 'locker.media.read', 'Encrypted storage is temporarily unavailable.');
++ return r;
++ },
++ async delete(id) { await call('DELETE', `/${encodeURIComponent(id)}?purge=1`); },
++ };
++}
++module.exports = { createMedia };
ddiff --git a/server/network.js b/server/network.js
nnew file mode 100644
iindex 0000000..c7e2014
---- /dev/null
++++ b/server/network.js
@@@ -0,0 +1,30 @@
++'use strict';
++const { serviceAuth } = require('openvibe-contracts');
++const { fail } = require('./errors');
++function createNetwork(config, fetchImpl = fetch) {
++ const clients = new Map();
++ async function json({ path, query, method = 'GET', json: body, token, preserveProblem = false, audience = 'openvibe.network', base = config.networkInternalUrl }) {
++ if (!clients.has(audience)) clients.set(audience, serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.clientId,
++ clientSecret: config.clientSecret, audience, fetchImpl,
++ }));
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) url.searchParams.set(k, v);
++ for (let attempt = 0; attempt < 2; attempt++) {
++ const headers = token ? { Authorization: `Bearer ${token}` } : await clients.get(audience).authHeaders();
++ const r = await fetchImpl(url, { method, headers: { ...headers, Accept: 'application/json', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
++ body: body === undefined ? undefined : JSON.stringify(body), signal: AbortSignal.timeout(15000) });
++ if (r.status === 401 && !token && !attempt) { clients.get(audience).invalidate(); continue; }
++ if (!r.ok) {
++ if (preserveProblem && r.status >= 400 && r.status < 500) {
++ const problem = await r.json().catch(() => ({}));
++ fail(r.status, problem.code || problem.error || 'locker.network.request', problem.detail || problem.message || problem.error || 'Network rejected this request.');
++ }
++ fail(r.status === 404 ? 404 : 503, 'locker.network.unavailable', 'Network could not complete this request.');
++ }
++ return r.json();
++ }
++ }
++ return { json, standing: (subject) => json({ path: '/internal/identity/resolve', query: { subject_id: subject, trust: '1' } }) };
++}
++module.exports = { createNetwork };
ddiff --git a/server/pages.js b/server/pages.js
nnew file mode 100644
iindex 0000000..dff8e10
---- /dev/null
++++ b/server/pages.js
@@@ -0,0 +1,57 @@
++'use strict';
++const express = require('express');
++const shell = require('openvibe-shared/shell');
++const { ID } = require('./service');
++const esc = (x) => String(x).replace(/[&<>"']/g, (s) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[s]));
++const warning = 'If you lose the password (or your locker passphrase and recovery kit), nobody can open this, including OpenVibe.';
++const button = (action, label, cls = '') => `<button type="button" data-action="${action}" class="${cls}">${label}</button>`;
++const device = `<section class="device panel"><div><span class="eyebrow">ON THIS DEVICE</span><h2 id="lock-state">Your locker is locked</h2><p id="fingerprint">Unlock to open your files. Your keys stay here.</p></div><div class="actions">${button('unlock', 'Unlock locker', 'primary')}${button('lock', 'Lock now')}</div></section>`;
++const report = `<form id="report-form" class="report"><label>Report this item<select name="reason"><option value="abuse">Abuse</option><option value="illegal">Illegal content</option><option value="malware">Malware</option><option value="other">Other concern</option></select></label><button>Send report</button><p class="muted">Reports identify the item. Its content and key stay private.</p></form>`;
++function createPages({ config }) {
++ const r = express.Router();
++ const nav = [{ label: 'Locker', href: '/' }, { label: 'Drive', href: '/drive' }, { label: 'Sends', href: '/sends' }, { label: 'Recovery kit', href: '/recovery' }, { label: 'Docs', href: '/docs' }];
++ function page(req, res, title, body, mode, id = '') {
++ const boot = JSON.stringify({ network: config.networkUrl, mode, id }).replaceAll('<', '\\u003c');
++ res.set({ 'Cache-Control': 'private, no-store', 'Referrer-Policy': 'no-referrer', 'X-Robots-Tag': 'noindex' }).type('html').send(shell.page({
++ name: 'OpenVibe.Locker', siteName: 'OpenVibe.Locker', title: `${title} · OpenVibe.Locker`, titleSuffix: '', lang: 'en',
++ description: 'Your files, locked on your device. Private by math.', canonical: `${config.baseUrl}${req.path}`, robots: 'noindex, nofollow',
++ navbar: { service: 'locker', history: false, brandName: 'OpenVibe.Locker', apiBase: config.networkUrl, links: nav.map((n) => ({ ...n, active: req.path === n.href })),
++ sessionUrl: '/auth/me', loginUrl: '/auth/login?next={path}', logoutUrl: '/auth/logout?next={path}' },
++ footer: { service: 'locker', variant: 'full', brandName: 'OpenVibe.Locker' },
++ head: '<link rel="stylesheet" href="/shared/kit.css"><link rel="stylesheet" href="/shared/ov-locker.css"><link rel="stylesheet" href="/css/locker.css"><link rel="icon" href="/icon.svg"><meta name="referrer" content="no-referrer">',
++ body: `<div id="navbar-mount"></div><main id="main" class="locker-page" data-mode="${mode}"><div id="notice" role="status" aria-live="polite"></div>${body}</main><script>window.LockerPage=${boot};window.OpenVibeLockerConfig={network:'',reauthUrl:'/reauth'};</script><script src="/shared/ov-locker-core.js"></script><script src="/shared/ov-locker.js"></script><script src="/shared/ov-share.js"></script><script src="/js/payload.js"></script><script src="/js/app.js"></script>`,
++ }));
++ }
++ r.get('/', (req, res) => page(req, res, 'Keep it yours', `<header class="hero"><div><p class="eyebrow">OPENVIBE LOCKER</p><h1>Keep it<br><em>yours.</em></h1><p class="lead">A private place for your files.<br>Locked on your device, before they leave it.</p><div class="actions"><a class="button primary" href="/drive">Open your Drive <span aria-hidden="true">↗</span></a><a class="button" href="/sends">Send something privately</a></div></div><div class="lock-art" aria-hidden="true"><div class="shackle"></div><div class="lock-body"><span></span></div><span class="art-caption">ONLY YOU HOLD THE KEYS</span></div></header>${device}<section class="grid"><a class="panel feature" href="/drive"><span class="number">01 / DRIVE</span><h2>Your files.<br>Your keys.</h2><p>Files and folders encrypted on your device. Share with people you choose.</p><span class="link-label">Go to Drive ↗</span></a><a class="panel feature" href="/sends"><span class="number">02 / SENDS</span><h2>A link with<br>a time limit.</h2><p>Send a file or a private note. Choose when it expires and how often it can be opened.</p><span class="link-label">Create a Send ↗</span></a></section><section class="plain"><h2>Private, including from us.</h2><p>OpenVibe stores encrypted bytes. Names, file types and content stay inside the lock. We can see the owner, padded size and time.</p><p>${warning}</p><a href="/recovery">Set up your locker and keep a recovery kit →</a></section>`, 'home'));
++ r.get('/drive', (req, res) => page(req, res, 'Drive', `<header class="page-heading"><p class="eyebrow">YOUR PRIVATE SPACE</p><h1>Drive</h1><p>Files and folders that only you and the people you choose can open.</p></header>${device}<div id="account" class="storage"></div><section class="panel"><div class="section-heading"><h2>Your files</h2><a href="/files/shared">Shared with you ↗</a></div><div class="upload-grid"><form id="upload-form"><label>Choose a file<input name="file" type="file" required></label><label>Folder<select id="upload-parent" name="parent"><option value="">Drive</option></select></label><button class="primary">Lock and upload</button></form><form id="folder-form"><label>New folder name<input name="name" maxlength="200" required autocomplete="off"></label><button>Create locked folder</button></form></div><p id="progress" role="status"></p><nav id="folders" aria-label="Folders" class="actions"></nav><div id="items" class="item-list"><p class="empty">Sign in to see your encrypted files.</p></div></section>`, 'drive'));
++ r.get('/sends', (req, res) => page(req, res, 'Sends', `<header class="page-heading"><p class="eyebrow">PRIVATE, FOR A WHILE</p><h1>Sends</h1><p>A locked file or note. The recipient needs only the full link.</p></header><section class="panel"><h2>Create a Send</h2><form id="send-form"><div class="grid"><label>A file<input name="file" type="file"></label><label>Or a private note<textarea name="text" rows="4" maxlength="1048576" placeholder="Write your note here"></textarea></label></div><div class="grid"><label>Expires after<select name="expires"><option value="3600">1 hour</option><option value="86400" selected>1 day</option><option value="604800">7 days</option><option value="2592000">30 days</option></select></label><label>Download limit (optional)<input name="limit" type="number" min="1" max="100000" placeholder="Until it expires"></label></div><p class="warning">${warning}</p><button class="primary">Lock and create link</button></form><p id="progress" role="status"></p><div id="send-result"></div></section><section class="plain"><p>The key stays after # in the link and is never sent to OpenVibe. Anyone with the full link can open the Send and keep a copy. Each download attempt counts, including interrupted downloads.</p></section><section class="panel"><h2>Your Sends</h2><div id="items" class="item-list"><p class="empty">Sign in to create and manage Sends.</p></div></section>`, 'sends'));
++ r.get('/recovery', (req, res) => page(req, res, 'Recovery kit', `<header class="page-heading"><p class="eyebrow">KEEP A WAY BACK IN</p><h1>Your recovery kit.</h1><p>24 words. Shown once. Kept by you.</p></header>${device}<section class="panel recovery"><h2>Make your locker</h2><p>Choose a passphrase or a supported passkey on this device. Your recovery kit can restore your keys on a new device.</p><p class="warning">${warning}</p><div class="actions">${button('setup', 'Set up my locker', 'primary')}${button('unlock', 'I already have a locker')}</div><p>The setup screen lets you print or save the words, then checks two of them. Store the kit somewhere only you can reach. OpenVibe cannot display it again.</p><p>A passkey belongs to this site and device. If it is unavailable, use your recovery kit.</p></section>`, 'recovery'));
++ r.get('/s/:id', (req, res) => { if (!ID.test(req.params.id)) return res.sendStatus(404); page(req, res, 'A private Send', `<header class="page-heading"><p class="eyebrow">SOMEONE SENT YOU SOMETHING PRIVATE</p><h1>A locked Send.</h1><p id="item-description">Open it on this device with the key in your link.</p></header><section class="panel"><div id="opened"></div>${button('open-send', 'Open this Send', 'primary')}<p id="progress" role="status"></p></section>${report}`, 'send', req.params.id); });
++ r.get('/files/shared', (req, res) => page(req, res, 'Shared with you', `<header class="page-heading"><p class="eyebrow">FOR YOUR EYES</p><h1>Shared with you.</h1></header>${device}<section class="panel"><div id="items" class="item-list"></div></section>`, 'shared'));
++ r.get('/files/:id', (req, res) => { if (!ID.test(req.params.id)) return res.sendStatus(404); page(req, res, 'A locked file', `<header class="page-heading"><p class="eyebrow">OPEN ON YOUR DEVICE</p><h1 id="file-name">A locked file.</h1><p id="item-description">Its name and content are encrypted.</p></header>${device}<section class="panel"><div id="opened"></div><div class="actions">${button('download', 'Open and download', 'primary')}<span data-owner-only hidden>${button('share', 'Share with people')}</span></div><form id="rename-form" data-owner-only hidden><label>New file name<input name="name" required maxlength="200" autocomplete="off"></label><button>Rename inside the lock</button></form><p id="progress" role="status"></p></section>${report}`, 'file', req.params.id); });
++ r.get('/staff', (req, res) => page(req, res, 'Reports', '<header class="page-heading"><p class="eyebrow">MODERATION</p><h1>Reports</h1><p>Review reports by item and account. Encrypted content stays private.</p></header><section class="panel"><div id="reports"></div></section>', 'staff'));
++ r.get('/docs', (req, res) => page(req, res, 'Locked items on OpenVibe', `<header class="page-heading"><p class="eyebrow">FOR PEOPLE AND AGENTS</p><h1>Locked items<br>on OpenVibe.</h1></header><article class="panel prose"><h2>Three ways in</h2><p>A people lock opens with the keys of the people on its list. A password lock opens with its password. A link lock opens with a key after #. The same Locker envelope works across OpenVibe.</p><h2>Drive and Sends</h2><p>Drive uses people locks. Sends use link locks, expire in 1 hour to 30 days, and can have a download limit. Names, types, true sizes and contents are inside the envelope. Recipients can keep what they open.</p><h2>Lock a file for an @name</h2><p>Use the <a href="https://github.com/OpenVibers/OpenVibe.SDK/blob/main/docs/locker.md">existing Locker SDK</a> and the Network key directory. Include your own key before uploading to Drive, then use the Share API to add the recipient.</p><pre><code>${esc(`const locker = require('openvibe-sdk/locker');
++const { share } = require('openvibe-sdk/share');
++// token and subject come from your signed-in OpenVibe session.
++const headers = { Authorization: \`Bearer \${token}\` };
++const mine = await fetch(\`https://openvibe.network/api/v1/people/\${subject}/keys\`, { headers }).then(r => r.json());
++const recipient = await fetch('https://openvibe.network/api/v1/people/@name/keys', { headers }).then(r => r.json());
++// payload contains the private file description, then the file bytes (see API docs).
++const sealed = await locker.seal(payload, {
++ lock: 'people', keys: [{ subject: { type: 'user', id: subject }, publicKey: mine.ecdh_public }]
++});
++const item = await fetch('https://openvibe.locker/api/v1/files', {
++ method: 'POST', headers: { ...headers, 'Content-Type': 'application/octet-stream' }, body: sealed.bytes
++}).then(r => r.json());
++const withRecipient = await locker.rewrap(sealed.bytes, {
++ credentials: { person }, add: [{ subject: { type: 'user', id: recipient.subject }, publicKey: recipient.ecdh_public }]
++});
++await share({ base: 'https://openvibe.locker', token }).for('files', item.id).update({
++ members: [{ subject: { type: 'user', id: recipient.subject }, role: 'viewer', key_slot: withRecipient.header.slots.people.find(s => s.subject.id === recipient.subject) }],
++ envelope: Buffer.from(withRecipient.bytes).toString('base64')
++});`)}</code></pre><p>For large files use <code>sealStream</code> and <code>openStream</code>. Discard a download if final verification fails. See the <a href="/openapi.json">OpenAPI reference</a>, <a href="https://github.com/OpenVibers/OpenVibe.Locker">source and payload format</a>, and <a href="/llms.txt">agent guide</a>.</p><p>${warning}</p></article>`, 'docs'));
++ const legal = require('openvibe-shared/legal');
++ r.get(legal.PATHS, legal.handler({ id: 'locker', service: 'locker', host: 'openvibe.locker', name: 'OpenVibe.Locker', profile: 'hosting' }));
++ return r;
++}
++module.exports = { createPages };
ddiff --git a/server/service.js b/server/service.js
nnew file mode 100644
iindex 0000000..5c756f9
---- /dev/null
++++ b/server/service.js
@@@ -0,0 +1,259 @@
++'use strict';
++const { randomUUID } = require('node:crypto');
++const fs = require('node:fs/promises');
++const { createWriteStream } = require('node:fs');
++const { Transform } = require('node:stream');
++const { pipeline } = require('node:stream/promises');
++const path = require('node:path');
++const { fail } = require('./errors');
++const { inspectFile, headerInfo } = require('./envelope');
++const { padme } = require('openvibe-sdk/locker');
++const subjectRef = (id) => ({ type: 'user', id });
++const shareRef = (id) => ({ service: 'locker', kind: 'files', id });
++const ID = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
++const userId = (user) => {
++ const id = user && (user.subject_id || user.subject?.id || user.sub);
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(id || '') || user.is_anon) fail(401, 'locker.auth.required', 'Sign in with an OpenVibe account.');
++ return id;
++};
++function createService({ db, config, network, media, now = Date.now }) {
++ let shares;
++ const iso = () => new Date(now()).toISOString();
++ // True sizes stay encrypted. The client enforces the 2 GiB plaintext limit; the server bounds its padded envelope.
++ const maxPadded = padme(config.maxFileBytes + 65536 + 8);
++ const maxWire = maxPadded + Math.ceil(maxPadded / 1048576) * 33 + 65544;
++ const tmp = path.join(config.dataDir, 'uploads');
++ const wire = (r) => ({ id: r.id, kind: r.kind, parent: r.parent, envelope_bytes: r.envelope_bytes, lock: r.lock,
++ created_at: r.created_at, expires_at: r.expires_at, download_limit: r.download_limit, downloads: r.downloads,
++ disabled: r.disabled, revision: r.revision });
++ async function account(subject, t = db) {
++ await t.exec('INSERT INTO locker_accounts(subject) VALUES ($1) ON CONFLICT DO NOTHING', [subject]);
++ return t.one('SELECT * FROM locker_accounts WHERE subject = $1 FOR UPDATE', [subject]);
++ }
++ async function rate(bucket, limit, windowMs = 3600000) {
++ const start = Math.floor(now() / windowMs) * windowMs;
++ const r = await db.one(`INSERT INTO locker_rates(bucket, window_start, attempts) VALUES ($1,$2,1)
++ ON CONFLICT(bucket) DO UPDATE SET window_start = excluded.window_start,
++ attempts = CASE WHEN locker_rates.window_start = excluded.window_start THEN locker_rates.attempts + 1 ELSE 1 END RETURNING attempts`, [bucket, start]);
++ if (r.attempts > limit) fail(429, 'locker.rate_limited', 'Please wait before trying again.');
++ }
++ async function standing(user) {
++ const subject = userId(user);
++ const state = await network.standing(subject);
++ const age = state?.trust?.account_age_days;
++ if (!state || state.subject?.id !== subject || state.banned || state.deleted || state.restricted || state.trust?.upload_restricted
++ || !Number.isFinite(age) || age < config.minAgeDays || (config.allowlistOnly && !config.allowlist.has(subject))) {
++ fail(403, 'locker.upload.ineligible', `Uploads need an account at least ${config.minAgeDays} days old in good standing.`);
++ }
++ return subject;
++ }
++ async function reserve(user, bytes, kind) {
++ const subject = await standing(user);
++ if (!Number.isSafeInteger(bytes) || bytes < 8) fail(411, 'locker.length.required', 'Send Content-Length for the encrypted envelope.');
++ if (bytes > maxWire) fail(413, 'locker.file.too_large', 'The encrypted file is too large.');
++ await rate(`upload:${subject}`, config.uploadsPerHour);
++ if (kind === 'send') await rate(`send:${subject}`, config.sendsPerHour);
++ const id = randomUUID();
++ // A staged replacement is a real second ciphertext copy until it commits.
++ const held = bytes;
++ await db.tx(async (t) => {
++ const a = await account(subject, t);
++ if (a.restricted || a.deleted) fail(403, 'locker.upload.restricted', 'Uploads are restricted for this account.');
++ if (a.used_bytes + a.reserved_bytes + held > config.quotaBytes) fail(413, 'locker.quota', 'Your locker storage is full. Delete an item to make room.');
++ await t.exec('UPDATE locker_accounts SET reserved_bytes = reserved_bytes + $2 WHERE subject = $1', [subject, held]);
++ await t.exec('INSERT INTO locker_uploads(id, owner, bytes, due_at) VALUES ($1,$2,$3,$4)', [id, subject, held, new Date(now() + 3600000).toISOString()]);
++ });
++ return { id, owner: subject, bytes: held, total: bytes, file: path.join(tmp, `${id}.ovlk`), media_id: null };
++ }
++ async function queue(t, mediaId) {
++ if (mediaId) await t.exec('INSERT INTO locker_gc(media_id,due_at) VALUES ($1,$2) ON CONFLICT DO NOTHING', [mediaId, iso()]);
++ }
++ async function release(upload, expiredOnly = false) {
++ const removed = await db.tx(async (t) => {
++ await account(upload.owner, t);
++ const r = await t.maybe('DELETE FROM locker_uploads WHERE id = $1 AND ($2::boolean = false OR due_at <= $3) RETURNING *', [upload.id, expiredOnly, iso()]);
++ if (!r) return;
++ await t.exec('UPDATE locker_accounts SET reserved_bytes = reserved_bytes - $2 WHERE subject = $1', [r.owner, r.bytes]);
++ await queue(t, r.media_id);
++ return true;
++ });
++ if (expiredOnly && !removed) return;
++ if (upload.file) await fs.rm(upload.file, { force: true });
++ }
++ async function receive(upload, source) {
++ await fs.mkdir(tmp, { recursive: true, mode: 0o700 });
++ let count = 0;
++ const limit = new Transform({ transform(chunk, _encoding, done) {
++ count += chunk.length;
++ if (count > upload.total) done(Object.assign(new Error('Envelope length mismatch.'), { status: 400, code: 'locker.length' }));
++ else done(null, chunk);
++ } });
++ await pipeline(source, limit, createWriteStream(upload.file, { flags: 'wx', mode: 0o600 }));
++ if (count !== upload.total) fail(400, 'locker.length', 'Envelope length mismatch.');
++ const info = await inspectFile(upload.file, count, maxPadded);
++ return info;
++ }
++ async function putMedia(upload) {
++ upload.media_id = await media.upload(upload.file, upload.total, upload.owner, async (id) => {
++ upload.media_id = id;
++ const result = await db.exec('UPDATE locker_uploads SET media_id = $2 WHERE id = $1', [upload.id, id]);
++ if (!result) { await queue(db, id); fail(409, 'locker.upload.expired', 'The upload reservation expired. Try again.'); }
++ });
++ }
++ function checkPeople(header, owner, members = []) {
++ const expected = new Set([owner, ...members]);
++ const actual = header.slots.people || [];
++ if (actual.length !== expected.size || actual.some((s) => s.subject.type !== 'user' || !expected.delete(s.subject.id)) || expected.size) {
++ fail(400, 'locker.recipients', 'The envelope must have exactly the owner and the current sharing list.');
++ }
++ }
++ async function owned(user, id, t = db) {
++ if (!ID.test(id)) fail(404, 'locker.not_found', 'Item not found.');
++ const r = await t.maybe('SELECT * FROM locker_items WHERE id = $1 AND owner = $2 AND deleted_at IS NULL', [id, userId(user)]);
++ if (!r) fail(404, 'locker.not_found', 'Item not found.');
++ return r;
++ }
++ async function ready(t, upload, delta) {
++ const a = await account(upload.owner, t);
++ if (a.restricted || a.deleted) fail(403, 'locker.upload.restricted', 'Uploads are restricted for this account.');
++ const row = await t.maybe('DELETE FROM locker_uploads WHERE id = $1 RETURNING *', [upload.id]);
++ if (!row || Date.parse(row.due_at) <= now()) fail(409, 'locker.upload.expired', 'The upload reservation expired. Try again.');
++ await t.exec('UPDATE locker_accounts SET used_bytes = used_bytes + $2, reserved_bytes = reserved_bytes - $3 WHERE subject = $1', [upload.owner, delta, row.bytes]);
++ }
++ async function remember(t, upload, result) {
++ if (upload.resumable) await t.exec(`INSERT INTO locker_upload_results(id,owner,result,due_at) VALUES ($1,$2,$3::jsonb,$4)`,
++ [upload.id, upload.owner, JSON.stringify(result), new Date(now() + 86400000).toISOString()]);
++ }
++ async function create(user, source, bytes, { kind = 'file', parent = null, expiresIn, downloadLimit } = {}, prepared = null) {
++ if (!['file', 'folder', 'send'].includes(kind)) fail(400, 'locker.kind', 'Choose a file, folder or Send.');
++ let expires = null;
++ if (kind === 'send') {
++ if (!Number.isSafeInteger(expiresIn) || expiresIn < 3600 || expiresIn > 30 * 86400) fail(400, 'locker.expiry', 'A Send expires in 1 hour to 30 days.');
++ if (downloadLimit != null && (!Number.isSafeInteger(downloadLimit) || downloadLimit < 1 || downloadLimit > 100000)) fail(400, 'locker.download_limit', 'The download limit must be a positive integer up to 100000.');
++ expires = new Date(now() + expiresIn * 1000).toISOString();
++ if (parent) fail(400, 'locker.parent', 'Sends do not belong to folders.');
++ }
++ const upload = prepared?.upload || await reserve(user, bytes, kind);
++ try {
++ const { header } = prepared?.info || await receive(upload, source);
++ if (kind === 'send' ? header.lock !== 'link' : header.lock !== 'people') fail(400, 'locker.lock', kind === 'send' ? 'Sends use a link lock.' : 'Drive items use a people lock.');
++ if (kind !== 'send') checkPeople(header, upload.owner);
++ await putMedia(upload);
++ const row = await db.tx(async (t) => {
++ // The account lock also serializes a parent deletion with this child insertion.
++ await ready(t, upload, bytes);
++ if (parent) {
++ const p = await owned(user, parent, t);
++ if (p.kind !== 'folder' || p.disabled) fail(400, 'locker.parent', 'Choose one of your active folders.');
++ }
++ const id = randomUUID();
++ const r = await t.one(`INSERT INTO locker_items(id,owner,kind,parent,media_id,envelope_bytes,lock,header,created_at,updated_at,expires_at,download_limit)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8::jsonb,$9,$9,$10,$11) RETURNING *`,
++ [id, upload.owner, kind, parent, upload.media_id, bytes, header.lock, JSON.stringify(header), iso(), expires, downloadLimit || null]);
++ if (kind !== 'send') {
++ await shares.register(shareRef(id), { owner_subject: subjectRef(upload.owner), audience: 'people', lock: 'people' });
++ await t.exec('UPDATE shares SET reseal_nonce_base = $2 WHERE service = \'locker\' AND kind = \'files\' AND resource_id = $1', [id, header.nonce_base]);
++ }
++ await remember(t, upload, wire(r));
++ return r;
++ });
++ return wire(row);
++ } finally { await release(upload); }
++ }
++ async function replace(user, id, source, bytes, revision, prepared = null) {
++ const old = await owned(user, id);
++ if (old.kind === 'send' || old.disabled) fail(409, 'locker.replace', 'This item cannot be changed.');
++ if (revision !== old.revision) fail(412, 'locker.revision', 'The item changed. Open it again before saving.');
++ const upload = prepared?.upload || await reserve(user, bytes, old.kind);
++ try {
++ const { header } = prepared?.info || await receive(upload, source);
++ if (header.lock !== 'people') fail(400, 'locker.lock', 'Drive items use a people lock.');
++ await putMedia(upload);
++ return await db.tx(async (t) => {
++ const current = await t.one('SELECT * FROM locker_items WHERE id = $1 FOR UPDATE', [id]);
++ if (current.revision !== revision || current.deleted_at || current.disabled) fail(412, 'locker.revision', 'The item changed. Open it again.');
++ const members = await t.many('SELECT subject FROM share_members WHERE service = \'locker\' AND kind = \'files\' AND resource_id = $1', [id]);
++ checkPeople(header, upload.owner, members.map((m) => m.subject.id));
++ await ready(t, upload, bytes - old.envelope_bytes);
++ await queue(t, old.media_id);
++ const r = await t.one('UPDATE locker_items SET media_id=$2,envelope_bytes=$3,header=$4::jsonb,revision=revision+1,updated_at=$5 WHERE id=$1 RETURNING *', [id, upload.media_id, bytes, JSON.stringify(header), iso()]);
++ await t.exec('UPDATE shares SET reseal_nonce_base=$2, revision=revision+1 WHERE service=\'locker\' AND kind=\'files\' AND resource_id=$1', [id, header.nonce_base]);
++ for (const slot of header.slots.people) await t.exec('UPDATE share_members SET key_slot=$3::jsonb WHERE service=\'locker\' AND kind=\'files\' AND resource_id=$1 AND subject=$2::jsonb', [id, JSON.stringify(slot.subject), JSON.stringify(slot)]);
++ await remember(t, upload, wire(r));
++ return wire(r);
++ });
++ } finally { await release(upload); }
++ }
++ async function list(user, kind, { before, limit = 100 } = {}) {
++ let date = null, id = null;
++ if (before) {
++ try {
++ [date, id] = JSON.parse(Buffer.from(before, 'base64url').toString());
++ if (!Number.isFinite(Date.parse(date)) || !ID.test(id)) throw new Error('cursor');
++ } catch { fail(400, 'locker.cursor', 'Invalid cursor.'); }
++ }
++ const count = Math.min(100, Math.max(1, Math.floor(Number(limit)) || 100));
++ const rows = await db.many(`SELECT * FROM locker_items WHERE owner=$1 AND deleted_at IS NULL AND (kind='send')=$2
++ AND ($3::timestamptz IS NULL OR (created_at,id)<($3,$4::uuid)) ORDER BY created_at DESC,id DESC LIMIT $5`,
++ [userId(user), kind === 'send', date, id, count]);
++ return { items: rows.map(wire), next_cursor: rows.length === count ? Buffer.from(JSON.stringify([rows.at(-1).created_at, rows.at(-1).id])).toString('base64url') : null };
++ }
++ async function view(user, id, send = false) {
++ if (!ID.test(id)) fail(404, 'locker.not_found', 'Item not found.');
++ const r = await db.maybe('SELECT * FROM locker_items WHERE id=$1 AND deleted_at IS NULL AND disabled=false', [id]);
++ if (!r || (r.kind === 'send') !== send) fail(404, 'locker.not_found', 'Item not found.');
++ if (send) {
++ if (Date.parse(r.expires_at) <= now() || (r.download_limit && r.downloads >= r.download_limit)) fail(410, 'locker.send.expired', 'This Send has expired or reached its download limit.');
++ } else if (!user || !await shares.canView({ subject: subjectRef(userId(user)) }, shareRef(id))) fail(404, 'locker.not_found', 'Item not found.');
++ return r;
++ }
++ async function download(user, id, send) {
++ let r = await view(user, id, send);
++ if (send) {
++ // One atomic claim per fetch. A disconnected or failed transfer consumes its claim too.
++ r = await db.maybe(`UPDATE locker_items SET downloads=downloads+1 WHERE id=$1 AND kind='send' AND disabled=false AND deleted_at IS NULL
++ AND expires_at>$2 AND (download_limit IS NULL OR downloads<download_limit) RETURNING *`, [id, iso()]);
++ if (!r) fail(410, 'locker.send.expired', 'This Send has expired or reached its download limit.');
++ }
++ return { row: r, response: await media.read(r.media_id) };
++ }
++ async function removeIn(t, row) {
++ if (row.deleted_at) return;
++ await t.exec('UPDATE locker_items SET deleted_at=$2,disabled=true WHERE id=$1 AND deleted_at IS NULL', [row.id, iso()]);
++ await t.exec('UPDATE locker_items SET parent=NULL WHERE parent=$1', [row.id]);
++ await t.exec('UPDATE locker_accounts SET used_bytes=used_bytes-$2 WHERE subject=$1', [row.owner, row.envelope_bytes]);
++ await queue(t, row.media_id);
++ }
++ async function remove(user, id) {
++ const subject = userId(user);
++ await db.tx(async (t) => {
++ await account(subject, t);
++ const row = await owned(user, id, t);
++ await removeIn(t, row);
++ });
++ }
++ async function sweep() {
++ // Due rows, one bounded batch per invocation. A one-shot timer schedules the next due timestamp.
++ await db.tx(async (t) => {
++ const expired = await t.many(`SELECT * FROM locker_items WHERE kind='send' AND deleted_at IS NULL AND expires_at<=$1 ORDER BY expires_at LIMIT 100 FOR UPDATE SKIP LOCKED`, [iso()]);
++ for (const row of expired) { await account(row.owner, t); await removeIn(t, row); }
++ });
++ const abandoned = await db.many('SELECT * FROM locker_uploads WHERE due_at<=$1 ORDER BY due_at LIMIT 100', [iso()]);
++ for (const u of abandoned) await release({ ...u, file: path.join(tmp, `${u.id}.ovlk`) }, true);
++ const garbage = await db.many('SELECT * FROM locker_gc WHERE due_at<=$1 ORDER BY due_at LIMIT 100', [iso()]);
++ for (const g of garbage) {
++ try { await media.delete(g.media_id); await db.exec('DELETE FROM locker_gc WHERE media_id=$1', [g.media_id]); }
++ catch { await db.exec('UPDATE locker_gc SET attempts=attempts+1,due_at=$2 WHERE media_id=$1', [g.media_id, new Date(now() + Math.min(3600000, 30000 * 2 ** Math.min(g.attempts, 7))).toISOString()]); }
++ }
++ await db.exec('DELETE FROM locker_upload_results WHERE due_at<=$1', [iso()]);
++ await db.exec('DELETE FROM locker_rates WHERE window_start<$1', [now() - 2 * 3600000]);
++ }
++ async function nextDue() {
++ return db.value(`SELECT MIN(due) FROM (SELECT MIN(expires_at) due FROM locker_items WHERE kind='send' AND deleted_at IS NULL
++ UNION ALL SELECT MIN(due_at) FROM locker_upload_results UNION ALL SELECT MIN(due_at) FROM locker_gc UNION ALL SELECT MIN(due_at) FROM locker_uploads) q`);
++ }
++ return { db, config, now, iso, wire, maxPadded, maxWire, rate, standing, account, owned, create, replace, list, view, download, remove,
++ removeIn, sweep, nextDue, remember, reserve, receive, putMedia, release, queue, ready, checkPeople, inspect: (b) => headerInfo(b, b.length, maxPadded),
++ flushShares: () => shares?.flush(), setShares(value) { shares = value; } };
++}
++module.exports = { createService, userId, subjectRef, shareRef, ID };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..de2e66a
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,119 @@
++'use strict';
++const { AsyncLocalStorage } = require('node:async_hooks');
++const { Readable } = require('node:stream');
++const fs = require('node:fs/promises');
++const { createShares } = require('openvibe-sdk/share');
++const { userId, subjectRef, shareRef } = require('./service');
++const { fail } = require('./errors');
++function createSharing({ service, network }) {
++ const { db, config } = service;
++ const context = new AsyncLocalStorage();
++ async function commitEnvelope(ref, _receipt, t) {
++ const ctx = context.getStore();
++ if (!ctx?.prepared || ctx.id !== ref.id) fail(409, 'locker.share.prepare', 'Upload the newly sealed envelope with this sharing change.');
++ const { upload, info, old } = ctx.prepared;
++ const current = await t.one('SELECT * FROM locker_items WHERE id=$1 FOR UPDATE', [ref.id]);
++ if (current.revision !== old.revision || current.disabled || current.deleted_at || current.owner !== upload.owner) fail(412, 'locker.revision', 'The item changed. Open it again.');
++ const held = await t.maybe('SELECT * FROM locker_uploads WHERE id=$1 FOR UPDATE', [upload.id]);
++ if (!held || Date.parse(held.due_at) <= service.now() || (held.target && held.target !== ref.id)) fail(409, 'locker.upload.expired', 'The staged upload has expired.');
++ const members = ctx.members || [];
++ service.checkPeople(info.header, current.owner, members.map((m) => m.subject.id));
++ await service.ready(t, upload, upload.total - current.envelope_bytes);
++ await service.queue(t, current.media_id);
++ await t.exec('UPDATE locker_items SET media_id=$2,envelope_bytes=$3,header=$4::jsonb,revision=revision+1,updated_at=$5 WHERE id=$1',
++ [ref.id, upload.media_id, upload.total, JSON.stringify(info.header), service.iso()]);
++ }
++ const shares = createShares({ db, now: service.now,
++ network: { async json(options) {
++ const keys = /^\/api\/v1\/people\//.test(options.path);
++ const out = await network.json({ ...options, token: keys ? context.getStore()?.token : undefined });
++ return keys && out?.ecdh_public ? { ...out, ecdh: out.ecdh_public, signing: out.signing_public } : out;
++ } },
++ events: { publish: (event) => network.json({ path: '/api/v1/events', method: 'POST', json: event, audience: 'openvibe.events', base: config.eventsUrl }) },
++ notify: (n) => network.json({ path: '/internal/notifications/push', method: 'POST', json: {
++ subject: n.to.id, service: 'locker', type: 'GENERIC', category: 'social',
++ title: 'Sharing on OpenVibe Locker', message: 'A locked item was shared or its access changed.',
++ url: `${config.baseUrl}/files/${encodeURIComponent(n.ref.id)}`,
++ } }),
++ onAudienceChange: async (_ref, audience) => {
++ if (!['people', 'private'].includes(audience)) fail(400, 'locker.share.private', 'Drive items can only be shared with specific people.');
++ },
++ onOwnerChange: async () => fail(409, 'locker.transfer.unavailable', 'Move a copy into your own locker; ownership transfer is not available.'),
++ onReseal: commitEnvelope,
++ resolveReseal: async (ref, viewer, receipt) => {
++ const prepared = context.getStore()?.prepared;
++ if (!prepared || prepared.upload.id !== receipt || prepared.old.id !== ref.id || prepared.upload.owner !== viewer.subject.id) fail(404, 'locker.upload.not_found', 'No staged envelope for this item.');
++ return { prefix: prepared.info.prefix, byteLength: prepared.upload.total };
++ },
++ onResealReference: commitEnvelope,
++ });
++ service.setShares(shares);
++ const viewer = (req) => req.user ? { subject: subjectRef(userId(req.user)) } : null;
++ async function middleware(req, res, next) {
++ const match = req.path.match(/^\/api\/v1\/files\/([^/]+)\/share(?:\/(.*))?$/);
++ if (!match && !['/api/v1/share/people', '/api/v1/shared-with-me'].includes(req.path)) return next();
++ const ctx = { token: req.token, id: match?.[1], prepared: null, members: req.body?.members };
++ try {
++ if (match) {
++ // Disabled and deleted items cannot be changed through the generic share router.
++ const row = await service.view(req.user, match[1]);
++ if (!['GET', 'HEAD'].includes(req.method) && match[2] !== 'requests') {
++ await service.owned(req.user, match[1]);
++ if (req.body?.members !== undefined && !Array.isArray(req.body.members)) fail(400, 'locker.share.members', 'Members must be a list.');
++ if (req.body?.members?.some((m) => !m || m.role !== 'viewer')) fail(400, 'locker.share.role', 'Locked files are shared with viewers.');
++ if ((req.body?.envelope !== undefined || req.body?.envelope_ref !== undefined) && !Array.isArray(req.body.members)) fail(400, 'locker.share.members', 'A reseal must include the complete members list.');
++ if (match[2]?.startsWith('transfer')) fail(409, 'locker.transfer.unavailable', 'Ownership transfer is not available.');
++ if (typeof req.body?.envelope === 'string') {
++ const bytes = Buffer.from(req.body.envelope, 'base64');
++ const info = service.inspect(bytes);
++ const upload = await service.reserve(req.user, bytes.length, row.kind);
++ ctx.prepared = { upload, info, old: row };
++ await service.receive(upload, Readable.from(bytes));
++ await service.putMedia(upload);
++ } else if (req.body?.envelope_ref) {
++ const receipt = req.body.envelope_ref;
++ if (!/^[0-9a-f-]{36}$/.test(receipt)) fail(400, 'locker.upload.reference', 'Invalid staged upload reference.');
++ const upload = await db.maybe('SELECT * FROM locker_uploads WHERE id=$1 AND owner=$2 AND target=$3', [receipt, userId(req.user), row.id]);
++ if (!upload || !upload.prefix || !upload.media_id || Date.parse(upload.due_at) <= service.now() || upload.revision !== row.revision) fail(409, 'locker.upload.expired', 'The item or staged upload changed. Upload it again.');
++ const prefix = Buffer.from(upload.prefix);
++ ctx.prepared = { upload: { ...upload, total: upload.total_bytes, file: '' }, info: { prefix, header: JSON.parse(prefix.subarray(8).toString()) }, old: row };
++ }
++ }
++ }
++ await context.run(ctx, () => shares.router({ service: 'locker', authenticate: viewer,
++ describe: async (ref) => {
++ await service.view(req.user, ref.id);
++ return { title: 'A locked item', link: `${config.baseUrl}/files/${ref.id}`, roles: ['viewer'], audiences: ['people', 'private'], transferable: false,
++ envelope_url: `/api/v1/files/${ref.id}/content` };
++ }, csrf: false,
++ })(req, res, next));
++ } catch (e) { next(e); }
++ finally { if (ctx.prepared) await service.release(ctx.prepared.upload); }
++ }
++ async function stage(user, id, source, bytes, revision, prepared = null) {
++ const old = await service.owned(user, id);
++ if (old.kind === 'send' || old.disabled || old.revision !== revision) fail(412, 'locker.revision', 'The item changed. Open it again.');
++ const upload = prepared?.upload || await service.reserve(user, bytes, old.kind);
++ let staged = false;
++ try {
++ const info = prepared?.info || await service.receive(upload, source);
++ if (info.header.lock !== 'people') fail(400, 'locker.lock', 'Drive items use a people lock.');
++ await service.putMedia(upload);
++ await db.tx(async (t) => {
++ const a = await service.account(upload.owner, t);
++ if (a.deleted || a.restricted) fail(403, 'locker.upload.restricted', 'Uploads are restricted for this account.');
++ const held = await t.maybe('SELECT * FROM locker_uploads WHERE id=$1 FOR UPDATE', [upload.id]);
++ if (!held || Date.parse(held.due_at) <= service.now()) fail(409, 'locker.upload.expired', 'The upload expired.');
++ await t.exec('UPDATE locker_uploads SET total_bytes=$2,target=$3,revision=$4,prefix=$5 WHERE id=$1', [upload.id, bytes, id, revision, info.prefix]);
++ await service.remember(t, upload, { envelope_ref: upload.id, header: info.header });
++ });
++ staged = true;
++ return { envelope_ref: upload.id, header: info.header };
++ } finally {
++ if (!staged) await service.release(upload);
++ else await fs.rm(upload.file, { force: true });
++ }
++ }
++ return { shares, middleware, viewer, shareRef, stage };
++}
++module.exports = { createSharing };
ddiff --git a/server/uploads.js b/server/uploads.js
nnew file mode 100644
iindex 0000000..c19587e
---- /dev/null
++++ b/server/uploads.js
@@@ -0,0 +1,125 @@
++'use strict';
++const fs = require('node:fs/promises');
++const path = require('node:path');
++const { ID, userId } = require('./service');
++const { inspectFile } = require('./envelope');
++const { fail } = require('./errors');
++const PART_SIZE = 32 * 1024 ** 2;
++const SINGLE_LIMIT = 90 * 1024 ** 2;
++function createUploads({ service, sharing }) {
++ const { db, config } = service;
++ const directory = path.join(config.dataDir, 'uploads');
++ const fileFor = (id) => path.join(directory, `${id}.ovlk`);
++ const due = () => new Date(service.now() + 3600000).toISOString();
++ async function row(user, id, t = db, lock = false) {
++ if (!ID.test(id)) fail(404, 'locker.upload.not_found', 'Upload not found.');
++ const r = await t.maybe(`SELECT * FROM locker_uploads WHERE id=$1 AND owner=$2 AND options IS NOT NULL${lock ? ' FOR UPDATE' : ''}`, [id, userId(user)]);
++ if (!r || Date.parse(r.due_at) <= service.now()) fail(404, 'locker.upload.not_found', 'Upload not found or expired.');
++ return r;
++ }
++ async function result(user, id) {
++ if (!ID.test(id)) fail(404, 'locker.upload.not_found', 'Upload not found.');
++ return db.maybe('SELECT result FROM locker_upload_results WHERE id=$1 AND owner=$2 AND due_at>$3', [id, userId(user), service.iso()]);
++ }
++ async function start(user, input, revision) {
++ const allowed = ['kind', 'bytes', 'parent', 'expires_in', 'download_limit', 'item_id'];
++ if (!input || Array.isArray(input) || Object.keys(input).some((k) => !allowed.includes(k))) fail(400, 'locker.metadata.private', 'Only upload settings belong outside the envelope.');
++ const { kind, bytes, parent, expires_in: expiresIn, download_limit: downloadLimit, item_id: itemId } = input;
++ if (!['file', 'folder', 'send', 'replace', 'reseal'].includes(kind)) fail(400, 'locker.kind', 'Choose file, folder, send, replace or reseal.');
++ if (['replace', 'reseal'].includes(kind)) {
++ if (!Number.isSafeInteger(revision) || revision < 1) fail(428, 'locker.revision.required', 'Send the item revision in If-Match.');
++ const item = await service.owned(user, itemId);
++ if (item.kind === 'send' || item.disabled || item.revision !== revision) fail(412, 'locker.revision', 'The item changed. Open it again.');
++ } else if (itemId != null) fail(400, 'locker.kind', 'Use replace or reseal with item_id.');
++ if (kind === 'send') {
++ if (!Number.isSafeInteger(expiresIn) || expiresIn < 3600 || expiresIn > 30 * 86400) fail(400, 'locker.expiry', 'A Send expires in 1 hour to 30 days.');
++ if (downloadLimit != null && (!Number.isSafeInteger(downloadLimit) || downloadLimit < 1 || downloadLimit > 100000)) fail(400, 'locker.download_limit', 'Choose a positive download limit up to 100000.');
++ } else if (expiresIn != null || downloadLimit != null) fail(400, 'locker.kind', 'Only Sends have expiry and download limits.');
++ if (parent != null) {
++ if (!['file', 'folder'].includes(kind)) fail(400, 'locker.parent', 'Only Drive items belong to folders.');
++ const folder = await service.owned(user, parent);
++ if (folder.kind !== 'folder' || folder.disabled) fail(400, 'locker.parent', 'Choose an active folder.');
++ }
++ const upload = await service.reserve(user, bytes, kind);
++ try {
++ await fs.mkdir(directory, { recursive: true, mode: 0o700 });
++ const file = await fs.open(upload.file, 'wx', 0o600);
++ try { await file.truncate(bytes); } finally { await file.close(); }
++ await db.exec('UPDATE locker_uploads SET options=$2::jsonb,total_bytes=$3,target=$4,revision=$5 WHERE id=$1',
++ [upload.id, JSON.stringify(input), bytes, itemId || null, revision || null]);
++ return { id: upload.id, part_size: PART_SIZE };
++ } catch (e) { await service.release(upload); throw e; }
++ }
++ async function status(user, id) {
++ const done = await result(user, id);
++ if (done) return { id, part_size: PART_SIZE, state: 'complete', result: done.result };
++ const r = await row(user, id);
++ const parts = await db.many('SELECT part FROM locker_upload_parts WHERE upload_id=$1 ORDER BY part', [id]);
++ return { id, part_size: PART_SIZE, bytes: r.total_bytes, received_parts: parts.map((p) => p.part),
++ state: r.completing ? 'completing' : 'open', expires_at: r.due_at };
++ }
++ async function part(user, id, number, source, length) {
++ if (!/^(0|[1-9][0-9]*)$/.test(number)) fail(400, 'locker.upload.part', 'Invalid part number.');
++ const n = Number(number);
++ // Hold the row lock through writes and fsync. Completion, duplicate parts and cleanup
++ // cannot race these writes, including when multiple service processes share the database.
++ await db.tx(async (t) => {
++ const r = await row(user, id, t, true);
++ if (r.completing) fail(409, 'locker.upload.busy', 'The upload is completing.');
++ const expected = Math.min(PART_SIZE, r.total_bytes - n * PART_SIZE);
++ if (!Number.isSafeInteger(n) || expected <= 0) fail(400, 'locker.upload.part', 'Part is outside the envelope.');
++ if (length > PART_SIZE || length > expected) fail(413, 'locker.upload.part_size', 'The part is too large.');
++ if (length != null && length !== expected) fail(400, 'locker.upload.length', 'The part length does not match.');
++ const received = await t.maybe('SELECT part FROM locker_upload_parts WHERE upload_id=$1 AND part=$2', [id, n]);
++ // Retry bodies are counted too, but an already committed part is never overwritten.
++ const file = received ? null : await fs.open(fileFor(id), 'r+');
++ let count = 0;
++ try {
++ // Do not destroy IncomingMessage on a length error; the caller must receive the 413.
++ for await (const chunk of source.iterator({ destroyOnReturn: false })) {
++ if (count + chunk.length > expected) fail(413, 'locker.upload.part_size', 'The part is too large.');
++ let offset = 0;
++ while (file && offset < chunk.length) {
++ const { bytesWritten } = await file.write(chunk, offset, chunk.length - offset, n * PART_SIZE + count + offset);
++ if (!bytesWritten) throw new Error('Could not write the upload part.');
++ offset += bytesWritten;
++ }
++ count += chunk.length;
++ }
++ if (count !== expected) fail(400, 'locker.upload.length', 'The part length does not match.');
++ if (received) return;
++ await file.sync();
++ await t.exec('INSERT INTO locker_upload_parts(upload_id,part,bytes) VALUES ($1,$2,$3)', [id, n, count]);
++ await t.exec('UPDATE locker_uploads SET due_at=$2 WHERE id=$1', [id, due()]);
++ } finally { if (file) await file.close(); }
++ });
++ return { received: n };
++ }
++ async function complete(user, id, revision) {
++ const done = await result(user, id);
++ if (done) return done.result;
++ await service.standing(user);
++ const r = await db.tx(async (t) => {
++ const held = await row(user, id, t, true);
++ if (held.completing) fail(409, 'locker.upload.busy', 'The upload is completing.');
++ if (held.target && held.revision !== revision) fail(412, 'locker.revision', 'Send the original If-Match revision.');
++ const parts = await t.many('SELECT part,bytes FROM locker_upload_parts WHERE upload_id=$1 ORDER BY part', [id]);
++ if (parts.length !== Math.ceil(held.total_bytes / PART_SIZE) || parts.some((p, n) => p.part !== n || p.bytes !== Math.min(PART_SIZE, held.total_bytes - n * PART_SIZE))) {
++ fail(409, 'locker.upload.incomplete', 'Upload every part before completing.');
++ }
++ await t.exec('UPDATE locker_uploads SET completing=true,due_at=$2 WHERE id=$1', [id, due()]);
++ return held;
++ });
++ const upload = { ...r, total: r.total_bytes, file: fileFor(id), resumable: true };
++ try {
++ const info = await inspectFile(upload.file, upload.total, service.maxPadded);
++ const prepared = { upload, info };
++ if (r.options.kind === 'reseal') return await sharing.stage(user, r.target, null, upload.total, r.revision, prepared);
++ if (r.options.kind === 'replace') return await service.replace(user, r.target, null, upload.total, r.revision, prepared);
++ return await service.create(user, null, upload.total, { kind: r.options.kind, parent: r.options.parent,
++ expiresIn: r.options.expires_in, downloadLimit: r.options.download_limit }, prepared);
++ } catch (e) { await service.release(upload); throw e; }
++ }
++ return { start, status, part, complete };
++}
++module.exports = { createUploads, PART_SIZE, SINGLE_LIMIT };
ddiff --git a/test/browser.js b/test/browser.js
nnew file mode 100644
iindex 0000000..20509b8
---- /dev/null
++++ b/test/browser.js
@@@ -0,0 +1,122 @@
++'use strict';
++const assert = require('node:assert/strict');
++const fs = require('node:fs/promises');
++const path = require('node:path');
++let chromium;
++try { ({ chromium } = require('playwright')); }
++catch { ({ chromium } = require('/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright')); }
++const { boot, SUBJECT, OTHER, locker } = require('./helpers');
++(async () => {
++ const t = await boot({ config: { networkUrl: 'https://openvibe.network' } });
++ const out = path.resolve('artifacts'); await fs.mkdir(out, { recursive: true });
++ const options = { headless: true };
++ try { await fs.access('/usr/bin/google-chrome'); options.executablePath = '/usr/bin/google-chrome'; } catch { /* bundled Chromium in CI */ }
++ const browser = await chromium.launch(options);
++ const passphrase = 'violet lantern river cobalt';
++ const privateKeys = await locker.exportPrivateKeys(t.people.get(SUBJECT));
++ const backup = await locker.seal(JSON.stringify(privateKeys), { lock: 'password', password: passphrase });
++ t.network.backups.set(SUBJECT, { passphrase: { envelope: Buffer.from(backup.bytes).toString('base64'), format: 'locker.envelope.v1' } });
++ const pub = await locker.exportPersonKeys(t.people.get(SUBJECT));
++ const errors = [], badRequests = [], traffic = [];
++ let droppedPart = false, droppedCompletion = false, resumed = false;
++ async function pageFor(width, scheme, signed = false) {
++ const context = await browser.newContext({ viewport: { width, height: 900 }, colorScheme: scheme,
++ deviceScaleFactor: 1, ...(signed ? { extraHTTPHeaders: { Authorization: `Bearer ${SUBJECT}` } } : {}) });
++ await context.addInitScript((mode) => { document.cookie = `ov_theme_id=${mode === "light" ? "cloud" : "vibe"};path=/`; }, scheme);
++ await context.route('https://openvibe.network/**', async (route) => {
++ const url = new URL(route.request().url());
++ const request = route.request();
++ let json = {};
++ if (url.pathname.endsWith('/keys/backup')) json = { subject: SUBJECT, backups: { passphrase: { envelope: Buffer.from(backup.bytes).toString('base64'), format: 'locker.envelope.v1' } } };
++ else if (url.pathname.endsWith('/keys')) { const subject = url.pathname.includes(OTHER) ? OTHER : SUBJECT; const keys = await locker.exportPersonKeys(t.people.get(subject)); json = { subject, ecdh_public: keys.ecdh, signing_public: keys.signing, fingerprint: keys.fingerprint }; }
++ else if (url.pathname.includes('me') || url.pathname.includes('session')) json = { user: null };
++ const type = request.resourceType();
++ await route.fulfill({ status: 200, contentType: type === 'script' ? 'application/javascript' : 'application/json', body: type === 'script' ? '' : JSON.stringify(json),
++ headers: { 'Access-Control-Allow-Origin': t.base, 'Access-Control-Allow-Credentials': 'true', 'Access-Control-Allow-Headers': 'authorization,content-type,x-requested-with' } });
++ });
++ await context.route(`${t.base}/api/v1/uploads/**`, async (route) => {
++ const request = route.request();
++ if (request.method() === 'GET' && droppedPart) resumed = true;
++ const dropPart = !droppedPart && request.method() === 'PUT' && request.url().includes('/parts/');
++ const dropCompletion = !droppedCompletion && request.method() === 'POST' && request.url().endsWith('/complete');
++ if (dropPart || dropCompletion) {
++ const response = await route.fetch();
++ assert.equal(response.status(), 200);
++ if (dropPart) droppedPart = true; else droppedCompletion = true;
++ return route.abort('connectionreset');
++ }
++ return route.continue();
++ });
++ const page = await context.newPage();
++ page.on('pageerror', (e) => errors.push(e.message));
++ page.on('console', (m) => { if (m.type() === 'error' && /Content Security Policy|Refused to/.test(m.text())) errors.push(m.text()); });
++ context.on('request', (r) => {
++ traffic.push(r.url() + '\n' + (r.postData() || '') + '\n' + (r.headers().referer || ''));
++ if (r.url().startsWith(t.base) && /[?&]k=|#k=/.test(r.url())) badRequests.push(r.url());
++ });
++ return { page, context };
++ }
++ try {
++ for (const width of (process.env.LOCKER_SKIP_SHOTS ? [] : [390, 1280])) for (const scheme of ['light', 'dark']) {
++ const { page, context } = await pageFor(width, scheme);
++ for (const route of ['/', '/drive', '/sends', '/recovery', '/docs']) {
++ await page.goto(t.base + route, { waitUntil: 'networkidle' });
++ assert.equal(await page.evaluate(() => document.documentElement.scrollWidth), width, `${route} ${width} ${scheme}: overflow`);
++ const smallTargets = await page.locator('main button,main input,main select').evaluateAll((elements) => elements.filter((e) => e.getAttribute('aria-hidden') !== 'true' && e.getBoundingClientRect().height && e.getBoundingClientRect().height < 44).map((e) => e.outerHTML.slice(0, 90)));
++ assert.deepEqual(smallTargets, [], 'touch controls are at least 44px');
++ await page.screenshot({ animations: 'disabled', timeout: 60000, path: path.join(out, `${route === '/' ? 'home' : route.slice(1)}-${width}-${scheme}.png`), fullPage: true });
++ }
++ await context.close();
++ }
++ const { page, context } = await pageFor(1280, 'light', true);
++ await page.goto(`${t.base}/drive`, { waitUntil: 'networkidle' });
++ await page.locator('[data-action=unlock]').click();
++ await page.locator('[data-k=pass]').fill(passphrase); await page.locator('[data-k=unlock]').click();
++ await page.waitForFunction(() => OVLocker.state().unlocked);
++ const content = Buffer.alloc(2 * 1048576 + 57, 77);
++ await page.locator('#upload-form input[type=file]').setInputFiles({ name: 'browser-private.txt', mimeType: 'text/plain', buffer: content });
++ await page.locator('#upload-form button.primary').click();
++ await page.waitForFunction(() => document.querySelector('#notice.error')?.textContent || [...document.querySelectorAll('main a')].some(a => a.textContent === 'browser-private.txt'), { timeout: 60000 });
++ assert.equal(await page.locator('#notice').textContent(), '', 'upload error');
++ await page.getByRole('link', { name: 'browser-private.txt', exact: true }).waitFor({ timeout: 5000 });
++ assert.ok(droppedPart && droppedCompletion && resumed, 'worker resumes a lost part response and retries completion');
++ console.log('Browser worker upload and connection-drop recovery passed.');
++ const response = await t.request('GET', '/api/v1/files'); const { items } = await response.json(); assert.equal(items.length, 1);
++ const encrypted = new Uint8Array(await (await t.request('GET', `/api/v1/files/${items[0].id}/content`)).arrayBuffer());
++ const plain = await locker.open(encrypted, { person: t.people.get(SUBJECT) });
++ assert.ok(Buffer.from(plain).includes(content));
++ assert.ok(!JSON.stringify(await t.db.many('SELECT * FROM locker_items')).includes('browser-private.txt'));
++ await page.goto(`${t.base}/files/${items[0].id}`, { waitUntil: 'networkidle' });
++ await page.locator('[data-action=unlock]').click(); await page.locator('[data-k=pass]').fill(passphrase); await page.locator('[data-k=unlock]').click();
++ await page.waitForFunction(() => OVLocker.state().unlocked);
++ const saved = page.waitForEvent('download'); await page.locator('[data-action=download]').click();
++ const download = await saved; const file = await download.path(); assert.deepEqual(await fs.readFile(file), content);
++ console.log('Browser download passed.');
++ await page.locator('#rename-form input').fill('renamed-privately.txt'); await page.locator('#rename-form button').click();
++ await page.waitForFunction(() => document.querySelector('#file-name').textContent === 'renamed-privately.txt');
++ console.log('Browser encrypted rename passed.');
++ await page.locator('[data-action=share]').click();
++ await page.locator('[data-k=add]').fill('friend'); await page.locator('[data-k=add-btn]').click();
++ await page.getByRole('button', { name: 'Save', exact: true }).click();
++ await page.waitForFunction(() => document.querySelector('.ov-share-note')?.textContent === 'Saved.' || document.querySelector('.ov-share-error')?.textContent, { timeout: 60000 });
++ assert.equal((await t.request('GET', `/api/v1/files/${items[0].id}/content`, { user: OTHER })).status, 200, 'the real Share sheet saved its staged envelope receipt');
++ const sharedBytes = new Uint8Array(await (await t.request('GET', `/api/v1/files/${items[0].id}/content`, { user: OTHER })).arrayBuffer());
++ assert.ok(Buffer.from(await locker.open(sharedBytes, { person: t.people.get(OTHER) })).includes(content));
++ console.log('Browser sharing passed.');
++ await page.goto(`${t.base}/sends`, { waitUntil: 'networkidle' });
++ await page.locator('#send-form textarea').fill('A browser round trip that stays private.');
++ await page.locator('#send-form button.primary').click();
++ await page.locator('#send-result input').waitFor({ timeout: 60000 }); const link = await page.locator('#send-result input').inputValue(); assert.match(link, /#k=[A-Za-z0-9_-]{43}$/);
++ const anonymous = await pageFor(390, 'dark');
++ await anonymous.page.goto(link, { waitUntil: 'networkidle' });
++ await anonymous.page.locator('[data-action=open-send]').click();
++ await anonymous.page.locator('#opened pre').waitFor({ timeout: 60000 });
++ assert.equal(await anonymous.page.locator('#opened pre').textContent(), 'A browser round trip that stays private.');
++ assert.deepEqual(badRequests, [], 'link keys never occur in HTTP requests');
++ const linkKey = new URL(link).hash.slice(3);
++ assert.equal(traffic.some((request) => request.includes(linkKey)), false, 'no request URL, body or referrer to any origin contains the link key');
++ await anonymous.context.close(); await context.close();
++ assert.deepEqual(errors, [], 'no browser exceptions or CSP refusals');
++ console.log(`Browser: ${process.env.LOCKER_SKIP_SHOTS ? 'existing' : '20 local'} screenshots, no overflow/CSP errors; real worker upload, download, rename, sharing and anonymous Send round trips.`);
++ } finally { await browser.close(); await t.close(); }
++})().catch((e) => { console.error(e); process.exitCode = 1; });
ddiff --git a/test/helpers.js b/test/helpers.js
nnew file mode 100644
iindex 0000000..ae691fe
---- /dev/null
++++ b/test/helpers.js
@@@ -0,0 +1,75 @@
++'use strict';
++const { createDb } = require('openvibe-sdk/db');
++const { migrate } = require('../server/db');
++const { createApp } = require('../server/app');
++const { load } = require('../server/config');
++const fs = require('node:fs/promises');
++const os = require('node:os');
++const path = require('node:path');
++const locker = require('openvibe-sdk/locker');
++const SUBJECT = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++const OTHER = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPR';
++const STAFF = 'usr_01JAB2C3D4E5F6G7H8J9K0MNPS';
++async function boot(overrides = {}) {
++ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'locker-test-'));
++ const db = createDb({ pglite: true, service: 'locker-test' }); await migrate(db);
++ const config = { ...load({ LOCKER_DATA_DIR: dir, BASE_URL: 'http://localhost', LOCKER_QUOTA_BYTES: String(30 * 1024 ** 2) }), ...overrides.config };
++ let clock = Date.now(), seq = 0;
++ const blobs = new Map(), deleted = [], principals = new Map();
++ const people = new Map();
++ people.set(SUBJECT, await locker.generatePersonKeys()); people.set(OTHER, await locker.generatePersonKeys());
++ const backups = new Map();
++ const network = {
++ backups,
++ async standing(subject) { return { subject: { type: 'user', id: subject }, trust: { account_age_days: 8 }, ...principals.get(subject) }; },
++ async json(options) {
++ if (options.path === '/api/v1/me/keys/backup') return { subject: options.token, backups: backups.get(options.token) || {} };
++ if (options.path === '/api/v1/me/keys' && options.method === 'PUT') { backups.set(options.token, options.json.backups); return { saved: true }; }
++ if (options.path.endsWith('/resolve-batch')) return { results: Object.fromEntries(options.json.subject_ids.map((subject) => [subject,
++ { subject: { type: 'user', id: subject }, username: subject === OTHER ? 'friend' : 'owner', display_name: null, avatar_url: null }])) };
++ if (options.path.startsWith('/api/v1/people/')) {
++ const id = options.path.split('/')[4]; const keys = people.get(id);
++ if (!keys) throw new Error('Unknown person');
++ const pub = await locker.exportPersonKeys(keys);
++ return { subject: id, ecdh_public: pub.ecdh, signing_public: pub.signing, fingerprint: pub.fingerprint };
++ }
++ if (options.path.includes('/identity/resolve')) {
++ const subject = options.query.subject_id || (options.query.username === 'friend' ? OTHER : SUBJECT);
++ return { subject: { type: 'user', id: subject }, username: subject === OTHER ? 'friend' : 'owner', display_name: null, avatar_url: null };
++ }
++ return {};
++ },
++ };
++ const media = {
++ async upload(file, bytes, _owner, record) {
++ const id = `med_${String(++seq).padStart(26, '0')}`; await record(id);
++ const contents = await fs.readFile(file); if (contents.length !== bytes) throw new Error('wrong size');
++ blobs.set(id, contents); return id;
++ },
++ async read(id) { if (!blobs.has(id)) throw new Error('missing'); return new Response(blobs.get(id)); },
++ async delete(id) { deleted.push(id); blobs.delete(id); },
++ };
++ const sso = {
++ router(express) { const r = express.Router(); r.get('/me', (_req, res) => res.json({ user: null })); return r; },
++ optionalAuth: () => (req, _res, next) => {
++ const id = String(req.headers.authorization || '').replace(/^Bearer /, '');
++ if ([SUBJECT, OTHER, STAFF].includes(id)) { req.user = { subject_id: id, role: id === STAFF ? 'global_mod' : 'user' }; req.token = id; }
++ next();
++ },
++ };
++ const ctx = await createApp({ ...overrides, config, db, sso, media, network, now: () => clock });
++ const server = ctx.app.listen(0, '127.0.0.1'); await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}`; config.baseUrl = base;
++ async function request(method, url, { user = SUBJECT, json, body, headers = {} } = {}) {
++ const r = await fetch(base + url, { method, headers: { ...(user ? { Authorization: `Bearer ${user}` } : {}),
++ ...(json === undefined ? body ? { 'Content-Type': 'application/octet-stream' } : {} : { 'Content-Type': 'application/json' }), ...headers },
++ body: json === undefined ? body : JSON.stringify(json) });
++ return r;
++ }
++ async function envelope(value = 'private bytes', { lock = 'people', owner = SUBJECT, keys = people.get(owner) } = {}) {
++ return locker.seal(value, lock === 'people' ? { lock, keys: [{ subject: { type: 'user', id: owner }, publicKey: keys.ecdhPublic }] } : { lock });
++ }
++ return { ...ctx, media, network, config, base, server, blobs, deleted, principals, people, request, envelope,
++ advance(ms) { clock += ms; }, async close() { await new Promise((r) => server.close(r)); await db.close(); await fs.rm(dir, { recursive: true, force: true }); } };
++}
++module.exports = { boot, SUBJECT, OTHER, STAFF, locker };
ddiff --git a/test/locker.test.js b/test/locker.test.js
nnew file mode 100644
iindex 0000000..de1686b
---- /dev/null
++++ b/test/locker.test.js
@@@ -0,0 +1,131 @@
++'use strict';
++const { test, after, before } = require('node:test');
++const assert = require('node:assert/strict');
++const { boot, SUBJECT, OTHER, STAFF, locker } = require('./helpers');
++const payload = require('../public/js/payload');
++let t;
++before(async () => { t = await boot(); }); after(async () => t?.close());
++const create = async (sealed, send = false, extra = '') => {
++ const r = await t.request('POST', send ? `/api/v1/sends?expires_in=3600${extra}` : `/api/v1/files${extra}`, { body: sealed.bytes });
++ const body = await r.json(); assert.equal(r.status, 201, JSON.stringify(body)); return body;
++};
++test('WebCrypto stream → ciphertext API → download → authenticated opening, with private metadata', async () => {
++ const file = new Blob([Buffer.alloc(2 * 1048576 + 93, 71)]);
++ const meta = { name: 'private-name.pdf', type: 'application/pdf', size: file.size };
++ const plain = payload.pack(meta, file.stream());
++ const encrypted = await new Response(locker.sealStream(plain, { lock: 'people', keys: [{ subject: { type: 'user', id: SUBJECT }, publicKey: t.people.get(SUBJECT).ecdhPublic }], size: payload.prefix(meta).length + file.size })).arrayBuffer();
++ const item = await create({ bytes: encrypted });
++ const row = await t.db.one('SELECT * FROM locker_items WHERE id=$1', [item.id]);
++ assert.ok(!JSON.stringify(row).includes(meta.name)); assert.ok(!JSON.stringify(row).includes(meta.type));
++ assert.ok(!t.blobs.get(row.media_id).includes(Buffer.from(meta.name)));
++ const r = await t.request('GET', `/api/v1/files/${item.id}/content`); assert.equal(r.status, 200);
++ const opened = await payload.unpack(locker.openStream(r.body, { person: t.people.get(SUBJECT) }));
++ assert.deepEqual(opened.meta, { version: 1, ...meta });
++ assert.deepEqual(Buffer.from(await new Response(opened.stream).arrayBuffer()), Buffer.from(await file.arrayBuffer()));
++ assert.equal((await t.request('GET', `/api/v1/files/${item.id}/content`, { user: OTHER })).status, 404);
++ assert.equal((await t.request('GET', `/api/v1/files/${item.id}/content`, { user: null })).status, 404);
++});
++test('plaintext, JSON, malformed, truncated and trailing-byte envelopes are rejected without storage', async () => {
++ const good = await t.envelope(); const original = t.blobs.size;
++ for (const bytes of [Buffer.from('plaintext is never a file'), good.bytes.slice(0, -1), Buffer.concat([good.bytes, Buffer.from([0])])]) {
++ assert.equal((await t.request('POST', '/api/v1/files', { body: bytes })).status, 400);
++ }
++ assert.equal((await t.request('POST', '/api/v1/files', { json: { name: 'plaintext', content: 'hello' } })).status, 415);
++ assert.equal((await t.request('POST', '/api/v1/files?name=private', { body: good.bytes })).status, 400);
++ assert.equal(t.blobs.size, original);
++ assert.equal(await t.db.value('SELECT COUNT(*)::int FROM locker_uploads'), 0);
++});
++test('good standing uses current Network age and bans, local restrictions and optional allowlist', async () => {
++ const env = await t.envelope();
++ t.principals.set(SUBJECT, { trust: { account_age_days: 6 } });
++ assert.equal((await t.request('POST', '/api/v1/files', { body: env.bytes })).status, 403);
++ t.principals.set(SUBJECT, { banned: true });
++ assert.equal((await t.request('POST', '/api/v1/files', { body: env.bytes })).status, 403);
++ t.principals.delete(SUBJECT);
++ await t.request('PUT', `/api/v1/staff/people/${SUBJECT}/restriction`, { user: STAFF, json: { restricted: true } });
++ assert.equal((await t.request('POST', '/api/v1/files', { body: env.bytes })).status, 403);
++ await t.request('PUT', `/api/v1/staff/people/${SUBJECT}/restriction`, { user: STAFF, json: { restricted: false } });
++ t.config.allowlistOnly = true;
++ assert.equal((await t.request('POST', '/api/v1/files', { body: env.bytes })).status, 403);
++ t.config.allowlist.add(SUBJECT); await create(env); t.config.allowlistOnly = false;
++});
++test('quota reservations stop competing uploads and are released after failure', async () => {
++ const env = await t.envelope(); const oldQuota = t.config.quotaBytes;
++ const used = await t.db.value('SELECT used_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT]);
++ t.config.quotaBytes = used + env.bytes.length;
++ const results = await Promise.all([t.request('POST', '/api/v1/files', { body: env.bytes }), t.request('POST', '/api/v1/files', { body: env.bytes })]);
++ assert.deepEqual(results.map((r) => r.status).sort(), [201, 413]);
++ assert.equal(await t.db.value('SELECT reserved_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT]), 0);
++ t.config.quotaBytes = oldQuota;
++});
++test('anonymous Sends enforce expiry bounds, atomic download limits, and due deletion', async () => {
++ const env = await t.envelope('a private Send', { lock: 'link' });
++ assert.equal((await t.request('POST', '/api/v1/sends?expires_in=3599', { body: env.bytes })).status, 400);
++ assert.equal((await t.request('POST', '/api/v1/sends?expires_in=2592001', { body: env.bytes })).status, 400);
++ const item = await create(env, true, '&download_limit=1');
++ const r = await Promise.all([t.request('GET', `/api/v1/sends/${item.id}/content`, { user: null }), t.request('GET', `/api/v1/sends/${item.id}/content`, { user: null })]);
++ assert.deepEqual(r.map((x) => x.status).sort(), [200, 410]);
++ const good = r.find((x) => x.status === 200);
++ assert.equal(new TextDecoder().decode(await locker.open(new Uint8Array(await good.arrayBuffer()), { key: env.linkKey })), 'a private Send');
++ const row = await t.db.one('SELECT * FROM locker_items WHERE id=$1', [item.id]);
++ assert.ok(!JSON.stringify(row).includes(env.linkKey));
++ t.advance(3600001);
++ assert.equal((await t.request('GET', `/api/v1/sends/${item.id}/content`, { user: null })).status, 410);
++ await t.service.sweep(); assert.ok(t.deleted.includes(row.media_id)); assert.ok(!t.blobs.has(row.media_id));
++});
++test('reports work without a Send account, only Network staff can disable and restrict', async () => {
++ const item = await create(await t.envelope('report me', { lock: 'link' }), true);
++ const headers = { Origin: t.base, 'X-Requested-With': 'locker' };
++ assert.equal((await t.request('POST', `/api/v1/sends/${item.id}/report`, { user: null, json: { reason: 'abuse' }, headers })).status, 202);
++ assert.equal((await t.request('POST', `/api/v1/staff/items/${item.id}/disable`)).status, 403);
++ assert.equal((await t.request('POST', `/api/v1/staff/items/${item.id}/disable`, { user: STAFF })).status, 200);
++ assert.equal((await t.request('GET', `/api/v1/sends/${item.id}/content`, { user: null })).status, 404);
++ assert.equal((await t.request('GET', '/api/v1/staff/reports', { user: STAFF })).status, 200);
++});
++test('folders, ciphertext rename with revision and deletion maintain quota', async () => {
++ const folder = await create(await t.envelope('folder'), false, '?kind=folder');
++ const item = await create(await t.envelope('first name'), false, `?parent=${folder.id}`);
++ const next = await t.envelope('renamed privately');
++ const r = await t.request('PUT', `/api/v1/files/${item.id}`, { body: next.bytes, headers: { 'If-Match': '"1"' } }); assert.equal(r.status, 200, await r.text());
++ assert.equal((await t.request('PUT', `/api/v1/files/${item.id}`, { body: next.bytes, headers: { 'If-Match': '"1"' } })).status, 412);
++ assert.equal((await t.request('DELETE', `/api/v1/files/${folder.id}`)).status, 204);
++ assert.equal(await t.db.value('SELECT parent FROM locker_items WHERE id=$1', [item.id]), null);
++});
++test('CSRF rejects foreign writes, rate limits Send creation', async () => {
++ assert.equal((await t.request('POST', '/api/v1/sends', { user: null, headers: { Origin: 'https://elsewhere.invalid' } })).status, 403);
++ const previous = t.config.sendsPerHour; t.config.sendsPerHour = 1;
++ t.advance(3600001); const env = await t.envelope('limited', { lock: 'link' });
++ await create(env, true);
++ assert.equal((await t.request('POST', '/api/v1/sends?expires_in=3600', { body: env.bytes })).status, 429);
++ t.config.sendsPerHour = previous;
++});
++test('same-origin key proxies use the authenticated user, without third-party cookies', async () => {
++ const keys = await locker.exportPersonKeys(t.people.get(SUBJECT));
++ const backup = await locker.seal('wrapped private key export', { lock: 'password', password: 'a local test passphrase' });
++ const body = { ecdh_public: keys.ecdh, signing_public: keys.signing, fingerprint: keys.fingerprint,
++ self_signature: keys.fingerprint_signature, backups: { passphrase: { format: 'locker.envelope.v1', envelope: Buffer.from(backup.bytes).toString('base64') } } };
++ assert.equal((await t.request('PUT', '/api/v1/me/keys', { json: body })).status, 200);
++ const own = await (await t.request('GET', '/api/v1/me/keys/backup')).json();
++ assert.equal(own.subject, SUBJECT); assert.deepEqual(own.backups, body.backups);
++ assert.deepEqual((await (await t.request('GET', '/api/v1/me/keys/backup', { user: OTHER })).json()).backups, {});
++ assert.equal((await t.request('GET', '/api/v1/me/keys/backup', { user: null })).status, 401);
++ assert.deepEqual((await (await t.request('GET', `/api/v1/people/${OTHER}/keys`)).json()).ecdh_public, (await locker.exportPersonKeys(t.people.get(OTHER))).ecdh);
++});
++test('ADR-033 export contains opaque items; deletion denies access, queues bytes and is idempotent', async () => {
++ const item = await create(await t.envelope('account deletion'));
++ const before = await t.accountData.exportPart(SUBJECT);
++ assert.ok(before.files.find((x) => x.name === 'items.json').content.some((x) => x.id === item.id));
++ assert.ok(!JSON.stringify(before).includes('account deletion'));
++ const pending = await t.service.reserve({ subject_id: SUBJECT }, 100, 'file');
++ const fs = require('node:fs/promises');
++ await fs.mkdir(require('node:path').dirname(pending.file), { recursive: true });
++ await fs.writeFile(pending.file, 'ciphertext awaiting transfer');
++ const gone = await t.accountData.erase([SUBJECT]); assert.ok(gone.erased.items > 0);
++ assert.equal((await t.request('GET', `/api/v1/files/${item.id}/content`)).status, 404);
++ await t.accountData.erase([SUBJECT]); await t.service.sweep();
++ assert.equal(await t.db.value('SELECT used_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT]), 0);
++ await assert.rejects(fs.access(pending.file));
++ assert.equal(await t.db.value('SELECT reserved_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT]), 0);
++ assert.equal(await t.db.value('SELECT count(*) FROM locker_uploads WHERE owner=$1', [SUBJECT]), 0);
++ assert.equal((await t.request('POST', '/api/v1/files', { body: (await t.envelope()).bytes })).status, 403);
++});
ddiff --git a/test/media.test.js b/test/media.test.js
nnew file mode 100644
iindex 0000000..49dd587
---- /dev/null
++++ b/test/media.test.js
@@@ -0,0 +1,54 @@
++'use strict';
++const test = require('node:test');
++const assert = require('node:assert/strict');
++const fs = require('node:fs/promises');
++const os = require('node:os');
++const path = require('node:path');
++const { createMedia } = require('../server/media');
++test('Media adapter streams multipart ciphertext, keeps signed URLs private and requests retryable erasure', async () => {
++ const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'locker-media-client-'));
++ const file = path.join(dir, 'ciphertext');
++ const ciphertext = Buffer.alloc(2 * 1048576 + 29, 123); await fs.writeFile(file, ciphertext);
++ const id = 'med_01JAB2C3D4E5F6G7H8J9K0MNPQ';
++ const parts = [], calls = []; let recorded, tokenRequests = 0, retry = true;
++ const json = (body, status = 200) => Response.json(body, { status });
++ const fakeFetch = async (url, options) => {
++ const u = new URL(url); calls.push([options?.method || 'GET', u.pathname, u.search]);
++ if (u.pathname === '/oauth/token') { tokenRequests++; return json({ access_token: 'test-only', expires_in: 300 }); }
++ if (u.pathname === '/signed-ciphertext') {
++ assert.equal(options.redirect, 'follow'); assert.equal(options.headers, undefined);
++ return new Response(ciphertext);
++ }
++ assert.equal(options.headers.Authorization, 'Bearer test-only');
++ if (retry) { retry = false; return json({}, 401); }
++ if (u.pathname.endsWith('/objects')) {
++ assert.deepEqual(JSON.parse(options.body), { kind: 'file', namespace: 'locker', visibility: 'private', mime_type: 'application/octet-stream',
++ size_bytes: ciphertext.length, filename: 'locked.ovlk', multipart: true, part_size: 8388608 });
++ assert.equal(options.headers['X-OV-Subject'], 'usr_test');
++ return json({ id, upload: { multipart: { upload_id: 'multipart-test', part_size: 1048576 } } });
++ }
++ if (u.pathname.includes('/parts/')) { assert.equal(recorded, id); parts.push(Buffer.from(options.body)); return json({}); }
++ if (u.pathname.endsWith('/complete')) { assert.deepEqual(Buffer.concat(parts), ciphertext); return json({}); }
++ if (u.pathname.endsWith('/download')) { assert.equal(u.search, '?format=json'); return json({ url: 'https://media.test/signed-ciphertext' }); }
++ assert.equal(options.method, 'DELETE'); assert.equal(u.search, '?purge=1'); return json({});
++ };
++ try {
++ const media = createMedia({ networkInternalUrl: 'https://network.test', mediaUrl: 'https://media.test', clientId: 'locker', clientSecret: '' }, fakeFetch);
++ assert.equal(await media.upload(file, ciphertext.length, 'usr_test', async (value) => { recorded = value; }), id);
++ assert.equal(tokenRequests, 2, 'a rejected token is refreshed once');
++ assert.equal(parts.length, 3); assert.ok(calls.some((x) => x[1].endsWith('/multipart/multipart-test/parts/3')));
++ assert.deepEqual(Buffer.from(await (await media.read(id)).arrayBuffer()), ciphertext);
++ await media.delete(id);
++ } finally { await fs.rm(dir, { recursive: true, force: true }); }
++});
++
++test('Network key proxy preserves reauthentication errors without fetching service credentials', async () => {
++ const { createNetwork } = require('../server/network');
++ const network = createNetwork({ networkInternalUrl: 'https://network.test', clientId: 'locker', clientSecret: '' }, async (url, options) => {
++ assert.equal(new URL(url).pathname, '/api/v1/me/keys');
++ assert.equal(options.headers.Authorization, 'Bearer test-session');
++ return Response.json({ error: 'locker.reauth_required', message: 'Sign in again.' }, { status: 403 });
++ });
++ await assert.rejects(network.json({ method: 'PUT', path: '/api/v1/me/keys', json: {}, token: 'test-session', preserveProblem: true }),
++ (e) => e.status === 403 && e.code === 'locker.reauth_required');
++});
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..0687b20
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,32 @@
++'use strict';
++const { test } = require('node:test');
++const assert = require('node:assert/strict');
++const { boot, SUBJECT, OTHER, locker } = require('./helpers');
++test('a large staged reseal changes ciphertext and people access together; removal rotates the key', async () => {
++ const t = await boot({ config: { quotaBytes: 60 * 1024 ** 2 } });
++ try {
++ const original = await t.envelope(new Uint8Array(9 * 1024 ** 2));
++ const created = await t.request('POST', '/api/v1/files', { body: original.bytes }); assert.equal(created.status, 201);
++ const item = await created.json(); const path = `/api/v1/files/${item.id}`;
++ const shared = await locker.rewrap(original.bytes, { credentials: { person: t.people.get(SUBJECT) }, add: [{ subject: { type: 'user', id: OTHER }, publicKey: t.people.get(OTHER).ecdhPublic }] });
++ t.config.quotaBytes = original.bytes.length + shared.bytes.length;
++ let stage = await t.request('POST', `${path}/reseals`, { body: shared.bytes, headers: { 'If-Match': '"1"' } }); assert.equal(stage.status, 201, await stage.clone().text());
++ const receipt = await stage.json();
++ assert.equal((await t.request('POST', `${path}/reseals`, { body: shared.bytes, headers: { 'If-Match': '"1"' } })).status, 413, 'uncommitted replacements reserve their full stored size');
++ assert.equal(await t.db.value('SELECT reserved_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT]), shared.bytes.length);
++ assert.equal((await t.request('GET', `${path}/content`, { user: OTHER })).status, 404);
++ const members = [{ subject: { type: 'user', id: OTHER }, role: 'viewer', key_slot: shared.header.slots.people[1] }];
++ let save = await t.request('PUT', `${path}/share`, { json: { members, envelope_ref: receipt.envelope_ref } }); assert.equal(save.status, 200, await save.clone().text());
++ let fetched = await t.request('GET', `${path}/content`, { user: OTHER }); assert.equal(fetched.status, 200);
++ assert.equal((await locker.open(new Uint8Array(await fetched.arrayBuffer()), { person: t.people.get(OTHER) })).length, 9 * 1024 ** 2);
++ assert.equal((await t.request('PUT', `${path}/share`, { json: { members, envelope_ref: receipt.envelope_ref } })).status, 409, 'a receipt is consumed once');
++ const fresh = await locker.rewrap(shared.bytes, { credentials: { person: t.people.get(SUBJECT) }, remove: [{ type: 'user', id: OTHER }], keys: [{ subject: { type: 'user', id: SUBJECT }, publicKey: t.people.get(SUBJECT).ecdhPublic }] });
++ stage = await t.request('POST', `${path}/reseals`, { body: fresh.bytes, headers: { 'If-Match': '"2"' } }); assert.equal(stage.status, 201);
++ save = await t.request('PUT', `${path}/share`, { json: { members: [], envelope_ref: (await stage.json()).envelope_ref } }); assert.equal(save.status, 200, await save.clone().text());
++ assert.equal((await t.request('GET', `${path}/content`, { user: OTHER })).status, 404);
++ fetched = await t.request('GET', `${path}/content`);
++ await assert.rejects(() => locker.open(new Uint8Array(fresh.bytes), { person: t.people.get(OTHER) }), (e) => e.code === 'locker.no_slot');
++ await fetched.arrayBuffer(); await t.service.sweep();
++ assert.equal(t.blobs.size, 1, 'replaced encrypted objects are collected');
++ } finally { await t.close(); }
++});
ddiff --git a/test/uploads.test.js b/test/uploads.test.js
nnew file mode 100644
iindex 0000000..1b92dc0
---- /dev/null
++++ b/test/uploads.test.js
@@@ -0,0 +1,116 @@
++'use strict';
++const { test, before, after } = require('node:test');
++const assert = require('node:assert/strict');
++const fs = require('node:fs/promises');
++const path = require('node:path');
++const http = require('node:http');
++const { boot, SUBJECT, OTHER, locker } = require('./helpers');
++const { PART_SIZE, SINGLE_LIMIT } = require('../server/uploads');
++let t;
++before(async () => { t = await boot({ config: { quotaBytes: 512 * 1024 ** 2, uploadsPerHour: 200 } }); });
++after(async () => { await t.close(); });
++async function start(bytes, options = {}, headers = {}) {
++ const r = await t.request('POST', '/api/v1/uploads', { json: { kind: 'file', bytes, ...options }, headers });
++ assert.equal(r.status, 201, await r.clone().text()); return r.json();
++}
++async function put(id, n, bytes) { return t.request('PUT', `/api/v1/uploads/${id}/parts/${n}`, { body: bytes }); }
++async function complete(id, headers = {}) { return t.request('POST', `/api/v1/uploads/${id}/complete`, { headers }); }
++async function small(options = {}, headers = {}) {
++ const { bytes } = await t.envelope(); const u = await start(bytes.length, options, headers);
++ assert.equal((await put(u.id, 0, bytes)).status, 200); return u;
++}
++test('three 32 MiB parts resume from durable receipts and complete exactly once', async () => {
++ assert.equal(PART_SIZE, 33554432);
++ const plain = new Uint8Array(2 * PART_SIZE + 7).fill(93);
++ const { bytes } = await t.envelope(plain);
++ const u = await start(bytes.length); assert.equal(u.part_size, PART_SIZE);
++ assert.equal(Math.ceil(bytes.length / PART_SIZE), 3);
++ assert.equal((await put(u.id, 0, bytes.subarray(0, PART_SIZE))).status, 200);
++ assert.equal((await complete(u.id)).status, 409, 'missing parts cannot complete');
++ const state = await (await t.request('GET', `/api/v1/uploads/${u.id}`)).json();
++ assert.deepEqual(state.received_parts, [0]);
++ assert.equal((await t.request('GET', `/api/v1/uploads/${u.id}`, { user: OTHER })).status, 404);
++ assert.equal((await t.request('PUT', `/api/v1/uploads/${u.id}/parts/1`, { user: OTHER, body: bytes.subarray(PART_SIZE, 2 * PART_SIZE) })).status, 404);
++ assert.equal((await t.request('POST', `/api/v1/uploads/${u.id}/complete`, { user: OTHER })).status, 404);
++ assert.equal((await put(u.id, 0, bytes.subarray(0, PART_SIZE))).status, 200, 'lost part response can retry');
++ assert.equal((await put(u.id, 2, bytes.subarray(2 * PART_SIZE))).status, 200, 'out of order parts');
++ assert.equal((await put(u.id, 1, bytes.subarray(PART_SIZE, 2 * PART_SIZE))).status, 200);
++ const response = await complete(u.id); assert.equal(response.status, 200, await response.clone().text());
++ const item = await response.json();
++ assert.deepEqual(await (await complete(u.id)).json(), item, 'lost completion response returns original item');
++ assert.equal((await (await t.request('GET', `/api/v1/uploads/${u.id}`)).json()).state, 'complete');
++ const downloaded = new Uint8Array(await (await t.request('GET', `/api/v1/files/${item.id}/content`)).arrayBuffer());
++ assert.deepEqual(await locker.open(downloaded, { person: t.people.get(SUBJECT) }), plain);
++ const a = await t.db.one('SELECT * FROM locker_accounts WHERE subject=$1', [SUBJECT]);
++ assert.equal(a.reserved_bytes, 0); assert.equal(a.used_bytes, bytes.length);
++ await t.request('DELETE', `/api/v1/files/${item.id}`); await t.service.sweep();
++});
++test('counts streamed parts, refuses oversized, short and out-of-range parts without receipts', async () => {
++ const u = await start(PART_SIZE + 9);
++ assert.equal((await put(u.id, 0, new Uint8Array(PART_SIZE + 1))).status, 413);
++ assert.equal((await put(u.id, 0, new Uint8Array(9))).status, 400);
++ assert.equal((await put(u.id, 2, new Uint8Array(9))).status, 400);
++ assert.equal((await put(u.id, 1, new Uint8Array(9))).status, 200);
++ // Even an immutable retry with no Content-Length must enforce the final part bound.
++ const status = await new Promise((resolve, reject) => {
++ const req = http.request(`${t.base}/api/v1/uploads/${u.id}/parts/1`, { method: 'PUT', headers: {
++ Authorization: `Bearer ${SUBJECT}`, 'Content-Type': 'application/octet-stream', 'Transfer-Encoding': 'chunked',
++ } }, (res) => { res.resume(); res.on('end', () => resolve(res.statusCode)); });
++ req.on('error', reject); req.write(Buffer.alloc(10)); req.end();
++ });
++ assert.equal(status, 413);
++ assert.deepEqual((await (await t.request('GET', `/api/v1/uploads/${u.id}`)).json()).received_parts, [1]);
++ assert.equal((await complete(u.id)).status, 409);
++});
++test('abandoned sessions release quota and remove staging files; progress renews the deadline', async () => {
++ const u = await small();
++ const file = path.join(t.config.dataDir, 'uploads', `${u.id}.ovlk`);
++ await fs.access(file);
++ t.advance(3600001); await t.service.sweep();
++ assert.equal((await t.db.one('SELECT reserved_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT])).reserved_bytes, 0);
++ await assert.rejects(fs.access(file), { code: 'ENOENT' });
++ assert.equal((await t.request('GET', `/api/v1/uploads/${u.id}`)).status, 404);
++ const next = await start(PART_SIZE + 8);
++ t.advance(3500000); assert.equal((await put(next.id, 1, new Uint8Array(8))).status, 200);
++ t.advance(100001); await t.service.sweep();
++ assert.equal((await t.request('GET', `/api/v1/uploads/${next.id}`)).status, 200);
++ t.advance(3600001); await t.service.sweep();
++});
++test('completion enforces envelope, recipient and standing checks, and reserves quota at initiation', async () => {
++ const bad = await start(8); await put(bad.id, 0, Buffer.from('not-lock'));
++ assert.equal((await complete(bad.id)).status, 400);
++ const wrong = await t.envelope('private', { owner: OTHER });
++ const u = await start(wrong.bytes.length); await put(u.id, 0, wrong.bytes);
++ assert.equal((await complete(u.id)).status, 400);
++ const waiting = await small(); t.principals.set(SUBJECT, { banned: true });
++ assert.equal((await complete(waiting.id)).status, 403);
++ assert.equal((await t.request('POST', '/api/v1/uploads', { json: { kind: 'file', bytes: 8 } })).status, 403);
++ t.principals.delete(SUBJECT);
++ const oldQuota = t.config.quotaBytes; t.config.quotaBytes = 8;
++ assert.equal((await t.request('POST', '/api/v1/uploads', { json: { kind: 'file', bytes: 8 } })).status, 413);
++ t.config.quotaBytes = oldQuota;
++ t.advance(3600001); await t.service.sweep();
++});
++test('resumable replacement and reseal bind the item revision and keep receipt semantics', async () => {
++ const original = await small(); const item = await (await complete(original.id)).json();
++ const headers = { 'If-Match': '"1"' };
++ const replacement = await small({ kind: 'replace', item_id: item.id }, headers);
++ assert.equal((await complete(replacement.id)).status, 412);
++ const replaced = await (await complete(replacement.id, headers)).json(); assert.equal(replaced.revision, 2);
++ const reseal = await small({ kind: 'reseal', item_id: item.id }, { 'If-Match': '"2"' });
++ const staged = await (await complete(reseal.id, { 'If-Match': '"2"' })).json();
++ assert.equal(staged.envelope_ref, reseal.id); assert.equal(staged.header.lock, 'people');
++ assert.deepEqual(await (await complete(reseal.id, { 'If-Match': '"2"' })).json(), staged);
++ const result = await t.request('PUT', `/api/v1/files/${item.id}/share`, { json: { envelope_ref: reseal.id, members: [] } });
++ assert.equal(result.status, 200, await result.clone().text());
++ assert.equal((await t.db.one('SELECT reserved_bytes FROM locker_accounts WHERE subject=$1', [SUBJECT])).reserved_bytes, 0);
++});
++test('single-request uploads reject envelopes above 90 MiB before reading the body', async () => {
++ const status = await new Promise((resolve, reject) => {
++ const req = http.request(`${t.base}/api/v1/files`, { method: 'POST', headers: {
++ Authorization: `Bearer ${SUBJECT}`, 'Content-Type': 'application/octet-stream', 'Content-Length': SINGLE_LIMIT + 1,
++ } }, (res) => { res.resume(); res.on('end', () => { resolve(res.statusCode); req.destroy(); }); });
++ req.on('error', reject); req.flushHeaders();
++ });
++ assert.equal(status, 413);
++});