openvibers/openvibe.media Commits

Delete the read-only pastes app API and its handlers (#37)

OP OpenVibers committed 5 Oct 2026 · 77124cf6c1 · 15 files, +77 −325

**Files changed**

- `server/pastes/routes.js` — deleted: the read-only paste app API and its four GET handlers; nothing called it.
- `server/index.js` — removed the `app.use('/api/v1/:app/pastes', …)` mount (line 204); reworded the avatar-ingest comment that referenced "the paste router".
- `server/pastes/storage.js` — kept as the leaf; moved `removePasteScreenshot` here from the router and exported it alongside `SCREENSHOTS_DIR`/`generateSlug`; header comment updated.
- `server/public/routes.js` — burn-after-read now calls `require('../pastes/storage').removePasteScreenshot`.
- `server/avatars/ingest.js` — comment only: the avatar document no longer claims paste-route serving/deletability.
- `server/admin/routes.js` — comment only: the paste API is retired, not "being retired".
- `test/pastes-moved.test.js` — dropped the router mount; write/read paths now assert 404 (unmounted) and create nothing; public `/p/:slug` 200/301, `/raw`, image 302 and `/screenshot` byte checks kept.
- `test/avatar-ingest.test.js` — removed the now-impossible `pastes/routes` cache/export assertions; kept the shared screenshots-dir check.
- `test/private-not-found.test.js` — dropped the mount; the v1 paste read asserts 404 for owner and non-owner alike (no view counted).
- `test/retention-holds.test.js` — dropped the mount; a held screenshot paste is now proven protected by the delete trigger (`/retention hold/`), plus a 404 probe on the removed route.
- `test/object-first-writes.test.js` — dropped the mount; `POST /pastes` asserts 404 and creates no row/object.
- `STATUS.json` — `pastes` line now says the read-only API is deleted (T10 step 2), not that writes answer 410.
- `README.md` — purpose, module map (`pastes/storage.js`), user-JWT endpoint list, app-token/sandbox list, the rewritten Pastes section (public routes table), client-addresses line, and the v1 privacy-detail list.
- `docs/object-model.md` — intro ("v1 pastes API retired"), screenshot upload row, and the retention-hold delete bullets.
- `scripts/smoke-test.sh` — pastes smoke section now expects 404 for create/list instead of `pastes.moved`/200.

**Tests run** (`ov test`, never the full suite)

- `test/pastes-moved.test.js` → 1 passed
- `test/public-pages.test.js test/crawl.test.js test/me-explorer.test.js` → 3 passed
- `test/avatar-ingest.test.js test/private-not-found.test.js` → 2 passed
- `test/retention-holds.test.js test/object-first-writes.test.js` → 2 passed
- `test/observability.test.js test/trust-proxy.test.js test/not-found-csp.test.js` → 3 passed (they boot `server/index.js`/read its route order)

Also validated `STATUS.json` parses, `bash -n scripts/smoke-test.sh`, and that `server/pastes/storage.js` loads. No commits, pushes, deploys or installs. No outbound requests, capability uses or SSRF inventories were touched by this change.

Changes

ddiff --git a/README.md b/README.md
iindex ed864a3..d01794b 100644
---- a/README.md
++++ b/README.md
@@@ -7,7 +7,8 @@ OpenVibe.Live predecessor. Owns VOD ingest/recording, storage tiering
(local → Backblaze B2 → Cloudflare R2), clips, generic files, the canonical
object model and thumbnails, for all OpenVibe apps (`live`, `games`, `tools`,
`network`, `community`) and developer projects. Pastes moved to OpenVibe.Community
--on 2026-09-22; Media's paste API is read-only and writes answer 410 ([Pastes](#pastes)).
++on 2026-09-22; Media's read-only paste API is gone (T10 step 2) and it serves only the
++frozen rows and old screenshots ([Pastes](#pastes)).
Implements **Media API v1** from `../CONTRACTS.md`.
@@@ -89,7 +90,7 @@ server/
vod/vod-storage.js local/B2/R2 tiering, presigned playback, sweep, CLI
vod/health-scanner.js probe/decode/master-recovery primitives
vod/health-job.js background health scan + quarantine cleanup + master sweep
-- pastes/routes.js /api/v1/:app/pastes (read-only; writes answer 410 pastes.moved)
++ pastes/storage.js screenshot dir + slug minting kept after the pastes API was retired
files/routes.js /api/v1/:app/files
admin/routes.js /api/v1/:app/admin/storage (disk, tiers, buckets, bulk ops)
thumbnails/ thumbnail service + /api/v1/:app/thumbnails
@@@ -160,7 +161,7 @@ Credential types on `/api/v1/:app/...`:
constant time against the app's stored hash. A key is only valid for its
own `:app` segment; presenting another app's key returns **403**.
2. **Network user JWT** (browser endpoints only — chunks/complete, clips,
-- pastes, files, thumbnails) — RS256, verified **offline** against the JWKS
++ files, thumbnails) — RS256, verified **offline** against the JWKS
public key fetched from `OV_NETWORK_URL/api/.well-known/jwks` at boot
(cached, refreshed every 6 h). `aud` must include `openvibe.media` when
present. If the request carries an `Origin` header it must be in the app's
@@@ -193,7 +194,7 @@ namespaces are `app.<project_id>` (production) and `app.<project_id>.sandbox`, w
named in a URL. Nothing is created unless the token's `project_id` is the path's project and the
token holds the route's capability.
- App tokens reach only these tenants and only the capability routes (files, objects v2 without
-- retention holds). VODs, clips, pastes, thumbnails, assets, stats and admin refuse them.
++ retention holds). VODs, clips, thumbnails, assets, stats and admin refuse them.
- **Sandbox tokens** (`env: sandbox`) are accepted on these routes only; everywhere else they get
`401 token.sandbox_refused`.
- **Sandbox content is never public.** `/f/:key` and `/o/:id` answer 404 for it unless the URL
@@@ -278,9 +279,10 @@ lossless `.master.mkv` recovery archive. A `.seekable` sidecar is remuxed every
### Pastes
--**Read-only since the move:** OpenVibe.Community owns pastes (since
--2026-09-22). The paste API here answers reads only: every other method
--(POST, PUT, DELETE, …) answers 410 `{ "code": "pastes.moved" }` for every app.
++**The API is gone:** OpenVibe.Community owns pastes (since 2026-09-22) and Media's
++read-only paste app API was retired in T10 step 2 — `/api/v1/:app/pastes` is
++unmounted, so every method there answers 404. No paste is created, read, listed or
++deleted through Media any more.
`PASTES_MOVED_TO=https://openvibe.community` turns `/p/:slug` and its text
`/raw` into 301s to Community. Screenshot bytes are still served from here, and
new screenshots are uploaded to the token-only `community` tenant.
@@@ -288,15 +290,16 @@ Paste screenshot and avatar objects (`legacy:<app>:paste:<slug>`,
`legacy:<app>:avatar:<slug>`) are frozen with them: their v1 route is gone and
`DELETE /api/v2/:app/objects/:id` answers 409 `media.object.legacy_managed`, so
Media has no API that deletes them.
--An unauthenticated write still answers 401 before the 410. `GET /pastes/config`
--reports the old limits for information only.
++
++`server/pastes/storage.js` stays as a leaf: the avatar ingest still mints slugs
++and writes screenshots into the same directory, and burn-after-read removes a
++screenshot's bytes (skipping a held row).
| method | path | notes |
|---|---|---|
--| GET | `/pastes?limit&offset&type&search&user_id` | public list |
--| GET | `/pastes/config` | paste limits (`maxSizeKb`, `cooldownSeconds`, `maxPerUserPerDay`, `todayCount`, …) |
--| GET | `/pastes/:slug` | full paste (private: owner/app only) |
--| GET | `/pastes/:slug/comments` | threaded comments |
++| GET | `/p/:slug` | 301 to OpenVibe.Community when `PASTES_MOVED_TO` is set; otherwise the old page |
++| GET | `/p/:slug/raw` | 301 to Community; image pastes 302 to their screenshot |
++| GET | `/p/:slug/screenshot` | frozen screenshot bytes (unknown/private slugs 301 to Community) |
AI summary/tags columns remain for imported rows.
@@@ -411,7 +414,7 @@ release now in production as the next N-1 (`npm run n-1:record [ref]`) and commi
Express `trust proxy` is `loopback` (`server/client-ip.js`): `req.ip` is the `X-Forwarded-For` that the
local nginx set when the request came through it, and the socket address for anyone else. A caller
that reaches port 4100 directly cannot choose its IP by sending `X-Forwarded-For` or
--`CF-Connecting-IP` itself. View counting, paste limits and the live-frame rate limit read `req.ip`,
++`CF-Connecting-IP` itself. View counting and the live-frame rate limit read `req.ip`,
never the headers (`test/trust-proxy.test.js`).
## Per-actor limits
@@@ -477,7 +480,7 @@ Private items (and legacy rows with no visibility and `is_public = 0`) answer
exactly like a missing id — the same 404 and body — unless the request bears the
owning app's API key, optionally acting for the owner via `X-OV-User-Id`. That holds
for the watch page, the bytes, `transcript.json`, the legacy `/api/thumbnails` redirect
--and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`, `/pastes/:slug`)
++and `/p/:slug/raw`; the v1 detail routes (`GET /vods/:id`, `/clips/:id`)
apply it to an app acting for someone other than the owner. A call acting for a user writes
only that user's VODs and clips (update, delete, ingest, chunks, finalize, re-cut, thumbnail):
someone else's private one is the missing answer, anything else 403. The media index lists a
ddiff --git a/STATUS.json b/STATUS.json
iindex b7daeea..d0cf59d 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -19,7 +19,7 @@
"media.lifecycle.transition"
],
"capabilitiesRegistered": true,
-- "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's paste API is read-only and every write answers 410 pastes.moved, /p/:slug 301s to Community and Media serves screenshot bytes only (the read-only API is being retired, T10 step 2)",
++ "pastes": "OpenVibe.Community is the paste authority since 2026-09-22 (PASTES_MOVED_TO); Media's read-only paste API is deleted (T10 step 2: /api/v1/:app/pastes is unmounted), /p/:slug 301s to Community and Media serves screenshot bytes only",
"deployed": true,
"deployment": "Public at https://openvibe.media: deployed with `sudo ovhost deploy media` (restart refused while a VOD records; --wait-idle), /opt/openvibe.media, unit openvibe-media on 127.0.0.1:4100 behind the openvibe.media nginx vhost, principal media.",
"features": [
ddiff --git a/docs/object-model.md b/docs/object-model.md
iindex 38eb878..9c06882 100644
---- a/docs/object-model.md
++++ b/docs/object-model.md
@@@ -4,7 +4,9 @@ Roadmap Wave 4 turns Media from predecessor-shaped VOD/clip/file tables into one
platform. Every stored blob is a **media object** with an id `med_<ULID>`. The inherited
`vods`, `clips`, `files` and screenshot `pastes` rows stay as they are, and each one is now a typed
projection over an object through its `object_id` column. Pastes moved to OpenVibe.Community on
--2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only. Every existing URL and v1 response is
++2026-09-22: Media keeps the frozen `pastes` rows and their screenshot/avatar objects, read-only, and
++its v1 pastes API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted; only the public
++`/p/:slug` routes remain). Other existing URLs and v1 responses are
unchanged. New code talks to objects through the [v2 API](#object-api-v2).
Code: `server/objects/` (`model.js`, `routes.js`, `backfill.js`, `reconcile.js`, `invariant.js`,
@@@ -119,7 +121,7 @@ after the outcome event.
| clip cut / re-cut result | clips routes and `clip-jobs`, inside the clip.ready / clip.failed transaction |
| file upload | `db.createFile` |
| thumbnail generate / upload | `thumbnail-service`, `POST /thumbnails` |
--| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API is read-only and writes answer 410 `pastes.moved` |
++| screenshot upload / update / censor | none since pastes moved to OpenVibe.Community (2026-09-22): the paste API was retired in T10 step 2 (`/api/v1/:app/pastes` is unmounted) |
| avatar ingest | `avatars/ingest` |
| tier moves | `moveToCold`, `moveToHot`, `promoteToR2`, `demoteFromR2`, sweep, `migrateLegacy`: the copy the move verified is marked `present` in the same transaction |
@@@ -505,11 +507,11 @@ While any hold applies:
- **No delete path works.**
- v2 `DELETE` answers 409.
-- - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (paste deletes answer 410 `pastes.moved` for every paste since the move).
++ - The v1 deletes for vods, clips and files, and the admin bulk delete, answer 409 or report `held` (the paste API was retired in T10 step 2, so a paste row is only ever deleted by SQL, which the trigger refuses).
- Quarantine cleanup and the boot junk sweep skip the object.
- Finalize never deletes a held recording that turned out empty (no file, or zero bytes): it keeps the row and the file and settles it as failed (`missing_file` / `zero_byte`, quarantined, `vod.failed`).
- `deleteVodObjects` keeps the bytes.
-- - Paste screenshot removal no longer exists (the paste API is read-only since pastes moved to OpenVibe.Community).
++ - Paste screenshot removal is only the burn-after-read path in `server/pastes/storage.js`, which skips a held row.
- A `BEFORE DELETE` trigger on each projected table, plus a trigger on `media_objects.lifecycle_status`, refuses the change, so paths nobody has hooked are still covered. The triggers are the `_v2` ones (clip-aware); an older database has its first-version guards replaced at start.
- **No tier move.** `moveToCold`, `moveToHot`, `promoteToR2` and `demoteFromR2` return `{ ok: false, held: true }` (the admin moves answer it, R2 moves are logged as `refused`), and the sweep counts these as `skippedHeld` instead of errors. A held native object is never promoted to or demoted from the R2 cache either (logged as `refused`; a hold placed while its bytes were being copied wins, and the unrecorded copy is removed). A hold freezes an object's placement: `moveToHot` is refused too, because restoring flips the row to local and drops an R2 copy. A clip cut from a held VOD in B2/R2 is cut from the cloud copy instead of fetching the VOD home. Row updates that only record where the bytes already are (the sweep finding a local file gone while B2 has the copy, the legacy-tier migration) still happen: they move nothing.
ddiff --git a/scripts/smoke-test.sh b/scripts/smoke-test.sh
iindex 72f2754..07939ef 100755
---- a/scripts/smoke-test.sh
++++ b/scripts/smoke-test.sh
@@@ -105,15 +105,15 @@ echo "$HDRS" | head -1 | grep -q ' 206 ' && ok "Range request → 206 Partial Co
echo "$HDRS" | grep -qi '^content-range: bytes 0-99/' && ok "Content-Range header present" || bad "Content-Range header"
echo "$HDRS" | grep -qi '^x-robots-tag: noindex' && ok "/v is noindex" || bad "/v noindex header"
--echo "-- pastes: read-only (OpenVibe.Community owns pastes)"
--R=$(curl -s -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
++echo "-- pastes: API retired (OpenVibe.Community owns pastes), public routes still serve"
++CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" -H 'Content-Type: application/json' \
-d '{"title":"Smoke paste","content":"hello from the smoke test"}')
--check "paste create → pastes.moved" "pastes.moved" "$(jsonget "$R" code)"
++check "paste create → 404 (no route)" "404" "$CODE"
CODE=$(curl -s -o /dev/null -w '%{http_code}' -X POST "$BASE/api/v1/live/pastes" -H "$AUTH" \
-F "screenshot=@$WORK/thumb.jpg;type=image/jpeg" -F "title=Smoke screenshot")
--check "screenshot paste create → 410" "410" "$CODE"
++check "screenshot paste create → 404" "404" "$CODE"
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$BASE/api/v1/live/pastes" -H "$AUTH")
--check "paste list still reads" "200" "$CODE"
++check "paste list → 404" "404" "$CODE"
echo "-- admin storage: overview + vod listing"
R=$(curl -s "$BASE/api/v1/live/admin/storage" -H "$AUTH")
ddiff --git a/server/admin/routes.js b/server/admin/routes.js
iindex 3e5a938..3e9511d 100644
---- a/server/admin/routes.js
++++ b/server/admin/routes.js
@@@ -125,7 +125,7 @@ router.get('/', async (req, res) => {
`, [req.appId]) || {};
const clipStats = await db.get('SELECT COUNT(*) AS count FROM clips WHERE app_id = ?', [req.appId]) || {};
// Paste rows are frozen (pastes moved to OpenVibe.Community, 2026-09-22) and
-- // the read-only paste API is being retired (T10 step 2); the count stays
++ // the read-only paste API was retired (T10 step 2); the count stays
// while the table exists (it feeds /api/v2/me and operator stats).
const pasteStats = await db.get(`
SELECT COUNT(*) AS count,
ddiff --git a/server/avatars/ingest.js b/server/avatars/ingest.js
iindex 8a3b125..a90e7fe 100644
---- a/server/avatars/ingest.js
++++ b/server/avatars/ingest.js
@@@ -15,7 +15,8 @@
// • the bytes are decoded by sharp and re-encoded (512×512 WebP): metadata, scripts hidden in image
// containers and non-image payloads do not survive; a pixel-count limit stops decompression bombs
// The result is stored as an unlisted "screenshot" paste owned by the account, so avatars live in the same
--// system as every other picture (deletable, reportable, served with range/caching by the paste routes).
++// system as every other picture (reportable, served with range/caching by the public /p/:slug/screenshot
++// route). The paste API was retired in T10 step 2, so Media has no route that deletes them.
// ═══════════════════════════════════════════════════════════════
const dns = require('dns').promises;
const https = require('https');
@@@ -34,7 +35,7 @@ const egress = require('openvibe-shared/egress');
const isPublicAddress = (ip) => egress.isPublicAddress(ip);
// Where avatars land and how their slug is minted: the paste storage module
--// (server/pastes/storage.js), not the paste router. The defaults keep the exact
++// (server/pastes/storage.js), a leaf with no router. The defaults keep the exact
// persisted path and slug behavior; tests may inject their own.
const pasteStorage = require('../pastes/storage');
ddiff --git a/server/index.js b/server/index.js
iindex d3e0ec6..a4650fc 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -110,7 +110,7 @@ const ready = (async () => {
// Loopback only. A Network service token holding media.avatar.ingest (audience openvibe.media);
// a Bearer is judged on the token alone (401 bad, 403 no capability) and nothing else opens the
// route (server/service-guard.js). The handler takes its storage (screenshots dir, slug minting)
-- // from server/pastes/storage.js itself — nothing from the paste router is passed in.
++ // from server/pastes/storage.js itself — a leaf, not a router.
app.post('/internal/avatar-ingest', require('./service-guard').guard('media.avatar.ingest'),
require('./avatars/ingest').createIngestHandler({ db: require('./db/database'), config }));
}
@@@ -201,7 +201,6 @@ const ready = (async () => {
app.use('/api/v1/:app/views', require('./views/routes'));
app.use('/api/v1/:app/vods', require('./vod/routes'));
app.use('/api/v1/:app/clips', require('./vod/clips-routes'));
-- app.use('/api/v1/:app/pastes', require('./pastes/routes'));
app.use('/api/v1/:app/files', require('./files/routes'));
app.use('/api/v1/:app/thumbnails', require('./thumbnails/routes'));
app.use('/api/v1/:app/assets', require('./assets/routes'));
ddiff --git a/server/pastes/routes.js b/server/pastes/routes.js
ddeleted file mode 100644
iindex 3221d66..0000000
---- a/server/pastes/routes.js
++++ /dev/null
@@@ -1,259 +0,0 @@
--/**
-- * OpenVibe.Media — Pastes API, read-only (mounted at /api/v1/:app/pastes)
-- *
-- * OpenVibe.Community owns pastes since the 2026-09-22 cutover (PASTES_MOVED_TO).
-- * This read-only surface is being retired (T10 step 2): nothing calls the app
-- * API any more, so the router and its GET handlers go; the `pastes` table and
-- * the screenshot leaves below stay until Pics owns the bytes. Media keeps the
-- * rows and answers reads for anything still pointed here; public pages live at
-- * /p/:slug (HTML) and /p/:slug/raw.
-- *
-- * GET / list (?limit&offset&type&search&user_id&include_unlisted&sort)
-- * GET /config paste limits (+ the caller's count today)
-- * GET /:slug paste meta/content
-- * GET /:slug/comments list comments (+replies)
-- *
-- * Every other method answers 410 { code: 'pastes.moved' }.
-- */
--'use strict';
--
--const express = require('express');
--const fs = require('fs');
--const db = require('../db/database');
--const { tenantAuth, tenantCors } = require('../auth');
--
--const router = express.Router({ mergeParams: true });
--router.use(tenantCors);
--// Pastes moved to OpenVibe.Community: no writes here, for any app. The caller is
--// authenticated first, so an anonymous write still answers 401 like the reads.
--const writeAuth = tenantAuth({ allowUser: true });
--router.use((req, res, next) => {
-- if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') return next();
-- writeAuth(req, res, () => res.status(410).json({ error: 'Pastes moved to OpenVibe.Community', code: 'pastes.moved' }));
--});
--
--// Screenshot directory, shared with the avatar ingest (server/pastes/storage.js).
--const { SCREENSHOTS_DIR } = require('./storage');
--
--// ── Helpers ─────────────────────────────────────────────────
--
--async function getPasteConfig() {
-- return {
-- maxSizeKb: Number(await db.getSetting('paste_max_size_kb')) || 512,
-- screenshotMaxSizeMb: Number(await db.getSetting('paste_screenshot_max_size_mb')) || 8,
-- cooldownSeconds: Number(await db.getSetting('paste_cooldown_seconds')) || 30,
-- maxPerUserPerDay: Number(await db.getSetting('paste_max_per_user_per_day')) || 0,
-- };
--}
--
--function screenshotUrl(paste) {
-- return paste.screenshot_path ? `/p/${paste.slug}/screenshot` : null;
--}
--
--function pastePublic(paste) {
-- if (!paste) return null;
-- return {
-- unique_views: paste.unique_views || 0,
--
-- id: paste.id,
-- app_id: paste.app_id,
-- slug: paste.slug,
-- user_id: paste.user_id,
-- type: paste.type,
-- title: paste.title,
-- content: paste.content,
-- language: paste.language,
-- visibility: paste.visibility,
-- stream_id: paste.stream_id,
-- screenshot_url: screenshotUrl(paste),
-- metadata: paste.metadata,
-- burn_after_read: !!paste.burn_after_read,
-- forked_from: paste.forked_from,
-- pinned: !!paste.pinned,
-- views: paste.views || 0,
-- copies: paste.copies || 0,
-- likes: paste.likes || 0,
-- is_nsfw: !!paste.is_nsfw,
-- ai_summary: paste.ai_summary || null,
-- ai_tags: paste.ai_tags || null,
-- ai_analyzed_at: paste.ai_analyzed_at || null,
-- url: `/p/${paste.slug}`,
-- raw_url: `/p/${paste.slug}/raw`,
-- created_at: paste.created_at,
-- updated_at: paste.updated_at,
-- };
--}
--
--// Effective actor for the daily count in GET /config: user-JWT callers are
--// counted by their token; app-key callers name the user in the request.
--function _actor(req) {
-- if (req.authType === 'user') return { userId: req.userId, ip: req.ip };
-- return { userId: (req.body && req.body.user_id) || null, ip: req.ip };
--}
--
--async function _getPasteScoped(req, res) {
-- const slug = String(req.params.slug);
-- const paste = await db.getPasteBySlug(slug, req.appId);
-- if (!paste) {
-- // "Paste not found" was being reported for pastes that demonstrably exist, and the
-- // 404 was silent — so there was no way to tell whether the slug was wrong, the
-- // tenant scope was wrong, or the row was genuinely missing. Log all three.
-- let existsElsewhere = null;
-- try {
-- const any = await db.get('SELECT app_id FROM pastes WHERE slug = ?', [slug]);
-- existsElsewhere = any ? any.app_id : null;
-- } catch { /* */ }
-- console.warn(`[Pastes] 404 slug="${slug}" appId="${req.appId}" auth=${req.authType || 'none'}` +
-- (existsElsewhere ? ` — EXISTS under app_id="${existsElsewhere}" (tenant mismatch)` : ' — no row with that slug'));
-- res.status(404).json({ error: 'Paste not found' });
-- return null;
-- }
-- return paste;
--}
--
--// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
--async function removePasteScreenshot(paste) {
-- if (!paste) return;
-- // A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).
-- const objects = require('../objects/model');
-- if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;
-- if (paste.screenshot_path) {
-- try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
-- }
-- try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
--}
--
--// ── List pastes ─────────────────────────────────────────────
--router.get('/', tenantAuth({ allowUser: true }), async (req, res) => {
-- try {
-- const limit = Math.min(Math.max(parseInt(req.query.limit) || 50, 1), 200);
-- const offset = Math.max(parseInt(req.query.offset) || 0, 0);
-- const type = req.query.type; // 'paste', 'screenshot', or undefined for all
-- const search = req.query.search ? `%${req.query.search}%` : null;
-- const userId = req.query.user_id != null ? req.query.user_id : null;
--
-- // Unlisted/private pastes are hidden from PUBLIC listings, but their owner must
-- // still see them in their own list — otherwise creating an unlisted paste looks
-- // exactly like it was never created at all. Only honoured for a caller that can be
-- // trusted to be the owner: either an app key (the app gates who may ask) or a user
-- // token whose subject matches the user_id being listed.
-- const wantMine = req.query.include_unlisted === '1' || req.query.include_unlisted === 'true';
-- const ownerView = wantMine && userId != null && (
-- req.authType === 'app' || (req.authType === 'user' && String(req.userId) === String(userId))
-- );
-- const visClause = ownerView
-- ? `AND visibility IN ('public', 'unlisted', 'private')`
-- : `AND visibility = 'public'`;
--
-- let sql = `SELECT * FROM pastes WHERE app_id = ? ${visClause}`;
-- const params = [req.appId];
--
-- if (type === 'paste' || type === 'screenshot') { sql += ` AND type = ?`; params.push(type); }
-- if (userId != null) { sql += ` AND user_id = ?`; params.push(userId); }
-- // Work queue for the owning app's AI pass. Media holds the pastes but has no LLM;
-- // the app polls this, analyses, and posts results back to /:slug/ai. App-key only —
-- // this deliberately spans visibility, so it must never be reachable by a user token.
-- if (req.authType === 'app' && (req.query.needs_ai === '1' || req.query.needs_ai === 'true')) {
-- sql = sql.replace(visClause, `AND COALESCE(ai_summary, '') = ''`);
-- }
-- if (search) { sql += ` AND (title ILIKE ? OR content ILIKE ?)`; params.push(search, search); }
--
-- const dir = req.query.sort === 'oldest' ? 'ASC' : 'DESC';
-- sql += ` ORDER BY pinned DESC, created_at ${dir} LIMIT ? OFFSET ?`;
-- params.push(limit, offset);
--
-- const pastes = (await db.all(sql, params)).map(p => ({
-- ...pastePublic(p),
-- content: p.type === 'paste' ? (p.content || '').slice(0, 300) : null, // Preview only in list
-- }));
--
-- let countSql = `SELECT COUNT(*) as total FROM pastes WHERE app_id = ? ${visClause}`;
-- const countParams = [req.appId];
-- if (type === 'paste' || type === 'screenshot') { countSql += ` AND type = ?`; countParams.push(type); }
-- if (userId != null) { countSql += ` AND user_id = ?`; countParams.push(userId); }
-- if (search) { countSql += ` AND (title ILIKE ? OR content ILIKE ?)`; countParams.push(search, search); }
-- const { total } = await db.get(countSql, countParams);
--
-- res.json({ pastes, total, limit, offset });
-- } catch (err) {
-- console.error('[Pastes] List error:', err.message);
-- res.status(500).json({ error: 'Failed to list pastes' });
-- }
--});
--
--// ── Paste config / limits (inherited SPA reads this before posting) ──
--// Registered before /:slug so the literal path wins.
--router.get('/config', tenantAuth({ allowUser: true }), async (req, res) => {
-- try {
-- const cfg = await getPasteConfig();
-- const actor = _actor(req);
-- res.json({
-- maxSizeKb: cfg.maxSizeKb,
-- screenshotMaxSizeMb: cfg.screenshotMaxSizeMb,
-- cooldownSeconds: cfg.cooldownSeconds,
-- maxPerUserPerDay: cfg.maxPerUserPerDay,
-- todayCount: await db.countUserPastesToday(req.appId, actor.userId, actor.ip),
-- });
-- } catch (err) {
-- console.error('[Pastes] Config error:', err.message);
-- res.status(500).json({ error: 'Failed to load paste config' });
-- }
--});
--
--
--// ── Get single paste by slug ────────────────────────────────
--router.get('/:slug', tenantAuth({ allowUser: true }), async (req, res) => {
-- try {
-- const paste = await _getPasteScoped(req, res);
-- if (!paste) return;
-- // Private pastes: owner (or the app itself) only — decided before anything is counted.
-- if (paste.visibility === 'private') {
-- const allowed = req.authType === 'app' || (req.userId != null && paste.user_id === req.userId);
-- if (!allowed) return res.status(404).json({ error: 'Paste not found' });
-- }
-- // The owning app renders the paste page itself — count the view here (real client
-- // IP via X-Forwarded-For, viewer via the "user" JWT / X-OV-User-Id) unless it says
-- // this fetch is not a page view (?no_view=1 for edit forms, embeds, bots).
-- if (String(req.query.no_view || '') !== '1' && !paste.burn_after_read) {
-- try { const r = await require('../views/service').recordView('paste', paste.id, { req, ownerUserId: paste.user_id }); if (r.view_count != null) { paste.views = r.view_count; paste.unique_views = r.unique_views; } } catch { /* */ }
-- }
--
--
-- paste.liked = req.userId != null ? await db.hasUserLikedPaste(paste.id, req.userId) : false;
-- res.json({ paste: { ...pastePublic(paste), liked: paste.liked } });
-- } catch (err) {
-- console.error('[Pastes] Get error:', err.message);
-- res.status(500).json({ error: 'Failed to get paste' });
-- }
--});
--
--// ═════════════════════════════════════════════════════════════
--// ── Paste Comments (supports anonymous via app callers) ─────
--// ═════════════════════════════════════════════════════════════
--
--router.get('/:slug/comments', tenantAuth({ allowUser: true }), async (req, res) => {
-- try {
-- const paste = await _getPasteScoped(req, res);
-- if (!paste) return;
--
-- const limit = Math.min(parseInt(req.query.limit || '50'), 100);
-- const offset = parseInt(req.query.offset || '0');
--
-- const comments = await db.getPasteComments(paste.id, limit, offset);
-- const total = await db.getPasteCommentCount(paste.id);
--
-- for (const c of comments) {
-- c.replies = await db.getPasteCommentReplies(c.id);
-- c.reply_count = c.replies.length;
-- }
--
-- res.json({ comments, total });
-- } catch (err) {
-- console.error('[PasteComments] List error:', err.message);
-- res.status(500).json({ error: 'Failed to load comments' });
-- }
--});
--
--module.exports = router;
--module.exports.SCREENSHOTS_DIR = SCREENSHOTS_DIR;
--module.exports.removePasteScreenshot = removePasteScreenshot;
ddiff --git a/server/pastes/storage.js b/server/pastes/storage.js
iindex cebfee2..e09f0b9 100644
---- a/server/pastes/storage.js
++++ b/server/pastes/storage.js
@@@ -1,9 +1,10 @@
'use strict';
--// Paste storage plumbing, shared by the paste routes and the avatar ingest (which
--// stores every avatar as an unlisted screenshot paste): where a paste's screenshot
--// bytes land, and how a slug is minted (checked against the pastes table, so two
--// writes never get the same one). A leaf module — requiring it pulls in neither the
--// paste router nor anything that mounts it.
++// Paste storage plumbing, shared by the public paste routes and the avatar ingest
++// (which stores every avatar as an unlisted screenshot paste): where a paste's
++// screenshot bytes land, how a slug is minted (checked against the pastes table, so
++// two writes never get the same one), and how a burn-after-read screenshot is
++// removed. A leaf module — requiring it pulls in neither a router nor anything that
++// mounts one.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
@@@ -63,4 +64,16 @@ async function generateSlug(attempts = 0) {
return slug;
}
--module.exports = { SCREENSHOTS_DIR, generateSlug };
++// Fully remove a paste's screenshot from local disk AND any legacy B2 object.
++// A held screenshot keeps its bytes (the row delete that follows is refused by the hold trigger).
++async function removePasteScreenshot(paste) {
++ if (!paste) return;
++ const objects = require('../objects/model');
++ if (await objects.isHeldRow(paste.object_id !== undefined ? paste : await db.get('SELECT object_id FROM pastes WHERE id = ?', [paste.id]))) return;
++ if (paste.screenshot_path) {
++ try { fs.unlinkSync(paste.screenshot_path); } catch { /* ignore */ }
++ }
++ try { require('../vod/vod-storage').deleteLegacyPasteScreenshot(paste.id).catch(() => {}); } catch { /* ignore */ }
++}
++
++module.exports = { SCREENSHOTS_DIR, generateSlug, removePasteScreenshot };
ddiff --git a/server/public/routes.js b/server/public/routes.js
iindex ca93f3f..7c3e2b7 100644
---- a/server/public/routes.js
++++ b/server/public/routes.js
@@@ -548,7 +548,7 @@ router.get('/p/:slug', optionalIdentity, async (req, res) => {
// Burn-after-read: allow one non-owner read, then delete.
if (paste.burn_after_read && !isOwner && paste.views > 1) {
-- await require('../pastes/routes').removePasteScreenshot(paste);
++ await require('../pastes/storage').removePasteScreenshot(paste);
await db.run('DELETE FROM pastes WHERE id = ?', [paste.id]);
return res.status(410).send('This paste has been burned after reading.');
}
ddiff --git a/test/avatar-ingest.test.js b/test/avatar-ingest.test.js
iindex d7f3ca9..189291d 100644
---- a/test/avatar-ingest.test.js
++++ b/test/avatar-ingest.test.js
@@@ -49,12 +49,10 @@ process.env.MEDIA_PUBLIC_URL = 'https://media.test';
await rejects(toAvatar(await sharp({ create: { width: 8, height: 8, channels: 3, background: '#000' } }).png().toBuffer()), /too small/, 'tiny images are refused');
// ── Shared paste storage: the screenshots dir and the slug have one source ──
-- // (server/pastes/storage.js — the avatar ingest no longer takes them from the paste router)
-- assert.ok(!require.cache[require.resolve('../server/pastes/routes')], 'requiring the avatar ingest does not load the paste routes');
++ // (server/pastes/storage.js — the paste router is gone, the avatar ingest reads the leaf directly)
const storage = require('../server/pastes/storage');
const config = require('../server/config');
assert.strictEqual(storage.SCREENSHOTS_DIR, path.join(config.pastes.path, 'screenshots'), 'avatars keep landing in the paste screenshots directory');
-- assert.strictEqual(storage.SCREENSHOTS_DIR, require('../server/pastes/routes').SCREENSHOTS_DIR, 'the paste routes share that one directory');
const slugA = await storage.generateSlug();
const slugB = await storage.generateSlug();
for (const s of [slugA, slugB]) assert.match(s, /^[a-z]+-[a-z]+-[0-9]{2}$/, `slug shape: ${s}`);
ddiff --git a/test/object-first-writes.test.js b/test/object-first-writes.test.js
iindex 6b0d5f5..bfc9b20 100644
---- a/test/object-first-writes.test.js
++++ b/test/object-first-writes.test.js
@@@ -72,7 +72,6 @@ const sharp = require('sharp');
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
app.use('/api/v1/:app/files', require('../server/files/routes'));
app.use('/api/v1/:app/thumbnails', require('../server/thumbnails/routes'));
-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
const server = http.createServer(app);
(async () => {
@@@ -203,10 +202,10 @@ const sharp = require('sharp');
assert.strictEqual((await call('DELETE', `/files/${encodeURIComponent(spare.body.key)}`)).status, 200);
assert.strictEqual((await model.getObject(spareObj)).lifecycle_status, 'deleted');
-- // Paste writes moved to OpenVibe.Community: POST /pastes answers 410 and writes nothing.
++ // The read-only paste API is retired (T10 step 2): POST /pastes has no route and writes nothing.
const pastesBefore = [await count('pastes'), await count('media_objects')];
r = await call('POST', '/pastes', form('screenshot', png, 'image/png', 'shot.png', { title: 'Shot', visibility: 'unlisted' }));
-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
++ assert.strictEqual(r.status, 404, 'no paste-write route remains');
assert.deepStrictEqual([await count('pastes'), await count('media_objects')], pastesBefore, 'a refused paste write creates neither a row nor an object');
// A screenshot paste row (imported before the move) still commits with its object.
const slug = 'shot1';
ddiff --git a/test/pastes-moved.test.js b/test/pastes-moved.test.js
iindex d4cdd93..f91ed02 100644
---- a/test/pastes-moved.test.js
++++ b/test/pastes-moved.test.js
@@@ -1,6 +1,7 @@
'use strict';
--// Pastes moved to OpenVibe.Community: paste writes always answer 410 pastes.moved and reads keep working.
--// With PASTES_MOVED_TO set, the paste page and its text 301 to Community; screenshot bytes are still served here.
++// Pastes moved to OpenVibe.Community: Media's read-only paste API is retired (T10 step 2), so
++// /api/v1/:app/pastes is unmounted and every method there answers 404. The public /p/:slug page
++// and its text still 301 to Community when PASTES_MOVED_TO is set, and screenshot bytes are served here.
const assert = require('assert');
const fs = require('fs');
const os = require('os');
@@@ -18,7 +19,6 @@ const express = require('express');
const app = express();
app.use(express.json());
-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use(require('../server/public/routes'));
const server = http.createServer(app);
@@@ -28,25 +28,21 @@ const express = require('express');
const get = (p) => fetch(base + p, { redirect: 'manual' });
const post = () => fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { authorization: 'Bearer live-key', 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
-- // Writes answer 410 for every app, with or without PASTES_MOVED_TO, and create nothing.
++ // The app API is gone: no method on /api/v1/:app/pastes is mounted, so a write has no route to
++ // answer 410 and creates nothing, and a read has no route either.
const pasteCount = async () => (await db.get('SELECT COUNT(*) AS n FROM pastes')).n;
const rows = await pasteCount();
let r = await post();
-- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
-- assert.strictEqual(await pasteCount(), rows, 'a refused write creates no paste');
-- // The caller is authenticated before the 410: an anonymous write answers 401, like the reads.
-- r = await fetch(`${base}/api/v1/live/pastes`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ content: 'x' }) });
-- assert.strictEqual(r.status, 401);
++ assert.strictEqual(r.status, 404, 'no paste-write route remains');
++ r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
++ assert.strictEqual(r.status, 404, 'no paste-read route remains');
++ assert.strictEqual(await pasteCount(), rows, 'nothing creates a paste');
// Before the redirect switch: the pages are served here.
assert.strictEqual((await get('/p/text-1')).status, 200);
assert.strictEqual(await (await get('/p/text-1/raw')).text(), 'hello');
process.env.PASTES_MOVED_TO = 'https://openvibe.community/';
-- r = await post();
-- assert.strictEqual(r.status, 410); assert.strictEqual((await r.json()).code, 'pastes.moved');
-- r = await fetch(`${base}/api/v1/live/pastes/text-1`, { headers: { authorization: 'Bearer live-key' } });
-- assert.strictEqual(r.status, 200, 'reads still work');
r = await get('/p/text-1');
assert.strictEqual(r.status, 301); assert.strictEqual(r.headers.get('location'), 'https://openvibe.community/p/text-1');
r = await get('/p/text-1/raw');
ddiff --git a/test/private-not-found.test.js b/test/private-not-found.test.js
iindex dd69c76..be3a551 100644
---- a/test/private-not-found.test.js
++++ b/test/private-not-found.test.js
@@@ -63,7 +63,6 @@ const http = require('http');
app.use(express.json());
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use('/o', require('../server/objects/routes').publicRouter);
app.use('/', require('../server/public/routes'));
const server = http.createServer(app);
@@@ -182,9 +181,11 @@ const http = require('http');
const views = async () => (await raw.prepare("SELECT views FROM pastes WHERE slug = 'priv-text'").get()).views;
const before = await views();
-- await same(['/api/v1/live/pastes/priv-text', asUser(77)], ['/api/v1/live/pastes/no-such-slug', asUser(77)], 'acting non-owner: private paste looks missing');
++ // The read-only paste API is retired (T10 step 2): the v1 path is unmounted for everyone,
++ // so it cannot confirm a private paste exists either.
++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 404, 'the paste API is gone');
++ assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(77))).status, 404);
assert.strictEqual(await views(), before, 'a refused read counts no view');
-- assert.strictEqual((await get('/api/v1/live/pastes/priv-text', asUser(5))).status, 200);
console.log('✅ v1 detail: acting non-owners get the missing answer');
server.close();
ddiff --git a/test/retention-holds.test.js b/test/retention-holds.test.js
iindex f7a0851..5ccf9a9 100644
---- a/test/retention-holds.test.js
++++ b/test/retention-holds.test.js
@@@ -67,7 +67,6 @@ const http = require('http');
app.use('/api/v1/:app/admin/storage', require('../server/admin/routes'));
app.use('/api/v1/:app/vods', require('../server/vod/routes'));
app.use('/api/v1/:app/clips', require('../server/vod/clips-routes'));
-- app.use('/api/v1/:app/pastes', require('../server/pastes/routes'));
app.use('/api/v2/:app/objects', require('../server/objects/routes'));
(async () => {
@@@ -173,17 +172,17 @@ const http = require('http');
assert.strictEqual(await vod(24), undefined, 'and still removes an unheld one');
console.log('✅ finalize and the junk sweep settle held empty recordings instead of deleting them');
-- // ── A held screenshot paste: paste writes moved to OpenVibe.Community, so deletes answer 410 ──
++ // ── A held screenshot paste: the v1 paste API is retired (T10 step 2), so the hold is
++ // enforced by the delete trigger alone — the row, its bytes and its object stay. ──
const shot = await db.get("SELECT * FROM pastes WHERE slug = 'shot1'");
await model.placeHold({ object_id: shot.object_id, kind: 'evidence', reason: 'x' });
-- r = await call('DELETE', '/api/v1/live/pastes/shot1');
-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved']);
-- r = await call('POST', '/api/v1/live/pastes/bulk', { slugs: ['shot1'], action: 'delete' });
-- assert.deepStrictEqual([r.status, r.body.code], [410, 'pastes.moved'], 'bulk delete is gone too');
++ await assert.rejects(async () => await db.run("DELETE FROM pastes WHERE slug = 'shot1'"), /retention hold/, 'row SQL is refused by the trigger');
++ const probe = await fetch(`http://127.0.0.1:${server.address().port}/api/v1/live/pastes/shot1`, { method: 'DELETE', headers: { authorization: 'Bearer live-key' }, redirect: 'manual' });
++ assert.strictEqual(probe.status, 404, 'the paste API has no delete route any more');
assert.ok(fs.existsSync(shot.screenshot_path) && await db.get("SELECT 1 AS x FROM pastes WHERE slug = 'shot1'"));
const shotObj = await model.getObject(shot.object_id);
assert.ok(shotObj && shotObj.lifecycle_status !== 'deleted', 'the paste keeps its object');
-- console.log('✅ a held screenshot paste: DELETE and bulk delete answer 410 pastes.moved; row, bytes and object stay');
++ console.log('✅ a held screenshot paste: the delete trigger refuses it; row, bytes and object stay');
// ── Listing and release ──
r = await call('GET', H);