openvibers/openvibe.media Commits
Media: VODs and clips can be shared with specific people (sharing step 5) (#332)
Changes
@@@ -46,7 +46,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pgthe 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 toolsscripts/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 -->@@@ -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",nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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;@@@ -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",@@@ -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"},@@@ -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 };}@@@ -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 };@@@ -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);@@@ -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 & 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 };@@@ -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);});@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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 filetry {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) {@@@ -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 transactionconst 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 transactionconst 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, retry {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));}@@@ -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.@@@ -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 transactionconst 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.@@@ -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(', ')}`);@@@ -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, // 2nnew file mode 100644@@@ -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); });nnew file mode 100644@@@ -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); });
@@ -46,7 +46,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
@@ -46,7 +46,7 @@ without `DATABASE_URL`, development uses an embedded PGlite database in `data/pg
the account and revocation subscriptions)
the account and revocation subscriptions)
- Backblaze B2 and Cloudflare R2 (S3 API) when configured; `ffmpeg`/`ffprobe` on the host
- 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-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
## Capabilities
@@ -129,7 +129,7 @@ server/
@@ -129,7 +129,7 @@ server/
storage.orphans.scan (monthly storage orphan report, service-wide, report only),
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)
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
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/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/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
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>`.
@@ -780,5 +780,5 @@ restart; afterwards `sudo ovhost rollback media --to <sha>`.
<!-- versions:start -->
<!-- versions:start -->
- openvibe-contracts: v0.302.0
- openvibe-contracts: v0.302.0
- openvibe-sdk: v0.91.0
- openvibe-sdk: v0.91.0
-- openvibe-shared: v4.122.0
+- openvibe-shared: v4.123.0
<!-- versions:end -->
<!-- versions:end -->
@@ -8,7 +8,7 @@
@@ -8,7 +8,7 @@
"domain": "openvibe.media",
"domain": "openvibe.media",
"contracts": "openvibe-contracts v0.302.0",
"contracts": "openvibe-contracts v0.302.0",
"sdk": "openvibe-sdk v0.91.0",
"sdk": "openvibe-sdk v0.91.0",
- "shared": "openvibe-shared v4.122.0",
+ "shared": "openvibe-shared v4.123.0",
"capabilities": [
"capabilities": [
"media.object.upload",
"media.object.upload",
"media.object.read",
"media.object.read",
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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;
@@ -18,7 +18,7 @@
@@ -18,7 +18,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"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-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",
"pg": "^8.23.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
},
},
@@ -2166,9 +2166,9 @@
@@ -2166,9 +2166,9 @@
}
}
},
},
"node_modules/openvibe-shared": {
"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",
"license": "MIT",
"dependencies": {
"dependencies": {
"jsonwebtoken": "^9.0.0",
"jsonwebtoken": "^9.0.0",
@@ -22,7 +22,7 @@
@@ -22,7 +22,7 @@
"multer": "^1.4.5-lts.1",
"multer": "^1.4.5-lts.1",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.302.0",
"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-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",
"pg": "^8.23.0",
"sharp": "^0.33.4"
"sharp": "^0.33.4"
},
},
@@ -46,6 +46,14 @@ async function exportPart(db, subjects) {
@@ -46,6 +46,14 @@ async function exportPart(db, subjects) {
if (vods.length) files.push({ name: 'vods.json', content: vods });
if (vods.length) files.push({ name: 'vods.json', content: vods });
if (clips.length) files.push({ name: 'clips.json', content: clips });
if (clips.length) files.push({ name: 'clips.json', content: clips });
if (plain.length) files.push({ name: 'files.json', content: plain });
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.' };
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) {
@@ -99,6 +107,19 @@ async function erase(db, subjects) {
throw e;
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 */ }
try { require('./events').kick(); } catch { /* the relay polls anyway */ }
return { erased, retained };
return { erased, retained };
}
}
@@ -194,6 +194,25 @@ function checkUsage(payload) {
@@ -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('; ') };
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:
* 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.
* no tenant field, low priority. Same rule as record(): inside the transaction that records the push.
@@ -253,7 +272,9 @@ function objectChangeEvent(change, obj) {
@@ -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 };
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) }];
if (change.change === 'deleted') return ['media.object.deleted', { ...base, deleted_at: isoTime(change.changed_at) }];
return ['media.object.visibility_changed', {
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 } = {}) {
@@ -276,6 +297,21 @@ async function recordObjectChanges({ limit = 1000 } = {}) {
for (const c of rows) {
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);
const obj = await raw.prepare('SELECT id, app_id, kind, legacy_ref FROM media_objects WHERE id = ?').get(c.object_id);
if (obj) {
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);
const [type, payload] = objectChangeEvent(c, obj);
if (await enqueue(type, obj.app_id, { type: 'object', id: obj.id }, payload, 'important')) n++;
if (await enqueue(type, obj.app_id, { type: 'object', id: obj.id }, payload, 'important')) n++;
}
}
@@ -313,4 +349,4 @@ function _reset() {
@@ -313,4 +349,4 @@ function _reset() {
stats.queued = 0; stats.lastError = null;
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 };
@@ -266,6 +266,36 @@ const ready = (async () => {
@@ -266,6 +266,36 @@ const ready = (async () => {
// ahead of /api/v1/:app/… so /api/v1/resources is never read as a tenant named "resources".
// 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-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') }));
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/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/recordings', require('./vod/recordings').router);
app.use('/api/v1/:app/recordings', require('./vod/recordings').router);
@@ -16,6 +16,7 @@ const path = require('path');
@@ -16,6 +16,7 @@ const path = require('path');
const config = require('../config');
const config = require('../config');
const pageFrame = require('./page-frame');
const pageFrame = require('./page-frame');
const showcase = require('openvibe-shared/showcase');
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;
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) {
@@ -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 & more</a>` : ''}
${ownedByLive ? `<a class="btn" href="${esc(canonical)}">${isVod ? 'Watch on OpenVibe.Live' : 'Watch on OpenVibe.Live'} — chat, transcript & more</a>` : ''}
${playable ? `<a class="btn ghost" href="${esc(rawUrl)}" download>Download ${isVod ? 'VOD' : 'clip'}</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>` : ''}
${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>
</div>
${record.description ? `<p class="desc">${esc(snip(record.description, 2000))}</p>` : ''}
${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>` : ''}
${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) {
@@ -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 };
@@ -32,6 +32,7 @@ const db = require('../db/database');
@@ -32,6 +32,7 @@ const db = require('../db/database');
const tools = require('../vod/media-tools');
const tools = require('../vod/media-tools');
const { optionalIdentity } = require('../auth');
const { optionalIdentity } = require('../auth');
const pages = require('./pages');
const pages = require('./pages');
+const sharingLib = require('../sharing');
const sourceVod = require('./source-vod');
const sourceVod = require('./source-vod');
const notFoundPage = require('./not-found-page');
const notFoundPage = require('./not-found-page');
// A restore drill (MEDIA_DRILL) serves no stored bytes: every byte route answers 503 before it looks
// 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) {
@@ -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
* 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.
* 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) {
if (_remuxing >= REMUX_MAX) {
res.set('Retry-After', '5');
res.set('Retry-After', '5');
return res.status(503).json({ error: 'Other downloads are being prepared: try again in a few seconds' });
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) {
@@ -194,7 +195,7 @@ function sendVirtualClipFile(req, res, record, closed, disposition) {
ff.stdout.on('data', (chunk) => {
ff.stdout.on('data', (chunk) => {
if (!started) {
if (!started) {
started = true;
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' });
'X-Robots-Tag': 'noindex', 'X-Content-Type-Options': 'nosniff' });
}
}
if (!res.write(chunk)) { ff.stdout.pause(); res.once('drain', () => ff.stdout.resume()); }
if (!res.write(chunk)) { ff.stdout.pause(); res.once('drain', () => ff.stdout.resume()); }
@@ -220,9 +221,38 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -220,9 +221,38 @@ async function serveMediaRecord(kind, record, req, res) {
const noindex = { 'X-Robots-Tag': 'noindex' };
const noindex = { 'X-Robots-Tag': 'noindex' };
const visibility = recordVisibility(record);
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
// 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,
// 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) {
@@ -240,7 +270,7 @@ async function serveMediaRecord(kind, record, req, res) {
if (!closed && pages.wantsHtmlPage(req)) {
if (!closed && pages.wantsHtmlPage(req)) {
const canonical = pages.canonicalPageUrl(kind, record);
const canonical = pages.canonicalPageUrl(kind, record);
if (canonical) {
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);
return res.redirect(302, canonical);
}
}
}
}
@@ -248,8 +278,8 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -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' });
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);
await trackUniqueView(kind, record.id, req, record.user_id);
const asFile = downloadDisposition(req, kind, record, '.mp4');
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`);
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) {
@@ -261,7 +291,7 @@ async function serveMediaRecord(kind, record, req, res) {
if (pages.wantsHtmlPage(req)) {
if (pages.wantsHtmlPage(req)) {
// A watch page names media that can become private at any moment: no stale serving, or a cache
// 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.
// 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)));
return res.type('html').send(pages.renderWatchPage(kind, record, await require('../objects/readiness').forRow(record)));
}
}
if (drill.refuseBytes(res)) return;
if (drill.refuseBytes(res)) return;
@@ -299,7 +329,7 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -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 || ''));
const asFile = key && downloadDisposition(req, kind, record, path.extname(key) || path.extname(record.file_path || ''));
if (asFile) {
if (asFile) {
const url = await vodStorage.presignGet(plan.provider, key, 900, { contentDisposition: asFile }).catch(() => null);
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') {
if (plan?.kind === 'redirect') {
// The origin shield (placement/shield.js): a public copy on a shielded provider is served by nginx through
// 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) {
@@ -308,13 +338,13 @@ async function serveMediaRecord(kind, record, req, res) {
const shield = require('../placement/shield');
const shield = require('../placement/shield');
const shieldable = shield.enabled(plan.provider) && plan.provider !== shieldFailed && visibility !== 'private' && !record.is_recording;
const shieldable = shield.enabled(plan.provider) && plan.provider !== shieldFailed && visibility !== 'private' && !record.is_recording;
const edge = shieldable && shield.toEdge(req, plan.provider);
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)) {
if (shieldable && shield.onEdge(req)) {
const url = await vodStorage.presignGet(plan.provider, vodStorage.keyForVod(record), shield.SHIELD_TTL_SECONDS).catch(() => null);
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) });
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');
res.set('X-Robots-Tag', 'noindex');
return res.redirect(302, plan.url);
return res.redirect(302, plan.url);
}
}
@@ -340,13 +370,13 @@ async function serveMediaRecord(kind, record, req, res) {
@@ -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).
// 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' };
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 ─────────────────────────────────────────────
// ── VOD playback ─────────────────────────────────────────────
// By numeric id only: a file name is not a route (the basename fallback for old /api/vods/file/<name> URLs
// 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).
// 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 {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const vod = await db.getVodById(parseInt(req.params.id, 10));
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 })
@@ -422,7 +452,7 @@ router.get('/v/:id/dvr/:segment', dvrRoute(async (req, res, { filePath, index })
}));
}));
// ── Clip playback ────────────────────────────────────────────
// ── Clip playback ────────────────────────────────────────────
-router.get('/c/:id', optionalIdentity, async (req, res) => {
+router.get('/c/:id', optionalIdentity, sharingLib.attachViewer, async (req, res) => {
try {
try {
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
if (!/^\d+$/.test(req.params.id)) return notFound(req, res);
const wantsPage = pages.wantsHtmlPage(req);
const wantsPage = pages.wantsHtmlPage(req);
@@ -447,13 +477,30 @@ router.get('/og-image.png', (req, res) => {
@@ -447,13 +477,30 @@ router.get('/og-image.png', (req, res) => {
});
});
// ── Thumbnails ───────────────────────────────────────────────
// ── Thumbnails ───────────────────────────────────────────────
-router.get('/t/:id', (req, res) => {
+router.get('/t/:id', sharingLib.attachViewer, async (req, res) => {
if (drill.refuseBytes(res)) return;
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
// 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).
// 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.
// Whoever has the URL can already load the image; this lets a page read its pixels.
res.set('Access-Control-Allow-Origin', '*');
res.set('Access-Control-Allow-Origin', '*');
res.set('Cross-Origin-Resource-Policy', 'cross-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);
require('../thumbnails/thumbnail-service').serveThumbnail(req, res);
});
});
@@ -89,6 +89,21 @@ async function sync({ now = Date.now() } = {}) {
@@ -89,6 +89,21 @@ async function sync({ now = Date.now() } = {}) {
return n;
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() {
async function start() {
if (timer || !(await events.status()).enabled || process.env.MEDIA_SEARCH_DOCUMENTS === 'off') return false;
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; } };
const run = async () => { try { await sync(); } catch (err) { stats.lastError = err.message; } };
@@ -102,4 +117,4 @@ async function start() {
@@ -102,4 +117,4 @@ async function start() {
function stop() { if (timer) clearInterval(timer); timer = null; }
function stop() { if (timer) clearInterval(timer); timer = null; }
function status() { return { ...stats }; }
function status() { return { ...stats }; }
-module.exports = { start, stop, sync, documentFor, status };
+module.exports = { start, stop, sync, syncOne, documentFor, status };
new file mode 100644
new file mode 100644
@@ -0,0 +1,311 @@
@@ -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 };
@@ -385,7 +385,7 @@ async function thumbnailItemGone(filename) {
@@ -385,7 +385,7 @@ async function thumbnailItemGone(filename) {
return !exists;
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
if (require('../drill').refuseBytes(res)) return; // a restore drill serves no stored file
try {
try {
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
const filename = path.basename(req.params.id || req.params.filename || ''); // prevent traversal
@@ -418,14 +418,16 @@ async function serveThumbnail(req, res) {
@@ -418,14 +418,16 @@ async function serveThumbnail(req, res) {
// (a regenerated thumb gets a NEW name), so those bytes never change —
// (a regenerated thumb gets a NEW name), so those bytes never change —
// mark them immutable so browsers stop revalidating on every render.
// mark them immutable so browsers stop revalidating on every render.
const isLive = filename.startsWith('stream-');
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) {
if (spec) {
let v = null;
let v = null;
try { v = await variants().get(filePath, spec); } catch { v = null; }
try { v = await variants().get(filePath, spec); } catch { v = null; }
if (v) return sendFile(res, v.file, v.size, v.type, cacheControl);
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.
// 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);
sendFile(res, filePath, stat.size, contentType, cacheControl);
} catch (err) {
} catch (err) {
@@ -29,6 +29,14 @@ const labels = require('../labels');
@@ -29,6 +29,14 @@ const labels = require('../labels');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
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`) ──
// ── Direct clip upload (browser MediaRecorder blob → multipart `video`) ──
// Blobs can arrive with codec-qualified types, empty types, or
// Blobs can arrive with codec-qualified types, empty types, or
// application/octet-stream depending on the browser/platform.
// application/octet-stream depending on the browser/platform.
@@ -91,6 +99,8 @@ async function clipPublic(clip, { readiness = false, hls } = {}) {
@@ -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.
// 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,
audio_only: !!clip.audio_only,
visibility: clip.visibility || 'public',
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',
is_public: (clip.visibility || (clip.is_public ? 'public' : 'private')) === 'public',
storage_provider: clip.storage_provider || 'local',
storage_provider: clip.storage_provider || 'local',
auto_generated: !!clip.auto_generated,
auto_generated: !!clip.auto_generated,
@@ -290,6 +300,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
@@ -290,6 +300,7 @@ router.post('/', tenantAuth({ verb: 'write', allowUser: true }), clipUpload.sing
}); // the row and its object (uploading) in one transaction
}); // the row and its object (uploading) in one transaction
const clipId = result.lastInsertRowid;
const clipId = result.lastInsertRowid;
const appId = req.appId;
const appId = req.appId;
+ await registerClip(req, clipId, visibility);
if (win) {
if (win) {
await clipJobs.makeVirtual(clipId, win);
await clipJobs.makeVirtual(clipId, win);
return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, appId), { readiness: true }) });
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) {
@@ -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,
is_public: visibility === 'public' ? 1 : 0, visibility, status: 'processing', audio_only: !!first.audio_only,
});
});
const clipId = result.lastInsertRowid;
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 });
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 }) });
return res.status(201).json({ ...await clipPublic(await db.getClipById(clipId, req.appId), { readiness: true }) });
}
}
@@ -405,6 +417,7 @@ async function _createUploadedClip(req, res) {
@@ -405,6 +417,7 @@ async function _createUploadedClip(req, res) {
audio_only: audioOnly,
audio_only: audioOnly,
}); // the row and its object (ready, local copy present) in one transaction
}); // the row and its object (ready, local copy present) in one transaction
const clipId = result.lastInsertRowid;
const clipId = result.lastInsertRowid;
+ await registerClip(req, clipId, visibility);
require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
require('../thumbnails/thumbnail-service').generateClipThumbnail(clipId, clipPath)
.catch(err => console.warn(`[Clips] Thumbnail failed for clip ${clipId}:`, err.message));
.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
@@ -474,6 +487,15 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
try {
try {
const clip = await _getClipScoped(req, res);
const clip = await _getClipScoped(req, res);
if (!clip) return;
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).
// 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))) {
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' });
return res.status(404).json({ error: 'Clip not found' });
@@ -629,6 +651,8 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -629,6 +651,8 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
}
}
if (visibility !== undefined) {
if (visibility !== undefined) {
await db.setClipVisibility(clip.id, visibility);
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.
// 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));
await require('../indexnow-notify').pingWatch('clip', await db.getClipById(clip.id));
}
}
@@ -70,6 +70,10 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
@@ -70,6 +70,10 @@ async function vodPublic(vod, { readiness = false, hls } = {}) {
audio_only: !!vod.audio_only,
audio_only: !!vod.audio_only,
storage_provider: vod.storage_provider || 'local',
storage_provider: vod.storage_provider || 'local',
visibility: vod.visibility || 'public',
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,
// Kept alongside visibility — inherited SPA surfaces (manager badges,
// visibility toggles) read the boolean, and omitting it rendered every
// visibility toggles) read the boolean, and omitting it rendered every
// VOD as "Private" in the owner's manager.
// VOD as "Private" in the owner's manager.
@@ -33,6 +33,18 @@ const labels = require('../labels');
@@ -33,6 +33,18 @@ const labels = require('../labels');
const router = express.Router({ mergeParams: true });
const router = express.Router({ mergeParams: true });
router.use(tenantCors);
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) ──────
// ── Chunk upload storage (browser MediaRecorder chunks) ──────
const VOD_MIME_TO_EXT = { 'video/webm': '.webm', 'video/mp4': '.mp4', 'video/x-matroska': '.mkv', 'video/ogg': '.ogg' };
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) => {
@@ -110,6 +122,10 @@ router.post('/', tenantAuth({ verb: 'write' }), async (req, res) => {
clips_only: !!clips_only,
clips_only: !!clips_only,
}); // the row (visibility and clips_only included) and its object in one transaction
}); // the row (visibility and clips_only included) and its object in one transaction
const id = result.lastInsertRowid;
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 });
res.status(201).json({ id });
} catch (err) {
} catch (err) {
console.error('[VOD] Create error:', err.message);
console.error('[VOD] Create error:', err.message);
@@ -297,7 +313,9 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
@@ -297,7 +313,9 @@ router.get('/', tenantAuth({ verb: 'list', allowUser: true }), async (req, res)
const total = await db.countVods(req.appId, filters);
const total = await db.countVods(req.appId, filters);
const hls = await require('../objects/hls').discoveryMany(vods);
const hls = await require('../objects/hls').discoveryMany(vods);
const recs = await require('./recordings').summaries(vods.map((v) => v.id));
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 });
...(recs.has(Number(v.id)) && { recording: recs.get(Number(v.id)) }) })))), total, limit, offset, hasMore: offset + vods.length < total });
} catch (err) {
} catch (err) {
console.error('[VOD] List error:', err.message);
console.error('[VOD] List error:', err.message);
@@ -356,6 +374,11 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
@@ -356,6 +374,11 @@ router.get('/:id', tenantAuth({ verb: 'read', allowUser: true }), async (req, re
// Detail responses carry the transcript too (lists stay light).
// 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.
// 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 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,
res.json({ ...await vodPublic(vod, { readiness: true }), ai_transcript: vod.ai_transcript || null,
...(rec && { recording: { ...rec.recording, this_part: rec.part } }) });
...(rec && { recording: { ...rec.recording, this_part: rec.part } }) });
} catch (err) {
} catch (err) {
@@ -384,7 +407,11 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
@@ -384,7 +407,11 @@ router.put('/:id', tenantAuth({ verb: 'write' }), async (req, res) => {
params.push(vod.id);
params.push(vod.id);
await db.run(`UPDATE vods SET ${updates.join(', ')} WHERE id = ?`, params);
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
// 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.
// the recording's.
@@ -35,6 +35,12 @@ const path = require('path');
@@ -35,6 +35,12 @@ const path = require('path');
pastes: 'a frozen table Community owns and erases itself',
pastes: 'a frozen table Community owns and erases itself',
paste_comments: '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',
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
@@ -21,7 +21,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -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
// 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).
// 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)
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)
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)
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
externalFiles: 3, // 2
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,261 @@
@@ -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); });