openvibers/openvibe.media Commits

Media: VODs and clips can be shared with specific people (sharing step 5) (#332)

OP OpenVibers committed 6 days ago · 35df7db3f3 · 21 files, +1022 −36

* Media: VODs and clips can be shared with specific people (sharing step 5)

The share.v1 row next to every VOD and clip, backed from today's public/unlisted/private column and kept in step with it. The watch page, its bytes, the thumbnail and the API's audience answer ask canView, so a recording shared with named people opens for them and looks missing to everyone else; a people-only recording stays out of the index, the sitemap, llms.txt, the search feed and the page's structured data. The uniform /api/v1/vod|clip/:id/share router, the Share button and OVShare.gate, account export/erasure of the rows, and the audience/members on the visibility event Live's Rundown follows.

* Review: the share gate is markup, and a people-only recording never enters a shared cache

Bump openvibe-shared to v4.123.0 and drop the inline OVShare.gate script: the gate is now [data-ov-share-gate] markup started by ov-share.js, so no CSP nonce is needed and attribute escaping does the work. A Headless-Chrome test loads the real gate and checks Sign in / Ask for access with no CSP refusal. Every serving path for a Specific-people recording now answers private, no-store (local bytes, B2/R2 redirect and download, virtual clip redirect, thumbnails, watch page); the unsigned answer is an empty 404. Tests cover local, remote redirect, virtual clip, thumbnail, object and signed playlist.

* Merge remote-tracking branch 'origin/main' into feat/sharing-people-media

# Conflicts:
#	README.md

Changes

ddiff --git a/README.md b/README.md
iindex 689cf07..a1002d7 100644
---- a/README.md
++++ b/README.md
@@@ -46,7 +46,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- `openvibe-contracts` v0.302.0, `openvibe-sdk` v0.91.0 (tokens, events outbox, usage readings, per-actor limits),
-- `openvibe-shared` v4.122.0, pinned by release tarball
++ `openvibe-shared` v4.123.0, pinned by release tarball
## Capabilities
@@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
storage.move.cleanup (the alert for placement moves whose delete keeps failing, service-wide)
client-ip.js trust proxy = loopback; req.ip is the only client address
--(openvibe-shared v4.122.0, openvibe-contracts v0.302.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)
++(openvibe-shared v4.123.0, openvibe-contracts v0.302.0, openvibe-sdk v0.91.0: pinned release tarballs, installed by npm)
scripts/smoke-test.sh end-to-end smoke test (boots a temp instance)
scripts/reconcile-objects.js / object-invariant.js / no-good-copy-report.js object-model operator tools
scripts/media-jobs.js list jobs, run the size-invariant scan (dry run by default), approve/cancel proposals
@@@ -780,5 +780,5 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
- openvibe-contracts: v0.302.0
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.122.0
++- openvibe-shared: v4.123.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 8e7365b..9608914 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -8,7 +8,7 @@
"domain": "openvibe.media",
"contracts": "openvibe-contracts v0.302.0",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.122.0",
++ "shared": "openvibe-shared v4.123.0",
"capabilities": [
"media.object.upload",
"media.object.read",
ddiff --git a/migrations/0022_sharing.sql b/migrations/0022_sharing.sql
nnew file mode 100644
iindex 0000000..aa37fb2
---- /dev/null
++++ b/migrations/0022_sharing.sql
@@@ -0,0 +1,70 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 5): the tables openvibe-sdk/share v0.91.0's
++-- sharesSchema() creates, kept verbatim so Media's database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
ddiff --git a/migrations/0023_sharing_backfill.sql b/migrations/0023_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..189b715
---- /dev/null
++++ b/migrations/0023_sharing_backfill.sql
@@@ -0,0 +1,39 @@
++-- phase: migrate
++-- Every VOD and clip that existed before sharing is registered once, so canView has a row for all of
++-- them and the share sheet opens on an old item too.
++--
++-- visibility public → public (Everyone)
++-- visibility unlisted → link
++-- visibility private → private (Only me)
++--
++-- A legacy row with no visibility falls back to is_public. The owner is the Network subject Media's
++-- object projection carries (media_objects.owner_subject); an imported or app-owned row with none
++-- belongs to the service, which nobody can sign in as. `people` is never in the column: it is a
++-- share.v1 audience only. Deleted objects are not registered.
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'media', 'vod', v.id::text,
++ CASE WHEN o.owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
++ THEN jsonb_build_object('type', 'user', 'id', o.owner_subject)
++ ELSE jsonb_build_object('type', 'service', 'id', 'media') END,
++ CASE COALESCE(v.visibility, CASE WHEN v.is_public = 1 THEN 'public' ELSE 'private' END)
++ WHEN 'public' THEN 'public'
++ WHEN 'unlisted' THEN 'link'
++ ELSE 'private' END
++FROM vods v
++LEFT JOIN media_objects o ON o.legacy_ref = 'legacy:' || v.app_id || ':vod:' || v.id::text
++WHERE COALESCE(v.visibility, CASE WHEN v.is_public = 1 THEN 'public' ELSE 'private' END) IN ('public', 'unlisted', 'private')
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'media', 'clip', c.id::text,
++ CASE WHEN o.owner_subject ~ '^usr_[0-9A-HJKMNP-TV-Z]{26}$'
++ THEN jsonb_build_object('type', 'user', 'id', o.owner_subject)
++ ELSE jsonb_build_object('type', 'service', 'id', 'media') END,
++ CASE COALESCE(c.visibility, CASE WHEN c.is_public = 1 THEN 'public' ELSE 'private' END)
++ WHEN 'public' THEN 'public'
++ WHEN 'unlisted' THEN 'link'
++ ELSE 'private' END
++FROM clips c
++LEFT JOIN media_objects o ON o.legacy_ref = 'legacy:' || c.app_id || ':clip:' || c.id::text
++WHERE COALESCE(c.visibility, CASE WHEN c.is_public = 1 THEN 'public' ELSE 'private' END) IN ('public', 'unlisted', 'private')
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex d4bf5ec..148882e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -18,7 +18,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"sharp": "^0.33.4"
},
@@@ -2166,9 +2166,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.122.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
++ "version": "4.123.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 92fbcb6..66f9e99 100644
---- a/package.json
++++ b/package.json
@@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0",
"sharp": "^0.33.4"
},
ddiff --git a/server/account-data.js b/server/account-data.js
iindex d43c9fe..1450c54 100644
---- a/server/account-data.js
++++ b/server/account-data.js
@@@ -46,6 +46,14 @@ async function exportPart(db, subjects) {
if (vods.length) files.push({ name: 'vods.json', content: vods });
if (clips.length) files.push({ name: 'clips.json', content: clips });
if (plain.length) files.push({ name: 'files.json', content: plain });
++ // The share.v1 rows the person owns or is on (step 5): the recording itself is above; this is who it was
++ // shared with. `people` is a list of subject ids, never a name.
++ const owned = await db.prepare(`SELECT service, kind, resource_id, audience, lock, revision FROM shares WHERE owner_subject->>'id' IN ${inList(subjects)} ORDER BY resource_id DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
++ if (owned.length) files.push({ name: 'shares.json', content: owned });
++ const memberships = await db.prepare(`SELECT s.service, s.kind, s.resource_id, s.audience, m.role, m.added_at, m.expires_at
++ FROM share_members m JOIN shares s ON s.service=m.service AND s.kind=m.kind AND s.resource_id=m.resource_id
++ WHERE m.subject->>'id' IN ${inList(subjects)} ORDER BY m.added_at DESC LIMIT ${ROW_LIMIT}`).all(...subjects);
++ if (memberships.length) files.push({ name: 'shared_with_me.json', content: memberships });
return { files, truncated: objs.length >= ROW_LIMIT ? ['objects.json'] : [], note: 'Download each file from its public_url; private ones from openvibe.media while signed in.' };
}
@@@ -99,6 +107,19 @@ async function erase(db, subjects) {
throw e;
}
}
++ // The recording's share.v1 row goes with the recording: ownership, memberships, requests and transfers.
++ // share_log is the append-only audit trail and stays (ids only, no titles or content).
++ for (const subject of subjects) {
++ for (const sql of [
++ 'DELETE FROM share_members WHERE subject->>\'id\' = ?',
++ 'DELETE FROM share_access_requests WHERE subject->>\'id\' = ?',
++ 'DELETE FROM share_transfers WHERE from_subject->>\'id\' = ? OR to_subject->>\'id\' = ?',
++ 'DELETE FROM shares WHERE owner_subject->>\'id\' = ?',
++ ]) {
++ const params = sql.includes('to_subject') ? [subject, subject] : [subject];
++ try { await db.run(sql, params); } catch { /* sharing tables absent in an old restore copy */ }
++ }
++ }
try { require('./events').kick(); } catch { /* the relay polls anyway */ }
return { erased, retained };
}
ddiff --git a/server/events.js b/server/events.js
iindex e052820..5c78449 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -194,6 +194,25 @@ function checkUsage(payload) {
return c.valid ? { valid: true, errors: '' } : { valid: false, errors: c.errors.map((e) => `${e.path || '/'} ${e.message}`).join('; ') };
}
++/**
++ * Queue a share.* envelope from openvibe-sdk/share (server/sharing.js) as it is built: the SDK has
++ * already allow-listed its payload (ids, audiences and roles, never a title or a description), and the
++ * outbox relays it like every other media event. Off with the outbox.
++ */
++async function publish(envelope) {
++ if (!outbox) return null;
++ const env = await outbox.enqueue(inTx(), {
++ event_type: envelope.event_type,
++ actor: envelope.actor,
++ subject: envelope.subject,
++ visibility: 'internal',
++ priority: 'low',
++ payload: envelope.payload,
++ });
++ stats.queued++;
++ return env;
++}
++
/**
* Queue a Search document or tombstone (media.index_document.*, ./public/search-documents.js) as is:
* no tenant field, low priority. Same rule as record(): inside the transaction that records the push.
@@@ -253,7 +272,9 @@ function objectChangeEvent(change, obj) {
const base = { object_id: obj.id, app_id: obj.app_id, kind: obj.kind, legacy_ref: obj.legacy_ref || null };
if (change.change === 'deleted') return ['media.object.deleted', { ...base, deleted_at: isoTime(change.changed_at) }];
return ['media.object.visibility_changed', {
-- ...base, visibility: change.visibility, previous_visibility: change.previous_visibility, changed_at: isoTime(change.changed_at),
++ ...base, ...(change.audience ? { audience: change.audience } : {}),
++ ...(change.members ? { members: change.members } : {}),
++ visibility: change.visibility, previous_visibility: change.previous_visibility, changed_at: isoTime(change.changed_at),
}];
}
@@@ -276,6 +297,21 @@ async function recordObjectChanges({ limit = 1000 } = {}) {
for (const c of rows) {
const obj = await raw.prepare('SELECT id, app_id, kind, legacy_ref FROM media_objects WHERE id = ?').get(c.object_id);
if (obj) {
++ // The share row is the audience authority (openvibe-sdk/share): an item whose column moved to
++ // private for `people` still tells consumers which people, so Live's Rundown follows it. The
++ // payload carries subject ids only, never a title or a member's name.
++ const m = /^legacy:[^:]+:(vod|clip):(\d+)$/.exec(String(obj.legacy_ref || ''));
++ if (m) {
++ const share = await raw.prepare('SELECT audience FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('media', m[1], m[2]);
++ if (share && share.audience !== c.visibility) {
++ c.audience = share.audience;
++ if (share.audience === 'people') {
++ const list = await raw.prepare(`SELECT subject->>'id' AS id FROM share_members
++ WHERE service = ? AND kind = ? AND resource_id = ? AND (expires_at IS NULL OR expires_at > NOW())`).all('media', m[1], m[2]);
++ c.members = list.map((r) => r.id).filter(Boolean);
++ }
++ }
++ }
const [type, payload] = objectChangeEvent(c, obj);
if (await enqueue(type, obj.app_id, { type: 'object', id: obj.id }, payload, 'important')) n++;
}
@@@ -313,4 +349,4 @@ function _reset() {
stats.queued = 0; stats.lastError = null;
}
--module.exports = { init, initWriter, record, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordUsage, checkUsage, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };
++module.exports = { init, initWriter, record, publish, recordPlacement, recordIndexDocument, recordModeration, recordJob, recordUsage, checkUsage, recordObjectChanges, drainObjectChanges, discardObjectChanges, objectChangeEvent, kick, status, TYPES, PLACEMENT_TYPES, JOB_TRANSITIONS, _reset };
ddiff --git a/server/index.js b/server/index.js
iindex 3400922..cdd81f5 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -266,6 +266,36 @@ const ready = (async () => {
// ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".
app.use('/api/v1/resources', require('./registry/resource-control').router({ guard: require('./service-guard').guard('media.resource.control') }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ guard: require('./service-guard').guard('media.resource.read') }));
++
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ // GET/PUT /api/v1/vod/:id/share (and clip), .../share/requests, .../share/transfer[/accept|decline|cancel],
++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. Authentication is Media's own signed-in
++ // viewer (ov_token or Bearer user JWT); an app key is never a share owner here. The router answers only
++ // its own paths, so the rest of /api/v1 keeps its own 404 and the tenant routes below are untouched.
++ {
++ const sharingLib = require('./sharing');
++ const sharing = sharingLib.createSharing({ db: db.getDb(), config });
++ app.locals.sharing = sharing;
++ const routed = Object.create(sharing.shares);
++ routed.getWithRevision = async (viewer, ref) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.getWithRevision(viewer, ref); };
++ routed.update = async (viewer, ref, body, opts) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.update(viewer, ref, body, opts); };
++ routed.requestAccess = async (viewer, ref) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.requestAccess(viewer, ref); };
++ const shareApi = express.Router();
++ shareApi.use(sharingLib.attachViewer);
++ shareApi.use(sharing.shares.router({
++ service: 'media',
++ authenticate: (req) => (req.shareClaims ? sharingLib.shareViewer(req.shareClaims) : null),
++ describe: sharingLib.describeRef(db.getDb()),
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|(?:vod|clip)\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ if (!drill.enabled) {
++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
++ }
++
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/recordings', require('./vod/recordings').router);
ddiff --git a/server/public/pages.js b/server/public/pages.js
iindex 770452a..b41c5fc 100644
---- a/server/public/pages.js
++++ b/server/public/pages.js
@@@ -16,6 +16,7 @@ const path = require('path');
const config = require('../config');
const pageFrame = require('./page-frame');
const showcase = require('openvibe-shared/showcase');
++const ovServe = require('openvibe-shared/serve');
const { esc, abs, snip, appUrl, watchPageUrl, isoDate, isoDuration, fmtDuration, fmtDate, SITE_NAME, NETWORK_URL } = pageFrame;
@@@ -213,6 +214,8 @@ function renderWatchPage(kind, record, readiness = null) {
${ownedByLive ? `<a class="btn" href="${esc(canonical)}">${isVod ? 'Watch on OpenVibe.Live' : 'Watch on OpenVibe.Live'} — chat, transcript &amp; more</a>` : ''}
${playable ? `<a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</a>` : ''}
${isVod ? `<a class="btn ghost" href="${esc(`${self}/transcript.json`)}">Transcript (JSON)</a>` : ''}
++ <button type="button" class="btn ghost" data-ov-share-sheet data-kind="${isVod ? 'vod' : 'clip'}" data-id="${id}"
++ data-title="${esc(title)}" data-url="${self}" data-text="${esc(description)}"${thumb ? ` data-image="${esc(thumb)}"` : ''}>Share</button>
</div>
${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
${record.ai_overview && record.ai_overview !== record.description ? `<section><h2 class="h2">Overview</h2><p class="desc">${esc(snip(record.ai_overview, 3000))}</p></section>` : ''}
@@@ -252,4 +255,30 @@ function renderWatchPage(kind, record, readiness = null) {
});
}
--module.exports = { wantsHtmlPage, renderWatchPage, sourcePage, canonicalPageUrl, watchIndexable, isAiClip };
++/**
++ * The page a person who is not on a Specific-people recording's list sees: the audience, never the
++ * title, the description, the pictures or the player. OVShare.gate offers "Ask for access" to a
++ * signed-in person and "Sign in" to everybody else.
++ */
++function renderShareGate(kind, id, { signedInAs = null, signInUrl = '/auth/login' } = {}) {
++ const isVod = kind === 'vod';
++ const body = `<section class="message">
++<h1>A ${isVod ? 'recording' : 'clip'} on OpenVibe.Media</h1>
++<p>This is shared with specific people.</p>
++<div data-ov-share-gate data-kind="${isVod ? 'vod' : 'clip'}" data-id="${esc(id)}"${signedInAs ? ` data-signed-in-as="${esc(signedInAs)}"` : ''} data-sign-in-url="${esc(signInUrl)}">This ${isVod ? 'recording' : 'clip'} is shared with specific people on OpenVibe.Media.</div>
++</section>
++<script src="${ovServe.url('ov-share.js')}" defer></script>`;
++ return pageFrame.page({
++ seo: {
++ title: `A ${isVod ? 'recording' : 'clip'} on OpenVibe.Media`,
++ description: `A ${isVod ? 'recording' : 'clip'} shared with specific people on OpenVibe.Media.`,
++ canonical: `${config.publicUrl}/${isVod ? 'v' : 'c'}/${encodeURIComponent(id)}`,
++ robots: 'noindex, nofollow',
++ },
++ body,
++ css: `.message { max-width: 640px; margin: 3rem auto; padding: 0 1rem; }`,
++ footer: { variant: 'compact' },
++ });
++}
++
++module.exports = { wantsHtmlPage, renderWatchPage, renderShareGate, sourcePage, canonicalPageUrl, watchIndexable, isAiClip };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex 501b5f5..369750c 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -32,6 +32,7 @@ const db = require('../db/database');
const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const pages = require('./pages');
++const sharingLib = require('../sharing');
const sourceVod = require('./source-vod');
const notFoundPage = require('./not-found-page');
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
@@@ -170,7 +171,7 @@ function downloadDisposition(req, kind, record, ext) {
* the clip is closed) into a fragmented MP4 piped to the reader, so it starts at once and no file is kept. The headers
* go out with the first bytes: a remux that fails before them is a 502 the caller can show.
*/
--function sendVirtualClipFile(req, res, record, closed, disposition) {
++function sendVirtualClipFile(req, res, record, closed, disposition, membersOnly = false) {
if (_remuxing >= REMUX_MAX) {
res.set('Retry-After', '5');
return res.status(503).json({ error: 'Other downloads are being prepared: try again in a few seconds' });
@@@ -194,7 +195,7 @@ function sendVirtualClipFile(req, res, record, closed, disposition) {
ff.stdout.on('data', (chunk) => {
if (!started) {
started = true;
-- res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Disposition': disposition, 'Cache-Control': 'private, max-age=0',
++ res.writeHead(200, { 'Content-Type': 'video/mp4', 'Content-Disposition': disposition, 'Cache-Control': closed || membersOnly ? 'private, no-store' : 'private, max-age=0',
'X-Robots-Tag': 'noindex', 'X-Content-Type-Options': 'nosniff' });
}
if (!res.write(chunk)) { ff.stdout.pause(); res.once('drain', () => ff.stdout.resume()); }
@@@ -220,9 +221,38 @@ async function serveMediaRecord(kind, record, req, res) {
const noindex = { 'X-Robots-Tag': 'noindex' };
const visibility = recordVisibility(record);
-- // Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
-- // recordings exist (and, through the basename form of /v, which file names are real).
-- if (visibility === 'private' && !canAccessPrivate(record, req, kind)) return notFound(req, res);
++ const sharing = req.app && req.app.locals ? req.app.locals.sharing : null;
++ // The share row is the audience authority for an item that has one; a legacy row keeps the column.
++ let audience = visibility === 'private' ? 'private' : visibility === 'unlisted' ? 'link' : 'public';
++ let onList = false;
++ if (sharing) {
++ try {
++ const share = await sharing.shareOf(kind, record.id);
++ if (share && ['public', 'link', 'people', 'private'].includes(share.audience)) audience = share.audience;
++ if (audience === 'people' || audience === 'private') onList = await sharing.canView(kind, record.id, req.shareClaims);
++ } catch (err) { console.warn('[Public] share audience:', err.message); }
++ }
++ // A member of a Specific-people item reads it like its owner; everybody else gets exactly the answer
++ // a missing id gets — on the page as the gate, on the bytes as a 404 (never a 403 that names it).
++ if (audience === 'people' && !onList && !canAccessPrivate(record, req, kind)) {
++ if (pages.wantsHtmlPage(req)) {
++ const claims = req.shareClaims || null;
++ res.set('Cache-Control', 'no-store');
++ return res.status(404).type('html').send(pages.renderShareGate(kind, record.id, {
++ signedInAs: claims && claims.username ? String(claims.username) : null,
++ signInUrl: `/auth/login?next=${encodeURIComponent(req.originalUrl || req.url || '')}`,
++ }));
++ }
++ // A member could cache these bytes in a browser or a shared cache; the unsigned answer is an
++ // empty 404, so nothing about the recording travels with it.
++ res.set('Cache-Control', 'private, no-store');
++ return res.status(404).end();
++ }
++ if (audience === 'private' && !onList && !canAccessPrivate(record, req, kind)) {
++ // Exactly the answer a missing id gets: a 403 here told anyone probing ids which private
++ // recordings exist (and, through the basename form of /v, which file names are real).
++ return notFound(req, res);
++ }
// A virtual clip (F3.5) has no file: its bytes are its HLS playlist over the source's timeline. Every reader allowed
// here (public, the owner, or a 'getc' URL from GET /api/clips/:id/signed-url) is sent to the master playlist,
@@@ -240,7 +270,7 @@ async function serveMediaRecord(kind, record, req, res) {
if (!closed && pages.wantsHtmlPage(req)) {
const canonical = pages.canonicalPageUrl(kind, record);
if (canonical) {
-- res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
++ res.set({ 'Cache-Control': audience === 'people' ? 'private, no-store' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
return res.redirect(302, canonical);
}
}
@@@ -248,8 +278,8 @@ async function serveMediaRecord(kind, record, req, res) {
if (!config.hls.enabled || !record.object_id || record.status !== 'ready') return res.status(404).json({ error: 'Media file unavailable' });
await trackUniqueView(kind, record.id, req, record.user_id);
const asFile = downloadDisposition(req, kind, record, '.mp4');
-- if (asFile) return sendVirtualClipFile(req, res, record, closed, asFile);
-- res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
++ if (asFile) return sendVirtualClipFile(req, res, record, closed, asFile, audience === 'people');
++ res.set({ 'Cache-Control': audience === 'people' ? 'private, no-store' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
return res.redirect(302, closed ? require('../objects/signing').signedPlaylistUrl(record.object_id).url : `${config.publicUrl}/o/${record.object_id}/master.m3u8`);
}
@@@ -261,7 +291,7 @@ async function serveMediaRecord(kind, record, req, res) {
if (pages.wantsHtmlPage(req)) {
// A watch page names media that can become private at any moment: no stale serving, or a cache
// could still hand out its title and description afterwards.
-- res.set('Cache-Control', cache.htmlHeaders(visibility === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));
++ res.set('Cache-Control', cache.htmlHeaders(audience === 'public' ? { maxAge: 60, swr: 0 } : { private: true }));
return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));
}
if (drill.refuseBytes(res)) return;
@@@ -299,7 +329,7 @@ async function serveMediaRecord(kind, record, req, res) {
const asFile = key && downloadDisposition(req, kind, record, path.extname(key) || path.extname(record.file_path || ''));
if (asFile) {
const url = await vodStorage.presignGet(plan.provider, key, 900, { contentDisposition: asFile }).catch(() => null);
-- if (url) { res.set({ 'Cache-Control': 'private, max-age=0', 'X-Robots-Tag': 'noindex' }); return res.redirect(302, url); }
++ if (url) { res.set({ 'Cache-Control': audience === 'people' ? 'private, no-store' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' }); return res.redirect(302, url); }
}
if (plan?.kind === 'redirect') {
// The origin shield (placement/shield.js): a public copy on a shielded provider is served by nginx through
@@@ -308,13 +338,13 @@ async function serveMediaRecord(kind, record, req, res) {
const shield = require('../placement/shield');
const shieldable = shield.enabled(plan.provider) && plan.provider !== shieldFailed && visibility !== 'private' && !record.is_recording;
const edge = shieldable && shield.toEdge(req, plan.provider);
-- if (edge) { res.set('Cache-Control', 'private, max-age=0'); res.set('X-Robots-Tag', 'noindex'); return res.redirect(302, edge); }
++ if (edge) { res.set('Cache-Control', audience === 'people' ? 'private, no-store' : 'private, max-age=0'); res.set('X-Robots-Tag', 'noindex'); return res.redirect(302, edge); }
if (shieldable && shield.onEdge(req)) {
const url = await vodStorage.presignGet(plan.provider, vodStorage.keyForVod(record), shield.SHIELD_TTL_SECONDS).catch(() => null);
const t = url && shield.target({ provider: plan.provider, url, visibility, edge: shield.edgeOf(req) });
-- if (t) return shield.send(res, t, { 'Cache-Control': visibility === 'public' ? 'public, max-age=300' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
++ if (t) return shield.send(res, t, { 'Cache-Control': audience === 'people' ? 'private, no-store' : visibility === 'public' ? 'public, max-age=300' : 'private, max-age=0', 'X-Robots-Tag': 'noindex' });
}
-- res.set('Cache-Control', 'private, max-age=0');
++ res.set('Cache-Control', audience === 'people' ? 'private, no-store' : 'private, max-age=0');
res.set('X-Robots-Tag', 'noindex');
return res.redirect(302, plan.url);
}
@@@ -340,13 +370,13 @@ async function serveMediaRecord(kind, record, req, res) {
// analyser that draws an audio-only item's level ring: createMediaElementSource needs an untainted CORS load).
const corsHeaders = visibility === 'private' ? {} : { 'Access-Control-Allow-Origin': '*', 'Cross-Origin-Resource-Policy': 'cross-origin' };
-- streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex, ...corsHeaders, ...(asFile ? { 'Content-Disposition': asFile } : {}) });
++ streamFileWithRange(req, res, filePath, { ...cacheHeaders, ...noindex, ...(audience === 'people' ? { 'Cache-Control': 'private, no-store' } : {}), ...corsHeaders, ...(asFile ? { 'Content-Disposition': asFile } : {}) });
}
// ── VOD playback ─────────────────────────────────────────────
// By numeric id only: a file name is not a route (the basename fallback for old /api/vods/file/<name> URLs
// was retired with compatibility register C-75 once Live asked by id).
--router.get('/v/:id', optionalIdentity, async (req, res) => {
++router.get('/v/:id', optionalIdentity, sharingLib.attachViewer, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const vod = await db.getVodById(parseInt(req.params.id, 10));
@@@ -422,7 +452,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
}));
// ── Clip playback ────────────────────────────────────────────
--router.get('/c/:id', optionalIdentity, async (req, res) => {
++router.get('/c/:id', optionalIdentity, sharingLib.attachViewer, async (req, res) => {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const wantsPage = pages.wantsHtmlPage(req);
@@@ -447,13 +477,30 @@ router.get('/og-image.png', (req, res) => {
});
// ── Thumbnails ───────────────────────────────────────────────
--router.get('/t/:id', (req, res) => {
++router.get('/t/:id', sharingLib.attachViewer, async (req, res) => {
if (drill.refuseBytes(res)) return;
// Open to every origin like the other public images: Live's player samples a stream thumbnail's colours on a
// canvas (an anonymous CORS load), which failed with a console error on every offline channel page (2026-10-23).
// Whoever has the URL can already load the image; this lets a page read its pixels.
res.set('Access-Control-Allow-Origin', '*');
res.set('Cross-Origin-Resource-Policy', 'cross-origin');
++ // A Specific-people recording's picture is part of it: only the owner and the people on its list may fetch
++ // it, by the same check the watch page and its bytes use. A private thumbnail keeps its old unguessable-URL
++ // behaviour (changing that is not this step).
++ try {
++ const m = /^(vod|clip)-(\d+)-/i.exec(String(req.params.id || ''));
++ const sharing = req.app && req.app.locals ? req.app.locals.sharing : null;
++ if (m && sharing) {
++ const audience = await sharing.audienceOf(m[1].toLowerCase(), Number(m[2]));
++ if (audience === 'people') {
++ if (!(await sharing.canView(m[1].toLowerCase(), Number(m[2]), req.shareClaims))) {
++ res.set('Cache-Control', 'private, no-store');
++ return res.status(404).end();
++ }
++ return require('../thumbnails/thumbnail-service').serveThumbnail(req, res, { noStore: true });
++ }
++ }
++ } catch { /* the thumbnail service decides */ }
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
});
ddiff --git a/server/public/search-documents.js b/server/public/search-documents.js
iindex a4ac519..87cb1ff 100644
---- a/server/public/search-documents.js
++++ b/server/public/search-documents.js
@@@ -89,6 +89,21 @@ async function sync({ now = Date.now() } = {}) {
return n;
}
++/**
++ * Bring one page in line at once (a share audience change): send its new document while it is still
++ * an indexable Everyone page, otherwise a tombstone. No-op while the outbox is off.
++ */
++async function syncOne(kind, id, { now = Date.now() } = {}) {
++ if (!(await events.status()).enabled) return 'off';
++ if (!['vod', 'clip'].includes(kind)) return 'ignored';
++ const table = kind === 'vod' ? 'vods' : 'clips';
++ const cols = `id, app_id, title, description, duration_seconds, visibility, is_public, ${kind === 'clip' ? 'auto_generated, ' : ''}created_at`;
++ const row = await db.maybe(`SELECT ${cols} FROM ${table} WHERE id = ? AND ${crawl.WATCH_WHERE[kind]}`, [Number(id)]);
++ const sandbox = await crawl.sandboxApps();
++ const qualifies = !!row && !sandbox.has(row.app_id) && pages.watchIndexable(kind, row);
++ return await send(kind, id, qualifies ? documentFor(kind, row) : null, now);
++}
++
async function start() {
if (timer || !(await events.status()).enabled || process.env.MEDIA_SEARCH_DOCUMENTS === 'off') return false;
const run = async () => { try { await sync(); } catch (err) { stats.lastError = err.message; } };
@@@ -102,4 +117,4 @@ async function start() {
function stop() { if (timer) clearInterval(timer); timer = null; }
function status() { return { ...stats }; }
--module.exports = { start, stop, sync, documentFor, status };
++module.exports = { start, stop, sync, syncOne, documentFor, status };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..528ddbc
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,311 @@
++'use strict';
++/**
++ * Sharing for Media's VODs and clips (design 2026-11-06, VISIBILITY-EVERYWHERE step 5; openvibe-sdk/share).
++ *
++ * The SDK owns the model and the rules: one `shares` row per VOD or clip, members, access requests,
++ * transfers, the append-only log and the delivery queue. This file is the wiring Media supplies:
++ *
++ * - network.json Network's identity lookups (@name → subject, a person's card for the sheet)
++ * - events.publish Media's Events outbox, so share.* envelopes go out with every other media event
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps vods/clips.visibility and is_public in step: public→public, link→unlisted,
++ * people→private, private→private. The column fails closed for every old read path,
++ * and the object projection and Search follow it.
++ *
++ * Media's column stays the fail-closed mirror. `people` is never stored there: it is a share.v1 audience,
++ * and the watch page/bytes ask canView for it. A row that has no shares row yet (a legacy item, or one
++ * created before the sheet first opened it) keeps its old column behaviour through `audienceOf`, and
++ * `ensure()` registers it the first time its owner opens the sheet.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++const contracts = require('openvibe-contracts');
++const dbModule = require('./db/database');
++
++const SERVICE = 'media';
++const KINDS = ['vod', 'clip'];
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** Media's visibility words to the SDK's, and back. `people` has no column of its own. */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
++const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++
++const refOf = (kind, id) => ({ service: SERVICE, kind, id: String(id) });
++
++/** Media's viewer as the SDK wants it: a canonical subject object. Never an app-local user id. */
++function shareViewer(claims) {
++ if (!claims) return null;
++ const id = typeof claims.subject_id === 'string' ? claims.subject_id : null;
++ if (!id || !/^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/.test(id)) return null;
++ return { subject: { type: id.startsWith('gst_') ? 'guest' : 'user', id }, username: claims.username || null,
++ staff: contracts.staff.can(claims, 'staff.moderation.chat') || contracts.staff.effectiveRole(claims) === 'owner' };
++}
++
++/** The owner of a row, as a canonical subject: the object's owner, else the service itself. */
++function ownerOf(kind, row, object) {
++ const subject = object && object.owner_subject;
++ if (subject && /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return { type: 'user', id: subject };
++ return { type: 'service', id: SERVICE };
++}
++
++function tableOf(kind) { return kind === 'vod' ? 'vods' : 'clips'; }
++function columnOf(kind) { return kind === 'vod' ? 'vods' : 'clips'; }
++
++function createSharing({ db = dbModule.getDb(), config = require('./config'), events = require('./events'),
++ fetchImpl = globalThis.fetch, log = console, now = Date.now, networkJson: networkOverride = null } = {}) {
++ const base = String(config.network.internalUrl || config.network.url || '').replace(/\/+$/, '');
++ const tokens = !networkOverride && process.env.OV_OAUTH_CLIENT_SECRET
++ ? serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: process.env.OV_OAUTH_CLIENT_ID || 'media',
++ clientSecret: process.env.OV_OAUTH_CLIENT_SECRET,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (networkOverride) return await networkOverride({ path, method, json, query });
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(`${base}${path}`);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification, addressed by canonical subject. It never carries a title or a description. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A recording was shared with you', 'Open it on OpenVibe.Media.'],
++ 'share.access_requested': ['Someone asked for access to your recording', 'Review the request on OpenVibe.Media.'],
++ 'share.transfer.requested': ['Someone wants to give you a recording', 'Accept or decline it on OpenVibe.Media.'],
++ 'share.transfer.answered': ['A recording transfer was answered', 'See what happened on OpenVibe.Media.'],
++ }[n.kind] || ['Sharing on OpenVibe.Media', 'There is something new for you.'];
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: SERVICE, url: n.ref && n.ref.id ? `${config.publicUrl}/${n.ref.kind === 'clip' ? 'c' : 'v'}/${encodeURIComponent(n.ref.id)}` : config.publicUrl,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ /** The row's audience change keeps the old column in step, then re-projects the object and Search. */
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || !KINDS.includes(ref.kind)) return;
++ const table = tableOf(ref.kind);
++ const row = await tx.maybe(`SELECT id, app_id, visibility FROM ${table} WHERE id = $1`, [Number(ref.id)]);
++ if (!row) return;
++ const visibility = FROM_SDK[audience] || 'private';
++ if (row.visibility !== visibility) {
++ await tx.exec(`UPDATE ${table} SET visibility = $1, is_public = $2 WHERE id = $3`, [visibility, visibility === 'public' ? 1 : 0, row.id]);
++ }
++ const changed = row.visibility !== visibility;
++ tx.afterCommit(async () => {
++ if (changed) {
++ const model = require('./objects/model');
++ try {
++ // The object's visibility is the fail-closed mirror; re-project it and turn the staged
++ // change into media.object.visibility_changed (with the audience and members) in one
++ // transaction, so Live's Rundown hears about a `people` change as it happens.
++ await db.tx(async () => {
++ await (ref.kind === 'clip' ? model.syncClip(row.id) : model.syncVod(row.id));
++ await events.recordObjectChanges();
++ });
++ events.kick();
++ } catch (err) { log.warn('[Sharing] object sync:', err.message); }
++ }
++ try { await require('./public/search-documents').syncOne(ref.kind, row.id); } catch (err) { log.warn('[Sharing] search sync:', err.message); }
++ });
++ }
++
++ /** Ownership moved: the object carries the canonical owner (the row's app-local ids never change). */
++ async function onOwnerChange(ref, from, to, tx) {
++ if (ref.service !== SERVICE || !KINDS.includes(ref.kind)) return;
++ if (!to || to.type !== 'user') return;
++ const row = await tx.maybe(`SELECT app_id FROM ${tableOf(ref.kind)} WHERE id = $1`, [Number(ref.id)]);
++ if (!row) return;
++ await tx.exec('UPDATE media_objects SET owner_subject = $1, updated_at = ov_now() WHERE legacy_ref = $2',
++ [to.id, `legacy:${row.app_id}:${ref.kind}:${ref.id}`]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish: async (envelope) => await events.publish(envelope) },
++ notify,
++ onAudienceChange,
++ onOwnerChange,
++ });
++
++ const rowOf = async (kind, id) => await db.maybe(`SELECT * FROM ${tableOf(kind)} WHERE id = $1`, [Number(id)]);
++
++ /** The share row, or null. */
++ async function shareOf(kind, id) {
++ return await db.maybe(`SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3`, [SERVICE, kind, String(id)]);
++ }
++
++ /** Media's audience for a row: the share row when it exists, else the column's 1:1 mapping. */
++ async function audienceOf(kind, id) {
++ const share = await shareOf(kind, id);
++ if (share && AUDIENCES.includes(share.audience)) return share.audience;
++ const row = await rowOf(kind, id);
++ if (!row) return 'private';
++ const visibility = row.visibility || (row.is_public ? 'public' : 'private');
++ return TO_SDK[visibility] || 'private';
++ }
++
++ /** The audience of every named row in one query. */
++ async function audiencesOf(kind, ids) {
++ const list = [...new Set(ids.map(Number).filter(Number.isSafeInteger))];
++ if (!list.length) return new Map();
++ const rows = await db.many(`SELECT resource_id, audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = ANY($3::text[])`,
++ [SERVICE, kind, list.map(String)]);
++ const found = new Map(rows.map((r) => [Number(r.resource_id), r.audience]));
++ const missing = list.filter((id) => !found.has(id));
++ if (missing.length) {
++ const table = tableOf(kind);
++ const cols = await db.many(`SELECT id, visibility, is_public FROM ${table} WHERE id = ANY($1::bigint[])`, [missing]);
++ for (const r of cols) found.set(Number(r.id), TO_SDK[r.visibility || (r.is_public ? 'public' : 'private')] || 'private');
++ }
++ return found;
++ }
++
++ /** Register a row that has no share row yet (a legacy item, or one created before its owner opened the sheet). */
++ async function ensure(kind, id) {
++ if (!KINDS.includes(kind)) return null;
++ const existing = await shareOf(kind, id);
++ if (existing) return existing;
++ const row = await rowOf(kind, id);
++ if (!row) return null;
++ const object = await db.maybe('SELECT owner_subject FROM media_objects WHERE legacy_ref = $1', [`legacy:${row.app_id}:${kind}:${id}`]);
++ const visibility = row.visibility || (row.is_public ? 'public' : 'private');
++ try {
++ await shares.register(refOf(kind, id), { owner_subject: ownerOf(kind, row, object), audience: TO_SDK[visibility] || 'private' });
++ } catch (err) {
++ if (err.status !== 409) throw err; // somebody registered it first: use theirs
++ }
++ return await shareOf(kind, id);
++ }
++
++ /** The owner's save from a create route: a new VOD/clip starts at its column's audience. */
++ async function register(kind, id, visibility = 'private') {
++ const row = await rowOf(kind, id);
++ if (!row) return null;
++ const object = await db.maybe('SELECT owner_subject FROM media_objects WHERE legacy_ref = $1', [`legacy:${row.app_id}:${kind}:${id}`]);
++ try {
++ return await shares.register(refOf(kind, id), { owner_subject: ownerOf(kind, row, object), audience: TO_SDK[visibility] || 'private' });
++ } catch (err) {
++ if (err.status === 409) return await shareOf(kind, id);
++ throw err;
++ }
++ }
++
++ /**
++ * The write routes' visibility change keeps the share row in step (the sheet and the select never
++ * disagree). A `people` row moved by the owning app becomes that app's new column audience.
++ */
++ async function syncAudience(kind, id, visibility) {
++ const next = TO_SDK[visibility] || 'private';
++ await db.tx(async (t) => {
++ const row = await t.maybe(`SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3 FOR UPDATE`, [SERVICE, kind, String(id)]);
++ if (!row || row.audience === next) return;
++ await t.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4', [next, SERVICE, kind, String(id)]);
++ });
++ }
++
++ return {
++ shares,
++ SERVICE,
++ KINDS,
++ AUDIENCES,
++ TO_SDK,
++ FROM_SDK,
++ refOf,
++ shareViewer,
++ shareOf,
++ audienceOf,
++ audiencesOf,
++ ensure,
++ register,
++ syncAudience,
++ canView: async (kind, id, claims) => await shares.canView(shareViewer(claims), refOf(kind, id)),
++ canEdit: async (kind, id, claims) => await shares.canEdit(shareViewer(claims), refOf(kind, id)),
++ get: async (kind, id, claims) => { await ensure(kind, id); return await shares.get(shareViewer(claims), refOf(kind, id)); },
++ getWithRevision: async (kind, id, claims) => { await ensure(kind, id); return await shares.getWithRevision(shareViewer(claims), refOf(kind, id)); },
++ update: async (kind, id, claims, body, opts) => { await ensure(kind, id); return await shares.update(shareViewer(claims), refOf(kind, id), body, opts); },
++ addMembers: async (kind, id, claims, list) => { await ensure(kind, id); return await shares.addMembers(shareViewer(claims), refOf(kind, id), list); },
++ removeMember: async (kind, id, claims, subject) => { await ensure(kind, id); return await shares.removeMember(shareViewer(claims), refOf(kind, id), subject); },
++ requestAccess: async (kind, id, claims) => { await ensure(kind, id); return await shares.requestAccess(shareViewer(claims), refOf(kind, id)); },
++ dismiss: async (kind, id, claims, requestId) => await shares.dismiss(shareViewer(claims), refOf(kind, id), requestId),
++ transfer: async (kind, id, claims, input) => await shares.transfer(shareViewer(claims), refOf(kind, id), input),
++ accept: async (kind, id, claims, transferId) => await shares.accept(shareViewer(claims), refOf(kind, id), transferId),
++ decline: async (kind, id, claims, transferId) => await shares.decline(shareViewer(claims), refOf(kind, id), transferId),
++ cancel: async (kind, id, claims, transferId) => await shares.cancel(shareViewer(claims), refOf(kind, id), transferId),
++ lookup: async (claims, name) => await shares.lookup(shareViewer(claims), name),
++ sharedWithMe: async (claims, opts) => await shares.sharedWithMe(shareViewer(claims), { service: SERVICE, ...(opts || {}) }),
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++/** The signed-in viewer's verified claims, or null. Never an app key, never an app-local user id. */
++async function viewerClaims(req) {
++ let token = null;
++ try { token = require('./user-auth').extractToken(req); } catch { token = null; }
++ if (!token) return null;
++ try {
++ const { verifyUserToken } = require('openvibe-sdk/auth');
++ const auth = require('./auth');
++ const claims = await verifyUserToken(String(token), { ...auth.networkKeys().verifyOptions, issuer: require('./config').network.url });
++ if (!claims || require('./revocations').isRevoked(claims)) return null;
++ return claims;
++ } catch { return null; }
++}
++
++/** Express middleware: attach the verified viewer claims for the public watch page and the share API. */
++async function attachViewer(req, _res, next) {
++ req.shareClaims = await viewerClaims(req);
++ next();
++}
++
++/** The uniform API's describe(ref): the sheet shows the VOD/clip's title and watch page to its owner. */
++function describeRef(db) {
++ return async (ref) => {
++ if (ref.service !== SERVICE || !KINDS.includes(ref.kind)) return {};
++ const table = tableOf(ref.kind);
++ const row = await db.maybe(`SELECT id, app_id, title, visibility, is_public FROM ${table} WHERE id = $1`, [Number(ref.id)]);
++ if (!row) return {};
++ const audience = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, ref.kind, String(ref.id)]);
++ const mapped = audience ? audience.audience : (TO_SDK[row.visibility || (row.is_public ? 'public' : 'private')] || 'private');
++ return {
++ title: row.title || (ref.kind === 'vod' ? `VOD #${row.id}` : `Clip #${row.id}`),
++ link: `${String(require('./config').publicUrl || '').replace(/\/+$/, '')}/${ref.kind === 'clip' ? 'c' : 'v'}/${row.id}`,
++ roles: ['viewer'],
++ audiences: AUDIENCES,
++ transferable: true,
++ audience: mapped,
++ };
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, viewerClaims, attachViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KINDS };
ddiff --git a/server/thumbnails/thumbnail-service.js b/server/thumbnails/thumbnail-service.js
iindex af11db0..ec61900 100644
---- a/server/thumbnails/thumbnail-service.js
++++ b/server/thumbnails/thumbnail-service.js
@@@ -385,7 +385,7 @@ async function thumbnailItemGone(filename) {
return !exists;
}
--async function serveThumbnail(req, res) {
++async function serveThumbnail(req, res, { noStore = false } = {}) {
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
try {
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
@@@ -418,14 +418,16 @@ async function serveThumbnail(req, res) {
// (a regenerated thumb gets a NEW name), so those bytes never change —
// mark them immutable so browsers stop revalidating on every render.
const isLive = filename.startsWith('stream-');
-- const cacheControl = isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';
++ // A Specific-people recording's picture is part of the recording: its member's request must never
++ // enter a shared cache under a public header.
++ const cacheControl = noStore ? 'private, no-store' : isLive ? 'public, max-age=30' : 'public, max-age=604800, immutable';
if (spec) {
let v = null;
try { v = await variants().get(filePath, spec); } catch { v = null; }
if (v) return sendFile(res, v.file, v.size, v.type, cacheControl);
// Busy or unmakeable: the original, cached briefly so the variant is made on a later request.
-- return sendFile(res, filePath, stat.size, contentType, 'public, max-age=60');
++ return sendFile(res, filePath, stat.size, contentType, noStore ? 'private, no-store' : 'public, max-age=60');
}
sendFile(res, filePath, stat.size, contentType, cacheControl);
} catch (err) {
ddiff --git a/server/vod/clips-routes.js b/server/vod/clips-routes.js
iindex 586c705..e704d94 100644
---- a/server/vod/clips-routes.js
++++ b/server/vod/clips-routes.js
@@@ -29,6 +29,14 @@ const labels = require('../labels');
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
++/** The sharing handle the app mounted (server/sharing.js), or null in a bare test app. */
++const sharingOf = (req) => (req.app && req.app.locals ? req.app.locals.sharing : null);
++/** Register a fresh clip with the sharing system at its visibility's audience. */
++async function registerClip(req, id, visibility) {
++ const sharing = sharingOf(req);
++ if (sharing) await sharing.register('clip', id, visibility).catch((err) => console.warn('[Clips] share row not registered:', err.message));
++}
++
// ── Direct clip upload (browser MediaRecorder blob → multipart `video`) ──
// Blobs can arrive with codec-qualified types, empty types, or
// application/octet-stream depending on the browser/platform.
@@@ -91,6 +99,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
// No picture in the source (an audio-only recording's clip): the player shows the avatar card, not a black box.
audio_only: !!clip.audio_only,
visibility: clip.visibility || 'public',
++ audience: clip.audience || ({ public: 'public', unlisted: 'link', private: 'private' }[clip.visibility || (clip.is_public ? 'public' : 'private')] || 'private'),
++ ...(clip.share_members ? { members: clip.share_members } : {}),
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
auto_generated: !!clip.auto_generated,
@@@ -290,6 +300,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
}); // the row and its object (uploading) in one transaction
const clipId = result.lastInsertRowid;
const appId = req.appId;
++ await registerClip(req, clipId, visibility);
if (win) {
await clipJobs.makeVirtual(clipId, win);
return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, appId), { readiness: true }) });
@@@ -353,6 +364,7 @@ async function _createRecordingClip(req, res, vod, startTime, endTime, body) {
is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing', audio_only: !!first.audio_only,
});
const clipId = result.lastInsertRowid;
++ await registerClip(req, clipId, visibility);
await require('./clip-jobs').makeVirtual(clipId, { objectId: rec.id, endTime: startTime + Number(last.end_ms) / 1000 });
return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, req.appId), { readiness: true }) });
}
@@@ -405,6 +417,7 @@ async function _createUploadedClip(req, res) {
audio_only: audioOnly,
}); // the row and its object (ready, local copy present) in one transaction
const clipId = result.lastInsertRowid;
++ await registerClip(req, clipId, visibility);
require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
.catch(err => console.warn(`[Clips] Thumbnail failed for clip ${clipId}:`, err.message));
@@@ -474,6 +487,15 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
try {
const clip = await _getClipScoped(req, res);
if (!clip) return;
++ const sharing = sharingOf(req);
++ if (sharing) {
++ clip.audience = await sharing.audienceOf('clip', clip.id);
++ if (clip.audience === 'people') {
++ clip.share_members = (await require('../db/database').all(
++ `SELECT subject->>'id' AS id FROM share_members WHERE service = 'media' AND kind = 'clip' AND resource_id = ? AND (expires_at IS NULL OR expires_at > NOW())`,
++ [String(clip.id)])).map((r) => r.id).filter(Boolean);
++ }
++ }
// Acting for one of the app's users: another user's private clip looks missing (see GET /vods/:id).
if (req.authType === 'user' && _isPrivate(clip) && !(clip.user_id != null && String(clip.user_id) === String(req.userId))) {
return res.status(404).json({ error: 'Clip not found' });
@@@ -629,6 +651,8 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
}
if (visibility !== undefined) {
await db.setClipVisibility(clip.id, visibility);
++ const sharing = sharingOf(req);
++ if (sharing) await sharing.syncAudience('clip', clip.id, visibility).catch((err) => console.warn('[Clips] share row not synced:', err.message));
// Made public (or already public): its watch page and the sitemap are indexable now.
await require('../indexnow-notify').pingWatch('clip', await db.getClipById(clip.id));
}
ddiff --git a/server/vod/finalize.js b/server/vod/finalize.js
iindex 7f41b3d..6168817 100644
---- a/server/vod/finalize.js
++++ b/server/vod/finalize.js
@@@ -70,6 +70,10 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
audio_only: !!vod.audio_only,
storage_provider: vod.storage_provider || 'local',
visibility: vod.visibility || 'public',
++ // The share.v1 audience, when the caller looked it up: `people` has no visibility column of its own
++ // and is the one Live's Rundown needs to follow a recording shared with named people.
++ audience: vod.audience || ({ public: 'public', unlisted: 'link', private: 'private' }[vod.visibility || (vod.is_public ? 'public' : 'private')] || 'private'),
++ ...(vod.share_members ? { members: vod.share_members } : {}),
// Kept alongside visibility — inherited SPA surfaces (manager badges,
// visibility toggles) read the boolean, and omitting it rendered every
// VOD as "Private" in the owner's manager.
ddiff --git a/server/vod/routes.js b/server/vod/routes.js
iindex 1f2982e..5e732d6 100644
---- a/server/vod/routes.js
++++ b/server/vod/routes.js
@@@ -33,6 +33,18 @@ const labels = require('../labels');
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
++/** The sharing handle the app mounted (server/sharing.js), or null in a bare test app. */
++const sharingOf = (req) => (req.app && req.app.locals ? req.app.locals.sharing : null);
++/** The people on a Specific-people item's list, as canonical subject ids. */
++async function membersOf(kind, id) {
++ try {
++ const rows = await require('../db/database').all(
++ `SELECT subject->>'id' AS id FROM share_members WHERE service = 'media' AND kind = ? AND resource_id = ? AND (expires_at IS NULL OR expires_at > NOW())`,
++ [kind, String(id)]);
++ return rows.map((r) => r.id).filter(Boolean);
++ } catch { return []; }
++}
++
// ── Chunk upload storage (browser MediaRecorder chunks) ──────
const VOD_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };
@@@ -110,6 +122,10 @@ router.post('/', tenantAuth({ verb: 'write' }), async (req, res) => {
clips_only: !!clips_only,
}); // the row (visibility and clips_only included) and its object in one transaction
const id = result.lastInsertRowid;
++ const sharing = sharingOf(req);
++ if (sharing) {
++ await sharing.register('vod', id, visibility || 'public').catch((err) => console.warn('[VOD] share row not registered:', err.message));
++ }
res.status(201).json({ id });
} catch (err) {
console.error('[VOD] Create error:', err.message);
@@@ -297,7 +313,9 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
const total = await db.countVods(req.appId, filters);
const hls = await require('../objects/hls').discoveryMany(vods);
const recs = await require('./recordings').summaries(vods.map((v) => v.id));
-- res.json({ vods: (await Promise.all(vods.map(async v => ({ ...await vodPublic(v, { readiness: true, hls: hls.get(String(v.object_id)) || null }),
++ const sharing = sharingOf(req);
++ const audiences = sharing ? await sharing.audiencesOf('vod', vods.map((v) => v.id)) : new Map();
++ res.json({ vods: (await Promise.all(vods.map(async v => ({ ...await vodPublic({ ...v, audience: audiences.get(Number(v.id)) }, { readiness: true, hls: hls.get(String(v.object_id)) || null }),
...(recs.has(Number(v.id)) && { recording: recs.get(Number(v.id)) }) })))), total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
console.error('[VOD] List error:', err.message);
@@@ -356,6 +374,11 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
// Detail responses carry the transcript too (lists stay light).
// A part of a recording (docs/recordings.md) names it: the whole video's id, length, parts and playlist.
const rec = await require('./recordings').forVod(req.appId, vod.id);
++ const sharing = sharingOf(req);
++ if (sharing) {
++ vod.audience = await sharing.audienceOf('vod', vod.id);
++ if (vod.audience === 'people') vod.share_members = await membersOf('vod', vod.id);
++ }
res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null,
...(rec && { recording: { ...rec.recording, this_part: rec.part } }) });
} catch (err) {
@@@ -384,7 +407,11 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
params.push(vod.id);
await db.run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
}
-- if (visibility !== undefined) await db.setVodVisibility(vod.id, visibility);
++ if (visibility !== undefined) {
++ await db.setVodVisibility(vod.id, visibility);
++ const sharing = sharingOf(req);
++ if (sharing) await sharing.syncAudience('vod', vod.id, visibility).catch((err) => console.warn('[VOD] share row not synced:', err.message));
++ }
});
// A part of a recording (docs/recordings.md): who may see it is the whole video's, and the first part's title is
// the recording's.
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex dc84813..9d60690 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -35,6 +35,12 @@ const path = require('path');
pastes: 'a frozen table Community owns and erases itself',
paste_comments: 'a frozen table Community owns and erases itself',
paste_likes: 'a frozen table Community owns and erases itself',
++ share_members: 'who a recording is shared with: exported as shared_with_me.json, erased with the account',
++ share_access_requests: 'pending access requests: transient, erased with the account',
++ share_transfers: 'pending ownership transfers: transient, erased with the account',
++ share_deliveries: 'the delivery queue for share events/notifications; rows carry ids',
++ share_log: 'the append-only sharing audit trail; ids only, no titles or content',
++ shares: 'the share.v1 row of a recording: exported as shares.json, erased with its owner',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex 40a0383..83a789d 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -21,7 +21,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// 2026-10-13 (T11): the index is composed by openvibe-shared/shell like the watch pages, which adds the
// shared web-runtime.js; the showcase stylesheet (same-origin) replaces Font Awesome (cross-origin).
jsRawKB: 326.9, // 326.9 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 325.4 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 323.3 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 322.1 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 320.8 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 318.1 with openvibe-shared 4.107.0 (2026-11-03: open.<tld> sites are named for their domain; the brand knows its front door); 317.5 with openvibe-shared 4.106.2 (2026-11-02: the site's name reads whole on a signed-in phone; the balances stack); 316.3 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 313.7 openvibe-shared 4.70.0 (2026-11-01: the app bar, busy(), skeletons, view transitions); 310.3 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 309.5 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 308.7 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 306.6 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 303.6 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 301.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 293.3 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 286 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 279.9 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 274.1 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 260.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 248.9 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 241 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 230.9 (was 197.7)
-- jsBrotliKB: 77.3, // 77.3 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 77.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 76.8 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 76.6 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 76.2 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 75.3 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 74.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 73.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 70.1 Shared 4.33.0 navbar glyph widths; 68.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 66.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 64.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 61.5 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.1 with openvibe-shared 4.10.0 (Report a problem in the footer, 2026-10-24); 53.7 (was 45.6)
++ jsBrotliKB: 77.4, // 77.4 with openvibe-shared 4.123.0 (2026-11-06: the share gate from markup, [data-ov-share-gate], no inline script); 77.3 with openvibe-shared 4.122.0 (2026-11-06: the featured link's phone button (4.119.0), the Dock's agent view (4.120.0), the agent view in the Dock's pop-out (4.121.0) and the thread start page's head (4.122.0)); 77.2 with openvibe-shared 4.118.0 (2026-11-06: the Locker re-auth link, the 44 px footer and TLD targets); 76.8 with openvibe-shared 4.114.0 (2026-11-06: the one Share sheet in the navbar (ov-share.js and ov-qr.js load on the first press); navbar.js gains its click handler); 76.6 with openvibe-shared 4.112.1 (2026-11-06: theme-loader.js (4.111.1) and the footer/appbar phone CSS (4.112.1)); 76.2 with openvibe-shared 4.109.2 (2026-11-03: OpenVibe's own select, the navbar loads ov-forms); 75.3 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 74.1 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 73.5 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 70.1 Shared 4.33.0 navbar glyph widths; 68.2 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 66.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 64.1 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 61.5 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 56.1 with openvibe-shared 4.10.0 (Report a problem in the footer, 2026-10-24); 53.7 (was 45.6)
cssFiles: 2, // 1 (showcase.css)
cssRawKB: 52.5, // 52.2 (2026-11-03: openvibe-shared 4.107.3, a custom room picture sits under the text on phones); 51.1 (2026-11-02: openvibe-shared 4.94.0, content labels and counted footer folds); 50.2 with openvibe-shared 4.82.0: kit.css grows about 1.1 KB raw with the motion tokens (49 KB measured on 4.81.0) (2026-11-01); 49.5 with openvibe-shared 4.81.0: room for the rounded 49 KB measure (2026-11-01); 49.0 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 47.2 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 45.4 with openvibe-shared 4.39.1: room.css carries the kit's tokens and numbers strip (+2.8 KB), so this page's cards keep their corners and colours and its numbers are tiles without loading kit.css (25.6 KB); 38.6 the front page's latest media as a rail of kit cards (room.css, 15.7 KB raw): the page shed a 48-card grid, 17 of its 23 phone screens; 23.3 openvibe-shared 4.26.1 (hero visual); 17.8 with Shared 4.13.0 (2026-10-26); 11.6 same-origin (showcase.css; was 0)
externalFiles: 3, // 2
ddiff --git a/test/share-gate-browser.test.js b/test/share-gate-browser.test.js
nnew file mode 100644
iindex 0000000..1fde6aa
---- /dev/null
++++ b/test/share-gate-browser.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++/**
++ * The Specific-people gate is markup, not an inline script (openvibe-shared 4.123.0): Headless Chrome
++ * loads the real watch-page gate, runs ov-share.js and checks that it draws — "Sign in" signed out,
++ * "Ask for access" for a signed-in stranger — with no console error and no CSP refusal.
++ *
++ * Skips (exit 0) where Playwright or Chrome is not installed, as in CI; PLAYWRIGHT_MODULE overrides the path.
++ */
++const assert = require('assert');
++const http = require('http');
++const fs = require('fs');
++
++function loadPlaywright() {
++ const candidates = [process.env.PLAYWRIGHT_MODULE, 'playwright',
++ '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'].filter(Boolean);
++ for (const name of candidates) { try { return require(name); } catch { /* next */ } }
++ return null;
++}
++const CHROME = process.env.CHROME_PATH || '/usr/bin/google-chrome';
++
++(async () => {
++ if (!fs.existsSync(CHROME)) return console.log('◌ share gate browser: skipped (no Chrome)');
++ const playwright = loadPlaywright();
++ if (!playwright) return console.log('◌ share gate browser: skipped (no Playwright)');
++
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ process.env.OV_NETWORK_URL = 'https://openvibe.network';
++ const express = require('express');
++ const pages = require('../server/public/pages');
++
++ const app = express();
++ app.use('/shared', require('openvibe-shared/serve').handler());
++ const gate = (signedInAs) => pages.renderShareGate('vod', '100001', {
++ signedInAs, signInUrl: '/auth/login?next=%2Fv%2F100001',
++ });
++ app.get('/gate-out', (_req, res) => res.status(404).type('html').send(gate(null)));
++ app.get('/gate-in', (_req, res) => res.status(404).type('html').send(gate('sam')));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++
++ const browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox'] });
++ try {
++ const check = async (path, words) => {
++ const page = await browser.newPage();
++ const errors = [];
++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ page.on('pageerror', (e) => errors.push(String(e && e.message || e)));
++ await page.goto(base + path, { waitUntil: 'domcontentloaded' });
++ await page.waitForSelector('.ov-share-gate', { timeout: 15000 });
++ const text = await page.locator('.ov-share-gate').innerText();
++ for (const word of words) assert.ok(text.includes(word), `${path}: ${word} in ${JSON.stringify(text)}`);
++ const bad = errors.filter((e) => /content security policy|refused to (execute|load|apply).*inline|violates the following/i.test(e));
++ assert.deepStrictEqual(bad, [], `${path}: CSP/console errors: ${bad.join(' | ')}`);
++ await page.close();
++ };
++ await check('/gate-out', ['This is shared with specific people', 'Sign in']);
++ await check('/gate-in', ['This is shared with specific people', 'Ask for access', '@sam']);
++ console.log('✅ share gate browser: both gates draw with no CSP refusal');
++ } finally {
++ await browser.close();
++ await new Promise((r) => server.close(r));
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/sharing-people.test.js b/test/sharing-people.test.js
nnew file mode 100644
iindex 0000000..cfaef7d
---- /dev/null
++++ b/test/sharing-people.test.js
@@@ -0,0 +1,261 @@
++'use strict';
++/**
++ * Sharing a VOD or a clip with specific people (design 2026-11-06, VISIBILITY-EVERYWHERE step 5).
++ *
++ * A recording starts at its visibility's audience; the Share sheet's four choices are enforced by
++ * canView on the watch page, the bytes, the thumbnail and the API's audience field. A named person
++ * reads it; a stranger gets the gate (never the title or the player); Everyone puts it back in the
++ * sitemap, Specific people takes it out. The share column stays `private` for people, so every old
++ * read path that only knows the column fails closed.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const http = require('http');
++
++(async () => {
++ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ov-media-sharing-'));
++ const dir = (n) => { const d = path.join(tmp, n); fs.mkdirSync(d, { recursive: true }); return d; };
++ process.env.VOD_PATH = dir('vods');
++ process.env.CLIPS_PATH = dir('clips');
++ process.env.FILES_PATH = dir('files');
++ process.env.OBJECTS_PATH = dir('objects');
++ process.env.THUMBNAILS_PATH = dir('thumbnails');
++ process.env.ASSETS_PATH = dir('assets');
++ process.env.OV_NETWORK_URL = 'https://openvibe.network';
++ process.env.OV_NETWORK_INTERNAL_URL = 'https://openvibe.network';
++ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
++ process.env.MEDIA_HLS_ENABLED = '1';
++ process.env.MEDIA_SIGNING_SECRET = 'test-signing-secret';
++ delete process.env.EVENTS_URL;
++ delete process.env.OV_OAUTH_CLIENT_SECRET;
++
++ const db = require('../server/db/database');
++ const auth = require('../server/auth');
++ const express = require('express');
++ const sharingLib = require('../server/sharing');
++ const { serviceAuth, ids } = require('openvibe-contracts');
++
++ const OWNER = ids.newId('user');
++ const FRIEND = ids.newId('user');
++ const STRANGER = ids.newId('user');
++ const keys = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
++ auth._setNetworkPublicKeyForTests(keys.publicKey.export({ type: 'spki', format: 'pem' }));
++ const now = () => Math.floor(Date.now() / 1000);
++ const enc = (o) => Buffer.from(JSON.stringify(o)).toString('base64url');
++ const token = (subject, username) => {
++ const claims = { sub: 1, subject_id: subject, username, display_name: username, role: 'user', iss: 'https://openvibe.network', aud: ['openvibe.network', 'openvibe.media'], iat: now(), exp: now() + 600 };
++ const input = `${enc({ alg: 'RS256', typ: 'JWT' })}.${enc(claims)}`;
++ return `${input}.${crypto.sign('RSA-SHA256', Buffer.from(input), keys.privateKey).toString('base64url')}`;
++ };
++ const PEOPLE = {
++ [FRIEND]: { subject: { type: 'user', id: FRIEND }, username: 'maticus', display_name: 'Maticus', avatar_url: null },
++ [STRANGER]: { subject: { type: 'user', id: STRANGER }, username: 'sam', display_name: 'Sam', avatar_url: null },
++ [OWNER]: { subject: { type: 'user', id: OWNER }, username: 'carol', display_name: 'Carol', avatar_url: null },
++ };
++ const networkJson = async ({ path, method = 'GET', json, query } = {}) => {
++ if (path === '/internal/identity/resolve' && String(query && query.username).toLowerCase() === 'maticus') {
++ return { ...PEOPLE[FRIEND], banned: false, deleted: false };
++ }
++ if (path === '/internal/identity/resolve-batch') {
++ const results = {};
++ for (const id of (json && json.subject_ids) || []) if (PEOPLE[id]) results[id] = { ...PEOPLE[id], banned: false, deleted: false };
++ return { results };
++ }
++ if (path === '/internal/notifications/push') return { queued: true };
++ const err = new Error(`no mock Network route ${method} ${path}`); err.status = 404; throw err;
++ };
++
++ await db.upsertApp({ app_id: 'games', api_key: 'games-key' });
++ const raw = db.getDb();
++ const mk = async (title, visibility, thumb) => {
++ const file = path.join(process.env.VOD_PATH, `${title.replace(/\W/g, '')}.webm`);
++ fs.writeFileSync(file, Buffer.alloc(64, 1));
++ const res = await raw.prepare(`INSERT INTO vods (app_id, user_id, title, file_path, is_public, visibility, duration_seconds, thumbnail_url)
++ VALUES ('games', 5, ?, ?, ?, ?, 10, ?) RETURNING id`).run(title, file, visibility === 'public' ? 1 : 0, visibility, thumb);
++ return Number(res.rows[0].id);
++ };
++ const project = async (id, subject) => {
++ await db.withObject('vod', id, async () => {});
++ await raw.prepare('UPDATE media_objects SET owner_subject = ? WHERE legacy_ref = ?').run(subject, `legacy:games:vod:${id}`);
++ };
++ const pubId = await mk('Everyone recording', 'public', '/t/vod-0-1.jpg');
++ const quietId = await mk('Quiet recording', 'public', '/t/vod-0-1.jpg');
++ const thumb = (id) => { const name = `vod-${id}-1.jpg`; fs.writeFileSync(path.join(process.env.THUMBNAILS_PATH, name), Buffer.from('ffd8ffe000104a464946', 'hex')); return `/t/${name}`; };
++ await raw.prepare('UPDATE vods SET thumbnail_url = ? WHERE id IN (?, ?)').run(null, pubId, quietId).catch(() => undefined);
++ await raw.prepare('UPDATE vods SET thumbnail_url = ? WHERE id = ?').run(thumb(pubId), pubId);
++ await raw.prepare('UPDATE vods SET thumbnail_url = ? WHERE id = ?').run(thumb(quietId), quietId);
++ await project(pubId, OWNER);
++ await project(quietId, OWNER);
++
++ const sharing = sharingLib.createSharing({ db: raw, config: require('../server/config'), networkJson, log: console });
++ await sharing.register('vod', pubId, 'public');
++ await sharing.register('vod', quietId, 'public');
++
++ const app = express();
++ app.locals.sharing = sharing;
++ app.use(express.json());
++ app.use((req, _res, next) => {
++ req.cookies = {};
++ for (const part of String(req.headers.cookie || '').split(';')) {
++ const i = part.indexOf('=');
++ if (i > 0) req.cookies[part.slice(0, i).trim()] = decodeURIComponent(part.slice(i + 1).trim());
++ }
++ next();
++ });
++ // The same wiring server/index.js mounts.
++ {
++ const routed = Object.create(sharing.shares);
++ routed.getWithRevision = async (viewer, ref) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.getWithRevision(viewer, ref); };
++ routed.update = async (viewer, ref, body, opts) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.update(viewer, ref, body, opts); };
++ routed.requestAccess = async (viewer, ref) => { await sharing.ensure(ref.kind, ref.id); return await sharing.shares.requestAccess(viewer, ref); };
++ const shareApi = express.Router();
++ shareApi.use(sharingLib.attachViewer);
++ shareApi.use(sharing.shares.router({ service: 'media', authenticate: (req) => (req.shareClaims ? sharingLib.shareViewer(req.shareClaims) : null), describe: sharingLib.describeRef(raw) }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|(?:vod|clip)\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++ app.use('/api/v1/:app/vods', require('../server/vod/routes'));
++ app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
++ app.use('/o', require('../server/objects/routes').publicRouter);
++ app.use('/', require('../server/public/routes'));
++ const server = http.createServer(app);
++ await new Promise((r) => server.listen(0, '127.0.0.1', r));
++ const base = `http://127.0.0.1:${server.address().port}`;
++ const get = async (p, { as, headers = {}, method = 'GET', json } = {}) => {
++ const h = { 'sec-fetch-site': 'same-origin', ...headers };
++ if (as) h.cookie = `ov_token=${token(as, PEOPLE[as].username)}`;
++ let body;
++ if (json !== undefined) { body = JSON.stringify(json); h['content-type'] = 'application/json'; }
++ const res = await fetch(base + p, { method, headers: h, body, redirect: 'manual' });
++ const text = await res.text();
++ return { status: res.status, text, headers: res.headers };
++ };
++ const share = (id, opts) => get(`/api/v1/vod/${id}/share`, opts);
++ const nav = { accept: 'text/html', 'sec-fetch-dest': 'document' };
++ const bytes = { accept: 'video/webm', 'sec-fetch-dest': 'video' };
++
++ try {
++ let r = await get(`/v/${pubId}`, { headers: bytes });
++ assert.strictEqual(r.status, 200, 'a public recording serves');
++ assert.ok((await get('/sitemap.xml')).text.includes(`/v/${pubId}`), 'and is in the sitemap');
++ assert.strictEqual((await share(pubId, { as: OWNER })).status, 200, 'the owner opens the sheet');
++ assert.strictEqual((await share(pubId, { as: STRANGER })).status, 404, 'a stranger does not');
++
++ // Specific people.
++ r = await share(pubId, { as: OWNER, method: 'PUT', json: { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] } });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.strictEqual(JSON.parse(r.text).audience, 'people');
++ const row = await raw.prepare('SELECT visibility FROM vods WHERE id = ?').get(pubId);
++ assert.strictEqual(row.visibility, 'private', 'the old column fails closed');
++
++ const memberPage = await get(`/v/${pubId}`, { as: FRIEND, headers: nav });
++ assert.strictEqual(memberPage.status, 200, 'the named person reads the page');
++ assert.ok(memberPage.text.includes('Everyone recording'));
++ assert.match(memberPage.headers.get('cache-control') || '', /private/);
++ assert.match(memberPage.headers.get('cache-control') || '', /no-store/);
++ const memberBytes = await get(`/v/${pubId}`, { as: FRIEND, headers: bytes });
++ assert.strictEqual(memberBytes.status, 200, 'and its bytes');
++ assert.match(memberBytes.headers.get('cache-control') || '', /private/);
++ assert.match(memberBytes.headers.get('cache-control') || '', /no-store/);
++
++ const strangerPage = await get(`/v/${pubId}`, { as: STRANGER, headers: nav });
++ assert.strictEqual(strangerPage.status, 404);
++ assert.ok(strangerPage.text.includes('shared with specific people') && strangerPage.text.includes('data-ov-share-gate'), 'the gate');
++ assert.ok(strangerPage.text.includes('data-kind="vod"') && strangerPage.text.includes(`data-id="${pubId}"`), 'with its kind and id');
++ assert.ok(!strangerPage.text.includes('OVShare.gate('), 'no inline script to need a CSP nonce');
++ assert.ok(!strangerPage.text.includes('Everyone recording'), 'never the title');
++ const anonBytes = await get(`/v/${pubId}`, { headers: bytes });
++ assert.strictEqual(anonBytes.status, 404, 'no bytes signed out');
++ assert.strictEqual(anonBytes.text, '', 'and no body');
++ assert.strictEqual((await get(`/v/${pubId}`, { as: STRANGER, headers: bytes })).status, 404, 'no bytes for a stranger');
++ const anonThumb = await get(`/t/vod-${pubId}-1.jpg`);
++ assert.strictEqual(anonThumb.status, 404, 'no thumbnail signed out');
++ assert.strictEqual(anonThumb.text, '', 'and no body');
++ const memberThumb = await get(`/t/vod-${pubId}-1.jpg`, { as: FRIEND });
++ assert.strictEqual(memberThumb.status, 200, 'the member gets the picture');
++ assert.match(memberThumb.headers.get('cache-control') || '', /private/);
++ assert.match(memberThumb.headers.get('cache-control') || '', /no-store/);
++
++ r = await get(`/api/v1/games/vods/${pubId}`, { headers: { authorization: 'Bearer games-key' } });
++ assert.strictEqual(r.status, 200);
++ const dto = JSON.parse(r.text);
++ assert.strictEqual(dto.audience, 'people', 'the API names the audience');
++ assert.deepStrictEqual(dto.members, [FRIEND], 'with the people on the list');
++
++ assert.ok(!(await get('/sitemap.xml')).text.includes(`/v/${pubId}`), 'leaving Everyone drops the sitemap');
++ assert.ok(!(await get('/')).text.includes('Everyone recording'), 'and the gathered index');
++ assert.ok(!(await get('/llms.txt')).text.includes('Everyone recording'), 'and llms.txt');
++
++ // ── Every byte path answers the member with a private, no-store header ──
++ // Local: above. Thumbnail: above. B2/R2 redirect: patch the placement answer, as the provider would.
++ const vodStorage = require('../server/vod/vod-storage');
++ const saveIsRemote = vodStorage.isRemote, saveResolve = vodStorage.resolvePlayback;
++ try {
++ const remoteFile = path.join(process.env.VOD_PATH, 'not-local-b2.webm');
++ const remoteId = Number((await raw.prepare(`INSERT INTO vods (app_id, user_id, title, file_path, is_public, visibility, duration_seconds, storage_provider, storage_key)
++ VALUES ('games', 5, 'Remote recording', NULL, 1, 'public', 10, 'b2', 'b2/key') RETURNING id`).run()).rows[0].id);
++ await project(remoteId, OWNER);
++ await sharing.register('vod', remoteId, 'public');
++ await share(remoteId, { as: OWNER, method: 'PUT', json: { audience: 'people', members: [{ subject: { type: 'user', id: FRIEND }, role: 'viewer' }] } });
++ vodStorage.isRemote = () => true;
++ vodStorage.resolvePlayback = async () => ({ kind: 'redirect', provider: 'b2', url: 'https://b2.example/presigned' });
++ const remoteMember = await get(`/v/${remoteId}`, { as: FRIEND, headers: bytes });
++ assert.strictEqual(remoteMember.status, 302, 'the member gets the provider redirect');
++ assert.match(remoteMember.headers.get('cache-control') || '', /private, no-store/);
++ const remoteAnon = await get(`/v/${remoteId}`, { headers: bytes });
++ assert.strictEqual(remoteAnon.status, 404);
++ assert.strictEqual(remoteAnon.text, '');
++
++ // A virtual clip is a redirect to its signed playlist; the closed clip is only the member's.
++ const clipId = Number((await raw.prepare(`INSERT INTO clips (app_id, vod_id, user_id, title, file_path, is_public, visibility, status, storage_provider, object_id)
++ VALUES ('games', ?, 5, 'Virtual clip', NULL, 0, 'public', 'ready', 'timeline', 'med_01J0000000000000000000000A') RETURNING id`).run(pubId)).rows[0].id);
++ await db.withObject('clip', clipId, async () => {});
++ await raw.prepare('UPDATE media_objects SET owner_subject = ? WHERE legacy_ref = ?').run(OWNER, `legacy:games:clip:${clipId}`);
++ await sharing.register('clip', clipId, 'public');
++ const clipPut = await get(`/api/v1/clip/${clipId}/share`, { as: OWNER, method: 'PUT', json: { audience: 'people', members: [{ subject: { type: 'user', id: FRIEND }, role: 'viewer' }] } });
++ assert.strictEqual(clipPut.status, 200, clipPut.text);
++ const virtualMember = await get(`/c/${clipId}`, { as: FRIEND, headers: bytes });
++ assert.strictEqual(virtualMember.status, 302, 'the member is sent to the signed playlist');
++ assert.match(virtualMember.headers.get('cache-control') || '', /private, no-store/);
++ assert.ok(String(virtualMember.headers.get('location')).includes('/o/'), 'a signed object playlist');
++ const virtualAnon = await get(`/c/${clipId}`, { headers: bytes });
++ assert.strictEqual(virtualAnon.status, 404);
++ assert.strictEqual(virtualAnon.text, '');
++
++ // The object and its HLS (playlist and, by the same check, segments): unsigned is missing;
++ // a short-lived signed playlist is the member's and never enters a shared cache.
++ const objectId = (await raw.prepare('SELECT object_id FROM clips WHERE id = ?').get(clipId)).object_id;
++ await raw.prepare("UPDATE media_objects SET lifecycle_status = 'ready' WHERE id = ?").run(objectId);
++ assert.strictEqual((await get(`/o/${objectId}`)).status, 404, 'the private object is missing unsigned');
++ const playlist = require('../server/objects/signing').signedPlaylistUrl(objectId);
++ const signedPlaylist = await get(playlist.url.replace('https://media.test', ''));
++ assert.match(signedPlaylist.headers.get('cache-control') || '', /private, no-store/);
++ } finally {
++ vodStorage.isRemote = saveIsRemote;
++ vodStorage.resolvePlayback = saveResolve;
++ }
++
++ // Only me: even the member loses it.
++ assert.strictEqual((await share(pubId, { as: OWNER, method: 'PUT', json: { audience: 'private' } })).status, 200);
++ assert.strictEqual((await get(`/v/${pubId}`, { as: FRIEND, headers: nav })).status, 404, 'Only me');
++ assert.strictEqual((await get(`/v/${pubId}`, { as: OWNER, headers: nav })).status, 200, 'its owner still reads it');
++
++ // Everyone again: back in the sitemap and readable by anybody.
++ assert.strictEqual((await share(pubId, { as: OWNER, method: 'PUT', json: { audience: 'public' } })).status, 200);
++ assert.strictEqual((await get(`/v/${pubId}`, { headers: bytes })).status, 200);
++ assert.ok((await get('/sitemap.xml')).text.includes(`/v/${pubId}`), 'Everyone is listed again');
++
++ // A stranger cannot learn a people-only item's transcript, and the API hides the title from the
++ // watch page; the share API stays the owner's.
++ assert.strictEqual((await share(quietId, { as: STRANGER })).status, 404);
++
++ console.log('✅ media sharing: audiences, the gate, bytes, thumbnails, sitemap');
++ } finally {
++ sharing.work = null;
++ await new Promise((r) => server.close(r));
++ fs.rmSync(tmp, { recursive: true, force: true });
++ }
++})().catch((err) => { console.error(err); process.exit(1); });