openvibers/openvibe.community Commits
Sharing pilot: pastes shared with specific people, and transfer (#261)
Changes
@@@ -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 thenew-thread form, in a Labels box on the thread, or with `PUT /api/v1/spaces/:space/threads/:slug/labels`. Lists and@@@ -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 gatecomments/ 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 -->nnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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;nnew file mode 100644@@@ -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;@@@ -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"@@@ -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"},nnew file mode 100644@@@ -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 };@@@ -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 || ''));@@@ -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);@@@ -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;}@@@ -65,6 +65,17 @@ async function record(eventType, subject, payload, { isPublic = false, actor = nreturn 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 };@@@ -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@@@ -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@@@ -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 };@@@ -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: {@@@ -42,6 +42,13 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = nconst 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.@@@ -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 = {}, limconst 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 = {}, limconst 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 = {}, limreturn { 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 = {}, limif (!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 = {}, limif (!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 = {}, limoriginal_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 = {}, limpatch.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 = {}, limconst 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 = {}, limif (!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');@@@ -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 };@@@ -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 };nnew file mode 100644@@@ -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 };@@@ -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');@@@ -11,20 +11,48 @@ const { serviceAuth, ids } = require('openvibe-contracts');function start({ publicPem, issuer }) {const uploads = [];++ const deletes = []; // v2 object ids a DELETE removedconst pending = new Map(); // v2 objects between init and completelet 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)),})));}@@@ -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 bodiesconst 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)) });}));}nnew file mode 100644@@@ -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; });@@@ -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;@@@ -155,7 +155,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVRassert.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('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVRawait 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');});@@@ -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 = [@@@ -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 () => {@@@ -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);
@@ -2,6 +2,19 @@
@@ -2,6 +2,19 @@
## Unreleased
## 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 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
`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
new-thread form, in a Labels box on the thread, or with `PUT /api/v1/spaces/:space/threads/:slug/labels`. Lists and
@@ -41,8 +41,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
@@ -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
- 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)
(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.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
## How it fits the network
@@ -804,6 +804,7 @@ server/
@@ -804,6 +804,7 @@ server/
pastes/store.js pure SQL over pastes / versions / likes / projections (comments: comments/store.js)
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/source.js where pages read pastes from (the store)
pastes/catalog.js recent public pastes: trending, related, language filter
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),
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)
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)
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]
@@ -850,7 +851,7 @@ release after paste comments moved onto the typed comment threads. [Submissions]
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
- Games: https://openvibe.games (OpenVibers/OpenVibe.Games)
<!-- versions:start -->
<!-- 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
- openvibe-shared: v4.114.0
<!-- versions:end -->
<!-- versions:end -->
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,56 @@
@@ -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;
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -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;
@@ -17,8 +17,8 @@
@@ -17,8 +17,8 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
@@ -1192,9 +1192,9 @@
@@ -1192,9 +1192,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -1208,9 +1208,9 @@
@@ -1208,9 +1208,9 @@
}
}
},
},
"node_modules/openvibe-sdk": {
"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",
"license": "MIT",
"bin": {
"bin": {
"openvibe-download": "bin/openvibe-download.js"
"openvibe-download": "bin/openvibe-download.js"
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"test": "node test/run.js",
"test": "node test/run.js",
"n-1:record": "node scripts/n-1-record.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"
"test:pg": "COMMUNITY_TEST_STORE=pg node test/run.js"
},
},
"keywords": [
"keywords": [
@@ -28,8 +29,8 @@
@@ -28,8 +29,8 @@
"highlight.js": "^11.11.1",
"highlight.js": "^11.11.1",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
"multer": "^2.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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.114.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
},
},
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -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 };
@@ -43,6 +43,7 @@ const { createNetworkIdentity } = require('./identity/network');
@@ -43,6 +43,7 @@ const { createNetworkIdentity } = require('./identity/network');
const { createViewerResolver, ANONYMOUS } = require('./identity/viewer');
const { createViewerResolver, ANONYMOUS } = require('./identity/viewer');
const v1 = require('./http/v1');
const v1 = require('./http/v1');
const { createCommentService } = require('./comments/service');
const { createCommentService } = require('./comments/service');
+const { createSharing, describeRef } = require('./sharing');
const { createCommentsApi } = require('./comments/api');
const { createCommentsApi } = require('./comments/api');
const { createCommentPages } = require('./comments/routes');
const { createCommentPages } = require('./comments/routes');
const { createForumService } = require('./forum/service');
const { createForumService } = require('./forum/service');
@@ -63,6 +64,7 @@ const { createSubmissionPages } = require('./submissions/routes');
@@ -63,6 +64,7 @@ const { createSubmissionPages } = require('./submissions/routes');
const { pulsePage } = require('./render/pulse');
const { pulsePage } = require('./render/pulse');
const { extensionFor } = require('./render/highlight');
const { extensionFor } = require('./render/highlight');
const { createIndexNow } = require('openvibe-shared/indexnow');
const { createIndexNow } = require('openvibe-shared/indexnow');
+const { createPasteService } = require('./pastes/service');
const seo = require('openvibe-shared/seo');
const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const cache = require('openvibe-shared/cache-policy');
@@ -171,32 +173,77 @@ async function createApp(opts = {}) {
@@ -171,32 +173,77 @@ async function createApp(opts = {}) {
// services ping the engines when a public, indexable page appears, changes or goes away.
// 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 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 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.
// 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 });
const submissions = createSubmissionService({ db, network, pulse, indexnow, config, limits: opts.submissionLimits });
discovery.useForum(forum);
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();
if (opts.startRelay !== false) relay.start();
// ── Pastes: Community's own store is the only authority ──
// ── 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);
source.use(service);
app.locals.pastes = 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).
// One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
const anonWriteLimiter = rateLimit({
const anonWriteLimiter = rateLimit({
@@ -252,6 +299,30 @@ async function createApp(opts = {}) {
@@ -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/control', require('./registry/resource-control').router({ db, viewers, pastes: app.locals.pastes }));
app.use('/api/v1/resources', require('./registry/resource-index').router({ db, viewers }));
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 }));
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
// 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.
// sendBeacon, no auth) into /metrics as release_client_updates_total.
@@ -283,6 +354,10 @@ async function createApp(opts = {}) {
@@ -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 })));
.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 }))
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 }))));
.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) => {
app.get('/sitemap.xml', async (_req, res) => {
let rows;
let rows;
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
@@ -458,10 +533,20 @@ async function createApp(opts = {}) {
@@ -458,10 +533,20 @@ async function createApp(opts = {}) {
try {
try {
paste = await source.getPaste(slug, ctx);
paste = await source.getPaste(slug, ctx);
} catch (err) {
} 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.' }) };
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;
throw err;
}
}
+ const share = await app.locals.pastes.shareState(viewer, paste);
const related = await catalog.related(paste, 6);
const related = await catalog.related(paste, 6);
// Its comments are the paste's typed thread (community/paste/<slug>), the one /c/:accessId and the
// 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
// 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 = {}) {
@@ -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 });
const thread = paste.burn_after_read ? null : await comments.forRef(viewer, pasteRef(paste.slug), { sort, after, limit: 30 });
return {
return {
status: 200, slug: paste.slug,
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 = {}) {
@@ -520,10 +605,19 @@ async function createApp(opts = {}) {
throw err;
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) => {
app.get('/p/:slug/screenshot', withUser, async (req, res) => {
if (!SLUG_RE.test(req.params.slug)) return notFound(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 === 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);
if (err.status === 404) return notFound(res);
throw err;
throw err;
@@ -536,7 +630,7 @@ async function createApp(opts = {}) {
@@ -536,7 +630,7 @@ async function createApp(opts = {}) {
let paste;
let paste;
try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
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; }
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('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
@@ -68,7 +68,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
@@ -68,7 +68,7 @@ const ACCESS_ID_RE = /^cth_[A-Za-z0-9_-]{22}$/;
const PAGE = 30;
const PAGE = 30;
const REPLY_PAGE = 20;
const REPLY_PAGE = 20;
-function createCommentService({ db, network = null, limits = {} } = {}) {
+function createCommentService({ db, network = null, limits = {}, canViewPaste = null } = {}) {
const authors = createAuthors({ db, network });
const authors = createAuthors({ db, network });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const commentLimiter = createPersonLimiter({ cooldownSec: 10, perMinute: 5, noun: 'comments', ...(limits.comments || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
const voteLimiter = createPersonLimiter({ cooldownSec: 0, perMinute: 60, duplicate: false, noun: 'votes', ...(limits.votes || {}) });
@@ -142,10 +142,16 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
@@ -142,10 +142,16 @@ function createCommentService({ db, network = null, limits = {} } = {}) {
return { comment: c, thread: await visible(v, await store.getThread(db, c.thread_id)) };
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) {
async function pasteVisibleTo(v, slug) {
const p = await pasteStore.getBySlug(db, String(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). */
/** 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);
const entityVisible = async (v, t) => !(t.ref_service === 'community' && t.ref_type === 'paste') || await pasteVisibleTo(v, t.ref_id);
@@ -34,6 +34,16 @@ function llmsSections() {
@@ -34,6 +34,16 @@ function llmsSections() {
] },
] },
// The API is a primary entry for an agent, not something to skip.
// 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' }] },
{ 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: [
optional: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
@@ -47,7 +57,9 @@ function llmsSections() {
@@ -47,7 +57,9 @@ function llmsSections() {
/** The latest public threads and text pastes in full (no NSFW, no burn-after-read, nothing gated). */
/** The latest public threads and text pastes in full (no NSFW, no burn-after-read, nothing gated). */
async function llmsFullSections() {
async function llmsFullSections() {
const { markdownToText } = require('./render/markdown');
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) {
if (_forum) {
const threads = await _forum.recentPublic({ limit: 50 });
const threads = await _forum.recentPublic({ limit: 50 });
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
sections.push({ title: 'Latest threads', pages: threads.map((t) => ({
@@ -59,6 +71,9 @@ async function llmsFullSections() {
@@ -59,6 +71,9 @@ async function llmsFullSections() {
sections.push({ title: 'Latest pastes', pages: full.filter(Boolean).map((p) => ({
sections.push({ title: 'Latest pastes', pages: full.filter(Boolean).map((p) => ({
title: p.title || 'Untitled', url: abs(`/p/${p.slug}`), text: String(p.content || ''),
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;
return sections;
}
}
@@ -65,6 +65,17 @@ async function record(eventType, subject, payload, { isPublic = false, actor = n
@@ -65,6 +65,17 @@ async function record(eventType, subject, payload, { isPublic = false, actor = n
return env;
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) ─────────────────────────────
// ── 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 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; };
const actorOf = (sub) => { const r = subjectRef(sub); return r ? { type: r.type, id: r.id } : null; };
@@ -148,4 +159,4 @@ function stop() {
@@ -148,4 +159,4 @@ function stop() {
}
}
function _reset() { if (outbox) outbox.stop(); outbox = null; outboxDb = null; stats.queued = 0; stats.lastError = null; }
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 };
@@ -53,6 +53,21 @@ async function exportPart(db, subject) {
@@ -53,6 +53,21 @@ async function exportPart(db, subject) {
if (rows.length > ROW_LIMIT) truncated.push(name);
if (rows.length > ROW_LIMIT) truncated.push(name);
files.push({ name, content: rows.slice(0, ROW_LIMIT) });
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.' };
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() } = {}) {
@@ -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);
add(erased, table, (await db.prepare(`DELETE FROM ${table} WHERE subject_id IN ${S}`).run(...subjects)).changes);
recount.push([table, items]);
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.
// Pastes: gone, unless someone else commented.
if (await hasTable(db, 'pastes')) {
if (await hasTable(db, 'pastes')) {
for (const p of await db.prepare(`SELECT id, slug FROM pastes WHERE owner_subject IN ${S}`).all(...subjects)) {
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.
// 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)
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
|| (await hasTable(db, 'comments') && await db.prepare(`SELECT 1 FROM comments c JOIN comment_threads t ON t.id = c.thread_id
@@ -54,6 +54,8 @@ const { createPgTelemetrySink, startTelemetryRetention } = require('openvibe-sdk
@@ -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).
// Public threads and pastes in OpenVibe.Search (WS-O task 10): community.index_document.* through the outbox (off without EVENTS_URL).
let searchDocs = null;
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); }
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) ──────
// ── Stop (roadmap WS-P lifecycle; openvibe-sdk/service) ──────
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
// SIGTERM: the Pulse subscription retries, the community.profile scan and the search-document scans
@@ -22,7 +22,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -22,7 +22,7 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
clientId: config.oauth.clientId,
clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret,
clientSecret: config.oauth.clientSecret,
audience: 'openvibe.media',
audience: 'openvibe.media',
- scope: 'media.object.upload',
+ scope: 'media.object.upload media.object.read media.object.delete',
fetchImpl,
fetchImpl,
}) : null;
}) : null;
const base = `${config.mediaInternalUrl}/api/v2/community/objects`;
const base = `${config.mediaInternalUrl}/api/v2/community/objects`;
@@ -61,7 +61,36 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
@@ -61,7 +61,36 @@ function createMediaObjects({ config, fetchImpl = globalThis.fetch } = {}) {
return { id, url: `${config.mediaUrl}/o/${id}`, size_bytes: buffer.length, mime };
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 };
module.exports = { createMediaObjects };
@@ -57,7 +57,13 @@ const json = (description, schema) => ({ description, ...(schema ? { content: {
@@ -57,7 +57,13 @@ const json = (description, schema) => ({ description, ...(schema ? { content: {
const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
const body = (schema, required = true) => ({ required, content: { 'application/json': { schema } } });
const nothing = (description) => ({ description });
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 = {};
const PATHS = {};
function route(method, p, operation) {
function route(method, p, operation) {
@@ -79,14 +85,18 @@ route('post', '/api/pastes', {
@@ -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).',
description: 'community.paste.create. The body is JSON, or multipart with a file (then it is a screenshot paste).',
requestBody: { required: true, content: {
requestBody: { required: true, content: {
'application/json': { schema: { type: 'object', required: ['content'], properties: {
'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}$' }),
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' },
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_summary: str({ maxLength: 2000, description: 'A service with community.paste.moderate' }),
ai_tags: { type: ['array', 'string'], 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: {
'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' },
burn_after_read: { type: 'boolean' }, is_nsfw: { type: 'boolean' },
} } },
} } },
} },
} },
@@ -656,6 +666,26 @@ function openapi() {
@@ -656,6 +666,26 @@ function openapi() {
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Community', email: '[email protected]' },
contact: { name: 'OpenVibers', url: 'https://github.com/OpenVibers/OpenVibe.Community', email: '[email protected]' },
},
},
servers: [{ url: SITE }],
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 })),
tags: ['pastes', 'pastes-admin', 'submissions', 'comments', 'pulse', 'spaces', 'posts', 'relay', 'resources'].map((name) => ({ name })),
components: {
components: {
securitySchemes: {
securitySchemes: {
@@ -42,6 +42,13 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
@@ -42,6 +42,13 @@ function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = n
const router = express.Router();
const router = express.Router();
router.use(contracts.http.middleware());
router.use(contracts.http.middleware());
router.use(viewers.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
// 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,
// (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.
// refused ones included, and the writes without a content limit. Live's AI pastes count as svc:live.
@@ -81,7 +81,7 @@ function sinceParam(value) {
@@ -81,7 +81,7 @@ function sinceParam(value) {
return Number.isFinite(t) ? new Date(t).toISOString().replace('T', ' ').slice(0, 19) : null;
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 };
const L = { ...DEFAULT_LIMITS, ...limits };
// Pulse (server/pulse) hears about public pastes written by people; a Pulse problem never fails a paste write.
// 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); } };
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
@@ -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 isOwner = (v, p) => !!(v && v.subject && p.owner_subject && v.subject === p.owner_subject);
const isStaff = (v) => !!(v && v.staff);
const isStaff = (v) => !!(v && v.staff);
const canSeeHidden = (v, p) => isOwner(v, p) || isStaff(v);
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.
// Media applied cooldowns/daily caps to callers acting as a person; not to anonymous app calls.
const limited = (v) => !!(v && v.subject);
const limited = (v) => !!(v && v.subject);
const hasCap = (v, cap) => !!(v && v.kind === 'service' && capabilities.check(v.claims, cap).allowed);
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
@@ -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'); };
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) {
async function visible(v, slug) {
const p = await store.getBySlug(db, String(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;
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 ──────────────────────────────────────
// ── response shapes ──────────────────────────────────────
function streamIdOf(ref) {
function streamIdOf(ref) {
return ref && ref.service === 'live' && ref.type === 'stream' && /^\d+$/.test(String(ref.id)) ? Number(ref.id) : null;
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
@@ -273,12 +352,6 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
}
}
// ── validation helpers ───────────────────────────────────
// ── 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) {
function streamRefFor(v, body) {
if (v.kind === 'service' && v.sourceRef) return JSON.stringify(v.sourceRef);
if (v.kind === 'service' && v.sourceRef) return JSON.stringify(v.sourceRef);
const sid = body.stream_id;
const sid = body.stream_id;
@@ -370,6 +443,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -370,6 +443,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { slug: p.slug, title: p.title, content: p.content };
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. */
/** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
async get(v, slug, ctx = {}) {
async get(v, slug, ctx = {}) {
const p = await visible(v, slug);
const p = await visible(v, slug);
@@ -383,14 +459,16 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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;
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). */
/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
async raw(v, slug, ctx = {}) {
async raw(v, slug, ctx = {}) {
const p = await store.getBySlug(db, String(slug));
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 (!isOwner(v, p)) {
if (p.burn_after_read) {
if (p.burn_after_read) {
if (p.views > 0) await burn(p);
if (p.views > 0) await burn(p);
@@ -405,7 +483,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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). */
/** 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) {
async screenshotUrl(v, slug) {
const p = await store.getBySlug(db, String(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.burn_after_read && !isOwner(v, p)) {
if (p.views > 0) await burn(p);
if (p.views > 0) await burn(p);
await store.bumpViews(db, p.id);
await store.bumpViews(db, p.id);
@@ -420,28 +498,42 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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 || 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)`);
if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
const metadata = serviceMetadata(v, body);
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
// Link spam from someone signed out is kept but unlisted (./spam.js); people with an account and AI pastes are
// never checked.
// 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) */
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
@@ -454,13 +546,18 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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 (!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 (file.buffer.length > L.screenshotMaxSizeMb * 1024 * 1024) fail(400, `File too large (max ${L.screenshotMaxSizeMb} MB)`);
if (!media) fail(503, 'Media service unavailable');
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 slug = await slugFor(v, body, visibility, truthy(body.burn_after_read));
const extra = serviceMetadata(v, body);
const extra = serviceMetadata(v, body);
const bytes = stripImageMetadata(file.buffer, mime);
const bytes = stripImageMetadata(file.buffer, mime);
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] screenshot upload failed:', err.message);
console.warn('[Pastes] screenshot upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -472,25 +569,31 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -472,25 +569,31 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
original_name: file.originalname || null,
original_name: file.originalname || null,
size_bytes: bytes.length,
size_bytes: bytes.length,
mime_type: mime,
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. */
/** PUT /api/pastes/:slug — owner (or staff) only. */
@@ -506,14 +609,22 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -506,14 +609,22 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
patch.content = content;
patch.content = content;
patch.language = store.detectLanguage(content, body.language);
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.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 (body.pinned !== undefined && isStaff(v)) patch.pinned = truthy(body.pinned) ? 1 : 0;
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
if (!Object.keys(patch).length) fail(400, 'Nothing to update');
const row = await db.tx(async () => {
const row = await db.tx(async () => {
const r = await store.updatePaste(db, p.id, patch, v.subject || null);
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).
// 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;
return r;
});
});
await tell((pl) => pl.pasteChanged(row));
await tell((pl) => pl.pasteChanged(row));
@@ -551,20 +662,26 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -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.
// 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');
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
await pasteRateCheck(v);
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. */
/** POST /api/pastes/:slug/like — toggles; needs a person. */
@@ -694,7 +811,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -694,7 +811,12 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
const p = await store.getBySlug(db, String(slug));
const p = await store.getBySlug(db, String(slug));
if (!p) { skipped++; continue; }
if (!p) { skipped++; continue; }
if (action === 'delete') await store.softDelete(db, p.id);
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));
if (action !== 'public') await tell((pl) => pl.pasteGone(p.slug));
pingIndexNow(action === 'public' ? { ...p, visibility: 'public' } : p);
pingIndexNow(action === 'public' ? { ...p, visibility: 'public' } : p);
done++;
done++;
@@ -715,7 +837,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
@@ -715,7 +837,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
if (!media) fail(503, 'Media service unavailable');
if (!media) fail(503, 'Media service unavailable');
let stored;
let stored;
try {
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) {
} catch (err) {
console.warn('[Pastes] censor upload failed:', err.message);
console.warn('[Pastes] censor upload failed:', err.message);
fail(502, 'Media service unavailable');
fail(502, 'Media service unavailable');
@@ -34,8 +34,8 @@ function mediaAbs(u) {
@@ -34,8 +34,8 @@ function mediaAbs(u) {
function mediaW(u, w) {
function mediaW(u, w) {
return /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]+$/.test(String(u || '')) ? `${u}?w=${w}` : u;
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) {
function timeAgo(v) {
const iso = ld.isoDate(v);
const iso = ld.isoDate(v);
@@ -110,7 +110,7 @@ function pagerHref(state, page) {
@@ -110,7 +110,7 @@ function pagerHref(state, page) {
function pasteCard(p) {
function pasteCard(p) {
const isShot = p.type === 'screenshot';
const isShot = p.type === 'screenshot';
const preview = isShot
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>`;
: `<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> ' : ''}`;
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' : ''}">
return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
@@ -173,7 +173,7 @@ function homeStats(c = {}) {
@@ -173,7 +173,7 @@ function homeStats(c = {}) {
*/
*/
function pasteKitCard(p) {
function pasteKitCard(p) {
const isShot = p.type === 'screenshot';
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({
return kit.card({
href: `/p/${p.slug}`, title: p.title || 'Untitled', type: 'post', ratio: '16/9', image: image || '',
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' : '',
badge: p.is_nsfw ? 'NSFW' : p.origin === 'ai' ? 'AI' : p.pinned ? 'Pinned' : '',
@@ -380,18 +380,6 @@ function pasteDescription(p) {
@@ -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);
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
* 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) {
@@ -407,11 +395,18 @@ function withoutTitle(content, title) {
return m && bare(m[1]) && bare(m[1]) === bare(title) ? text.slice(m[0].length) : text;
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 isShot = p.type === 'screenshot';
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
const description = pasteDescription(p);
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.
// Pastes name their owner by Network subject; the projection cache supplies the display fields.
const isOwner = !!(user && (p.is_owner === true
const isOwner = !!(user && (p.is_owner === true
|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
|| (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
@@ -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>`}
${!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>
<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>` : ''}
${!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>` : ''}
${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>`;
</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 = `
const body = `
+${offer}
<article class="paste" itemscope itemtype="https://schema.org/${isShot ? 'ImageObject' : 'Article'}">
<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>
<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">
<header class="paste-head">
@@ -463,7 +461,7 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
@@ -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><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>
<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>` : ''}
${!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.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>
${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>` : ''}
${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
@@ -471,7 +469,6 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
</header>
</header>
${bodyBlock}
${bodyBlock}
<footer class="paste-foot">
<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>
<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>
</footer>
</article>
</article>
@@ -482,11 +479,23 @@ ${p.burn_after_read ? '' : `<section class="section" id="comments">
@@ -482,11 +479,23 @@ ${p.burn_after_read ? '' : `<section class="section" id="comments">
${related && related.length ? `<section class="section" id="related">
${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>
<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)}
${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({
return renderPage({
- title,
- description,
+ title: metaTitle,
+ description: metaDescription,
canonicalPath: `/p/${p.slug}`,
canonicalPath: `/p/${p.slug}`,
agentLayer: true, // the public page has a Markdown twin (server/agent-pages.js)
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
// 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">
@@ -495,14 +504,14 @@ ${related && related.length ? `<section class="section" id="related">
// burn-after-read pastes were already noindex and stay so.
// burn-after-read pastes were already noindex and stay so.
robots: 'noindex',
robots: 'noindex',
ogType: 'article',
ogType: 'article',
- ogImage: shot || undefined,
+ ogImage: metaImage,
published: ld.isoDate(p.created_at) || undefined,
published: ld.isoDate(p.created_at) || undefined,
modified: ld.isoDate(p.updated_at) || undefined,
modified: ld.isoDate(p.updated_at) || undefined,
active: 'pastes',
active: 'pastes',
historyType: 'paste',
historyType: 'paste',
historyTitle: title,
historyTitle: title,
footerVariant: 'compact',
footerVariant: 'compact',
- jsonLd: [
+ jsonLd: restricted ? [] : [
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
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>` : ''}
@@ -586,6 +595,39 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
}
}
// ── Errors ───────────────────────────────────────────────────
// ── 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 }) {
function errorPage({ status = 500, title = 'Something went wrong', message = '', links = true }) {
const body = `
const body = `
<section class="error">
<section class="error">
@@ -597,4 +639,4 @@ function errorPage({ status = 500, title = 'Something went wrong', message = '',
@@ -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 });
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 };
@@ -139,6 +139,11 @@ function createSearchDocuments({ db }) {
@@ -139,6 +139,11 @@ function createSearchDocuments({ db }) {
if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';
if (!row || !SLUG_RE.test(String(row.slug || ''))) return 'skipped';
return await send('paste', pasteDocId(row), pasteDocument(row));
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; } };
const guard = async (fn) => { try { await fn(); } catch (err) { stats.lastError = err.message; } };
async function scan({ now = Date.now() } = {}) {
async function scan({ now = Date.now() } = {}) {
@@ -181,7 +186,7 @@ function createSearchDocuments({ db }) {
@@ -181,7 +186,7 @@ function createSearchDocuments({ db }) {
}
}
function stop() { for (const t of timers) { clearTimeout(t); clearInterval(t); } timers = []; }
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 };
module.exports = { createSearchDocuments };
new file mode 100644
new file mode 100644
@@ -0,0 +1,232 @@
@@ -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 };
@@ -23,7 +23,11 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
@@ -23,7 +23,11 @@ let SECRET, db, sent, failNext, accountSend, app, DANA, OLD, OTTO, evt;
{
{
const missing = await uncovered(db, {
const missing = await uncovered(db, {
taskKeyed: ['spaces', 'comment_threads'],
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(', ')}`);
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;
@@ -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 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 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);
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(),
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 });
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');
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;
@@ -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.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 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.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);
r = await post(del);
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
assert.deepStrictEqual([r.status, r.json.outcome], [200, 'confirmed'], 'the retry confirms without erasing again');
@@ -11,20 +11,48 @@ const { serviceAuth, ids } = require('openvibe-contracts');
@@ -11,20 +11,48 @@ const { serviceAuth, ids } = require('openvibe-contracts');
function start({ publicPem, issuer }) {
function start({ publicPem, issuer }) {
const uploads = [];
const uploads = [];
+ const deletes = []; // v2 object ids a DELETE removed
const pending = new Map(); // v2 objects between init and complete
const pending = new Map(); // v2 objects between init and complete
let failNext = false;
let failNext = false;
+ let failNextDelete = false;
const server = http.createServer((req, res) => {
const server = http.createServer((req, res) => {
const chunks = [];
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
req.on('end', () => {
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
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' });
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' });
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' });
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.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);
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 (v2) {
if (req.method === 'POST' && !v2[1]) {
if (req.method === 'POST' && !v2[1]) {
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
@@ -63,8 +91,9 @@ function start({ publicPem, issuer }) {
@@ -63,8 +91,9 @@ function start({ publicPem, issuer }) {
});
});
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
url: `http://127.0.0.1:${server.address().port}`,
url: `http://127.0.0.1:${server.address().port}`,
- uploads,
+ uploads, deletes,
failNext: () => { failNext = true; },
failNext: () => { failNext = true; },
+ failNextDelete: () => { failNextDelete = true; },
close: () => new Promise((r) => server.close(r)),
close: () => new Promise((r) => server.close(r)),
})));
})));
}
}
@@ -20,6 +20,7 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -20,6 +20,7 @@ function start({ clientSecret = 'shh' } = {}) {
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
const grants = [];
const resolveCalls = [];
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)
const issued = []; // every client-credentials access token handed out (security-secrets.test.js looks for them)
let issuer = 'http://network.test';
let issuer = 'http://network.test';
@@ -86,6 +87,15 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -86,6 +87,15 @@ function start({ clientSecret = 'shh' } = {}) {
}
}
return json(200, { results });
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') {
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
// 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).
// leading @ ignored; 404 for nobody (or a banned account).
@@ -114,7 +124,7 @@ function start({ clientSecret = 'shh' } = {}) {
@@ -114,7 +124,7 @@ function start({ clientSecret = 'shh' } = {}) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
const url = `http://127.0.0.1:${server.address().port}`;
issuer = url;
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)) });
}));
}));
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,396 @@
@@ -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; });
@@ -271,7 +271,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -271,7 +271,7 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(out.slug, p.slug);
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.type, 'screenshot');
assert.strictEqual(p.owner_subject, alex.subject_id);
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(up.owner, alex.subject_id, 'owned by the person');
assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.screenshot_url, `https://openvibe.media/o/${up.key}`);
assert.strictEqual(p.title, 'My desk');
assert.strictEqual(p.title, 'My desk');
@@ -321,7 +321,7 @@ const { boot, check, done } = require('./helpers/app');
@@ -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');
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`);
const raw = await call(`/p/${alexSlug}/raw`);
assert.strictEqual(raw.status, 200);
assert.strictEqual(raw.status, 200);
assert.strictEqual(raw.headers.get('content-type'), 'text/plain; charset=utf-8');
assert.strictEqual(raw.headers.get('content-type'), 'text/plain; charset=utf-8');
@@ -334,8 +334,10 @@ const { boot, check, done } = require('./helpers/app');
@@ -334,8 +334,10 @@ const { boot, check, done } = require('./helpers/app');
assert.strictEqual(api.status, 302);
assert.strictEqual(api.status, 302);
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
assert.strictEqual(api.headers.get('location'), `/p/${alexSlug}/raw`);
const shot = await call(`/p/${shotSlug}/screenshot`);
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/${shotSlug}/raw`)).headers.get('location'), `/p/${shotSlug}/screenshot`);
assert.strictEqual((await call(`/p/${alexSlug}/screenshot`)).status, 404);
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;
const burn = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'once', burn_after_read: true } })).json().slug;
@@ -155,7 +155,7 @@ const PNG = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR
@@ -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', `/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.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.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.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');
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
@@ -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 () => {
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 });
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 });
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(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');
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.
// The owner's own looks do not burn it.
const mineToo = await shotPaste(alex, { title: 'mine', burn_after_read: 'true' });
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');
assert.strictEqual((await send('GET', `/api/pastes/${mineToo.slug}`, { who: sam })).status, 200, 'still unread');
});
});
@@ -38,6 +38,7 @@ const INVENTORY = {
@@ -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/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/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/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'],
'server/vip/index.js': [2, 'OV_VIP_INTERNAL_URL policy evaluate, service token'],
};
};
const PATTERNS = [
const PATTERNS = [
@@ -78,9 +78,9 @@ const { boot, check, done } = require('./helpers/app');
@@ -78,9 +78,9 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(dl.text.startsWith('def add'));
assert.ok(dl.text.startsWith('def add'));
assert.strictEqual((await t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py)).views, before);
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');
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');
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 () => {
await check('browse: search, views sort and language filter over the store', async () => {
@@ -134,7 +134,8 @@ const { boot, check, done } = require('./helpers/app');
@@ -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/raw"');
has(r.text, 'href="/p/amber-fox-42/download"');
has(r.text, 'href="/p/amber-fox-42/download"');
has(r.text, 'data-copy-content="amber-fox-42"');
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, '<h2>More like this</h2>');
has(r.text, 'https://openvibe.live/data/avatars/alex.png', 'relative avatar made absolute against Live');
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]);
const cfg = JSON.parse(r.text.match(/window\.__OV_PAGE = (.*);\n/)[1]);
@@ -153,12 +154,13 @@ const { boot, check, done } = require('./helpers/app');
@@ -153,12 +154,13 @@ const { boot, check, done } = require('./helpers/app');
assert.ok(!r.text.includes('<script>alert(1)</script>'));
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');
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, '<meta name="twitter:card" content="summary_large_image">');
has(r.text, '"@type":"ImageObject"');
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 () => {
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');
@@ -171,13 +173,13 @@ const { boot, check, done } = require('./helpers/app');
has(owner.text, 'data-delete="grim-vale-61"', 'owner sees delete');
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');
const raw = await t.get('/p/amber-fox-42/raw');
assert.strictEqual(raw.status, 200);
assert.strictEqual(raw.status, 200);
assert.ok(raw.text.startsWith('const greet'));
assert.ok(raw.text.startsWith('const greet'));
const shot = await t.get('/p/fair-moon-23/screenshot');
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 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');
const dl = await t.get('/p/amber-fox-42/download');
assert.strictEqual(dl.status, 200);
assert.strictEqual(dl.status, 200);