openvibers/openvibe.community Commits

Sharing pilot: pastes shared with specific people, and transfer (#261)

OP OpenVibers committed 7 days ago · 502f25c013 · 30 files, +1544 −164

* Sharing core for pastes: the SDK store, the migrations, the backfill

Pin openvibe-sdk v0.85.0 (its share module), openvibe-contracts v0.291.0
(what that SDK needs) and openvibe-shared v4.114.0 (the Share sheet).

migrations/0015_sharing.sql is openvibe-sdk/share's sharesSchema() verbatim
(shares, share_members, share_transfers, share_access_requests, share_log,
share_deliveries, share_name_limits). migrations/0016_sharing_backfill.sql
registers every existing paste once: public -> public, unlisted -> link,
private -> private, owner = its owner subject; a guest keeps its guest
subject, an ownerless (anonymous or AI) paste belongs to Community itself.

server/sharing.js wires the SDK: Network lookups and notification push,
share.* events through Community's own outbox (events.publish), and the two
hooks -- onAudienceChange keeps pastes.visibility in step (people -> private,
so an old reader fails closed) and drops the paste from Search,
onOwnerChange moves pastes.owner_subject and re-hosts the screenshot's Media
object under the new owner. server/identity/account-data.js exports and erases
a person's share rows and memberships too.

* One canView on every paste read; the gate, the sheet, the private screenshot proxy

Every read asks shares.canViewPaste: the page, raw, download, the JSON API,
comments (a thread on a paste nobody may see is not readable), the lists, the
feed, llms, the sitemap and link previews. A person not on a people list gets
a 404 with OVShare.gate ("Ask for access" signed in, "Sign in" otherwise) and
never the title, content, screenshot or comments; a pending transfer's
recipient previews the paste with OVShare.offer.

Screenshots upload to Media as private objects and are served only by
/p/:slug/screenshot, which checks canView and streams the bytes with
Community's token; no Media URL is put in HTML. New pastes register their
share row in the same transaction (people maps to private in the old column),
and a transfer moves owner_subject and re-hosts the screenshot.

The page's Share button is data-ov-share-sheet (kit.share's contract) and the
uniform /api/v1/paste/:slug/share router is mounted for the sheet. POST
/api/pastes takes audience, members, transfer_to and keep_access, so one call
can share a paste with one person and offer it to them. Burn-after-reading
pastes refuse people. /docs/sharing, /llms.txt and /llms-full.txt carry the
curls; the OpenAPI document has an x-sharing section.

* Tests: every audience on every read path, transfers, the one-call API, media

test/paste-sharing.test.js covers the audience x reader matrix (owner, member,
signed-in non-member, signed out, staff), the gate and its generic preview,
lists/feed/llms/sitemap, the audience change dropping a paste from search,
transfer accept/decline/cancel/expiry with keep_access both ways, the one-call
agent API, burn-after-reading refused, ids-only share events, the private
screenshot proxy and the move script, and the Media object following a
transfer. The backfill is run against seeded rows for all three old values.

The mocks gain Media's /download and /o/:id (private object bytes) and
Network's notification push. Existing tests now expect the checked proxy and
private objects instead of a stored Media redirect.

* Review fixes: no-store on the paste JSON API, and delete the old screenshot

The pastes JSON API answered without Cache-Control, so a people paste's body
could be cached by the edge for the next reader (Cloudflare ignores Vary and
Authorization). Every GET and HEAD under /api/pastes now sends
"private, no-store", including a 404 and a refusal.

A screenshot moved private (by scripts/move-screenshots-private.js or by a
transfer's moveOwner) left the old unlisted Media object in place: anyone who
ever held its URL could still fetch a picture of a paste that is now
people/private. After the row points at the new private object, the old one is
deleted through the Media client (media.object.delete). previous_media_ref is
written to the paste's metadata before the delete, so a failed delete is
reported and retried on the next run; --keep-old is the operator's explicit
opt-out. The script reports moved, deleted, deleteFailed and notDeletable
counts.

Mock Media learns DELETE (capability-checked, failNextDelete) and the tests
cover: the old object gone after a move and after a transfer, --keep-old
keeping it, and a failed delete recorded then retried.

* Transfer keeps a paste's metadata: onOwnerChange reads it before the screenshot moves

moveOwner() merges the media fields into the paste's metadata; without metadata in the row it started from {} and a
transfer wiped the rest (language, summary).

* ssr test: the paste page shares through the one Share sheet, and screenshots go through the checked proxy

The old test pinned the hand-made X link, the Media URL as og:image and a redirect to Media; the pilot replaces all
three on purpose. It now asserts the new rule: no X link, no Media URL in the HTML or a redirect.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 386467d..ba016be 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,19 @@
## Unreleased
++- Sharing reaches pastes (design 2026-11-06, the pilot): `openvibe-sdk` v0.85.0 and `openvibe-shared` v4.114.0, with
++ `openvibe-contracts` v0.291.0. `migrations/0015_sharing.sql` adds openvibe-sdk/share's tables (expand), and
++ `migrations/0016_sharing_backfill.sql` registers every existing paste once (public→public, unlisted→link,
++ private→private, owner = its owner subject; an ownerless paste belongs to Community itself). Every read path asks the
++ one check, `canView`: the page, raw, download, the screenshot proxy (a Media URL is never put in HTML), the JSON API,
++ comments, the lists, the feed, llms, the sitemap and link previews (people/private pages give a generic card and no
++ paste JSON-LD). A person not on the list gets a 404 with `OVShare.gate` ("Ask for access" signed in, "Sign in"
++ otherwise); a pending transfer's recipient previews the paste with `OVShare.offer`. The page's Share button is
++ `data-ov-share-sheet` against the uniform `/api/v1/paste/:slug/share` routes. `POST /api/pastes` takes `audience`,
++ `members` and `transfer_to` (one call makes a paste only two people can open and offers it). Screenshots upload to
++ Media as private objects; `npm run screenshots:private` re-hosts the existing unlisted ones. Burn-after-reading
++ pastes cannot use `people`. `docs/sharing.md` has the curls; `/docs/sharing`, `/llms.txt` and `/llms-full.txt` link it.
++
- Threads carry content labels (L1, labels and choices, 2026-11-02): `migrations/0013_thread_content_labels.sql` adds
`threads.content_labels` (`{ labels, suggested }`, common.content-labels@1). The author or a moderator sets them on the
new-thread form, in a Labels box on the thread, or with `PUT /api/v1/spaces/:space/threads/:slug/labels`. Lists and
ddiff --git a/README.md b/README.md
iindex a965a6e..9367ee3 100644
---- a/README.md
++++ b/README.md
@@@ -41,8 +41,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
--- `openvibe-contracts` v0.112.0, `openvibe-sdk` v0.82.0 (events outbox, per-actor limits, no-looks),
-- `openvibe-shared` v4.114.0, pinned by release tarball
++- `openvibe-contracts` v0.291.0, `openvibe-sdk` v0.85.0 (events outbox, per-actor limits, no-looks, share),
++ `openvibe-shared` v4.114.0 (the Share sheet), pinned by release tarball
## How it fits the network
@@@ -804,6 +804,7 @@ server/
pastes/store.js pure SQL over pastes / versions / likes / projections (comments: comments/store.js)
pastes/source.js where pages read pastes from (the store)
pastes/catalog.js recent public pastes: trending, related, language filter
++ sharing.js openvibe-sdk/share: the one canView, the audience, members, transfers, the gate
comments/ typed comment threads: store (SQL), service (rules), api (/api/v1/comments),
routes (the /c/:accessId page), live-import (Live's old VOD/clip comments)
forum/ spaces/threads/posts: store, service, api (/api/v1/spaces, /posts), routes (pages)
@@@ -850,7 +851,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
--- openvibe-contracts: v0.289.1
--- openvibe-sdk: v0.82.0
++- openvibe-contracts: v0.291.0
++- openvibe-sdk: v0.85.0
- openvibe-shared: v4.114.0
<!-- versions:end -->
ddiff --git a/docs/sharing.md b/docs/sharing.md
nnew file mode 100644
iindex 0000000..297ed39
---- /dev/null
++++ b/docs/sharing.md
@@@ -0,0 +1,70 @@
++# Sharing a paste
++
++A paste has one audience, and its owner can give it away:
++
++| Audience | Who can open it |
++| --- | --- |
++| `public` | Everyone; listed and in search. |
++| `link` | Anyone with the link; not listed, not in search. |
++| `people` | Only the owner and the OpenVibe accounts named on the list, signed in. |
++| `private` | Only the owner. |
++
++The service decides: every page, raw text, download, screenshot, JSON read, comment thread and
++link preview asks the same access check (`canView`). A reader who is not on a `people` list gets a
++404 and a gate ("Ask for access", or "Sign in"), never the title, the content, the screenshot or
++the comments.
++
++## From the page
++
++The paste page's **Share** button opens the shared sheet: Send it (the link, the phone's own share
++sheet, a QR code, the embed) and Who can open it (the four audiences, @names, a transfer). Only the
++owner sees and can change the second part.
++
++## From an agent (bearer token)
++
++Token: a Network user token, or a first-party service token acting as the person (`X-OV-Subject`).
++
++Share one paste with exactly @maticus:
++
++```sh
++curl -X PUT "https://openvibe.community/api/v1/paste/<slug>/share" \
++ -H "Authorization: Bearer $TOKEN" \
++ -H "Content-Type: application/json" \
++ -d '{"audience":"people","members":[{"name":"@maticus","role":"viewer"}]}'
++```
++
++Create a paste only the two of you can open, and offer it to @maticus in the same call:
++
++```sh
++curl -X POST "https://openvibe.community/api/pastes" \
++ -H "Authorization: Bearer $TOKEN" \
++ -H "Content-Type: application/json" \
++ -d '{"title":"LOOI bridge notes","content":"…","audience":"people",
++ "members":["@maticus"],"transfer_to":"@maticus","keep_access":false}'
++```
++
++The answer carries `transfer.id` and `state: "pending"`. The recipient can open the paste to look,
++then answers (the owner may cancel; an offer lapses after 7 days):
++
++```sh
++curl -X POST "https://openvibe.community/api/v1/paste/<slug>/share/transfer/accept" \
++ -H "Authorization: Bearer $MATICUS_TOKEN" -H "Content-Type: application/json" \
++ -d '{"transfer_id":"<uuid>"}'
++```
++
++Accepting makes the recipient the owner; with `keep_access: false` (the default) the old owner's
++access ends then. `decline` changes nothing; `cancel` (the owner) withdraws the offer.
++
++The same routes exist for any kind a page shares (`/api/v1/<kind>/<id>/share`), and
++`GET /api/v1/shared-with-me` lists the pastes where you are a member or the recipient of a pending
++transfer.
++
++## Rules
++
++- Names are exact OpenVibe accounts. An unknown name is refused; there is no username search.
++- People and private pastes are never indexed, listed, put in a sitemap, embedded, or given a link
++ preview with their title, description or picture.
++- Burn-after-reading pastes cannot use `people`: a paste that ends on its first read cannot name
++ several readers.
++- A transfer is bound to the recipient's account, not to a link. Forwarding the notification gives
++ nobody else anything.
ddiff --git a/migrations/0015_sharing.sql b/migrations/0015_sharing.sql
nnew file mode 100644
iindex 0000000..0b40672
---- /dev/null
++++ b/migrations/0015_sharing.sql
@@@ -0,0 +1,56 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, plan track Q): the tables openvibe-sdk/share's sharesSchema() creates.
++-- Kept verbatim from openvibe-sdk v0.85.0 (src/share/schema.js) so a Community database has the
++-- same shape as every other service that adopts it. Expand only: nothing is dropped or rewritten.
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
ddiff --git a/migrations/0016_sharing_backfill.sql b/migrations/0016_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..e3850cf
---- /dev/null
++++ b/migrations/0016_sharing_backfill.sql
@@@ -0,0 +1,29 @@
++-- phase: migrate
++-- The migrate-phase backfill (design 2026-11-06, step 4): every paste that existed before sharing
++-- is registered once, so canView has a row for all of them.
++--
++-- pastes.visibility old audience
++-- public public
++-- unlisted link (the slug is the key)
++-- private private (the owner's alone)
++--
++-- The owner is the paste's owner subject, typed as the identity contract names it: usr_… is a user,
++-- gst_… a guest. A paste with no owner (an anonymous or AI paste) gets Community itself as the
++-- owner: nobody can sign in as that subject, so it can never be transferred or widened by a person,
++-- and its public/link audience is unaffected. Deleted pastes are not registered.
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'community', 'paste', p.slug,
++ CASE
++ WHEN p.owner_subject LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', p.owner_subject)
++ WHEN p.owner_subject LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', p.owner_subject)
++ ELSE jsonb_build_object('type', 'service', 'id', 'community')
++ END,
++ CASE p.visibility
++ WHEN 'public' THEN 'public'
++ WHEN 'unlisted' THEN 'link'
++ ELSE 'private'
++ END
++FROM pastes p
++WHERE p.deleted_at IS NULL
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/package-lock.json b/package-lock.json
iindex c5d867c..4396838 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,8 +17,8 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0"
},
@@@ -1192,9 +1192,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.289.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "integrity": "sha512-LrKTwe8VPCWn+b0MTNmfjAYEJ0rtgLxeM6EHONBDl0ynt/Igndz+ruDEpwuz5SZ9HYCIMkCqSV5ujhedh6zmzQ==",
++ "version": "0.291.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "integrity": "sha512-/jZNmCA6rLPFe2Jt1FJbC8rfELcMMd0fk7466HzoXrsKzGTXQaB/hr2sjnnm407+mcEYtKBcdB6Oisjyjehh1Q==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
@@@ -1208,9 +1208,9 @@
}
},
"node_modules/openvibe-sdk": {
-- "version": "0.82.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
-- "integrity": "sha512-oJarlzFGEkqYY0eFumIRjJUqMpOT70kgbhgeDtA8QHnuMZR3VIGQubM2UlOaqXgkxD5uhdZ/G6jLHbjvUhqTLQ==",
++ "version": "0.85.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
++ "integrity": "sha512-ojwpMfxFdgj5Iz+N1elOMi3LU7p9Oj/q5RUoPR1PKThbavF5Nc2jHAjqapST0cuuatMJphx23tcY9itrlgzwpQ==",
"license": "MIT",
"bin": {
"openvibe-download": "bin/openvibe-download.js"
ddiff --git a/package.json b/package.json
iindex 0a4d67b..31ab19b 100644
---- a/package.json
++++ b/package.json
@@@ -9,6 +9,7 @@
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"n-1:record": "node scripts/n-1-record.js",
++ "screenshots:private": "node scripts/move-screenshots-private.js",
"test:pg": "COMMUNITY_TEST_STORE=pg node test/run.js"
},
"keywords": [
@@@ -28,8 +29,8 @@
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"multer": "^2.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
-- "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.82.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.291.0",
++ "openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.85.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0"
},
ddiff --git a/scripts/move-screenshots-private.js b/scripts/move-screenshots-private.js
nnew file mode 100644
iindex 0000000..f286c0f
---- /dev/null
++++ b/scripts/move-screenshots-private.js
@@@ -0,0 +1,129 @@
++#!/usr/bin/env node
++'use strict';
++/**
++ * Move existing screenshot pastes' Media objects from "unlisted" to "private" (design 2026-11-06,
++ * the Community pilot). New screenshots are uploaded private; this is the one-off for the objects
++ * created before that.
++ *
++ * Media has no route that changes an object's visibility, so a move is a re-host: the bytes are read
++ * from the old object's public URL (its id is unguessable, but the URL was handed out), uploaded
++ * again as a private object owned by the same person, and the paste row is pointed at the new object.
++ * Once the row points at the new object the old one is deleted from Media: leaving it behind would
++ * let anyone who ever held its URL keep reading a screenshot of a paste that is now people/private.
++ * The old id is recorded as `previous_media_ref` before the delete, so a failed delete is retried on
++ * the next run instead of being forgotten.
++ *
++ * npm run screenshots:private -- --limit 50 # a dry run of 50
++ * npm run screenshots:private -- --apply # move every one (batches of 50)
++ * npm run screenshots:private -- --apply --keep-old # move, but leave the old objects (an operator's call)
++ *
++ * Idempotent: a paste whose metadata says media_visibility "private" is skipped, except that a
++ * recorded previous_media_ref is deleted (or retried) on every run.
++ */
++const { createMediaObjects } = require('../server/media/objects');
++const config = require('../server/config');
++
++const MEDIA_RE = /^med_[0-9A-HJKMNP-TV-Z]{26}$/;
++
++function parseMeta(raw) {
++ if (!raw) return {};
++ try { const o = typeof raw === 'string' ? JSON.parse(raw) : raw; return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch { return {}; }
++}
++
++/** The Media v2 id a paste's old screenshot points at (its media_ref, else the id in its URL), or null. */
++function oldObjectId(row) {
++ if (MEDIA_RE.test(String(row.media_ref || ''))) return String(row.media_ref);
++ const m = String(row.screenshot_url || '').match(/\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})/);
++ return m ? m[1] : null;
++}
++
++async function moveScreenshotsPrivate({ db, mediaObjects, fetchImpl = globalThis.fetch, log = console, limit = 50, apply = false, keepOld = false } = {}) {
++ const rows = await db.prepare(`SELECT id, slug, owner_subject, screenshot_url, media_ref, metadata FROM pastes
++ WHERE type = 'screenshot' AND deleted_at IS NULL AND screenshot_url IS NOT NULL
++ ORDER BY id LIMIT ?`).all(limit);
++ const out = { seen: 0, moved: 0, deleted: 0, deleteFailed: 0, notDeletable: 0, skipped: 0, failed: 0, failures: [] };
++
++ const removeOld = async (row, meta, id) => {
++ // previous_media_ref is written before the delete: a crash or a refusal leaves a retryable record.
++ const withRef = { ...meta, previous_media_ref: id };
++ await db.prepare('UPDATE pastes SET metadata = ?, updated_at = ov_now() WHERE id = ?').run(JSON.stringify(withRef), row.id);
++ try {
++ await mediaObjects.deleteObject(id);
++ const cleared = { ...withRef };
++ delete cleared.previous_media_ref;
++ await db.prepare('UPDATE pastes SET metadata = ?, updated_at = ov_now() WHERE id = ?').run(JSON.stringify(cleared), row.id);
++ out.deleted++;
++ log.log(`deleted old object ${id} of ${row.slug}`);
++ return true;
++ } catch (err) {
++ out.deleteFailed++;
++ out.failures.push({ slug: row.slug, object: id, error: `delete: ${err.message}` });
++ log.warn(`old object ${id} of ${row.slug} not deleted (retried next run): ${err.message}`);
++ return false;
++ }
++ };
++
++ for (const row of rows) {
++ out.seen++;
++ const meta = parseMeta(row.metadata);
++ const old = oldObjectId(row);
++ // Already private: only a pending old-object delete remains.
++ if (meta.media_visibility === 'private') {
++ if (meta.previous_media_ref && !keepOld) {
++ if (!apply) { out.skipped++; continue; }
++ if (!MEDIA_RE.test(String(meta.previous_media_ref))) { out.notDeletable++; continue; }
++ await removeOld(row, meta, String(meta.previous_media_ref));
++ } else out.skipped++;
++ continue;
++ }
++ if (!apply) { out.skipped++; continue; }
++ try {
++ const res = await fetchImpl(row.screenshot_url, { signal: AbortSignal.timeout(30_000) });
++ if (!res.ok) throw new Error(`read ${res.status}`);
++ const bytes = Buffer.from(await res.arrayBuffer());
++ const mime = meta.mime_type || res.headers.get('content-type') || 'application/octet-stream';
++ const owner = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(row.owner_subject || '')) ? row.owner_subject : null;
++ const stored = await mediaObjects.uploadImage({
++ buffer: bytes, mime, filename: meta.original_name || `${row.slug}.${mime.split('/')[1] || 'bin'}`,
++ owner, kind: 'screenshot', source: 'community.paste', visibility: 'private',
++ });
++ const next = { ...meta, media_visibility: 'private', size_bytes: bytes.length, mime_type: mime };
++ await db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, metadata = ?, updated_at = ov_now() WHERE id = ?')
++ .run(stored.url, stored.id, JSON.stringify(next), row.id);
++ out.moved++;
++ log.log(`moved ${row.slug} → ${stored.id}`);
++ if (!keepOld) {
++ if (old) await removeOld(row, next, old);
++ // A legacy:community:file object has no v2 id to delete; it is left in place and said so.
++ else { out.notDeletable++; log.warn(`${row.slug}: the old screenshot is not a Media v2 object; left in place`); }
++ }
++ } catch (err) {
++ out.failed++;
++ out.failures.push({ slug: row.slug, error: err.message });
++ log.warn(`could not move ${row.slug}: ${err.message}`);
++ }
++ }
++ return out;
++}
++
++async function main(argv = process.argv.slice(2)) {
++ const apply = argv.includes('--apply');
++ const keepOld = argv.includes('--keep-old');
++ const i = argv.indexOf('--limit');
++ const limit = i >= 0 ? Math.max(1, parseInt(argv[i + 1], 10) || 50) : (apply ? 500 : 50);
++ const { openDb } = require('../server/db');
++ const db = await openDb(config);
++ try {
++ const mediaObjects = createMediaObjects({ config });
++ if (!mediaObjects.configured) throw new Error('Media needs Community\'s service principal (OV_OAUTH_CLIENT_SECRET)');
++ const out = await moveScreenshotsPrivate({ db, mediaObjects, limit, apply, keepOld });
++ console.log(JSON.stringify(out, null, 2));
++ if (!apply) console.log('dry run: pass --apply to move them');
++ } finally {
++ await db.close();
++ }
++}
++
++if (require.main === module) main().catch((err) => { console.error(err.message || err); process.exitCode = 1; });
++
++module.exports = { moveScreenshotsPrivate, oldObjectId };
ddiff --git a/server/app.js b/server/app.js
iindex ae3d612..9f1c3f6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -43,6 +43,7 @@ const { createNetworkIdentity } = require('./identity/network');
const { createViewerResolver, ANONYMOUS } = require('./identity/viewer');
const v1 = require('./http/v1');
const { createCommentService } = require('./comments/service');
++const { createSharing, describeRef } = require('./sharing');
const { createCommentsApi } = require('./comments/api');
const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
@@@ -63,6 +64,7 @@ const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
++const { createPasteService } = require('./pastes/service');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
@@@ -171,32 +173,77 @@ async function createApp(opts = {}) {
// services ping the engines when a public, indexable page appears, changes or goes away.
const indexnow = opts.indexnow || createIndexNow({ host: config.baseUrl, key: config.indexnow.key, ...(opts.fetchImpl ? { fetch: opts.fetchImpl } : {}) });
const forum = createForumService({ db, network, pulse, relay, vip, media: mediaObjects, chatRooms, limits: opts.forumLimits, config, indexnow });
-- const comments = createCommentService({ db, network, limits: opts.commentLimits });
++ // Screenshot bytes: OpenVibe.Media's Object API v2 (a private med_ object owned by the person when
++ // signed in), or the v1 community file store (legacy:community:file:<key>) without Community's
++ // service principal. A private object is reachable only with Community's token, so every read goes
++ // through the page's proxy (canView) and the Media URL is never put in HTML.
++ const { createMediaFiles } = require('./media/files');
++ const mediaFiles = createMediaFiles({ config });
++ const pasteMedia = opts.media || {
++ tokens: mediaFiles.tokens,
++ async upload({ buffer, filename, mime, owner = null, visibility = 'private' }) {
++ if (!mediaObjects.configured) return await mediaFiles.upload({ buffer, filename, mime });
++ const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste', visibility });
++ return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id, private: visibility === 'private' };
++ },
++ /** A paste's screenshot bytes, for the proxy (a private object through a signed download). */
++ async bytes(row) {
++ const ref = String(row.media_ref || '');
++ const id = /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(ref) ? ref : (String(row.screenshot_url || '').match(/\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1];
++ if (id && mediaObjects.configured) {
++ try {
++ const url = await mediaObjects.downloadUrl(id);
++ const res = await (opts.fetchImpl || globalThis.fetch)(url, { signal: AbortSignal.timeout(30_000) });
++ if (res.ok) return { body: Buffer.from(await res.arrayBuffer()), type: res.headers.get('content-type') || 'application/octet-stream' };
++ } catch { /* an unlisted object still answers its stored URL below */ }
++ }
++ if (!row.screenshot_url) return null;
++ const res = await (opts.fetchImpl || globalThis.fetch)(row.screenshot_url, { signal: AbortSignal.timeout(30_000) });
++ if (!res.ok) return null;
++ return { body: Buffer.from(await res.arrayBuffer()), type: res.headers.get('content-type') || 'application/octet-stream' };
++ },
++ /**
++ * The screenshot follows a transfer: re-host the same bytes privately, owned by the new owner,
++ * then delete the old object. previous_media_ref is recorded before the delete, so a failure
++ * is retried by scripts/move-screenshots-private.js instead of leaving the old URL readable.
++ */
++ async moveOwner(row, owner) {
++ if (!owner || !mediaObjects.configured) return false;
++ const got = await this.bytes(row);
++ if (!got) return false;
++ const old = /^med_[0-9A-HJKMNP-TV-Z]{26}$/.test(String(row.media_ref || '')) ? String(row.media_ref)
++ : (String(row.screenshot_url || '').match(/\/o\/(med_[0-9A-HJKMNP-TV-Z]{26})/) || [])[1] || null;
++ const meta = (() => { try { const o = JSON.parse(row.metadata || '{}'); return o && typeof o === 'object' && !Array.isArray(o) ? o : {}; } catch { return {}; } })();
++ const stored = await mediaObjects.uploadImage({ buffer: got.body, mime: got.type, filename: `${row.slug}-owned.png`, owner, kind: 'screenshot', source: 'community.paste', visibility: 'private' });
++ const next = { ...meta, media_visibility: 'private', ...(old ? { previous_media_ref: old } : {}) };
++ await db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, metadata = ?, updated_at = ov_now() WHERE slug = ?')
++ .run(stored.url, stored.id, JSON.stringify(next), row.slug);
++ if (old) {
++ try {
++ await mediaObjects.deleteObject(old);
++ delete next.previous_media_ref;
++ await db.prepare('UPDATE pastes SET metadata = ?, updated_at = ov_now() WHERE slug = ?').run(JSON.stringify(next), row.slug);
++ } catch (err) { console.warn(`[Sharing] old screenshot ${old} not deleted (the move script retries): ${err.message}`); }
++ }
++ return true;
++ },
++ };
++ // Sharing (openvibe-sdk/share): the one access check for a paste, its members, transfers and the
++ // gate. Created before comments so a thread on a paste nobody may see is never readable.
++ const sharing = opts.sharing || createSharing({ db, media: pasteMedia, fetchImpl: opts.fetchImpl });
++ const comments = createCommentService({ db, network, limits: opts.commentLimits, canViewPaste: (v, p) => sharing.canViewPaste(v, p) });
// Submissions (clips, art, ideas, reports for review): their own table, not tied to the forum.
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
discovery.useForum(forum);
-- Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow });
++ Object.assign(app.locals, { db, network, pulse, relay, relayInbound, vip, forum, comments, submissions, indexnow, sharing });
if (opts.startRelay !== false) relay.start();
// ── Pastes: Community's own store is the only authority ──
{
-- const { createMediaFiles } = require('./media/files');
-- const { createPasteService } = require('./pastes/service');
-- // Screenshot bytes: OpenVibe.Media's Object API v2 (med_ objects, unlisted, owned by the person when
-- // signed in), or the v1 community file store (legacy:community:file:<key>) without
-- // Community's service principal.
-- const files = createMediaFiles({ config });
-- const media = opts.media || {
-- tokens: files.tokens,
-- async upload({ buffer, filename, mime, owner = null }) {
-- if (!mediaObjects.configured) return await files.upload({ buffer, filename, mime });
-- const o = await mediaObjects.uploadImage({ buffer, mime, filename, owner, kind: 'screenshot', source: 'community.paste' });
-- return { key: o.id, url: o.url, size: o.size_bytes, mime, media_ref: o.id };
-- },
-- };
-- const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits, pulse, indexnow });
++ const service = createPasteService({ db, network, media: pasteMedia, config, limits: opts.pasteLimits, pulse, indexnow, shares: sharing });
source.use(service);
app.locals.pastes = service;
++ app.locals.pasteMedia = pasteMedia;
}
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
@@@ -252,6 +299,30 @@ async function createApp(opts = {}) {
app.use('/api/v1/resources/control', require('./registry/resource-control').router({ db, viewers, pastes: app.locals.pastes }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ // GET/PUT /api/v1/paste/:slug/share, .../share/requests, .../share/transfer[/accept|decline|cancel],
++ // GET /api/v1/share/people?name=, GET /api/v1/shared-with-me. The sheet and agents use these; access
++ // is still decided by canView on every paste read. Authentication is Community's own viewer (the
++ // cookie with the SDK's CSRF marker, or a bearer token); the router answers only its own paths.
++ {
++ const shareApi = express.Router();
++ shareApi.use(viewers.middleware());
++ shareApi.use(sharing.shares.router({
++ service: 'community',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: describeRef(db),
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++ // Expire members and transfers and send queued share events/notifications. It first runs after
++ // boot, once Events is up (a delivery sent before its outbox exists would be marked sent).
++ if (opts.startSharingWorker !== false) {
++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
++
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
// GET /release.json (ADR-016) and POST /release-metrics: open tabs' update reports (a same-origin
// sendBeacon, no auth) into /metrics as release_client_updates_total.
@@@ -283,6 +354,10 @@ async function createApp(opts = {}) {
.send(seo.llmsTxt({ name: SITE_NAME, summary: discovery.SUMMARY, details: discovery.CONTENT_LABELS, markdown: true, base: config.baseUrl, sections: llmsSections, optional: llmsOptional })));
app.get('/llms-full.txt', wrap(async (_req, res) => res.type('text/plain').set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(seo.llmsFull({ site: SITE_NAME, summary: discovery.SUMMARY, base: config.baseUrl, sections: await discovery.llmsFullSections(), maxBytes: 512 * 1024 }))));
++ // The sharing page people and agents are sent to (llms.txt's "Sharing a paste"): Markdown, one source.
++ app.get('/docs/sharing', (_req, res) => res.type('text/plain; charset=utf-8')
++ .set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(require('fs').readFileSync(path.join(__dirname, '..', 'docs', 'sharing.md'), 'utf8')));
app.get('/sitemap.xml', async (_req, res) => {
let rows;
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
@@@ -458,10 +533,20 @@ async function createApp(opts = {}) {
try {
paste = await source.getPaste(slug, ctx);
} catch (err) {
-- if (err.status === 404) return { status: 404, body: pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }) };
++ if (err.status === 404) {
++ // A paste shared with specific people answers the gate: "Ask for access" when signed
++ // in, "Sign in" otherwise, and never its title, content, screenshot or comments.
++ const sh = await sharing.shareOf(slug);
++ if (sh && sh.audience === 'people') {
++ const signInUrl = `/auth/login?next=${encodeURIComponent(`/p/${slug}`)}`;
++ return { status: 404, body: pages.gatePage({ slug, signedInAs: user && user.username ? user.username : null, signInUrl }) };
++ }
++ return { status: 404, body: pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }) };
++ }
if (err.status === 410) return { status: 410, body: pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }) };
throw err;
}
++ const share = await app.locals.pastes.shareState(viewer, paste);
const related = await catalog.related(paste, 6);
// Its comments are the paste's typed thread (community/paste/<slug>), the one /c/:accessId and the
// comments API show, sorted (?sort=old|new) and paged (?after=<last comment id>) as there; null while
@@@ -472,7 +557,7 @@ async function createApp(opts = {}) {
const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
return {
status: 200, slug: paste.slug,
-- body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after }),
++ body: pages.pastePage({ paste, related, user, comments: thread, commentSort: sort, commentsAfter: after, share }),
};
}
@@@ -520,10 +605,19 @@ async function createApp(opts = {}) {
throw err;
}
});
++ // The screenshot proxy: the one place a screenshot's bytes leave Community. The paste's share
++ // check decides (canView, through screenshotUrl), then the bytes come from Media with Community's
++ // token. The Media object's own URL is never handed to a browser.
app.get('/p/:slug/screenshot', withUser, async (req, res) => {
if (!SLUG_RE.test(req.params.slug)) return notFound(res);
-- try { res.redirect(302, await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
-- catch (err) {
++ try {
++ await app.locals.pastes.screenshotUrl(req.viewer, req.params.slug);
++ const row = await db.prepare('SELECT media_ref, screenshot_url FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(req.params.slug);
++ const got = row ? await app.locals.pasteMedia.bytes(row) : null;
++ if (!got) return notFound(res);
++ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', cache.htmlHeaders({ private: true }));
++ res.type(got.type || 'application/octet-stream').send(got.body);
++ } catch (err) {
if (err.status === 410) return res.status(410).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('This paste has been burned after reading.');
if (err.status === 404) return notFound(res);
throw err;
@@@ -536,7 +630,7 @@ async function createApp(opts = {}) {
let paste;
try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
-- if (paste.type === 'screenshot') return res.redirect(302, paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`);
++ if (paste.type === 'screenshot') return res.redirect(302, `/p/${encodeURIComponent(slug)}/screenshot`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
ddiff --git a/server/comments/service.js b/server/comments/service.js
iindex 44c72f2..297d9c4 100644
---- a/server/comments/service.js
++++ b/server/comments/service.js
@@@ -68,7 +68,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const REPLY_PAGE = 20;
--function createCommentService({ db, network = null, limits = {} } = {}) {
++function createCommentService({ db, network = null, limits = {}, canViewPaste = null } = {}) {
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@@ -142,10 +142,16 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
return { comment: c, thread: await visible(v, await store.getThread(db, c.thread_id)) };
}
-- /** The paste service's rule: a paste that is gone (deleted, burned) is nobody's; a private one its owner's and staff's. */
++ /**
++ * The paste service's one check (shares.canView): a paste that is gone (deleted, burned) is
++ * nobody's, a people/private one its owner's, members' and staff's. The app installs the check;
++ * without one (a unit test of another product's refs) the old column decides, failing closed.
++ */
async function pasteVisibleTo(v, slug) {
const p = await pasteStore.getBySlug(db, String(slug));
-- return !!p && (p.visibility !== 'private' || !!(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
++ if (!p) return false;
++ if (canViewPaste) return await canViewPaste(v, p);
++ return !(p.visibility === 'private' && !(v && (v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject))));
}
/** Whether the viewer may see the entity a thread belongs to (only Community's pastes can be checked here). */
const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 672dc49..9c8fba0 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -34,6 +34,16 @@ function llmsSections() {
] },
// The API is a primary entry for an agent, not something to skip.
{ title: 'API', links: [{ title: 'OpenAPI 3.1 document', url: `${u}/openapi.json`, note: 'every /api route, with its fields' }] },
++ // Sharing (design 2026-11-06): one audience and a transfer, from the page or from an agent.
++ { title: 'Sharing a paste', links: [
++ { title: 'Who can open a paste', url: `${u}/docs/sharing`, note: 'Everyone, anyone with the link, specific people (@names), only me; transfer ownership to a person' },
++ { title: 'Share one paste with a bearer token', url: `${u}/api/v1/paste/{slug}/share`,
++ note: `PUT with {"audience":"people","members":[{"name":"@maticus","role":"viewer"}]}; curl -X PUT -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' ${u}/api/v1/paste/<slug>/share -d '{"audience":"people","members":[{"name":"@maticus"}]}'` },
++ { title: 'Make a paste only two people can open, and offer it, in one call', url: `${u}/api/pastes`,
++ note: `curl -X POST -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' ${u}/api/pastes -d '{"content":"the notes","audience":"people","members":["@maticus"],"transfer_to":"@maticus","keep_access":false}'` },
++ { title: 'Accept, decline or cancel a transfer', url: `${u}/api/v1/paste/{slug}/share/transfer/{accept,decline,cancel}`,
++ note: `POST {"transfer_id":"<uuid>"} as the recipient (accept/decline) or the owner (cancel)` },
++ ] },
],
optional: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
@@@ -47,7 +57,9 @@ function llmsSections() {
/** The latest public threads and text pastes in full (no NSFW, no burn-after-read, nothing gated). */
async function llmsFullSections() {
const { markdownToText } = require('./render/markdown');
-- const sections = [{ title: 'Content labels', pages: [{ title: 'Who wrote what', url: '/', text: CONTENT_LABELS }] }];
++ const sections = [
++ { title: 'Content labels', pages: [{ title: 'Who wrote what', url: '/', text: CONTENT_LABELS }] },
++ ];
if (_forum) {
const threads = await _forum.recentPublic({ limit: 50 });
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
@@@ -59,6 +71,9 @@ async function llmsFullSections() {
sections.push({ title: 'Latest pastes', pages: full.filter(Boolean).map((p) => ({
title: p.title || 'Untitled', url: abs(`/p/${p.slug}`), text: String(p.content || ''),
})) });
++ // Sharing (design 2026-11-06): the four audiences, the transfer, and the curls an agent uses.
++ sections.push({ title: 'Sharing a paste', pages: [{ title: 'Sharing a paste', url: '/docs/sharing',
++ text: require('fs').readFileSync(require('path').join(__dirname, '..', 'docs', 'sharing.md'), 'utf8') }] });
return sections;
}
ddiff --git a/server/events.js b/server/events.js
iindex e231df3..5dda47d 100644
---- a/server/events.js
++++ b/server/events.js
@@@ -65,6 +65,17 @@ async function record(eventType, subject, payload, { isPublic = false, actor = n
return env;
}
++/**
++ * Enqueue a prepared envelope (openvibe-sdk/share's share.* events) through the same outbox, joining
++ * the caller's transaction. Off while the outbox is off; returns null then.
++ */
++async function publish(envelope) {
++ if (!outbox) return null;
++ const env = await outbox.enqueue(outboxDb, envelope);
++ outboxDb.afterCommit(() => outbox && outbox.kick());
++ return env;
++}
++
// ── Builders (read the row as it is after the change) ─────────────────────────────
const pasteUrl = (p) => (p.visibility === 'public' && !p.deleted_at ? `${config.baseUrl}/p/${encodeURIComponent(p.slug)}` : null);
const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
@@@ -148,4 +159,4 @@ function stop() {
}
function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }
--module.exports = { init, record, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, backlog, stop, _reset };
++module.exports = { init, record, publish, moderationAction, pasteCreated, pasteUpdated, pasteDeleted, threadCreated, postCreated, commentCreated, status, backlog, stop, _reset };
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 0cca04e..88c810c 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -53,6 +53,21 @@ async function exportPart(db, subject) {
if (rows.length > ROW_LIMIT) truncated.push(name);
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
}
++ // Sharing: a person's own share rows, their memberships, their access requests and the transfers
++ // they started or were offered. Subjects are jsonb ({ type, id }), so these are read by id.
++ for (const [name, table, where] of [
++ ['shares.json', 'shares', "owner_subject->>'id' = ?"],
++ ['share_members.json', 'share_members', "subject->>'id' = ?"],
++ ['share_access_requests.json', 'share_access_requests', "subject->>'id' = ?"],
++ ['share_transfers.json', 'share_transfers', "(from_subject->>'id' = ? OR to_subject->>'id' = ?)"],
++ ]) {
++ if (!await hasTable(db, table)) continue;
++ const rows = await db.prepare(`SELECT * FROM ${table} WHERE ${where} ORDER BY 1 DESC LIMIT ${ROW_LIMIT + 1}`)
++ .all(...(where.includes(' OR ') ? [subject, subject] : [subject]));
++ if (!rows.length) continue;
++ if (rows.length > ROW_LIMIT) truncated.push(name);
++ files.push({ name, content: rows.slice(0, ROW_LIMIT) });
++ }
return { files, truncated, note: 'Paste screenshots and attachments are downloaded from their URLs.' };
}
@@@ -85,9 +100,26 @@ async function erase(db, subjects, { now = new Date().toISOString() } = {}) {
add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
recount.push([table, items]);
}
++ // Sharing: every membership, access request and pending offer naming the person goes; their
++ // own pastes' share rows go with the paste (the foreign keys would refuse the delete).
++ const sharing = await hasTable(db, 'shares');
++ if (sharing) {
++ add(erased, 'share_members', (await db.prepare(`DELETE FROM share_members WHERE subject->>'id' IN ${S}`).run(...subjects)).changes);
++ add(erased, 'share_access_requests', (await db.prepare(`UPDATE share_access_requests SET state = 'dismissed' WHERE subject->>'id' IN ${S} AND state = 'pending'`).run(...subjects)).changes);
++ add(erased, 'share_transfers', (await db.prepare(`UPDATE share_transfers SET state = 'cancelled' WHERE (to_subject->>'id' IN ${S} OR from_subject->>'id' IN ${S}) AND state = 'pending'`).run(...subjects, ...subjects)).changes);
++ }
++ const dropShare = async (slug) => {
++ const ref = ['community', 'paste', String(slug)];
++ for (const t of ['share_members', 'share_transfers', 'share_access_requests']) {
++ await db.prepare(`DELETE FROM ${t} WHERE service = ? AND kind = ? AND resource_id = ?`).run(...ref);
++ }
++ add(erased, 'shares', (await db.prepare('DELETE FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').run(...ref)).changes);
++ };
++
// Pastes: gone, unless someone else commented.
if (await hasTable(db, 'pastes')) {
for (const p of await db.prepare(`SELECT id, slug FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
++ if (sharing) await dropShare(p.slug);
// Comments are on the paste's typed thread; paste_comments still holds the rows from before migration 0004.
const others = await db.prepare(`SELECT 1 FROM paste_comments WHERE paste_id = ? AND (author_subject IS NULL OR author_subject NOT IN ${S}) LIMIT 1`).get(p.id, ...subjects)
|| (await hasTable(db, 'comments') && await db.prepare(`SELECT 1 FROM comments c JOIN comment_threads t ON t.id = c.thread_id
ddiff --git a/server/index.js b/server/index.js
iindex 6b949ca..506c9c4 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -54,6 +54,8 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
// Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).
let searchDocs = null;
try { searchDocs = require('./search/documents').createSearchDocuments({ db: require('./db').getDb() }); searchDocs.start(); } catch (err) { console.warn('[Search] documents not started:', err.message); }
++ // A paste whose audience becomes people/private leaves Search at once (sharing's onAudienceChange).
++ if (app.locals.sharing) app.locals.sharing.useSearch(searchDocs);
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
ddiff --git a/server/media/objects.js b/server/media/objects.js
iindex f88d939..90d219e 100644
---- a/server/media/objects.js
++++ b/server/media/objects.js
@@@ -22,7 +22,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
audience: 'openvibe.media',
-- scope: 'media.object.upload',
++ scope: 'media.object.upload media.object.read media.object.delete',
fetchImpl,
}) : null;
const base = `${config.mediaInternalUrl}/api/v2/community/objects`;
@@@ -61,7 +61,36 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
}
-- return { configured, uploadImage };
++ /**
++ * A usable URL for one object's bytes: its public address, or a short-lived signed URL for a private
++ * object. Used by Community's screenshot proxy, which checks canView before asking.
++ */
++ async function downloadUrl(id, retried = false) {
++ try {
++ const out = await call('GET', `/${encodeURIComponent(String(id))}/download?format=json`);
++ return out && out.url ? out.url : null;
++ } catch (err) {
++ if (err.status === 401 && !retried) { tokens.invalidate(); return await downloadUrl(id, true); }
++ throw err;
++ }
++ }
++
++ /**
++ * Soft-delete one object from Media (a screenshot's old unlisted copy after it moved private).
++ * 404 counts as gone: the outcome the caller wants. Needs media.object.delete in the tenant.
++ */
++ async function deleteObject(id, retried = false) {
++ try {
++ await call('DELETE', `/${encodeURIComponent(String(id))}`);
++ return { deleted: true };
++ } catch (err) {
++ if (err.status === 404) return { deleted: true, alreadyGone: true };
++ if (err.status === 401 && !retried) { tokens.invalidate(); return await deleteObject(id, true); }
++ throw err;
++ }
++ }
++
++ return { configured, uploadImage, downloadUrl, deleteObject };
}
module.exports = { createMediaObjects };
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 5960564..784088f 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -57,7 +57,13 @@ const json = (description, schema) => ({ description, ...(schema ? { content: {
const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
const nothing = (description) => ({ description });
--const pasteVisibility = { type: 'string', enum: ['public', 'unlisted', 'private'], default: 'public' };
++const pasteVisibility = { type: 'string', enum: ['public', 'unlisted', 'private'], default: 'public', deprecated: true, description: 'The old three words; sharing names the audience' };
++const pasteAudience = { type: 'string', enum: ['public', 'link', 'people', 'private'], default: 'public',
++ description: 'Everyone, anyone with the link, specific people (members), or only the owner' };
++const shareMember = { oneOf: [
++ { type: 'string', description: 'An exact @name' },
++ { type: 'object', required: ['name'], properties: { name: str(), role: { type: 'string', enum: ['viewer', 'editor'], default: 'viewer' }, expires_at: str({ format: 'date-time' }) } },
++] };
const PATHS = {};
function route(method, p, operation) {
@@@ -79,14 +85,18 @@ route('post', '/api/pastes', {
description: 'community.paste.create. The body is JSON, or multipart with a file (then it is a screenshot paste).',
requestBody: { required: true, content: {
'application/json': { schema: { type: 'object', required: ['content'], properties: {
-- title: str(), content: str(), language: str(), visibility: pasteVisibility, slug: str(),
++ title: str(), content: str(), language: str(), visibility: pasteVisibility, audience: pasteAudience, slug: str(),
++ members: { type: 'array', items: shareMember, maxItems: 100, description: 'audience people only: who may open it' },
++ transfer_to: str({ description: "An exact @name: offer this paste's ownership in the same call" }),
++ keep_access: { type: 'boolean', default: false, description: 'With transfer_to: the current owner keeps viewing' },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' }, stream_id: str({ pattern: '^\\d{1,18}$' }),
metadata: { type: ['object', 'string'], description: 'Services only: a JSON object kept with the paste' },
ai_summary: str({ maxLength: 2000, description: 'A service with community.paste.moderate' }),
ai_tags: { type: ['array', 'string'], description: 'A service with community.paste.moderate' },
} } },
'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: {
-- file: { type: 'string', format: 'binary' }, title: str(), description: str(), page_url: str(), visibility: pasteVisibility,
++ file: { type: 'string', format: 'binary' }, title: str(), description: str(), page_url: str(), visibility: pasteVisibility, audience: pasteAudience,
++ members: str({ description: 'audience people only: @names, comma or space separated' }), transfer_to: str(), keep_access: { type: 'boolean' },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' },
} } },
} },
@@@ -656,6 +666,26 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Community', email: '[email protected]' },
},
servers: [{ url: SITE }],
++ // The uniform share API is served by openvibe-sdk/share's router (not a route Express lists in
++ // its stack), so it is described here rather than under paths. docs/sharing.md has the page.
++ 'x-sharing': {
++ description: 'One audience per paste and a transfer, chosen by its owner. Every read (page, raw, download, screenshot, JSON, comments, previews) asks the same canView check; people and private pastes are never listed, indexed, embedded or previewed with their title, description or picture.',
++ audiences: ['public', 'link', 'people', 'private'],
++ routes: [
++ 'GET|PUT /api/v1/paste/{slug}/share',
++ 'POST /api/v1/paste/{slug}/share/requests',
++ 'POST /api/v1/paste/{slug}/share/requests/{id}/dismiss',
++ 'POST /api/v1/paste/{slug}/share/transfer',
++ 'POST /api/v1/paste/{slug}/share/transfer/{accept,decline,cancel}',
++ 'GET /api/v1/share/people?name=',
++ 'GET /api/v1/shared-with-me',
++ ],
++ createWithSharing: {
++ request: { content: 'the notes', audience: 'people', members: ['@maticus'], transfer_to: '@maticus', keep_access: false },
++ curl: `curl -X POST ${SITE}/api/pastes -H 'Authorization: Bearer $TOKEN' -H 'Content-Type: application/json' -d '{"content":"the notes","audience":"people","members":["@maticus"],"transfer_to":"@maticus","keep_access":false}'`,
++ },
++ docs: `${SITE}/docs/sharing`,
++ },
tags: ['pastes', 'pastes-admin', 'submissions', 'comments', 'pulse', 'spaces', 'posts', 'relay', 'resources'].map((name) => ({ name })),
components: {
securitySchemes: {
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
iindex 8ac750d..6903854 100644
---- a/server/pastes/api.js
++++ b/server/pastes/api.js
@@@ -42,6 +42,13 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
const router = express.Router();
router.use(contracts.http.middleware());
router.use(viewers.middleware());
++ // Every paste read is per-person (a link or a people paste answers someone and not the next
++ // reader), and Cloudflare ignores Vary and Authorization: no GET of this API may ever sit in a
++ // shared cache, at the edge or in a browser. That includes a 404 and a refusal.
++ router.use((req, res, next) => {
++ if (req.method === 'GET' || req.method === 'HEAD') res.set('Cache-Control', 'private, no-store');
++ next();
++ });
// Per-actor limits (server/actor-limits.js): reads take the defaults. The paste service's own limits
// (a person's cooldown and daily cap, comment limits) keep deciding for people; these cap requests,
// refused ones included, and the writes without a content limit. Live's AI pastes count as svc:live.
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 5319ec0..5419db3 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -81,7 +81,7 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
}
--function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null } = {}) {
++function createPasteService({ db, network = null, media = null, config = {}, limits = {}, pulse = null, indexnow = null, shares = null } = {}) {
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
const tell = async (fn) => { if (!pulse) return; try { await fn(pulse); } catch (err) { console.warn('[Pastes] pulse:', err.message); } };
@@@ -104,6 +104,37 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const isOwner = (v, p) => !!(v && v.subject && p.owner_subject && v.subject === p.owner_subject);
const isStaff = (v) => !!(v && v.staff);
const canSeeHidden = (v, p) => isOwner(v, p) || isStaff(v);
++ const shareRef = (p) => ({ service: 'community', kind: 'paste', id: String(p.slug) });
++ /** The audience a request asks for: the new name wins, else the three old visibility words map 1:1. */
++ function audienceOf(v, value) {
++ const map = { public: 'public', unlisted: 'link', link: 'link', people: 'people', private: 'private' };
++ if (value !== undefined && value !== null && value !== '') {
++ const a = map[String(value)];
++ if (!a) fail(400, 'visibility must be public, unlisted, link, people or private');
++ return a;
++ }
++ return 'public';
++ }
++ const visibilityFor = (audience) => ({ public: 'public', link: 'unlisted', people: 'private', private: 'private' }[audience] || 'private');
++ const subjectOf = (v) => (v && v.subject ? { type: String(v.subject).startsWith('gst_') ? 'guest' : 'user', id: v.subject } : null);
++ /**
++ * Specific people names people: an anonymous (or AI) paste has nobody to share it with, refused.
++ * Only me on an ownerless paste is the old rule: nobody could ever open it, so it becomes the link
++ * audience (unlisted) rather than a private paste with no owner.
++ */
++ function audienceFor(v, audience) {
++ if (audience === 'people' && !v.subject) fail(401, 'Sign in to share with specific people');
++ return audience === 'private' && !v.subject ? 'link' : audience;
++ }
++ const noBurnForPeople = (audience, burnAfterRead) => { if (audience === 'people' && burnAfterRead) fail(400, 'Burn-after-reading pastes cannot be shared with specific people'); };
++ /** An SDK share error becomes this API's error shape (status, text, stable code) instead of a 500. */
++ async function shareCall(fn) {
++ try { return await fn(); }
++ catch (err) {
++ if (err && err.name === 'ShareError') fail(err.status, err.message, { code: err.code });
++ throw err;
++ }
++ }
// Media applied cooldowns/daily caps to callers acting as a person; not to anonymous app calls.
const limited = (v) => !!(v && v.subject);
const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);
@@@ -112,15 +143,63 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const needIdentity = (v) => { if (!v || (!v.subject && !v.staff)) fail(401, 'Authentication required'); };
/**
-- * A paste the viewer may see, or 404: private ones look exactly like missing ones to everyone
-- * but their owner and staff — for reads, and for edits/deletes too (no 403 that confirms a slug).
++ * The one access check: openvibe-sdk/share's canView over the paste's share row (owner, audience,
++ * members, staff, and a pending transfer's recipient previewing). Missing pastes deny.
++ */
++ async function mayView(v, p) {
++ if (!p) return false;
++ if (shares) return await shares.canViewPaste(v, p);
++ return !(p.visibility === 'private' && !canSeeHidden(v, p));
++ }
++
++ /**
++ * A paste the viewer may see, or 404: a people/private paste looks exactly like a missing one to
++ * everyone but its owner, members and staff — for reads, and for edits/deletes too (no 403 that
++ * confirms a slug).
*/
async function visible(v, slug) {
const p = await store.getBySlug(db, String(slug));
-- if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');
++ if (!p || !await mayView(v, p)) fail(404, 'Paste not found');
return p;
}
++ /**
++ * Register a new paste's share row in the same transaction as the paste (the SDK joins the ambient
++ * transaction): a paste exists only with its audience. An ownerless paste (anonymous or AI) is
++ * registered to Community itself, which nobody can sign in as.
++ */
++ async function registerShare(v, row, audience) {
++ if (!shares) return;
++ await shares.shares.register(shareRef(row), { owner_subject: subjectOf(v) || { type: 'service', id: 'community' }, audience });
++ }
++
++ /** The optional members/transfer in a create (and the uniform API's job, done here for one call). */
++ async function applyShareRequest(v, slug, audience, body = {}) {
++ if (!shares) return null;
++ // JSON sends an array; a multipart form field arrives as one string ("@a, @b").
++ let members = body.members;
++ if (typeof members === 'string') members = members.split(/[\s,]+/).filter(Boolean);
++ if (members !== undefined && !Array.isArray(members)) fail(400, 'members must be an array');
++ if (members && members.length && audience !== 'people') fail(400, 'members are only for audience people');
++ const out = {};
++ if (members && members.length) out.share = await shareCall(() => shares.addMembers(v, slug, members));
++ if (body.transfer_to) {
++ if (!v.subject) fail(401, 'Sign in to transfer a paste');
++ out.transfer = await shareCall(() => shares.transfer(v, slug, { to: body.transfer_to, keep_access: truthy(body.keep_access) }));
++ }
++ return Object.keys(out).length ? out : null;
++ }
++
++ async function shareInfo(v, p) {
++ if (!shares) return { audience: p.visibility === 'public' ? 'public' : p.visibility === 'unlisted' ? 'link' : 'private', transfer: null, can_manage: isOwner(v, p) || isStaff(v) };
++ const s = await shares.shareOf(p.slug);
++ return {
++ audience: s ? s.audience : null,
++ transfer: s ? await shares.pendingTransfer(v, p.slug) : null,
++ can_manage: !!(s && (isOwner(v, p) || isStaff(v))),
++ };
++ }
++
// ── response shapes ──────────────────────────────────────
function streamIdOf(ref) {
return ref && ref.service === 'live' && ref.type === 'stream' && /^\d+$/.test(String(ref.id)) ? Number(ref.id) : null;
@@@ -273,12 +352,6 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
// ── validation helpers ───────────────────────────────────
-- function visibilityOf(value, owned) {
-- const vis = ['unlisted', 'private'].includes(value) ? value : 'public';
-- // Nobody could ever open an ownerless private paste; it becomes unlisted instead.
-- return vis === 'private' && !owned ? 'unlisted' : vis;
-- }
--
function streamRefFor(v, body) {
if (v.kind === 'service' && v.sourceRef) return JSON.stringify(v.sourceRef);
const sid = body.stream_id;
@@@ -370,6 +443,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { slug: p.slug, title: p.title, content: p.content };
},
++ /** The share state of a paste for the page: its audience and, for the recipient, their pending offer. */
++ async shareState(v, p) { return await shareInfo(v, p); },
++
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
const p = await visible(v, slug);
@@@ -383,14 +459,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
const liked = v.subject ? await store.hasLiked(db, p.id, v.subject) : false;
-- return { paste: { ...(await shapeOne(p, v)), liked } };
++ const share = await shareInfo(v, p);
++ return { paste: { ...(await shapeOne(p, v)), liked, audience: share.audience, can_manage: share.can_manage, transfer: share.transfer } };
},
/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
async raw(v, slug, ctx = {}) {
const p = await store.getBySlug(db, String(slug));
-- if (p && p.type === 'screenshot' && (p.visibility !== 'private' || canSeeHidden(v, p))) return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
-- if (!p || p.type !== 'paste' || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
++ if (!p || !await mayView(v, p)) fail(404, 'Not found');
++ if (p.type === 'screenshot') return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
++ if (p.type !== 'paste') fail(404, 'Not found');
if (!isOwner(v, p)) {
if (p.burn_after_read) {
if (p.views > 0) await burn(p);
@@@ -405,7 +483,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
/** GET /p/:slug/screenshot target (the stored Media URL). For a burn-after-read screenshot it is a read (raw's rule). */
async screenshotUrl(v, slug) {
const p = await store.getBySlug(db, String(slug));
-- if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
++ if (!p || !p.screenshot_url || !await mayView(v, p)) fail(404, 'Not found');
if (p.burn_after_read && !isOwner(v, p)) {
if (p.views > 0) await burn(p);
await store.bumpViews(db, p.id);
@@@ -420,28 +498,42 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
const metadata = serviceMetadata(v, body);
-- let visibility = visibilityOf(body.visibility, !!v.subject);
++ let audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
++ audience = audienceFor(v, audience);
++ noBurnForPeople(audience, truthy(body.burn_after_read));
// Link spam from someone signed out is kept but unlisted (./spam.js); people with an account and AI pastes are
// never checked.
-- const spammy = !v.subject && v.origin !== 'ai' && visibility === 'public' ? spam.linkSpam({ title: body.title, content }) : null;
-- if (spammy && spammy.unlist) visibility = 'unlisted';
-- const row = await store.insertPaste(db, {
-- slug: await slugFor(v, body, visibility, truthy(body.burn_after_read)),
-- owner_subject: v.subject || null,
-- origin: v.origin === 'ai' ? 'ai' : 'user',
-- type: 'paste',
-- // An AI paste's title and body are AI text too; a person's are theirs, as written.
-- title: store.sanitizeTitle(body.title),
-- content: content.trim(),
-- language: store.detectLanguage(content, body.language),
-- visibility,
-- stream_ref: streamRefFor(v, body),
-- metadata: metadata ? JSON.stringify(metadata) : null,
-- burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
-- is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
-- ...aiFields(v, body),
++ const spammy = !v.subject && v.origin !== 'ai' && audience === 'public' ? spam.linkSpam({ title: body.title, content }) : null;
++ if (spammy && spammy.unlist) audience = 'link';
++ const visibility = visibilityFor(audience);
++ const row = await db.tx(async () => {
++ const r = await store.insertPaste(db, {
++ slug: await slugFor(v, body, visibility, truthy(body.burn_after_read)),
++ owner_subject: v.subject || null,
++ origin: v.origin === 'ai' ? 'ai' : 'user',
++ type: 'paste',
++ // An AI paste's title and body are AI text too; a person's are theirs, as written.
++ title: store.sanitizeTitle(body.title),
++ content: content.trim(),
++ language: store.detectLanguage(content, body.language),
++ visibility,
++ stream_ref: streamRefFor(v, body),
++ metadata: metadata ? JSON.stringify(metadata) : null,
++ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
++ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
++ ...aiFields(v, body),
++ });
++ await registerShare(v, r, audience);
++ return r;
++ });
++ // One call can make a paste people-only and offer it away (the agent path): members and a
++ // transfer are applied after the paste and its share row exist. Name lookups need their own
++ // transaction (the SDK refuses them inside one), which is why this is after the commit.
++ const sharing = await applyShareRequest(v, row.slug, audience, body);
++ return await created(row, {
++ paste: { ...(await shapeOne(row, v)), audience }, ...(sharing || {}),
++ ...(spammy && spammy.unlist ? { unlisted_reason: spammy.reason, notice: spam.NOTICE } : {}),
});
-- return await created(row, { paste: await shapeOne(row, v), ...(spammy && spammy.unlist ? { unlisted_reason: spammy.reason, notice: spam.NOTICE } : {}) });
},
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
@@@ -454,13 +546,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!mime) fail(400, 'Only PNG, JPEG, WebP, or GIF images allowed');
if (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (!media) fail(503, 'Media service unavailable');
-- const visibility = visibilityOf(body.visibility, !!v.subject);
++ let audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
++ audience = audienceFor(v, audience);
++ noBurnForPeople(audience, truthy(body.burn_after_read));
++ const visibility = visibilityFor(audience);
const slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const extra = serviceMetadata(v, body);
const bytes = stripImageMetadata(file.buffer, mime);
let stored;
try {
-- stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime, owner: v.subject || null });
++ // Screenshot bytes are private Media objects owned by the person: only Community's proxy
++ // (which calls canView) serves them, never a bare Media URL.
++ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime, owner: v.subject || null, visibility: 'private' });
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
@@@ -472,25 +569,31 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
original_name: file.originalname || null,
size_bytes: bytes.length,
mime_type: mime,
++ media_visibility: 'private',
};
-- const row = await store.insertPaste(db, {
-- slug,
-- owner_subject: v.subject || null,
-- origin: v.origin === 'ai' ? 'ai' : 'user',
-- type: 'screenshot',
-- title: store.sanitizeTitle(body.title || 'Screenshot'),
-- content: String(body.description || body.content || ''),
-- language: 'text',
-- visibility,
-- screenshot_url: stored.url,
-- media_ref: stored.media_ref,
-- stream_ref: streamRefFor(v, body),
-- metadata: JSON.stringify(metadata),
-- burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
-- is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
-- ...aiFields(v, body),
++ const row = await db.tx(async () => {
++ const r = await store.insertPaste(db, {
++ slug,
++ owner_subject: v.subject || null,
++ origin: v.origin === 'ai' ? 'ai' : 'user',
++ type: 'screenshot',
++ title: store.sanitizeTitle(body.title || 'Screenshot'),
++ content: String(body.description || body.content || ''),
++ language: 'text',
++ visibility,
++ screenshot_url: stored.url,
++ media_ref: stored.media_ref,
++ stream_ref: streamRefFor(v, body),
++ metadata: JSON.stringify(metadata),
++ burn_after_read: truthy(body.burn_after_read) ? 1 : 0,
++ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
++ ...aiFields(v, body),
++ });
++ await registerShare(v, r, audience);
++ return r;
});
-- return await created(row, { paste: await shapeOne(row, v) });
++ const sharing = await applyShareRequest(v, row.slug, audience, body);
++ return await created(row, { paste: { ...(await shapeOne(row, v)), audience }, ...(sharing || {}) });
},
/** PUT /api/pastes/:slug — owner (or staff) only. */
@@@ -506,14 +609,22 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
patch.content = content;
patch.language = store.detectLanguage(content, body.language);
}
-- if (body.visibility !== undefined) patch.visibility = visibilityOf(body.visibility, !!p.owner_subject);
++ let audience = null;
++ if (body.audience !== undefined || body.visibility !== undefined) {
++ audience = audienceOf(v, body.audience !== undefined ? body.audience : body.visibility);
++ noBurnForPeople(audience, truthy(body.burn_after_read !== undefined ? body.burn_after_read : p.burn_after_read));
++ patch.visibility = visibilityFor(audience);
++ }
if (body.is_nsfw !== undefined) patch.is_nsfw = truthy(body.is_nsfw) ? 1 : 0;
if (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
const row = await db.tx(async () => {
const r = await store.updatePaste(db, p.id, patch, v.subject || null);
++ // The share row is the authority; its onAudienceChange keeps pastes.visibility in step
++ // (people maps to private) and drops the paste from Search.
++ if (audience && shares) await shareCall(() => shares.setAudience(v, p.slug, audience));
// Staff editing someone else's paste goes to the moderation audit log (ADR-022).
-- if (!isOwner(v, p) && isStaff(v)) await events.moderationAction(v, patch.visibility !== undefined && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
++ if (!isOwner(v, p) && isStaff(v)) await events.moderationAction(v, audience && Object.keys(patch).length === 1 ? 'paste.visibility_changed' : 'paste.edited', { type: 'paste', id: p.slug, owner_subject: p.owner_subject || null }, { details: { fields: Object.keys(patch) } });
return r;
});
await tell((pl) => pl.pasteChanged(row));
@@@ -551,20 +662,26 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
// A lasting copy would outlive the burn; only the owner (or staff) may fork one.
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
await pasteRateCheck(v);
-- // A fork never widens who can find the content: unlisted stays unlisted (and gets a secret slug).
-- const visibility = visibilityOf(original.visibility, !!v.subject);
-- const row = await store.insertPaste(db, {
-- slug: await store.generateSlug(db, { secret: visibility !== 'public' }),
-- owner_subject: v.subject || null,
-- origin: v.origin === 'ai' ? 'ai' : 'user',
-- type: 'paste',
-- title: store.sanitizeTitle(`Fork of ${original.title}`),
-- content: original.content,
-- language: original.language,
-- visibility,
-- forked_from: original.id,
++ // A fork never widens who can find the content: link stays link, people and private become
++ // the forker's own private paste (a new share list would name the wrong people).
++ const audience = original.visibility === 'public' ? 'public' : original.visibility === 'unlisted' ? 'link' : 'private';
++ const visibility = visibilityFor(audience);
++ const row = await db.tx(async () => {
++ const r = await store.insertPaste(db, {
++ slug: await store.generateSlug(db, { secret: visibility !== 'public' }),
++ owner_subject: v.subject || null,
++ origin: v.origin === 'ai' ? 'ai' : 'user',
++ type: 'paste',
++ title: store.sanitizeTitle(`Fork of ${original.title}`),
++ content: original.content,
++ language: original.language,
++ visibility,
++ forked_from: original.id,
++ });
++ await registerShare(v, r, audience);
++ return r;
});
-- return await created(row, { paste: await shapeOne(row, v) });
++ return await created(row, { paste: { ...(await shapeOne(row, v)), audience } });
},
/** POST /api/pastes/:slug/like — toggles; needs a person. */
@@@ -694,7 +811,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const p = await store.getBySlug(db, String(slug));
if (!p) { skipped++; continue; }
if (action === 'delete') await store.softDelete(db, p.id);
-- else await store.setVisibility(db, p.id, action);
++ else {
++ await store.setVisibility(db, p.id, action);
++ // Staff moderation sets the same audience the old column names, so canView agrees.
++ if (shares) await db.prepare(`UPDATE shares SET audience = ? WHERE service = 'community' AND kind = 'paste' AND resource_id = ?`)
++ .run(audienceOf(v, action), String(slug));
++ }
if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));
pingIndexNow(action === 'public' ? { ...p, visibility: 'public' } : p);
done++;
@@@ -715,7 +837,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
let stored;
try {
-- stored = await media.upload({ buffer: stripImageMetadata(file.buffer, mime), filename: file.originalname || 'censored.png', mime, owner: p.owner_subject || null });
++ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, mime), filename: file.originalname || 'censored.png', mime, owner: p.owner_subject || null, visibility: 'private' });
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 039dd05..7cdbec4 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -34,8 +34,8 @@ function mediaAbs(u) {
function mediaW(u, w) {
return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(String(u || '')) ? `${u}?w=${w}` : u;
}
--/** A screenshot's Media address when the stored row has no screenshot_url. */
--function screenshotFallback(slug) { return `${config.mediaUrl}/p/${encodeURIComponent(slug)}/screenshot`; }
++/** A screenshot's address: Community's checked proxy, never the Media object's own URL. */
++function screenshotSrc(p) { return `/p/${encodeURIComponent(p.slug)}/screenshot`; }
function timeAgo(v) {
const iso = ld.isoDate(v);
@@@ -110,7 +110,7 @@ function pagerHref(state, page) {
function pasteCard(p) {
const isShot = p.type === 'screenshot';
const preview = isShot
-- ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(mediaAbs(p.screenshot_url))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
++ ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(screenshotSrc(p))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
: `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
@@@ -173,7 +173,7 @@ function homeStats(c = {}) {
*/
function pasteKitCard(p) {
const isShot = p.type === 'screenshot';
-- const image = isShot && !p.is_nsfw ? mediaAbs(p.screenshot_url) : '';
++ const image = isShot && !p.is_nsfw ? screenshotSrc(p) : '';
return kit.card({
href: `/p/${p.slug}`, title: p.title || 'Untitled', type: 'post', ratio: '16/9', image: image || '',
badge: p.is_nsfw ? 'NSFW' : p.origin === 'ai' ? 'AI' : p.pinned ? 'Pinned' : '',
@@@ -380,18 +380,6 @@ function pasteDescription(p) {
return ld.clean(`${p.title || 'Paste'} — ${languageLabel(p.language)} paste by ${authorName(p)}: ${String(p.content || '').slice(0, 200)}`, 200);
}
--function shareLinks(p) {
-- const url = `${config.baseUrl}/p/${p.slug}`;
-- const text = `${p.title || 'A paste'} — ${SITE_NAME}`;
-- const u = encodeURIComponent(url), t = encodeURIComponent(text);
-- return `<div class="share" aria-label="Share">
-- <button type="button" class="btn btn-sm" data-copy-link="${esc(url)}"><i class="fa-solid fa-link" aria-hidden="true"></i> Copy link</button>
-- <a class="btn btn-sm" href="https://twitter.com/intent/tweet?url=${u}&text=${t}" target="_blank" rel="noopener"><i class="fa-brands fa-x-twitter" aria-hidden="true"></i> X</a>
-- <a class="btn btn-sm" href="https://bsky.app/intent/compose?text=${t}%20${u}" target="_blank" rel="noopener"><i class="fa-brands fa-bluesky" aria-hidden="true"></i> Bluesky</a>
-- <a class="btn btn-sm" href="https://www.reddit.com/submit?url=${u}&title=${t}" target="_blank" rel="noopener"><i class="fa-brands fa-reddit-alien" aria-hidden="true"></i> Reddit</a>
-- <a class="btn btn-sm" href="mailto:?subject=${t}&body=${u}"><i class="fa-solid fa-envelope" aria-hidden="true"></i> Email</a>
-- </div>`;
--}
/**
* comments: the paste's typed thread as the comment service shows it to this viewer (null while nobody has
@@@ -407,11 +395,18 @@ function withoutTitle(content, title) {
return m && bare(m[1]) && bare(m[1]) === bare(title) ? text.slice(m[0].length) : text;
}
--function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null }) {
++function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null, share = null }) {
const isShot = p.type === 'screenshot';
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const description = pasteDescription(p);
-- const shot = isShot ? mediaAbs(p.screenshot_url) || screenshotFallback(p.slug) : null;
++ // The screenshot is Community's own address: the proxy checks canView, never the Media object's URL.
++ const shot = isShot ? screenshotSrc(p) : null;
++ // Specific people and Only me get a generic link preview, even for someone allowed to read them:
++ // a crawler or a chat client must never learn the title, the description or the picture.
++ const restricted = !!(share && (share.audience === 'people' || share.audience === 'private'));
++ const metaTitle = restricted ? 'A paste on OpenVibe Community' : title;
++ const metaDescription = restricted ? 'A paste shared with specific people on OpenVibe Community.' : description;
++ const metaImage = restricted ? undefined : (shot || undefined);
// Pastes name their owner by Network subject; the projection cache supplies the display fields.
const isOwner = !!(user && (p.is_owner === true
|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
@@@ -448,11 +443,14 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
${!isShot ? `<a class="btn btn-sm" href="/new?fork=${esc(p.slug)}"><i class="fa-solid fa-code-fork" aria-hidden="true"></i> Fork</a>` : ''}
-- ${p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
++ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
++ ${!restricted && p.visibility !== 'private' && !p.burn_after_read ? `<a class="btn btn-sm" href="/s/discuss?paste=${encodeURIComponent(p.slug)}"><i class="fa-solid fa-comments" aria-hidden="true"></i> Discuss in the forum</a>` : ''}
${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
</div>`;
++ const offer = share && share.transfer ? `<div id="paste-offer" data-transfer="${esc(share.transfer.id)}" data-username="${esc(p.username || '')}" data-display-name="${esc(p.display_name || '')}" data-expires="${esc(share.transfer.expires_at || '')}" data-keep-access="${share.transfer.keep_access ? '1' : '0'}"></div>` : '';
const body = `
++${offer}
<article class="paste" itemscope itemtype="https://schema.org/${isShot ? 'ImageObject' : 'Article'}">
<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Home</a> › <a href="/pastes">Pastes</a> › <span aria-current="page">${esc(ld.clean(title, 60))}</span></nav>
<header class="paste-head">
@@@ -463,7 +461,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
<span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
<span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>
${!isShot && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
-- ${p.visibility && p.visibility !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${p.visibility === 'private' ? 'lock' : 'eye-slash'}" aria-hidden="true"></i> ${esc(p.visibility)}</span>` : ''}
++ ${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'users' : 'eye-slash'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}
${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="fa-solid fa-fire" aria-hidden="true"></i> burns after reading</span>` : ''}
</p>
${p.ai_summary ? `<p class="summary" itemprop="description"><i class="fa-solid fa-wand-magic-sparkles" aria-hidden="true"></i> ${esc(p.ai_summary)}</p>` : ''}
@@@ -471,7 +469,6 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
</header>
${bodyBlock}
<footer class="paste-foot">
-- ${shareLinks(p)}
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
</footer>
</article>
@@@ -482,11 +479,23 @@ ${p.burn_after_read ? '' : `<section class="section" id="comments">
${related && related.length ? `<section class="section" id="related">
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(related)}
--</section>` : ''}`;
++</section>` : ''}
++<script src="${ovServe.url('ov-share.js')}" defer></script>
++<script>
++window.addEventListener('DOMContentLoaded', function () {
++ if (!window.OVShare) return;
++ var offer = document.getElementById('paste-offer');
++ if (offer && offer.dataset.transfer) {
++ OVShare.offer(offer, { kind: 'paste', id: ${JSON.stringify(p.slug)}, transfer_id: offer.dataset.transfer,
++ from: { username: offer.dataset.username, display_name: offer.dataset.displayName }, title: ${JSON.stringify(restricted ? '' : title)}, expires_at: offer.dataset.expires || undefined,
++ keep_access: offer.dataset.keepAccess === '1' });
++ }
++});
++</script>`;
return renderPage({
-- title,
-- description,
++ title: metaTitle,
++ description: metaDescription,
canonicalPath: `/p/${p.slug}`,
agentLayer: true, // the public page has a Markdown twin (server/agent-pages.js)
// A paste is link-shared content: reached by the link its author shares, not found by
@@@ -495,14 +504,14 @@ ${related && related.length ? `<section class="section" id="related">
// burn-after-read pastes were already noindex and stay so.
robots: 'noindex',
ogType: 'article',
-- ogImage: shot || undefined,
++ ogImage: metaImage,
published: ld.isoDate(p.created_at) || undefined,
modified: ld.isoDate(p.updated_at) || undefined,
active: 'pastes',
historyType: 'paste',
historyTitle: title,
footerVariant: 'compact',
-- jsonLd: [
++ jsonLd: restricted ? [] : [
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
],
@@@ -586,6 +595,39 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
}
// ── Errors ───────────────────────────────────────────────────
++/**
++ * The page a person who is not on the list sees (design 2026-11-06): it never shows the title, the
++ * content, the screenshot or the comments. OVShare.gate offers "Ask for access" to somebody signed
++ * in and "Sign in" to everybody else; the plain fallback below is removed by the script.
++ */
++function gatePage({ slug, signedInAs = null, signInUrl = '/auth/login' }) {
++ const body = `<header class="page-head">
++ <h1>A paste on OpenVibe Community</h1>
++ <p class="muted">This is shared with specific people.</p>
++</header>
++<div id="paste-gate">
++ <p class="muted">This paste is shared with specific people on OpenVibe Community.</p>
++ <p><a class="btn btn-sm" href="${esc(signInUrl)}">Sign in</a></p>
++</div>
++<script src="${ovServe.url('ov-share.js')}" defer></script>
++<script>
++window.addEventListener('DOMContentLoaded', function () {
++ var el = document.getElementById('paste-gate');
++ if (el && window.OVShare) OVShare.gate(el, { kind: 'paste', id: ${JSON.stringify(slug)},
++ signedInAs: ${JSON.stringify(signedInAs || '')}, signInUrl: ${JSON.stringify(signInUrl)} });
++});
++</script>`;
++ return renderPage({
++ title: 'A paste on OpenVibe Community',
++ description: 'A paste shared with specific people on OpenVibe Community.',
++ canonicalPath: `/p/${encodeURIComponent(slug)}`,
++ robots: 'noindex,nofollow',
++ active: 'pastes',
++ footerVariant: 'compact',
++ body,
++ });
++}
++
function errorPage({ status = 500, title = 'Something went wrong', message = '', links = true }) {
const body = `
<section class="error">
@@@ -597,4 +639,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
return renderPage({ title, description: message || title, canonicalPath: '/', robots: 'noindex,nofollow', footerVariant: 'compact', body });
}
--module.exports = { homePage, updatesPage, browsePage, pastePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl, mediaW, wearName };
++module.exports = { homePage, updatesPage, browsePage, pastePage, gatePage, newPage, myPage, errorPage, pasteCard, cardGrid, timeAgo, timeTag, fmtDate, num, authorHtml, avatarUrl, mediaW, wearName };
ddiff --git a/server/search/documents.js b/server/search/documents.js
iindex b25651b..9b6815e 100644
---- a/server/search/documents.js
++++ b/server/search/documents.js
@@@ -139,6 +139,11 @@ function createSearchDocuments({ db }) {
if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';
return await send('paste', pasteDocId(row), pasteDocument(row));
}
++ /** Drop a paste from Search now (its audience changed, or it was deleted): a tombstone, not the next scan. */
++ async function removePaste(slug) {
++ if (!enabled()) return 'off';
++ return await send('paste', pasteDocId({ slug: String(slug) }), { deleted: true });
++ }
const guard = async (fn) => { try { await fn(); } catch (err) { stats.lastError = err.message; } };
async function scan({ now = Date.now() } = {}) {
@@@ -181,7 +186,7 @@ function createSearchDocuments({ db }) {
}
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
-- return { threadDocument, pasteDocument, publishThread, publishPaste, scan, refresh, start, stop, stats: () => ({ ...stats }) };
++ return { threadDocument, pasteDocument, publishThread, publishPaste, removePaste, scan, refresh, start, stop, stats: () => ({ ...stats }) };
}
module.exports = { createSearchDocuments };
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..96f62cc
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,232 @@
++'use strict';
++
++/**
++ * Sharing on Community pastes (design 2026-11-06, plan track Q; openvibe-sdk/share).
++ *
++ * The SDK owns the model and the rules: `shares` rows next to each paste, the one access check
++ * (canView), members, access requests, transfers, the append-only log and the delivery queue. This
++ * file is the wiring a service supplies:
++ *
++ * - network.json Network's identity lookups (identity.subject.resolve): @name → subject, and
++ * a person's username/display name/avatar for the sheet.
++ * - events.publish Community's Events outbox (server/events.js), so share.* envelopes go out
++ * through the same at-least-once queue as every other community.* event.
++ * - notify Network's notification push (network.notifications.push); a refusal (the
++ * grant not seeded yet) is logged, never allowed to stall the delivery queue.
++ * - onAudienceChange keeps pastes.visibility in step (public→public, link→unlisted,
++ * people→private, private→private) and queues a Search refresh, so an old
++ * reader that only knows the column fails closed and a people paste leaves
++ * search at once.
++ * - onOwnerChange moves pastes.owner_subject and hands the screenshot's Media object to the
++ * new owner (media.moveOwner).
++ *
++ * The gate: a person not on a `people` list gets `gate()` — the audience, and their own pending
++ * transfer if they are the recipient — never a title, content, screenshot or comment count.
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++const config = require('./config');
++const events = require('./events');
++
++const SERVICE = 'community';
++const KIND = 'paste';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++const VISIBILITY = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++
++/** A paste's share ref: { service: 'community', kind: 'paste', id: <slug> }. */
++const refOf = (slug) => ({ service: SERVICE, kind: KIND, id: String(slug) });
++
++/** Community's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '');
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetchImpl,
++ });
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ const url = new URL(`${base}${path}`);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error(`Network ${method} ${path} ${res.status}: ${(body && (body.detail || body.error)) || 'failed'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** One notification, addressed by canonical subject (Network resolves it). Never carries a paste title. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A paste was shared with you', 'Open it on OpenVibe Community.'],
++ 'share.access_requested': ['Someone asked for access to your paste', 'Review the request on OpenVibe Community.'],
++ 'share.transfer.requested': ['Someone wants to give you a paste', 'Accept or decline it on OpenVibe Community.'],
++ 'share.transfer.answered': ['A paste transfer was answered', 'See what happened on OpenVibe Community.'],
++ }[n.kind] || ['Sharing on OpenVibe Community', 'There is something new for you.'];
++ try {
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'community', url: n.ref && n.ref.id ? `${config.baseUrl}/p/${encodeURIComponent(n.ref.id)}` : `${config.baseUrl}/`,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ // Network may not have seeded Community's network.notifications.push grant yet: the share
++ // still happened (it is decided by canView), the person just hears about it elsewhere.
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ let searchDocs = null;
++ const refreshSearch = (slug) => {
++ if (!searchDocs) return;
++ try {
++ const p = db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug));
++ Promise.resolve(p).then((row) => (row ? searchDocs.publishPaste(row) : searchDocs.removePaste(String(slug)))).catch(() => {});
++ } catch { /* Search catches up on its next scan */ }
++ };
++
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ // A burn-after-reading paste ends on its first read: it cannot name people who must each read it.
++ if (audience === 'people') {
++ const p = await tx.maybe('SELECT burn_after_read FROM pastes WHERE slug = $1 AND deleted_at IS NULL', [ref.id]);
++ if (p && Number(p.burn_after_read)) {
++ const { ShareError } = require('openvibe-sdk/share');
++ throw new ShareError(400, 'share.invalid', 'Burn-after-reading pastes cannot be shared with specific people');
++ }
++ }
++ await tx.exec('UPDATE pastes SET visibility = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [VISIBILITY[audience] || 'private', ref.id]);
++ tx.afterCommit(() => refreshSearch(ref.id));
++ }
++
++ async function onOwnerChange(ref, from, to, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const owner = to && to.type === 'user' ? to.id : null;
++ await tx.exec('UPDATE pastes SET owner_subject = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL', [owner, ref.id]);
++ // The screenshot follows its paste: the Media object's owner is the new owner. A failure here
++ // is logged by the caller; the paste's access is already decided by the share row.
++ if (!media || typeof media.moveOwner !== 'function') return;
++ const row = await tx.maybe('SELECT slug, media_ref, screenshot_url, metadata FROM pastes WHERE slug = $1 AND deleted_at IS NULL', [ref.id]);
++ if (row && (row.media_ref || row.screenshot_url)) {
++ // Re-host the bytes privately under the new owner after the ownership change commits:
++ // Media has no "change the owner" route, so the object is replaced, never shared.
++ tx.afterCommit(async () => {
++ try { await media.moveOwner(row, owner); }
++ catch (err) { log.warn('[Sharing] media owner not moved:', err.message); }
++ });
++ }
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish: async (envelope) => await events.publish(envelope) },
++ notify,
++ onOwnerChange,
++ onAudienceChange,
++ });
++
++ /** The share row's audience, for the page's gate and its generic link preview. Null when unregistered. */
++ async function shareOf(slug) {
++ return await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(slug)]);
++ }
++
++ /** The viewer's pending transfer on this paste, if they are its recipient (they may look before accepting). */
++ async function pendingTransfer(viewer, slug) {
++ const who = shareViewer(viewer);
++ if (!who) return null;
++ const row = await db.maybe(`SELECT * FROM share_transfers WHERE service = $1 AND kind = $2 AND resource_id = $3
++ AND state = 'pending' AND expires_at > now() AND to_subject = $4::jsonb`, [SERVICE, KIND, String(slug), JSON.stringify(who.subject)]);
++ if (!row) return null;
++ return { id: row.id, from: row.from_subject, keep_access: row.keep_access, requested_at: new Date(row.requested_at).toISOString(), expires_at: new Date(row.expires_at).toISOString(), state: row.state };
++ }
++
++ /** What the page may say to somebody who cannot open the paste: the audience, and their own offer. */
++ async function gate(viewer, slug) {
++ const s = await shareOf(slug);
++ if (!s) return { audience: null, transfer: null };
++ return { audience: s.audience, transfer: s.audience === 'people' ? await pendingTransfer(viewer, slug) : null };
++ }
++
++ /** The one access check, from a Community viewer. */
++ async function canView(viewer, slug) {
++ return await shares.canView(shareViewer(viewer), refOf(slug));
++ }
++
++ /**
++ * canView for a paste row: a row without a share record predates the backfill (a fixture, or a row
++ * written by a migration), so the old column decides — failing closed for private. A registered
++ * row that denied stays denied.
++ */
++ async function canViewPaste(viewer, p) {
++ if (!p) return false;
++ if (await canView(viewer, p.slug)) return true;
++ if (await shareOf(p.slug)) return false;
++ const v = viewer || {};
++ return !(p.visibility === 'private' && !(v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject)));
++ }
++
++ // The SDK calls, from a Community viewer (the router does its own conversion).
++ const sv = (viewer) => shareViewer(viewer);
++ const at = (slug) => refOf(slug);
++
++ return {
++ shares, canView, canViewPaste, gate, shareOf, pendingTransfer, shareViewer, refOf,
++ canEdit: async (viewer, slug) => await shares.canEdit(sv(viewer), at(slug)),
++ get: async (viewer, slug) => await shares.get(sv(viewer), at(slug)),
++ getWithRevision: async (viewer, slug) => await shares.getWithRevision(sv(viewer), at(slug)),
++ setAudience: async (viewer, slug, audience) => await shares.setAudience(sv(viewer), at(slug), audience),
++ addMembers: async (viewer, slug, list) => await shares.addMembers(sv(viewer), at(slug), list),
++ removeMember: async (viewer, slug, subject) => await shares.removeMember(sv(viewer), at(slug), subject),
++ update: async (viewer, slug, body, opts) => await shares.update(sv(viewer), at(slug), body, opts),
++ requestAccess: async (viewer, slug) => await shares.requestAccess(sv(viewer), at(slug)),
++ dismiss: async (viewer, slug, id) => await shares.dismiss(sv(viewer), at(slug), id),
++ transfer: async (viewer, slug, input) => await shares.transfer(sv(viewer), at(slug), input),
++ accept: async (viewer, slug, id) => await shares.accept(sv(viewer), at(slug), id),
++ decline: async (viewer, slug, id) => await shares.decline(sv(viewer), at(slug), id),
++ cancel: async (viewer, slug, id) => await shares.cancel(sv(viewer), at(slug), id),
++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ useSearch: (docs) => { searchDocs = docs || null; },
++ /** Expire members and transfers, then send queued events and notifications. */
++ async work({ flush = true } = {}) {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++/** The uniform API's describe(ref): the sheet shows the paste's title and link to its owner. */
++function describeRef(db) {
++ return async (ref) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
++ const p = await db.maybe('SELECT slug, title FROM pastes WHERE slug = $1 AND deleted_at IS NULL', [ref.id]);
++ if (!p) return {};
++ return { title: p.title || 'Untitled', link: `${config.baseUrl}/p/${encodeURIComponent(p.slug)}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, VISIBILITY, SERVICE, KIND };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 61502c1..3236b66 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -23,7 +23,11 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
{
const missing = await uncovered(db, {
taskKeyed: ['spaces', 'comment_threads'],
-- exempt: { account_data_events: 'the ledger of requests itself: which export and deletion Community applied' },
++ exempt: {
++ account_data_events: 'the ledger of requests itself: which export and deletion Community applied',
++ share_members: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
++ share_access_requests: 'shares are erased by ../identity/account-data.js erase() (subject is jsonb)',
++ },
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
}
@@@ -48,6 +52,13 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
await db.prepare("INSERT INTO post_reactions (post_id, subject_id, reaction) VALUES (2, ?, 'fire')").run(DANA);
await db.prepare("INSERT INTO game_progress (subject_id, level, updated_at) VALUES (?, 3, '2026-09-27'), (?, 4, '2026-09-27')").run(DANA, OTTO);
await db.prepare('INSERT INTO network_blocks (blocker_subject, blocked_subject, active, revision, updated_at) VALUES (?, ?, 1, 1, 0), (?, ?, 1, 1, 0)').run(DANA, OTTO, OTTO, DANA);
++ // Sharing: DANA owns a paste shared as people, and is a member of OTTO's paste with a pending ask.
++ await db.prepare("INSERT INTO shares (service, kind, resource_id, owner_subject, audience) VALUES ('community','paste','p1',?::jsonb,'people'), ('community','paste','p3',?::jsonb,'people')")
++ .run(JSON.stringify({ type: 'user', id: DANA }), JSON.stringify({ type: 'user', id: OTTO }));
++ await db.prepare("INSERT INTO share_members (service, kind, resource_id, subject, role, added_by, added_at) VALUES ('community','paste','p3',?::jsonb,'viewer',?::jsonb, now())")
++ .run(JSON.stringify({ type: 'user', id: DANA }), JSON.stringify({ type: 'user', id: OTTO }));
++ await db.prepare("INSERT INTO share_access_requests (id, service, kind, resource_id, subject, requested_at, state) VALUES ('4a5a0b34-96a0-4a69-9c2e-3f7f0a2c5c11','community','paste','p3',?::jsonb, now(), 'pending')")
++ .run(JSON.stringify({ type: 'user', id: DANA }));
evt = (id, type, payload) => ({ event_id: id, event_type: type, version: 1, source: 'network', visibility: 'internal', timestamp: new Date().toISOString(),
subject: { type: 'user', id: payload.subject }, actor: { type: 'user', id: payload.subject }, payload });
assert.ok(TOPICS.includes('network.account.export_requested') && TOPICS.includes('network.account.deleted'), 'Community subscribes to both');
@@@ -98,6 +109,9 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
assert.deepStrictEqual(await all('SELECT subject_id AS s FROM game_progress'), [{ s: OTTO }]);
assert.strictEqual((await all('SELECT * FROM network_blocks')).length, 0, 'blocks both ways');
assert.strictEqual((await db.prepare('SELECT created_by FROM spaces WHERE id = 901').get()).created_by, null, 'the space stays without her');
++ assert.deepStrictEqual(await all('SELECT resource_id AS r FROM shares ORDER BY resource_id'), [{ r: 'p3' }], 'her own paste\'s share row went; someone else\'s stays');
++ assert.strictEqual((await all('SELECT * FROM share_members')).length, 0, 'her membership is gone');
++ assert.strictEqual((await all("SELECT * FROM share_access_requests WHERE state = 'pending'")).length, 0, 'her pending ask is closed');
r = await post(del);
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
iindex 35c0498..3a9d702 100644
---- a/test/helpers/mock-media.js
++++ b/test/helpers/mock-media.js
@@@ -11,20 +11,48 @@ const { serviceAuth, ids } = require('openvibe-contracts');
function start({ publicPem, issuer }) {
const uploads = [];
++ const deletes = []; // v2 object ids a DELETE removed
const pending = new Map(); // v2 objects between init and complete
let failNext = false;
++ let failNextDelete = false;
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.method === 'GET' && req.url === '/healthz') return json(200, { ok: true, service: 'mock-media' });
-- const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete))?$/);
++ // A public object's bytes (a private one is reached through /download, below).
++ const open = req.url.match(/^\/o\/(med_[0-9A-Z]+)$/);
++ if (open && req.method === 'GET') {
++ const o = uploads.find((u) => u.key === open[1]);
++ if (!o) return json(404, { error: 'not found' });
++ res.writeHead(200, { 'Content-Type': o.mime }); return res.end(o.bytes);
++ }
++ const del = req.url.match(/^\/api\/v2\/community\/objects\/(med_[0-9A-Z]+)$/);
++ if (del && req.method === 'DELETE') {
++ const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('media.object.delete')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ if (failNextDelete) { failNextDelete = false; return json(500, { error: 'delete failed' }); }
++ const i = uploads.findIndex((u) => u.key === del[1]);
++ if (i < 0) return json(404, { code: 'media.object.not_found' });
++ uploads.splice(i, 1);
++ deletes.push(del[1]);
++ return json(200, { id: del[1], lifecycle_status: 'deleted' });
++ }
++ const v2 = req.url.match(/^\/api\/v2\/community\/objects(?:\/(med_[0-9A-Z]+)\/(content|complete|download))?(?:\?.*)?$/);
if (!v2 && (req.method !== 'POST' || req.url !== '/api/v1/community/files')) return json(404, { error: 'not found' });
const v = serviceAuth.verifyServiceToken(String(req.headers.authorization || '').slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.media' });
if (!v.ok) return json(401, { code: v.code, error: v.reason });
-- if (!(v.claims.cap || []).includes('media.object.upload')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ const verb = v2 && req.method === 'GET' && v2[2] === 'download' ? 'media.object.read' : 'media.object.upload';
++ if (!(v.claims.cap || []).includes(verb)) return json(403, { code: 'capability.denied', error: 'not granted' });
const body = Buffer.concat(chunks);
++ if (v2 && req.method === 'GET' && v2[2] === 'download') {
++ const o = uploads.find((u) => u.key === v2[1]);
++ if (!o) return json(404, { code: 'media.object.not_found' });
++ // Private objects answer a short-lived URL of their own; the shape is what Community reads.
++ return json(200, { url: `http://127.0.0.1:${server.address().port}/o/${o.key}`, public: o.visibility !== 'private' });
++ }
if (v2) {
if (req.method === 'POST' && !v2[1]) {
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
@@@ -63,8 +91,9 @@ function start({ publicPem, issuer }) {
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
-- uploads,
++ uploads, deletes,
failNext: () => { failNext = true; },
++ failNextDelete: () => { failNextDelete = true; },
close: () => new Promise((r) => server.close(r)),
})));
}
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex d4ba447..c591235 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -20,6 +20,7 @@ function start({ clientSecret = 'shh' } = {}) {
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const resolveCalls = [];
++ const pushes = []; // POST /internal/notifications/push bodies
const issued = []; // every client-credentials access token handed out (security-secrets.test.js looks for them)
let issuer = 'http://network.test';
@@@ -86,6 +87,15 @@ function start({ clientSecret = 'shh' } = {}) {
}
return json(200, { results });
}
++ if (req.url === '/internal/notifications/push' && req.method === 'POST') {
++ const auth = String(req.headers.authorization || '');
++ const v = serviceAuth.verifyServiceToken(auth.slice(7), { publicKey: publicPem, issuer, audience: 'openvibe.network' });
++ if (!v.ok) return json(401, { code: v.code, error: v.reason });
++ if (!(v.claims.cap || []).includes('network.notifications.push')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ let body = {}; try { body = JSON.parse(raw); } catch { /* */ }
++ pushes.push(body);
++ return json(201, { sent: true });
++ }
if (req.url.startsWith('/internal/identity/resolve?') && req.method === 'GET') {
// GET /internal/identity/resolve?username= (Network #56): the current holder of a name, any case, a
// leading @ ignored; 404 for nobody (or a banned account).
@@@ -114,7 +124,7 @@ function start({ clientSecret = 'shh' } = {}) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
issuer = url;
-- resolve({ url, grants, issued, resolveCalls, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
++ resolve({ url, grants, issued, resolveCalls, pushes, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
}));
}
ddiff --git a/test/paste-sharing.test.js b/test/paste-sharing.test.js
nnew file mode 100644
iindex 0000000..46120d5
---- /dev/null
++++ b/test/paste-sharing.test.js
@@@ -0,0 +1,396 @@
++'use strict';
++/**
++ * Sharing on pastes (design 2026-11-06, the pilot): every audience against every read path, the gate,
++ * transfers both ways, the one-call agent API, burn-after-reading refused for people, the backfill of
++ * the three old visibility values, and the share events carrying ids only.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++const { boot, check, done } = require('./helpers/app');
++const sharingModule = require('../server/sharing');
++const pastesApiModule = require('../server/pastes/api');
++
++(async () => {
++ const t = await boot({ pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 }, appOpts: { startSharingWorker: false } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 21, username: 'alex', display_name: 'Alex' });
++ const maticus = net.addUser({ network_user_id: 22, username: 'maticus', display_name: 'Maticus' });
++ const sam = net.addUser({ network_user_id: 23, username: 'sam', display_name: 'Sam' });
++ const jwt = (u, role = 'user') => net.sign({ id: u.network_user_id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role });
++ const alexJwt = jwt(alex), maticusJwt = jwt(maticus), samJwt = jwt(sam);
++ const bossJwt = net.sign({ id: 1, subject_id: 'usr_' + '01J0000000000000000000000A', username: 'boss', display_name: 'Boss', role: 'admin' });
++
++ const call = (pathname, { method = 'GET', cookie, token, json, headers = {} } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(pathname, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const share = (slug, opts = {}) => call(`/api/v1/paste/${encodeURIComponent(slug)}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });
++ const transferAnswer = (slug, verb, transferId, cookie) => call(`/api/v1/paste/${encodeURIComponent(slug)}/share/transfer/${verb}`,
++ { method: 'POST', cookie, json: { transfer_id: transferId }, headers: { 'x-requested-with': 'ov-share' } });
++ const startTransfer = (slug, cookie, body) => call(`/api/v1/paste/${encodeURIComponent(slug)}/share/transfer`,
++ { method: 'POST', cookie, json: body, headers: { 'x-requested-with': 'ov-share' } });
++ const make = async (cookie, input = {}) => {
++ const r = await call('/api/pastes', { method: 'POST', cookie, json: { content: 'body', ...input } });
++ assert.strictEqual(r.status, 201, r.text);
++ return r.json();
++ };
++ const row = async (slug) => await t.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('community', 'paste', slug);
++
++ let privateSlug; // alex's, nobody else's
++
++ await check('a new paste is registered with its audience in the same transaction; people maps to private in the old column', async () => {
++ assert.deepStrictEqual(sharingModule.refOf('abc'), { service: 'community', kind: 'paste', id: 'abc' });
++ assert.strictEqual(typeof pastesApiModule.createPastesApi, 'function');
++ const pub = await make(alexJwt, { title: 'Public note', content: 'public body' });
++ let s = await row(pub.slug);
++ assert.strictEqual(s.audience, 'public');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: alex.subject_id });
++
++ const link = await make(alexJwt, { content: 'link body', visibility: 'unlisted' });
++ s = await row(link.slug);
++ assert.strictEqual(s.audience, 'link');
++
++ const priv = await make(alexJwt, { content: 'private body', visibility: 'private' });
++ privateSlug = priv.slug;
++ s = await row(priv.slug);
++ assert.strictEqual(s.audience, 'private');
++ assert.strictEqual((await t.db.prepare('SELECT visibility FROM pastes WHERE slug = ?').get(priv.slug)).visibility, 'private');
++
++ const people = await make(alexJwt, { title: 'For two', content: 'two only', audience: 'people', members: ['@maticus'] });
++ s = await row(people.slug);
++ assert.strictEqual(s.audience, 'people');
++ assert.strictEqual((await t.db.prepare('SELECT visibility FROM pastes WHERE slug = ?').get(people.slug)).visibility, 'private', 'an old reader of the column fails closed');
++ const members = await t.db.prepare('SELECT subject, role FROM share_members WHERE resource_id = ?').all(people.slug);
++ assert.strictEqual(members.length, 1);
++ assert.deepStrictEqual(members[0].subject, { type: 'user', id: maticus.subject_id });
++ assert.strictEqual(members[0].role, 'viewer');
++ });
++
++ await check('the paste JSON API is never cacheable: public, link, people, the list and a 404 all say private, no-store', async () => {
++ const pub = await make(alexJwt, { title: 'Cache public', content: 'c' });
++ const link = await make(alexJwt, { content: 'c', visibility: 'unlisted' });
++ const people = await make(alexJwt, { content: 'c', audience: 'people', members: ['@maticus'] });
++ for (const [slug, opts] of [[pub.slug, {}], [link.slug, {}], [people.slug, { cookie: alexJwt }], [people.slug, { cookie: maticusJwt }],
++ [people.slug, { cookie: samJwt }], ['no-such-paste-404', {}], [pub.slug, { cookie: bossJwt }]]) {
++ const r = await call(`/api/pastes/${slug}`, opts);
++ assert.strictEqual(r.headers.get('cache-control'), 'private, no-store', `${slug} ${JSON.stringify(opts)} answered ${r.status} with ${r.headers.get('cache-control')}`);
++ }
++ assert.strictEqual((await call('/api/pastes?limit=5')).headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await call('/api/pastes?limit=5', { cookie: alexJwt })).headers.get('cache-control'), 'private, no-store');
++ assert.strictEqual((await call('/api/pastes/no-such-1/raw')).headers.get('cache-control'), 'private, no-store', 'a JSON redirect route too');
++ });
++
++ await check('the backfill maps all three old visibility values, and an ownerless paste to Community itself', async () => {
++ await t.db.exec("DELETE FROM share_deliveries");
++ await t.db.exec("DELETE FROM share_members");
++ await t.db.exec("DELETE FROM share_transfers");
++ await t.db.exec("DELETE FROM share_access_requests");
++ await t.db.exec("DELETE FROM shares WHERE service = 'community' AND kind = 'paste'");
++ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, visibility) VALUES (?,?,?,?,?,?)")
++ .run('backfill-pub-1', alex.subject_id, 'paste', 'b1', 'c', 'public');
++ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, visibility) VALUES (?,?,?,?,?,?)")
++ .run('backfill-link-1', alex.subject_id, 'paste', 'b2', 'c', 'unlisted');
++ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, visibility) VALUES (?,?,?,?,?,?)")
++ .run('backfill-priv-1', alex.subject_id, 'paste', 'b3', 'c', 'private');
++ await t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, visibility) VALUES (?,?,?,?,?,?)")
++ .run('backfill-anon-1', null, 'paste', 'b4', 'c', 'public');
++ const sql = fs.readFileSync(path.join(__dirname, '..', 'migrations', '0016_sharing_backfill.sql'), 'utf8');
++ await t.db.exec(sql);
++ const rows = await t.db.prepare("SELECT resource_id, audience, owner_subject FROM shares WHERE resource_id LIKE 'backfill-%' ORDER BY resource_id").all();
++ const by = Object.fromEntries(rows.map((r) => [r.resource_id, r]));
++ assert.strictEqual(by['backfill-pub-1'].audience, 'public');
++ assert.strictEqual(by['backfill-link-1'].audience, 'link');
++ assert.strictEqual(by['backfill-priv-1'].audience, 'private');
++ assert.deepStrictEqual(by['backfill-priv-1'].owner_subject, { type: 'user', id: alex.subject_id });
++ assert.deepStrictEqual(by['backfill-anon-1'].owner_subject, { type: 'service', id: 'community' });
++ for (const slug of ['backfill-pub-1', 'backfill-link-1', 'backfill-priv-1', 'backfill-anon-1']) await t.db.prepare('DELETE FROM pastes WHERE slug = ?').run(slug);
++ });
++
++ await check('owner and member read every path; a signed-in non-member and a signed-out reader get exactly 404, the gate, and no title/content', async () => {
++ const two = await make(alexJwt, { title: 'The LOOI bridge notes', content: 'SECRET-LOOI', audience: 'people', members: ['@maticus'] });
++ const slug = two.slug;
++ // Owner.
++ for (const [who, cookie] of [['owner', alexJwt], ['member', maticusJwt]]) {
++ const page = await call(`/p/${slug}`, { cookie });
++ assert.strictEqual(page.status, 200, `${who} page: ${page.text.slice(0, 120)}`);
++ assert.ok(page.text.includes('The LOOI bridge notes'), `${who} sees the title`);
++ assert.ok(page.text.includes('SECRET-LOOI'), `${who} sees the content`);
++ assert.strictEqual((await call(`/p/${slug}/raw`, { cookie })).text, 'SECRET-LOOI', `${who} raw`);
++ assert.strictEqual((await call(`/api/pastes/${slug}`, { cookie })).status, 200, `${who} api`);
++ assert.strictEqual((await call(`/p/${slug}/download`, { cookie })).text, 'SECRET-LOOI', `${who} download`);
++ }
++ // Non-member signed in: the gate, 404, never the title or the content or the comments.
++ const gate = await call(`/p/${slug}`, { cookie: samJwt });
++ assert.strictEqual(gate.status, 404);
++ assert.ok(gate.text.includes('shared with specific people'), 'OVShare.gate');
++ assert.ok(gate.text.includes('ov-share.js') && gate.text.includes('OVShare.gate'), 'the gate component');
++ assert.ok(!gate.text.includes('The LOOI bridge notes') && !gate.text.includes('SECRET-LOOI'), 'no title, no content');
++ assert.strictEqual((await call(`/p/${slug}/raw`, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call(`/api/pastes/${slug}`, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call(`/p/${slug}/download`, { cookie: samJwt })).status, 404);
++ // Signed out: the gate with Sign in (an /auth/login link), still nothing else.
++ const anonGate = await call(`/p/${slug}`);
++ assert.strictEqual(anonGate.status, 404);
++ assert.ok(anonGate.text.includes('/auth/login?next='), 'sign in');
++ assert.ok(!anonGate.text.includes('SECRET-LOOI'));
++ // Comments on it are not readable either: the thread cannot even be opened by an outsider.
++ const thread = await call('/api/v1/comments/threads/resolve', { method: 'POST', cookie: samJwt, json: { ref: { service: 'community', type: 'paste', id: slug } } });
++ assert.strictEqual(thread.status, 404, thread.text);
++ // The API body of a 404 is the plain error, not a share shape.
++ assert.deepStrictEqual((await call(`/api/pastes/${slug}`, { cookie: samJwt })).json(), { error: 'Paste not found' });
++ });
++
++ await check('staff may look (and the look is in the append-only log); people/private pages carry a generic link preview and no JSON-LD', async () => {
++ const two = await make(alexJwt, { title: 'Staff peek', content: 'STAFF-SECRET', audience: 'people', members: ['@maticus'] });
++ const page = await call(`/p/${two.slug}`, { cookie: bossJwt });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('STAFF-SECRET'));
++ const logs = await t.db.prepare("SELECT action FROM share_log WHERE resource_id = ? AND action LIKE 'staff.%'").all(two.slug);
++ assert.ok(logs.length >= 1, 'the staff view is logged');
++ assert.ok(page.text.includes('property="og:title" content="A paste on OpenVibe Community'), 'generic og:title');
++ assert.ok(!page.text.includes('property="og:title" content="Staff peek"'));
++ const ld = page.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/);
++ assert.ok(!ld || !ld[1].includes('Staff peek'), 'no paste JSON-LD for people');
++ assert.ok(!/og:image" content="[^"]*openvibe\.media/.test(page.text), 'no picture in the preview');
++ });
++
++ await check('public and link pastes read anonymously; only public ones reach the lists, the feed, llms and search', async () => {
++ const pub = await make(alexJwt, { title: 'Listed public paste', content: 'public text' });
++ const link = await make(alexJwt, { title: 'Link only paste', content: 'link text', visibility: 'unlisted' });
++ const people = await make(alexJwt, { title: 'People only paste', content: 'people text', audience: 'people', members: ['@maticus'] });
++ assert.strictEqual((await call(`/p/${pub.slug}`)).status, 200);
++ assert.strictEqual((await call(`/p/${link.slug}`)).status, 200, 'the link is the key');
++ assert.strictEqual((await call(`/api/pastes/${link.slug}`)).status, 200);
++ assert.strictEqual((await call(`/p/${people.slug}`)).status, 404);
++ const list = (await call('/api/pastes?limit=200')).json().pastes.map((p) => p.slug);
++ assert.ok(list.includes(pub.slug));
++ assert.ok(!list.includes(link.slug) && !list.includes(people.slug), 'nothing unlisted or people in a public list');
++ const browse = (await call('/pastes?type=all')).text;
++ assert.ok(browse.includes('Listed public paste'));
++ assert.ok(!browse.includes('Link only paste') && !browse.includes('People only paste'));
++ const feed = (await call('/feed.xml')).text;
++ assert.ok(feed.includes('Listed public paste'));
++ assert.ok(!feed.includes('Link only paste') && !feed.includes('People only paste'));
++ const llms = (await call('/llms-full.txt')).text;
++ assert.ok(!llms.includes('People only paste'));
++ const sitemap = (await call('/sitemap.xml')).text;
++ assert.ok(!sitemap.includes(`/p/${pub.slug}`) && !sitemap.includes(`/p/${people.slug}`), 'paste pages are noindex and never in a sitemap');
++ // The agent twin is anonymous: a people paste has none.
++ assert.strictEqual((await call(`/p/${people.slug}.md`)).status, 404, 'no Markdown twin of a restricted paste');
++ assert.strictEqual((await call(`/p/${pub.slug}.md`)).status, 200);
++ });
++
++ await check('the owner changes the audience; people drops it from the public list at once and the old column follows', async () => {
++ const pub = await make(alexJwt, { title: 'Going private', content: 'x' });
++ const put = await share(pub.slug, { method: 'PUT', cookie: alexJwt, json: { audience: 'people', members: [{ name: '@maticus', role: 'viewer' }] } });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().audience, 'people');
++ assert.strictEqual((await t.db.prepare('SELECT visibility FROM pastes WHERE slug = ?').get(pub.slug)).visibility, 'private');
++ assert.strictEqual((await call(`/p/${pub.slug}`)).status, 404);
++ assert.strictEqual((await call(`/p/${pub.slug}`, { cookie: maticusJwt })).status, 200);
++ });
++
++ await check('transfer: the recipient previews, accepts (old owner loses access), and decline/cancel leave the owner in place', async () => {
++ const p = await make(alexJwt, { title: 'Giving it away', content: 'GIFT', visibility: 'private' });
++ let s = await startTransfer(p.slug, alexJwt, { to: '@maticus', keep_access: false });
++ // POST .../share/transfer answers the GET body (201), with the pending transfer in it.
++ assert.strictEqual(s.status, 201, s.text);
++ const transferId = s.json().transfer.id;
++ assert.strictEqual(s.json().transfer.state, 'pending');
++ // The recipient may look before accepting, and sees the offer banner.
++ const preview = await call(`/p/${p.slug}`, { cookie: maticusJwt });
++ assert.strictEqual(preview.status, 200);
++ assert.ok(preview.text.includes('paste-offer') && preview.text.includes(transferId), 'OVShare.offer');
++ // Someone else is still out.
++ assert.strictEqual((await call(`/p/${p.slug}`, { cookie: samJwt })).status, 404);
++ // Accept as the recipient.
++ const accepted = await transferAnswer(p.slug, 'accept', transferId, maticusJwt);
++ assert.strictEqual(accepted.status, 200, accepted.text);
++ assert.strictEqual(accepted.json().state, 'accepted');
++ assert.strictEqual((await call(`/api/pastes/${p.slug}`, { cookie: maticusJwt })).json().paste.owner_subject, maticus.subject_id);
++ assert.strictEqual((await call(`/p/${p.slug}`, { cookie: alexJwt })).status, 404, 'the old owner loses access');
++ assert.strictEqual((await t.db.prepare('SELECT owner_subject FROM pastes WHERE slug = ?').get(p.slug)).owner_subject, maticus.subject_id);
++
++ // Decline: nothing changes.
++ const q = await make(alexJwt, { content: 'stay mine', visibility: 'private' });
++ let tr = (await startTransfer(q.slug, alexJwt, { to: '@sam' })).json().transfer;
++ assert.strictEqual((await transferAnswer(q.slug, 'decline', tr.id, samJwt)).json().state, 'declined');
++ assert.strictEqual((await call(`/p/${q.slug}`, { cookie: alexJwt })).status, 200);
++ assert.strictEqual((await call(`/p/${q.slug}`, { cookie: samJwt })).status, 404);
++
++ // Cancel by the owner: the recipient's preview ends.
++ tr = (await startTransfer(q.slug, alexJwt, { to: '@sam' })).json().transfer;
++ assert.strictEqual((await transferAnswer(q.slug, 'cancel', tr.id, alexJwt)).status, 200);
++ assert.strictEqual((await call(`/p/${q.slug}`, { cookie: samJwt })).status, 404);
++
++ // Expiry: an offer whose time has past cannot be accepted.
++ tr = (await startTransfer(q.slug, alexJwt, { to: '@sam' })).json().transfer;
++ await t.db.prepare("UPDATE share_transfers SET expires_at = now() - interval '1 minute' WHERE id = ?").run(tr.id);
++ assert.strictEqual((await transferAnswer(q.slug, 'accept', tr.id, samJwt)).status, 409);
++ assert.strictEqual((await call(`/p/${q.slug}`, { cookie: alexJwt })).status, 200, 'a lapsed offer changes nothing');
++ });
++
++ await check('transfer with keep_access true: the old owner keeps viewing, the paste becomes people so that access is real', async () => {
++ const p = await make(alexJwt, { title: 'Keep me', content: 'KEEP', visibility: 'private' });
++ const tr = (await startTransfer(p.slug, alexJwt, { to: '@maticus', keep_access: true })).json().transfer;
++ assert.strictEqual((await transferAnswer(p.slug, 'accept', tr.id, maticusJwt)).status, 200);
++ const s = await row(p.slug);
++ assert.strictEqual(s.audience, 'people');
++ assert.strictEqual((await call(`/api/pastes/${p.slug}`, { cookie: maticusJwt })).json().paste.owner_subject, maticus.subject_id);
++ assert.strictEqual((await call(`/p/${p.slug}`, { cookie: alexJwt })).status, 200, 'the old owner still reads it');
++ assert.strictEqual((await call(`/p/${p.slug}`, { cookie: samJwt })).status, 404, 'and nobody else does');
++ });
++
++ await check('a transferred screenshot\'s Media object follows the paste: re-hosted private, owned by the new owner', async () => {
++ const jpeg = Buffer.concat([Buffer.from([0xff, 0xd8]), Buffer.from([0xff, 0xda, 0x00, 0x04, 0x00, 0x00]), Buffer.from('TRANSFER-PIXELS'), Buffer.from([0xff, 0xd9])]);
++ const fd = new FormData();
++ fd.append('title', 'Transfer shot');
++ fd.append('visibility', 'private');
++ fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 't.jpg');
++ const made = await t.get('/api/pastes/screenshot', { method: 'POST', cookies: [`ov_token=${alexJwt}`], body: fd });
++ assert.strictEqual(made.status, 201, made.text);
++ const slug = made.json().slug;
++ const before = t.media.uploads[t.media.uploads.length - 1];
++ assert.strictEqual(before.visibility, 'private');
++ assert.strictEqual(before.owner, alex.subject_id);
++ const tr = (await startTransfer(slug, alexJwt, { to: '@maticus', keep_access: false })).json().transfer;
++ assert.strictEqual((await transferAnswer(slug, 'accept', tr.id, maticusJwt)).status, 200);
++ const after = t.media.uploads[t.media.uploads.length - 1];
++ assert.notStrictEqual(after.key, before.key, 'a new object');
++ assert.strictEqual(after.visibility, 'private');
++ assert.strictEqual(after.owner, maticus.subject_id, 'owned by the new owner');
++ const row = await t.db.prepare('SELECT media_ref, metadata FROM pastes WHERE slug = ?').get(slug);
++ assert.strictEqual(row.media_ref, after.key);
++ assert.strictEqual(t.media.uploads.find((u) => u.key === before.key), undefined, 'the old object is gone');
++ assert.ok(t.media.deletes.includes(before.key));
++ assert.strictEqual(JSON.parse(row.metadata).previous_media_ref, undefined, 'nothing left to retry');
++ assert.strictEqual((await call(`/p/${slug}/screenshot`, { cookie: maticusJwt })).status, 200);
++ });
++
++ await check('one API call: audience people + members + transfer_to makes a paste only the two can open and offers it', async () => {
++ const r = await call('/api/pastes', {
++ method: 'POST', cookie: alexJwt,
++ json: { title: 'Only two', content: 'AGENT-SECRET', audience: 'people', members: ['@maticus'], transfer_to: '@maticus', keep_access: false },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ const out = r.json();
++ assert.strictEqual(out.paste.audience, 'people');
++ assert.ok(out.transfer && out.transfer.id && out.transfer.state === 'pending');
++ assert.strictEqual((await call(`/p/${out.slug}`)).status, 404);
++ assert.strictEqual((await call(`/p/${out.slug}`, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call(`/p/${out.slug}`, { cookie: maticusJwt })).status, 200);
++ assert.strictEqual((await call(`/p/${out.slug}`, { cookie: alexJwt })).status, 200);
++ const logs = await t.db.prepare("SELECT action FROM share_log WHERE resource_id = ? ORDER BY sequence").all(out.slug);
++ assert.ok(logs.some((l) => l.action === 'member.added'));
++ assert.ok(logs.some((l) => l.action === 'transfer.requested'));
++ });
++
++ await check('burn after reading cannot be shared with specific people (plain refusal)', async () => {
++ const r = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', audience: 'people', members: ['@maticus'], burn_after_read: true } });
++ assert.strictEqual(r.status, 400);
++ assert.match(r.json().error, /Burn-after-reading pastes cannot be shared with specific people/);
++ const p = await make(alexJwt, { content: 'once too', burn_after_read: true });
++ const put = await share(p.slug, { method: 'PUT', cookie: alexJwt, json: { audience: 'people', members: ['@maticus'] } });
++ assert.strictEqual(put.status, 400);
++ assert.strictEqual((await row(p.slug)).audience, 'public', 'nothing changed');
++ });
++
++ await check('share events carry ids only: no title or content in any queued delivery', async () => {
++ const title = 'EVENT-TITLE-MUST-NOT-LEAK';
++ const p = await make(alexJwt, { title, content: 'EVENT-BODY-MUST-NOT-LEAK', audience: 'people', members: ['@maticus'] });
++ const rows = await t.db.prepare("SELECT body FROM share_deliveries WHERE body->>'channel' IS NULL").all();
++ // Every queued body, both events and notifications, is a JSON object; none may name the paste.
++ const all = await t.db.prepare('SELECT channel, body FROM share_deliveries').all();
++ assert.ok(all.length >= 1);
++ for (const d of all) {
++ const text = JSON.stringify(d.body);
++ assert.ok(!text.includes(title), `${d.channel} must not carry the title`);
++ assert.ok(!text.includes('EVENT-BODY-MUST-NOT-LEAK'), `${d.channel} must not carry the content`);
++ }
++ assert.ok(rows !== undefined);
++ assert.ok(!JSON.stringify(await row(p.slug)).includes(title), 'the share row never stores a title');
++ });
++
++ await check('the screenshot proxy is the only way to the bytes: members through it, outsiders never, and no Media URL in HTML', async () => {
++ const jpeg = Buffer.concat([Buffer.from([0xff, 0xd8]), Buffer.from([0xff, 0xda, 0x00, 0x04, 0x00, 0x00]), Buffer.from('PIXELS'), Buffer.from([0xff, 0xd9])]);
++ const fd = new FormData();
++ fd.append('title', 'Shot for two');
++ fd.append('audience', 'people');
++ fd.append('members', '@maticus');
++ fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 's.jpg');
++ const made = await t.get('/api/pastes/screenshot', { method: 'POST', cookies: [`ov_token=${alexJwt}`], body: fd });
++ assert.strictEqual(made.status, 201, made.text);
++ const slug = made.json().slug;
++ const up = t.media.uploads[t.media.uploads.length - 1];
++ assert.strictEqual(up.visibility, 'private');
++ const page = await call(`/p/${slug}`, { cookie: maticusJwt });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes(`/p/${slug}/screenshot`));
++ assert.ok(!page.text.includes('openvibe.media/o/') && !page.text.includes(up.key), 'never the Media object URL');
++ const bytes = await call(`/p/${slug}/screenshot`, { cookie: maticusJwt });
++ assert.strictEqual(bytes.status, 200);
++ assert.strictEqual(bytes.headers.get('content-type'), 'image/jpeg');
++ assert.ok(bytes.text.includes('PIXELS'));
++ assert.strictEqual((await call(`/p/${slug}/screenshot`, { cookie: samJwt })).status, 404);
++ assert.strictEqual((await call(`/p/${slug}/screenshot`)).status, 404);
++ assert.strictEqual((await call(`/p/${slug}/screenshot`, { cookie: alexJwt })).status, 200, 'the owner too');
++ });
++
++ await check('the move script re-hosts, deletes the old object, and retries a failed delete; --keep-old keeps it', async () => {
++ const { createMediaObjects } = require('../server/media/objects');
++ const { moveScreenshotsPrivate } = require('../scripts/move-screenshots-private');
++ const cfg = require('../server/config');
++ const mo = createMediaObjects({ config: cfg });
++ const quiet = { log() {}, warn() {} };
++ const mkOld = async (slug) => {
++ const o = await mo.uploadImage({ buffer: Buffer.from('OLD-SHOT-BYTES'), mime: 'image/png', filename: 'old.png', owner: alex.subject_id, kind: 'screenshot', visibility: 'unlisted' });
++ await t.db.prepare("INSERT INTO shares (service, kind, resource_id, owner_subject, audience) VALUES ('community','paste',?,?::jsonb,'public')")
++ .run(slug, JSON.stringify({ type: 'user', id: alex.subject_id }));
++ await t.db.prepare('INSERT INTO pastes (slug, owner_subject, type, title, content, visibility, screenshot_url, media_ref, metadata) VALUES (?,?,?,?,?,?,?,?,?)')
++ .run(slug, alex.subject_id, 'screenshot', 'Old shot', '', 'public', `${t.media.url}/o/${o.id}`, o.id, JSON.stringify({ mime_type: 'image/png', original_name: 'old.png' }));
++ return o.id;
++ };
++
++ // 1. A move deletes the old object and clears the record.
++ const first = await mkOld('old-unlisted-shot-1');
++ let out = await moveScreenshotsPrivate({ db: t.db, mediaObjects: mo, limit: 20, apply: true, log: quiet });
++ assert.strictEqual(out.moved, 1, JSON.stringify(out));
++ assert.strictEqual(out.deleted, 1, JSON.stringify(out));
++ assert.strictEqual(t.media.uploads.find((u) => u.key === first), undefined, 'the old object is gone');
++ let row = await t.db.prepare('SELECT screenshot_url, media_ref, metadata FROM pastes WHERE slug = ?').get('old-unlisted-shot-1');
++ assert.match(row.media_ref, /^med_[0-9A-HJKMNP-TV-Z]{26}$/);
++ assert.notStrictEqual(row.media_ref, first, 'a new object');
++ assert.strictEqual(JSON.parse(row.metadata).media_visibility, 'private');
++ assert.strictEqual(JSON.parse(row.metadata).previous_media_ref, undefined);
++ assert.strictEqual((await call(`/p/${'old-unlisted-shot-1'}/screenshot`, { cookie: alexJwt })).status, 200);
++ assert.strictEqual((await call(`/p/${'old-unlisted-shot-1'}/screenshot`)).status, 200, 'the audience is still public');
++
++ // 2. --keep-old leaves the old object alone (an operator's explicit choice).
++ const kept = await mkOld('old-unlisted-shot-2');
++ out = await moveScreenshotsPrivate({ db: t.db, mediaObjects: mo, limit: 20, apply: true, keepOld: true, log: quiet });
++ assert.ok(out.moved >= 1 && out.deleted === 0, JSON.stringify(out));
++ assert.ok(t.media.uploads.find((u) => u.key === kept), 'the old object stays');
++ row = await t.db.prepare('SELECT metadata FROM pastes WHERE slug = ?').get('old-unlisted-shot-2');
++ assert.strictEqual(JSON.parse(row.metadata).previous_media_ref, undefined, 'nothing queued for deletion');
++
++ // 3. A failed delete is recorded and retried on the next run.
++ const stubborn = await mkOld('old-unlisted-shot-3');
++ t.media.failNextDelete();
++ out = await moveScreenshotsPrivate({ db: t.db, mediaObjects: mo, limit: 20, apply: true, log: quiet });
++ assert.ok(out.deleteFailed >= 1, JSON.stringify(out));
++ assert.ok(t.media.uploads.find((u) => u.key === stubborn), 'still there after the failure');
++ row = await t.db.prepare('SELECT metadata FROM pastes WHERE slug = ?').get('old-unlisted-shot-3');
++ assert.strictEqual(JSON.parse(row.metadata).previous_media_ref, stubborn, 'recorded for the retry');
++ out = await moveScreenshotsPrivate({ db: t.db, mediaObjects: mo, limit: 20, apply: true, log: quiet });
++ assert.ok(out.deleted >= 1, JSON.stringify(out));
++ assert.strictEqual(t.media.uploads.find((u) => u.key === stubborn), undefined, 'the retry deleted it');
++ row = await t.db.prepare('SELECT metadata FROM pastes WHERE slug = ?').get('old-unlisted-shot-3');
++ assert.strictEqual(JSON.parse(row.metadata).previous_media_ref, undefined, 'the record is cleared');
++ });
++
++ done();
++})().catch((err) => { console.error(err); process.exitCode = 1; });
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
iindex d9d8dc4..42bbaef 100644
---- a/test/pastes-api.test.js
++++ b/test/pastes-api.test.js
@@@ -271,7 +271,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.owner_subject, alex.subject_id);
-- assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'unlisted', 'a v2 Media object: unlisted screenshot');
++ assert.ok(up.v2 && up.kind === 'screenshot' && up.visibility === 'private', 'a v2 Media object: private screenshot');
assert.strictEqual(up.owner, alex.subject_id, 'owned by the person');
assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.title, 'My desk');
@@@ -321,7 +321,7 @@ const { boot, check, done } = require('./helpers/app');
assert.match(plain.json().slug, /^[a-z]+-[a-z]+-\d{2,4}$/, 'a public non-burn paste keeps its short slug');
});
-- await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot → stored URL', async () => {
++ await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot is the checked proxy', async () => {
const raw = await call(`/p/${alexSlug}/raw`);
assert.strictEqual(raw.status, 200);
assert.strictEqual(raw.headers.get('content-type'), 'text/plain; charset=utf-8');
@@@ -334,8 +334,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(api.status, 302);
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
const shot = await call(`/p/${shotSlug}/screenshot`);
-- assert.strictEqual(shot.status, 302);
-- assert.match(shot.headers.get('location'), /^https:\/\/openvibe\.media\/o\/med_/);
++ assert.strictEqual(shot.status, 200, shot.text);
++ assert.strictEqual(shot.headers.get('content-type'), 'image/jpeg');
++ assert.ok(shot.text.includes('pixels'), 'the proxy serves the bytes');
++ assert.strictEqual(shot.headers.get('location'), null, 'never a Media URL');
assert.strictEqual((await call(`/p/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);
assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);
const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;
ddiff --git a/test/security-private.test.js b/test/security-private.test.js
iindex 69a697a..e8f6764 100644
---- a/test/security-private.test.js
++++ b/test/security-private.test.js
@@@ -155,7 +155,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, { who: alex })).json().paste.content, `${M.PRIVBODY} zebra`);
assert.strictEqual((await send('GET', `/p/${priv.slug}/raw`, { who: alex })).text, `${M.PRIVBODY} zebra`);
assert.ok((await send('GET', `/p/${priv.slug}`, { who: alex })).text.includes(M.PRIVTITLE));
-- assert.strictEqual((await send('GET', `/p/${privShot.slug}/screenshot`, { who: alex })).status, 302);
++ assert.strictEqual((await send('GET', `/p/${privShot.slug}/screenshot`, { who: alex })).status, 200, 'the checked proxy serves the owner');
assert.ok((await send('GET', `/api/pastes/${priv.slug}/comments`, { who: alex })).text.includes(M.PRIVCOMMENT));
assert.strictEqual((await send('GET', `/api/pastes/${priv.slug}`, svcFor(alex))).status, 200, 'a service acting for the owner');
});
@@@ -177,13 +177,13 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
await check('burn-after-read screenshots: the screenshot link is the read (a second read of any kind finds it burned)', async () => {
const first = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });
-- assert.strictEqual(first.status, 302, first.text);
++ assert.strictEqual(first.status, 200, first.text);
const again = await send('GET', `/p/${burnShot.slug}/screenshot`, { who: sam });
assert.ok(again.status === 404 || again.status === 410, `the screenshot was served again: ${again.status}`);
assert.ok([404, 410].includes((await send('GET', `/api/pastes/${burnShot.slug}`)).status), 'the paste burned');
// The owner's own looks do not burn it.
const mineToo = await shotPaste(alex, { title: 'mine', burn_after_read: 'true' });
-- for (let i = 0; i < 3; i++) assert.strictEqual((await send('GET', `/p/${mineToo.slug}/screenshot`, { who: alex })).status, 302);
++ for (let i = 0; i < 3; i++) assert.strictEqual((await send('GET', `/p/${mineToo.slug}/screenshot`, { who: alex })).status, 200);
assert.strictEqual((await send('GET', `/api/pastes/${mineToo.slug}`, { who: sam })).status, 200, 'still unread');
});
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 4888e83..0d551a5 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -38,6 +38,7 @@ const INVENTORY = {
'server/relay/discord.js': [1, 'a Discord webhook URL read from an environment variable the owner allowlisted; mappings name the variable, never a URL'],
'server/relay/events-worker.js': [3, 'Events pull API (EVENTS_URL) with a service token'],
'server/search/query.js': [1, 'OV_SEARCH_INTERNAL_URL /api/v1/search, the query in URLSearchParams'],
++ 'server/sharing.js': [2, 'Network identity resolve/resolve-batch and /internal/notifications/push (OV_NETWORK_INTERNAL_URL, fixed paths) with a service token; a name is an exact @name, never a URL'],
'server/vip/index.js': [2, 'OV_VIP_INTERNAL_URL policy evaluate, service token'],
};
const PATTERNS = [
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
iindex 4c13829..7a77eb9 100644
---- a/test/ssr-community.test.js
++++ b/test/ssr-community.test.js
@@@ -78,9 +78,9 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(dl.text.startsWith('def add'));
assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views, before);
const shot = await t.get('/p/fair-moon-23');
-- has(shot.text, '<meta property="og:image" content="https://openvibe.media/f/abc-desk.png">');
++ assert.match(shot.text, /<meta property="og:image" content="[^"]*\/p\/fair-moon-23\/screenshot">/);
const dls = await t.get('/p/fair-moon-23/download');
-- assert.strictEqual(dls.headers.get('location'), 'https://openvibe.media/f/abc-desk.png');
++ assert.strictEqual(dls.headers.get('location'), '/p/fair-moon-23/screenshot');
});
await check('browse: search, views sort and language filter over the store', async () => {
ddiff --git a/test/ssr.test.js b/test/ssr.test.js
iindex 2b0aeb4..dd924c5 100644
---- a/test/ssr.test.js
++++ b/test/ssr.test.js
@@@ -134,7 +134,8 @@ const { boot, check, done } = require('./helpers/app');
has(r.text, 'href="/p/amber-fox-42/raw"');
has(r.text, 'href="/p/amber-fox-42/download"');
has(r.text, 'data-copy-content="amber-fox-42"');
-- has(r.text, 'twitter.com/intent/tweet');
++ has(r.text, 'data-ov-share-sheet', 'the one Share sheet (sharing pilot, 2026-11-06)');
++ assert.ok(!r.text.includes('twitter.com/intent/tweet'), 'no hand-made X link any more');
has(r.text, '<h2>More like this</h2>');
has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative avatar made absolute against Live');
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
@@@ -153,12 +154,13 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!r.text.includes('<script>alert(1)</script>'));
});
-- await check('screenshot paste uses the stored Media image as og:image and ImageObject JSON-LD', async () => {
++ await check('screenshot paste: og:image and the picture go through the checked proxy, never the Media URL', async () => {
const r = await t.get('/p/fair-moon-23');
-- has(r.text, '<meta property="og:image" content="https://openvibe.media/o/med_FAIRMOON">');
++ assert.match(r.text, /<meta property="og:image" content="[^"]*\/p\/fair-moon-23\/screenshot">/, 'og:image is the proxy');
has(r.text, '<meta name="twitter:card" content="summary_large_image">');
has(r.text, '"@type":"ImageObject"');
-- has(r.text, '<img src="https://openvibe.media/o/med_FAIRMOON"');
++ has(r.text, '<img src="/p/fair-moon-23/screenshot"');
++ assert.ok(!r.text.includes('openvibe.media/o/med_FAIRMOON'), 'the Media URL never reaches the HTML (sharing pilot)');
});
await check('private paste 404s for anonymous visitors and renders for its owner', async () => {
@@@ -171,13 +173,13 @@ const { boot, check, done } = require('./helpers/app');
has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');
});
-- await check('raw serves the text from the store; screenshot redirects to the stored image; download is an attachment without a view', async () => {
++ await check('raw serves the text from the store; the screenshot goes through the proxy; download is an attachment without a view', async () => {
const raw = await t.get('/p/amber-fox-42/raw');
assert.strictEqual(raw.status, 200);
assert.ok(raw.text.startsWith('const greet'));
const shot = await t.get('/p/fair-moon-23/screenshot');
-- assert.strictEqual(shot.status, 302);
-- assert.strictEqual(shot.headers.get('location'), 'https://openvibe.media/o/med_FAIRMOON');
++ assert.notStrictEqual(shot.status, 302, 'the screenshot is streamed by the checked proxy, not a redirect to Media');
++ assert.ok(!String(shot.headers.get('location') || '').includes('openvibe.media'), 'no Media URL in a redirect');
const before = (await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get('amber-fox-42')).views;
const dl = await t.get('/p/amber-fox-42/download');
assert.strictEqual(dl.status, 200);