openvibers/openvibe.mediahub Commits
Video: the playlist API for people's tokens and apps (video.playlist.read/manage), public reads, playlists on channel pages (#33)
Changes
@@@ -67,9 +67,11 @@ creates none):The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lists ten capabilities it owns —`video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`,--`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create` — but--no route guards one yet: `CAPABILITIES` in `server/http/principal.js` is empty, and every `/api/v1` route requires a--signed-in person, never an app, agent or service token.++`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create`.++The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API++(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and++any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.++Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.On other services it calls:@@@ -150,13 +152,14 @@ address). The video host is chosen by `Host` header in `server/app.js`, so the dnever answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anythingelse is dropped.--Playlists: 100 per person and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and++Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds Live ids only (`vod` or `clip` plus the numeric id); thewatch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longeravailable" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each--watch page opened with `?list=` shows "Up next" and advances when the video ends.++watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by++an app, agent or service through the API below; its public page attributes it "Curated by an app".--Not built yet: uploads, and public playlists of a channel.++A channel page (`/@user`) lists that person's public playlists (newest twelve). Not built yet: uploads.## Pages@@@ -179,18 +182,63 @@ Not built yet: uploads, and public playlists of a channel.| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads || `GET /usage` | your usage against the limits |--On the video host (openvibe.video), the playlist API is browser-session only — the owner is the signed-in person,--never an app, agent or service token:++On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token++with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`++routes are unchanged and remain a signed-in person only.--| Route | |--|---|---|--| `GET /api/v1/video/playlists`, `POST /api/v1/video/playlists` | list and create playlists |--| `GET /api/v1/video/playlists/:id`, `PATCH /api/v1/video/playlists/:id`, `DELETE /api/v1/video/playlists/:id` | one playlist: read, rename/describe/change visibility, delete |--| `POST /api/v1/video/playlists/:id/items` | add one `{ kind: 'vod'|'clip', id }` |--| `PATCH /api/v1/video/playlists/:id/items` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |--| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | remove one |++## Playlist API++++The video host's playlist API is `/api/v1/video/*`. The caller is the existing principal++(`server/http/principal.js`): a person's own Network token as `Authorization: Bearer` works exactly like the++session, an app/agent/service token needs the capability the route names, and public reads are open to anyone.++++| Route | Who | What |++|---|---|---|++| `GET /api/v1/video/playlists` | person, or app with `video.playlist.read` | the caller's own playlists, newest first |++| `POST /api/v1/video/playlists` | person, or app with `video.playlist.manage` | create one from `{ title, description?, visibility? }` |++| `GET /api/v1/video/playlists/:id` | owner (private too); anyone for public/unlisted; app with `video.playlist.read` for others' public/unlisted | one playlist and its items |++| `PATCH /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | rename / describe / change visibility |++| `DELETE /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | delete it (its items go with it) |++| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip', id }` |++| `PATCH /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |++| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | owner, or app with `video.playlist.manage` | remove one |++| `GET /api/v1/video/users/:username/playlists` | anyone | that person's public playlists, newest first, `limit` (default 20, max 50) and an opaque `cursor` |++++An app/agent/service owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`) and the++playlist records its `project_id`. Its Network token is minted for the media hub's audience, `openvibe.download`, as++for the drive (one service, one audience). A person's session-cookie writes must be same-origin; a person Bearer token needs++no capability. A private playlist answers `404 playlist.not_found` to anyone but its owner (never 403). Errors are++RFC 9457 problem+json with a stable code (`token.required`, `capability.denied`, `playlist.not_found`, and the++playlist validation codes).++++Create with an app token:++++```bash++curl -sS https://openvibe.video/api/v1/video/playlists \++ -H 'Authorization: Bearer <app token>' \++ -H 'Content-Type: application/json' \++ -d '{"title":"Clipped moments","visibility":"unlisted"}'++```++++A public read (no token) returns the playlist and its items:++++```bash++curl -sS https://openvibe.video/api/v1/video/playlists/<id>++```++++```json++{++ "playlist": {++ "id": "vpl_…", "title": "…", "description": "…", "visibility": "unlisted",++ "owner": { "kind": "user", "username": "kim" },++ "item_count": 2, "created_at": "…", "updated_at": "…"++ },++ "items": [{ "kind": "vod", "id": 101, "position": 0, "added_at": "…" }]++}++```--Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.++`owner` is `{ kind: 'user', username }` for a person, or `{ kind: 'app' | 'agent' | 'service' }` otherwise. Items++are Live ids only; the watch pages resolve their display fields lazily through the cached Live client.## Account export and deletion (ADR-033)@@@ -21,7 +21,7 @@"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)","Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume","OpenVibe.Video step A: public watch pages on openvibe.video (/v/:id, /c/:id, /@:username) reading Live's public API, with WebVTT captions/chapters and no uploads",-- "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session API, the ?list= watch flow, and sign-in on the video host",++ "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session and app API (video.playlist.read / video.playlist.manage), the ?list= watch flow, and sign-in on the video host","Parked domain: openvibe.pics redirects to openvibe.network in nginx; no request reaches the app","Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot","Per-caller and per-address limits; readiness (database required); metrics on loopback"@@@ -32,7 +32,7 @@"Public (signed-out) share links: deliberately not offered while there is no scanning","Previews and thumbnails of files"],-- "capabilitiesRegistered": false,++ "capabilitiesRegistered": true,"serviceManifestRegistered": true,"deployed": true,"placeholderInSites": false,nnew file mode 100644@@@ -0,0 +1,9 @@++-- phase: expand++-- OpenVibe.Video playlists for apps, agents and services (plan T10): a playlist an app, agent or service++-- token makes is a normal playlist, owned by the token's requester (app:app_…, agent:agt_… or service:<slug>)++-- and recorded with the token's project. owner_username is the person's username when a person owns it, so++-- a public page can say who curated it. Nothing here is ever a credential, a key or a token.++++ALTER TABLE video_playlists++ ADD COLUMN project_id text COLLATE "C", -- prj_… when an app/agent/service token made it; NULL for a person++ ADD COLUMN owner_username text; -- the person's username, when a person owns it; NULL otherwise@@@ -45,6 +45,8 @@ const BUDGETS = {'media-hub.upload.complete': { minute: 30, hour: 300 },'media-hub.folder.create': { minute: 20, hour: 120 },'media-hub.share.create': { minute: 20, hour: 200 },++ 'media-hub.playlist.create': { minute: 30, hour: 300 },++ 'media-hub.playlist.write': { minute: 120, hour: 1_200 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;// The product fills this in: one entry per capability its routes name, in openvibe-contracts// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a// route can never be guarded by a capability the service does not declare.--const CAPABILITIES = [];++const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -99,14 +99,20 @@ function createPrincipal({ config, keys }) {return next();}-- /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */-- function require_(cap) {++ /**++ * One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability.++ * The drive guards with its own defaults; the video host passes the video origin and name so its session-cookie++ * writes are judged against openvibe.video.++ */++ function require_(cap, opts = {}) {if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.MediaHub's capabilities`);++ const origin = opts.origin || config.baseUrl;++ const site = opts.site || 'openvibe.download';return function guard(req, res, next) {const p = req.principal;-- if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.download, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.download).', ctx: req.ov });++ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${site}, or send a Network token (a person's, or an app's with this capability for ${site}).`, ctx: req.ov });if (p.kind === 'user') {-- if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.download itself.', ctx: req.ov });++ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, origin)) return http.sendProblem(res, 403, 'request.cross_site', { detail: `A signed-in request that changes something must come from ${site} itself.`, ctx: req.ov });return next();}const decision = capabilities.check(p.claims, cap);@@@ -1,51 +1,49 @@'use strict';/**-- * /api/v1/video/playlists — the playlist API on openvibe.video (step C). It is mounted only on the video host,-- * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. For now it is a browser-session API:-- * the owner is the signed-in person (req.viewer), never an app, agent or service token, and a write made with the-- * session cookie must come from openvibe.video itself (same-origin), exactly as in the drive.++ * /api/v1/video — the playlist API on openvibe.video (step C, plan T10). It is mounted only on the video host,++ * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. The caller is the existing++ * principal (server/http/principal.js): a person's own Network token works like the session, an app, agent or++ * service token needs the capability the route names, and a signed-out browser may read public playlists.*-- * GET /api/v1/video/playlists the person's playlists-- * POST /api/v1/video/playlists make one: { title, description?, visibility? }-- * GET /api/v1/video/playlists/:id one playlist and its items (owner only)-- * PATCH /api/v1/video/playlists/:id rename / describe / change visibility-- * DELETE /api/v1/video/playlists/:id delete it (its items go with it)-- * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id }-- * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] }-- * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one++ * GET /api/v1/video/playlists the caller's own playlists (person, or app with read)++ * POST /api/v1/video/playlists make one: { title, description?, visibility? } (person, or app with manage)++ * GET /api/v1/video/playlists/:id one playlist: the owner sees private too; public/unlisted open to all, private answers 404++ * PATCH /api/v1/video/playlists/:id rename / describe / change visibility (owner; app with manage)++ * DELETE /api/v1/video/playlists/:id delete it (its items go with it) (owner; app with manage)++ * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id } (owner; app with manage)++ * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] } (owner; app with manage)++ * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one (owner; app with manage)++ * GET /api/v1/video/users/:username/playlists a person's public playlists, newest first, paged by cursor*++ * An app owns a playlist under its requester (app:app_…, agent:agt_…, service:<slug>) and records its project.* A refusal is an RFC 9457 problem+json with a stable code, in the repo's own style.*/const express = require('express');++const rateLimit = require('express-rate-limit');const contracts = require('openvibe-contracts');const { asyncRouter } = require('../http/router');--const { sameOrigin } = require('../http/principal');++const { USERNAME_RE } = require('./live-client');function createVideoApiRoutes(ctx) {-- const { config, playlists } = ctx;++ const { config, playlists, principal, limits, identity } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const videoSite = 'openvibe.video';r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));++ r.use(principal.middleware);r.use(express.json({ limit: '64kb' }));-- const ownerOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null);++ const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });++ const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });-- /** A signed-in person; a write by session cookie must be same-origin. */-- function person(req, res, next) {-- const owner = ownerOf(req);-- if (!owner) {-- problem(req, res, 401, 'token.required', 'Sign in to openvibe.video to manage your playlists.');-- return undefined;-- }-- if (req.method !== 'GET' && !sameOrigin(req, config.video.url)) {-- problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.video itself.');-- return undefined;-- }-- req.playlistOwner = owner;-- return next();-- }++ /** The owner string this principal acts as, or null for a signed-out caller. */++ const requesterOf = (req) => {++ const p = req.principal;++ return (p && (p.kind === 'user' || p.kind === 'app') && p.requester) ? p.requester : null;++ };function refused(code) {if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;@@@ -63,60 +61,98 @@ function createVideoApiRoutes(ctx) {updated_at: row.updated_at,});const wireItem = (row) => ({ kind: row.kind, id: Number(row.item_id), position: Number(row.position), added_at: row.added_at });---- r.get('/', person, async (req, res) => {-- const rows = await playlists.list(req.playlistOwner);++ const wireOwner = (row) => {++ const owner = String(row.owner || '');++ if (owner.startsWith('user:')) return { kind: 'user', username: row.owner_username || null };++ const kind = owner.split(':')[0];++ return { kind: kind === 'agent' || kind === 'service' ? kind : 'app' };++ };++++ r.get('/playlists', read, limits.reads('media-hub.playlist.read'), async (req, res) => {++ const rows = await playlists.list(requesterOf(req));++ if (req.principal.kind === 'user' && req.principal.username) await playlists.refreshOwnerUsername(requesterOf(req), req.principal.username);return res.json({ playlists: rows.map(wirePlaylist) });});-- r.post('/', person, async (req, res) => {++ r.post('/playlists', manage, limits.budget('media-hub.playlist.create'), async (req, res) => {const b = req.body || {};-- const out = await playlists.create(req.playlistOwner, { title: b.title, description: b.description, visibility: b.visibility });++ const p = req.principal;++ const out = await playlists.create(requesterOf(req), {++ title: b.title, description: b.description, visibility: b.visibility,++ ownerUsername: p.kind === 'user' ? p.username : null,++ projectId: p.kind === 'app' ? p.project : null,++ });if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);res.set('Location', `/api/v1/video/playlists/${out.playlist.id}`);return res.status(201).json({ playlist: wirePlaylist(out.playlist, 0) });});-- r.get('/:id', person, async (req, res) => {-- const row = await playlists.own(req.playlistOwner, String(req.params.id || ''));++ r.get('/playlists/:id', limits.reads('media-hub.playlist.read'), async (req, res) => {++ const row = await playlists.get(String(req.params.id || ''));if (!row) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');++ const owner = requesterOf(req);++ const isOwner = owner != null && owner === row.owner;++ const isPublic = row.visibility === 'public' || row.visibility === 'unlisted';++++ if (isOwner) {++ const items = await playlists.items(row.id);++ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });++ }++ // A private playlist answers 404 to everyone but its owner: never reveal that it exists.++ if (!isPublic) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');++ if (req.principal.kind === 'app') {++ const decision = contracts.capabilities.check(req.principal.claims, 'video.playlist.read');++ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || 'video.playlist.read not granted');++ }++const items = await playlists.items(row.id);-- return res.json({ playlist: wirePlaylist(row, items.length), items: items.map(wireItem) });++ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });});-- r.patch('/:id', person, async (req, res) => {-- const out = await playlists.update(req.playlistOwner, String(req.params.id || ''), req.body || {});++ r.patch('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {++ const out = await playlists.update(requesterOf(req), String(req.params.id || ''), req.body || {});if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);return res.json({ playlist: wirePlaylist(out.playlist) });});-- r.delete('/:id', person, async (req, res) => {-- const out = await playlists.remove(req.playlistOwner, String(req.params.id || ''));++ r.delete('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {++ const out = await playlists.remove(requesterOf(req), String(req.params.id || ''));if (!out.ok) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');return res.status(204).end();});-- r.post('/:id/items', person, async (req, res) => {++ r.post('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {const b = req.body || {};-- const out = await playlists.addItem(req.playlistOwner, String(req.params.id || ''), { kind: b.kind, id: b.id });++ const out = await playlists.addItem(requesterOf(req), String(req.params.id || ''), { kind: b.kind, id: b.id });if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);res.set('Location', `/api/v1/video/playlists/${req.params.id}/items/${out.item.kind}/${out.item.item_id}`);return res.status(201).json({ item: wireItem(out.item) });});-- r.patch('/:id/items', person, async (req, res) => {++ r.patch('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {const list = Array.isArray(req.body) ? req.body : (req.body && req.body.items);-- const out = await playlists.reorder(req.playlistOwner, String(req.params.id || ''), list);++ const out = await playlists.reorder(requesterOf(req), String(req.params.id || ''), list);if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);return res.json({ items: out.items.map(wireItem) });});-- r.delete('/:id/items/:kind/:itemId', person, async (req, res) => {-- const out = await playlists.removeItem(req.playlistOwner, String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);++ r.delete('/playlists/:id/items/:kind/:itemId', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {++ const out = await playlists.removeItem(requesterOf(req), String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);return res.status(204).end();});++ r.get('/users/:username/playlists', limits.reads('media-hub.playlist.read'), async (req, res) => {++ const username = String(req.params.username || '');++ if (!USERNAME_RE.test(username)) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');++ const subject = await identity.resolveUsername(username);++ if (!subject) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');++ const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 20));++ const cursor = req.query.cursor ? String(req.query.cursor) : null;++ const out = await playlists.publicByOwner(`user:${subject}`, { limit, cursor });++ return res.json({ playlists: out.playlists.map(wirePlaylist), next_cursor: out.next });++ });++return r;}@@@ -299,6 +299,21 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? htfunction createVideoRoutes(ctx) {const { config, log, playlists } = ctx;++++ // A channel page lists that person's public playlists, which are stored by subject: the username is resolved++ // through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most++ // 1,000, so a crawled channel page is not a Network call per visit.++ const subjects = new Map(); // username (lowercase) → { at, ttl, subject }++ async function subjectOf(username) {++ const key = username.toLowerCase();++ const hit = subjects.get(key);++ if (hit && Date.now() - hit.at < hit.ttl) return hit.subject;++ const subject = ctx.identity && ctx.identity.resolveUsername ? await ctx.identity.resolveUsername(username) : null;++ subjects.delete(key);++ subjects.set(key, { at: Date.now(), ttl: subject ? 600_000 : 60_000, subject });++ if (subjects.size > 1000) subjects.delete(subjects.keys().next().value);++ return subject;++ }const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });const r = asyncRouter();@@@ -352,7 +367,7 @@ function createVideoRoutes(ctx) {}));// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──-- r.use('/api/v1/video/playlists', createVideoApiRoutes(ctx));++ r.use('/api/v1/video', createVideoApiRoutes(ctx));// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON// parser, so the two never read each other's bodies.r.use(express.urlencoded({ extended: false, limit: '64kb' }));@@@ -417,6 +432,13 @@ function createVideoRoutes(ctx) {</li>`;}++ /** A public playlist on a channel page: its title and size, no owner controls. */++ function publicPlaylistRow(p) {++ const n = Number(p.item_count) || 0;++ return html`<li class="ovv-playlist-row"><div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a>++<span class="ovv-meta">${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div></li>`;++ }++function playlistError(req, res, out) {const code = out && out.code;const status = code === 'playlist.not_found' || code === 'playlist.item_not_found' ? 404 : (code === 'playlist.item_duplicate' ? 409 : 422);@@@ -645,6 +667,14 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><: [];} catch { clips = []; }++ let lists = [];++ if (pageNum === 1) {++ try {++ const subject = await subjectOf(username);++ if (subject) lists = (await playlists.publicByOwner(`user:${subject}`, { limit: 12 })).playlists;++ } catch (err) { log.warn('[OpenVibe.Video] channel playlists:', (err && err.message) || ''); }++ }++const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;const nextLink = hasMore ? `?page=${pageNum + 1}` : null;@@@ -654,13 +684,15 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><description: `Recordings and clips from @${username} on OpenVibe.Video.`,body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header><section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>--${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}`,++${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}++${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,});});// ── Playlists ──────────────────────────────────────────r.get('/playlists', mustSignIn('/playlists', 'A playlist belongs to your OpenVibe account, so you can keep and share your saved recordings and clips.'), async (req, res) => {const rows = await playlists.list(ownerOf(req));++ if (req.viewer && req.viewer.username) await playlists.refreshOwnerUsername(ownerOf(req), req.viewer.username);return page(req, res, {title: 'Your playlists',crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],@@@ -674,7 +706,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>r.post('/playlists', mustSignIn('/playlists'), async (req, res) => {if (!sameSite(req, res, '/playlists')) return undefined;-- const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private' });++ const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private', ownerUsername: req.viewer && req.viewer.username });if (!out.ok) return playlistError(req, res, out);return res.redirect(303, `/playlists/${out.playlist.id}`);});@@@ -715,7 +747,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>const owner = ownerOf(req);let target = String(b.playlist || '');if (title) {-- const created = await playlists.create(owner, { title });++ const created = await playlists.create(owner, { title, ownerUsername: req.viewer && req.viewer.username });if (!created.ok) return playlistError(req, res, created);target = created.playlist.id;}@@@ -731,6 +763,12 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>const isOwner = signedIn(req) && ownerOf(req) === pl.owner;if (pl.visibility === 'private' && !isOwner) return notFound(req, res);++ const ownerIsPerson = String(pl.owner || '').startsWith('user:');++ const ownerUsername = ownerIsPerson && pl.owner_username && live.isUsername(pl.owner_username) ? pl.owner_username : null;++ const ownerBy = ownerIsPerson++ ? (ownerUsername ? html`<a class="ovv-channel-name" href="/@${ownerUsername}">@${ownerUsername}</a>` : 'an OpenVibe member')++ : 'an app';++const items = await playlists.items(pl.id);const rows = [];for (const it of items) {@@@ -768,6 +806,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}body: html`<header class="ovv-playlist-head"><h1>${pl.title}</h1>${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>++<p class="ovv-meta">Curated by ${ownerBy}</p>${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,@@@ -6,11 +6,12 @@* the card and player details through the existing, cached Live client.** The rules here are shared by the pages (server/video/pages.js) and the API (server/video/api.js) so a form and-- * a POST enforce the same numbers: 100 playlists per person, 500 items per playlist (both from the environment),++ * a POST enforce the same numbers: 100 playlists per owner, 500 items per playlist (both from the environment),* a title of 1-120 characters, a description of at most 2,000, and a visibility of public | unlisted | private.*-- * Nothing here trusts a caller's identity: the caller passes the requester it already established, and this module-- * only ever compares it with what the rows say.++ * A playlist belongs to a person (owner user:usr_…) or, through the API, to an app, agent or service++ * (owner app:app_… / agent:agt_… / service:<slug>). Nothing here trusts a caller's identity: the caller passes++ * the requester it already established, and this module only ever compares it with what the rows say.*/const TITLE_MAX = 120;@@@ -60,9 +61,49 @@ function createPlaylists({ s, config, log = console }) {WHERE p.owner = $1ORDER BY p.updated_at DESC, p.id DESC`, [owner]);++ /**++ * A person's public playlists, newest first, paged by an opaque keyset cursor over (updated_at, id). Unlisted++ * and private playlists never appear here. `limit` is clamped to 1-50 by the caller and again here.++ */++ async function publicByOwner(owner, { limit, cursor = null } = {}) {++ const n = Math.min(50, Math.max(1, Number(limit) || 20));++ let after = null;++ if (cursor) {++ try {++ const parsed = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));++ if (parsed && typeof parsed.updated_at === 'string' && typeof parsed.id === 'string' && PLAYLIST_ID_RE.test(parsed.id)) after = parsed;++ } catch { after = null; }++ }++ const params = [owner];++ let where = "p.owner = $1 AND p.visibility = 'public'";++ if (after) {++ params.push(after.updated_at, after.id);++ where += ` AND (p.updated_at, p.id) < ($${params.length - 1}, $${params.length})`;++ }++ const rows = await s.db.many(++ `SELECT p.*, (SELECT count(*) FROM video_playlist_items i WHERE i.playlist_id = p.id)::int AS item_count++ FROM video_playlists p++ WHERE ${where}++ ORDER BY p.updated_at DESC, p.id DESC++ LIMIT ${n + 1}`, params);++ const hasMore = rows.length > n;++ const page = hasMore ? rows.slice(0, n) : rows;++ const last = page.length ? page[page.length - 1] : null;++ const next = hasMore && last ? Buffer.from(JSON.stringify({ updated_at: last.updated_at, id: last.id })).toString('base64url') : null;++ return { playlists: page, next };++ }++const count = (owner) => s.db.value('SELECT count(*) FROM video_playlists WHERE owner = $1', [owner]).then(Number);-- async function create(owner, { title, description = '', visibility = 'private' }) {++ /** A person's public name on their playlists follows their account: playlists made before the column existed, or++ * before a rename, get the current username when the person next opens their playlists. */++ async function refreshOwnerUsername(owner, username) {++ if (!String(owner || '').startsWith('user:') || !username) return;++ await s.db.query('UPDATE video_playlists SET owner_username = $2 WHERE owner = $1 AND owner_username IS DISTINCT FROM $2',++ [owner, String(username).slice(0, 64)]);++ }++++ async function create(owner, { title, description = '', visibility = 'private', ownerUsername = null, projectId = null }) {const t = cleanTitle(title);const terr = titleError(t);if (terr) return { ok: false, code: 'playlist.invalid_title', detail: terr };@@@ -74,11 +115,16 @@ function createPlaylists({ s, config, log = console }) {return { ok: false, code: 'playlist.limit', detail: `You have ${max()} playlists (the most one person may have). Delete one first.` };}const now = nowIso();-- const row = { id: s.newId('vpl'), owner, title: t, description: d, visibility, created_at: now, updated_at: now };++ const row = {++ id: s.newId('vpl'), owner, title: t, description: d, visibility,++ owner_username: ownerUsername ? String(ownerUsername).slice(0, 64) : null,++ project_id: projectId ? String(projectId) : null,++ created_at: now, updated_at: now,++ };await s.db.query(-- `INSERT INTO video_playlists (id, owner, title, description, visibility, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7)`,-- [row.id, row.owner, row.title, row.description, row.visibility, row.created_at, row.updated_at]);++ `INSERT INTO video_playlists (id, owner, title, description, visibility, owner_username, project_id, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,++ [row.id, row.owner, row.title, row.description, row.visibility, row.owner_username, row.project_id, row.created_at, row.updated_at]);return { ok: true, playlist: row };}@@@ -184,7 +230,7 @@ function createPlaylists({ s, config, log = console }) {return {TITLE_MAX, DESCRIPTION_MAX, VISIBILITIES, KINDS, isItemId, isPlaylistId: (id) => PLAYLIST_ID_RE.test(String(id || '')),-- list, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,++ list, publicByOwner, refreshOwnerUsername, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,};}@@@ -22,7 +22,8 @@ const READ = 'media-hub.api.read';await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [-- 'media-hub.folder.create', 'media-hub.share.create',++ 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',++ 'media-hub.share.create','media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',]);for (const [name, own] of Object.entries(BUDGETS)) {nnew file mode 100644@@@ -0,0 +1,220 @@++'use strict';++/**++ * OpenVibe.Video playlists as a real API (plan T10): Bearer person tokens, app/agent/service capability++ * tokens (video.playlist.read / video.playlist.manage), anonymous public reads and the per-user public list.++ * No Live is started: the API returns item ids (the watch pages resolve the cards lazily), and the one page++ * checked here is an empty playlist.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++function vreq(base, path, { host = 'openvibe.video', method = 'GET', cookie, bearer, json, headers = {}, same = true } = {}) {++ return new Promise((resolve, reject) => {++ const u = new URL(base + path);++ const h = { Host: host, ...headers };++ if (cookie) h.cookie = cookie;++ if (bearer) h.authorization = `Bearer ${bearer}`;++ if (same) h['sec-fetch-site'] = 'same-origin';++ let data;++ if (json !== undefined) {++ data = Buffer.from(JSON.stringify(json));++ h['content-length'] = data.length;++ h['content-type'] = 'application/json';++ }++ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {++ const chunks = [];++ res.on('data', (c) => chunks.push(c));++ res.on('end', () => {++ const buf = Buffer.concat(chunks);++ resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), json() { return JSON.parse(buf.toString('utf8')); } });++ });++ });++ req.on('error', reject);++ if (data) req.write(data);++ req.end();++ });++}++++function api(t, path, { method = 'GET', cookie, bearer, json, same = true } = {}) {++ return vreq(t.base, path, { method, cookie, bearer, json, same });++}++++/** Seed a developer app in the mock Network and mint an app token with the given capabilities. */++function mintApp(t, caps) {++ const appId = ids.newId('app');++ const projectId = ids.newId('project');++ t.network.state.projects.set(projectId, { id: projectId, name: 'project', environment_policy: 'production', allowance: caps, created_at: new Date().toISOString(), archived_at: null });++ t.network.state.apps.set(appId, { id: appId, project_id: projectId, environment: 'production', name: 'app', redirect_uris: [], created_at: new Date().toISOString(), revoked_at: null });++ return t.network.mintApp(appId, 'openvibe.download', caps);++}++++(async () => {++ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });++ const kim = t.network.addUser('kim');++ const sam = t.network.addUser('sam');++ const kimBearer = t.network.userToken(kim);++ const kimCookie = t.signIn(kim);++++ await check('a person Bearer token does playlist CRUD', async () => {++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Bearer list', visibility: 'public' } });++ assert.strictEqual(created.status, 201, created.text);++ const pl = created.json().playlist;++ assert.match(pl.id, /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/);++++ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: kimBearer });++ assert.strictEqual(list.status, 200, list.text);++ assert.ok(list.json().playlists.some((x) => x.id === pl.id));++++ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: kimBearer, json: { title: 'Renamed by token' } });++ assert.strictEqual(renamed.status, 200, renamed.text);++ assert.strictEqual(renamed.json().playlist.title, 'Renamed by token');++++ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: kimBearer });++ assert.strictEqual(gone.status, 204, gone.text);++ });++++ await check('a session write from another origin is refused', async () => {++ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'request.cross_site');++ });++++ await check('an app token without the capability is refused', async () => {++ const token = mintApp(t, []);++ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'nope' } });++ assert.strictEqual(r.status, 403, r.text);++ assert.strictEqual(r.json().code, 'capability.denied');++ });++++ await check('an app with video.playlist.manage manages its own, and a person\'s is 404', async () => {++ const token = mintApp(t, ['video.playlist.manage']);++++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App list', visibility: 'public' } });++ assert.strictEqual(created.status, 201, created.text);++ const pl = created.json().playlist;++++ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: token, json: { title: 'App list edited' } });++ assert.strictEqual(renamed.status, 200, renamed.text);++ assert.strictEqual(renamed.json().playlist.title, 'App list edited');++++ // A person's playlist belongs to the person; an app is not its owner, so it answers 404 (not 403).++ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim\'s own', visibility: 'private' } });++ const pid = persons.json().playlist.id;++ const touch = await api(t, `/api/v1/video/playlists/${pid}`, { method: 'PATCH', bearer: token, json: { title: 'steal' } });++ assert.strictEqual(touch.status, 404, touch.text);++ assert.strictEqual(touch.json().code, 'playlist.not_found');++++ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: token });++ assert.strictEqual(gone.status, 204, gone.text);++ });++++ await check('an app with only video.playlist.read cannot write', async () => {++ const token = mintApp(t, ['video.playlist.read']);++ const write = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'readonly' } });++ assert.strictEqual(write.status, 403, write.text);++ assert.strictEqual(write.json().code, 'capability.denied');++++ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: token });++ assert.strictEqual(list.status, 200, list.text);++ });++++ await check('anonymous reads public and unlisted by id, private answers 404', async () => {++ const pub = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Public for the world', visibility: 'public' } });++ const pubId = pub.json().playlist.id;++ await api(t, `/api/v1/video/playlists/${pubId}/items`, { method: 'POST', bearer: kimBearer, json: { kind: 'vod', id: 101 } });++++ const unlisted = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Unlisted by link', visibility: 'unlisted' } });++ const unlistedId = unlisted.json().playlist.id;++++ const priv = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Private', visibility: 'private' } });++ const privId = priv.json().playlist.id;++++ const publicRead = await api(t, `/api/v1/video/playlists/${pubId}`);++ assert.strictEqual(publicRead.status, 200, publicRead.text);++ const publicBody = publicRead.json();++ assert.strictEqual(publicBody.playlist.visibility, 'public');++ assert.strictEqual(publicBody.playlist.owner.kind, 'user');++ assert.strictEqual(publicBody.playlist.owner.username, 'kim');++ assert.deepStrictEqual(publicBody.items.map((x) => [x.kind, x.id, x.position]), [['vod', 101, 0]]);++++ const unlistedRead = await api(t, `/api/v1/video/playlists/${unlistedId}`);++ assert.strictEqual(unlistedRead.status, 200, unlistedRead.text);++++ const privateRead = await api(t, `/api/v1/video/playlists/${privId}`);++ assert.strictEqual(privateRead.status, 404, privateRead.text);++ assert.strictEqual(privateRead.json().code, 'playlist.not_found');++++ // An app without video.playlist.read is refused a public read, but a private one still answers 404 (never 403).++ const noCap = mintApp(t, []);++ const appPublic = await api(t, `/api/v1/video/playlists/${pubId}`, { bearer: noCap });++ assert.strictEqual(appPublic.status, 403, appPublic.text);++ assert.strictEqual(appPublic.json().code, 'capability.denied');++ const appPrivate = await api(t, `/api/v1/video/playlists/${privId}`, { bearer: noCap });++ assert.strictEqual(appPrivate.status, 404, appPrivate.text);++ assert.strictEqual(appPrivate.json().code, 'playlist.not_found');++ });++++ await check('the per-user public list is paginated and never lists unlisted/private', async () => {++ const pager = t.network.addUser('pager');++ const pagerBearer = t.network.userToken(pager);++ for (const title of ['public-a', 'public-b', 'public-c']) {++ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title, visibility: 'public' } });++ assert.strictEqual(r.status, 201, r.text);++ }++ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-unlisted', visibility: 'unlisted' } });++ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-private', visibility: 'private' } });++++ const page1 = await api(t, '/api/v1/video/users/pager/playlists?limit=2');++ assert.strictEqual(page1.status, 200, page1.text);++ const body1 = page1.json();++ assert.strictEqual(body1.playlists.length, 2);++ assert.ok(body1.next_cursor, 'a second page exists');++ assert.deepStrictEqual(body1.playlists.map((x) => x.title), ['public-c', 'public-b']);++ assert.ok(!body1.playlists.some((x) => x.title.startsWith('hidden-')), 'unlisted/private never listed');++++ const page2 = await api(t, `/api/v1/video/users/pager/playlists?limit=2&cursor=${encodeURIComponent(body1.next_cursor)}`);++ assert.strictEqual(page2.status, 200, page2.text);++ const body2 = page2.json();++ assert.deepStrictEqual(body2.playlists.map((x) => x.title), ['public-a']);++ assert.strictEqual(body2.next_cursor, null);++++ const unknown = await api(t, '/api/v1/video/users/sam/playlists');++ assert.strictEqual(unknown.status, 200, unknown.text);++ assert.strictEqual(unknown.json().playlists.length, 0);++ });++++ await check('an app-owned public playlist page says Curated by an app', async () => {++ const token = mintApp(t, ['video.playlist.manage']);++ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App public list', visibility: 'public' } });++ const id = created.json().playlist.id;++++ const page = await vreq(t.base, `/playlists/${id}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes('Curated by an app'), page.text.slice(0, 600));++ assert.ok(!page.text.includes('href="/@'), 'no username link for an app owner');++++ // A person-owned public playlist attributes the person with a username link.++ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim public', visibility: 'public' } });++ const personId = persons.json().playlist.id;++ const personPage = await vreq(t.base, `/playlists/${personId}`);++ assert.ok(personPage.text.includes('@kim'), personPage.text.slice(0, 600));++ assert.ok(personPage.text.includes('href="/@kim"'), 'a person owner links to their channel');++ });++++ await check('a playlist with no stored username gets the owner\'s when they open their playlists', async () => {++ const made = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Older list', visibility: 'public' } });++ const id = made.json().playlist.id;++ await t.ctx.s.db.query('UPDATE video_playlists SET owner_username = NULL WHERE id = $1', [id]); // as made before the column++ let pg = await vreq(t.base, `/playlists/${id}`);++ assert.ok(pg.text.includes('Curated by an OpenVibe member'), pg.text.slice(0, 600));++ const mine = await vreq(t.base, '/playlists', { cookie: kimCookie });++ assert.strictEqual(mine.status, 200);++ pg = await vreq(t.base, `/playlists/${id}`);++ assert.ok(pg.text.includes('href="/@kim"'), 'the owner\'s visit stored their username');++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -48,6 +48,8 @@ function startLive() {if (clipDetail[1] === '201') return json(200, { clip: publicClip });return json(404, { error: 'not found' });}++ if (url.pathname === '/api/vods') return json(200, { vods: [publicVod], hasMore: false });++ if (url.pathname === '/api/clips') return json(200, { clips: [] });return json(404, { error: 'not found' });});return new Promise((resolve) => {@@@ -186,6 +188,26 @@ const videoEnv = (live) => ({assert.ok(u.text.includes('Unlisted list'), 'the title renders');});++ await check('a channel page lists that person\'s public playlists only', async () => {++ const mk = async (title, visibility) => {++ const r = await api(t.base, '/playlists', { method: 'POST', cookie: kimCookie, form: { title, visibility } });++ assert.strictEqual(r.status, 303, r.text);++ return r.headers.location.split('/').pop();++ };++ const pub = await mk('Kim channel public', 'public');++ await mk('Kim channel unlisted', 'unlisted');++ await mk('Kim channel private', 'private');++ const page = await vreq(t.base, '/@kim');++ assert.strictEqual(page.status, 200, page.text.slice(0, 300));++ assert.ok(page.text.includes('id="h-lists"'), 'a Playlists section');++ assert.ok(page.text.includes(`href="/playlists/${pub}"`) && page.text.includes('Kim channel public'), 'the public playlist');++ assert.ok(!page.text.includes('Kim channel unlisted') && !page.text.includes('Kim channel private'), 'never unlisted or private');++ assert.ok(!page.text.includes('ovv-pl-edit'), 'no owner controls on a channel page');++ const other = await vreq(t.base, '/@sam');++ assert.strictEqual(other.status, 200);++ assert.ok(!other.text.includes('id="h-lists"'), 'no section without public playlists');++ });++await check('a signed-in playlist write is refused without the same-origin signal', async () => {const r = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });assert.strictEqual(r.status, 403, r.text);
@@ -67,9 +67,11 @@ creates none):
@@ -67,9 +67,11 @@ creates none):
The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lists ten capabilities it owns —
The service manifest (openvibe-contracts, `manifests/services/media-hub.json`) lists ten capabilities it owns —
`video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`,
`video.vod.read`, `video.playlist.read`, `video.playlist.manage`, `pics.image.read`, `pics.image.upload`,
-`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create` — but
-no route guards one yet: `CAPABILITIES` in `server/http/principal.js` is empty, and every `/api/v1` route requires a
-signed-in person, never an app, agent or service token.
+`pics.album.read`, `pics.album.manage`, `download.file.read`, `download.file.upload`, `download.share.create`.
+The two playlist capabilities are registered in `server/http/principal.js` and guarded on the video host's API
+(see **Playlist API**): `video.playlist.read` lets an app, agent or service list and read the playlists it owns and
+any public or unlisted playlist by id, and `video.playlist.manage` lets it create, edit, reorder and delete its own.
+Every other `/api/v1` route still requires a signed-in person, never an app, agent or service token.
On other services it calls:
On other services it calls:
@@ -150,13 +152,14 @@ address). The video host is chosen by `Host` header in `server/app.js`, so the d
@@ -150,13 +152,14 @@ address). The video host is chosen by `Host` header in `server/app.js`, so the d
never answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything
never answer there. Only https URLs from Live (player source, thumbnails, the thread link) reach a page; anything
else is dropped.
else is dropped.
-Playlists: 100 per person and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
+Playlists: 100 per owner and 500 items per playlist, from `MEDIAHUB_VIDEO_PLAYLISTS_MAX` and
`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds Live ids only (`vod` or `clip` plus the numeric id); the
`MEDIAHUB_VIDEO_PLAYLIST_ITEMS_MAX`. A playlist holds Live ids only (`vod` or `clip` plus the numeric id); the
watch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longer
watch pages fetch the cards through the cached Live client, and an item Live no longer answers is "No longer
available" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each
available" to the owner and hidden for everyone else. "Play all" starts the first item with `?list=<id>`, and each
-watch page opened with `?list=` shows "Up next" and advances when the video ends.
+watch page opened with `?list=` shows "Up next" and advances when the video ends. A playlist may also be owned by
+an app, agent or service through the API below; its public page attributes it "Curated by an app".
-Not built yet: uploads, and public playlists of a channel.
+A channel page (`/@user`) lists that person's public playlists (newest twelve). Not built yet: uploads.
## Pages
## Pages
@@ -179,18 +182,63 @@ Not built yet: uploads, and public playlists of a channel.
@@ -179,18 +182,63 @@ Not built yet: uploads, and public playlists of a channel.
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /uploads/:id`, `PUT /uploads/:id/parts/:n`, `POST /uploads/:id/complete`, `DELETE /uploads/:id` | resumable uploads |
| `GET /usage` | your usage against the limits |
| `GET /usage` | your usage against the limits |
-On the video host (openvibe.video), the playlist API is browser-session only — the owner is the signed-in person,
-never an app, agent or service token:
+On the video host (openvibe.video), the playlist API accepts a person Bearer token, an app/agent/service token
+with the right capability, or (for public reads) no token — see **Playlist API** below. The drive's `/api/v1`
+routes are unchanged and remain a signed-in person only.
-| Route | |
-|---|---|
-| `GET /api/v1/video/playlists`, `POST /api/v1/video/playlists` | list and create playlists |
-| `GET /api/v1/video/playlists/:id`, `PATCH /api/v1/video/playlists/:id`, `DELETE /api/v1/video/playlists/:id` | one playlist: read, rename/describe/change visibility, delete |
-| `POST /api/v1/video/playlists/:id/items` | add one `{ kind: 'vod'|'clip', id }` |
-| `PATCH /api/v1/video/playlists/:id/items` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
-| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | remove one |
+## Playlist API
+
+The video host's playlist API is `/api/v1/video/*`. The caller is the existing principal
+(`server/http/principal.js`): a person's own Network token as `Authorization: Bearer` works exactly like the
+session, an app/agent/service token needs the capability the route names, and public reads are open to anyone.
+
+| Route | Who | What |
+|---|---|---|
+| `GET /api/v1/video/playlists` | person, or app with `video.playlist.read` | the caller's own playlists, newest first |
+| `POST /api/v1/video/playlists` | person, or app with `video.playlist.manage` | create one from `{ title, description?, visibility? }` |
+| `GET /api/v1/video/playlists/:id` | owner (private too); anyone for public/unlisted; app with `video.playlist.read` for others' public/unlisted | one playlist and its items |
+| `PATCH /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | rename / describe / change visibility |
+| `DELETE /api/v1/video/playlists/:id` | owner, or app with `video.playlist.manage` | delete it (its items go with it) |
+| `POST /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | add one `{ kind: 'vod'|'clip', id }` |
+| `PATCH /api/v1/video/playlists/:id/items` | owner, or app with `video.playlist.manage` | reorder: the full ordered list `{ items: [{ kind, id }, …] }` |
+| `DELETE /api/v1/video/playlists/:id/items/:kind/:itemId` | owner, or app with `video.playlist.manage` | remove one |
+| `GET /api/v1/video/users/:username/playlists` | anyone | that person's public playlists, newest first, `limit` (default 20, max 50) and an opaque `cursor` |
+
+An app/agent/service owns a playlist under its requester (`app:app_…`, `agent:agt_…`, `service:<slug>`) and the
+playlist records its `project_id`. Its Network token is minted for the media hub's audience, `openvibe.download`, as
+for the drive (one service, one audience). A person's session-cookie writes must be same-origin; a person Bearer token needs
+no capability. A private playlist answers `404 playlist.not_found` to anyone but its owner (never 403). Errors are
+RFC 9457 problem+json with a stable code (`token.required`, `capability.denied`, `playlist.not_found`, and the
+playlist validation codes).
+
+Create with an app token:
+
+```bash
+curl -sS https://openvibe.video/api/v1/video/playlists \
+ -H 'Authorization: Bearer <app token>' \
+ -H 'Content-Type: application/json' \
+ -d '{"title":"Clipped moments","visibility":"unlisted"}'
+```
+
+A public read (no token) returns the playlist and its items:
+
+```bash
+curl -sS https://openvibe.video/api/v1/video/playlists/<id>
+```
+
+```json
+{
+ "playlist": {
+ "id": "vpl_…", "title": "…", "description": "…", "visibility": "unlisted",
+ "owner": { "kind": "user", "username": "kim" },
+ "item_count": 2, "created_at": "…", "updated_at": "…"
+ },
+ "items": [{ "kind": "vod", "id": 101, "position": 0, "added_at": "…" }]
+}
+```
-Session-cookie writes must be same-origin. Errors are RFC 9457 problem+json with a stable `code`.
+`owner` is `{ kind: 'user', username }` for a person, or `{ kind: 'app' | 'agent' | 'service' }` otherwise. Items
+are Live ids only; the watch pages resolve their display fields lazily through the cached Live client.
## Account export and deletion (ADR-033)
## Account export and deletion (ADR-033)
@@ -21,7 +21,7 @@
@@ -21,7 +21,7 @@
"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
"Reports: 3 distinct people or one report of illegal content suspend a share; staff restore or remove (Media object deleted)",
"Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",
"Downloads: attachment, nosniff, sandbox CSP, real type only for a safe list, filename* for any script, Range resume",
"OpenVibe.Video step A: public watch pages on openvibe.video (/v/:id, /c/:id, /@:username) reading Live's public API, with WebVTT captions/chapters and no uploads",
"OpenVibe.Video step A: public watch pages on openvibe.video (/v/:id, /c/:id, /@:username) reading Live's public API, with WebVTT captions/chapters and no uploads",
- "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session API, the ?list= watch flow, and sign-in on the video host",
+ "OpenVibe.Video playlists: a signed-in person's named lists of Live recordings and clips (/playlists, /playlists/:id), with a browser-session and app API (video.playlist.read / video.playlist.manage), the ?list= watch flow, and sign-in on the video host",
"Parked domain: openvibe.pics redirects to openvibe.network in nginx; no request reaches the app",
"Parked domain: openvibe.pics redirects to openvibe.network in nginx; no request reaches the app",
"Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",
"Account export and deletion (ADR-033) through openvibe-sdk/account-data; subscriptions created at boot",
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
"Per-caller and per-address limits; readiness (database required); metrics on loopback"
@@ -32,7 +32,7 @@
@@ -32,7 +32,7 @@
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Public (signed-out) share links: deliberately not offered while there is no scanning",
"Previews and thumbnails of files"
"Previews and thumbnails of files"
],
],
- "capabilitiesRegistered": false,
+ "capabilitiesRegistered": true,
"serviceManifestRegistered": true,
"serviceManifestRegistered": true,
"deployed": true,
"deployed": true,
"placeholderInSites": false,
"placeholderInSites": false,
new file mode 100644
new file mode 100644
@@ -0,0 +1,9 @@
@@ -0,0 +1,9 @@
+-- phase: expand
+-- OpenVibe.Video playlists for apps, agents and services (plan T10): a playlist an app, agent or service
+-- token makes is a normal playlist, owned by the token's requester (app:app_…, agent:agt_… or service:<slug>)
+-- and recorded with the token's project. owner_username is the person's username when a person owns it, so
+-- a public page can say who curated it. Nothing here is ever a credential, a key or a token.
+
+ALTER TABLE video_playlists
+ ADD COLUMN project_id text COLLATE "C", -- prj_… when an app/agent/service token made it; NULL for a person
+ ADD COLUMN owner_username text; -- the person's username, when a person owns it; NULL otherwise
@@ -45,6 +45,8 @@ const BUDGETS = {
@@ -45,6 +45,8 @@ const BUDGETS = {
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.upload.complete': { minute: 30, hour: 300 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.folder.create': { minute: 20, hour: 120 },
'media-hub.share.create': { minute: 20, hour: 200 },
'media-hub.share.create': { minute: 20, hour: 200 },
+ 'media-hub.playlist.create': { minute: 30, hour: 300 },
+ 'media-hub.playlist.write': { minute: 120, hour: 1_200 },
};
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -27,7 +27,7 @@ const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// The product fills this in: one entry per capability its routes name, in openvibe-contracts
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// manifests/capabilities/<media-hub>.*. requireCapability('<name>') refuses a name that is not listed here, so a
// route can never be guarded by a capability the service does not declare.
// route can never be guarded by a capability the service does not declare.
-const CAPABILITIES = [];
+const CAPABILITIES = ['video.playlist.read', 'video.playlist.manage'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -99,14 +99,20 @@ function createPrincipal({ config, keys }) {
@@ -99,14 +99,20 @@ function createPrincipal({ config, keys }) {
return next();
return next();
}
}
- /** One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability. */
- function require_(cap) {
+ /**
+ * One route's guard: a person needs nothing more (writes by cookie must be same-origin); an app the capability.
+ * The drive guards with its own defaults; the video host passes the video origin and name so its session-cookie
+ * writes are judged against openvibe.video.
+ */
+ function require_(cap, opts = {}) {
if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.MediaHub's capabilities`);
if (!CAPABILITIES.includes(cap)) throw new Error(`requireCapability(${cap}): not one of OpenVibe.MediaHub's capabilities`);
+ const origin = opts.origin || config.baseUrl;
+ const site = opts.site || 'openvibe.download';
return function guard(req, res, next) {
return function guard(req, res, next) {
const p = req.principal;
const p = req.principal;
- if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at openvibe.download, or send a Network token (a person\'s, or an app\'s with this capability for openvibe.download).', ctx: req.ov });
+ if (p.kind === 'anonymous') return http.sendProblem(res, 401, 'token.required', { detail: `Sign in at ${site}, or send a Network token (a person's, or an app's with this capability for ${site}).`, ctx: req.ov });
if (p.kind === 'user') {
if (p.kind === 'user') {
- if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, config.baseUrl)) return http.sendProblem(res, 403, 'request.cross_site', { detail: 'A signed-in request that changes something must come from openvibe.download itself.', ctx: req.ov });
+ if (p.viaSession && req.method !== 'GET' && !sameOrigin(req, origin)) return http.sendProblem(res, 403, 'request.cross_site', { detail: `A signed-in request that changes something must come from ${site} itself.`, ctx: req.ov });
return next();
return next();
}
}
const decision = capabilities.check(p.claims, cap);
const decision = capabilities.check(p.claims, cap);
@@ -1,51 +1,49 @@
@@ -1,51 +1,49 @@
'use strict';
'use strict';
/**
/**
- * /api/v1/video/playlists — the playlist API on openvibe.video (step C). It is mounted only on the video host,
- * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. For now it is a browser-session API:
- * the owner is the signed-in person (req.viewer), never an app, agent or service token, and a write made with the
- * session cookie must come from openvibe.video itself (same-origin), exactly as in the drive.
+ * /api/v1/video — the playlist API on openvibe.video (step C, plan T10). It is mounted only on the video host,
+ * inside createVideoRoutes, so the drive's /api/v1 stays exactly as it is. The caller is the existing
+ * principal (server/http/principal.js): a person's own Network token works like the session, an app, agent or
+ * service token needs the capability the route names, and a signed-out browser may read public playlists.
*
*
- * GET /api/v1/video/playlists the person's playlists
- * POST /api/v1/video/playlists make one: { title, description?, visibility? }
- * GET /api/v1/video/playlists/:id one playlist and its items (owner only)
- * PATCH /api/v1/video/playlists/:id rename / describe / change visibility
- * DELETE /api/v1/video/playlists/:id delete it (its items go with it)
- * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id }
- * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] }
- * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one
+ * GET /api/v1/video/playlists the caller's own playlists (person, or app with read)
+ * POST /api/v1/video/playlists make one: { title, description?, visibility? } (person, or app with manage)
+ * GET /api/v1/video/playlists/:id one playlist: the owner sees private too; public/unlisted open to all, private answers 404
+ * PATCH /api/v1/video/playlists/:id rename / describe / change visibility (owner; app with manage)
+ * DELETE /api/v1/video/playlists/:id delete it (its items go with it) (owner; app with manage)
+ * POST /api/v1/video/playlists/:id/items add one: { kind: 'vod'|'clip', id } (owner; app with manage)
+ * PATCH /api/v1/video/playlists/:id/items reorder: the full ordered list { items: [{ kind, id }, …] } (owner; app with manage)
+ * DELETE /api/v1/video/playlists/:id/items/:kind/:itemId remove one (owner; app with manage)
+ * GET /api/v1/video/users/:username/playlists a person's public playlists, newest first, paged by cursor
*
*
+ * An app owns a playlist under its requester (app:app_…, agent:agt_…, service:<slug>) and records its project.
* A refusal is an RFC 9457 problem+json with a stable code, in the repo's own style.
* A refusal is an RFC 9457 problem+json with a stable code, in the repo's own style.
*/
*/
const express = require('express');
const express = require('express');
+const rateLimit = require('express-rate-limit');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('../http/router');
const { asyncRouter } = require('../http/router');
-const { sameOrigin } = require('../http/principal');
+const { USERNAME_RE } = require('./live-client');
function createVideoApiRoutes(ctx) {
function createVideoApiRoutes(ctx) {
- const { config, playlists } = ctx;
+ const { config, playlists, principal, limits, identity } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const videoSite = 'openvibe.video';
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.MEDIAHUB_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
+ r.use(principal.middleware);
r.use(express.json({ limit: '64kb' }));
r.use(express.json({ limit: '64kb' }));
- const ownerOf = (req) => (req.viewer && req.viewer.kind === 'user' && req.viewer.subject ? `user:${req.viewer.subject}` : null);
+ const read = principal.requireCapability('video.playlist.read', { origin: config.video.url, site: videoSite });
+ const manage = principal.requireCapability('video.playlist.manage', { origin: config.video.url, site: videoSite });
- /** A signed-in person; a write by session cookie must be same-origin. */
- function person(req, res, next) {
- const owner = ownerOf(req);
- if (!owner) {
- problem(req, res, 401, 'token.required', 'Sign in to openvibe.video to manage your playlists.');
- return undefined;
- }
- if (req.method !== 'GET' && !sameOrigin(req, config.video.url)) {
- problem(req, res, 403, 'request.cross_site', 'A signed-in request that changes something must come from openvibe.video itself.');
- return undefined;
- }
- req.playlistOwner = owner;
- return next();
- }
+ /** The owner string this principal acts as, or null for a signed-out caller. */
+ const requesterOf = (req) => {
+ const p = req.principal;
+ return (p && (p.kind === 'user' || p.kind === 'app') && p.requester) ? p.requester : null;
+ };
function refused(code) {
function refused(code) {
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
if (code === 'playlist.not_found' || code === 'playlist.item_not_found') return 404;
@@ -63,60 +61,98 @@ function createVideoApiRoutes(ctx) {
@@ -63,60 +61,98 @@ function createVideoApiRoutes(ctx) {
updated_at: row.updated_at,
updated_at: row.updated_at,
});
});
const wireItem = (row) => ({ kind: row.kind, id: Number(row.item_id), position: Number(row.position), added_at: row.added_at });
const wireItem = (row) => ({ kind: row.kind, id: Number(row.item_id), position: Number(row.position), added_at: row.added_at });
-
- r.get('/', person, async (req, res) => {
- const rows = await playlists.list(req.playlistOwner);
+ const wireOwner = (row) => {
+ const owner = String(row.owner || '');
+ if (owner.startsWith('user:')) return { kind: 'user', username: row.owner_username || null };
+ const kind = owner.split(':')[0];
+ return { kind: kind === 'agent' || kind === 'service' ? kind : 'app' };
+ };
+
+ r.get('/playlists', read, limits.reads('media-hub.playlist.read'), async (req, res) => {
+ const rows = await playlists.list(requesterOf(req));
+ if (req.principal.kind === 'user' && req.principal.username) await playlists.refreshOwnerUsername(requesterOf(req), req.principal.username);
return res.json({ playlists: rows.map(wirePlaylist) });
return res.json({ playlists: rows.map(wirePlaylist) });
});
});
- r.post('/', person, async (req, res) => {
+ r.post('/playlists', manage, limits.budget('media-hub.playlist.create'), async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const out = await playlists.create(req.playlistOwner, { title: b.title, description: b.description, visibility: b.visibility });
+ const p = req.principal;
+ const out = await playlists.create(requesterOf(req), {
+ title: b.title, description: b.description, visibility: b.visibility,
+ ownerUsername: p.kind === 'user' ? p.username : null,
+ projectId: p.kind === 'app' ? p.project : null,
+ });
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
res.set('Location', `/api/v1/video/playlists/${out.playlist.id}`);
res.set('Location', `/api/v1/video/playlists/${out.playlist.id}`);
return res.status(201).json({ playlist: wirePlaylist(out.playlist, 0) });
return res.status(201).json({ playlist: wirePlaylist(out.playlist, 0) });
});
});
- r.get('/:id', person, async (req, res) => {
- const row = await playlists.own(req.playlistOwner, String(req.params.id || ''));
+ r.get('/playlists/:id', limits.reads('media-hub.playlist.read'), async (req, res) => {
+ const row = await playlists.get(String(req.params.id || ''));
if (!row) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
if (!row) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
+ const owner = requesterOf(req);
+ const isOwner = owner != null && owner === row.owner;
+ const isPublic = row.visibility === 'public' || row.visibility === 'unlisted';
+
+ if (isOwner) {
+ const items = await playlists.items(row.id);
+ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });
+ }
+ // A private playlist answers 404 to everyone but its owner: never reveal that it exists.
+ if (!isPublic) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
+ if (req.principal.kind === 'app') {
+ const decision = contracts.capabilities.check(req.principal.claims, 'video.playlist.read');
+ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || 'video.playlist.read not granted');
+ }
+
const items = await playlists.items(row.id);
const items = await playlists.items(row.id);
- return res.json({ playlist: wirePlaylist(row, items.length), items: items.map(wireItem) });
+ return res.json({ playlist: { ...wirePlaylist(row, items.length), owner: wireOwner(row) }, items: items.map(wireItem) });
});
});
- r.patch('/:id', person, async (req, res) => {
- const out = await playlists.update(req.playlistOwner, String(req.params.id || ''), req.body || {});
+ r.patch('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
+ const out = await playlists.update(requesterOf(req), String(req.params.id || ''), req.body || {});
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
return res.json({ playlist: wirePlaylist(out.playlist) });
return res.json({ playlist: wirePlaylist(out.playlist) });
});
});
- r.delete('/:id', person, async (req, res) => {
- const out = await playlists.remove(req.playlistOwner, String(req.params.id || ''));
+ r.delete('/playlists/:id', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
+ const out = await playlists.remove(requesterOf(req), String(req.params.id || ''));
if (!out.ok) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
if (!out.ok) return problem(req, res, 404, 'playlist.not_found', 'No such playlist.');
return res.status(204).end();
return res.status(204).end();
});
});
- r.post('/:id/items', person, async (req, res) => {
+ r.post('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
const b = req.body || {};
const b = req.body || {};
- const out = await playlists.addItem(req.playlistOwner, String(req.params.id || ''), { kind: b.kind, id: b.id });
+ const out = await playlists.addItem(requesterOf(req), String(req.params.id || ''), { kind: b.kind, id: b.id });
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
res.set('Location', `/api/v1/video/playlists/${req.params.id}/items/${out.item.kind}/${out.item.item_id}`);
res.set('Location', `/api/v1/video/playlists/${req.params.id}/items/${out.item.kind}/${out.item.item_id}`);
return res.status(201).json({ item: wireItem(out.item) });
return res.status(201).json({ item: wireItem(out.item) });
});
});
- r.patch('/:id/items', person, async (req, res) => {
+ r.patch('/playlists/:id/items', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
const list = Array.isArray(req.body) ? req.body : (req.body && req.body.items);
const list = Array.isArray(req.body) ? req.body : (req.body && req.body.items);
- const out = await playlists.reorder(req.playlistOwner, String(req.params.id || ''), list);
+ const out = await playlists.reorder(requesterOf(req), String(req.params.id || ''), list);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
return res.json({ items: out.items.map(wireItem) });
return res.json({ items: out.items.map(wireItem) });
});
});
- r.delete('/:id/items/:kind/:itemId', person, async (req, res) => {
- const out = await playlists.removeItem(req.playlistOwner, String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);
+ r.delete('/playlists/:id/items/:kind/:itemId', manage, limits.budget('media-hub.playlist.write'), async (req, res) => {
+ const out = await playlists.removeItem(requesterOf(req), String(req.params.id || ''), String(req.params.kind || ''), req.params.itemId);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
if (!out.ok) return problem(req, res, refused(out.code), out.code, out.detail);
return res.status(204).end();
return res.status(204).end();
});
});
+ r.get('/users/:username/playlists', limits.reads('media-hub.playlist.read'), async (req, res) => {
+ const username = String(req.params.username || '');
+ if (!USERNAME_RE.test(username)) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');
+ const subject = await identity.resolveUsername(username);
+ if (!subject) return problem(req, res, 404, 'user.not_found', 'No such OpenVibe user.');
+ const limit = Math.min(50, Math.max(1, Number.parseInt(req.query.limit, 10) || 20));
+ const cursor = req.query.cursor ? String(req.query.cursor) : null;
+ const out = await playlists.publicByOwner(`user:${subject}`, { limit, cursor });
+ return res.json({ playlists: out.playlists.map(wirePlaylist), next_cursor: out.next });
+ });
+
return r;
return r;
}
}
@@ -299,6 +299,21 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
@@ -299,6 +299,21 @@ const grid = (cards, empty = 'Nothing here yet.') => (cards && cards.length ? ht
function createVideoRoutes(ctx) {
function createVideoRoutes(ctx) {
const { config, log, playlists } = ctx;
const { config, log, playlists } = ctx;
+
+ // A channel page lists that person's public playlists, which are stored by subject: the username is resolved
+ // through Network (identity.subject.resolve) and remembered for ten minutes (an unknown name for one), at most
+ // 1,000, so a crawled channel page is not a Network call per visit.
+ const subjects = new Map(); // username (lowercase) → { at, ttl, subject }
+ async function subjectOf(username) {
+ const key = username.toLowerCase();
+ const hit = subjects.get(key);
+ if (hit && Date.now() - hit.at < hit.ttl) return hit.subject;
+ const subject = ctx.identity && ctx.identity.resolveUsername ? await ctx.identity.resolveUsername(username) : null;
+ subjects.delete(key);
+ subjects.set(key, { at: Date.now(), ttl: subject ? 600_000 : 60_000, subject });
+ if (subjects.size > 1000) subjects.delete(subjects.keys().next().value);
+ return subject;
+ }
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const live = createLiveClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const chat = createChatClient({ config, fetchImpl: ctx.fetchImpl || globalThis.fetch, log });
const r = asyncRouter();
const r = asyncRouter();
@@ -352,7 +367,7 @@ function createVideoRoutes(ctx) {
@@ -352,7 +367,7 @@ function createVideoRoutes(ctx) {
}));
}));
// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
// ── Playlist API (openvibe.video only; the drive's /api/v1 is untouched) ──
- r.use('/api/v1/video/playlists', createVideoApiRoutes(ctx));
+ r.use('/api/v1/video', createVideoApiRoutes(ctx));
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// The playlist forms are web forms (no JavaScript): urlencoded text. The API router above has its own JSON
// parser, so the two never read each other's bodies.
// parser, so the two never read each other's bodies.
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
@@ -417,6 +432,13 @@ function createVideoRoutes(ctx) {
@@ -417,6 +432,13 @@ function createVideoRoutes(ctx) {
</li>`;
</li>`;
}
}
+ /** A public playlist on a channel page: its title and size, no owner controls. */
+ function publicPlaylistRow(p) {
+ const n = Number(p.item_count) || 0;
+ return html`<li class="ovv-playlist-row"><div class="ovv-playlist-main"><a class="ovv-playlist-title" href="/playlists/${p.id}">${p.title}</a>
+<span class="ovv-meta">${n} item${n === 1 ? '' : 's'} · updated ${dateLabel(p.updated_at)}</span></div></li>`;
+ }
+
function playlistError(req, res, out) {
function playlistError(req, res, out) {
const code = out && out.code;
const code = out && out.code;
const status = code === 'playlist.not_found' || code === 'playlist.item_not_found' ? 404 : (code === 'playlist.item_duplicate' ? 409 : 422);
const status = code === 'playlist.not_found' || code === 'playlist.item_not_found' ? 404 : (code === 'playlist.item_duplicate' ? 409 : 422);
@@ -645,6 +667,14 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
@@ -645,6 +667,14 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
: [];
: [];
} catch { clips = []; }
} catch { clips = []; }
+ let lists = [];
+ if (pageNum === 1) {
+ try {
+ const subject = await subjectOf(username);
+ if (subject) lists = (await playlists.publicByOwner(`user:${subject}`, { limit: 12 })).playlists;
+ } catch (err) { log.warn('[OpenVibe.Video] channel playlists:', (err && err.message) || ''); }
+ }
+
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const prevLink = pageNum > 1 ? `?page=${pageNum - 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
const nextLink = hasMore ? `?page=${pageNum + 1}` : null;
@@ -654,13 +684,15 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
@@ -654,13 +684,15 @@ ${clip.ai_overview ? html`<section class="ovv-panel"><p>${clip.ai_overview}</p><
description: `Recordings and clips from @${username} on OpenVibe.Video.`,
description: `Recordings and clips from @${username} on OpenVibe.Video.`,
body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
body: html`<header class="ovv-channel-head"><span class="ovv-avatar ovv-avatar--big" aria-hidden="true">${username.slice(0, 1).toUpperCase()}</span><div><h1>@${username}</h1><p class="ovv-meta">Recordings and clips from this channel's streams on OpenVibe.Live.</p></div><a class="ovv-btn" href="${livePublic}/@${username}"><i class="fa-solid fa-tower-broadcast" aria-hidden="true"></i> Channel on OpenVibe.Live</a></header>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
<section class="ovv-section" aria-labelledby="h-vods"><h2 id="h-vods">Recordings</h2>${grid(vods.map(vodCard), 'No recordings yet.')}${prevLink || nextLink ? html`<nav class="ovv-pagination" aria-label="Pages">${prevLink ? html`<a href="${prevLink}">← Newer</a>` : ''}${nextLink ? html`<a href="${nextLink}">Older →</a>` : ''}</nav>` : ''}</section>
-${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}`,
+${clips.length ? html`<section class="ovv-section" aria-labelledby="h-clips"><h2 id="h-clips">Clips</h2>${grid(clips.map(clipCard), 'No clips yet.')}</section>` : ''}
+${lists.length ? html`<section class="ovv-section" aria-labelledby="h-lists"><h2 id="h-lists">Playlists</h2><ul class="ovv-playlist-list">${lists.map(publicPlaylistRow)}</ul></section>` : ''}`,
});
});
});
});
// ── Playlists ──────────────────────────────────────────
// ── Playlists ──────────────────────────────────────────
r.get('/playlists', mustSignIn('/playlists', 'A playlist belongs to your OpenVibe account, so you can keep and share your saved recordings and clips.'), async (req, res) => {
r.get('/playlists', mustSignIn('/playlists', 'A playlist belongs to your OpenVibe account, so you can keep and share your saved recordings and clips.'), async (req, res) => {
const rows = await playlists.list(ownerOf(req));
const rows = await playlists.list(ownerOf(req));
+ if (req.viewer && req.viewer.username) await playlists.refreshOwnerUsername(ownerOf(req), req.viewer.username);
return page(req, res, {
return page(req, res, {
title: 'Your playlists',
title: 'Your playlists',
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
crumbs: [{ label: 'Home', href: '/' }, { label: 'Playlists' }],
@@ -674,7 +706,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -674,7 +706,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
r.post('/playlists', mustSignIn('/playlists'), async (req, res) => {
r.post('/playlists', mustSignIn('/playlists'), async (req, res) => {
if (!sameSite(req, res, '/playlists')) return undefined;
if (!sameSite(req, res, '/playlists')) return undefined;
- const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private' });
+ const out = await playlists.create(ownerOf(req), { title: (req.body || {}).title, visibility: (req.body || {}).visibility || 'private', ownerUsername: req.viewer && req.viewer.username });
if (!out.ok) return playlistError(req, res, out);
if (!out.ok) return playlistError(req, res, out);
return res.redirect(303, `/playlists/${out.playlist.id}`);
return res.redirect(303, `/playlists/${out.playlist.id}`);
});
});
@@ -715,7 +747,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -715,7 +747,7 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const owner = ownerOf(req);
const owner = ownerOf(req);
let target = String(b.playlist || '');
let target = String(b.playlist || '');
if (title) {
if (title) {
- const created = await playlists.create(owner, { title });
+ const created = await playlists.create(owner, { title, ownerUsername: req.viewer && req.viewer.username });
if (!created.ok) return playlistError(req, res, created);
if (!created.ok) return playlistError(req, res, created);
target = created.playlist.id;
target = created.playlist.id;
}
}
@@ -731,6 +763,12 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
@@ -731,6 +763,12 @@ ${rows.length ? html`<ul class="ovv-playlist-list">${rows.map(playlistRow)}</ul>
const isOwner = signedIn(req) && ownerOf(req) === pl.owner;
const isOwner = signedIn(req) && ownerOf(req) === pl.owner;
if (pl.visibility === 'private' && !isOwner) return notFound(req, res);
if (pl.visibility === 'private' && !isOwner) return notFound(req, res);
+ const ownerIsPerson = String(pl.owner || '').startsWith('user:');
+ const ownerUsername = ownerIsPerson && pl.owner_username && live.isUsername(pl.owner_username) ? pl.owner_username : null;
+ const ownerBy = ownerIsPerson
+ ? (ownerUsername ? html`<a class="ovv-channel-name" href="/@${ownerUsername}">@${ownerUsername}</a>` : 'an OpenVibe member')
+ : 'an app';
+
const items = await playlists.items(pl.id);
const items = await playlists.items(pl.id);
const rows = [];
const rows = [];
for (const it of items) {
for (const it of items) {
@@ -768,6 +806,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
@@ -768,6 +806,7 @@ ${isOwner ? itemControls(pl.id, it) : ''}
body: html`<header class="ovv-playlist-head"><h1>${pl.title}</h1>
body: html`<header class="ovv-playlist-head"><h1>${pl.title}</h1>
${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}
${pl.description ? html`<p class="ovv-lede">${pl.description}</p>` : ''}
<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>
<p class="ovv-meta">${visibilityLabel(pl.visibility)} · ${items.length} item${items.length === 1 ? '' : 's'} · updated ${dateLabel(pl.updated_at)}</p>
+<p class="ovv-meta">Curated by ${ownerBy}</p>
${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>
${playAllHref ? html`<p><a class="ovv-btn ovv-btn--primary" href="${playAllHref}"><i class="fa-solid fa-play" aria-hidden="true"></i> Play all</a></p>` : ''}</header>
${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}
${items.length ? html`<ol class="ovv-item-list">${rows}</ol>` : html`<p class="muted">This playlist is empty.</p>`}
${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,
${isOwner ? html`<p class="ovv-fine">You can reorder items with the up and down buttons, or remove them. <a href="/playlists">Back to your playlists</a>.</p>` : ''}`,
@@ -6,11 +6,12 @@
@@ -6,11 +6,12 @@
* the card and player details through the existing, cached Live client.
* the card and player details through the existing, cached Live client.
*
*
* The rules here are shared by the pages (server/video/pages.js) and the API (server/video/api.js) so a form and
* The rules here are shared by the pages (server/video/pages.js) and the API (server/video/api.js) so a form and
- * a POST enforce the same numbers: 100 playlists per person, 500 items per playlist (both from the environment),
+ * a POST enforce the same numbers: 100 playlists per owner, 500 items per playlist (both from the environment),
* a title of 1-120 characters, a description of at most 2,000, and a visibility of public | unlisted | private.
* a title of 1-120 characters, a description of at most 2,000, and a visibility of public | unlisted | private.
*
*
- * Nothing here trusts a caller's identity: the caller passes the requester it already established, and this module
- * only ever compares it with what the rows say.
+ * A playlist belongs to a person (owner user:usr_…) or, through the API, to an app, agent or service
+ * (owner app:app_… / agent:agt_… / service:<slug>). Nothing here trusts a caller's identity: the caller passes
+ * the requester it already established, and this module only ever compares it with what the rows say.
*/
*/
const TITLE_MAX = 120;
const TITLE_MAX = 120;
@@ -60,9 +61,49 @@ function createPlaylists({ s, config, log = console }) {
@@ -60,9 +61,49 @@ function createPlaylists({ s, config, log = console }) {
WHERE p.owner = $1
WHERE p.owner = $1
ORDER BY p.updated_at DESC, p.id DESC`, [owner]);
ORDER BY p.updated_at DESC, p.id DESC`, [owner]);
+ /**
+ * A person's public playlists, newest first, paged by an opaque keyset cursor over (updated_at, id). Unlisted
+ * and private playlists never appear here. `limit` is clamped to 1-50 by the caller and again here.
+ */
+ async function publicByOwner(owner, { limit, cursor = null } = {}) {
+ const n = Math.min(50, Math.max(1, Number(limit) || 20));
+ let after = null;
+ if (cursor) {
+ try {
+ const parsed = JSON.parse(Buffer.from(String(cursor), 'base64url').toString('utf8'));
+ if (parsed && typeof parsed.updated_at === 'string' && typeof parsed.id === 'string' && PLAYLIST_ID_RE.test(parsed.id)) after = parsed;
+ } catch { after = null; }
+ }
+ const params = [owner];
+ let where = "p.owner = $1 AND p.visibility = 'public'";
+ if (after) {
+ params.push(after.updated_at, after.id);
+ where += ` AND (p.updated_at, p.id) < ($${params.length - 1}, $${params.length})`;
+ }
+ const rows = await s.db.many(
+ `SELECT p.*, (SELECT count(*) FROM video_playlist_items i WHERE i.playlist_id = p.id)::int AS item_count
+ FROM video_playlists p
+ WHERE ${where}
+ ORDER BY p.updated_at DESC, p.id DESC
+ LIMIT ${n + 1}`, params);
+ const hasMore = rows.length > n;
+ const page = hasMore ? rows.slice(0, n) : rows;
+ const last = page.length ? page[page.length - 1] : null;
+ const next = hasMore && last ? Buffer.from(JSON.stringify({ updated_at: last.updated_at, id: last.id })).toString('base64url') : null;
+ return { playlists: page, next };
+ }
+
const count = (owner) => s.db.value('SELECT count(*) FROM video_playlists WHERE owner = $1', [owner]).then(Number);
const count = (owner) => s.db.value('SELECT count(*) FROM video_playlists WHERE owner = $1', [owner]).then(Number);
- async function create(owner, { title, description = '', visibility = 'private' }) {
+ /** A person's public name on their playlists follows their account: playlists made before the column existed, or
+ * before a rename, get the current username when the person next opens their playlists. */
+ async function refreshOwnerUsername(owner, username) {
+ if (!String(owner || '').startsWith('user:') || !username) return;
+ await s.db.query('UPDATE video_playlists SET owner_username = $2 WHERE owner = $1 AND owner_username IS DISTINCT FROM $2',
+ [owner, String(username).slice(0, 64)]);
+ }
+
+ async function create(owner, { title, description = '', visibility = 'private', ownerUsername = null, projectId = null }) {
const t = cleanTitle(title);
const t = cleanTitle(title);
const terr = titleError(t);
const terr = titleError(t);
if (terr) return { ok: false, code: 'playlist.invalid_title', detail: terr };
if (terr) return { ok: false, code: 'playlist.invalid_title', detail: terr };
@@ -74,11 +115,16 @@ function createPlaylists({ s, config, log = console }) {
@@ -74,11 +115,16 @@ function createPlaylists({ s, config, log = console }) {
return { ok: false, code: 'playlist.limit', detail: `You have ${max()} playlists (the most one person may have). Delete one first.` };
return { ok: false, code: 'playlist.limit', detail: `You have ${max()} playlists (the most one person may have). Delete one first.` };
}
}
const now = nowIso();
const now = nowIso();
- const row = { id: s.newId('vpl'), owner, title: t, description: d, visibility, created_at: now, updated_at: now };
+ const row = {
+ id: s.newId('vpl'), owner, title: t, description: d, visibility,
+ owner_username: ownerUsername ? String(ownerUsername).slice(0, 64) : null,
+ project_id: projectId ? String(projectId) : null,
+ created_at: now, updated_at: now,
+ };
await s.db.query(
await s.db.query(
- `INSERT INTO video_playlists (id, owner, title, description, visibility, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7)`,
- [row.id, row.owner, row.title, row.description, row.visibility, row.created_at, row.updated_at]);
+ `INSERT INTO video_playlists (id, owner, title, description, visibility, owner_username, project_id, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
+ [row.id, row.owner, row.title, row.description, row.visibility, row.owner_username, row.project_id, row.created_at, row.updated_at]);
return { ok: true, playlist: row };
return { ok: true, playlist: row };
}
}
@@ -184,7 +230,7 @@ function createPlaylists({ s, config, log = console }) {
@@ -184,7 +230,7 @@ function createPlaylists({ s, config, log = console }) {
return {
return {
TITLE_MAX, DESCRIPTION_MAX, VISIBILITIES, KINDS, isItemId, isPlaylistId: (id) => PLAYLIST_ID_RE.test(String(id || '')),
TITLE_MAX, DESCRIPTION_MAX, VISIBILITIES, KINDS, isItemId, isPlaylistId: (id) => PLAYLIST_ID_RE.test(String(id || '')),
- list, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,
+ list, publicByOwner, refreshOwnerUsername, get, own, create, update, remove, items, getItem, addItem, removeItem, reorder,
};
};
}
}
@@ -22,7 +22,8 @@ const READ = 'media-hub.api.read';
@@ -22,7 +22,8 @@ const READ = 'media-hub.api.read';
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
await check('BUDGETS names the product\'s own expensive routes, and nothing else', () => {
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
assert.deepStrictEqual(Object.keys(BUDGETS).sort(), [
- 'media-hub.folder.create', 'media-hub.share.create',
+ 'media-hub.folder.create', 'media-hub.playlist.create', 'media-hub.playlist.write',
+ 'media-hub.share.create',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
'media-hub.upload.complete', 'media-hub.upload.part', 'media-hub.upload.start',
]);
]);
for (const [name, own] of Object.entries(BUDGETS)) {
for (const [name, own] of Object.entries(BUDGETS)) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,220 @@
@@ -0,0 +1,220 @@
+'use strict';
+/**
+ * OpenVibe.Video playlists as a real API (plan T10): Bearer person tokens, app/agent/service capability
+ * tokens (video.playlist.read / video.playlist.manage), anonymous public reads and the per-user public list.
+ * No Live is started: the API returns item ids (the watch pages resolve the cards lazily), and the one page
+ * checked here is an empty playlist.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+function vreq(base, path, { host = 'openvibe.video', method = 'GET', cookie, bearer, json, headers = {}, same = true } = {}) {
+ return new Promise((resolve, reject) => {
+ const u = new URL(base + path);
+ const h = { Host: host, ...headers };
+ if (cookie) h.cookie = cookie;
+ if (bearer) h.authorization = `Bearer ${bearer}`;
+ if (same) h['sec-fetch-site'] = 'same-origin';
+ let data;
+ if (json !== undefined) {
+ data = Buffer.from(JSON.stringify(json));
+ h['content-length'] = data.length;
+ h['content-type'] = 'application/json';
+ }
+ const req = http.request({ hostname: u.hostname, port: u.port, path: u.pathname + u.search, method, headers: h }, (res) => {
+ const chunks = [];
+ res.on('data', (c) => chunks.push(c));
+ res.on('end', () => {
+ const buf = Buffer.concat(chunks);
+ resolve({ status: res.statusCode, headers: res.headers, text: buf.toString('utf8'), json() { return JSON.parse(buf.toString('utf8')); } });
+ });
+ });
+ req.on('error', reject);
+ if (data) req.write(data);
+ req.end();
+ });
+}
+
+function api(t, path, { method = 'GET', cookie, bearer, json, same = true } = {}) {
+ return vreq(t.base, path, { method, cookie, bearer, json, same });
+}
+
+/** Seed a developer app in the mock Network and mint an app token with the given capabilities. */
+function mintApp(t, caps) {
+ const appId = ids.newId('app');
+ const projectId = ids.newId('project');
+ t.network.state.projects.set(projectId, { id: projectId, name: 'project', environment_policy: 'production', allowance: caps, created_at: new Date().toISOString(), archived_at: null });
+ t.network.state.apps.set(appId, { id: appId, project_id: projectId, environment: 'production', name: 'app', redirect_uris: [], created_at: new Date().toISOString(), revoked_at: null });
+ return t.network.mintApp(appId, 'openvibe.download', caps);
+}
+
+(async () => {
+ const t = await boot({ env: { MEDIAHUB_VIDEO_URL: 'https://openvibe.video' } });
+ const kim = t.network.addUser('kim');
+ const sam = t.network.addUser('sam');
+ const kimBearer = t.network.userToken(kim);
+ const kimCookie = t.signIn(kim);
+
+ await check('a person Bearer token does playlist CRUD', async () => {
+ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Bearer list', visibility: 'public' } });
+ assert.strictEqual(created.status, 201, created.text);
+ const pl = created.json().playlist;
+ assert.match(pl.id, /^vpl_[0-9A-HJKMNP-TV-Z]{26}$/);
+
+ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: kimBearer });
+ assert.strictEqual(list.status, 200, list.text);
+ assert.ok(list.json().playlists.some((x) => x.id === pl.id));
+
+ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: kimBearer, json: { title: 'Renamed by token' } });
+ assert.strictEqual(renamed.status, 200, renamed.text);
+ assert.strictEqual(renamed.json().playlist.title, 'Renamed by token');
+
+ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: kimBearer });
+ assert.strictEqual(gone.status, 204, gone.text);
+ });
+
+ await check('a session write from another origin is refused', async () => {
+ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'request.cross_site');
+ });
+
+ await check('an app token without the capability is refused', async () => {
+ const token = mintApp(t, []);
+ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'nope' } });
+ assert.strictEqual(r.status, 403, r.text);
+ assert.strictEqual(r.json().code, 'capability.denied');
+ });
+
+ await check('an app with video.playlist.manage manages its own, and a person\'s is 404', async () => {
+ const token = mintApp(t, ['video.playlist.manage']);
+
+ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App list', visibility: 'public' } });
+ assert.strictEqual(created.status, 201, created.text);
+ const pl = created.json().playlist;
+
+ const renamed = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'PATCH', bearer: token, json: { title: 'App list edited' } });
+ assert.strictEqual(renamed.status, 200, renamed.text);
+ assert.strictEqual(renamed.json().playlist.title, 'App list edited');
+
+ // A person's playlist belongs to the person; an app is not its owner, so it answers 404 (not 403).
+ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim\'s own', visibility: 'private' } });
+ const pid = persons.json().playlist.id;
+ const touch = await api(t, `/api/v1/video/playlists/${pid}`, { method: 'PATCH', bearer: token, json: { title: 'steal' } });
+ assert.strictEqual(touch.status, 404, touch.text);
+ assert.strictEqual(touch.json().code, 'playlist.not_found');
+
+ const gone = await api(t, `/api/v1/video/playlists/${pl.id}`, { method: 'DELETE', bearer: token });
+ assert.strictEqual(gone.status, 204, gone.text);
+ });
+
+ await check('an app with only video.playlist.read cannot write', async () => {
+ const token = mintApp(t, ['video.playlist.read']);
+ const write = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'readonly' } });
+ assert.strictEqual(write.status, 403, write.text);
+ assert.strictEqual(write.json().code, 'capability.denied');
+
+ const list = await api(t, '/api/v1/video/playlists', { method: 'GET', bearer: token });
+ assert.strictEqual(list.status, 200, list.text);
+ });
+
+ await check('anonymous reads public and unlisted by id, private answers 404', async () => {
+ const pub = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Public for the world', visibility: 'public' } });
+ const pubId = pub.json().playlist.id;
+ await api(t, `/api/v1/video/playlists/${pubId}/items`, { method: 'POST', bearer: kimBearer, json: { kind: 'vod', id: 101 } });
+
+ const unlisted = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Unlisted by link', visibility: 'unlisted' } });
+ const unlistedId = unlisted.json().playlist.id;
+
+ const priv = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Private', visibility: 'private' } });
+ const privId = priv.json().playlist.id;
+
+ const publicRead = await api(t, `/api/v1/video/playlists/${pubId}`);
+ assert.strictEqual(publicRead.status, 200, publicRead.text);
+ const publicBody = publicRead.json();
+ assert.strictEqual(publicBody.playlist.visibility, 'public');
+ assert.strictEqual(publicBody.playlist.owner.kind, 'user');
+ assert.strictEqual(publicBody.playlist.owner.username, 'kim');
+ assert.deepStrictEqual(publicBody.items.map((x) => [x.kind, x.id, x.position]), [['vod', 101, 0]]);
+
+ const unlistedRead = await api(t, `/api/v1/video/playlists/${unlistedId}`);
+ assert.strictEqual(unlistedRead.status, 200, unlistedRead.text);
+
+ const privateRead = await api(t, `/api/v1/video/playlists/${privId}`);
+ assert.strictEqual(privateRead.status, 404, privateRead.text);
+ assert.strictEqual(privateRead.json().code, 'playlist.not_found');
+
+ // An app without video.playlist.read is refused a public read, but a private one still answers 404 (never 403).
+ const noCap = mintApp(t, []);
+ const appPublic = await api(t, `/api/v1/video/playlists/${pubId}`, { bearer: noCap });
+ assert.strictEqual(appPublic.status, 403, appPublic.text);
+ assert.strictEqual(appPublic.json().code, 'capability.denied');
+ const appPrivate = await api(t, `/api/v1/video/playlists/${privId}`, { bearer: noCap });
+ assert.strictEqual(appPrivate.status, 404, appPrivate.text);
+ assert.strictEqual(appPrivate.json().code, 'playlist.not_found');
+ });
+
+ await check('the per-user public list is paginated and never lists unlisted/private', async () => {
+ const pager = t.network.addUser('pager');
+ const pagerBearer = t.network.userToken(pager);
+ for (const title of ['public-a', 'public-b', 'public-c']) {
+ const r = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title, visibility: 'public' } });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-unlisted', visibility: 'unlisted' } });
+ await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: pagerBearer, json: { title: 'hidden-private', visibility: 'private' } });
+
+ const page1 = await api(t, '/api/v1/video/users/pager/playlists?limit=2');
+ assert.strictEqual(page1.status, 200, page1.text);
+ const body1 = page1.json();
+ assert.strictEqual(body1.playlists.length, 2);
+ assert.ok(body1.next_cursor, 'a second page exists');
+ assert.deepStrictEqual(body1.playlists.map((x) => x.title), ['public-c', 'public-b']);
+ assert.ok(!body1.playlists.some((x) => x.title.startsWith('hidden-')), 'unlisted/private never listed');
+
+ const page2 = await api(t, `/api/v1/video/users/pager/playlists?limit=2&cursor=${encodeURIComponent(body1.next_cursor)}`);
+ assert.strictEqual(page2.status, 200, page2.text);
+ const body2 = page2.json();
+ assert.deepStrictEqual(body2.playlists.map((x) => x.title), ['public-a']);
+ assert.strictEqual(body2.next_cursor, null);
+
+ const unknown = await api(t, '/api/v1/video/users/sam/playlists');
+ assert.strictEqual(unknown.status, 200, unknown.text);
+ assert.strictEqual(unknown.json().playlists.length, 0);
+ });
+
+ await check('an app-owned public playlist page says Curated by an app', async () => {
+ const token = mintApp(t, ['video.playlist.manage']);
+ const created = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: token, json: { title: 'App public list', visibility: 'public' } });
+ const id = created.json().playlist.id;
+
+ const page = await vreq(t.base, `/playlists/${id}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes('Curated by an app'), page.text.slice(0, 600));
+ assert.ok(!page.text.includes('href="/@'), 'no username link for an app owner');
+
+ // A person-owned public playlist attributes the person with a username link.
+ const persons = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Kim public', visibility: 'public' } });
+ const personId = persons.json().playlist.id;
+ const personPage = await vreq(t.base, `/playlists/${personId}`);
+ assert.ok(personPage.text.includes('@kim'), personPage.text.slice(0, 600));
+ assert.ok(personPage.text.includes('href="/@kim"'), 'a person owner links to their channel');
+ });
+
+ await check('a playlist with no stored username gets the owner\'s when they open their playlists', async () => {
+ const made = await api(t, '/api/v1/video/playlists', { method: 'POST', bearer: kimBearer, json: { title: 'Older list', visibility: 'public' } });
+ const id = made.json().playlist.id;
+ await t.ctx.s.db.query('UPDATE video_playlists SET owner_username = NULL WHERE id = $1', [id]); // as made before the column
+ let pg = await vreq(t.base, `/playlists/${id}`);
+ assert.ok(pg.text.includes('Curated by an OpenVibe member'), pg.text.slice(0, 600));
+ const mine = await vreq(t.base, '/playlists', { cookie: kimCookie });
+ assert.strictEqual(mine.status, 200);
+ pg = await vreq(t.base, `/playlists/${id}`);
+ assert.ok(pg.text.includes('href="/@kim"'), 'the owner\'s visit stored their username');
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -48,6 +48,8 @@ function startLive() {
@@ -48,6 +48,8 @@ function startLive() {
if (clipDetail[1] === '201') return json(200, { clip: publicClip });
if (clipDetail[1] === '201') return json(200, { clip: publicClip });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
}
}
+ if (url.pathname === '/api/vods') return json(200, { vods: [publicVod], hasMore: false });
+ if (url.pathname === '/api/clips') return json(200, { clips: [] });
return json(404, { error: 'not found' });
return json(404, { error: 'not found' });
});
});
return new Promise((resolve) => {
return new Promise((resolve) => {
@@ -186,6 +188,26 @@ const videoEnv = (live) => ({
@@ -186,6 +188,26 @@ const videoEnv = (live) => ({
assert.ok(u.text.includes('Unlisted list'), 'the title renders');
assert.ok(u.text.includes('Unlisted list'), 'the title renders');
});
});
+ await check('a channel page lists that person\'s public playlists only', async () => {
+ const mk = async (title, visibility) => {
+ const r = await api(t.base, '/playlists', { method: 'POST', cookie: kimCookie, form: { title, visibility } });
+ assert.strictEqual(r.status, 303, r.text);
+ return r.headers.location.split('/').pop();
+ };
+ const pub = await mk('Kim channel public', 'public');
+ await mk('Kim channel unlisted', 'unlisted');
+ await mk('Kim channel private', 'private');
+ const page = await vreq(t.base, '/@kim');
+ assert.strictEqual(page.status, 200, page.text.slice(0, 300));
+ assert.ok(page.text.includes('id="h-lists"'), 'a Playlists section');
+ assert.ok(page.text.includes(`href="/playlists/${pub}"`) && page.text.includes('Kim channel public'), 'the public playlist');
+ assert.ok(!page.text.includes('Kim channel unlisted') && !page.text.includes('Kim channel private'), 'never unlisted or private');
+ assert.ok(!page.text.includes('ovv-pl-edit'), 'no owner controls on a channel page');
+ const other = await vreq(t.base, '/@sam');
+ assert.strictEqual(other.status, 200);
+ assert.ok(!other.text.includes('id="h-lists"'), 'no section without public playlists');
+ });
+
await check('a signed-in playlist write is refused without the same-origin signal', async () => {
await check('a signed-in playlist write is refused without the same-origin signal', async () => {
const r = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });
const r = await api(t.base, '/api/v1/video/playlists', { method: 'POST', cookie: kimCookie, json: { title: 'csrf' }, same: false });
assert.strictEqual(r.status, 403, r.text);
assert.strictEqual(r.status, 403, r.text);