openvibers/openvibe.wiki Commits

Media attachments across authors and editors: a person attaches an OpenVibe.Media object only if they can read it there (public, unlisted or their own; missing, deleted and other people's private objects get one media.not_readable answer), private objects are never attached because every reader of a page sees its media, and a Media outage attaches nothing (503). wiki_attachment_origins records who attached each object and the visibility checked then; later editors revise, revert and publish without re-attaching. The check marks an object Media made private broken ('forbidden', shown as no longer shared publicly) until Media shares it again. test/media-handoff.test.js: A attaches, B revises and keeps it, B is refused A's private object, Media deletes one and hides the other, the public page shows both states

OP OpenVibers committed 23 Sept 2026 · 39a06486bf · 9 files, +282 −21


Changes

ddiff --git a/README.md b/README.md
iindex 1ab0b47..0ee400d 100644
---- a/README.md
++++ b/README.md
@@@ -43,7 +43,8 @@ The ten authority tables of §15.13, in Wiki's own SQLite database (`WIKI_DB_PAT
The packages add their helper tables under the same prefix (`wiki_page_drafts`, `wiki_page_reviews`,
`wiki_page_attachments`, `wiki_schedule_jobs`, `wiki_discussion_refs`, `wiki_index_revisions`, the
--purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
++purge audit tables); the SDK outbox keeps `wiki_event_outbox`. Wiki's own `wiki_attachment_origins`
++records who attached each Media object and what Media said about it at that moment.
## Does not own
@@@ -83,6 +84,17 @@ purge audit tables); the SDK outbox keeps `wiki_event_outbox`.
- **Media**: attachments by Media object id; a check against Media marks missing or deleted objects
`broken` and the page shows an explicit "no longer available" placeholder instead of an image; an
outage changes nothing (`check_failed`). A periodic check runs when Media is configured.
++- **Media across authors and editors**: Media owns read rights (public and unlisted objects are
++ readable by anyone, a private one only by its owner). A person attaches an object only if they can
++ read it in Media — missing, deleted and other people's private objects get one answer
++ (`media.not_readable`), so an id is never confirmed — and only public or unlisted objects are
++ attached at all (every reader of a page sees its media; Wiki never re-shares a private object under
++ its own authority). The attachment belongs to the page, with who attached it and the Media
++ visibility checked then (`wiki_attachment_origins`); later editors revise, revert and publish
++ without re-attaching and need no rights on it. When Media deletes an object or makes it private,
++ the next check shows "deleted" or "no longer shared publicly" instead of the image, until Media
++ shares it again. With Media unreachable or not configured nothing is attached (503). The model is
++ documented at `attachMedia` in [server/wiki/service.js](server/wiki/service.js).
- **Discussion**: public, published pages get a Community thread (resolved once, stored as a
reference); comments render server-side; signed-in people comment through a form (posted to
Community as that person). Failures show an explicit "could not be loaded" state.
@@@ -208,6 +220,9 @@ Production: `/opt/openvibe.wiki`, env `/etc/openvibe/wiki.env`, unit
- `/feed.atom` is a valid Atom feed with zero entries while nothing is listable (`feeds.test.js`)
- a public page is useful with JavaScript disabled, and so is editing (`nojs.test.js`)
- a deleted or missing Media object renders an explicit broken-asset state (`integrations.test.js`)
++- Media permissions survive the author/editor handoff: an editor keeps the author's attachments,
++ cannot attach an object only the author can read, and an object Media deletes or makes private is
++ shown as broken or withheld on the public page (`media-handoff.test.js`)
- permissions are enforced; visitors without SSO read public content only; AI proposals need a
person's approval (`permissions.test.js`)
- every event is a valid `events.event-envelope@1` and every index document a valid
ddiff --git a/server/db.js b/server/db.js
iindex 513a8f3..eca680b 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -17,6 +17,7 @@
* The Publishing packages add their helper tables under the same prefix (wiki_page_drafts,
* wiki_page_reviews, wiki_page_attachments, wiki_schedule_jobs, wiki_discussion_refs,
* wiki_index_revisions, the purge audit tables) and the SDK outbox keeps wiki_event_outbox.
++ * wiki_attachment_origins records who attached each Media object and the read rights checked then.
*/
const fs = require('fs');
const path = require('path');
@@@ -131,6 +132,20 @@ CREATE TABLE IF NOT EXISTS wiki_ai_proposals (
UNIQUE (page_id, revision)
);
CREATE INDEX IF NOT EXISTS wiki_ai_proposals_status ON wiki_ai_proposals (status, created_at);
++
++-- Who attached a Media object to a page, and what Media said about it at that moment (the read
++-- rights that were checked). One row per attachment, written with it and never changed.
++CREATE TABLE IF NOT EXISTS wiki_attachment_origins (
++ attachment_id INTEGER PRIMARY KEY,
++ page_id TEXT NOT NULL,
++ media_id TEXT NOT NULL,
++ attached_by TEXT NOT NULL,
++ media_owner TEXT,
++ media_visibility TEXT NOT NULL CHECK (media_visibility IN ('public','unlisted','private')),
++ attached_at INTEGER NOT NULL
++);
++CREATE TRIGGER IF NOT EXISTS wiki_attachment_origins_no_update BEFORE UPDATE ON wiki_attachment_origins
++BEGIN SELECT RAISE(ABORT, 'wiki_attachment_origins rows are immutable'); END;
`;
function openDb(dbPath) {
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 93aabc8..a143c47 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -26,7 +26,7 @@
* POST /pages/:id/schedule wiki.revision.publish { revision?, run_at }
* POST /pages/:id/unpublish wiki.revision.publish
* POST /pages/:id/revert wiki.revision.revert { to_revision, expected_revision, message?, publish? }
-- * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? }
++ * POST /pages/:id/media wiki.page.create { media_id, alt?, caption? } a person who can read the object in Media
* POST /pages/:id/media/verify wiki.page.create
* PUT /pages/:id/watch { watching: bool } people only
* POST /proposals wiki.revision.propose { space, page_id?, title?, body, summary?, infobox?, citations?, workflow: { id, run_id, version?, model? }, stub_provider?, expected_revision?, note? }
@@@ -66,6 +66,10 @@ function serializeCitation(c) {
return { id: c.id, revision: c.revision, url: c.url, title: c.title, source_item_id: c.sourceItemId, retrieved_at: c.retrievedAt, quote: c.quote, license_note: c.licenseNote, carried_from: c.carriedFrom, attached_by: c.attachedBy, attached_at: c.attachedAt };
}
++function serializeAttachment(a) {
++ return { id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt, attached_by: a.attachedBy };
++}
++
function serializeTree(nodes, svc, space) {
return nodes.map((n) => ({ ...serializePage(n.page, svc, space), children: serializeTree(n.children, svc, space) }));
}
@@@ -125,7 +129,7 @@ function createApi({ svc, viewers, platform, config, log = console }) {
return {
page: serializePage(page, svc, space), revision: serializeRevision(v.revision),
citations: v.citations.map(serializeCitation), infobox: v.infobox,
-- attachments: v.attachments.map((a) => ({ id: a.id, media_id: a.mediaId, alt: a.alt, caption: a.caption, state: a.state, broken_reason: a.brokenReason, checked_at: a.checkedAt })),
++ attachments: v.attachments.map(serializeAttachment),
links: v.links.map((l) => ({ space: l.target_space, slug: l.target_slug, label: l.label })),
indexability: { indexable: v.decision.indexable, reasons: v.decision.reasons },
discussion_thread: v.discussion ? v.discussion.threadId : null,
@@@ -201,9 +205,10 @@ function createApi({ svc, viewers, platform, config, log = console }) {
const out = svc.revert(req.params.id, { toRevision: int(b.to_revision), expectedRevision: int(b.expected_revision), message: b.message, publish: b.publish }, req.actor);
return { page: serializePage(out.page, svc, svc.spaceById(out.page.space_id)), revision: serializeRevision(out.revision), published: out.published };
}, 201));
-- router.post('/pages/:id/media', guard('wiki.page.create'), R((req) => {
++ router.post('/pages/:id/media', guard('wiki.page.create'), R(async (req) => {
const b = req.body || {};
-- return { attachment: svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor) };
++ const a = await svc.attachMedia(req.params.id, { mediaId: b.media_id, alt: b.alt, caption: b.caption }, req.actor, { describe: platform.media.describe });
++ return { attachment: serializeAttachment(a) };
}, 201));
router.post('/pages/:id/media/verify', guard('wiki.page.create'), R(async (req) => {
const { space } = pageAndSpace(req.params.id);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 572cff2..1efc6c9 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -421,7 +421,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
}
case 'unpublish': svc.unpublish(page.id, req.actor); return back();
case 'move': { const p = svc.movePage(page.id, { slug: b.slug, parentId: b.parent_id || null }, req.actor); return back(p); }
-- case 'attach': svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor); return back();
++ case 'attach': await svc.attachMedia(page.id, { mediaId: b.media_id, alt: b.alt || null, caption: b.caption || null }, req.actor, { describe: platform.media.describe }); return back();
case 'detach': svc.detachMedia(page.id, Number(b.attachment_id), req.actor); return back();
case 'verify': {
const results = await svc.verifyMedia(page.id, platform.media.resolve);
@@@ -433,7 +433,7 @@ function createPages({ svc, viewers, platform, config, log = console }) {
default: throw new svc.WikiError(400, 'request.invalid', 'Unknown action');
}
} catch (err) {
-- if (!err.status || err.status >= 500) throw err;
++ if (!err.status || (err.status >= 500 && err.status !== 503)) throw err;
renderPageSettings(req, res, space, svc.pageById(page.id), err.status, { error: err.message });
}
}));
ddiff --git a/server/integrations/platform.js b/server/integrations/platform.js
iindex 7cd034b..89a16a1 100644
---- a/server/integrations/platform.js
++++ b/server/integrations/platform.js
@@@ -18,6 +18,7 @@ class IntegrationError extends Error {
}
const SUBJECT_RE = /^(usr|gst)_[0-9A-HJKMNP-TV-Z]{26}$/;
++const MEDIA_VISIBILITIES = ['public', 'unlisted', 'private'];
function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = null, now = () => Date.now(), log = console } = {}) {
const tokenClient = tokens || (config.oauth.clientSecret
@@@ -132,12 +133,18 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
},
};
-- // ── Media: does an attached object still exist? (media.object.read, namespace = WIKI_MEDIA_APP) ──
++ // ── Media: attached objects (media.object.read, namespace = WIKI_MEDIA_APP) ──
++ // Wiki reads with its own authority, which sees the whole namespace; who else may read an object
++ // is decided from what Media reports (visibility, owner) — see attachMedia in the wiki service.
const media = {
configured: !!(tokenClient && config.mediaInternalUrl),
publicUrl: (mediaId) => `${config.mediaPublicUrl}/o/${encodeURIComponent(mediaId)}`,
-- /** { exists: true } | { exists: false, reason } | throws (outage: no verdict). */
-- async resolve(mediaId) {
++ /**
++ * { exists: false, reason: 'not_found'|'deleted' }
++ * | { exists: true, visibility: 'public'|'unlisted'|'private'|null, owner: 'usr_…'|null, status }
++ * | throws (outage: no verdict).
++ */
++ async describe(mediaId) {
if (!media.configured) throw new IntegrationError(503, 'media.unavailable', 'OpenVibe.Media is not configured (OV_MEDIA_INTERNAL_URL)');
const r = await getJson(`${config.mediaInternalUrl}/api/v2/${encodeURIComponent(config.mediaApp)}/objects/${encodeURIComponent(mediaId)}`, 'openvibe.media');
if (r.status === 404) return { exists: false, reason: 'not_found' };
@@@ -146,6 +153,20 @@ function createPlatform({ config, db, fetchImpl = globalThis.fetch, tokens = nul
if (r.status !== 200 || !r.body) throw new IntegrationError(503, 'media.unavailable', `Media answered ${r.status}`);
const obj = r.body.object || r.body;
if (obj.lifecycle_status === 'deleted') return { exists: false, reason: 'deleted' };
++ const owner = obj.owner && typeof obj.owner.subject === 'string' && SUBJECT_RE.test(obj.owner.subject) ? obj.owner.subject : null;
++ return { exists: true, visibility: MEDIA_VISIBILITIES.includes(obj.visibility) ? obj.visibility : null, owner, status: obj.lifecycle_status || null };
++ },
++ /**
++ * The periodic and on-demand check: { exists: true } | { exists: false, reason } | throws.
++ * Every reader of a page sees its attachments, so an object that became private in Media is
++ * withheld (broken, reason 'forbidden') until it is shared again; Media not saying who may
++ * read it is no verdict.
++ */
++ async resolve(mediaId) {
++ const d = await media.describe(mediaId);
++ if (!d.exists) return d;
++ if (d.visibility === 'private') return { exists: false, reason: 'forbidden' };
++ if (d.visibility == null) throw new IntegrationError(503, 'media.unavailable', 'Media did not say who may read the object');
return { exists: true };
},
};
ddiff --git a/server/render/views.js b/server/render/views.js
iindex 90ad029..3f44f26 100644
---- a/server/render/views.js
++++ b/server/render/views.js
@@@ -72,9 +72,15 @@ function citationsHtml(cites) {
return html`<section class="wk-sources"><h2>Sources</h2><ol>${cites.map((c) => html`<li id="cite-${String(c.id)}">${c.url ? html`<a href="${c.url}" rel="noopener nofollow">${c.title || c.url}</a>` : html`${c.title || 'Sources item'}`}${c.sourceItemId ? html` <span class="wk-muted">(OpenVibe.Sources item <code>${c.sourceItemId}</code>)</span>` : ''}${c.retrievedAt ? html`, retrieved ${t(c.retrievedAt)}` : html`, <span class="wk-muted">retrieval time unknown</span>`}${c.licenseNote ? html` (${c.licenseNote})` : ''}${c.quote && c.quote.text ? html`<blockquote>${c.quote.text}</blockquote>` : ''}</li>`)}</ol></section>`;
}
++const UNAVAILABLE = {
++ deleted: 'This media was deleted from OpenVibe.Media and is no longer available.',
++ forbidden: 'This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.',
++ not_found: 'This media is no longer available.',
++};
++
function mediaHtml(attachments, urlFor) {
if (!attachments.length) return '';
-- return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: a.brokenReason === 'deleted' ? 'This media was deleted from OpenVibe.Media and is no longer available.' : 'This media is no longer available.' })))}</section>`;
++ return html`<section class="wk-media"><h2>Media</h2>${attachments.map((a) => raw(figureHtml(a, { urlFor, unavailableText: UNAVAILABLE[a.brokenReason] || UNAVAILABLE.not_found })))}</section>`;
}
function discussionHtml(d, { space, page, actor }) {
@@@ -254,7 +260,8 @@ ${page.state === 'published' ? html`<form method="post" action="${base}/settings
<p class="wk-muted">The old address keeps working as a permanent redirect.</p>
<p><button type="submit">Move</button></p></form>
<h2>Media attachments</h2>
--${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}
++<p class="wk-muted">Attach public or unlisted OpenVibe.Media objects you can read; every reader of this page sees them. Attachments stay when someone else edits the page; if Media deletes an object or makes it private, the page shows a placeholder instead.</p>
++${v.attachments.length ? html`<ul>${v.attachments.map((a) => html`<li><code>${a.mediaId}</code> ${a.state}${a.brokenReason ? ` (${a.brokenReason})` : ''}${a.attachedBy ? html` <span class="wk-muted">attached by <code>${a.attachedBy}</code></span>` : ''} <form method="post" action="${base}/settings" class="wk-inline"><input type="hidden" name="op" value="detach"><input type="hidden" name="attachment_id" value="${String(a.id)}"><button type="submit">Detach</button></form></li>`)}</ul>` : html`<p class="wk-muted">No media attached.</p>`}
<form method="post" action="${base}/settings" class="wk-form"><input type="hidden" name="op" value="attach">
<label>OpenVibe.Media object id <input type="text" name="media_id" required placeholder="med_…"></label>
<label>Alt text <input type="text" name="alt" maxlength="1000"></label>
ddiff --git a/server/wiki/service.js b/server/wiki/service.js
iindex 2fd0ba4..d1fabfc 100644
---- a/server/wiki/service.js
++++ b/server/wiki/service.js
@@@ -16,6 +16,7 @@ const seo = require('openvibe-publishing/seo');
const hooks = require('openvibe-publishing/index-hooks');
const authorship = require('openvibe-publishing/authorship');
const ssr = require('openvibe-publishing/ssr');
++const { isMediaId } = require('openvibe-publishing/media');
const { ulid } = require('openvibe-contracts').ids;
const content = require('./content');
const { createAccess, VISIBILITIES } = require('./access');
@@@ -114,6 +115,9 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
allPublishedPublic: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
WHERE p.state = 'published' AND s.deleted_at IS NULL AND s.visibility = 'public' AND p.visibility = 'public'
ORDER BY p.id`),
++ insertOrigin: db.prepare(`INSERT INTO wiki_attachment_origins (attachment_id, page_id, media_id, attached_by, media_owner, media_visibility, attached_at)
++ VALUES (?, ?, ?, ?, ?, ?, ?)`),
++ originsOf: db.prepare('SELECT * FROM wiki_attachment_origins WHERE page_id = ?'),
search: db.prepare(`SELECT p.* FROM wiki_pages p JOIN wiki_spaces s ON s.id = p.space_id
JOIN wiki_page_revisions r ON r.entity_id = p.id AND r.number = p.published_revision
WHERE p.state = 'published' AND s.deleted_at IS NULL AND (@space IS NULL OR s.slug = @space)
@@@ -792,7 +796,7 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
infobox: infoboxOf(page.id, n),
citations: citations.forRevision(page.id, n),
links: q.linksOf.all(page.id, n),
-- attachments: attachments.list(page.id),
++ attachments: svc.attachmentsOf(page.id),
children: q.children.all(page.id).filter((c) => access.canReadPage(space, c, actor)),
parent: page.parent_id ? (() => { const p = q.pageById.get(page.parent_id); return p && access.canReadPage(space, p, actor) ? p : null; })() : null,
backlinks: svc.backlinks(space, page, actor),
@@@ -906,11 +910,65 @@ function createWikiService({ db, stores, outbox, config, community = null, now =
citationsOf(page, revisionNumber) { return citations.forRevision(page.id, Number(revisionNumber)); },
citationHistory(page) { return citations.history(page.id); },
-- attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor) {
++ /**
++ * MEDIA ATTACHMENTS ACROSS AUTHORS AND EDITORS. OpenVibe.Media owns an object's read rights:
++ * public and unlisted objects are readable by anyone, a private one only by its owner (the
++ * rule Media applies to an app acting for one of its users). Wiki reads Media with its own
++ * authority, which sees the whole namespace, so it applies that rule itself:
++ *
++ * 1. Attaching is done by a person (an editor of the space, or a service acting for one), and
++ * the object must be readable by that person: it exists, is not deleted, and is public,
++ * unlisted or theirs. Anything else — missing, deleted, someone else's private object —
++ * gets one answer (media.not_readable), so an id is never confirmed to exist.
++ * 2. A page shows its attachments to every reader of the page, and Wiki never re-shares an
++ * object under its own authority, so a private object is refused even for its owner
++ * (media.private: share it in Media first). Only public and unlisted objects are attached.
++ * 3. The attachment belongs to the page (every revision), with a record of who attached it
++ * and what Media said then (wiki_attachment_origins). Editors who revise, revert or publish
++ * later do not need read rights on existing attachments and never re-attach them: what was
++ * checked is the attacher's grant. A later editor can detach an attachment, and attaches
++ * new objects under rule 1 with their own rights.
++ * 4. Media stays the authority afterwards: the check (verifyMedia, periodic when Media is
++ * configured) marks an object that was deleted broken ('deleted'), and one that is missing
++ * or became private broken ('not_found' / 'forbidden'). A broken attachment renders an
++ * explicit placeholder, never an image; it is shown again when Media shares it again.
++ *
++ * describe(mediaId) is platform.media.describe. A Media outage refuses the attachment (503):
++ * nothing is attached unchecked.
++ */
++ async attachMedia(pageId, { mediaId, alt = null, caption = null, role = 'inline' } = {}, actor, { describe } = {}) {
const page = pageOrFail(pageId);
const space = spaceOrFail(page.space_id);
++ if (page.state === 'deleted') fail(410, 'page.deleted', 'This page was deleted');
if (!access.canEdit(space, actor)) fail(403, 'page.forbidden', 'Only editors of this space can attach media');
-- return wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: String(mediaId || '').trim(), alt, caption, role }));
++ const who = actor && actor.kind === 'system' ? null : requirePerson(actor);
++ const id = String(mediaId || '').trim();
++ if (!isMediaId(id)) fail(400, 'media.invalid_id', 'mediaId must be a Media object id (med_<ULID> or legacy:<app>:<kind>:<id>)');
++ if (typeof describe !== 'function') fail(503, 'media.unavailable', 'Attachments are checked against OpenVibe.Media, which is not configured here: nothing was attached');
++ let obj;
++ try { obj = await describe(id); } catch (err) {
++ fail(503, 'media.unavailable', `Attachments are checked against OpenVibe.Media, which could not answer (${err && err.message}): nothing was attached`);
++ }
++ if (obj && obj.exists && !['public', 'unlisted', 'private'].includes(obj.visibility)) fail(503, 'media.unavailable', 'OpenVibe.Media did not say who may read that object: nothing was attached');
++ const readable = !!(obj && obj.exists && (obj.visibility !== 'private' || (who && obj.owner === who)));
++ if (!readable) fail(422, 'media.not_readable', 'No OpenVibe.Media object you can read has that id');
++ if (obj.visibility === 'private') fail(409, 'media.private', 'That object is private in OpenVibe.Media. Every reader of a wiki page sees its media, so make the object unlisted or public in Media first');
++ if (obj.status && obj.status !== 'ready') fail(409, 'media.not_ready', `That object is ${obj.status} in OpenVibe.Media, not ready yet`);
++ return tx(() => {
++ const att = wrapContentError(() => attachments.attach({ entityId: page.id, mediaId: id, alt, caption, role }));
++ q.insertOrigin.run(att.id, page.id, id, actorId(actor), obj.owner || null, obj.visibility, now());
++ attachments.markAvailable(id);
++ return svc.attachmentsOf(page.id).find((a) => a.id === att.id);
++ });
++ },
++
++ /** A page's attachments, each with who attached it (null for rows older than the record). */
++ attachmentsOf(pageId) {
++ const origins = new Map(q.originsOf.all(pageId).map((o) => [o.attachment_id, o]));
++ return attachments.list(pageId).map((a) => {
++ const o = origins.get(a.id);
++ return { ...a, attachedBy: o ? o.attached_by : null, mediaVisibilityAtAttach: o ? o.media_visibility : null };
++ });
},
detachMedia(pageId, attachmentId, actor) {
ddiff --git a/test/integrations.test.js b/test/integrations.test.js
iindex ce9afd3..ef293a2 100644
---- a/test/integrations.test.js
++++ b/test/integrations.test.js
@@@ -15,6 +15,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
(async () => {
const calls = [];
let sourcesUp = true;
++ // Every object is ready when it is attached; later B goes missing, C is deleted and Media stops answering for D.
++ let mediaLater = false;
++ const mediaState = (id) => (!mediaLater || id === MED('A') ? 'ready' : { [MED('B')]: 'missing', [MED('C')]: 'deleted', [MED('D')]: 'down' }[id] || 'missing');
const thread = { id: 7, visibility: 'public', comment_count: 1 };
const comments = [{ id: 1, thread_id: 7, display_name: 'Bea', message: 'Nice page <b>really</b>', created_at: '2026-09-21T10:00:00.000Z', origin: 'user', deleted: false }];
const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
@@@ -24,10 +27,10 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
calls.push({ url: u.pathname, method: init.method || 'GET', headers, body: init.body ? String(init.body) : null, host: u.host });
if (u.host === 'media.internal') {
const id = u.pathname.split('/').pop();
-- if (id === MED('A')) return json(200, { id, lifecycle_status: 'ready' });
-- if (id === MED('B')) return json(404, { code: 'media.object.not_found' });
-- if (id === MED('C')) return json(200, { id, lifecycle_status: 'deleted' });
-- throw new Error('ECONNREFUSED');
++ const state = mediaState(id);
++ if (state === 'down') throw new Error('ECONNREFUSED');
++ if (state === 'missing') return json(404, { code: 'media.object.not_found' });
++ return json(200, { id, visibility: 'public', owner: { subject: me }, lifecycle_status: state });
}
if (u.host === 'sources.internal') {
if (!sourcesUp) throw new Error('ECONNREFUSED');
@@@ -84,6 +87,7 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
}
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token: tok, body: { media_id: 'https://evil.example/x.png' } });
assert.strictEqual(r.status, 400, 'only Media object ids, never URLs');
++ mediaLater = true;
r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tok, body: {} });
const outcome = Object.fromEntries(r.json.results.map((x) => [x.mediaId, x.outcome + (x.reason ? `:${x.reason}` : '')]));
assert.deepStrictEqual(outcome, { [MED('A')]: 'available', [MED('B')]: 'broken:not_found', [MED('C')]: 'broken:deleted', [MED('D')]: 'check_failed' });
@@@ -93,9 +97,9 @@ const ITEM = 'itm_01J8Z6Q3KX0000000000000001';
assert.ok(html.includes('This media is no longer available.'));
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('B')}"`), 'no image for a broken object');
assert.ok(!html.includes(`src="https://openvibe.media/o/${MED('C')}"`));
-- assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="unverified"`), 'an outage leaves the state alone');
++ assert.ok(html.includes(`data-media-id="${MED('D')}" data-state="available"`), 'an outage leaves the state alone');
const pj = (await H.req(h, 'GET', '/w/mt/cited.json')).json;
-- assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'unverified']);
++ assert.deepStrictEqual(pj.attachments.map((a) => a.state), ['available', 'broken', 'broken', 'available']);
// Community: the thread is resolved once and stored as a reference; comments are Community's.
r = await H.req(h, 'GET', '/w/mt/cited');
ddiff --git a/test/media-handoff.test.js b/test/media-handoff.test.js
nnew file mode 100644
iindex 0000000..70456e5
---- /dev/null
++++ b/test/media-handoff.test.js
@@@ -0,0 +1,136 @@
++'use strict';
++/**
++ * Media permissions survive the author/editor handoff (the model is documented at attachMedia in
++ * server/wiki/service.js):
++ * author A attaches an object A can read → editor B revises, reverts and publishes (the attachment
++ * stays, B needs no rights on it) → B cannot attach an object only A can read (the same answer as
++ * an unknown id), and not even A can attach a private one → Media deletes one object and makes the
++ * other private → the public page shows the explicit broken / withheld state, never the image, and
++ * the image comes back when Media shares the object again.
++ */
++const assert = require('assert');
++const H = require('./helpers');
++
++const MED = (n) => `med_01J8Z6Q3KX0000000000000${String(n).padStart(3, '0')}`;
++
++(async () => {
++ const A = H.subject();
++ const B = H.subject();
++ const C = H.subject();
++ // Media's view of the namespace; tests change it as Media would.
++ const objects = new Map([
++ [MED(1), { visibility: 'public', owner: A, lifecycle_status: 'ready' }], // A's picture
++ [MED(2), { visibility: 'unlisted', owner: C, lifecycle_status: 'ready' }], // someone else's, shared by link
++ [MED(3), { visibility: 'private', owner: A, lifecycle_status: 'ready' }], // only A can read it
++ [MED(4), { visibility: 'public', owner: A, lifecycle_status: 'pending' }], // upload not finished
++ ]);
++ let mediaUp = true;
++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } });
++ const fetchStub = async (url) => {
++ const u = new URL(url);
++ if (u.host !== 'media.internal') return json(404, { error: 'stub: no route' });
++ if (!mediaUp) throw new Error('ECONNREFUSED');
++ const id = decodeURIComponent(u.pathname.split('/').pop());
++ const o = objects.get(id);
++ if (!o) return json(404, { code: 'media.object.not_found' });
++ return json(200, { id, visibility: o.visibility, owner: { subject: o.owner, app: 'wiki', user_id: null }, lifecycle_status: o.lifecycle_status });
++ };
++ const h = await H.boot({ fetch: fetchStub, env: { OV_MEDIA_INTERNAL_URL: 'http://media.internal' } });
++ const tokA = H.userToken({ subject: A });
++ const tokB = H.userToken({ subject: B });
++ const attach = (token, pageId, mediaId, extra = {}) => H.req(h, 'POST', `/api/v1/pages/${pageId}/media`, { token, body: { media_id: mediaId, alt: `alt ${mediaId.slice(-3)}` }, ...extra });
++ const imgOf = (id) => `<img src="https://openvibe.media/o/${id}"`;
++ try {
++ // A's space; B is an editor.
++ let r = await H.req(h, 'POST', '/api/v1/spaces', { token: tokA, body: { name: 'Handoff', slug: 'handoff' } });
++ assert.strictEqual(r.status, 201);
++ assert.strictEqual((await H.req(h, 'PUT', `/api/v1/spaces/handoff/roles/${B}`, { token: tokA, body: { role: 'editor' } })).status, 200);
++ r = await H.req(h, 'POST', '/api/v1/spaces/handoff/pages', { token: tokA, body: { title: 'Gallery', body: H.LONG } });
++ const pageId = r.json.page.id;
++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokA, body: {} });
++
++ // 1. Author A attaches an object A owns and one shared by link; both are checked as A.
++ r = await attach(tokA, pageId, MED(1));
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json.attachment.state, 'available');
++ assert.strictEqual(r.json.attachment.attached_by, A);
++ assert.strictEqual((await attach(tokA, pageId, MED(2))).status, 201, 'unlisted objects are readable by anyone with the id');
++ const origins = h.db.prepare('SELECT media_id, attached_by, media_owner, media_visibility FROM wiki_attachment_origins WHERE page_id = ? ORDER BY attachment_id').all(pageId);
++ assert.deepStrictEqual(origins, [
++ { media_id: MED(1), attached_by: A, media_owner: A, media_visibility: 'public' },
++ { media_id: MED(2), attached_by: A, media_owner: C, media_visibility: 'unlisted' },
++ ]);
++ assert.throws(() => h.db.prepare('UPDATE wiki_attachment_origins SET attached_by = ?').run(B), /immutable/);
++ r = await attach(tokA, pageId, MED(4));
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'media.not_ready');
++
++ // 2. Editor B revises, publishes and reverts: the attachments stay, nothing is re-attached.
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revisions`, { token: tokB, body: { expected_revision: 1, body: `${H.LONG} Edited by B.` } });
++ assert.strictEqual(r.status, 201, r.text);
++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/publish`, { token: tokB, body: {} });
++ let page = await H.req(h, 'GET', '/w/handoff/gallery');
++ assert.ok(page.text.includes('Edited by B.'));
++ assert.ok(page.text.includes(imgOf(MED(1))) && page.text.includes(imgOf(MED(2))), 'the attachments carry over to B\'s revision');
++ r = await H.req(h, 'POST', `/api/v1/pages/${pageId}/revert`, { token: tokB, body: { to_revision: 1, expected_revision: 2 } });
++ assert.strictEqual(r.status, 201, r.text);
++ r = await H.req(h, 'GET', `/api/v1/pages/${pageId}`, { token: tokB });
++ assert.deepStrictEqual(r.json.attachments.map((a) => [a.media_id, a.attached_by, a.state]), [[MED(1), A, 'available'], [MED(2), A, 'available']]);
++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2);
++
++ // 3. B cannot attach an object only A can read — the same answer as an id that does not exist.
++ const refused = await attach(tokB, pageId, MED(3));
++ const unknown = await attach(tokB, pageId, MED(999));
++ assert.strictEqual(refused.status, 422);
++ assert.strictEqual(refused.json.code, 'media.not_readable');
++ assert.deepStrictEqual([unknown.status, unknown.json.code, unknown.json.detail], [refused.status, refused.json.code, refused.json.detail], 'a private id is not confirmed to exist');
++ // …nor through a service acting for B, nor through the page settings form.
++ const svcTok = H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] });
++ r = await attach(svcTok, pageId, MED(3), { headers: { 'X-OV-Subject': B } });
++ assert.strictEqual(r.status, 422);
++ assert.strictEqual(r.json.code, 'media.not_readable');
++ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'attach', media_id: MED(3) } });
++ assert.strictEqual(r.status, 422);
++ assert.ok(r.text.includes('No OpenVibe.Media object you can read has that id'));
++ // Its owner can read it, but a page shows media to every reader: private objects are never attached.
++ r = await attach(tokA, pageId, MED(3));
++ assert.strictEqual(r.status, 409);
++ assert.strictEqual(r.json.code, 'media.private');
++ // No person, no attachment (a service acting as itself has no rights in the space); a Media
++ // outage attaches nothing unchecked.
++ r = await attach(H.serviceToken({ client: 'ai', cap: ['wiki.page.create'] }), pageId, MED(1));
++ assert.strictEqual(r.status, 403);
++ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'service', service: 'svc:ai', subject: null }, { describe: h.platform.media.describe }), (e) => e.status === 403);
++ mediaUp = false;
++ r = await attach(tokA, pageId, MED(1));
++ assert.strictEqual(r.status, 503);
++ mediaUp = true;
++ await assert.rejects(h.svc.attachMedia(pageId, { mediaId: MED(1) }, { kind: 'user', subject: A }, {}), (e) => e.status === 503, 'no Media configured: nothing is attached');
++ assert.strictEqual(h.db.prepare('SELECT COUNT(*) AS n FROM wiki_page_attachments WHERE entity_id = ?').get(pageId).n, 2, 'nothing refused was attached');
++
++ // 4. Media deletes one object and makes the other private: after the check, the public page
++ // shows the explicit states and no image.
++ objects.get(MED(1)).visibility = 'private';
++ objects.get(MED(2)).lifecycle_status = 'deleted';
++ r = await H.req(h, 'POST', '/w/handoff/gallery/settings', { cookie: H.cookieFor(tokB), form: { op: 'verify' } });
++ assert.strictEqual(r.status, 303);
++ assert.ok(decodeURIComponent(r.headers.get('location')).includes('0 available, 2 broken'));
++ page = await H.req(h, 'GET', '/w/handoff/gallery');
++ assert.strictEqual(page.status, 200);
++ assert.ok(!page.text.includes(imgOf(MED(1))) && !page.text.includes(imgOf(MED(2))), 'no image for a withheld or deleted object');
++ assert.ok(page.text.includes('This media is no longer shared publicly in OpenVibe.Media, so it is not shown here.'));
++ assert.ok(page.text.includes('This media was deleted from OpenVibe.Media and is no longer available.'));
++ const pj = (await H.req(h, 'GET', '/w/handoff/gallery.json')).json;
++ assert.deepStrictEqual(pj.attachments.map((a) => [a.state, a.broken_reason]), [['broken', 'forbidden'], ['broken', 'deleted']]);
++
++ // 5. Shared again in Media: the next check shows it again.
++ objects.get(MED(1)).visibility = 'public';
++ await H.req(h, 'POST', `/api/v1/pages/${pageId}/media/verify`, { token: tokA, body: {} });
++ page = await H.req(h, 'GET', '/w/handoff/gallery');
++ assert.ok(page.text.includes(imgOf(MED(1))));
++ assert.ok(!page.text.includes(imgOf(MED(2))));
++ console.log('media handoff ok');
++ } finally {
++ await h.stop();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });