openvibers/openvibe.locker Commits
Build Locker v1 encrypted Drive and expiring Sends (#1)
Changes
nnew file mode 100644@@@ -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@e80c595ff3d4bc46b7e0f41fdb040b985885cb30nnew file mode 100644@@@ -0,0 +1,5 @@++node_modules/++data/++artifacts/++.env++*.local.js@@@ -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.nnew file mode 100644@@@ -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"++}nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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; }++}nnew file mode 100644@@@ -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.targetnnew file mode 100644@@@ -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++);nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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"++ }++ }++ }++}nnew file mode 100644@@@ -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"++ }++}nnew file mode 100644@@@ -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"++}nnew file mode 100644@@@ -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}nnew file mode 100644@@@ -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>nnew file mode 100644@@@ -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);++ }++})();nnew file mode 100644@@@ -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);nnew file mode 100644@@@ -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 });++ }++};nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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."++ }++ }++ }++ }++ }++}nnew file mode 100644@@@ -0,0 +1,7 @@++User-agent: *++Disallow: /api/++Disallow: /s/++Disallow: /files/++Disallow: /drive++Disallow: /sends++Disallow: /staffnnew file mode 100644@@@ -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.');nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; });nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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; });nnew file mode 100644@@@ -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 };nnew file mode 100644@@@ -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);++});nnew file mode 100644@@@ -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');++});nnew file mode 100644@@@ -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(); }++});nnew file mode 100644@@@ -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);++});
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+node_modules/
+data/
+artifacts/
+.env
+*.local.js
@@ -1,2 +1,102 @@
@@ -1,2 +1,102 @@
# OpenVibe.Locker
# 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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,42 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,94 @@
@@ -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; }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,66 @@
@@ -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
+);
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,1575 @@
@@ -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"
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,8 @@
@@ -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"
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,10 @@
@@ -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}
new file mode 100644
new file mode 100644
@@ -0,0 +1 @@
@@ -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>
new file mode 100644
new file mode 100644
@@ -0,0 +1,275 @@
@@ -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);
+ }
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,84 @@
@@ -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 });
+ }
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,28 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,2452 @@
@@ -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."
+ }
+ }
+ }
+ }
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+User-agent: *
+Disallow: /api/
+Disallow: /s/
+Disallow: /files/
+Disallow: /drive
+Disallow: /sends
+Disallow: /staff
new file mode 100644
new file mode 100644
@@ -0,0 +1,13 @@
@@ -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.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,40 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,190 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,4 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,16 @@
@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,30 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,57 @@
@@ -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) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,259 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,119 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,122 @@
@@ -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; });
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -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 };
new file mode 100644
new file mode 100644
@@ -0,0 +1,131 @@
@@ -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);
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -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');
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,32 @@
@@ -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(); }
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -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);
+});