openvibers/openvibe.community Commits

Pastes: Community becomes the authority (PASTES_AUTHORITY=community)

OP OpenVibers committed 22 Sept 2026 · 786e6f8ce9 · 30 files, +4104 −47

Community gets its own SQLite database (better-sqlite3, COMMUNITY_DB_PATH) and a
native /api/pastes that reproduces the Media API behind Live's proxy: paths,
bodies, status codes, error texts and response shapes, visibility, burn after
read, forks, like toggles, copies, visit-based view counting, comments with
anonymous names and one level of replies, config, staff tools and the AI queue.
PASTES_AUTHORITY defaults to 'live', which keeps today's proxy and Live-backed
pages exactly as they were; nothing changes until the flag flips.

- Identity: people are Network subjects. Browsers are the owner named by the
  JWT's subject_id (older tokens are resolved once through the Network).
  Services present a Network service token for openvibe.community and need the
  route's capability (community.paste.create / write / moderate); X-OV-Subject
  names the acting person, X-OV-Origin: ai stores AI output ownerless with
  origin 'ai', X-OV-Source-Ref records the stream, X-OV-Staff vouches for staff.
  Identity is never read from bodies or queries.
- Author names and avatars come from subject_projection, refreshed through the
  Network's resolve-batch with a client-credentials token.
- New screenshots lose EXIF/XMP and go to Media's file store with a service
  token; imported ones keep their Media URLs.
- Pages, /p/:slug/raw, /p/:slug/screenshot and /p/:slug/download read from the
  store in community mode (no bounce to Media, which will redirect back here).
- scripts/import-pastes.js imports Media's pastes-export bundle: verified
  (format, counts, sha256), idempotent, owners mapped across the id-space
  cutoff with holds for ambiguous or unmapped rows, AI moments ownerless,
  forks and replies remapped, reconciliation report, --dry-run.
- Anonymous writes stay at 20 per 10 minutes per address (API and no-JS form).
- CI also runs openvibe-contracts-check; the unit gets a StateDirectory for the
  database.

Changes

ddiff --git a/.env.example b/.env.example
iindex 05306c3..06a4456 100644
---- a/.env.example
++++ b/.env.example
@@@ -33,3 +33,16 @@ OV_LIVE_URL=https://openvibe.live
# OpenVibe.Media — public host for raw paste text and screenshots
OV_MEDIA_URL=https://openvibe.media
++# Media's internal address: new screenshot uploads (community authority) go to its file store
++# with a service token (the OAuth client needs media.object.upload for audience openvibe.media).
++OV_MEDIA_INTERNAL_URL=http://127.0.0.1:4100
++
++# Who owns pastes:
++# live (default) /api/pastes/* proxies to OpenVibe.Live, pages read through Live
++# community this site's own database is the authority (native API, pages, raw text)
++# With 'community' the OAuth client above also needs identity.subject.resolve (author names).
++PASTES_AUTHORITY=live
++# SQLite database (production unit: /var/lib/openvibe-community/community.db)
++COMMUNITY_DB_PATH=./data/community.db
++# Optional salt for hashed visitor ids in view counting (default: derived from the client secret)
++#VIEW_HASH_SECRET=
ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex d640bde..484b236 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -14,4 +14,5 @@ jobs:
cache: npm
- run: npm ci --no-audit --no-fund
- run: npm test
++ - run: npx openvibe-contracts-check --service community --src server
ddiff --git a/README.md b/README.md
iindex cb12e8f..44251dc 100644
---- a/README.md
++++ b/README.md
@@@ -6,12 +6,19 @@ The community hub of the OpenVibe network: community-run, open source, free spee
the rules. Today it is the home of **pastes** (code, text and screenshots with a link);
spaces, threads and submissions follow.
--It is a small Node/Express app (CommonJS, no framework, no database of its own) that
++It is a small Node/Express app (CommonJS, no framework, one SQLite database) that
server-renders every page — crawlers and no-JS readers get the whole thing — and adds a
little progressive JavaScript for pagination, copy buttons and the upload path.
## How it fits the network
++Pastes are moving to Community (roadmap Wave 5). `PASTES_AUTHORITY` picks who owns them:
++
++- **`live`** (default, today's production): the table below. Deploying the new code changes
++ nothing until the flag flips.
++- **`community`**: Community's own database is the authority — see
++ [Community as the paste authority](#community-as-the-paste-authority).
++
| Concern | Where it lives | How Community reaches it |
| --- | --- | --- |
| Paste storage | **OpenVibe.Media** (`pastes` table, `/api/v1/:app/pastes`) | never directly |
@@@ -27,6 +34,50 @@ writes, which only Live can do, so Community proxies `/api/pastes/*` to Live and
visitor's `Authorization: Bearer <network jwt>` (or `ov_token` cookie). Creation, listing,
anonymous-post limits, ownership and author names are therefore identical to Live's.
++### Community as the paste authority
++
++With `PASTES_AUTHORITY=community`, `/api/pastes/*` is Community's native API
++(`server/pastes/api.js` → `service.js` → `store.js`) with the paths, bodies, status codes and
++response shapes the browser clients already use, and every page, `/p/:slug/raw`,
++`/p/:slug/screenshot` and `/p/:slug/download` reads from the store (never from Media, which
++redirects its old paste URLs here after cutover).
++
++- **People are Network subjects** (`usr_…`, `gst_…`), never service-local integers. A browser's
++ owner is the JWT's `subject_id` (older tokens are resolved once through the Network and
++ cached in `legacy_id_map`). In responses `user_id` carries that subject id, next to
++ `owner_subject` and `origin`; names/avatars come from `subject_projection`, a cache
++ refreshed through the Network's `POST /internal/identity/resolve-batch` (service token,
++ `identity.subject.resolve`).
++- **Service callers** (Live's adapter, Live's AI jobs) send a Network service token for
++ audience `openvibe.community` and need the route's capability: `community.paste.create`
++ (create), `community.paste.write` (edit, delete, fork, like, copy, comment as someone),
++ `community.paste.moderate` (`/admin/*`, `/bulk`, `/:slug/censor`, the AI queue
++ `?needs_ai=1` and `POST /:slug/ai`). Headers: `X-OV-Subject: usr_…|gst_…` names the acting
++ person (no subject = anonymous); `X-OV-Origin: ai` stores AI output with origin `ai` and no
++ owner; `X-OV-Source-Ref: <EntityRef JSON>` records e.g. the stream; `X-OV-Staff: 1` (needs
++ moderate) vouches that the acting person is staff. Services may bring a `slug` and
++ `metadata`; browsers never. Identity is never read from bodies or queries.
++- **Limits** as before: 20 anonymous writes per 10 minutes per address (API and the no-JS
++ form share it); people get Media's 30 s paste cooldown, 200/day and comment limits.
++- **Screenshots**: new uploads lose their EXIF/XMP metadata and go to Media's file store
++ (`POST ${OV_MEDIA_INTERNAL_URL}/api/v1/community/files`, service token with
++ `media.object.upload`); the paste keeps the public URL and `media_ref`
++ `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot URLs.
++- **Deletes are soft** (content scrubbed, slug kept reserved); edits append `paste_versions`.
++
++Moving the data: `node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff
++2026-08-20T03:00:26Z]` imports Media's `openvibe.media.pastes-export` bundle. It checks the
++bundle's format, counts and sha256 first, is idempotent (upsert by `legacy_media_id`; a newer
++bundle adds and updates, counters never go down, local edits and deletes stay), maps Live user
++ids to subjects through the Network (rows before the cutoff are also checked as Network ids;
++disagreement or no answer imports the row ownerless and records an `import_hold`), keeps AI
++moment pastes ownerless with origin `ai`, and prints a reconciliation report (also stored in
++`migration_runs`). `--dry-run` rolls everything back.
++
++`/by-user/:username` and `?username=` look the name up in `subject_projection` (the Network
++has no service-token lookup by username), so a person appears there once Community has seen
++them — signed in here, or named as an author in a resolved projection.
++
## Endpoints
Pages (server-rendered HTML):
@@@ -36,9 +87,9 @@ Pages (server-rendered HTML):
| `GET /` | Home: hero, latest pastes, most viewed, "start a paste" CTA, what is coming |
| `GET /pastes` | Browse — `?q=` search, `?sort=new\|views`, `?lang=`, `?page=` |
| `GET /p/:slug` | Paste page — highlighted body (server-side, highlight.js), raw/download/copy/fork/share, screenshot, related |
--| `GET /p/:slug/raw` | 302 → `https://openvibe.media/p/:slug/raw` |
--| `GET /p/:slug/screenshot` | 302 → `https://openvibe.media/p/:slug/screenshot` |
--| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to Media |
++| `GET /p/:slug/raw` | `live`: 302 → `https://openvibe.media/p/:slug/raw`; `community`: the text itself |
++| `GET /p/:slug/screenshot` | `live`: 302 → Media's `/p/:slug/screenshot`; `community`: 302 → the stored image URL |
++| `GET /p/:slug/download` | The text as an attachment (`slug.ext`); screenshots bounce to the image |
| `GET /new`, `POST /new` | Create (signed-in or anonymous). `?fork=slug` prefills. The POST is the no-JS fallback; with JS the form talks to `/api/pastes` |
| `GET /my` | The signed-in user's pastes (public, unlisted and private); anonymous → sign-in |
@@@ -46,7 +97,7 @@ API and machine endpoints:
| Route | What |
| --- | --- |
--| `ANY /api/pastes/*` | Transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched |
++| `ANY /api/pastes/*` | `live`: transparent proxy to Live `/api/pastes/*` — list, get, create, `screenshot` (multipart), `:slug/copy`, `:slug/like`, comments, delete… bodies stream through untouched. `community`: the native API (same surface, plus `/:slug/versions`) |
| `GET /api/health`, `GET /api/ready` | Liveness / readiness |
| `GET /auth/login` | → Network `/oauth/authorize`. `?next=` (same-site path, this origin, or `https://openvibe.network/…`), `?silent=1` adds `prompt=none` |
| `GET /auth/callback` | Code exchange; sets cookies. `error=login_required` → `next` + `?sso=none` |
@@@ -84,6 +135,10 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
| `OV_LIVE_INTERNAL_URL` | `http://127.0.0.1:3000` | Live's `/api/pastes` |
| `OV_LIVE_URL` | `https://openvibe.live` | Author links, avatars, legal pages |
| `OV_MEDIA_URL` | `https://openvibe.media` | Raw text + screenshots |
++| `OV_MEDIA_INTERNAL_URL` | `http://127.0.0.1:4100` | Media file store for new screenshots (`community` authority) |
++| `PASTES_AUTHORITY` | `live` | `live` = proxy to Live; `community` = this site's database is the authority |
++| `COMMUNITY_DB_PATH` | `./data/community.db` | SQLite file (the systemd unit sets `/var/lib/openvibe-community/community.db`) |
++| `VIEW_HASH_SECRET` | derived from the client secret | Salt for hashed visitor ids in view counts |
| `COOKIE_SECURE` | `true` in production | Set `false` for plain-http local dev |
## Run
@@@ -91,7 +146,7 @@ Copy `.env.example` to `.env` (production: `/etc/openvibe/community.env`, mode 0
```
npm install
npm start # http://127.0.0.1:4200
--npm test # mocks Live + Network in-process; no network needed
++npm test # mocks Live, Network and Media in-process; no network needed
```
## Deploy
@@@ -104,8 +159,15 @@ deploy/nginx/openvibe.community.conf # → /etc/nginx/sites-available/, T
# /etc/letsencrypt/live/openvibe.community/
```
--Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. Nothing is
--written to disk by the app; there is no migration step.
++Update: `git pull && npm ci --omit=dev && systemctl restart openvibe-community`. The schema
++is created idempotently at boot (only when `PASTES_AUTHORITY=community`; in `live` mode the
++app writes nothing to disk). The database lives in the unit's `StateDirectory`
++(`/var/lib/openvibe-community`), so the code tree stays read-only.
++
++Before flipping to `community`, the Network's `community` OAuth client needs the
++`identity.subject.resolve` capability (audience `openvibe.network`) and
++`media.object.upload` (audience `openvibe.media`), and Live's service client needs the
++`community.paste.*` capabilities it will use.
The Network must have the OAuth client `community` registered with redirect
`https://openvibe.community/auth/callback`, and serve the current `openvibe-shared`
@@@ -119,10 +181,20 @@ server/
index.js process entry (listen, shutdown)
app.js Express app factory: middleware, routes
config.js env → config
++ db.js SQLite (better-sqlite3): schema, opened at COMMUNITY_DB_PATH
auth/routes.js OAuth2 client (login/callback/logout/me/refresh), optionalAuth
-- live-client.js server-side reads of Live's /api/pastes for rendered pages
-- pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address)
-- pastes/catalog.js cached window of recent public pastes: trending, related, language filter
++ identity/viewer.js who is calling: browser JWT, service token (+ X-OV-* headers), anonymous
++ identity/network.js Network resolve-batch client + subject_projection cache
++ media/files.js new screenshot bytes → Media's file store (service token)
++ media/strip-metadata.js EXIF/XMP/text chunks out of JPEG/PNG/WebP without re-encoding
++ live-client.js server-side reads of Live's /api/pastes for rendered pages ('live')
++ pastes/proxy.js /api/pastes/* → Live (streams bodies, forwards token + address) ('live')
++ pastes/api.js native /api/pastes/* ('community')
++ pastes/service.js paste rules: visibility, limits, burn-after-read, views, shapes
++ pastes/store.js pure SQL over pastes / versions / likes / comments / projections
++ pastes/importer.js Media export bundle → store (scripts/import-pastes.js is the CLI)
++ pastes/source.js where pages read pastes from (Live or the store)
++ pastes/catalog.js recent public pastes: trending, related, language filter
render/layout.js page shell: SEO head, shared chrome, hashed assets
render/pages.js home / browse / paste / new / my / error templates
render/highlight.js highlight.js wrapper, language list, download extensions
@@@ -130,14 +202,15 @@ server/
public/ css/community.css, js/community.js, favicon.svg, og-default.png
vendor/openvibe-shared unmodified copy of OpenVibe.Network/packages/openvibe-shared
deploy/ systemd unit, nginx vhost
--test/ run.js + *.test.js (mock Live + mock Network with a real RS256 key)
++scripts/import-pastes.js Media paste bundle importer
++test/ run.js + *.test.js (mock Live, Network and Media with a real RS256 key)
```
## What is next (phase 2)
Spaces (per-streamer / per-topic communities), threads (long-form forum discussion) and
submissions (clips, art, ideas, reports for community review). These need storage of their
--own — Community has none today — and moderation tooling; the paste pages, auth layer and
++own — the paste database is the first — and moderation tooling; the paste pages, auth layer and
chrome are built to be reused by them.
## Related services
ddiff --git a/deploy/systemd/openvibe-community.service b/deploy/systemd/openvibe-community.service
iindex 7a899ed..0b58eba 100644
---- a/deploy/systemd/openvibe-community.service
++++ b/deploy/systemd/openvibe-community.service
@@@ -21,7 +21,10 @@ NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
--# Nothing is written to disk — pastes live in OpenVibe.Media, sessions are cookies.
++# Community's own database (used once PASTES_AUTHORITY=community; untouched before that).
++# The code tree stays read-only; the database lives in /var/lib/openvibe-community.
++StateDirectory=openvibe-community
++Environment=COMMUNITY_DB_PATH=/var/lib/openvibe-community/community.db
[Install]
WantedBy=multi-user.target
ddiff --git a/package-lock.json b/package-lock.json
iindex 15e5e72..06787ac 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -9,12 +9,15 @@
"version": "1.0.0",
"license": "MIT",
"dependencies": {
++ "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
++ "multer": "^2.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared"
},
"engines": {
@@@ -34,12 +37,102 @@
"node": ">= 0.6"
}
},
++ "node_modules/ajv": {
++ "version": "8.20.0",
++ "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
++ "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
++ "license": "MIT",
++ "dependencies": {
++ "fast-deep-equal": "^3.1.3",
++ "fast-uri": "^3.0.1",
++ "json-schema-traverse": "^1.0.0",
++ "require-from-string": "^2.0.2"
++ },
++ "funding": {
++ "type": "github",
++ "url": "https://github.com/sponsors/epoberezkin"
++ }
++ },
++ "node_modules/ajv-formats": {
++ "version": "3.0.1",
++ "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
++ "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
++ "license": "MIT",
++ "dependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependencies": {
++ "ajv": "^8.0.0"
++ },
++ "peerDependenciesMeta": {
++ "ajv": {
++ "optional": true
++ }
++ }
++ },
++ "node_modules/append-field": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/append-field/-/append-field-1.0.0.tgz",
++ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==",
++ "license": "MIT"
++ },
"node_modules/array-flatten": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
"integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
"license": "MIT"
},
++ "node_modules/base64-js": {
++ "version": "1.5.1",
++ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
++ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/better-sqlite3": {
++ "version": "11.10.0",
++ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz",
++ "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==",
++ "hasInstallScript": true,
++ "license": "MIT",
++ "dependencies": {
++ "bindings": "^1.5.0",
++ "prebuild-install": "^7.1.1"
++ }
++ },
++ "node_modules/bindings": {
++ "version": "1.5.0",
++ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
++ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
++ "license": "MIT",
++ "dependencies": {
++ "file-uri-to-path": "1.0.0"
++ }
++ },
++ "node_modules/bl": {
++ "version": "4.1.0",
++ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
++ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
++ "license": "MIT",
++ "dependencies": {
++ "buffer": "^5.5.0",
++ "inherits": "^2.0.4",
++ "readable-stream": "^3.4.0"
++ }
++ },
"node_modules/body-parser": {
"version": "1.20.8",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
@@@ -64,12 +157,47 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/buffer": {
++ "version": "5.7.1",
++ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
++ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "base64-js": "^1.3.1",
++ "ieee754": "^1.1.13"
++ }
++ },
"node_modules/buffer-equal-constant-time": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
"integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==",
"license": "BSD-3-Clause"
},
++ "node_modules/busboy": {
++ "version": "1.6.0",
++ "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
++ "integrity": "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA==",
++ "dependencies": {
++ "streamsearch": "^1.1.0"
++ },
++ "engines": {
++ "node": ">=10.16.0"
++ }
++ },
"node_modules/bytes": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
@@@ -108,6 +236,12 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/chownr": {
++ "version": "1.1.4",
++ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
++ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
++ "license": "ISC"
++ },
"node_modules/content-disposition": {
"version": "0.5.4",
"resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
@@@ -166,6 +300,30 @@
"ms": "2.0.0"
}
},
++ "node_modules/decompress-response": {
++ "version": "6.0.0",
++ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
++ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
++ "license": "MIT",
++ "dependencies": {
++ "mimic-response": "^3.1.0"
++ },
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/deep-extend": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
++ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=4.0.0"
++ }
++ },
"node_modules/depd": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
@@@ -185,6 +343,15 @@
"npm": "1.2.8000 || >= 1.4.16"
}
},
++ "node_modules/detect-libc": {
++ "version": "2.1.2",
++ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
++ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
++ "license": "Apache-2.0",
++ "engines": {
++ "node": ">=8"
++ }
++ },
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
@@@ -235,6 +402,15 @@
"node": ">= 0.8"
}
},
++ "node_modules/end-of-stream": {
++ "version": "1.4.5",
++ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
++ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
++ "license": "MIT",
++ "dependencies": {
++ "once": "^1.4.0"
++ }
++ },
"node_modules/es-define-property": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
@@@ -280,6 +456,15 @@
"node": ">= 0.6"
}
},
++ "node_modules/expand-template": {
++ "version": "2.0.3",
++ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
++ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
++ "license": "(MIT OR WTFPL)",
++ "engines": {
++ "node": ">=6"
++ }
++ },
"node_modules/express": {
"version": "4.22.3",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.3.tgz",
@@@ -341,6 +526,34 @@
"express": ">= 4.11"
}
},
++ "node_modules/fast-deep-equal": {
++ "version": "3.1.3",
++ "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
++ "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
++ "license": "MIT"
++ },
++ "node_modules/fast-uri": {
++ "version": "3.1.8",
++ "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz",
++ "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/fastify"
++ },
++ {
++ "type": "opencollective",
++ "url": "https://opencollective.com/fastify"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
++ "node_modules/file-uri-to-path": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
++ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
++ "license": "MIT"
++ },
"node_modules/finalhandler": {
"version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@@ -377,6 +590,12 @@
"node": ">= 0.6"
}
},
++ "node_modules/fs-constants": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
++ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
++ "license": "MIT"
++ },
"node_modules/function-bind": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@@ -423,6 +642,12 @@
"node": ">= 0.4"
}
},
++ "node_modules/github-from-package": {
++ "version": "0.0.0",
++ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
++ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
++ "license": "MIT"
++ },
"node_modules/gopd": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@@ -512,12 +737,38 @@
"node": ">=0.10.0"
}
},
++ "node_modules/ieee754": {
++ "version": "1.2.1",
++ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
++ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "BSD-3-Clause"
++ },
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
++ "node_modules/ini": {
++ "version": "1.3.8",
++ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
++ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
++ "license": "ISC"
++ },
"node_modules/ipaddr.js": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@@@ -527,6 +778,12 @@
"node": ">= 0.10"
}
},
++ "node_modules/json-schema-traverse": {
++ "version": "1.0.0",
++ "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
++ "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
++ "license": "MIT"
++ },
"node_modules/jsonwebtoken": {
"version": "9.0.3",
"resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.3.tgz",
@@@ -687,12 +944,63 @@
"node": ">= 0.6"
}
},
++ "node_modules/mimic-response": {
++ "version": "3.1.0",
++ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
++ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=10"
++ },
++ "funding": {
++ "url": "https://github.com/sponsors/sindresorhus"
++ }
++ },
++ "node_modules/minimist": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
++ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
++ "license": "MIT",
++ "funding": {
++ "url": "https://github.com/sponsors/ljharb"
++ }
++ },
++ "node_modules/mkdirp-classic": {
++ "version": "0.5.3",
++ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
++ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
++ "license": "MIT"
++ },
"node_modules/ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
"license": "MIT"
},
++ "node_modules/multer": {
++ "version": "2.4.0",
++ "resolved": "https://registry.npmjs.org/multer/-/multer-2.4.0.tgz",
++ "integrity": "sha512-7dqa0ZcFfzbefdTuIkzOSMvZWC0J7FLqBOjJUZvDCXShIURWKxAyTT1wHhnE5q19c7jOJf43IYYKjBmZVZmvhg==",
++ "license": "MIT",
++ "dependencies": {
++ "append-field": "^1.0.0",
++ "busboy": "^1.6.0",
++ "type-is": "^1.6.18"
++ },
++ "engines": {
++ "node": ">= 10.16.0"
++ },
++ "funding": {
++ "type": "opencollective",
++ "url": "https://opencollective.com/express"
++ }
++ },
++ "node_modules/napi-build-utils": {
++ "version": "2.0.0",
++ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
++ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
++ "license": "MIT"
++ },
"node_modules/negotiator": {
"version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@@ -702,6 +1010,18 @@
"node": ">= 0.6"
}
},
++ "node_modules/node-abi": {
++ "version": "3.96.0",
++ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.96.0.tgz",
++ "integrity": "sha512-rebQ/lz7i0EkoLzUVSrKRzA69zMkwLp95kKMWoMDkkM00Suxz0D7zEQPwRml5fQum24mj7bPvmlgLAmu2JCiYg==",
++ "license": "MIT",
++ "dependencies": {
++ "semver": "^7.3.5"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
"node_modules/object-inspect": {
"version": "1.13.4",
"resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
@@@ -726,6 +1046,31 @@
"node": ">= 0.8"
}
},
++ "node_modules/once": {
++ "version": "1.4.0",
++ "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
++ "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
++ "license": "ISC",
++ "dependencies": {
++ "wrappy": "1"
++ }
++ },
++ "node_modules/openvibe-contracts": {
++ "version": "0.5.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
++ "integrity": "sha512-D9+WCb3A2dja7/dP9kZEZvCRe2ckwq8pEeegevEjVekOZG9vsbehjdfCK/Mcggo+ccFECwJcA0j4IrX8owOZSA==",
++ "license": "AGPL-3.0-only",
++ "dependencies": {
++ "ajv": "^8.17.1",
++ "ajv-formats": "^3.0.1"
++ },
++ "bin": {
++ "openvibe-contracts-check": "bin/check-service.js"
++ },
++ "engines": {
++ "node": ">=20"
++ }
++ },
"node_modules/openvibe-shared": {
"resolved": "vendor/openvibe-shared",
"link": true
@@@ -745,6 +1090,33 @@
"integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
"license": "MIT"
},
++ "node_modules/prebuild-install": {
++ "version": "7.1.3",
++ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
++ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
++ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
++ "license": "MIT",
++ "dependencies": {
++ "detect-libc": "^2.0.0",
++ "expand-template": "^2.0.3",
++ "github-from-package": "0.0.0",
++ "minimist": "^1.2.3",
++ "mkdirp-classic": "^0.5.3",
++ "napi-build-utils": "^2.0.0",
++ "node-abi": "^3.3.0",
++ "pump": "^3.0.0",
++ "rc": "^1.2.7",
++ "simple-get": "^4.0.0",
++ "tar-fs": "^2.0.0",
++ "tunnel-agent": "^0.6.0"
++ },
++ "bin": {
++ "prebuild-install": "bin.js"
++ },
++ "engines": {
++ "node": ">=10"
++ }
++ },
"node_modules/proxy-addr": {
"version": "2.0.8",
"resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz",
@@@ -762,6 +1134,16 @@
"url": "https://opencollective.com/express"
}
},
++ "node_modules/pump": {
++ "version": "3.0.4",
++ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
++ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
++ "license": "MIT",
++ "dependencies": {
++ "end-of-stream": "^1.1.0",
++ "once": "^1.3.1"
++ }
++ },
"node_modules/qs": {
"version": "6.16.0",
"resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz",
@@@ -802,6 +1184,44 @@
"node": ">= 0.8"
}
},
++ "node_modules/rc": {
++ "version": "1.2.8",
++ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
++ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
++ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
++ "dependencies": {
++ "deep-extend": "^0.6.0",
++ "ini": "~1.3.0",
++ "minimist": "^1.2.0",
++ "strip-json-comments": "~2.0.1"
++ },
++ "bin": {
++ "rc": "cli.js"
++ }
++ },
++ "node_modules/readable-stream": {
++ "version": "3.6.2",
++ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
++ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
++ "license": "MIT",
++ "dependencies": {
++ "inherits": "^2.0.3",
++ "string_decoder": "^1.1.1",
++ "util-deprecate": "^1.0.1"
++ },
++ "engines": {
++ "node": ">= 6"
++ }
++ },
++ "node_modules/require-from-string": {
++ "version": "2.0.2",
++ "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
++ "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
@@@ -963,6 +1383,51 @@
"url": "https://github.com/sponsors/ljharb"
}
},
++ "node_modules/simple-concat": {
++ "version": "1.0.1",
++ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
++ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT"
++ },
++ "node_modules/simple-get": {
++ "version": "4.0.1",
++ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
++ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
++ "funding": [
++ {
++ "type": "github",
++ "url": "https://github.com/sponsors/feross"
++ },
++ {
++ "type": "patreon",
++ "url": "https://www.patreon.com/feross"
++ },
++ {
++ "type": "consulting",
++ "url": "https://feross.org/support"
++ }
++ ],
++ "license": "MIT",
++ "dependencies": {
++ "decompress-response": "^6.0.0",
++ "once": "^1.3.1",
++ "simple-concat": "^1.0.0"
++ }
++ },
"node_modules/statuses": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
@@@ -972,6 +1437,60 @@
"node": ">= 0.8"
}
},
++ "node_modules/streamsearch": {
++ "version": "1.1.0",
++ "resolved": "https://registry.npmjs.org/streamsearch/-/streamsearch-1.1.0.tgz",
++ "integrity": "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg==",
++ "engines": {
++ "node": ">=10.0.0"
++ }
++ },
++ "node_modules/string_decoder": {
++ "version": "1.3.0",
++ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
++ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
++ "license": "MIT",
++ "dependencies": {
++ "safe-buffer": "~5.2.0"
++ }
++ },
++ "node_modules/strip-json-comments": {
++ "version": "2.0.1",
++ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
++ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
++ "license": "MIT",
++ "engines": {
++ "node": ">=0.10.0"
++ }
++ },
++ "node_modules/tar-fs": {
++ "version": "2.1.5",
++ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
++ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
++ "license": "MIT",
++ "dependencies": {
++ "chownr": "^1.1.1",
++ "mkdirp-classic": "^0.5.2",
++ "pump": "^3.0.0",
++ "tar-stream": "^2.1.4"
++ }
++ },
++ "node_modules/tar-stream": {
++ "version": "2.2.0",
++ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
++ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
++ "license": "MIT",
++ "dependencies": {
++ "bl": "^4.0.3",
++ "end-of-stream": "^1.4.1",
++ "fs-constants": "^1.0.0",
++ "inherits": "^2.0.3",
++ "readable-stream": "^3.1.1"
++ },
++ "engines": {
++ "node": ">=6"
++ }
++ },
"node_modules/toidentifier": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
@@@ -981,6 +1500,18 @@
"node": ">=0.6"
}
},
++ "node_modules/tunnel-agent": {
++ "version": "0.6.0",
++ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
++ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
++ "license": "Apache-2.0",
++ "dependencies": {
++ "safe-buffer": "^5.0.1"
++ },
++ "engines": {
++ "node": "*"
++ }
++ },
"node_modules/type-is": {
"version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@@ -1003,6 +1534,12 @@
"node": ">= 0.8"
}
},
++ "node_modules/util-deprecate": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
++ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
++ "license": "MIT"
++ },
"node_modules/utils-merge": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@@ -1021,6 +1558,12 @@
"node": ">= 0.8"
}
},
++ "node_modules/wrappy": {
++ "version": "1.0.2",
++ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
++ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
++ "license": "ISC"
++ },
"vendor/openvibe-shared": {
"version": "1.0.0",
"license": "MIT",
ddiff --git a/package.json b/package.json
iindex 01350c4..76ac70a 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-community",
"version": "1.0.0",
"private": true,
-- "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (pastes stored by OpenVibe.Media via OpenVibe.Live, identity by OpenVibe.Network).",
++ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes; forums and submissions to follow. Part of the OpenVibe network (identity by OpenVibe.Network; pastes via OpenVibe.Live until PASTES_AUTHORITY=community makes this site their authority).",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -13,15 +13,18 @@
"author": "OpenVibe Community",
"license": "MIT",
"dependencies": {
++ "better-sqlite3": "^11.10.0",
"cookie-parser": "^1.4.7",
"dotenv": "^16.4.7",
"express": "^4.21.2",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"highlight.js": "^11.11.1",
++ "multer": "^2.4.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.5.0",
"openvibe-shared": "file:./vendor/openvibe-shared"
},
"engines": {
-- "node": ">=18.0.0"
++ "node": ">=20.0.0"
}
}
ddiff --git a/scripts/import-pastes.js b/scripts/import-pastes.js
nnew file mode 100644
iindex 0000000..e94b718
---- /dev/null
++++ b/scripts/import-pastes.js
@@@ -0,0 +1,70 @@
++#!/usr/bin/env node
++'use strict';
++
++/**
++ * Import OpenVibe.Media's paste export into Community's database.
++ *
++ * node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff 2026-08-20T03:00:26Z]
++ *
++ * Reads the same environment as the server (.env / /etc/openvibe/community.env):
++ * COMMUNITY_DB_PATH, OV_NETWORK_INTERNAL_URL, OV_OAUTH_CLIENT_ID, OV_OAUTH_CLIENT_SECRET (the
++ * client needs identity.subject.resolve). Safe to re-run: see server/pastes/importer.js.
++ *
++ * Exit codes: 0 imported (or dry run finished), 2 bundle refused, 1 anything else.
++ */
++const fs = require('fs');
++const path = require('path');
++
++function usage(msg) {
++ if (msg) console.error(msg);
++ console.error('usage: node scripts/import-pastes.js <bundle.json> [--dry-run] [--id-fix-cutoff <ISO time>]');
++ process.exit(msg ? 1 : 0);
++}
++
++function parseArgs(argv) {
++ const out = { file: null, dryRun: false, cutoff: null };
++ for (let i = 0; i < argv.length; i++) {
++ const a = argv[i];
++ if (a === '--dry-run') out.dryRun = true;
++ else if (a === '--id-fix-cutoff') { out.cutoff = argv[++i]; if (!out.cutoff) usage('--id-fix-cutoff needs a value'); }
++ else if (a.startsWith('--id-fix-cutoff=')) out.cutoff = a.slice('--id-fix-cutoff='.length);
++ else if (a === '-h' || a === '--help') usage();
++ else if (a.startsWith('-')) usage(`unknown option ${a}`);
++ else if (!out.file) out.file = a;
++ else usage(`unexpected argument ${a}`);
++ }
++ if (!out.file) usage('missing <bundle.json>');
++ return out;
++}
++
++async function main() {
++ const args = parseArgs(process.argv.slice(2));
++ const config = require('../server/config');
++ const { openDb } = require('../server/db');
++ const { createNetworkIdentity } = require('../server/identity/network');
++ const { importBundle, formatReport, ImportError, DEFAULT_CUTOFF } = require('../server/pastes/importer');
++
++ let bundle;
++ try { bundle = JSON.parse(fs.readFileSync(args.file, 'utf8')); }
++ catch (err) { console.error(`Cannot read ${args.file}: ${err.message}`); process.exit(2); }
++
++ const db = openDb(config.dbPath);
++ const network = createNetworkIdentity({ config, db });
++ try {
++ const report = await importBundle(db, bundle, {
++ // A dry run writes nothing at all, not even the display cache.
++ resolveLegacy: (system, list) => network.resolveLegacy(system, list, { cache: !args.dryRun }),
++ cutoff: args.cutoff || DEFAULT_CUTOFF,
++ dryRun: args.dryRun,
++ source: `${bundle.app || 'media'}:${path.basename(args.file)}`,
++ });
++ console.log(formatReport(report));
++ } catch (err) {
++ if (err instanceof ImportError) { console.error(`Refused: ${err.message}`); process.exit(2); }
++ throw err;
++ } finally {
++ db.close();
++ }
++}
++
++main().catch((err) => { console.error(err && err.stack ? err.stack : err); process.exit(1); });
ddiff --git a/server/app.js b/server/app.js
iindex 01c2954..15e38b4 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -6,14 +6,20 @@
* Express app factory (server/index.js listens; tests build their own instance).
*
* Pages (server-rendered) API / machine
-- * GET / home ALL /api/pastes/* → OpenVibe.Live proxy
++ * GET / home ALL /api/pastes/* → see PASTES_AUTHORITY below
* GET /pastes browse GET /api/health, /api/ready
* GET /p/:slug paste GET /robots.txt, /sitemap.xml, /feed.xml
-- * GET /p/:slug/raw → Media /auth/login|callback|logout|me|refresh
-- * GET /p/:slug/screenshot → Media
++ * GET /p/:slug/raw raw text /auth/login|callback|logout|me|refresh
++ * GET /p/:slug/screenshot → image
* GET /p/:slug/download
* GET|POST /new create
* GET /my signed-in user's pastes
++ *
++ * PASTES_AUTHORITY (config.pastesAuthority):
++ * 'live' (default) /api/pastes/* is a transparent proxy to OpenVibe.Live, pages read through
++ * Live's API, raw text and screenshots bounce to OpenVibe.Media.
++ * 'community' this site's own database is the authority: the native API
++ * (pastes/api.js), pages, raw text and screenshots all come from the store.
*/
const path = require('path');
const express = require('express');
@@@ -24,6 +30,7 @@ const rateLimit = require('express-rate-limit');
const config = require('./config');
const live = require('./live-client');
const catalog = require('./pastes/catalog');
++const source = require('./pastes/source');
const seo = require('./seo');
const pages = require('./render/pages');
const { assetVersion } = require('./render/layout');
@@@ -74,9 +81,41 @@ function createApp(opts = {}) {
app.use('/auth', createAuthRoutes(config, auth));
{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'community', service: 'community', host: 'openvibe.community', name: 'OpenVibe.Community', profile: 'ugc' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
-- // ── /api/pastes → OpenVibe.Live (before any body parser: bodies stream through) ──
++ // ── Paste authority ──────────────────────────────────────
++ const community = config.pastesAuthority === 'community';
++ let viewers = null;
++ if (community) {
++ const { openDb, getDb } = require('./db');
++ const { createNetworkIdentity } = require('./identity/network');
++ const { createViewerResolver } = require('./identity/viewer');
++ const { createMediaFiles } = require('./media/files');
++ const { createPasteService } = require('./pastes/service');
++ const db = opts.db || (opts.dbPath ? openDb(opts.dbPath) : getDb());
++ const network = opts.network || createNetworkIdentity({ config, db });
++ const media = opts.media || createMediaFiles({ config });
++ const service = createPasteService({ db, network, media, config, limits: opts.pasteLimits });
++ viewers = createViewerResolver({ auth, config, network });
++ source.use(service);
++ app.locals.db = db;
++ app.locals.pastes = service;
++ } else {
++ source.use(null);
++ }
++ // One anonymous-write budget per address, shared by the API and the no-JS form (20 / 10 min).
++ const anonWriteLimiter = rateLimit({
++ windowMs: 10 * 60 * 1000, max: 20, standardHeaders: true, legacyHeaders: false,
++ skip: (req) => !!(req.viewer ? req.viewer.kind !== 'anonymous' : req.user),
++ handler: (req, res) => {
++ const error = 'Too many anonymous posts — sign in or try again later';
++ if (req.originalUrl.startsWith('/api/')) return res.status(429).json({ error });
++ res.status(429).type('html').set('Cache-Control', 'no-cache').send(pages.newPage({ user: req.user, error }));
++ },
++ });
++
++ // ── /api/pastes (before any body parser: in 'live' mode bodies stream through to Live) ──
app.use('/api/', rateLimit({ windowMs: 60_000, max: 120, standardHeaders: true, legacyHeaders: false }));
-- app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
++ if (community) app.use('/api/pastes', require('./pastes/api').createPastesApi({ service: app.locals.pastes, viewers, anonWriteLimiter }));
++ else app.use('/api/pastes', createPastesProxy({ liveUrl: opts.liveUrl }));
app.get('/api/health', (_req, res) => res.json({ status: 'ok', service: 'openvibe-community', version: VERSION }));
app.get('/api/ready', (_req, res) => res.json({ ready: true }));
@@@ -99,8 +138,9 @@ function createApp(opts = {}) {
app.get('/feed.xml', wrap(seo.feedHandler));
// ── Pages ────────────────────────────────────────────────
-- const withUser = optionalAuth(auth);
-- const ctxOf = (req) => ({ token: req.token, ip: req.ip });
++ // Pages are for browsers: in community mode the viewer (subject, staff) is resolved too.
++ const withUser = community ? viewers.middleware({ services: false }) : optionalAuth(auth);
++ const ctxOf = (req) => ({ token: req.token, ip: req.ip, userAgent: req.get('user-agent') || '', viewer: req.viewer });
const html = (res, body, status = 200) => res.status(status).type('html').set('Cache-Control', 'no-cache').send(body);
app.get('/', withUser, wrap(async (req, res) => {
@@@ -121,7 +161,7 @@ function createApp(opts = {}) {
if (!SLUG_RE.test(slug)) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'That link does not point at a paste.' }), 404);
let paste;
try {
-- paste = await live.getPaste(slug, ctxOf(req));
++ paste = await source.getPaste(slug, ctxOf(req));
} catch (err) {
if (err.status === 404) return html(res, pages.errorPage({ status: 404, title: 'Paste not found', message: 'It may have been deleted, burned after reading, or never existed.' }), 404);
if (err.status === 410) return html(res, pages.errorPage({ status: 410, title: 'This paste has burned', message: 'It was set to burn after reading, and it has been read.' }), 410);
@@@ -131,17 +171,41 @@ function createApp(opts = {}) {
html(res, pages.pastePage({ paste, related, user: req.user }));
}));
-- // Raw text and screenshots are public on OpenVibe.Media — bounce there.
-- app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
-- app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
++ if (community) {
++ // The store is the authority: raw text is served here, the screenshot link is the stored
++ // Media URL. (Never bounce to Media's /p/… — after cutover it redirects back here.)
++ const notFound = (res) => res.status(404).type('text/plain').set('X-Content-Type-Options', 'nosniff').send('Not found');
++ app.get('/p/:slug/raw', withUser, (req, res) => {
++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
++ try {
++ const out = app.locals.pastes.raw(req.viewer, req.params.slug, ctxOf(req));
++ if (out.redirect) return res.redirect(302, out.redirect);
++ res.set('X-Content-Type-Options', 'nosniff').set('Cache-Control', 'private, no-store');
++ res.type('text/plain; charset=utf-8').send(out.content);
++ } 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;
++ }
++ });
++ app.get('/p/:slug/screenshot', withUser, (req, res) => {
++ if (!SLUG_RE.test(req.params.slug)) return notFound(res);
++ try { res.redirect(302, app.locals.pastes.screenshotUrl(req.viewer, req.params.slug)); }
++ catch (err) { if (err.status === 404) return notFound(res); throw err; }
++ });
++ } else {
++ // Raw text and screenshots are public on OpenVibe.Media — bounce there.
++ app.get('/p/:slug/raw', (req, res) => res.redirect(302, live.rawUrl(req.params.slug)));
++ app.get('/p/:slug/screenshot', (req, res) => res.redirect(302, live.screenshotUrl(req.params.slug)));
++ }
app.get('/p/:slug/download', withUser, wrap(async (req, res) => {
const { slug } = req.params;
if (!SLUG_RE.test(slug)) return res.status(404).type('text/plain').send('Not found');
let paste;
-- try { paste = await live.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; }
-- if (paste.type === 'screenshot') return res.redirect(302, live.screenshotUrl(slug));
++ if (paste.type === 'screenshot') return res.redirect(302, community ? (paste.screenshot_url || `/p/${encodeURIComponent(slug)}/screenshot`) : live.screenshotUrl(slug));
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', 'private, no-cache');
res.type('text/plain; charset=utf-8').send(String(paste.content || ''));
@@@ -150,13 +214,15 @@ function createApp(opts = {}) {
app.get('/new', withUser, wrap(async (req, res) => {
let fork = null;
if (req.query.fork && SLUG_RE.test(String(req.query.fork))) {
-- try { const p = await live.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }
++ try { const p = await source.getPaste(String(req.query.fork), { ...ctxOf(req), noView: true }); if (p && p.type === 'paste') fork = p; } catch { /* no fork, plain form */ }
}
html(res, pages.newPage({ user: req.user, fork }));
}));
// No-JS fallback: a plain form post becomes the same JSON create the API path uses.
-- app.post('/new', withUser, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
++ // In community mode the anonymous-write budget applies here too (in 'live' mode Live enforces it).
++ const formLimiter = community ? anonWriteLimiter : (_req, _res, next) => next();
++ app.post('/new', withUser, formLimiter, express.urlencoded({ extended: false, limit: '1mb' }), wrap(async (req, res) => {
const b = req.body || {};
const values = {
title: String(b.title || '').slice(0, 200),
@@@ -169,7 +235,7 @@ function createApp(opts = {}) {
if (values.visibility === 'private' && !req.user) values.visibility = 'unlisted';
if (!values.content.trim()) return html(res, pages.newPage({ user: req.user, values, error: 'Paste something first — the content is empty.' }), 400);
try {
-- const out = await live.createPaste({
++ const out = await source.createPaste({
title: values.title, content: values.content, language: values.language, visibility: values.visibility,
burn_after_read: values.burn_after_read, is_nsfw: values.is_nsfw,
}, ctxOf(req));
@@@ -187,7 +253,7 @@ function createApp(opts = {}) {
if (!req.user) return res.redirect(`/auth/login?next=${encodeURIComponent('/my')}`);
let pastes = [], total = 0, error = null;
try {
-- const out = await live.listByUser(req.user.username, { limit: 100 }, ctxOf(req));
++ const out = await source.listByUser(req.user.username, { limit: 100 }, ctxOf(req));
pastes = (out && out.pastes) || [];
total = Number(out && out.total) || pastes.length;
} catch (err) {
ddiff --git a/server/config.js b/server/config.js
iindex 22c9863..bf8ab33 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -44,4 +44,13 @@ module.exports = {
liveUrl: (process.env.OV_LIVE_URL || 'https://openvibe.live').replace(/\/$/, ''),
// OpenVibe.Media — public host that serves raw paste text and screenshots.
mediaUrl: (process.env.OV_MEDIA_URL || 'https://openvibe.media').replace(/\/$/, ''),
++ // Media's internal address: new screenshot uploads go to its file store (community app).
++ mediaInternalUrl: (process.env.OV_MEDIA_INTERNAL_URL || 'http://127.0.0.1:4100').replace(/\/$/, ''),
++
++ // Who answers /api/pastes/* and the paste pages:
++ // 'live' — proxy to OpenVibe.Live (which stores in Media). The default until cutover.
++ // 'community' — this site's own database is the authority (server/pastes/api.js).
++ pastesAuthority: process.env.PASTES_AUTHORITY === 'community' ? 'community' : 'live',
++ // SQLite file for Community's own data (pastes once authority=community).
++ dbPath: process.env.COMMUNITY_DB_PATH || './data/community.db',
};
ddiff --git a/server/db.js b/server/db.js
nnew file mode 100644
iindex 0000000..a08edcc
---- /dev/null
++++ b/server/db.js
@@@ -0,0 +1,153 @@
++'use strict';
++
++/**
++ * Community's own database (SQLite, better-sqlite3).
++ *
++ * Community is the authority for pastes once PASTES_AUTHORITY=community: pastes, their edit
++ * history, likes and comments live here. People are referenced by Network subject ids
++ * (usr_… / gst_…), never by a service-local integer; subject_projection is only a display cache
++ * of what the Network says about them.
++ *
++ * Schema creation is idempotent (CREATE … IF NOT EXISTS) and runs on open.
++ */
++const fs = require('fs');
++const path = require('path');
++const Database = require('better-sqlite3');
++
++const SCHEMA = `
++CREATE TABLE IF NOT EXISTS pastes (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ slug TEXT UNIQUE NOT NULL,
++ owner_subject TEXT,
++ origin TEXT NOT NULL DEFAULT 'user' CHECK(origin IN ('user', 'ai', 'imported')),
++ type TEXT NOT NULL DEFAULT 'paste' CHECK(type IN ('paste', 'screenshot')),
++ title TEXT NOT NULL DEFAULT 'Untitled',
++ content TEXT,
++ language TEXT DEFAULT 'text',
++ visibility TEXT NOT NULL DEFAULT 'public' CHECK(visibility IN ('public', 'unlisted', 'private')),
++ screenshot_url TEXT,
++ media_ref TEXT,
++ stream_ref TEXT, -- JSON EntityRef, e.g. {"service":"live","type":"stream","id":"123"}
++ metadata TEXT, -- JSON
++ burn_after_read INTEGER NOT NULL DEFAULT 0,
++ forked_from INTEGER REFERENCES pastes(id) ON DELETE SET NULL,
++ pinned INTEGER NOT NULL DEFAULT 0,
++ views INTEGER NOT NULL DEFAULT 0,
++ unique_views INTEGER NOT NULL DEFAULT 0,
++ copies INTEGER NOT NULL DEFAULT 0,
++ likes INTEGER NOT NULL DEFAULT 0,
++ is_nsfw INTEGER NOT NULL DEFAULT 0,
++ ai_summary TEXT,
++ ai_tags TEXT, -- JSON array
++ ai_analyzed_at DATETIME,
++ revision INTEGER NOT NULL DEFAULT 1,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ deleted_at DATETIME,
++ legacy_media_id INTEGER UNIQUE,
++ legacy_user_id INTEGER
++);
++CREATE INDEX IF NOT EXISTS idx_pastes_listing ON pastes(visibility, created_at DESC) WHERE deleted_at IS NULL;
++CREATE INDEX IF NOT EXISTS idx_pastes_owner ON pastes(owner_subject, created_at DESC);
++CREATE INDEX IF NOT EXISTS idx_pastes_forked ON pastes(forked_from);
++
++CREATE TABLE IF NOT EXISTS paste_versions (
++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
++ revision INTEGER NOT NULL,
++ title TEXT,
++ content TEXT,
++ language TEXT,
++ edited_by TEXT,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (paste_id, revision)
++);
++
++CREATE TABLE IF NOT EXISTS paste_likes (
++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
++ subject_id TEXT NOT NULL,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (paste_id, subject_id)
++);
++
++CREATE TABLE IF NOT EXISTS paste_comments (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
++ author_subject TEXT,
++ anon_name TEXT,
++ parent_id INTEGER REFERENCES paste_comments(id) ON DELETE CASCADE,
++ message TEXT NOT NULL,
++ is_deleted INTEGER NOT NULL DEFAULT 0,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ legacy_media_id INTEGER UNIQUE
++);
++CREATE INDEX IF NOT EXISTS idx_paste_comments_paste ON paste_comments(paste_id, parent_id);
++
++-- Per-visitor view bookkeeping (cooldown + unique counts). Visitors are 'u:<subject>' or an
++-- HMAC of the address ('ip:<hash>'); no raw addresses are stored.
++CREATE TABLE IF NOT EXISTS paste_visits (
++ paste_id INTEGER NOT NULL REFERENCES pastes(id) ON DELETE CASCADE,
++ visitor TEXT NOT NULL,
++ first_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ last_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ visits INTEGER NOT NULL DEFAULT 1,
++ PRIMARY KEY (paste_id, visitor)
++);
++CREATE INDEX IF NOT EXISTS idx_paste_visits_last ON paste_visits(last_at);
++
++CREATE TABLE IF NOT EXISTS subject_projection (
++ subject_id TEXT PRIMARY KEY,
++ username TEXT,
++ display_name TEXT,
++ avatar_url TEXT,
++ profile_color TEXT,
++ refreshed_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
++);
++CREATE INDEX IF NOT EXISTS idx_subject_projection_username ON subject_projection(username COLLATE NOCASE);
++
++CREATE TABLE IF NOT EXISTS migration_runs (
++ id INTEGER PRIMARY KEY AUTOINCREMENT,
++ source TEXT NOT NULL,
++ started_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ finished_at DATETIME,
++ report TEXT
++);
++
++CREATE TABLE IF NOT EXISTS legacy_id_map (
++ source_system TEXT NOT NULL,
++ source_type TEXT NOT NULL,
++ source_id TEXT NOT NULL,
++ target_type TEXT NOT NULL,
++ target_id TEXT NOT NULL,
++ PRIMARY KEY (source_system, source_type, source_id)
++);
++
++CREATE TABLE IF NOT EXISTS import_hold (
++ source_type TEXT NOT NULL,
++ source_id TEXT NOT NULL,
++ reason TEXT NOT NULL,
++ detail TEXT,
++ created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
++ PRIMARY KEY (source_type, source_id, reason)
++);
++`;
++
++/** Open (creating the directory and schema if needed). ':memory:' works for tests. */
++function openDb(file) {
++ if (file !== ':memory:') fs.mkdirSync(path.dirname(path.resolve(file)), { recursive: true });
++ const db = new Database(file);
++ db.pragma('journal_mode = WAL');
++ db.pragma('foreign_keys = ON');
++ db.pragma('busy_timeout = 5000');
++ db.exec(SCHEMA);
++ return db;
++}
++
++let _shared = null;
++/** The process-wide database at COMMUNITY_DB_PATH (opened on first use). */
++function getDb() {
++ if (!_shared) _shared = openDb(require('./config').dbPath);
++ return _shared;
++}
++
++module.exports = { openDb, getDb, SCHEMA };
ddiff --git a/server/identity/network.js b/server/identity/network.js
nnew file mode 100644
iindex 0000000..6c8fb9d
---- /dev/null
++++ b/server/identity/network.js
@@@ -0,0 +1,155 @@
++'use strict';
++
++/**
++ * OpenVibe.Network identity client — who a subject is, for display.
++ *
++ * Community stores people as Network subject ids (usr_… / gst_…). Names and pictures come from
++ * the Network's resolve-batch endpoint (capability identity.subject.resolve, called with a
++ * client-credentials service token) and are cached in subject_projection. The cache is never
++ * authority: an entry older than PROJECTION_TTL_MS is refreshed in the background, a missing one
++ * is fetched before the response (bounded by RESOLVE_WAIT_MS so a slow Network only costs names).
++ *
++ * POST /internal/identity/resolve-batch
++ * { subject_ids: [...] } or { system, type: 'user', ids: [...] } (≤ 500 per call)
++ * → { results: { <key>: { subject: {type,id}, network_user_id, username, display_name, avatar_url, banned } | null } }
++ */
++const { serviceAuth } = require('openvibe-contracts');
++const store = require('../pastes/store');
++
++const BATCH = 500;
++const PROJECTION_TTL_MS = 6 * 60 * 60 * 1000;
++const RESOLVE_WAIT_MS = 2500;
++
++function createNetworkIdentity({ config, db, fetchImpl = globalThis.fetch } = {}) {
++ const base = config.networkInternalUrl;
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${base}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve',
++ fetchImpl,
++ });
++
++ /** Relative avatar paths the Network hands out are relative to the Network. */
++ const absAvatar = (u) => (!u ? null : /^https?:\/\//i.test(u) ? u : `${config.networkUrl}${u.startsWith('/') ? '' : '/'}${u}`);
++
++ async function post(body, retried = false) {
++ const res = await fetchImpl(`${base}/internal/identity/resolve-batch`, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(8000),
++ });
++ // A token the Network no longer accepts (rotated key, revoked client): fetch a new one once.
++ if (res.status === 401 && !retried) { tokens.invalidate(); return post(body, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || typeof data.results !== 'object') {
++ const err = new Error(`resolve-batch ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return data.results;
++ }
++
++ /** Resolve many keys in ≤500 chunks. `shape(chunk)` builds the request body. → Map key → projection|null */
++ async function resolveChunks(keys, shape) {
++ const out = new Map();
++ const list = [...new Set(keys.map(String))];
++ for (let i = 0; i < list.length; i += BATCH) {
++ const chunk = list.slice(i, i + BATCH);
++ const results = await post(shape(chunk));
++ for (const k of chunk) out.set(k, results[k] || null);
++ }
++ return out;
++ }
++
++ /** Store a Network projection in the cache. */
++ function remember(p) {
++ if (!p || !p.subject || !p.subject.id) return;
++ store.upsertProjection(db, {
++ subject_id: p.subject.id, username: p.username || null, display_name: p.display_name || p.username || null,
++ avatar_url: absAvatar(p.avatar_url), profile_color: p.profile_color || null,
++ });
++ }
++
++ /** Subject ids → projections (and cache them). */
++ async function resolveSubjects(subjectIds) {
++ const map = await resolveChunks(subjectIds, (chunk) => ({ subject_ids: chunk }));
++ for (const p of map.values()) remember(p);
++ return map;
++ }
++
++ /** Legacy ids of one system ('live' | 'network') → projections. Used by the importer. */
++ async function resolveLegacy(system, ids, { cache = true } = {}) {
++ const map = await resolveChunks(ids, (chunk) => ({ system, type: 'user', ids: chunk }));
++ if (cache) for (const p of map.values()) remember(p);
++ return map;
++ }
++
++ /**
++ * The subject of a Network account id, for user JWTs issued before tokens carried
++ * `subject_id`. Cached durably in legacy_id_map (network/user/<id> → subject).
++ */
++ async function subjectForNetworkUser(networkUserId) {
++ if (networkUserId == null || networkUserId === '') return null;
++ const hit = store.mapGet(db, 'network', 'user', networkUserId);
++ if (hit && hit.target_type === 'subject') return hit.target_id;
++ const map = await resolveLegacy('network', [networkUserId]);
++ const p = map.get(String(networkUserId));
++ if (!p || !p.subject || p.subject.type !== 'user') return null;
++ store.mapSet(db, 'network', 'user', networkUserId, 'subject', p.subject.id);
++ return p.subject.id;
++ }
++
++ /** What a verified user JWT says about its holder is a fresh projection too. */
++ function rememberClaims(subjectId, claims) {
++ if (!subjectId || !claims) return;
++ store.upsertProjection(db, {
++ subject_id: subjectId, username: claims.username || null, display_name: claims.display_name || claims.username || null,
++ avatar_url: absAvatar(claims.avatar_url), profile_color: claims.profile_color || null,
++ });
++ }
++
++ let inflight = null;
++ const pending = new Set();
++ /** Background refresh of stale entries, batched and deduplicated. */
++ function refreshLater(ids) {
++ for (const id of ids) pending.add(id);
++ if (inflight) return;
++ inflight = Promise.resolve().then(async () => {
++ while (pending.size) {
++ const batch = [...pending].slice(0, BATCH);
++ batch.forEach((id) => pending.delete(id));
++ try { await resolveSubjects(batch); } catch (err) { console.warn('[Identity] projection refresh failed:', err.message); pending.clear(); }
++ }
++ }).finally(() => { inflight = null; });
++ }
++
++ /**
++ * Projections for display. Missing entries are fetched now (bounded wait); stale ones are
++ * served as they are and refreshed behind the response. → Map subject_id → projection row
++ */
++ async function projections(subjectIds) {
++ const ids = [...new Set((subjectIds || []).filter((s) => typeof s === 'string' && /^(usr|gst)_/.test(s)))];
++ if (!ids.length) return new Map();
++ let map = store.getProjections(db, ids);
++ const missing = ids.filter((id) => !map.has(id));
++ const stale = ids.filter((id) => map.has(id) && Date.now() - Date.parse(String(map.get(id).refreshed_at).replace(' ', 'T') + 'Z') > PROJECTION_TTL_MS);
++ if (stale.length) refreshLater(stale);
++ if (missing.length) {
++ let timer;
++ const wait = new Promise((resolve) => { timer = setTimeout(resolve, RESOLVE_WAIT_MS); });
++ // The lookup keeps running (and fills the cache) if the wait runs out first.
++ const lookup = resolveSubjects(missing).catch((err) => console.warn('[Identity] resolve-batch failed:', err.message));
++ await Promise.race([lookup, wait]);
++ clearTimeout(timer);
++ map = store.getProjections(db, ids);
++ }
++ return map;
++ }
++
++ return { resolveSubjects, resolveLegacy, subjectForNetworkUser, rememberClaims, projections, tokens };
++}
++
++module.exports = { createNetworkIdentity, PROJECTION_TTL_MS };
ddiff --git a/server/identity/viewer.js b/server/identity/viewer.js
nnew file mode 100644
iindex 0000000..a481d86
---- /dev/null
++++ b/server/identity/viewer.js
@@@ -0,0 +1,131 @@
++'use strict';
++
++/**
++ * Who is calling — resolved once per request into `req.viewer`:
++ *
++ * { kind: 'anonymous' }
++ * { kind: 'user', subject: 'usr_…'|null, staff, user: <JWT claims> }
++ * A browser with the Network user JWT (ov_token cookie or Bearer). The subject comes from
++ * the token's subject_id; older tokens without one are resolved through the Network.
++ * role admin/global_mod makes the viewer staff. X-OV-* headers are ignored for browsers.
++ * { kind: 'service', service: 'svc:live', claims, subject: 'usr_…'|'gst_…'|null, origin, sourceRef, staff }
++ * A first-party service holding a Network client-credentials token for audience
++ * openvibe.community. It names the person it acts for in X-OV-Subject; with no subject the
++ * write is anonymous, unless X-OV-Origin: ai says it is AI output (never attributed to a
++ * person: subject stays null). X-OV-Source-Ref may carry a JSON EntityRef (e.g. the stream).
++ * X-OV-Staff: 1 vouches that the acting person is staff; it needs community.paste.moderate.
++ *
++ * Identity never comes from a request body or query. A request that presents a service token is
++ * judged on that token alone: a bad one is refused, never downgraded to anonymous.
++ */
++const contracts = require('openvibe-contracts');
++const { extractToken, claimsToUser, decodeJwtPayload } = require('../auth/routes');
++
++const { ids, capabilities, serviceAuth, http } = contracts;
++const STAFF_ROLES = new Set(['admin', 'global_mod']);
++const PRINCIPAL_SUB = /^(svc|app|mod):/;
++const AUDIENCE = 'openvibe.community';
++
++class ViewerError extends Error {
++ constructor(status, code, detail) { super(detail); this.status = status; this.code = code; }
++}
++
++const ANONYMOUS = Object.freeze({ kind: 'anonymous', subject: null, staff: false, origin: 'user' });
++
++function isActingSubject(v) {
++ return typeof v === 'string' && (ids.isSubjectId('user', v) || ids.isSubjectId('guest', v));
++}
++
++/** Does this viewer's service token grant the capability? (Browsers and anonymous: never.) */
++function hasCap(viewer, capabilityId) {
++ return !!(viewer && viewer.kind === 'service' && capabilities.check(viewer.claims, capabilityId).allowed);
++}
++
++function createViewerResolver({ auth, config, network }) {
++ async function fromServiceToken(req, token) {
++ const publicKey = await auth.ensureKey();
++ if (!publicKey) throw new ViewerError(503, 'identity.unavailable', 'the Network signing key is not loaded yet');
++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkUrl, audience: AUDIENCE });
++ if (!r.ok) throw new ViewerError(401, r.code, r.reason);
++ const claims = r.claims;
++
++ const originHeader = req.get('x-ov-origin');
++ if (originHeader && originHeader !== 'ai' && originHeader !== 'user') throw new ViewerError(400, 'request.invalid_origin', 'X-OV-Origin must be "ai" or "user"');
++ const origin = originHeader === 'ai' ? 'ai' : 'user';
++
++ const subjectHeader = req.get('x-ov-subject');
++ let subject = null;
++ if (subjectHeader) {
++ if (!isActingSubject(subjectHeader)) throw new ViewerError(400, 'subject.invalid', 'X-OV-Subject must be a usr_… or gst_… subject id');
++ // AI output is never attributed to a person, even if the caller also names one.
++ subject = origin === 'ai' ? null : subjectHeader;
++ }
++
++ let sourceRef = null;
++ const refHeader = req.get('x-ov-source-ref');
++ if (refHeader) {
++ try { sourceRef = JSON.parse(refHeader); } catch { sourceRef = undefined; }
++ if (!sourceRef || !contracts.validate('common.entity-ref@1', sourceRef).valid) {
++ throw new ViewerError(400, 'request.invalid_source_ref', 'X-OV-Source-Ref must be a JSON EntityRef {service, type, id}');
++ }
++ }
++
++ let staff = false;
++ if (req.get('x-ov-staff') === '1') {
++ const c = capabilities.check(claims, 'community.paste.moderate');
++ if (!c.allowed) throw new ViewerError(403, c.code, 'X-OV-Staff needs community.paste.moderate');
++ staff = true;
++ }
++ return { kind: 'service', service: claims.sub, claims, subject, origin, sourceRef, staff };
++ }
++
++ async function fromUserToken(token) {
++ const claims = await auth.verify(token);
++ if (!claims || (typeof claims.sub === 'string' && PRINCIPAL_SUB.test(claims.sub))) return null;
++ let subject = ids.isSubjectId('user', claims.subject_id) ? claims.subject_id : null;
++ if (!subject && network && claims.sub != null) {
++ try { subject = await network.subjectForNetworkUser(claims.sub); } catch (err) { console.warn('[Identity] subject lookup failed:', err.message); }
++ }
++ if (subject && network) { try { network.rememberClaims(subject, claims); } catch { /* display cache only */ } }
++ const user = claimsToUser(claims);
++ if (subject && !user.subject_id) user.subject_id = subject;
++ return { kind: 'user', subject, staff: STAFF_ROLES.has(claims.role), origin: 'user', user, token };
++ }
++
++ /**
++ * Resolve the caller. opts.services=false (server-rendered pages) treats a service token as
++ * no identity at all: pages are for browsers.
++ */
++ async function resolve(req, opts = {}) {
++ const header = String(req.headers.authorization || '');
++ if (header.startsWith('Bearer ')) {
++ const token = header.slice(7).trim();
++ const payload = decodeJwtPayload(token);
++ if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
++ if (opts.services === false) return ANONYMOUS;
++ return fromServiceToken(req, token);
++ }
++ }
++ const token = extractToken(req);
++ if (!token) return ANONYMOUS;
++ return (await fromUserToken(token)) || ANONYMOUS;
++ }
++
++ /** Express middleware: sets req.viewer (and req.user/req.token for the page renderers). */
++ function middleware(opts = {}) {
++ return async (req, res, next) => {
++ try {
++ req.viewer = await resolve(req, opts);
++ if (req.viewer.kind === 'user') { req.user = req.viewer.user; req.token = req.viewer.token; }
++ next();
++ } catch (err) {
++ if (!(err instanceof ViewerError)) return next(err);
++ http.sendProblem(res, err.status, err.code, { detail: err.message, ctx: req.ov });
++ }
++ };
++ }
++
++ return { resolve, middleware };
++}
++
++module.exports = { createViewerResolver, hasCap, isActingSubject, ANONYMOUS, STAFF_ROLES, ViewerError };
ddiff --git a/server/index.js b/server/index.js
iindex e856634..0f56ad2 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -10,7 +10,9 @@ const { createApp } = require('./app');
const app = createApp();
const server = app.listen(config.port, config.host, () => {
console.log(`[Community] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl}`);
-- console.log(`[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
++ console.log(config.pastesAuthority === 'community'
++ ? `[Community] pastes: this site is the authority (${config.dbPath}), identity via ${config.networkUrl}`
++ : `[Community] pastes via ${config.liveInternalUrl}/api/pastes, identity via ${config.networkUrl}`);
});
server.keepAliveTimeout = 65_000;
ddiff --git a/server/media/files.js b/server/media/files.js
nnew file mode 100644
iindex 0000000..fd499a1
---- /dev/null
++++ b/server/media/files.js
@@@ -0,0 +1,48 @@
++'use strict';
++
++/**
++ * OpenVibe.Media file store client — where new screenshot bytes go.
++ *
++ * Community keeps only the link: an upload is POSTed as multipart (field `file`) to Media's
++ * community file store with a service token (capability media.object.upload), and Media answers
++ * { key, url, size, mime }. The paste stores the absolute public URL and a transitional media
++ * reference `legacy:community:file:<key>`. Imported pastes keep their existing Media screenshot
++ * URLs; no bytes are ever copied here.
++ */
++const { serviceAuth, ids } = require('openvibe-contracts');
++
++function createMediaFiles({ config, fetchImpl = globalThis.fetch } = {}) {
++ const tokens = serviceAuth.createTokenClient({
++ tokenUrl: `${config.networkInternalUrl}/oauth/token`,
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.media',
++ scope: 'media.object.upload',
++ fetchImpl,
++ });
++ const absolute = (u) => (/^https?:\/\//i.test(u) ? u : `${config.mediaUrl}${u.startsWith('/') ? '' : '/'}${u}`);
++
++ /** Upload one file. → { key, url (absolute), size, mime, media_ref } */
++ async function upload({ buffer, filename, mime }, retried = false) {
++ const fd = new FormData();
++ fd.append('file', new Blob([buffer], { type: mime || 'application/octet-stream' }), filename || 'upload.bin');
++ const res = await fetchImpl(`${config.mediaInternalUrl}/api/v1/community/files`, {
++ method: 'POST',
++ headers: { Accept: 'application/json', ...(await tokens.authHeaders()) },
++ body: fd,
++ signal: AbortSignal.timeout(60_000),
++ });
++ if (res.status === 401 && !retried) { tokens.invalidate(); return upload({ buffer, filename, mime }, true); }
++ const data = await res.json().catch(() => null);
++ if (!res.ok || !data || !data.key || !data.url) {
++ const err = new Error(`Media upload ${res.status}: ${(data && (data.detail || data.error)) || 'bad response'}`);
++ err.status = res.status;
++ throw err;
++ }
++ return { key: data.key, url: absolute(data.url), size: data.size, mime: data.mime, media_ref: ids.legacyMediaId('community', 'file', data.key) };
++ }
++
++ return { upload, tokens };
++}
++
++module.exports = { createMediaFiles };
ddiff --git a/server/media/strip-metadata.js b/server/media/strip-metadata.js
nnew file mode 100644
iindex 0000000..577fe97
---- /dev/null
++++ b/server/media/strip-metadata.js
@@@ -0,0 +1,85 @@
++'use strict';
++
++/**
++ * Drop embedded metadata (EXIF incl. GPS, XMP, text chunks) from an uploaded image without
++ * re-encoding it. Media used to do this by re-encoding with sharp; Community has no image
++ * library, and the pixels don't need touching — only the side-channel segments do.
++ *
++ * JPEG — APP1 (EXIF/XMP) and APP13 (IPTC/Photoshop) segments and COM comments are removed;
++ * APP0 (JFIF), APP2 (ICC colour profile) and APP14 (Adobe) stay.
++ * PNG — eXIf, tEXt, zTXt, iTXt and tIME chunks are removed.
++ * WebP — EXIF and XMP chunks are removed and the VP8X flags updated.
++ * GIF — left alone (animations survive; GIF carries no EXIF).
++ * Anything that doesn't parse cleanly is returned unchanged.
++ */
++
++function stripJpeg(buf) {
++ if (buf.length < 4 || buf[0] !== 0xff || buf[1] !== 0xd8) return buf;
++ const parts = [buf.subarray(0, 2)];
++ let i = 2;
++ while (i + 4 <= buf.length) {
++ if (buf[i] !== 0xff) return buf;
++ const marker = buf[i + 1];
++ if (marker === 0xda) { parts.push(buf.subarray(i)); return Buffer.concat(parts); } // start of scan: rest is image data
++ if (marker === 0xd8 || (marker >= 0xd0 && marker <= 0xd7) || marker === 0x01) { parts.push(buf.subarray(i, i + 2)); i += 2; continue; }
++ const len = buf.readUInt16BE(i + 2);
++ if (len < 2 || i + 2 + len > buf.length) return buf;
++ const drop = marker === 0xe1 || marker === 0xed || marker === 0xfe;
++ if (!drop) parts.push(buf.subarray(i, i + 2 + len));
++ i += 2 + len;
++ }
++ return buf;
++}
++
++const PNG_SIG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
++const PNG_DROP = new Set(['eXIf', 'tEXt', 'zTXt', 'iTXt', 'tIME']);
++function stripPng(buf) {
++ if (buf.length < 8 || !buf.subarray(0, 8).equals(PNG_SIG)) return buf;
++ const parts = [PNG_SIG];
++ let i = 8;
++ while (i + 12 <= buf.length) {
++ const len = buf.readUInt32BE(i);
++ const type = buf.toString('latin1', i + 4, i + 8);
++ const end = i + 12 + len;
++ if (end > buf.length) return buf;
++ if (!PNG_DROP.has(type)) parts.push(buf.subarray(i, end));
++ i = end;
++ if (type === 'IEND') return Buffer.concat(parts);
++ }
++ return buf;
++}
++
++function stripWebp(buf) {
++ if (buf.length < 12 || buf.toString('latin1', 0, 4) !== 'RIFF' || buf.toString('latin1', 8, 12) !== 'WEBP') return buf;
++ const chunks = [];
++ let i = 12;
++ while (i + 8 <= buf.length) {
++ const type = buf.toString('latin1', i, i + 4);
++ const len = buf.readUInt32LE(i + 4);
++ const end = i + 8 + len + (len & 1);
++ if (i + 8 + len > buf.length) return buf;
++ if (type !== 'EXIF' && type !== 'XMP ') chunks.push(Buffer.from(buf.subarray(i, Math.min(end, buf.length))));
++ i = end;
++ }
++ for (const c of chunks) {
++ // VP8X flags byte: bit 3 = EXIF present, bit 2 = XMP present.
++ if (c.toString('latin1', 0, 4) === 'VP8X' && c.length > 8) c[8] &= ~0x0c;
++ }
++ const body = Buffer.concat(chunks);
++ const head = Buffer.alloc(12);
++ head.write('RIFF', 0, 'latin1');
++ head.writeUInt32LE(body.length + 4, 4);
++ head.write('WEBP', 8, 'latin1');
++ return Buffer.concat([head, body]);
++}
++
++function stripImageMetadata(buf, mime) {
++ try {
++ if (mime === 'image/jpeg') return stripJpeg(buf);
++ if (mime === 'image/png') return stripPng(buf);
++ if (mime === 'image/webp') return stripWebp(buf);
++ } catch { /* fall through: unchanged */ }
++ return buf;
++}
++
++module.exports = { stripImageMetadata, stripJpeg, stripPng, stripWebp };
ddiff --git a/server/pastes/api.js b/server/pastes/api.js
nnew file mode 100644
iindex 0000000..ab072a4
---- /dev/null
++++ b/server/pastes/api.js
@@@ -0,0 +1,149 @@
++'use strict';
++
++/**
++ * /api/pastes/* — Community's native paste API (PASTES_AUTHORITY=community).
++ *
++ * Same paths, bodies, status codes and response shapes the browser clients already use against
++ * OpenVibe.Live's proxy (which fronted OpenVibe.Media); the rules live in service.js.
++ *
++ * GET / list (?limit&offset&type&search&sort&origin&username&include_unlisted)
++ * ?needs_ai=1 — AI work queue, community.paste.moderate only
++ * POST / create (JSON text paste, or multipart with a `screenshot` file)
++ * GET /config limits
++ * POST /screenshot create an image paste (multipart `screenshot`)
++ * GET /by-user/:username someone's pastes (their own hidden ones for them)
++ * GET /:slug one paste (counts a view unless ?no_view=1)
++ * PUT /:slug DELETE /:slug owner or staff
++ * GET /:slug/raw → /p/:slug/raw
++ * GET /:slug/versions edit history (owner or staff)
++ * POST /:slug/fork|like|copy
++ * GET /:slug/comments POST /:slug/comments DELETE /:slug/comments/:id
++ * Staff: GET /admin/stats, GET|DELETE /admin/forks, POST /bulk, POST /:slug/censor, POST /:slug/ai
++ *
++ * Callers (identity/viewer.js): browsers with the Network user JWT, anonymous browsers, and
++ * first-party services with a Network service token. A service must hold the capability of the
++ * route it calls: community.paste.create (create), community.paste.write (everything done as the
++ * acting subject), community.paste.moderate (staff routes, needs_ai, X-OV-Staff). Anonymous writes
++ * are limited to 20 per 10 minutes per address.
++ */
++const express = require('express');
++const multer = require('multer');
++const rateLimit = require('express-rate-limit');
++const contracts = require('openvibe-contracts');
++const { PasteError } = require('./service');
++const { hasCap } = require('../identity/viewer');
++
++const PASTE_CAPS = ['community.paste.create', 'community.paste.write', 'community.paste.moderate'];
++
++function createPastesApi({ service, viewers, anonWriteLimiter: sharedLimiter = null }) {
++ const router = express.Router();
++ router.use(contracts.http.middleware());
++ router.use(viewers.middleware());
++
++ const json = express.json({ limit: '1mb' });
++ const jsonErrors = (err, _req, res, next) => (err ? res.status(400).json({ error: 'Malformed JSON body' }) : next());
++ const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 16 * 1024 * 1024, files: 1, fields: 30 } }).single('screenshot');
++ const withFile = (req, res, next) => upload(req, res, (err) => (err ? res.status(400).json({ error: err.code === 'LIMIT_FILE_SIZE' ? 'File too large' : (err.message || 'Upload failed') }) : next()));
++ const body = (req, res, next) => (req.is('multipart/form-data') ? withFile(req, res, next) : json(req, res, (err) => jsonErrors(err, req, res, next)));
++
++ // Anonymous posts are allowed; people acting as themselves and services have their own limits.
++ const anonWriteLimiter = sharedLimiter || rateLimit({
++ windowMs: 10 * 60 * 1000,
++ max: 20,
++ standardHeaders: true,
++ legacyHeaders: false,
++ skip: (req) => !req.viewer || req.viewer.kind !== 'anonymous',
++ message: { error: 'Too many anonymous posts — sign in or try again later' },
++ });
++
++ /** A service must hold the route's capability; browsers are judged by identity instead. */
++ const guard = (cap) => (req, res, next) => {
++ const v = req.viewer;
++ if (v.kind !== 'service') return next();
++ const c = contracts.capabilities.check(v.claims, cap);
++ if (c.allowed) return next();
++ return contracts.http.sendProblem(res, 403, c.code, { detail: c.reason, ctx: req.ov });
++ };
++ /** Reads: any community.paste.* grant (a service token with none of them isn't meant for us). */
++ const anyCap = (req, res, next) => {
++ const v = req.viewer;
++ if (v.kind !== 'service' || PASTE_CAPS.some((c) => hasCap(v, c))) return next();
++ return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'no community.paste capability granted', ctx: req.ov });
++ };
++ /** Staff-only routes: a service with community.paste.moderate, or an admin/global_mod browser. */
++ const staffOnly = (req, res, next) => {
++ const v = req.viewer;
++ if (v.kind === 'service') return guard('community.paste.moderate')(req, res, next);
++ if (v.kind === 'user' && v.staff) return next();
++ if (v.kind === 'anonymous') return res.status(401).json({ error: 'Authentication required' });
++ return res.status(403).json({ error: 'Admin access required' });
++ };
++ /** The AI pass (needs_ai queue, /:slug/ai) is for services only, never a browser — not even staff. */
++ const serviceModerator = (req, res, next) => {
++ if (req.viewer.kind !== 'service') return contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'service token with community.paste.moderate required', ctx: req.ov });
++ return guard('community.paste.moderate')(req, res, next);
++ };
++
++ const ctx = (req) => ({ ip: req.ip, userAgent: req.get('user-agent') || '' });
++ const run = (fn, status = 200) => async (req, res) => {
++ try {
++ const out = await fn(req, res);
++ if (out === undefined || res.headersSent) return;
++ res.status(status).json(out);
++ } catch (err) {
++ if (err instanceof PasteError) return res.status(err.status).json(err.body);
++ console.error('[Pastes]', err && err.stack ? err.stack : err);
++ if (!res.headersSent) res.status(500).json({ error: 'Internal error' });
++ }
++ };
++ const slug = (req) => req.params.slug;
++
++ // ── collection + literal paths (before /:slug) ───────────
++ router.get('/', anyCap, (req, res, next) => (req.query.needs_ai && req.viewer.kind !== 'service'
++ ? contracts.http.sendProblem(res, 403, 'capability.denied', { detail: 'needs_ai is for services holding community.paste.moderate', ctx: req.ov })
++ : next()), run((req) => service.list(req.viewer, req.query)));
++
++ router.post('/', guard('community.paste.create'), anonWriteLimiter, body,
++ run((req) => (req.file ? service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)) : service.createText(req.viewer, req.body || {})), 201));
++
++ router.get('/config', anyCap, run((req) => service.config(req.viewer)));
++
++ router.get('/admin/stats', staffOnly, run(() => service.stats()));
++ router.get('/admin/forks', staffOnly, run((req) => service.forks(req.query)));
++ router.delete('/admin/forks', staffOnly, run(() => service.deleteForks()));
++ router.post('/bulk', staffOnly, json, jsonErrors, run((req) => service.bulk(req.body || {})));
++
++ router.post('/screenshot', guard('community.paste.create'), anonWriteLimiter, withFile,
++ run((req) => service.createScreenshot(req.viewer, req.body || {}, req.file, ctx(req)), 201));
++
++ router.get('/by-user/:username', anyCap, run((req) => service.byUser(req.viewer, req.params.username, req.query)));
++
++ // ── one paste ────────────────────────────────────────────
++ router.get('/:slug', anyCap, run((req) => service.get(req.viewer, slug(req), { ...ctx(req), noView: String(req.query.no_view || '') === '1' })));
++ router.put('/:slug', guard('community.paste.write'), json, jsonErrors, run((req) => service.update(req.viewer, slug(req), req.body || {})));
++ router.delete('/:slug', guard('community.paste.write'), run((req) => service.remove(req.viewer, slug(req))));
++
++ router.post('/:slug/censor', staffOnly, withFile, run((req) => service.censor(req.viewer, slug(req), req.file)));
++ router.post('/:slug/ai', serviceModerator, json, jsonErrors, run((req) => service.setAi(req.viewer, slug(req), req.body || {})));
++
++ router.get('/:slug/raw', (req, res) => res.redirect(302, `/p/${encodeURIComponent(slug(req))}/raw`));
++ router.get('/:slug/versions', anyCap, run((req) => service.versions(req.viewer, slug(req))));
++
++ router.post('/:slug/fork', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors, run((req) => service.fork(req.viewer, slug(req)), 201));
++ router.post('/:slug/like', guard('community.paste.write'), run((req) => service.like(req.viewer, slug(req))));
++ router.post('/:slug/copy', guard('community.paste.write'), run((req) => service.copy(req.viewer, slug(req))));
++
++ router.get('/:slug/comments', anyCap, run((req) => service.comments(req.viewer, slug(req), req.query)));
++ router.post('/:slug/comments', guard('community.paste.write'), anonWriteLimiter, json, jsonErrors,
++ run((req) => service.addComment(req.viewer, slug(req), req.body || {}, ctx(req)), 201));
++ // Deleting someone else's comment is moderation: X-OV-Staff (with community.paste.moderate) for services.
++ router.delete('/:slug/comments/:commentId', guard('community.paste.write'), run((req) => service.deleteComment(req.viewer, slug(req), req.params.commentId)));
++
++ // Avatars belong to the Network account; Live's proxy set a Live-local avatar here.
++ router.post('/:slug/set-avatar', (_req, res) => res.status(501).json({ error: 'Set your picture on openvibe.network' }));
++
++ router.use((req, res) => res.status(404).json({ error: 'Not found' }));
++ return router;
++}
++
++module.exports = { createPastesApi };
ddiff --git a/server/pastes/catalog.js b/server/pastes/catalog.js
iindex 533d831..f8547f8 100644
---- a/server/pastes/catalog.js
++++ b/server/pastes/catalog.js
@@@ -8,11 +8,15 @@
* public pastes (Media's page cap) in memory for a short while and answers trending,
* related, language counts and the views/language browse modes from it. Everything here is
* fetched anonymously, so nothing unlisted or private can ever appear on a shared page.
++ *
++ * With the community authority (source.local) the store answers and the window is kept only
++ * LOCAL_TTL_MS — long enough that one page's latest/trending/languages share a single query.
*/
--const live = require('../live-client');
++const source = require('./source');
const WINDOW = 200; // Media's maximum page size
const TTL_MS = 45 * 1000; // fresh enough for a community feed, cheap enough for Live
++const LOCAL_TTL_MS = 2000;
const PER_PAGE = 24;
let _recent = { at: 0, promise: null, pastes: [] };
@@@ -25,8 +29,8 @@ function _textOnlyPublic(rows) {
async function recent() {
const now = Date.now();
if (_recent.promise) return _recent.promise;
-- if (now - _recent.at < TTL_MS) return _recent.pastes;
-- _recent.promise = live.listPastes({ limit: WINDOW, offset: 0 })
++ if (now - _recent.at < (source.local ? LOCAL_TTL_MS : TTL_MS)) return _recent.pastes;
++ _recent.promise = source.listPastes({ limit: WINDOW, offset: 0 })
.then((out) => { _recent = { at: Date.now(), promise: null, pastes: _textOnlyPublic(out && out.pastes) }; return _recent.pastes; })
.catch((err) => { console.warn('[Catalog] recent pastes unavailable:', err.message); _recent.promise = null; _recent.at = Date.now() - TTL_MS + 5000; return _recent.pastes; });
return _recent.promise;
@@@ -76,13 +80,13 @@ async function browse(query = {}, ctx = {}) {
let pastes, total, windowed = false;
if (sort === 'new' && !lang) {
-- const out = await live.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
++ const out = await source.listPastes({ limit: PER_PAGE, offset, search: q || undefined, type: 'paste' }, ctx);
pastes = _textOnlyPublic(out && out.pastes);
total = Number(out && out.total) || pastes.length;
} else {
let rows;
if (q) {
-- const out = await live.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
++ const out = await source.listPastes({ limit: WINDOW, offset: 0, search: q, type: 'paste' }, ctx);
rows = _textOnlyPublic(out && out.pastes);
} else {
rows = await recent();
ddiff --git a/server/pastes/importer.js b/server/pastes/importer.js
nnew file mode 100644
iindex 0000000..8620192
---- /dev/null
++++ b/server/pastes/importer.js
@@@ -0,0 +1,319 @@
++'use strict';
++
++/**
++ * Paste importer — OpenVibe.Media's paste export bundle → Community's store.
++ *
++ * Bundle (produced by Media, format openvibe.media.pastes-export v1):
++ * { format, version, app, generated_at, since_id, max_id, sha256, counts: {pastes, comments, likes},
++ * pastes: [...], comments: [...], likes: [...] }
++ * sha256 is over JSON.stringify({ pastes, comments, likes }).
++ *
++ * Rules:
++ * - The bundle is verified (format, version, counts, sha256) before anything is touched.
++ * - Idempotent: rows are upserted by legacy_media_id, so a re-run changes nothing and a newer
++ * bundle adds and updates. A paste deleted in Community stays deleted.
++ * - Slugs, timestamps, counters, visibility, NSFW and AI fields are kept. Counters never go
++ * down (Community may have counted views since the last run).
++ * - Owners: bundle user_ids are Live user ids, except rows created before the id-space fix
++ * (cutoff) which may hold a Network user id instead. Every id is resolved through the Network
++ * (system=live, and system=network for pre-cutoff rows). Pre-cutoff with two different
++ * answers → imported ownerless + hold 'ambiguous_owner'; no answer → ownerless + hold
++ * 'owner_unmapped'. A like that can't be mapped is held and not imported.
++ * - AI moment pastes (Live's AI jobs) get origin 'ai' and no owner: AI output is never
++ * attributed to a person. Their stream goes into stream_ref.
++ * - Every mapping lands in legacy_id_map; every run in migration_runs with a reconciliation report.
++ * - dryRun does all of it inside a transaction and rolls back.
++ */
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const store = require('./store');
++
++const FORMAT = 'openvibe.media.pastes-export';
++const DEFAULT_CUTOFF = '2026-08-20T03:00:26Z';
++// Media rebuilt screenshot metadata itself, so the ai_moment flag Live sent was dropped for image
++// pastes; the frame file names Live's AI jobs upload are the remaining marker.
++const AI_FRAME_NAME = /^(ai-moment-vod\d+-\d+|live-\d+-\d+)\.jpe?g$/i;
++
++class ImportError extends Error {}
++class Rollback extends Error {}
++
++function toMs(ts) {
++ if (!ts) return NaN;
++ const s = String(ts);
++ return Date.parse(/[zZ]|[+-]\d\d:?\d\d$/.test(s) ? s.replace(' ', 'T') : `${s.replace(' ', 'T')}Z`);
++}
++
++function objectOf(v) {
++ if (v && typeof v === 'object' && !Array.isArray(v)) return v;
++ if (typeof v === 'string') { try { const o = JSON.parse(v); return o && typeof o === 'object' && !Array.isArray(o) ? o : null; } catch { return null; } }
++ return null;
++}
++
++function isAiPaste(p) {
++ const meta = objectOf(p.metadata);
++ if (meta && meta.ai_moment === true) return true;
++ return p.type === 'screenshot' && !!meta && typeof meta.original_name === 'string' && AI_FRAME_NAME.test(meta.original_name);
++}
++
++/** Refuse a bundle that isn't exactly what it says it is. */
++function verifyBundle(bundle) {
++ if (!bundle || typeof bundle !== 'object') throw new ImportError('bundle is not a JSON object');
++ if (bundle.format !== FORMAT) throw new ImportError(`unexpected format ${JSON.stringify(bundle.format)} (want ${FORMAT})`);
++ if (bundle.version !== 1) throw new ImportError(`unsupported version ${JSON.stringify(bundle.version)} (want 1)`);
++ for (const k of ['pastes', 'comments', 'likes']) {
++ if (!Array.isArray(bundle[k])) throw new ImportError(`bundle.${k} is not an array`);
++ const want = bundle.counts && bundle.counts[k];
++ if (want !== bundle[k].length) throw new ImportError(`count mismatch for ${k}: counts says ${want}, bundle has ${bundle[k].length}`);
++ }
++ const sha = crypto.createHash('sha256').update(JSON.stringify({ pastes: bundle.pastes, comments: bundle.comments, likes: bundle.likes })).digest('hex');
++ if (sha !== String(bundle.sha256 || '').toLowerCase()) throw new ImportError(`sha256 mismatch: bundle says ${bundle.sha256}, content is ${sha}`);
++}
++
++/**
++ * Import a verified bundle.
++ * opts.resolveLegacy(system, ids) → Map<String(id), projection|null> (Network resolve-batch)
++ * opts.cutoff ISO time of the id-space fix
++ * opts.dryRun roll everything back at the end
++ * opts.source label for migration_runs
++ * → report
++ */
++async function importBundle(db, bundle, { resolveLegacy, cutoff = DEFAULT_CUTOFF, dryRun = false, source = 'media' } = {}) {
++ verifyBundle(bundle);
++ const cutoffMs = toMs(cutoff);
++ if (!Number.isFinite(cutoffMs)) throw new ImportError(`bad cutoff ${cutoff}`);
++ const startedAt = new Date().toISOString();
++
++ // ── 1. Resolve every legacy user id before the transaction (network calls) ──
++ const liveIds = new Set();
++ const preCutoffIds = new Set();
++ const note = (userId, createdAt) => {
++ if (userId == null) return;
++ liveIds.add(String(userId));
++ if (!(toMs(createdAt) >= cutoffMs)) preCutoffIds.add(String(userId));
++ };
++ for (const p of bundle.pastes) if (!isAiPaste(p)) note(p.user_id, p.created_at);
++ for (const c of bundle.comments) note(c.user_id, c.created_at);
++ for (const l of bundle.likes) note(l.user_id, l.created_at);
++ const subjectOf = (proj) => (proj && proj.subject && proj.subject.type === 'user' ? proj.subject.id : null);
++ const liveMap = liveIds.size ? await resolveLegacy('live', [...liveIds]) : new Map();
++ const netMap = preCutoffIds.size ? await resolveLegacy('network', [...preCutoffIds]) : new Map();
++
++ function owner(userId, createdAt) {
++ if (userId == null) return { subject: null, hold: null };
++ const key = String(userId);
++ const live = subjectOf(liveMap.get(key));
++ const pre = !(toMs(createdAt) >= cutoffMs);
++ if (!pre) return live ? { subject: live, hold: null } : { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'post-cutoff' } };
++ const net = subjectOf(netMap.get(key));
++ if (live && net && live !== net) return { subject: null, hold: 'ambiguous_owner', detail: { user_id: userId, created_at: createdAt, live_subject: live, network_subject: net } };
++ if (live || net) return { subject: live || net, hold: null };
++ return { subject: null, hold: 'owner_unmapped', detail: { user_id: userId, created_at: createdAt, era: 'pre-cutoff' } };
++ }
++
++ const blank = () => ({ bundle: 0, inserted: 0, updated: 0, unchanged: 0, held: 0, skipped: 0 });
++ const report = {
++ source, app: bundle.app || null, generated_at: bundle.generated_at || null, since_id: bundle.since_id ?? null, max_id: bundle.max_id ?? null,
++ cutoff, dry_run: !!dryRun,
++ pastes: { ...blank(), bundle: bundle.pastes.length, ai: 0, forks_remapped: 0, forks_unresolved: 0 },
++ comments: { ...blank(), bundle: bundle.comments.length },
++ likes: { ...blank(), bundle: bundle.likes.length },
++ holds: {},
++ users: { live_ids: liveIds.size, live_mapped: [...liveMap.values()].filter(subjectOf).length, network_checked: preCutoffIds.size, network_mapped: [...netMap.values()].filter(subjectOf).length },
++ };
++ const countHold = (reason) => { report.holds[reason] = (report.holds[reason] || 0) + 1; };
++
++ const hold = db.prepare('INSERT OR IGNORE INTO import_hold (source_type, source_id, reason, detail) VALUES (?, ?, ?, ?)');
++ const clearOwnerHolds = db.prepare("DELETE FROM import_hold WHERE source_type = ? AND source_id = ? AND reason IN ('ambiguous_owner', 'owner_unmapped')");
++ const byLegacy = db.prepare('SELECT * FROM pastes WHERE legacy_media_id = ?');
++ const slugOwner = db.prepare('SELECT id, legacy_media_id FROM pastes WHERE slug = ?');
++ const commentByLegacy = db.prepare('SELECT * FROM paste_comments WHERE legacy_media_id = ?');
++
++ const run = db.transaction(() => {
++ // Users seen: record the mappings the Network gave us.
++ for (const [id, proj] of liveMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'live', 'user', id, 'subject', s); }
++ for (const [id, proj] of netMap) { const s = subjectOf(proj); if (s) store.mapSet(db, 'network', 'user', id, 'subject', s); }
++
++ // ── 2. Pastes ──
++ const PASTE_TEXT_FIELDS = ['type', 'title', 'content', 'language', 'visibility', 'screenshot_url', 'media_ref', 'stream_ref', 'metadata',
++ 'burn_after_read', 'pinned', 'is_nsfw', 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'origin', 'updated_at'];
++ const COUNTERS = ['views', 'unique_views', 'copies', 'likes'];
++ for (const p of bundle.pastes) {
++ const ai = isAiPaste(p);
++ if (ai) report.pastes.ai++;
++ const who = ai ? { subject: null, hold: null } : owner(p.user_id, p.created_at);
++ const meta = objectOf(p.metadata);
++ const streamId = p.stream_id != null ? p.stream_id : (meta && meta.stream_id != null ? meta.stream_id : null);
++ const row = {
++ slug: String(p.slug),
++ origin: ai ? 'ai' : 'user',
++ type: p.type === 'screenshot' ? 'screenshot' : 'paste',
++ title: store.sanitizeTitle(p.title),
++ content: p.content == null ? null : String(p.content),
++ language: p.language || 'text',
++ visibility: ['public', 'unlisted', 'private'].includes(p.visibility) ? p.visibility : 'public',
++ screenshot_url: p.screenshot_url || null,
++ media_ref: p.type === 'screenshot' ? ids.legacyMediaId(bundle.app || 'live', 'paste', p.id) : null,
++ stream_ref: streamId != null && streamId !== '' ? JSON.stringify({ service: 'live', type: 'stream', id: String(streamId) }) : null,
++ metadata: meta ? JSON.stringify(meta) : null,
++ burn_after_read: p.burn_after_read ? 1 : 0,
++ pinned: p.pinned ? 1 : 0,
++ is_nsfw: p.is_nsfw ? 1 : 0,
++ ai_summary: p.ai_summary || null,
++ ai_tags: p.ai_tags == null ? null : (typeof p.ai_tags === 'string' ? p.ai_tags : JSON.stringify(p.ai_tags)),
++ ai_analyzed_at: p.ai_analyzed_at || null,
++ views: Number(p.views) || 0, unique_views: Number(p.unique_views) || 0, copies: Number(p.copies) || 0, likes: Number(p.likes) || 0,
++ created_at: p.created_at, updated_at: p.updated_at || p.created_at,
++ };
++ const existing = byLegacy.get(p.id);
++ let communityId;
++ if (!existing) {
++ const clash = slugOwner.get(row.slug);
++ if (clash) {
++ hold.run('paste', String(p.id), 'slug_conflict', JSON.stringify({ slug: row.slug, community_id: clash.id }));
++ countHold('slug_conflict'); report.pastes.skipped++;
++ continue;
++ }
++ const cols = Object.keys(row).concat(['owner_subject', 'legacy_media_id', 'legacy_user_id']);
++ const vals = Object.values(row).concat([who.subject, p.id, p.user_id == null ? null : Number(p.user_id)]);
++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`).run(...vals);
++ communityId = info.lastInsertRowid;
++ report.pastes.inserted++;
++ } else {
++ communityId = existing.id;
++ if (existing.deleted_at) { report.pastes.unchanged++; store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId); continue; }
++ const patch = {};
++ // Edited here since the last import? Then Community's text wins over the bundle's.
++ const localNewer = existing.revision > 1 || toMs(existing.updated_at) > toMs(row.updated_at);
++ if (!localNewer) for (const k of PASTE_TEXT_FIELDS) if (row[k] !== existing[k]) patch[k] = row[k];
++ for (const k of COUNTERS) if (row[k] > existing[k]) patch[k] = row[k];
++ if (who.subject && !existing.owner_subject) patch.owner_subject = who.subject;
++ if (row.origin === 'ai' && existing.owner_subject && existing.origin !== 'ai') patch.owner_subject = null;
++ const legacyUser = p.user_id == null ? null : Number(p.user_id);
++ if (legacyUser !== existing.legacy_user_id) patch.legacy_user_id = legacyUser;
++ const keys = Object.keys(patch);
++ if (keys.length) {
++ db.prepare(`UPDATE pastes SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), communityId);
++ report.pastes.updated++;
++ } else {
++ report.pastes.unchanged++;
++ }
++ }
++ store.mapSet(db, 'media', 'paste', p.id, 'paste', communityId);
++ if (who.hold) {
++ hold.run('paste', String(p.id), who.hold, JSON.stringify(who.detail));
++ countHold(who.hold); report.pastes.held++;
++ } else {
++ clearOwnerHolds.run('paste', String(p.id));
++ }
++ }
++ // Forks point at Community ids (second pass: the original may come later in the bundle).
++ for (const p of bundle.pastes) {
++ if (p.forked_from == null) continue;
++ const target = store.mapGet(db, 'media', 'paste', p.forked_from);
++ const self = store.mapGet(db, 'media', 'paste', p.id);
++ if (!self) continue;
++ if (!target) { report.pastes.forks_unresolved++; continue; }
++ const changed = db.prepare('UPDATE pastes SET forked_from = ? WHERE id = ? AND forked_from IS NOT ?').run(Number(target.target_id), Number(self.target_id), Number(target.target_id)).changes;
++ if (changed) report.pastes.forks_remapped++;
++ }
++
++ // ── 3. Comments ──
++ const comments = bundle.comments.slice().sort((a, b) => a.id - b.id);
++ for (const c of comments) {
++ const paste = store.mapGet(db, 'media', 'paste', c.paste_id);
++ if (!paste) {
++ hold.run('comment', String(c.id), 'paste_missing', JSON.stringify({ paste_id: c.paste_id }));
++ countHold('paste_missing'); report.comments.skipped++;
++ continue;
++ }
++ const who = owner(c.user_id, c.created_at);
++ const row = {
++ paste_id: Number(paste.target_id), anon_name: c.anon_name || null, message: String(c.message == null ? '' : c.message),
++ is_deleted: c.is_deleted ? 1 : 0, created_at: c.created_at, updated_at: c.updated_at || c.created_at,
++ };
++ const existing = commentByLegacy.get(c.id);
++ let cid;
++ if (!existing) {
++ const info = db.prepare(`INSERT INTO paste_comments (paste_id, author_subject, anon_name, message, is_deleted, created_at, updated_at, legacy_media_id)
++ VALUES (?, ?, ?, ?, ?, ?, ?, ?)`)
++ .run(row.paste_id, who.subject, row.anon_name, row.message, row.is_deleted, row.created_at, row.updated_at, c.id);
++ cid = info.lastInsertRowid;
++ report.comments.inserted++;
++ } else {
++ cid = existing.id;
++ const patch = {};
++ for (const k of ['message', 'anon_name', 'updated_at']) if (row[k] !== existing[k]) patch[k] = row[k];
++ // Deletion is one-way: a comment removed on either side stays removed.
++ if (row.is_deleted && !existing.is_deleted) patch.is_deleted = 1;
++ if (who.subject && !existing.author_subject) patch.author_subject = who.subject;
++ const keys = Object.keys(patch);
++ if (keys.length) {
++ db.prepare(`UPDATE paste_comments SET ${keys.map((k) => `${k} = ?`).join(', ')} WHERE id = ?`).run(...keys.map((k) => patch[k]), cid);
++ report.comments.updated++;
++ } else {
++ report.comments.unchanged++;
++ }
++ }
++ store.mapSet(db, 'media', 'comment', c.id, 'comment', cid);
++ if (who.hold) {
++ hold.run('comment', String(c.id), who.hold, JSON.stringify(who.detail));
++ countHold(who.hold); report.comments.held++;
++ } else {
++ clearOwnerHolds.run('comment', String(c.id));
++ }
++ }
++ for (const c of comments) {
++ if (c.parent_id == null) continue;
++ const self = store.mapGet(db, 'media', 'comment', c.id);
++ const parent = store.mapGet(db, 'media', 'comment', c.parent_id);
++ if (self && parent) db.prepare('UPDATE paste_comments SET parent_id = ? WHERE id = ? AND parent_id IS NOT ?').run(Number(parent.target_id), Number(self.target_id), Number(parent.target_id));
++ }
++
++ // ── 4. Likes (only ever imported for a mapped person) ──
++ for (const l of bundle.likes) {
++ const key = `${l.paste_id}:${l.user_id}`;
++ const paste = store.mapGet(db, 'media', 'paste', l.paste_id);
++ if (!paste) {
++ hold.run('like', key, 'paste_missing', JSON.stringify({ paste_id: l.paste_id, user_id: l.user_id }));
++ countHold('paste_missing'); report.likes.skipped++;
++ continue;
++ }
++ const who = l.user_id == null ? { subject: null, hold: 'owner_unmapped', detail: { user_id: null } } : owner(l.user_id, l.created_at);
++ if (!who.subject) {
++ hold.run('like', key, who.hold, JSON.stringify(who.detail));
++ countHold(who.hold); report.likes.held++;
++ continue;
++ }
++ clearOwnerHolds.run('like', key);
++ const info = db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id, created_at) VALUES (?, ?, ?)').run(Number(paste.target_id), who.subject, l.created_at || null);
++ if (info.changes) report.likes.inserted++; else report.likes.unchanged++;
++ }
++
++ report.finished_at = new Date().toISOString();
++ db.prepare('INSERT INTO migration_runs (source, started_at, finished_at, report) VALUES (?, ?, ?, ?)')
++ .run(source, startedAt, report.finished_at, JSON.stringify(report));
++ if (dryRun) throw new Rollback();
++ });
++
++ try { run(); } catch (err) { if (!(err instanceof Rollback)) throw err; }
++ return report;
++}
++
++/** Plain-text reconciliation table. */
++function formatReport(r) {
++ const line = (name, t) => ` ${name.padEnd(9)} ${String(t.bundle).padStart(7)} ${String(t.inserted).padStart(9)} ${String(t.updated).padStart(8)} ${String(t.unchanged).padStart(10)} ${String(t.held).padStart(6)} ${String(t.skipped).padStart(8)}`;
++ const out = [
++ `Paste import from ${r.source}${r.dry_run ? ' (DRY RUN — rolled back)' : ''}`,
++ ` bundle app=${r.app} ids ${r.since_id ?? '?'}..${r.max_id ?? '?'} generated ${r.generated_at || '?'}; id-space cutoff ${r.cutoff}`,
++ '',
++ ` ${'table'.padEnd(9)} ${'bundle'.padStart(7)} ${'inserted'.padStart(9)} ${'updated'.padStart(8)} ${'unchanged'.padStart(10)} ${'held'.padStart(6)} ${'skipped'.padStart(8)}`,
++ line('pastes', r.pastes), line('comments', r.comments), line('likes', r.likes),
++ '',
++ ` AI pastes (ownerless): ${r.pastes.ai}; forks remapped: ${r.pastes.forks_remapped}, unresolved: ${r.pastes.forks_unresolved}`,
++ ` users: ${r.users.live_ids} live ids (${r.users.live_mapped} mapped), ${r.users.network_checked} pre-cutoff ids checked as network ids (${r.users.network_mapped} mapped)`,
++ ` holds: ${Object.keys(r.holds).length ? Object.entries(r.holds).map(([k, v]) => `${k}=${v}`).join(', ') : 'none'}`,
++ ];
++ return out.join('\n');
++}
++
++module.exports = { importBundle, verifyBundle, formatReport, isAiPaste, ImportError, FORMAT, DEFAULT_CUTOFF };
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
nnew file mode 100644
iindex 0000000..d299a74
---- /dev/null
++++ b/server/pastes/service.js
@@@ -0,0 +1,611 @@
++'use strict';
++
++/**
++ * Paste service — the rules of the paste system, on top of store.js.
++ *
++ * This reproduces what OpenVibe.Media's paste API did behind OpenVibe.Live's /api/pastes proxy
++ * (status codes, error texts, response shapes, limits), with identity reworked around Network
++ * subjects:
++ * - a "limited" caller is one acting as a person (a signed-in browser, or a service naming
++ * X-OV-Subject): Media's paste cooldown / daily cap and comment limits apply to them, just as
++ * they applied to Media's user callers. Anonymous writes are limited per address in api.js.
++ * - private pastes are visible to their owner and to staff only; unlisted ones by slug or to
++ * their owner. Nothing private is ever listed for anyone else.
++ * - AI output (service + X-OV-Origin: ai) is stored with origin 'ai' and no owner.
++ *
++ * Every method takes the resolved viewer (identity/viewer.js) and returns a plain object for
++ * the JSON response, or throws PasteError(status, body).
++ */
++const crypto = require('crypto');
++const store = require('./store');
++const { stripImageMetadata } = require('../media/strip-metadata');
++const { capabilities } = require('openvibe-contracts');
++
++const DEFAULT_LIMITS = {
++ maxSizeKb: 512,
++ screenshotMaxSizeMb: 8,
++ cooldownSeconds: 30,
++ maxPerUserPerDay: 200,
++ commentCooldownSeconds: 10,
++ commentMaxLength: 2000,
++ commentsPerMinute: 5,
++ commentAnonAllowed: true,
++ viewCooldownSec: 6 * 3600,
++ viewEventsPerMinPerIp: 60,
++};
++const IMAGE_MIME = /^image\/(png|jpeg|webp|gif)$/;
++const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;
++// Literal /api/pastes/<word> routes: a paste with one of these slugs would be unreachable there.
++const RESERVED_SLUGS = new Set(['config', 'screenshot', 'bulk', 'admin', 'by-user']);
++const BOT_UA = /bot|crawl|spider|slurp|facebookexternalhit|preview|discordbot|twitterbot|whatsapp|telegram|curl\/|wget\/|python-requests|go-http-client|headless/i;
++const AI_DISPLAY_NAME = 'OpenVibe AI';
++
++class PasteError extends Error {
++ constructor(status, body) {
++ super(body && body.error);
++ this.name = 'PasteError';
++ this.status = status;
++ this.body = body;
++ }
++}
++const fail = (status, error, extra) => { throw new PasteError(status, { error, ...(extra || {}) }); };
++
++const truthy = (v) => v === true || v === 1 || v === '1' || v === 'true' || v === 'on';
++const intIn = (v, def, min, max) => Math.min(Math.max(parseInt(v, 10) || def, min), max);
++
++function parseJson(text) { try { return text ? JSON.parse(text) : null; } catch { return null; } }
++
++function createPasteService({ db, network = null, media = null, config = {}, limits = {} } = {}) {
++ const L = { ...DEFAULT_LIMITS, ...limits };
++ const visitorSecret = process.env.VIEW_HASH_SECRET
++ || crypto.createHash('sha256').update(`community-views:${(config.oauth && config.oauth.clientSecret) || crypto.randomBytes(16).toString('hex')}`).digest('hex');
++
++ // ── who may do what ──────────────────────────────────────
++ const isOwner = (v, p) => !!(v && v.subject && p.owner_subject && v.subject === p.owner_subject);
++ const isStaff = (v) => !!(v && v.staff);
++ const canSeeHidden = (v, p) => isOwner(v, p) || isStaff(v);
++ // 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);
++ const isService = (v) => !!(v && v.kind === 'service');
++ 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).
++ */
++ function visible(v, slug) {
++ const p = store.getBySlug(db, String(slug));
++ if (!p || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Paste not found');
++ return p;
++ }
++
++ // ── response shapes ──────────────────────────────────────
++ function streamIdOf(ref) {
++ return ref && ref.service === 'live' && ref.type === 'stream' && /^\d+$/.test(String(ref.id)) ? Number(ref.id) : null;
++ }
++
++ function authorFields(subject, origin, projections) {
++ if (origin === 'ai') return { username: null, display_name: AI_DISPLAY_NAME, avatar_url: null, profile_color: null };
++ const p = subject ? projections.get(subject) : null;
++ return {
++ username: p ? p.username : null,
++ display_name: p ? (p.display_name || p.username) : null,
++ avatar_url: p ? p.avatar_url : null,
++ profile_color: p ? p.profile_color : null,
++ };
++ }
++
++ /**
++ * Media's pastePublic shape plus the author projection. `user_id` carries the owner's subject
++ * id — Community has no numeric user ids — so "user_id is falsy ⇒ anonymous" keeps working,
++ * and a comparison against some service's numeric id never matches by accident.
++ */
++ function shape(p, v, projections) {
++ const ref = parseJson(p.stream_ref);
++ return {
++ unique_views: p.unique_views || 0,
++ id: p.id,
++ app_id: 'community',
++ slug: p.slug,
++ user_id: p.owner_subject || null,
++ owner_subject: p.owner_subject || null,
++ origin: p.origin,
++ type: p.type,
++ title: p.title,
++ content: p.content,
++ language: p.language,
++ visibility: p.visibility,
++ stream_id: streamIdOf(ref),
++ stream_ref: ref,
++ screenshot_url: p.screenshot_url || null,
++ metadata: p.metadata || null,
++ burn_after_read: !!p.burn_after_read,
++ forked_from: p.forked_from || null,
++ pinned: !!p.pinned,
++ views: p.views || 0,
++ copies: p.copies || 0,
++ likes: p.likes || 0,
++ is_nsfw: !!p.is_nsfw,
++ ai_summary: p.ai_summary || null,
++ ai_tags: p.ai_tags || null,
++ ai_analyzed_at: p.ai_analyzed_at || null,
++ revision: p.revision,
++ url: `/p/${p.slug}`,
++ raw_url: `/p/${p.slug}/raw`,
++ created_at: p.created_at,
++ updated_at: p.updated_at,
++ is_owner: isOwner(v, p),
++ ...authorFields(p.owner_subject, p.origin, projections),
++ };
++ }
++
++ async function projectionsFor(subjects) {
++ if (!network) return store.getProjections(db, subjects);
++ try { return await network.projections(subjects); } catch { return store.getProjections(db, subjects); }
++ }
++
++ async function shapeMany(rows, v, { preview = false } = {}) {
++ const projections = await projectionsFor(rows.map((r) => r.owner_subject));
++ return rows.map((r) => {
++ const out = shape(r, v, projections);
++ if (preview) out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists
++ return out;
++ });
++ }
++ async function shapeOne(row, v) { return (await shapeMany([row], v))[0]; }
++
++ function shapeComment(c, projections) {
++ const out = {
++ id: c.id, paste_id: c.paste_id, user_id: c.author_subject || null, author_subject: c.author_subject || null,
++ parent_id: c.parent_id || null, anon_name: c.anon_name, message: c.message, is_deleted: c.is_deleted,
++ created_at: c.created_at, updated_at: c.updated_at,
++ ...authorFields(c.author_subject, 'user', projections),
++ };
++ if (c.replies) { out.replies = c.replies.map((r) => shapeComment(r, projections)); out.reply_count = c.reply_count; }
++ return out;
++ }
++
++ // ── rate limits (acting persons) ─────────────────────────
++ function pasteRateCheck(v) {
++ if (!limited(v)) return;
++ if (L.cooldownSeconds > 0) {
++ const elapsed = (Date.now() - store.lastPasteTime(db, v.subject)) / 1000;
++ if (elapsed < L.cooldownSeconds) {
++ const wait = Math.ceil(L.cooldownSeconds - elapsed);
++ fail(429, `Please wait ${wait}s before creating another paste`, { cooldown: wait });
++ }
++ }
++ if (L.maxPerUserPerDay > 0 && store.countOwnerSince(db, v.subject, '-1 day') >= L.maxPerUserPerDay) {
++ fail(429, `Daily paste limit reached (${L.maxPerUserPerDay}/day)`);
++ }
++ }
++
++ // Comment limits, per acting person (Media keyed them by address, which behind a proxy is the proxy).
++ const recentComments = new Map(); // key → [{ at, message }] newest first
++ function commentRateCheck(key, message) {
++ const now = Date.now();
++ const list = (recentComments.get(key) || []).filter((c) => now - c.at < 60_000);
++ recentComments.set(key, list);
++ if (list.length && now - list[0].at < L.commentCooldownSeconds * 1000) {
++ fail(429, `Please wait ${Math.ceil((L.commentCooldownSeconds * 1000 - (now - list[0].at)) / 1000)}s before commenting again`);
++ }
++ if (list.length >= L.commentsPerMinute) fail(429, 'Too many comments. Please slow down.');
++ if (list.length && list[0].message === message) fail(400, 'Duplicate comment');
++ }
++ function commentRecorded(key, message) {
++ const list = recentComments.get(key) || [];
++ list.unshift({ at: Date.now(), message });
++ recentComments.set(key, list.slice(0, 20));
++ }
++
++ // ── view counting (Media's rules: per visitor, cooldown, no owner/bot views) ──
++ const ipBudget = new Map();
++ function overBudget(ip) {
++ const now = Date.now();
++ const b = ipBudget.get(ip);
++ if (!b || now - b.start >= 60_000) { ipBudget.set(ip, { start: now, n: 1 }); return false; }
++ b.n += 1;
++ return b.n > L.viewEventsPerMinPerIp;
++ }
++ const sweep = setInterval(() => {
++ const now = Date.now();
++ for (const [k, b] of ipBudget) if (now - b.start > 120_000) ipBudget.delete(k);
++ for (const [k, l] of recentComments) if (!l.length || now - l[0].at > 120_000) recentComments.delete(k);
++ try { store.pruneVisits(db, 30); } catch { /* db closed in tests */ }
++ }, 10 * 60_000);
++ if (sweep.unref) sweep.unref();
++
++ function countView(v, p, { ip, userAgent } = {}) {
++ if (BOT_UA.test(String(userAgent || ''))) return;
++ if (overBudget(ip || 'unknown')) return;
++ const visitor = v && v.subject ? `u:${v.subject}` : `ip:${crypto.createHmac('sha256', visitorSecret).update(String(ip || 'unknown')).digest('hex').slice(0, 32)}`;
++ const r = store.recordVisit(db, p.id, visitor, L.viewCooldownSec);
++ p.views = r.views; p.unique_views = r.unique_views;
++ }
++
++ /** Burn-after-read: the paste is gone the moment this read makes it spent. */
++ function burn(p) {
++ store.softDelete(db, p.id);
++ fail(410, 'This paste has been burned after reading.');
++ }
++
++ // ── 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;
++ if (sid != null && /^\d{1,18}$/.test(String(sid))) return JSON.stringify({ service: 'live', type: 'stream', id: String(sid) });
++ return null;
++ }
++
++ /** Service callers may carry structured metadata (AI moments); browsers may not. */
++ function serviceMetadata(v, body) {
++ if (!isService(v) || body.metadata == null || body.metadata === '') return null;
++ const obj = typeof body.metadata === 'string' ? parseJson(body.metadata) : body.metadata;
++ if (!obj || typeof obj !== 'object' || Array.isArray(obj)) fail(400, 'metadata must be a JSON object');
++ const text = JSON.stringify(obj);
++ if (text.length > 16 * 1024) fail(400, 'metadata too large (max 16 KB)');
++ return obj;
++ }
++
++ function aiFields(v, body) {
++ if (!isService(v) || body.ai_summary == null) return {};
++ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
++ return { ai_summary: String(body.ai_summary).slice(0, 2000), ai_tags: tags, ai_analyzed_at: new Date().toISOString().replace('T', ' ').slice(0, 19) };
++ }
++
++ function slugFor(v, body) {
++ if (!isService(v) || body.slug == null || body.slug === '') return store.generateSlug(db);
++ const slug = String(body.slug);
++ if (!SERVICE_SLUG_RE.test(slug) || RESERVED_SLUGS.has(slug.toLowerCase())) fail(400, 'Invalid slug');
++ if (db.prepare('SELECT 1 FROM pastes WHERE slug = ?').get(slug)) fail(409, 'Slug already taken');
++ return slug;
++ }
++
++ function created(row, extra = {}) {
++ return { id: row.id, slug: row.slug, url: `/p/${row.slug}`, ...extra };
++ }
++
++ // ═════════════════════════════════════════════════════════
++ return {
++ PasteError,
++ limits: L,
++
++ /** GET /api/pastes */
++ async list(v, q = {}) {
++ const needsAi = truthy(q.needs_ai);
++ if (needsAi && !hasCap(v, 'community.paste.moderate')) fail(403, 'needs_ai requires community.paste.moderate');
++ const limit = intIn(q.limit, 50, 1, 200);
++ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
++ const opts = { limit, offset, type: q.type, sort: q.sort, origin: q.origin, needsAi, search: q.search ? String(q.search).slice(0, 200) : null };
++ // ?username= lists one person's pastes (Live resolved it to its own id; we go through
++ // the projection cache). Their own unlisted/private ones only for them (or staff).
++ if (q.username && q.username !== 'all') {
++ const subject = store.subjectsByUsername(db, q.username)[0];
++ if (!subject) return { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false };
++ opts.ownerSubject = subject;
++ if (truthy(q.include_unlisted) && (v.subject === subject || isStaff(v))) opts.includeHidden = true;
++ }
++ const { rows, total } = store.listPastes(db, opts);
++ return { pastes: await shapeMany(rows, v, { preview: true }), total, limit, offset };
++ },
++
++ /** GET /api/pastes/by-user/:username */
++ async byUser(v, username, q = {}) {
++ const subject = store.subjectsByUsername(db, username)[0];
++ if (!subject) fail(404, 'User not found');
++ const limit = intIn(q.limit, 30, 1, 100);
++ const includeHidden = v.subject === subject;
++ const { rows, total } = store.listPastes(db, { ownerSubject: subject, includeHidden, limit, offset: 0, sort: q.sort === 'oldest' ? 'oldest' : 'newest' });
++ const proj = store.getProjections(db, [subject]).get(subject);
++ return { pastes: await shapeMany(rows, v, { preview: true }), total, username: (proj && proj.username) || String(username) };
++ },
++
++ /** GET /api/pastes/:slug — counts a page view unless noView; burns a spent burn-after-read paste. */
++ async get(v, slug, ctx = {}) {
++ const p = visible(v, slug);
++ if (!ctx.noView && !isOwner(v, p)) {
++ if (p.burn_after_read) {
++ p.views = store.bumpViews(db, p.id);
++ if (p.views > 1) burn(p);
++ } else {
++ countView(v, p, ctx);
++ }
++ }
++ const liked = v.subject ? store.hasLiked(db, p.id, v.subject) : false;
++ return { paste: { ...(await shapeOne(p, v)), liked } };
++ },
++
++ /** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
++ raw(v, slug, ctx = {}) {
++ const p = 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 (!isOwner(v, p)) {
++ if (p.burn_after_read) {
++ if (p.views > 0) burn(p);
++ store.bumpViews(db, p.id);
++ } else {
++ countView(v, p, ctx);
++ }
++ }
++ return { content: String(p.content || '') };
++ },
++
++ /** GET /p/:slug/screenshot target (the stored Media URL). */
++ screenshotUrl(v, slug) {
++ const p = store.getBySlug(db, String(slug));
++ if (!p || !p.screenshot_url || (p.visibility === 'private' && !canSeeHidden(v, p))) fail(404, 'Not found');
++ return p.screenshot_url;
++ },
++
++ /** POST /api/pastes (text) */
++ async createText(v, body = {}) {
++ pasteRateCheck(v);
++ const content = body.content;
++ if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
++ if (content.length > L.maxSizeKb * 1024) fail(400, `Paste too large (max ${L.maxSizeKb} KB)`);
++ const metadata = serviceMetadata(v, body);
++ const row = store.insertPaste(db, {
++ slug: slugFor(v, body),
++ owner_subject: v.subject || null,
++ origin: v.origin === 'ai' ? 'ai' : 'user',
++ type: 'paste',
++ title: store.sanitizeTitle(body.title),
++ content: content.trim(),
++ language: store.detectLanguage(content, body.language),
++ visibility: visibilityOf(body.visibility, !!v.subject),
++ 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),
++ });
++ return created(row, { paste: await shapeOne(row, v) });
++ },
++
++ /** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
++ async createScreenshot(v, body = {}, file, ctx = {}) {
++ pasteRateCheck(v);
++ if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
++ if (!IMAGE_MIME.test(file.mimetype || '')) 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 slug = slugFor(v, body);
++ const extra = serviceMetadata(v, body);
++ const bytes = stripImageMetadata(file.buffer, file.mimetype);
++ let stored;
++ try {
++ stored = await media.upload({ buffer: bytes, filename: file.originalname || 'screenshot.png', mime: file.mimetype });
++ } catch (err) {
++ console.warn('[Pastes] screenshot upload failed:', err.message);
++ fail(502, 'Media service unavailable');
++ }
++ const metadata = {
++ ...(extra || {}),
++ page_url: body.page_url || (extra && extra.page_url) || null,
++ user_agent: body.user_agent || ctx.userAgent || null,
++ original_name: file.originalname || null,
++ size_bytes: bytes.length,
++ mime_type: file.mimetype,
++ };
++ const row = 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: visibilityOf(body.visibility, !!v.subject),
++ 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),
++ });
++ return created(row, { paste: await shapeOne(row, v) });
++ },
++
++ /** PUT /api/pastes/:slug — owner (or staff) only. */
++ async update(v, slug, body = {}) {
++ needIdentity(v);
++ const p = visible(v, slug);
++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
++ const patch = {};
++ if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);
++ if (body.content !== undefined && p.type === 'paste') {
++ const content = String(body.content);
++ if (content.length > L.maxSizeKb * 1024) fail(400, 'Too large');
++ patch.content = content;
++ patch.language = store.detectLanguage(content, body.language);
++ }
++ if (body.visibility !== undefined) patch.visibility = visibilityOf(body.visibility, !!p.owner_subject);
++ 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 = store.updatePaste(db, p.id, patch, v.subject || null);
++ return { paste: await shapeOne(row, v) };
++ },
++
++ /** DELETE /api/pastes/:slug — owner (or staff) only. */
++ remove(v, slug) {
++ needIdentity(v);
++ const p = visible(v, slug);
++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
++ store.softDelete(db, p.id);
++ return { success: true };
++ },
++
++ /** GET /api/pastes/:slug/versions — edit history, owner (or staff) only. */
++ versions(v, slug) {
++ needIdentity(v);
++ const p = visible(v, slug);
++ if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
++ const list = store.listVersions(db, p.id);
++ return { revision: p.revision, versions: list.length ? list : [{ revision: p.revision, title: p.title, content: p.content, language: p.language, edited_by: p.owner_subject, created_at: p.updated_at }] };
++ },
++
++ /** POST /api/pastes/:slug/fork */
++ async fork(v, slug) {
++ const original = visible(v, slug);
++ if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
++ pasteRateCheck(v);
++ const row = store.insertPaste(db, {
++ slug: store.generateSlug(db),
++ 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: 'public',
++ forked_from: original.id,
++ });
++ return created(row, { paste: await shapeOne(row, v) });
++ },
++
++ /** POST /api/pastes/:slug/like — toggles; needs a person. */
++ like(v, slug) {
++ if (!v.subject) fail(401, 'Authentication required');
++ const p = visible(v, slug);
++ return store.toggleLike(db, p.id, v.subject);
++ },
++
++ /** POST /api/pastes/:slug/copy */
++ copy(v, slug) {
++ const p = visible(v, slug);
++ return { copies: store.incrementCopies(db, p.id) };
++ },
++
++ /** GET /api/pastes/:slug/comments */
++ async comments(v, slug, q = {}) {
++ const p = visible(v, slug);
++ const limit = Math.min(parseInt(q.limit || '50', 10) || 50, 100);
++ const offset = Math.max(parseInt(q.offset || '0', 10) || 0, 0);
++ const list = store.listComments(db, p.id, limit, offset);
++ const subjects = [];
++ for (const c of list) { subjects.push(c.author_subject); for (const r of c.replies) subjects.push(r.author_subject); }
++ const projections = await projectionsFor(subjects);
++ return { comments: list.map((c) => shapeComment(c, projections)), total: store.countComments(db, p.id) };
++ },
++
++ /** POST /api/pastes/:slug/comments — anonymous comments allowed (with a name). */
++ async addComment(v, slug, body = {}, ctx = {}) {
++ const p = visible(v, slug);
++ const author = v.subject || null;
++ if (!author && !L.commentAnonAllowed) fail(401, 'You must be logged in to comment');
++ const message = String(body.message || '').trim();
++ if (!message) fail(400, 'Comment cannot be empty');
++ if (message.length > L.commentMaxLength) fail(400, `Comment must be under ${L.commentMaxLength} characters`);
++ let anonName = null;
++ if (!author) {
++ anonName = String(body.anon_name || '').trim().substring(0, 32) || 'Anonymous';
++ anonName = anonName.replace(/[^a-zA-Z0-9 _-]/g, '').trim() || 'Anonymous';
++ }
++ const rateKey = author ? `s:${author}` : `ip:${ctx.ip || 'unknown'}`;
++ if (limited(v)) commentRateCheck(rateKey, message);
++ const parentId = body.parent_id ? parseInt(body.parent_id, 10) : null;
++ if (parentId) {
++ const parent = store.getComment(db, parentId);
++ if (!parent || parent.paste_id !== p.id) fail(400, 'Invalid parent comment');
++ if (parent.parent_id) fail(400, 'Cannot reply to a reply — reply to the original comment instead');
++ }
++ const c = store.createComment(db, { paste_id: p.id, author_subject: author, anon_name: anonName, parent_id: parentId, message });
++ if (limited(v)) commentRecorded(rateKey, message);
++ return { comment: shapeComment(c, await projectionsFor([author])) };
++ },
++
++ /** DELETE /api/pastes/:slug/comments/:id — the author, the paste's owner, or staff. */
++ deleteComment(v, slug, commentId) {
++ needIdentity(v);
++ const p = visible(v, slug);
++ const c = store.getComment(db, parseInt(commentId, 10));
++ if (!c || c.paste_id !== p.id) fail(404, 'Comment not found');
++ const isAuthor = !!(v.subject && c.author_subject && c.author_subject === v.subject);
++ if (!isAuthor && !isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized to delete this comment');
++ store.softDeleteComment(db, c.id);
++ return { message: 'Comment deleted' };
++ },
++
++ /** GET /api/pastes/config */
++ config(v) {
++ return {
++ maxSizeKb: L.maxSizeKb,
++ screenshotMaxSizeMb: L.screenshotMaxSizeMb,
++ cooldownSeconds: L.cooldownSeconds,
++ maxPerUserPerDay: L.maxPerUserPerDay,
++ todayCount: v.subject ? store.countOwnerSince(db, v.subject, '-1 day') : 0,
++ };
++ },
++
++ // ── staff (api.js has already checked the viewer is staff) ──
++ stats() { return { stats: store.stats(db) }; },
++
++ forks(q = {}) {
++ const limit = intIn(q.limit, 100, 1, 500);
++ const offset = Math.max(parseInt(q.offset, 10) || 0, 0);
++ const { forks, total } = store.listForks(db, limit, offset);
++ return { forks: forks.map((f) => ({ ...f, user_id: f.owner_subject || null })), total, limit, offset };
++ },
++
++ deleteForks() { return { success: true, deleted: store.deleteAllForks(db) }; },
++
++ bulk(body = {}) {
++ const { slugs, action } = body;
++ if (!Array.isArray(slugs) || !slugs.length) fail(400, 'No slugs provided');
++ if (!['delete', 'public', 'unlisted', 'private'].includes(action)) fail(400, 'Invalid action');
++ let done = 0, skipped = 0;
++ db.transaction(() => {
++ for (const slug of slugs.slice(0, 500)) {
++ const p = store.getBySlug(db, String(slug));
++ if (!p) { skipped++; continue; }
++ if (action === 'delete') store.softDelete(db, p.id);
++ else store.setVisibility(db, p.id, action);
++ done++;
++ }
++ })();
++ return { done, skipped };
++ },
++
++ /** POST /api/pastes/:slug/censor — replace a screenshot's image (slug or numeric id). */
++ async censor(v, slugOrId, file) {
++ let p = store.getBySlug(db, String(slugOrId));
++ if (!p && /^\d+$/.test(String(slugOrId))) p = store.getById(db, parseInt(slugOrId, 10));
++ if (!p) fail(404, 'Paste not found');
++ if (p.type !== 'screenshot' || !p.screenshot_url) fail(400, 'Not a screenshot paste');
++ if (!file || !file.buffer) fail(400, 'Censored image is required');
++ if (!/^image\/(png|jpeg|webp)$/.test(file.mimetype || '')) fail(400, 'Only PNG, JPEG, or WebP images allowed');
++ if (!media) fail(503, 'Media service unavailable');
++ let stored;
++ try {
++ stored = await media.upload({ buffer: stripImageMetadata(file.buffer, file.mimetype), filename: file.originalname || 'censored.png', mime: file.mimetype });
++ } catch (err) {
++ console.warn('[Pastes] censor upload failed:', err.message);
++ fail(502, 'Media service unavailable');
++ }
++ const row = store.setScreenshot(db, p.id, stored.url, stored.media_ref);
++ return { paste: await shapeOne(row, v) };
++ },
++
++ /** POST /api/pastes/:slug/ai — the AI pass writes its results back. */
++ async setAi(v, slug, body = {}) {
++ const p = store.getBySlug(db, String(slug));
++ if (!p) fail(404, 'Paste not found');
++ const summary = body.ai_summary == null ? null : String(body.ai_summary).slice(0, 2000);
++ const tags = body.ai_tags == null ? null : (typeof body.ai_tags === 'string' ? body.ai_tags : JSON.stringify(body.ai_tags)).slice(0, 2000);
++ const row = store.setAi(db, p.id, summary, tags);
++ return { ok: true, paste: await shapeOne(row, v) };
++ },
++ };
++}
++
++module.exports = { createPasteService, PasteError, DEFAULT_LIMITS, AI_DISPLAY_NAME };
ddiff --git a/server/pastes/source.js b/server/pastes/source.js
nnew file mode 100644
iindex 0000000..f1a758b
---- /dev/null
++++ b/server/pastes/source.js
@@@ -0,0 +1,43 @@
++'use strict';
++
++/**
++ * Where server-rendered pages read pastes from.
++ *
++ * 'live' authority → live-client.js (OpenVibe.Live's API, Media behind it) — the default.
++ * 'community' authority → this site's own store, through the same service the API uses, so a
++ * page and the API can never disagree about visibility or view counts.
++ *
++ * Both expose the live-client call shapes (listPastes / getPaste / listByUser / createPaste);
++ * `ctx` carries the visitor ({ token, ip, userAgent, viewer, noView }).
++ */
++const live = require('../live-client');
++const { ANONYMOUS } = require('../identity/viewer');
++
++let current = { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
++
++function localSource(service) {
++ const who = (ctx) => (ctx && ctx.viewer) || ANONYMOUS;
++ return {
++ local: true,
++ service,
++ listPastes: (query, ctx = {}) => service.list(who(ctx), query || {}),
++ getPaste: async (slug, ctx = {}) => (await service.get(who(ctx), slug, { noView: !!ctx.noView, ip: ctx.ip, userAgent: ctx.userAgent })).paste,
++ listByUser: (username, query, ctx = {}) => service.byUser(who(ctx), username, query || {}),
++ createPaste: (body, ctx = {}) => service.createText(who(ctx), body || {}),
++ };
++}
++
++/** Switch to the store (community authority); passing nothing goes back to Live. */
++function use(service) {
++ current = service ? localSource(service) : { local: false, listPastes: live.listPastes, getPaste: live.getPaste, listByUser: live.listByUser, createPaste: live.createPaste };
++ return current;
++}
++
++module.exports = {
++ use,
++ get local() { return current.local; },
++ listPastes: (...a) => current.listPastes(...a),
++ getPaste: (...a) => current.getPaste(...a),
++ listByUser: (...a) => current.listByUser(...a),
++ createPaste: (...a) => current.createPaste(...a),
++};
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
nnew file mode 100644
iindex 0000000..e4b0102
---- /dev/null
++++ b/server/pastes/store.js
@@@ -0,0 +1,393 @@
++'use strict';
++
++/**
++ * Paste store — pure database functions over Community's own tables (server/db.js).
++ *
++ * No HTTP, no identity resolution, no policy: every function takes the database handle first
++ * and does exactly the SQL its name says (parameterized, always). Who may call what, rate
++ * limits and response shaping live in service.js.
++ *
++ * Deleted pastes are soft-deleted (deleted_at set, content scrubbed) so their slug and legacy id
++ * stay reserved and a re-import can never bring them back; every read below skips them.
++ */
++const crypto = require('crypto');
++
++// ── Slugs (the same adjective-noun-number style OpenVibe.Media has always minted) ──
++const SLUG_ADJECTIVES = [
++ 'amber', 'blue', 'bold', 'brave', 'bright', 'calm', 'clean', 'clever',
++ 'cold', 'cool', 'coral', 'crisp', 'dark', 'dawn', 'deep', 'dry',
++ 'dusk', 'dusty', 'fair', 'fast', 'fierce', 'fine', 'foggy', 'free',
++ 'fresh', 'frost', 'glad', 'gold', 'grand', 'gray', 'green', 'grim',
++ 'hazy', 'heavy', 'hidden', 'hollow', 'honey', 'hot', 'icy', 'iron',
++ 'jade', 'keen', 'kind', 'late', 'lazy', 'light', 'lime', 'lit',
++ 'lone', 'lost', 'loud', 'lucky', 'lush', 'mild', 'misty', 'mossy',
++ 'muddy', 'neon', 'new', 'noble', 'odd', 'old', 'opal', 'open',
++ 'pale', 'pink', 'plain', 'plum', 'prime', 'proud', 'pure', 'quick',
++ 'quiet', 'rare', 'raw', 'red', 'rich', 'rocky', 'rosy', 'rough',
++ 'ruby', 'rusty', 'safe', 'sage', 'sandy', 'sharp', 'shy', 'silver',
++ 'slim', 'slow', 'smoky', 'snowy', 'soft', 'sour', 'steep', 'still',
++ 'stone', 'sunny', 'sweet', 'swift', 'tall', 'tame', 'teal', 'thin',
++ 'tidy', 'tiny', 'torn', 'vast', 'vivid', 'warm', 'wavy', 'west',
++ 'wet', 'white', 'wide', 'wild', 'windy', 'wise', 'worn', 'young',
++];
++const SLUG_NOUNS = [
++ 'acorn', 'arch', 'arrow', 'aspen', 'badger', 'basil', 'bay', 'bear',
++ 'birch', 'blade', 'bloom', 'bolt', 'brook', 'brush', 'cairn', 'cave',
++ 'cedar', 'cliff', 'cloud', 'clover', 'coast', 'coral', 'crane', 'creek',
++ 'crow', 'dale', 'deer', 'delta', 'dew', 'dock', 'dove', 'drift',
++ 'drum', 'dune', 'eagle', 'echo', 'edge', 'elm', 'ember', 'fawn',
++ 'fern', 'field', 'finch', 'flame', 'flare', 'flint', 'fog', 'ford',
++ 'forge', 'fox', 'frost', 'gale', 'gate', 'gem', 'glen', 'goat',
++ 'grove', 'gull', 'hawk', 'haze', 'heath', 'hedge', 'heron', 'hill',
++ 'holly', 'horse', 'hound', 'isle', 'ivy', 'jade', 'jay', 'kelp',
++ 'lake', 'lark', 'leaf', 'ledge', 'lily', 'lion', 'lodge', 'lynx',
++ 'maple', 'marsh', 'mesa', 'mill', 'mint', 'mist', 'moon', 'moss',
++ 'moth', 'mule', 'nest', 'oak', 'orca', 'otter', 'owl', 'palm',
++ 'path', 'peak', 'pearl', 'petal', 'pike', 'pine', 'plum', 'pond',
++ 'quail', 'rain', 'raven', 'reed', 'reef', 'ridge', 'river', 'robin',
++ 'root', 'rose', 'sage', 'seal', 'shade', 'shell', 'shore', 'slate',
++ 'snail', 'spark', 'stone', 'storm', 'stork', 'thorn', 'tide', 'trail',
++ 'trout', 'tulip', 'vale', 'vine', 'viper', 'wave', 'wren', 'wolf',
++];
++
++/** A free adj-noun-NN slug. Deleted pastes keep theirs, so an old link never points somewhere new. */
++function generateSlug(db) {
++ const taken = db.prepare('SELECT 1 FROM pastes WHERE slug = ?');
++ for (let i = 0; i < 10; i++) {
++ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(10, 100)}`;
++ if (!taken.get(slug)) return slug;
++ }
++ // ~180k combinations; if ten draws all collide, widen the number rather than fail.
++ for (let i = 0; i < 10; i++) {
++ const slug = `${SLUG_ADJECTIVES[crypto.randomInt(SLUG_ADJECTIVES.length)]}-${SLUG_NOUNS[crypto.randomInt(SLUG_NOUNS.length)]}-${crypto.randomInt(100, 10000)}`;
++ if (!taken.get(slug)) return slug;
++ }
++ throw new Error('Could not generate a unique paste slug');
++}
++
++function sanitizeTitle(title) {
++ return String(title == null ? '' : title).trim().slice(0, 200) || 'Untitled';
++}
++
++/** Media's language sniffing, unchanged: an explicit hint wins unless it is 'auto'. */
++function detectLanguage(content, hint) {
++ if (hint && hint !== 'auto') return String(hint).slice(0, 32);
++ const first = String(content || '').slice(0, 500);
++ if (/^<(!DOCTYPE|html|div|span|head|body)/im.test(first)) return 'html';
++ if (/^(import |from |const |let |var |function |=>|class )/m.test(first)) return 'javascript';
++ if (/^(def |class |import |from |print\(|if __name__)/m.test(first)) return 'python';
++ if (/^(package |func |import \(|fmt\.)/m.test(first)) return 'go';
++ if (/^\{[\s\n]*"/.test(first)) return 'json';
++ if (/^---\n|^[a-z_]+:\s/m.test(first)) return 'yaml';
++ if (/^#!\/(bin|usr)/m.test(first)) return 'bash';
++ if (/```|^#{1,6} |^\* |\*\*|^\[.*\]\(.*\)/m.test(first)) return 'markdown';
++ if (/^(SELECT|INSERT|CREATE|ALTER|DROP|UPDATE|DELETE)\s/im.test(first)) return 'sql';
++ if (/^<\?php/m.test(first)) return 'php';
++ if (/^(use |fn |let mut |pub |impl |struct )/m.test(first)) return 'rust';
++ return 'text';
++}
++
++const likeEscape = (s) => String(s).replace(/[\\%_]/g, (c) => `\\${c}`);
++
++// ── Pastes ───────────────────────────────────────────────────
++
++function getBySlug(db, slug) {
++ return db.prepare('SELECT * FROM pastes WHERE slug = ? AND deleted_at IS NULL').get(String(slug)) || null;
++}
++
++function getById(db, id) {
++ return db.prepare('SELECT * FROM pastes WHERE id = ? AND deleted_at IS NULL').get(id) || null;
++}
++
++const INSERT_COLUMNS = ['slug', 'owner_subject', 'origin', 'type', 'title', 'content', 'language', 'visibility',
++ 'screenshot_url', 'media_ref', 'stream_ref', 'metadata', 'burn_after_read', 'forked_from', 'pinned', 'is_nsfw',
++ 'ai_summary', 'ai_tags', 'ai_analyzed_at'];
++
++/** Insert a new paste (fields already validated); returns the stored row. */
++function insertPaste(db, fields) {
++ const cols = INSERT_COLUMNS.filter((c) => fields[c] !== undefined);
++ const info = db.prepare(`INSERT INTO pastes (${cols.join(', ')}) VALUES (${cols.map(() => '?').join(', ')})`)
++ .run(...cols.map((c) => fields[c]));
++ return getById(db, info.lastInsertRowid);
++}
++
++/**
++ * Apply an edit. `patch` may hold title/content/language/visibility/is_nsfw/pinned. A change to
++ * title, content or language bumps the revision and appends a paste_versions row; the first
++ * edit also snapshots the original as revision 1, so the history is complete without storing a
++ * copy of every paste that is never edited.
++ */
++function updatePaste(db, id, patch, editedBy = null) {
++ return db.transaction(() => {
++ const cur = getById(db, id);
++ if (!cur) return null;
++ const sets = [];
++ const params = [];
++ for (const k of ['title', 'content', 'language', 'visibility', 'is_nsfw', 'pinned']) {
++ if (patch[k] === undefined) continue;
++ sets.push(`${k} = ?`);
++ params.push(patch[k]);
++ }
++ const textChanged = ['title', 'content', 'language'].some((k) => patch[k] !== undefined && patch[k] !== cur[k]);
++ if (textChanged) {
++ const hasCur = db.prepare('SELECT 1 FROM paste_versions WHERE paste_id = ? AND revision = ?').get(id, cur.revision);
++ if (!hasCur) {
++ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)')
++ .run(id, cur.revision, cur.title, cur.content, cur.language, cur.owner_subject, cur.updated_at || cur.created_at);
++ }
++ sets.push('revision = revision + 1');
++ }
++ sets.push('updated_at = CURRENT_TIMESTAMP');
++ db.prepare(`UPDATE pastes SET ${sets.join(', ')} WHERE id = ?`).run(...params, id);
++ const next = getById(db, id);
++ if (textChanged) {
++ db.prepare('INSERT INTO paste_versions (paste_id, revision, title, content, language, edited_by) VALUES (?, ?, ?, ?, ?, ?)')
++ .run(id, next.revision, next.title, next.content, next.language, editedBy);
++ }
++ return next;
++ })();
++}
++
++function listVersions(db, pasteId) {
++ return db.prepare('SELECT revision, title, content, language, edited_by, created_at FROM paste_versions WHERE paste_id = ? ORDER BY revision ASC').all(pasteId);
++}
++
++/** Soft delete: the row stays (slug + legacy id reserved), its content and image link do not. */
++function softDelete(db, id) {
++ return db.prepare(`UPDATE pastes SET deleted_at = CURRENT_TIMESTAMP, content = NULL, screenshot_url = NULL, metadata = NULL,
++ ai_summary = NULL, ai_tags = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL`).run(id).changes;
++}
++
++/**
++ * List pastes.
++ * opts.ownerSubject only this owner's pastes
++ * opts.includeHidden also unlisted + private (only ever set for the owner or staff)
++ * opts.type 'paste' | 'screenshot'
++ * opts.search substring of title or content
++ * opts.origin 'user' | 'ai' (default: all)
++ * opts.needsAi pastes without an AI summary, any visibility (staff work queue)
++ * opts.sort 'oldest' | newest (default)
++ * Returns { rows, total }.
++ */
++function listPastes(db, opts = {}) {
++ const where = ['deleted_at IS NULL'];
++ const params = [];
++ // The AI work queue spans visibility (Media's rule) and takes rows nothing has annotated yet.
++ if (opts.needsAi) where.push("COALESCE(ai_summary, '') = '' AND ai_analyzed_at IS NULL");
++ else if (!opts.includeHidden) where.push("visibility = 'public'");
++ if (opts.origin === 'user' || opts.origin === 'ai' || opts.origin === 'imported') { where.push('origin = ?'); params.push(opts.origin); }
++ if (opts.ownerSubject !== undefined) { where.push('owner_subject = ?'); params.push(opts.ownerSubject); }
++ if (opts.type === 'paste' || opts.type === 'screenshot') { where.push('type = ?'); params.push(opts.type); }
++ if (opts.search) {
++ const q = `%${likeEscape(opts.search)}%`;
++ where.push("(title LIKE ? ESCAPE '\\' OR content LIKE ? ESCAPE '\\')");
++ params.push(q, q);
++ }
++ const dir = opts.sort === 'oldest' ? 'ASC' : 'DESC';
++ const limit = Math.min(Math.max(parseInt(opts.limit, 10) || 50, 1), 500);
++ const offset = Math.max(parseInt(opts.offset, 10) || 0, 0);
++ const clause = where.join(' AND ');
++ const rows = db.prepare(`SELECT * FROM pastes WHERE ${clause} ORDER BY pinned DESC, created_at ${dir}, id ${dir} LIMIT ? OFFSET ?`)
++ .all(...params, limit, offset);
++ const { total } = db.prepare(`SELECT COUNT(*) AS total FROM pastes WHERE ${clause}`).get(...params);
++ return { rows, total };
++}
++
++/** Pastes created by a subject since `sinceSql` (an SQLite datetime modifier, e.g. '-1 day'). */
++function countOwnerSince(db, subject, sinceSql) {
++ return db.prepare("SELECT COUNT(*) AS c FROM pastes WHERE owner_subject = ? AND created_at > datetime('now', ?)").get(subject, sinceSql).c;
++}
++
++/** ms timestamp of the subject's newest paste (deleted ones count: deleting doesn't reset a cooldown). */
++function lastPasteTime(db, subject) {
++ const row = db.prepare('SELECT created_at FROM pastes WHERE owner_subject = ? ORDER BY created_at DESC, id DESC LIMIT 1').get(subject);
++ return row ? Date.parse(String(row.created_at).replace(' ', 'T') + 'Z') : 0;
++}
++
++function hasLiked(db, pasteId, subject) {
++ return !!db.prepare('SELECT 1 FROM paste_likes WHERE paste_id = ? AND subject_id = ?').get(pasteId, subject);
++}
++
++/** Toggle a like; the counter is recounted from the rows (as Media did). → { liked, likes } */
++function toggleLike(db, pasteId, subject) {
++ return db.transaction(() => {
++ const already = hasLiked(db, pasteId, subject);
++ if (already) db.prepare('DELETE FROM paste_likes WHERE paste_id = ? AND subject_id = ?').run(pasteId, subject);
++ else db.prepare('INSERT OR IGNORE INTO paste_likes (paste_id, subject_id) VALUES (?, ?)').run(pasteId, subject);
++ db.prepare('UPDATE pastes SET likes = (SELECT COUNT(*) FROM paste_likes WHERE paste_id = ?) WHERE id = ?').run(pasteId, pasteId);
++ return { liked: !already, likes: db.prepare('SELECT likes FROM pastes WHERE id = ?').get(pasteId).likes };
++ })();
++}
++
++function incrementCopies(db, pasteId) {
++ db.prepare('UPDATE pastes SET copies = copies + 1 WHERE id = ?').run(pasteId);
++ return db.prepare('SELECT copies FROM pastes WHERE id = ?').get(pasteId).copies;
++}
++
++/** Burn-after-read pastes keep a literal every-read counter. Returns the new count. */
++function bumpViews(db, pasteId) {
++ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
++ return db.prepare('SELECT views FROM pastes WHERE id = ?').get(pasteId).views;
++}
++
++/**
++ * Visit-based view counting (Media's rules): a visitor's first visit counts as a view and a
++ * unique view; later visits count again only after `cooldownSec`. → { counted, unique, views, unique_views }
++ */
++function recordVisit(db, pasteId, visitor, cooldownSec) {
++ return db.transaction(() => {
++ const row = db.prepare('SELECT last_at FROM paste_visits WHERE paste_id = ? AND visitor = ?').get(pasteId, visitor);
++ let counted = false, unique = false;
++ if (!row) {
++ db.prepare('INSERT INTO paste_visits (paste_id, visitor) VALUES (?, ?)').run(pasteId, visitor);
++ db.prepare('UPDATE pastes SET views = views + 1, unique_views = unique_views + 1 WHERE id = ?').run(pasteId);
++ counted = true; unique = true;
++ } else {
++ const lastMs = Date.parse(String(row.last_at).replace(' ', 'T') + 'Z');
++ if (!(cooldownSec > 0 && Date.now() - lastMs < cooldownSec * 1000)) {
++ db.prepare('UPDATE paste_visits SET last_at = CURRENT_TIMESTAMP, visits = visits + 1 WHERE paste_id = ? AND visitor = ?').run(pasteId, visitor);
++ db.prepare('UPDATE pastes SET views = views + 1 WHERE id = ?').run(pasteId);
++ counted = true;
++ }
++ }
++ const c = db.prepare('SELECT views, unique_views FROM pastes WHERE id = ?').get(pasteId);
++ return { counted, unique, views: c.views, unique_views: c.unique_views };
++ })();
++}
++
++function pruneVisits(db, days = 30) {
++ return db.prepare("DELETE FROM paste_visits WHERE last_at < datetime('now', ?)").run(`-${days} days`).changes;
++}
++
++function setAi(db, pasteId, summary, tags) {
++ db.prepare('UPDATE pastes SET ai_summary = ?, ai_tags = ?, ai_analyzed_at = CURRENT_TIMESTAMP WHERE id = ?').run(summary, tags, pasteId);
++ return getById(db, pasteId);
++}
++
++function setScreenshot(db, pasteId, url, mediaRef) {
++ db.prepare('UPDATE pastes SET screenshot_url = ?, media_ref = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(url, mediaRef, pasteId);
++ return getById(db, pasteId);
++}
++
++function setVisibility(db, pasteId, visibility) {
++ return db.prepare('UPDATE pastes SET visibility = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND deleted_at IS NULL').run(visibility, pasteId).changes;
++}
++
++/** Media's admin stats shape. */
++function stats(db) {
++ const r = db.prepare(`
++ SELECT COUNT(*) AS total,
++ SUM(CASE WHEN type = 'paste' THEN 1 ELSE 0 END) AS textPastes,
++ SUM(CASE WHEN type = 'screenshot' THEN 1 ELSE 0 END) AS screenshots,
++ SUM(CASE WHEN forked_from IS NOT NULL THEN 1 ELSE 0 END) AS forks,
++ COALESCE(SUM(views), 0) AS totalViews,
++ COALESCE(SUM(copies), 0) AS totalCopies,
++ COALESCE(SUM(likes), 0) AS totalLikes
++ FROM pastes WHERE deleted_at IS NULL`).get() || {};
++ return {
++ total: r.total || 0, textPastes: r.textPastes || 0, screenshots: r.screenshots || 0, forks: r.forks || 0,
++ totalViews: r.totalViews || 0, totalCopies: r.totalCopies || 0, totalLikes: r.totalLikes || 0,
++ };
++}
++
++function listForks(db, limit, offset) {
++ const forks = db.prepare(`SELECT id, slug, owner_subject, type, title, forked_from, visibility, views, copies, likes, created_at
++ FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL
++ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(limit, offset);
++ const total = db.prepare('SELECT COUNT(*) AS c FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').get().c;
++ return { forks, total };
++}
++
++function deleteAllForks(db) {
++ return db.transaction(() => {
++ const ids = db.prepare('SELECT id FROM pastes WHERE forked_from IS NOT NULL AND deleted_at IS NULL').all().map((r) => r.id);
++ for (const id of ids) softDelete(db, id);
++ return ids.length;
++ })();
++}
++
++// ── Comments ─────────────────────────────────────────────────
++
++function createComment(db, { paste_id, author_subject, anon_name, parent_id, message }) {
++ const info = db.prepare('INSERT INTO paste_comments (paste_id, author_subject, anon_name, parent_id, message) VALUES (?, ?, ?, ?, ?)')
++ .run(paste_id, author_subject || null, anon_name || null, parent_id || null, message);
++ return getComment(db, info.lastInsertRowid);
++}
++
++function getComment(db, id) {
++ return db.prepare('SELECT * FROM paste_comments WHERE id = ?').get(id) || null;
++}
++
++/** Top-level comments (newest first) with their replies (oldest first); deleted ones hidden. */
++function listComments(db, pasteId, limit = 50, offset = 0) {
++ const top = db.prepare(`SELECT * FROM paste_comments WHERE paste_id = ? AND is_deleted = 0 AND parent_id IS NULL
++ ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?`).all(pasteId, limit, offset);
++ const replies = db.prepare('SELECT * FROM paste_comments WHERE parent_id = ? AND is_deleted = 0 ORDER BY created_at ASC, id ASC');
++ for (const c of top) { c.replies = replies.all(c.id); c.reply_count = c.replies.length; }
++ return top;
++}
++
++function countComments(db, pasteId) {
++ return db.prepare('SELECT COUNT(*) AS c FROM paste_comments WHERE paste_id = ? AND is_deleted = 0').get(pasteId).c;
++}
++
++function softDeleteComment(db, id) {
++ return db.prepare('UPDATE paste_comments SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(id).changes;
++}
++
++// ── Subject projection (display cache — never authority) ─────
++
++function getProjections(db, subjectIds) {
++ const out = new Map();
++ const ids = [...new Set((subjectIds || []).filter(Boolean))];
++ const stmt = db.prepare('SELECT * FROM subject_projection WHERE subject_id = ?');
++ for (const id of ids) { const r = stmt.get(id); if (r) out.set(id, r); }
++ return out;
++}
++
++/** Upsert what we were told about a subject. Fields left undefined keep their stored value. */
++function upsertProjection(db, p) {
++ if (!p || !p.subject_id) return;
++ db.prepare(`INSERT INTO subject_projection (subject_id, username, display_name, avatar_url, profile_color, refreshed_at)
++ VALUES (@subject_id, @username, @display_name, @avatar_url, @profile_color, CURRENT_TIMESTAMP)
++ ON CONFLICT(subject_id) DO UPDATE SET
++ username = COALESCE(excluded.username, username),
++ display_name = COALESCE(excluded.display_name, display_name),
++ avatar_url = COALESCE(excluded.avatar_url, avatar_url),
++ profile_color = COALESCE(excluded.profile_color, profile_color),
++ refreshed_at = CURRENT_TIMESTAMP`)
++ .run({
++ subject_id: p.subject_id, username: p.username ?? null, display_name: p.display_name ?? null,
++ avatar_url: p.avatar_url ?? null, profile_color: p.profile_color ?? null,
++ });
++}
++
++/** Subjects whose cached username matches (case-insensitive), most recently refreshed first. */
++function subjectsByUsername(db, username) {
++ return db.prepare('SELECT subject_id FROM subject_projection WHERE username = ? COLLATE NOCASE ORDER BY refreshed_at DESC')
++ .all(String(username)).map((r) => r.subject_id);
++}
++
++// ── Legacy id map ────────────────────────────────────────────
++
++function mapGet(db, system, type, id) {
++ return db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?')
++ .get(String(system), String(type), String(id)) || null;
++}
++
++function mapSet(db, system, type, id, targetType, targetId) {
++ db.prepare(`INSERT INTO legacy_id_map (source_system, source_type, source_id, target_type, target_id) VALUES (?, ?, ?, ?, ?)
++ ON CONFLICT(source_system, source_type, source_id) DO UPDATE SET target_type = excluded.target_type, target_id = excluded.target_id`)
++ .run(String(system), String(type), String(id), String(targetType), String(targetId));
++}
++
++module.exports = {
++ generateSlug, sanitizeTitle, detectLanguage,
++ getBySlug, getById, insertPaste, updatePaste, listVersions, softDelete, listPastes,
++ countOwnerSince, lastPasteTime, hasLiked, toggleLike, incrementCopies, bumpViews, recordVisit, pruneVisits,
++ setAi, setScreenshot, setVisibility, stats, listForks, deleteAllForks,
++ createComment, getComment, listComments, countComments, softDeleteComment,
++ getProjections, upsertProjection, subjectsByUsername,
++ mapGet, mapSet,
++ SLUG_ADJECTIVES, SLUG_NOUNS,
++};
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 8029d81..74fd7f3 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -217,7 +217,10 @@ function pastePage({ paste: p, related, user }) {
const description = pasteDescription(p);
const shot = isShot ? live.mediaPublicUrl(p.screenshot_url) || live.screenshotUrl(p.slug) : null;
const indexable = (p.visibility === 'public' || p.visibility == null) && !Number(p.is_nsfw) && !Number(p.burn_after_read);
-- const isOwner = !!(user && p.user_id != null && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)));
++ // Community-authority pastes name their owner by Network subject; Live-proxied ones by name.
++ const isOwner = !!(user && (p.is_owner === true
++ || (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
++ || (p.user_id != null && !p.owner_subject && (String(user.id) === String(p.user_id) || (p.username && user.username === p.username)))));
const hl = isShot ? null : highlight(p.content, p.language);
const ext = extensionFor(p.language);
ddiff --git a/test/helpers/app.js b/test/helpers/app.js
iindex 8320edf..a3f6083 100644
---- a/test/helpers/app.js
++++ b/test/helpers/app.js
@@@ -3,10 +3,17 @@
const http = require('http');
const mockLive = require('./mock-live');
const mockNetwork = require('./mock-network');
++const mockMedia = require('./mock-media');
--async function boot() {
++/**
++ * opts.authority 'live' (default) or 'community' — the latter runs the native paste API on a
++ * fresh in-memory database, with Network and Media mocks behind it.
++ * opts.pasteLimits overrides for service.js limits (e.g. { cooldownSeconds: 0 }).
++ */
++async function boot(opts = {}) {
const liveSrv = await mockLive.start();
const netSrv = await mockNetwork.start();
++ const mediaSrv = await mockMedia.start({ publicPem: netSrv.publicPem, issuer: netSrv.url });
process.env.NODE_ENV = 'test';
process.env.BASE_URL = 'https://openvibe.community';
process.env.OV_LIVE_INTERNAL_URL = liveSrv.url;
@@@ -19,9 +26,17 @@ async function boot() {
process.env.OV_OAUTH_REDIRECT_URI = 'https://openvibe.community/auth/callback';
process.env.COOKIE_SECURE = 'true';
process.env.TRUST_PROXY = '1';
++ process.env.OV_MEDIA_INTERNAL_URL = mediaSrv.url;
++ process.env.PASTES_AUTHORITY = opts.authority || 'live';
++ process.env.COMMUNITY_DB_PATH = ':memory:';
for (const k of Object.keys(require.cache)) if (k.includes('/server/')) delete require.cache[k];
const { createApp } = require('../../server/app');
-- const app = createApp();
++ const appOpts = {};
++ if (opts.authority === 'community') {
++ appOpts.db = require('../../server/db').openDb(':memory:');
++ appOpts.pasteLimits = opts.pasteLimits;
++ }
++ const app = createApp(appOpts);
const server = await new Promise((resolve) => { const s = http.createServer(app); s.listen(0, '127.0.0.1', () => resolve(s)); });
const base = `http://127.0.0.1:${server.address().port}`;
@@@ -41,8 +56,8 @@ async function boot() {
return { status: res.status, headers: res.headers, text, setCookies: res.headers.getSetCookie ? res.headers.getSetCookie() : [], json() { return JSON.parse(text); } };
}
return {
-- app, base, get, jar, live: liveSrv, network: netSrv,
-- close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); },
++ app, base, get, jar, live: liveSrv, network: netSrv, media: mediaSrv, db: appOpts.db || null,
++ close: async () => { await new Promise((r) => server.close(r)); await liveSrv.close(); await netSrv.close(); await mediaSrv.close(); },
};
}
ddiff --git a/test/helpers/mock-media.js b/test/helpers/mock-media.js
nnew file mode 100644
iindex 0000000..792047e
---- /dev/null
++++ b/test/helpers/mock-media.js
@@@ -0,0 +1,48 @@
++'use strict';
++/**
++ * A stand-in for OpenVibe.Media's file store: POST /api/v1/community/files (multipart `file`)
++ * with a Network service token for openvibe.media holding media.object.upload. Keeps each
++ * upload's raw bytes so tests can check what left Community.
++ */
++const http = require('http');
++const crypto = require('crypto');
++const { serviceAuth } = require('openvibe-contracts');
++
++function start({ publicPem, issuer }) {
++ const uploads = [];
++ let failNext = 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 !== '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' });
++ if (failNext) { failNext = false; return json(500, { error: 'disk full' }); }
++ const body = Buffer.concat(chunks);
++ // Pull the `file` part out of the multipart body.
++ const m = String(req.headers['content-type']).match(/boundary=(?:"([^"]+)"|([^;]+))/);
++ const boundary = m && (m[1] || m[2]);
++ const parts = body.toString('latin1').split(`--${boundary}`);
++ const part = parts.find((p) => /name="file"/.test(p)) || '';
++ const headerEnd = part.indexOf('\r\n\r\n');
++ const head = part.slice(0, headerEnd);
++ const bytes = Buffer.from(part.slice(headerEnd + 4, part.length - 2), 'latin1');
++ const name = (head.match(/filename="([^"]*)"/) || [])[1] || 'upload.bin';
++ const mime = ((head.match(/Content-Type:\s*([^\r\n]+)/i) || [])[1] || 'application/octet-stream').trim();
++ const key = `${crypto.createHash('sha256').update(bytes).digest('hex').slice(0, 12)}-${name}`;
++ uploads.push({ key, name, mime, bytes, claims: v.claims });
++ json(201, { key, url: `/f/${key}`, size: bytes.length, mime });
++ });
++ });
++ return new Promise((resolve) => server.listen(0, '127.0.0.1', () => resolve({
++ url: `http://127.0.0.1:${server.address().port}`,
++ uploads,
++ failNext: () => { failNext = true; },
++ close: () => new Promise((r) => server.close(r)),
++ })));
++}
++
++module.exports = { start };
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex 68ad185..86b4218 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -1,25 +1,54 @@
'use strict';
--/** A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair. */
++/**
++ * A stand-in for OpenVibe.Network: JWKS + the token endpoint, with a real RS256 key pair.
++ *
++ * Also the Wave-1 service-principal surface Community uses in 'community' authority mode:
++ * - client_credentials grants (form-encoded, as openvibe-contracts' token client sends them)
++ * mint service tokens with the requested audience and scope as capabilities;
++ * - POST /internal/identity/resolve-batch answers from `directory` and insists on a service
++ * token for openvibe.network holding identity.subject.resolve.
++ * signService() mints tokens as another service (e.g. Live) would present them to Community.
++ */
const http = require('http');
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
++const { serviceAuth, ids } = require('openvibe-contracts');
function start() {
const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 });
const publicPem = publicKey.export({ type: 'spki', format: 'pem' });
const privatePem = privateKey.export({ type: 'pkcs8', format: 'pem' });
const grants = [];
++ const resolveCalls = [];
let issuer = 'http://network.test';
++
++ // Network accounts: { network_user_id, subject_id, username, display_name, avatar_url }, plus
++ // legacy ids other services registered: legacy[system][id] = subject_id.
++ const directory = { users: [], legacy: { live: {} }, down: false };
++ function addUser(u) {
++ const user = { subject_id: ids.newId('user'), display_name: u.username, avatar_url: null, ...u };
++ directory.users.push(user);
++ return user;
++ }
++ const projection = (u) => (u ? { subject: { type: 'user', id: u.subject_id }, network_user_id: u.network_user_id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, banned: false } : null);
++
const server = http.createServer((req, res) => {
const chunks = [];
req.on('data', (c) => chunks.push(c));
req.on('end', () => {
++ const raw = Buffer.concat(chunks).toString('utf8');
const json = (status, obj) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(obj)); };
if (req.url === '/api/.well-known/jwks') return json(200, { public_key: publicPem, algorithm: 'RS256' });
if (req.url === '/oauth/token' && req.method === 'POST') {
-- let body = {}; try { body = JSON.parse(Buffer.concat(chunks).toString('utf8')); } catch { /* */ }
++ let body = {};
++ if (String(req.headers['content-type'] || '').includes('application/x-www-form-urlencoded')) body = Object.fromEntries(new URLSearchParams(raw));
++ else { try { body = JSON.parse(raw); } catch { /* */ } }
grants.push(body);
if (body.client_secret !== 'shh') return json(401, { error: 'invalid_client' });
++ if (body.grant_type === 'client_credentials') {
++ const cap = String(body.scope || '').split(/\s+/).filter(Boolean);
++ return json(200, { access_token: signService({ sub: `svc:${body.client_id}`, aud: [body.audience], cap }), token_type: 'Bearer', expires_in: 300 });
++ }
if (body.grant_type === 'authorization_code' && body.code !== 'good-code') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'refresh_token' && body.refresh_token !== 'refresh-1') return json(400, { error: 'invalid_grant' });
if (body.grant_type === 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
@@@ -31,16 +60,44 @@ function start() {
return json(200, { access_token: sign(user), refresh_token: 'refresh-2', token_type: 'Bearer', expires_in: 86400, user, preferences: { theme: 'vibe' } });
}
if (req.url === '/oauth/revoke') return json(200, { ok: true });
++ if (req.url === '/internal/identity/resolve-batch' && 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('identity.subject.resolve')) return json(403, { code: 'capability.denied', error: 'not granted' });
++ let body = {}; try { body = JSON.parse(raw); } catch { /* */ }
++ resolveCalls.push(body);
++ if (directory.down) return json(503, { error: 'down' });
++ const results = {};
++ if (Array.isArray(body.subject_ids)) {
++ for (const s of body.subject_ids) results[s] = projection(directory.users.find((u) => u.subject_id === s));
++ } else {
++ for (const id of body.ids || []) {
++ const k = String(id);
++ if (body.system === 'network') results[k] = projection(directory.users.find((u) => String(u.network_user_id) === k));
++ else {
++ const sid = (directory.legacy[body.system] || {})[k];
++ results[k] = projection(sid ? directory.users.find((u) => u.subject_id === sid) : null);
++ }
++ }
++ }
++ return json(200, { results });
++ }
json(404, { error: 'not found' });
});
});
function sign(claims, opts = {}) {
return jwt.sign({ sub: claims.id, ...claims }, privatePem, { algorithm: 'RS256', issuer, expiresIn: '1h', ...opts });
}
++ /** A client-credentials service token (identity.service-token-claims@1). */
++ function signService({ sub = 'svc:live', aud = ['openvibe.community'], cap = [], iss = issuer, expSec = 300, key = privatePem } = {}) {
++ const now = Math.floor(Date.now() / 1000);
++ return serviceAuth.signServiceToken({ iss, sub, actor_type: 'service', aud, cap, iat: now, exp: now + expSec, jti: crypto.randomBytes(8).toString('hex') }, key);
++ }
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, sign, publicPem, close: () => new Promise((r) => server.close(r)) });
++ resolve({ url, grants, resolveCalls, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
}));
}
ddiff --git a/test/import.test.js b/test/import.test.js
nnew file mode 100644
iindex 0000000..66295b7
---- /dev/null
++++ b/test/import.test.js
@@@ -0,0 +1,263 @@
++'use strict';
++/**
++ * scripts/import-pastes.js + server/pastes/importer.js: bundle verification, owner mapping across
++ * the id-space cutoff (ambiguous / unmapped → holds), AI pastes, fork and reply remapping, likes,
++ * idempotent re-runs, newer bundles, dry runs, and the CLI's exit codes.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const fs = require('fs');
++const os = require('os');
++const path = require('path');
++const { spawnSync } = require('child_process');
++const { openDb } = require('../server/db');
++const { importBundle, ImportError } = require('../server/pastes/importer');
++const { createNetworkIdentity } = require('../server/identity/network');
++const mockNetwork = require('./helpers/mock-network');
++const { check, done } = require('./helpers/app');
++
++const PRE = '2026-08-01 10:00:00'; // before the id-space fix (2026-08-20T03:00:26Z)
++const POST = '2026-09-01 10:00:00';
++
++function seal(bundle) {
++ const { pastes, comments, likes } = bundle;
++ return {
++ format: 'openvibe.media.pastes-export', version: 1, app: 'live', generated_at: '2026-09-22T12:00:00Z',
++ since_id: 0, max_id: Math.max(0, ...pastes.map((p) => p.id)),
++ counts: { pastes: pastes.length, comments: comments.length, likes: likes.length },
++ sha256: crypto.createHash('sha256').update(JSON.stringify({ pastes, comments, likes })).digest('hex'),
++ pastes, comments, likes,
++ };
++}
++
++const paste = (o) => ({
++ id: 1, slug: 'amber-fox-11', user_id: null, type: 'paste', title: 'T', content: 'c', language: 'text', visibility: 'public',
++ stream_id: null, screenshot_url: null, metadata: null, burn_after_read: 0, forked_from: null, pinned: 0, views: 0, unique_views: 0,
++ copies: 0, likes: 0, is_nsfw: 0, ai_summary: null, ai_tags: null, ai_analyzed_at: null, created_at: POST, updated_at: POST, ...o,
++});
++
++(async () => {
++ const net = await mockNetwork.start();
++ // A: network 7 = live 107. B: network 9 = live 109. C: live 9 (so "9" is ambiguous before the cutoff).
++ const A = net.addUser({ network_user_id: 7, username: 'alex' });
++ const B = net.addUser({ network_user_id: 9, username: 'sam' });
++ const C = net.addUser({ network_user_id: 30, username: 'cleo' });
++ net.directory.legacy.live = { 107: A.subject_id, 109: B.subject_id, 9: C.subject_id };
++ const config = { networkInternalUrl: net.url, networkUrl: net.url, oauth: { clientId: 'community', clientSecret: 'shh' } };
++
++ const db = openDb(':memory:');
++ const identity = createNetworkIdentity({ config, db });
++ const run = (bundle, opts = {}) => importBundle(db, bundle, { resolveLegacy: identity.resolveLegacy, source: 'test', ...opts });
++ const count = (table) => db.prepare(`SELECT COUNT(*) AS c FROM ${table}`).get().c;
++ const bySlug = (slug) => db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);
++ const holds = () => db.prepare('SELECT source_type, source_id, reason FROM import_hold ORDER BY source_type, source_id, reason').all();
++
++ const pastes = [
++ paste({ id: 6, slug: 'fork-of-one-66', user_id: 107, title: 'Fork of One', forked_from: 1 }), // fork listed before its original
++ paste({ id: 1, slug: 'amber-fox-11', user_id: 107, title: 'One', content: 'hello', language: 'javascript', views: 50, unique_views: 40, copies: 3, likes: 2, visibility: 'public', is_nsfw: 1, ai_summary: 'greets', ai_tags: ['hi'], ai_analyzed_at: '2026-09-02 00:00:00', created_at: POST, updated_at: '2026-09-03 00:00:00' }),
++ paste({ id: 2, slug: 'blue-lake-22', user_id: 7, created_at: PRE, updated_at: PRE, visibility: 'unlisted' }), // pre-cutoff, only the network mapping exists → A
++ paste({ id: 3, slug: 'cold-ridge-33', user_id: 9, created_at: PRE, updated_at: PRE }), // pre-cutoff, live 9 = C, network 9 = B → ambiguous
++ paste({ id: 4, slug: 'dark-owl-44', user_id: 555 }), // post-cutoff, unmapped
++ paste({ id: 5, slug: 'wild-moment-5555', user_id: 109, type: 'screenshot', content: '', screenshot_url: 'https://openvibe.media/p/wild-moment-5555/screenshot', metadata: { ai_moment: true, stream_id: 77 }, ai_summary: 'hype' }),
++ paste({ id: 7, slug: 'epic-frame-7777', user_id: 109, type: 'screenshot', screenshot_url: 'https://openvibe.media/p/epic-frame-7777/screenshot', stream_id: 12, metadata: { original_name: 'ai-moment-vod31-340.jpg', mime_type: 'image/jpeg' } }),
++ paste({ id: 8, slug: 'gold-gem-88', user_id: 777, created_at: PRE, updated_at: PRE, visibility: 'private' }), // pre-cutoff, no mapping at all
++ paste({ id: 9, slug: 'gray-dove-99', user_id: null, title: 'anon' }),
++ ];
++ const comments = [
++ { id: 11, paste_id: 1, user_id: 107, parent_id: null, anon_name: null, message: 'nice', is_deleted: 0, created_at: POST, updated_at: POST },
++ { id: 12, paste_id: 1, user_id: null, parent_id: 11, anon_name: 'Visitor', message: 'agreed', is_deleted: 0, created_at: POST, updated_at: POST },
++ { id: 13, paste_id: 1, user_id: 555, parent_id: null, anon_name: null, message: 'who am i', is_deleted: 0, created_at: POST, updated_at: POST },
++ { id: 14, paste_id: 99, user_id: 107, parent_id: null, anon_name: null, message: 'orphan', is_deleted: 0, created_at: POST, updated_at: POST },
++ ];
++ const likes = [
++ { paste_id: 1, user_id: 107, created_at: POST },
++ { paste_id: 1, user_id: 555, created_at: POST },
++ { paste_id: 1, user_id: 9, created_at: PRE },
++ ];
++ const bundle = seal({ pastes, comments, likes });
++
++ await check('refuses a bundle whose format, version, counts or sha256 do not check out', async () => {
++ for (const [mutate, re] of [
++ [(b) => { b.sha256 = '0'.repeat(64); }, /sha256 mismatch/],
++ [(b) => { b.counts.pastes += 1; }, /count mismatch for pastes/],
++ [(b) => { b.format = 'something-else'; }, /unexpected format/],
++ [(b) => { b.version = 2; }, /unsupported version/],
++ [(b) => { b.pastes[0].title = 'tampered'; }, /sha256 mismatch/],
++ ]) {
++ const b = JSON.parse(JSON.stringify(bundle));
++ mutate(b);
++ await assert.rejects(run(b), (err) => err instanceof ImportError && re.test(err.message));
++ }
++ assert.strictEqual(count('pastes'), 0);
++ });
++
++ await check('--dry-run reports everything and leaves the database untouched', async () => {
++ const r = await run(bundle, { dryRun: true });
++ assert.strictEqual(r.dry_run, true);
++ assert.strictEqual(r.pastes.inserted, 9);
++ for (const t of ['pastes', 'paste_comments', 'paste_likes', 'import_hold', 'legacy_id_map', 'migration_runs']) assert.strictEqual(count(t), 0, t);
++ });
++
++ let first;
++ await check('import: counters, timestamps, visibility, NSFW and AI fields kept; owners mapped across the cutoff', async () => {
++ first = await run(bundle);
++ assert.deepStrictEqual(first.pastes, { bundle: 9, inserted: 9, updated: 0, unchanged: 0, held: 3, skipped: 0, ai: 2, forks_remapped: 1, forks_unresolved: 0 });
++ const one = bySlug('amber-fox-11');
++ assert.strictEqual(one.owner_subject, A.subject_id);
++ assert.strictEqual(one.legacy_media_id, 1);
++ assert.strictEqual(one.legacy_user_id, 107);
++ assert.strictEqual(one.views, 50);
++ assert.strictEqual(one.unique_views, 40);
++ assert.strictEqual(one.copies, 3);
++ assert.strictEqual(one.likes, 2);
++ assert.strictEqual(one.is_nsfw, 1);
++ assert.strictEqual(one.ai_tags, '["hi"]');
++ assert.strictEqual(one.created_at, POST);
++ assert.strictEqual(one.updated_at, '2026-09-03 00:00:00');
++ assert.strictEqual(one.origin, 'user');
++ assert.strictEqual(bySlug('blue-lake-22').owner_subject, A.subject_id, 'pre-cutoff network id');
++ assert.strictEqual(bySlug('blue-lake-22').visibility, 'unlisted');
++ assert.strictEqual(bySlug('cold-ridge-33').owner_subject, null, 'ambiguous');
++ assert.strictEqual(bySlug('cold-ridge-33').legacy_user_id, 9);
++ assert.strictEqual(bySlug('dark-owl-44').owner_subject, null, 'unmapped');
++ assert.strictEqual(bySlug('gold-gem-88').owner_subject, null);
++ assert.strictEqual(bySlug('gray-dove-99').owner_subject, null, 'anonymous is not a hold');
++ assert.strictEqual(bySlug('fork-of-one-66').forked_from, one.id, 'forked_from remapped to the Community id');
++ });
++
++ await check('AI moment pastes: origin ai, no owner, stream in stream_ref, screenshot link kept, no hold', async () => {
++ const ai = bySlug('wild-moment-5555');
++ assert.strictEqual(ai.origin, 'ai');
++ assert.strictEqual(ai.owner_subject, null);
++ assert.strictEqual(ai.legacy_user_id, 109);
++ assert.deepStrictEqual(JSON.parse(ai.stream_ref), { service: 'live', type: 'stream', id: '77' });
++ assert.strictEqual(ai.screenshot_url, 'https://openvibe.media/p/wild-moment-5555/screenshot');
++ assert.strictEqual(ai.media_ref, 'legacy:live:paste:5');
++ const byName = bySlug('epic-frame-7777');
++ assert.strictEqual(byName.origin, 'ai', 'recognised by the AI frame file name Media kept');
++ assert.deepStrictEqual(JSON.parse(byName.stream_ref), { service: 'live', type: 'stream', id: '12' });
++ assert.ok(!holds().some((h) => h.source_id === '5' || h.source_id === '7'));
++ });
++
++ await check('holds: ambiguous / unmapped owners, unmappable likes and orphan comments', async () => {
++ assert.deepStrictEqual(holds(), [
++ { source_type: 'comment', source_id: '13', reason: 'owner_unmapped' },
++ { source_type: 'comment', source_id: '14', reason: 'paste_missing' },
++ { source_type: 'like', source_id: '1:555', reason: 'owner_unmapped' },
++ { source_type: 'like', source_id: '1:9', reason: 'ambiguous_owner' },
++ { source_type: 'paste', source_id: '3', reason: 'ambiguous_owner' },
++ { source_type: 'paste', source_id: '4', reason: 'owner_unmapped' },
++ { source_type: 'paste', source_id: '8', reason: 'owner_unmapped' },
++ ]);
++ const detail = JSON.parse(db.prepare("SELECT detail FROM import_hold WHERE source_type = 'paste' AND source_id = '3'").get().detail);
++ assert.strictEqual(detail.live_subject, C.subject_id);
++ assert.strictEqual(detail.network_subject, B.subject_id);
++ assert.deepStrictEqual(first.holds, { ambiguous_owner: 2, owner_unmapped: 4, paste_missing: 1 });
++ });
++
++ await check('comments and likes: authors mapped, reply parents remapped, only mappable likes imported', async () => {
++ const cs = db.prepare('SELECT * FROM paste_comments ORDER BY legacy_media_id').all();
++ assert.strictEqual(cs.length, 3);
++ const [c11, c12, c13] = cs;
++ assert.strictEqual(c11.author_subject, A.subject_id);
++ assert.strictEqual(c12.anon_name, 'Visitor');
++ assert.strictEqual(c12.parent_id, c11.id);
++ assert.strictEqual(c13.author_subject, null);
++ assert.deepStrictEqual(first.comments, { bundle: 4, inserted: 3, updated: 0, unchanged: 0, held: 1, skipped: 1 });
++ const ls = db.prepare('SELECT subject_id FROM paste_likes').all();
++ assert.deepStrictEqual(ls, [{ subject_id: A.subject_id }]);
++ assert.deepStrictEqual(first.likes, { bundle: 3, inserted: 1, updated: 0, unchanged: 0, held: 2, skipped: 0 });
++ });
++
++ await check('mappings and the run are recorded', async () => {
++ const m = (sys, type, id) => db.prepare('SELECT target_type, target_id FROM legacy_id_map WHERE source_system = ? AND source_type = ? AND source_id = ?').get(sys, type, id);
++ assert.deepStrictEqual(m('media', 'paste', '1'), { target_type: 'paste', target_id: String(bySlug('amber-fox-11').id) });
++ assert.deepStrictEqual(m('live', 'user', '107'), { target_type: 'subject', target_id: A.subject_id });
++ assert.deepStrictEqual(m('network', 'user', '7'), { target_type: 'subject', target_id: A.subject_id });
++ assert.ok(m('media', 'comment', '12'));
++ const runs = db.prepare('SELECT * FROM migration_runs').all();
++ assert.strictEqual(runs.length, 1);
++ assert.strictEqual(JSON.parse(runs[0].report).pastes.inserted, 9);
++ assert.ok(runs[0].finished_at);
++ });
++
++ await check('re-running the same bundle changes nothing', async () => {
++ const before = { pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') };
++ const again = await run(bundle);
++ assert.deepStrictEqual([again.pastes.inserted, again.pastes.updated, again.pastes.unchanged], [0, 0, 9]);
++ assert.deepStrictEqual([again.comments.inserted, again.comments.updated, again.comments.unchanged], [0, 0, 3]);
++ assert.deepStrictEqual([again.likes.inserted, again.likes.unchanged, again.likes.held], [0, 1, 2]);
++ assert.deepStrictEqual({ pastes: count('pastes'), comments: count('paste_comments'), likes: count('paste_likes'), holds: count('import_hold') }, before);
++ assert.strictEqual(count('migration_runs'), 2);
++ });
++
++ await check('a newer bundle adds and updates; Community-side edits, counts and deletes are not undone; late mappings clear holds', async () => {
++ // Community-side activity since the first import.
++ db.prepare("UPDATE pastes SET views = 80 WHERE slug = 'amber-fox-11'").run();
++ const store = require('../server/pastes/store');
++ store.updatePaste(db, bySlug('blue-lake-22').id, { content: 'edited in Community' }, A.subject_id);
++ store.softDelete(db, bySlug('gray-dove-99').id);
++ // Someone registered live user 555 with the Network in the meantime.
++ const D = net.addUser({ network_user_id: 40, username: 'dana' });
++ net.directory.legacy.live[555] = D.subject_id;
++
++ const newer = seal({
++ pastes: pastes.map((p) => (p.id === 1 ? { ...p, title: 'One (renamed on Live)', views: 60, updated_at: '2026-09-10 00:00:00' }
++ : p.id === 2 ? { ...p, content: 'changed on Live' } : p))
++ .concat([paste({ id: 10, slug: 'new-wave-10', user_id: 109, created_at: '2026-09-20 00:00:00', updated_at: '2026-09-20 00:00:00' })]),
++ comments: comments.concat([{ id: 15, paste_id: 10, user_id: 109, parent_id: null, anon_name: null, message: 'fresh', is_deleted: 0, created_at: POST, updated_at: POST }]),
++ likes,
++ });
++ const r = await run(newer);
++ assert.strictEqual(r.pastes.inserted, 1);
++ assert.strictEqual(r.comments.inserted, 1);
++ const one = bySlug('amber-fox-11');
++ assert.strictEqual(one.title, 'One (renamed on Live)');
++ assert.strictEqual(one.views, 80, 'counters never go down');
++ assert.strictEqual(bySlug('blue-lake-22').content, 'edited in Community', 'local edits win');
++ assert.ok(bySlug('gray-dove-99').deleted_at, 'deleted stays deleted');
++ assert.strictEqual(bySlug('new-wave-10').owner_subject, B.subject_id);
++ assert.strictEqual(bySlug('dark-owl-44').owner_subject, D.subject_id, 'late mapping fills the owner');
++ assert.ok(!holds().some((h) => h.source_type === 'paste' && h.source_id === '4'), 'and clears its hold');
++ assert.ok(!holds().some((h) => h.source_type === 'like' && h.source_id === '1:555'));
++ assert.strictEqual(db.prepare('SELECT COUNT(*) AS c FROM paste_likes').get().c, 2);
++ assert.strictEqual(db.prepare("SELECT author_subject FROM paste_comments WHERE legacy_media_id = 13").get().author_subject, D.subject_id);
++ assert.strictEqual(r.pastes.updated, 2, 'the renamed paste and the newly owned one');
++ });
++
++ await check('CLI: exit 2 on a refused bundle, dry run prints the report and writes nothing', async () => {
++ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'community-import-'));
++ const dbPath = path.join(dir, 'c.db');
++ const good = path.join(dir, 'good.json');
++ const bad = path.join(dir, 'bad.json');
++ fs.writeFileSync(good, JSON.stringify(bundle));
++ fs.writeFileSync(bad, JSON.stringify({ ...bundle, sha256: 'f'.repeat(64) }));
++ const env = { ...process.env, COMMUNITY_DB_PATH: dbPath, OV_NETWORK_INTERNAL_URL: net.url, OV_NETWORK_URL: net.url, OV_OAUTH_CLIENT_ID: 'community', OV_OAUTH_CLIENT_SECRET: 'shh' };
++ const script = path.join(__dirname, '..', 'scripts', 'import-pastes.js');
++ const refused = spawnSync(process.execPath, [script, bad], { env, encoding: 'utf8' });
++ assert.strictEqual(refused.status, 2, refused.stderr);
++ assert.match(refused.stderr, /Refused: sha256 mismatch/);
++ // Run the child asynchronously: the mock Network lives in this process's event loop.
++ const { spawn } = require('child_process');
++ const out = await new Promise((resolve) => {
++ const child = spawn(process.execPath, [script, good, '--dry-run', '--id-fix-cutoff', '2026-08-20T03:00:26Z'], { env });
++ let stdout = '', stderr = '';
++ child.stdout.on('data', (c) => { stdout += c; });
++ child.stderr.on('data', (c) => { stderr += c; });
++ child.on('close', (code) => resolve({ code, stdout, stderr }));
++ });
++ assert.strictEqual(out.code, 0, out.stderr);
++ assert.match(out.stdout, /DRY RUN — rolled back/);
++ // Live user 555 is mapped by now (previous check), so two pastes are held, not three.
++ assert.match(out.stdout, /pastes\s+9\s+9\s+0\s+0\s+2\s+0/, out.stdout);
++ const check2 = openDb(dbPath);
++ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM pastes').get().c, 0);
++ assert.strictEqual(check2.prepare('SELECT COUNT(*) AS c FROM subject_projection').get().c, 0, 'not even the display cache');
++ check2.close();
++ fs.rmSync(dir, { recursive: true, force: true });
++ });
++
++ db.close();
++ await net.close();
++ done();
++})();
ddiff --git a/test/pastes-api.test.js b/test/pastes-api.test.js
nnew file mode 100644
iindex 0000000..59caed2
---- /dev/null
++++ b/test/pastes-api.test.js
@@@ -0,0 +1,323 @@
++'use strict';
++/**
++ * Native /api/pastes (PASTES_AUTHORITY=community): who may call what.
++ * Browser JWTs, anonymous callers and service tokens (capabilities, X-OV-Subject / X-OV-Origin /
++ * X-OV-Source-Ref / X-OV-Staff), the AI work queue, the anonymous write limit, author display via
++ * the Network's resolve-batch, screenshot uploads to Media, raw text and screenshot links.
++ */
++const assert = require('assert');
++const crypto = require('crypto');
++const { ids } = require('openvibe-contracts');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });
++ const sam = net.addUser({ network_user_id: 9, username: 'sam', display_name: 'Sam' });
++ const kim = net.addUser({ network_user_id: 11, username: 'kim', display_name: 'Kim', avatar_url: '/avatar/kim' });
++ const alexJwt = net.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex', role: 'user', profile_color: '#0af' });
++ const samOldJwt = net.sign({ id: 9, username: 'sam', display_name: 'Sam', role: 'user' }); // pre-subject_id token
++ const adminJwt = net.sign({ id: 1, subject_id: ids.newId('user'), username: 'boss', display_name: 'Boss', role: 'admin' });
++ const svc = (cap, extra = {}) => net.signService({ cap, ...extra });
++ const CREATE = 'community.paste.create', WRITE = 'community.paste.write', MOD = 'community.paste.moderate';
++
++ const call = (path, { method = 'GET', token, cookie, headers = {}, json, body, ip } = {}) => {
++ const h = { ...headers };
++ if (token) h.authorization = `Bearer ${token}`;
++ if (ip) h['x-forwarded-for'] = ip;
++ let b = body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; b = JSON.stringify(json); }
++ return t.get(path, { method, headers: h, body: b, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++
++ let alexSlug;
++
++ await check('browser create: owner from the JWT subject; body identity fields and X-OV-* headers ignored', async () => {
++ const r = await call('/api/pastes', {
++ method: 'POST', cookie: alexJwt, ip: '192.0.2.10',
++ headers: { 'x-ov-subject': sam.subject_id, 'x-ov-origin': 'ai' },
++ json: { title: 'Mine', content: 'print(1)', language: 'python', user_id: 9, owner_subject: sam.subject_id, author_id: 9, origin: 'ai', slug: 'my-chosen-slug' },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ const out = r.json();
++ assert.ok(out.id && out.slug && out.url === `/p/${out.slug}` && out.paste);
++ assert.notStrictEqual(out.slug, 'my-chosen-slug', 'browsers never pick slugs');
++ assert.strictEqual(out.paste.owner_subject, alex.subject_id);
++ assert.strictEqual(out.paste.origin, 'user');
++ assert.strictEqual(out.paste.username, 'alex');
++ assert.strictEqual(out.paste.profile_color, '#0af', 'JWT claims feed the projection');
++ alexSlug = out.slug;
++ });
++
++ await check('an older JWT without subject_id is resolved through the Network once, then cached', async () => {
++ const before = net.resolveCalls.length;
++ const r = await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'hello from sam' } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().paste.owner_subject, sam.subject_id);
++ const lookups = net.resolveCalls.slice(before);
++ assert.deepStrictEqual(lookups[0], { system: 'network', type: 'user', ids: ['9'] });
++ await call('/api/pastes', { method: 'POST', token: samOldJwt, json: { content: 'again' } });
++ assert.strictEqual(net.resolveCalls.slice(before).filter((b) => b.system === 'network').length, 1, 'cached in legacy_id_map');
++ const grant = net.grants.find((g) => g.grant_type === 'client_credentials' && g.audience === 'openvibe.network');
++ assert.strictEqual(grant.scope, 'identity.subject.resolve');
++ });
++
++ await check('service token: create as the acting subject (X-OV-Subject)', async () => {
++ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { content: 'via live', user_id: 9 } });
++ assert.strictEqual(r.status, 201, r.text);
++ assert.strictEqual(r.json().paste.owner_subject, alex.subject_id);
++ });
++
++ await check('service token denials: wrong audience, bad signature, expired, missing capability, bad X-OV-Subject', async () => {
++ const wrongAud = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { aud: ['openvibe.live'] }), json: { content: 'x' } });
++ assert.strictEqual(wrongAud.status, 401);
++ assert.strictEqual(wrongAud.json().code, 'token.wrong_audience');
++ assert.match(wrongAud.headers.get('content-type'), /application\/problem\+json/);
++ const otherKey = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey.export({ type: 'pkcs8', format: 'pem' });
++ const forged = await call('/api/pastes', { method: 'POST', token: svc([CREATE], { key: otherKey }), json: { content: 'x' } });
++ assert.strictEqual(forged.status, 401);
++ assert.strictEqual(forged.json().code, 'token.bad_signature');
++ const expired = await call('/api/pastes', { token: svc([CREATE], { expSec: -120 }) });
++ assert.strictEqual(expired.json().code, 'token.expired');
++ const noCap = await call('/api/pastes', { method: 'POST', token: svc([WRITE]), json: { content: 'x' } });
++ assert.strictEqual(noCap.status, 403);
++ assert.strictEqual(noCap.json().code, 'capability.denied');
++ const noPasteCaps = await call('/api/pastes', { token: svc(['chat.message.send']) });
++ assert.strictEqual(noPasteCaps.status, 403, 'a token for something else is not a pass to read as someone');
++ const badSubject = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': '7' }, json: { content: 'x' } });
++ assert.strictEqual(badSubject.status, 400);
++ assert.strictEqual(badSubject.json().code, 'subject.invalid');
++ const badRef = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-source-ref': '{"service":"live"}' }, json: { content: 'x' } });
++ assert.strictEqual(badRef.status, 400);
++ });
++
++ await check('service without X-OV-Subject writes anonymously; X-OV-Origin: ai → origin ai, no owner, stream ref kept', async () => {
++ const anon = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'nobody' } });
++ assert.strictEqual(anon.json().paste.owner_subject, null);
++ assert.strictEqual(anon.json().paste.origin, 'user');
++ const ref = { service: 'live', type: 'stream', id: '42' };
++ const ai = await call('/api/pastes', {
++ method: 'POST', token: svc([CREATE]),
++ headers: { 'x-ov-origin': 'ai', 'x-ov-subject': alex.subject_id, 'x-ov-source-ref': JSON.stringify(ref) },
++ json: { title: 'A wild moment', content: 'the chat exploded', slug: 'wild-moment-4242', metadata: { ai_moment: true, stream_id: 42 }, ai_summary: 'chat exploded', ai_tags: ['hype'] },
++ });
++ assert.strictEqual(ai.status, 201, ai.text);
++ const p = ai.json().paste;
++ assert.strictEqual(p.slug, 'wild-moment-4242', 'services may bring their own slug');
++ assert.strictEqual(p.origin, 'ai');
++ assert.strictEqual(p.owner_subject, null, 'AI output is never attributed to a person');
++ assert.strictEqual(p.user_id, null);
++ assert.strictEqual(p.display_name, 'OpenVibe AI');
++ assert.deepStrictEqual(p.stream_ref, ref);
++ assert.strictEqual(p.stream_id, 42);
++ assert.deepStrictEqual(JSON.parse(p.metadata), { ai_moment: true, stream_id: 42 });
++ assert.strictEqual(p.ai_summary, 'chat exploded');
++ const dup = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'wild-moment-4242' } });
++ assert.strictEqual(dup.status, 409);
++ const bad = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), json: { content: 'y', slug: 'config' } });
++ assert.strictEqual(bad.status, 400);
++ const onlyAi = (await call('/api/pastes?origin=ai')).json();
++ assert.ok(onlyAi.pastes.length >= 1 && onlyAi.pastes.every((x) => x.origin === 'ai'));
++ const browserMeta = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'meta?', metadata: { ai_moment: true } } });
++ assert.strictEqual(browserMeta.json().paste.metadata, null, 'browsers cannot set metadata');
++ });
++
++ await check('write capability: edits as the acting subject are owner-checked by Community', async () => {
++ const asAlex = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'Edited via Live' } });
++ assert.strictEqual(asAlex.status, 200, asAlex.text);
++ assert.strictEqual(asAlex.json().paste.title, 'Edited via Live');
++ const asSam = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([WRITE]), headers: { 'x-ov-subject': sam.subject_id }, json: { title: 'hijack' } });
++ assert.strictEqual(asSam.status, 403);
++ assert.deepStrictEqual(asSam.json(), { error: 'Not authorized for this paste' });
++ const noSubject = await call(`/api/pastes/${alexSlug}`, { method: 'DELETE', token: svc([WRITE]) });
++ assert.strictEqual(noSubject.status, 401);
++ const createOnly = await call(`/api/pastes/${alexSlug}`, { method: 'PUT', token: svc([CREATE]), headers: { 'x-ov-subject': alex.subject_id }, json: { title: 'x' } });
++ assert.strictEqual(createOnly.status, 403);
++ assert.strictEqual(createOnly.json().code, 'capability.denied');
++ });
++
++ await check('staff: X-OV-Staff needs community.paste.moderate; with it a service may delete anyone\'s comment', async () => {
++ const c = (await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', token: samOldJwt, json: { message: 'spam spam' } })).json().comment;
++ assert.strictEqual(c.username, 'sam');
++ const notStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
++ assert.strictEqual(notStaff.status, 403);
++ const fakeStaff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE]), headers: { 'x-ov-staff': '1' } });
++ assert.strictEqual(fakeStaff.status, 403);
++ assert.strictEqual(fakeStaff.json().code, 'capability.denied');
++ const staff = await call(`/api/pastes/${alexSlug}/comments/${c.id}`, { method: 'DELETE', token: svc([WRITE, MOD]), headers: { 'x-ov-staff': '1', 'x-ov-subject': kim.subject_id } });
++ assert.strictEqual(staff.status, 200, staff.text);
++ assert.deepStrictEqual(staff.json(), { message: 'Comment deleted' });
++ const bulkUser = await call('/api/pastes/bulk', { method: 'POST', cookie: alexJwt, json: { slugs: [alexSlug], action: 'private' } });
++ assert.strictEqual(bulkUser.status, 403);
++ const bulkAdmin = await call('/api/pastes/admin/stats', { cookie: adminJwt });
++ assert.strictEqual(bulkAdmin.status, 200);
++ assert.ok(bulkAdmin.json().stats.total > 0);
++ const bulkSvc = await call('/api/pastes/admin/stats', { token: svc([WRITE]) });
++ assert.strictEqual(bulkSvc.status, 403);
++ });
++
++ await check('AI work queue: needs_ai + POST /:slug/ai for moderate service tokens only', async () => {
++ const hidden = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'hidden work', visibility: 'private' } })).json().slug;
++ const q = await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) });
++ assert.strictEqual(q.status, 200, q.text);
++ const queue = q.json().pastes.map((p) => p.slug);
++ assert.ok(queue.includes(hidden), 'the queue spans visibility');
++ assert.ok(!queue.includes('wild-moment-4242'), 'already annotated');
++ for (const who of [{ cookie: adminJwt }, {}, { token: svc([WRITE, CREATE]) }]) {
++ const denied = await call('/api/pastes?needs_ai=1', who);
++ assert.strictEqual(denied.status, 403, `needs_ai refused for ${JSON.stringify(Object.keys(who))}`);
++ const write = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', json: { ai_summary: 'x' }, ...who });
++ assert.strictEqual(write.status, 403);
++ }
++ const w = await call(`/api/pastes/${hidden}/ai`, { method: 'POST', token: svc([MOD]), json: { ai_summary: 'a secret note', ai_tags: ['notes', 'private'] } });
++ assert.strictEqual(w.status, 200, w.text);
++ assert.strictEqual(w.json().ok, true);
++ assert.strictEqual(w.json().paste.ai_summary, 'a secret note');
++ assert.strictEqual(w.json().paste.ai_tags, '["notes","private"]');
++ assert.ok(w.json().paste.ai_analyzed_at);
++ const again = (await call('/api/pastes?needs_ai=1&limit=200', { token: svc([MOD]) })).json().pastes.map((p) => p.slug);
++ assert.ok(!again.includes(hidden));
++ });
++
++ await check('author display: unknown subjects are resolved through resolve-batch; stale ones refresh', async () => {
++ const before = net.resolveCalls.length;
++ const r = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': kim.subject_id }, json: { content: 'kim was here' } });
++ const p = r.json().paste;
++ assert.strictEqual(p.username, 'kim');
++ assert.strictEqual(p.display_name, 'Kim');
++ assert.strictEqual(p.avatar_url, `${net.url}/avatar/kim`, 'relative Network avatars made absolute');
++ assert.ok(net.resolveCalls.slice(before).some((b) => Array.isArray(b.subject_ids) && b.subject_ids.includes(kim.subject_id)));
++ kim.display_name = 'Kim Renamed';
++ t.db.prepare("UPDATE subject_projection SET refreshed_at = datetime('now', '-1 day') WHERE subject_id = ?").run(kim.subject_id);
++ const stale = await call(`/api/pastes/${p.slug}?no_view=1`);
++ assert.strictEqual(stale.json().paste.display_name, 'Kim', 'stale entry served while it refreshes');
++ await new Promise((res) => setTimeout(res, 100));
++ const fresh = await call(`/api/pastes/${p.slug}?no_view=1`);
++ assert.strictEqual(fresh.json().paste.display_name, 'Kim Renamed');
++ net.directory.down = true;
++ const ghost = ids.newId('user');
++ const down = await call('/api/pastes', { method: 'POST', token: svc([CREATE]), headers: { 'x-ov-subject': ghost }, json: { content: 'network down' } });
++ assert.strictEqual(down.status, 201, 'a Network outage costs names, not the write');
++ assert.strictEqual(down.json().paste.username, null);
++ net.directory.down = false;
++ });
++
++ await check('anonymous writes: 20 per 10 minutes per address; signed-in and other addresses unaffected', async () => {
++ const ip = '203.0.113.77';
++ for (let i = 0; i < 20; i++) {
++ const r = await call('/api/pastes', { method: 'POST', ip, json: { content: `anon ${i}` } });
++ assert.strictEqual(r.status, 201, `#${i}: ${r.text}`);
++ }
++ const over = await call('/api/pastes', { method: 'POST', ip, json: { content: 'one too many' } });
++ assert.strictEqual(over.status, 429);
++ assert.deepStrictEqual(over.json(), { error: 'Too many anonymous posts — sign in or try again later' });
++ const comment = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip, json: { message: 'also limited' } });
++ assert.strictEqual(comment.status, 429, 'comments share the budget');
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, cookie: alexJwt, json: { content: 'signed in' } })).status, 201);
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip: '203.0.113.78', json: { content: 'elsewhere' } })).status, 201);
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', ip, token: svc([CREATE]), json: { content: 'service' } })).status, 201);
++ });
++
++ // A JPEG whose APP1 segment carries EXIF (with a fake GPS marker).
++ const jpeg = Buffer.concat([
++ Buffer.from([0xff, 0xd8]),
++ Buffer.from([0xff, 0xe0, 0x00, 0x10]), Buffer.from('JFIF\0\x01\x01\0\0\x01\0\x01\0\0', 'latin1'),
++ Buffer.from([0xff, 0xe1, 0x00, 0x17]), Buffer.from('Exif\0\0GPS-51.5N-0.12W', 'latin1'),
++ Buffer.from([0xff, 0xdb, 0x00, 0x04, 0x00, 0x00]),
++ Buffer.from([0xff, 0xda, 0x00, 0x04, 0x00, 0x00]), Buffer.from('pixels', 'latin1'), Buffer.from([0xff, 0xd9]),
++ ]);
++ let shotSlug;
++
++ await check('screenshot upload: metadata stripped, bytes stored in Media with a service token, URL + media_ref kept', async () => {
++ const fd = new FormData();
++ fd.append('title', 'My desk');
++ fd.append('description', 'look');
++ fd.append('page_url', 'https://example.com/x');
++ fd.append('user_id', '9');
++ fd.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'desk.jpg');
++ const r = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: fd });
++ assert.strictEqual(r.status, 201, r.text);
++ const out = r.json();
++ const up = t.media.uploads[t.media.uploads.length - 1];
++ assert.ok(!up.bytes.includes(Buffer.from('GPS')), 'EXIF never leaves Community');
++ assert.ok(up.bytes.includes(Buffer.from('pixels')) && up.bytes.includes(Buffer.from('JFIF')));
++ assert.strictEqual(up.mime, 'image/jpeg');
++ assert.ok(up.claims.cap.includes('media.object.upload'));
++ const p = out.paste;
++ assert.strictEqual(out.slug, p.slug);
++ assert.strictEqual(p.type, 'screenshot');
++ assert.strictEqual(p.owner_subject, alex.subject_id);
++ assert.strictEqual(p.screenshot_url, `https://openvibe.media/f/${up.key}`);
++ assert.strictEqual(p.title, 'My desk');
++ assert.strictEqual(p.content, 'look');
++ const meta = JSON.parse(p.metadata);
++ assert.strictEqual(meta.page_url, 'https://example.com/x');
++ assert.strictEqual(meta.original_name, 'desk.jpg');
++ const row = t.db.prepare('SELECT media_ref FROM pastes WHERE slug = ?').get(p.slug);
++ assert.strictEqual(row.media_ref, `legacy:community:file:${up.key}`);
++ shotSlug = p.slug;
++ const viaCreate = new FormData();
++ viaCreate.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'b.jpg');
++ const r2 = await call('/api/pastes', { method: 'POST', cookie: alexJwt, body: viaCreate });
++ assert.strictEqual(r2.status, 201, 'multipart POST / creates a screenshot paste too');
++ assert.strictEqual(r2.json().paste.title, 'Screenshot');
++ const bad = new FormData();
++ bad.append('screenshot', new Blob(['hello'], { type: 'text/plain' }), 'a.txt');
++ assert.strictEqual((await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: bad })).status, 400);
++ t.media.failNext();
++ const again = new FormData();
++ again.append('screenshot', new Blob([jpeg], { type: 'image/jpeg' }), 'c.jpg');
++ const failed = await call('/api/pastes/screenshot', { method: 'POST', cookie: alexJwt, body: again });
++ assert.strictEqual(failed.status, 502);
++ assert.deepStrictEqual(failed.json(), { error: 'Media service unavailable' });
++ });
++
++ await check('/p/:slug/raw is served from the store (nosniff, private rules); /p/:slug/screenshot → stored URL', 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');
++ assert.strictEqual(raw.headers.get('x-content-type-options'), 'nosniff');
++ assert.strictEqual(raw.text, 'print(1)');
++ const priv = (await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { content: 'eyes only', visibility: 'private' } })).json().slug;
++ assert.strictEqual((await call(`/p/${priv}/raw`)).status, 404);
++ assert.strictEqual((await call(`/p/${priv}/raw`, { cookie: alexJwt })).text, 'eyes only');
++ const api = await call(`/api/pastes/${alexSlug}/raw`);
++ 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\/f\//);
++ 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;
++ assert.strictEqual((await call(`/p/${burn}/raw`)).text, 'once');
++ assert.strictEqual((await call(`/p/${burn}/raw`)).status, 410);
++ });
++
++ await check('likes need a person; comments allow anonymous names; set-avatar is the Network\'s; unknown routes 404', async () => {
++ const like = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST' });
++ assert.strictEqual(like.status, 401);
++ assert.deepStrictEqual(like.json(), { error: 'Authentication required' });
++ const liked = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: samOldJwt });
++ assert.deepStrictEqual(liked.json(), { liked: true, likes: 1 });
++ const viaSvc = await call(`/api/pastes/${alexSlug}/like`, { method: 'POST', token: svc([WRITE]), headers: { 'x-ov-subject': kim.subject_id } });
++ assert.deepStrictEqual(viaSvc.json(), { liked: true, likes: 2 });
++ const c = await call(`/api/pastes/${alexSlug}/comments`, { method: 'POST', ip: '198.51.100.40', json: { message: 'nice', anon_name: 'Visitor' } });
++ assert.strictEqual(c.status, 201, c.text);
++ assert.strictEqual(c.json().comment.anon_name, 'Visitor');
++ const list = (await call(`/api/pastes/${alexSlug}/comments`)).json();
++ assert.ok(list.comments.some((x) => x.message === 'nice' && x.user_id === null));
++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/comments/${c.json().comment.id}`, { method: 'DELETE' })).status, 401);
++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/set-avatar`, { method: 'POST', cookie: alexJwt })).status, 501);
++ assert.strictEqual((await call(`/api/pastes/${alexSlug}/nope/deeper`)).status, 404);
++ const cfg = (await call('/api/pastes/config', { cookie: alexJwt })).json();
++ assert.strictEqual(cfg.maxSizeKb, 512);
++ assert.ok(cfg.todayCount > 0);
++ const malformed = await call('/api/pastes', { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{nope' });
++ assert.strictEqual(malformed.status, 400);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/ssr-community.test.js b/test/ssr-community.test.js
nnew file mode 100644
iindex 0000000..341b40d
---- /dev/null
++++ b/test/ssr-community.test.js
@@@ -0,0 +1,133 @@
++'use strict';
++/**
++ * Server-rendered pages with PASTES_AUTHORITY=community: everything reads from Community's own
++ * store (never from OpenVibe.Live), with the same visibility rules as the API.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++
++(async () => {
++ const t = await boot({ authority: 'community', pasteLimits: { cooldownSeconds: 0 } });
++ const has = (html, s, msg) => assert.ok(html.includes(s), msg || `expected to find: ${s}`);
++ const svc = t.app.locals.pastes;
++ const alex = t.network.addUser({ network_user_id: 7, username: 'alex', display_name: 'Alex', avatar_url: 'https://openvibe.media/avatars/alex.png' });
++ const token = t.network.sign({ id: 7, subject_id: alex.subject_id, username: 'alex', display_name: 'Alex' });
++ const asAlex = { kind: 'user', subject: alex.subject_id, staff: false, origin: 'user' };
++ const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
++
++ const js = (await svc.createText(asAlex, { title: 'Hello world in JavaScript', content: 'const greet = (name) => `Hi ${name}`;\n', language: 'javascript' })).slug;
++ const nginx = (await svc.createText(anon, { title: 'nginx snippet', content: 'server {\n listen 80;\n}\n', language: 'nginx' })).slug;
++ const py = (await svc.createText(asAlex, { title: 'python helper', content: 'def add(a, b):\n return a + b\n', language: 'python' })).slug;
++ const unl = (await svc.createText(asAlex, { title: 'secret notes', content: 'unlisted <script>alert(1)</script>', visibility: 'unlisted' })).slug;
++ const priv = (await svc.createText(asAlex, { title: 'private thing', content: 'private', visibility: 'private' })).slug;
++ const burn = (await svc.createText(asAlex, { title: 'burn me', content: 'once only', burn_after_read: true })).slug;
++ t.db.prepare('UPDATE pastes SET views = 900 WHERE slug = ?').run(nginx);
++ t.db.prepare("INSERT INTO pastes (slug, owner_subject, type, title, content, screenshot_url) VALUES ('fair-moon-23', ?, 'screenshot', 'Desktop shot', 'my desktop', 'https://openvibe.media/f/abc-desk.png')").run(alex.subject_id);
++
++ await check('home: latest + most viewed come from the store; nothing hidden; Live is never asked', async () => {
++ const r = await t.get('/');
++ assert.strictEqual(r.status, 200);
++ has(r.text, `href="/p/${js}"`);
++ const trendingAt = r.text.indexOf('id="trending"');
++ assert.ok(r.text.indexOf(nginx, trendingAt) < r.text.indexOf(js, trendingAt), 'most viewed first');
++ assert.ok(!r.text.includes(unl) && !r.text.includes(priv));
++ assert.strictEqual(t.live.calls.filter((c) => c.path.startsWith('/api/pastes')).length, 0);
++ });
++
++ await check('paste page: rendered from the store, view counted, author from the projection', async () => {
++ const r = await t.get(`/p/${js}`, { headers: { 'x-forwarded-for': '198.51.100.20' } });
++ assert.strictEqual(r.status, 200);
++ has(r.text, '<title>Hello world in JavaScript · OpenVibe.Community</title>');
++ has(r.text, 'class="hljs-keyword">const</span>');
++ const ld = r.text.match(/<script type="application\/ld\+json">(.*?)<\/script>/g).map((s) => JSON.parse(s.replace(/<script[^>]*>|<\/script>/g, '')));
++ assert.strictEqual(ld.find((o) => o['@type'] === 'Article').author.name, 'Alex');
++ has(r.text, 'https://openvibe.media/avatars/alex.png');
++ assert.strictEqual(t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(js).views, 1);
++ assert.ok(!r.text.includes(`data-delete="${js}"`), 'no delete button for visitors');
++ });
++
++ await check('unlisted: noindex and escaped; private: 404 for visitors, the owner gets the page and delete', async () => {
++ const u = await t.get(`/p/${unl}`);
++ has(u.text, '<meta name="robots" content="noindex,follow">');
++ has(u.text, '&lt;script&gt;alert(1)&lt;/script&gt;');
++ assert.ok(!u.text.includes('<script>alert(1)</script>'));
++ assert.strictEqual((await t.get(`/p/${priv}`)).status, 404);
++ const owner = await t.get(`/p/${priv}`, { cookies: [`ov_token=${token}`] });
++ assert.strictEqual(owner.status, 200);
++ has(owner.text, `data-delete="${priv}"`);
++ // A service token is no identity on a page.
++ const svcTok = t.network.signService({ cap: ['community.paste.write', 'community.paste.moderate'] });
++ assert.strictEqual((await t.get(`/p/${priv}`, { headers: { authorization: `Bearer ${svcTok}`, 'x-ov-subject': alex.subject_id, 'x-ov-staff': '1' } })).status, 404);
++ });
++
++ await check('burn after read: the first visitor sees it, the next gets the 410 page', async () => {
++ assert.strictEqual((await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.30' } })).status, 200);
++ const gone = await t.get(`/p/${burn}`, { headers: { 'x-forwarded-for': '198.51.100.31' } });
++ assert.strictEqual(gone.status, 410);
++ has(gone.text, 'This paste has burned');
++ });
++
++ await check('download reads the store without a view; screenshots use the stored Media URL', async () => {
++ const before = t.db.prepare('SELECT views FROM pastes WHERE slug = ?').get(py).views;
++ const dl = await t.get(`/p/${py}/download`);
++ assert.strictEqual(dl.status, 200);
++ assert.strictEqual(dl.headers.get('content-disposition'), `attachment; filename="${py}.py"`);
++ assert.ok(dl.text.startsWith('def add'));
++ assert.strictEqual(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">');
++ const dls = await t.get('/p/fair-moon-23/download');
++ assert.strictEqual(dls.headers.get('location'), 'https://openvibe.media/f/abc-desk.png');
++ });
++
++ await check('browse: search, views sort and language filter over the store', async () => {
++ const q = await t.get('/pastes?q=nginx');
++ has(q.text, `href="/p/${nginx}"`);
++ assert.ok(!q.text.includes(`href="/p/${js}"`));
++ const views = await t.get('/pastes?sort=views');
++ const res = views.text.slice(views.text.indexOf('id="results"'));
++ assert.ok(res.indexOf(nginx) < res.indexOf(js));
++ const lang = await t.get('/pastes?lang=python');
++ has(lang.text, `href="/p/${py}"`);
++ assert.ok(!lang.text.includes(`href="/p/${js}"`));
++ });
++
++ await check('/my lists the signed-in person\'s pastes, private and unlisted included', async () => {
++ const mine = await t.get('/my', { cookies: [`ov_token=${token}`] });
++ assert.strictEqual(mine.status, 200);
++ for (const slug of [js, py, unl, priv]) has(mine.text, `href="/p/${slug}"`);
++ assert.ok(!mine.text.includes(nginx));
++ });
++
++ await check('no-JS form creates in the store as the signed-in subject; anonymous posts share the 20/10min budget', async () => {
++ const body = new URLSearchParams({ title: 'via form', language: 'python', content: 'print(1)', visibility: 'private' }).toString();
++ const post = await t.get('/new', { method: 'POST', body, headers: { 'content-type': 'application/x-www-form-urlencoded' }, cookies: [`ov_token=${token}`] });
++ assert.strictEqual(post.status, 303);
++ const slug = post.headers.get('location').replace('/p/', '');
++ const row = t.db.prepare('SELECT owner_subject, visibility FROM pastes WHERE slug = ?').get(slug);
++ assert.deepStrictEqual(row, { owner_subject: alex.subject_id, visibility: 'private' });
++ const anonBody = new URLSearchParams({ content: 'hi' }).toString();
++ const ip = '203.0.113.200';
++ for (let i = 0; i < 20; i++) {
++ const r = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });
++ assert.strictEqual(r.status, 303, `#${i}`);
++ }
++ const over = await t.get('/new', { method: 'POST', body: anonBody, headers: { 'content-type': 'application/x-www-form-urlencoded', 'x-forwarded-for': ip } });
++ assert.strictEqual(over.status, 429);
++ has(over.text, 'Too many anonymous posts');
++ const api = await t.get('/api/pastes', { method: 'POST', body: JSON.stringify({ content: 'x' }), headers: { 'content-type': 'application/json', 'x-forwarded-for': ip } });
++ assert.strictEqual(api.status, 429, 'the API shares the budget');
++ });
++
++ await check('feed and sitemap list store pastes', async () => {
++ const feed = await t.get('/feed.xml');
++ has(feed.text, `/p/${js}`);
++ assert.ok(!feed.text.includes(priv));
++ const map = await t.get('/sitemap.xml');
++ has(map.text, `/p/${js}`);
++ assert.ok(!map.text.includes(unl) && !map.text.includes(priv));
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/store.test.js b/test/store.test.js
nnew file mode 100644
iindex 0000000..35e15e5
---- /dev/null
++++ b/test/store.test.js
@@@ -0,0 +1,280 @@
++'use strict';
++/**
++ * Paste store + service semantics on an in-memory database (no HTTP): create/get/list,
++ * visibility, burn-after-read, forks, likes, copies, comments and replies, deletes, versions,
++ * limits and view counting.
++ */
++const assert = require('assert');
++const { ids } = require('openvibe-contracts');
++const { openDb } = require('../server/db');
++const store = require('../server/pastes/store');
++const { createPasteService, PasteError } = require('../server/pastes/service');
++const { check, done } = require('./helpers/app');
++
++const db = openDb(':memory:');
++const svc = createPasteService({ db, config: { oauth: { clientSecret: 'test' } }, limits: { cooldownSeconds: 0 } });
++
++const alice = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++const bob = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++const mod = { kind: 'user', subject: ids.newId('user'), staff: true, origin: 'user' };
++const anon = { kind: 'anonymous', subject: null, staff: false, origin: 'user' };
++store.upsertProjection(db, { subject_id: alice.subject, username: 'alice', display_name: 'Alice', avatar_url: 'https://openvibe.media/a.png', profile_color: '#f0a' });
++
++async function rejects(p, status, message) {
++ try { await p; } catch (err) {
++ assert.ok(err instanceof PasteError, `expected PasteError, got ${err && err.stack}`);
++ assert.strictEqual(err.status, status, `status: ${JSON.stringify(err.body)}`);
++ if (message) assert.strictEqual(err.body.error, message);
++ return err;
++ }
++ throw new Error(`expected ${status}`);
++}
++
++(async () => {
++ let pub, unl, priv;
++
++ await check('create: slug style, trimmed content, language detection, owner, Media response shape', async () => {
++ const out = await svc.createText(alice, { title: ' Hello ', content: 'const x = 1;\n ', language: 'auto', visibility: 'public' });
++ assert.match(out.slug, /^[a-z]+-[a-z]+-\d{2}$/);
++ assert.deepStrictEqual(Object.keys(out), ['id', 'slug', 'url', 'paste']);
++ assert.strictEqual(out.url, `/p/${out.slug}`);
++ const p = out.paste;
++ assert.strictEqual(p.title, 'Hello');
++ assert.strictEqual(p.content, 'const x = 1;');
++ assert.strictEqual(p.language, 'javascript');
++ assert.strictEqual(p.owner_subject, alice.subject);
++ assert.strictEqual(p.user_id, alice.subject, 'user_id carries the subject id');
++ assert.strictEqual(p.origin, 'user');
++ assert.strictEqual(p.username, 'alice');
++ assert.strictEqual(p.display_name, 'Alice');
++ assert.strictEqual(p.avatar_url, 'https://openvibe.media/a.png');
++ assert.strictEqual(p.profile_color, '#f0a');
++ assert.strictEqual(p.raw_url, `/p/${out.slug}/raw`);
++ assert.strictEqual(p.is_owner, true);
++ for (const k of ['views', 'copies', 'likes', 'unique_views']) assert.strictEqual(p[k], 0);
++ pub = out.slug;
++ unl = (await svc.createText(alice, { title: 'unl', content: 'u', visibility: 'unlisted' })).slug;
++ priv = (await svc.createText(alice, { title: 'priv', content: 'p', visibility: 'private' })).slug;
++ });
++
++ await check('create: validation errors match Media', async () => {
++ await rejects(svc.createText(alice, { content: ' ' }), 400, 'Content is required');
++ await rejects(svc.createText(alice, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Paste too large (max 512 KB)');
++ const untitled = await svc.createText(alice, { content: 'y' });
++ assert.strictEqual(untitled.paste.title, 'Untitled');
++ // An anonymous private paste could never be opened: it becomes unlisted.
++ const anonPriv = await svc.createText(anon, { content: 'z', visibility: 'private' });
++ assert.strictEqual(anonPriv.paste.visibility, 'unlisted');
++ assert.strictEqual(anonPriv.paste.owner_subject, null);
++ assert.strictEqual(anonPriv.paste.user_id, null);
++ });
++
++ await check('visibility: public listed; unlisted by slug only; private owner/staff only (404 otherwise)', async () => {
++ const list = await svc.list(anon, { limit: 50 });
++ const slugs = list.pastes.map((p) => p.slug);
++ assert.ok(slugs.includes(pub));
++ assert.ok(!slugs.includes(unl) && !slugs.includes(priv));
++ assert.strictEqual((await svc.get(bob, unl, { noView: true })).paste.slug, unl);
++ await rejects(svc.get(bob, priv), 404, 'Paste not found');
++ await rejects(svc.get(anon, priv), 404, 'Paste not found');
++ assert.strictEqual((await svc.get(alice, priv)).paste.visibility, 'private');
++ assert.strictEqual((await svc.get(mod, priv, { noView: true })).paste.visibility, 'private');
++ // Private pastes can't be forked, liked, copied or commented by others either.
++ await rejects(svc.fork(bob, priv), 404);
++ await rejects(Promise.resolve().then(() => svc.like(bob, priv)), 404);
++ await rejects(Promise.resolve().then(() => svc.copy(anon, priv)), 404);
++ await rejects(svc.comments(anon, priv), 404);
++ });
++
++ await check('list: preview content (300 chars, null for screenshots), filters, owner view via username', async () => {
++ const long = await svc.createText(bob, { title: 'long one', content: 'L'.repeat(1000) });
++ store.upsertProjection(db, { subject_id: bob.subject, username: 'bob', display_name: 'Bob' });
++ const list = await svc.list(anon, { search: 'long one' });
++ assert.strictEqual(list.total, 1);
++ assert.strictEqual(list.pastes[0].content.length, 300);
++ assert.deepStrictEqual(Object.keys(list).sort(), ['limit', 'offset', 'pastes', 'total']);
++ const mine = await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 100 });
++ assert.ok(mine.pastes.some((p) => p.slug === priv), 'owner sees own private with include_unlisted');
++ const theirs = await svc.list(bob, { username: 'alice', include_unlisted: '1', limit: 100 });
++ assert.ok(!theirs.pastes.some((p) => p.slug === priv || p.slug === unl), 'others never do');
++ assert.deepStrictEqual(await svc.list(anon, { username: 'nobody' }), { pastes: [], total: 0, limit: 0, offset: 0, hasMore: false });
++ const wildcard = await svc.list(anon, { search: '%' });
++ assert.strictEqual(wildcard.total, 0, 'LIKE wildcards are literal');
++ assert.strictEqual((await svc.list(anon, { limit: 1 })).pastes.length, 1);
++ assert.strictEqual(long.paste.username, null, 'no projection yet at creation → no name (not a crash)');
++ });
++
++ await check('by-user: 404 for unknown, own hidden pastes only for the owner', async () => {
++ await rejects(svc.byUser(anon, 'ghost'), 404, 'User not found');
++ const self = await svc.byUser(alice, 'ALICE');
++ assert.strictEqual(self.username, 'alice');
++ assert.ok(self.pastes.some((p) => p.slug === priv));
++ const other = await svc.byUser(bob, 'alice');
++ assert.ok(!other.pastes.some((p) => p.slug === priv || p.slug === unl));
++ assert.strictEqual(other.total, other.pastes.length);
++ });
++
++ await check('views: counted once per visitor (cooldown), never for the owner, never for bots, not with noView', async () => {
++ const slug = (await svc.createText(alice, { content: 'views' })).slug;
++ assert.strictEqual((await svc.get(alice, slug)).paste.views, 0, 'owner');
++ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1);
++ assert.strictEqual((await svc.get(bob, slug)).paste.views, 1, 'cooldown');
++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.1' })).paste.views, 2);
++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.2', userAgent: 'Googlebot/2.1' })).paste.views, 2, 'bot');
++ assert.strictEqual((await svc.get(anon, slug, { ip: '198.51.100.3', noView: true })).paste.views, 2, 'no_view');
++ const p = (await svc.get(anon, slug, { ip: '198.51.100.3' })).paste;
++ assert.strictEqual(p.views, 3);
++ assert.strictEqual(p.unique_views, 3);
++ const visits = db.prepare('SELECT visitor FROM paste_visits WHERE paste_id = ?').all(p.id).map((r) => r.visitor);
++ assert.ok(visits.every((v) => !v.includes('198.51.100')), 'addresses are hashed, never stored');
++ });
++
++ await check('burn after read: the owner reads freely; the first other read shows it, the second burns it (410)', async () => {
++ const slug = (await svc.createText(alice, { content: 'secret', burn_after_read: true })).slug;
++ assert.strictEqual((await svc.get(alice, slug)).paste.content, 'secret');
++ const first = await svc.get(bob, slug);
++ assert.strictEqual(first.paste.content, 'secret');
++ assert.strictEqual(first.paste.burn_after_read, true);
++ await rejects(svc.get(anon, slug, { ip: '203.0.113.5' }), 410, 'This paste has been burned after reading.');
++ await rejects(svc.get(alice, slug), 404);
++ const row = db.prepare('SELECT content, deleted_at FROM pastes WHERE slug = ?').get(slug);
++ assert.strictEqual(row.content, null, 'content scrubbed');
++ assert.ok(row.deleted_at);
++ // Raw text: Media's rule — the first read serves it, the next one burns it.
++ const raw = (await svc.createText(alice, { content: 'raw secret', burn_after_read: true })).slug;
++ assert.strictEqual(svc.raw(bob, raw).content, 'raw secret');
++ await rejects(Promise.resolve().then(() => svc.raw(bob, raw)), 410);
++ });
++
++ await check('fork: text only, "Fork of …", public, forked_from set; like toggles; copy counts', async () => {
++ const f = await svc.fork(bob, pub);
++ assert.strictEqual(f.paste.title, 'Fork of Hello');
++ assert.strictEqual(f.paste.visibility, 'public');
++ assert.strictEqual(f.paste.owner_subject, bob.subject);
++ assert.strictEqual(f.paste.forked_from, (await svc.get(anon, pub, { noView: true })).paste.id);
++ db.prepare("INSERT INTO pastes (slug, type, title, screenshot_url) VALUES ('shot-only-1', 'screenshot', 'S', 'https://openvibe.media/f/x.png')").run();
++ await rejects(svc.fork(bob, 'shot-only-1'), 400, 'Only text pastes can be forked');
++ assert.deepStrictEqual(svc.like(bob, pub), { liked: true, likes: 1 });
++ assert.deepStrictEqual(svc.like(alice, pub), { liked: true, likes: 2 });
++ assert.deepStrictEqual(svc.like(bob, pub), { liked: false, likes: 1 });
++ await rejects(Promise.resolve().then(() => svc.like(anon, pub)), 401, 'Authentication required');
++ assert.strictEqual((await svc.get(alice, pub, { noView: true })).paste.liked, true);
++ assert.strictEqual((await svc.get(bob, pub, { noView: true })).paste.liked, false);
++ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 1 });
++ assert.deepStrictEqual(svc.copy(anon, pub), { copies: 2 });
++ });
++
++ await check('comments: anonymous with a cleaned name, replies one level deep, newest first, delete rules', async () => {
++ const a = (await svc.addComment(anon, pub, { message: ' hi ', anon_name: 'Guest <b>!' }, { ip: '1.2.3.4' })).comment;
++ assert.strictEqual(a.message, 'hi');
++ assert.strictEqual(a.anon_name, 'Guest b');
++ assert.strictEqual(a.user_id, null);
++ const b = (await svc.addComment(bob, pub, { message: 'first!' })).comment;
++ assert.strictEqual(b.user_id, bob.subject);
++ assert.strictEqual(b.username, 'bob');
++ const r = (await svc.addComment(alice, pub, { message: 'reply', parent_id: b.id })).comment;
++ assert.strictEqual(r.parent_id, b.id);
++ await rejects(svc.addComment(anon, pub, { message: 'deep', parent_id: r.id }, { ip: '1.2.3.5' }), 400, 'Cannot reply to a reply — reply to the original comment instead');
++ await rejects(svc.addComment(anon, pub, { message: '' }), 400, 'Comment cannot be empty');
++ await rejects(svc.addComment(anon, pub, { message: 'x'.repeat(2001) }), 400, 'Comment must be under 2000 characters');
++ await rejects(svc.addComment(anon, pub, { message: 'bad parent', parent_id: 99999 }), 400, 'Invalid parent comment');
++ const list = await svc.comments(anon, pub);
++ assert.strictEqual(list.total, 3);
++ assert.strictEqual(list.comments[0].id, b.id, 'newest top-level first');
++ assert.strictEqual(list.comments[0].reply_count, 1);
++ assert.strictEqual(list.comments[0].replies[0].username, 'alice');
++ assert.strictEqual(list.comments[0].replies[0].display_name, 'Alice');
++ // Delete: author, paste owner or staff; everyone else 403; anonymous 401.
++ await rejects(Promise.resolve().then(() => svc.deleteComment(anon, pub, b.id)), 401);
++ await rejects(Promise.resolve().then(() => svc.deleteComment({ kind: 'user', subject: ids.newId('user'), staff: false }, pub, b.id)), 403, 'Not authorized to delete this comment');
++ assert.deepStrictEqual(svc.deleteComment(bob, pub, b.id), { message: 'Comment deleted' });
++ assert.deepStrictEqual(svc.deleteComment(alice, pub, a.id), { message: 'Comment deleted' }, 'paste owner');
++ await rejects(Promise.resolve().then(() => svc.deleteComment(alice, pub, 424242)), 404, 'Comment not found');
++ assert.strictEqual((await svc.comments(anon, pub)).total, 1, 'deleted comments are hidden (the reply remains)');
++ });
++
++ await check('comment limits for people: cooldown, then duplicate', async () => {
++ const strict = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 10 } });
++ const carol = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++ await strict.addComment(carol, pub, { message: 'one' });
++ await rejects(strict.addComment(carol, pub, { message: 'two' }), 429);
++ const loose = createPasteService({ db, config: {}, limits: { commentCooldownSeconds: 0 } });
++ await loose.addComment(carol, pub, { message: 'same' });
++ await rejects(loose.addComment(carol, pub, { message: 'same' }), 400, 'Duplicate comment');
++ });
++
++ await check('paste limits for people: cooldown with seconds to wait, daily cap', async () => {
++ const strict = createPasteService({ db, config: {}, limits: { cooldownSeconds: 30, maxPerUserPerDay: 200 } });
++ const dave = { kind: 'user', subject: ids.newId('user'), staff: false, origin: 'user' };
++ await strict.createText(dave, { content: 'a' });
++ const err = await rejects(strict.createText(dave, { content: 'b' }), 429);
++ assert.ok(err.body.cooldown > 0 && err.body.cooldown <= 30);
++ assert.match(err.body.error, /^Please wait \d+s before creating another paste$/);
++ const capped = createPasteService({ db, config: {}, limits: { cooldownSeconds: 0, maxPerUserPerDay: 1 } });
++ await rejects(capped.createText(dave, { content: 'c' }), 429, 'Daily paste limit reached (1/day)');
++ // Anonymous callers aren't limited here (the per-address limiter in api.js is).
++ await strict.createText(anon, { content: 'x' });
++ await strict.createText(anon, { content: 'y' });
++ assert.deepStrictEqual(Object.keys(capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);
++ assert.strictEqual(capped.config(dave).todayCount, 1);
++ });
++
++ await check('update: owner or staff; versions appended; pinned staff-only; errors', async () => {
++ await rejects(svc.update(anon, pub, { title: 'x' }), 401, 'Authentication required');
++ await rejects(svc.update(bob, pub, { title: 'x' }), 403, 'Not authorized for this paste');
++ await rejects(svc.update(bob, priv, { title: 'x' }), 404, 'Paste not found');
++ await rejects(Promise.resolve().then(() => svc.remove(bob, priv)), 404, 'Paste not found');
++ await rejects(svc.update(alice, pub, {}), 400, 'Nothing to update');
++ await rejects(svc.update(alice, pub, { content: 'x'.repeat(512 * 1024 + 1) }), 400, 'Too large');
++ const u = await svc.update(alice, pub, { content: 'def f():\n pass', pinned: true });
++ assert.strictEqual(u.paste.language, 'python');
++ assert.strictEqual(u.paste.pinned, false, 'owners cannot pin');
++ assert.strictEqual(u.paste.revision, 2);
++ const m = await svc.update(mod, pub, { title: 'Renamed', pinned: true });
++ assert.strictEqual(m.paste.pinned, true);
++ assert.strictEqual(m.paste.revision, 3);
++ const v = svc.versions(alice, pub);
++ assert.deepStrictEqual(v.versions.map((x) => x.revision), [1, 2, 3]);
++ assert.strictEqual(v.versions[0].content, 'const x = 1;', 'original snapshotted');
++ assert.strictEqual(v.versions[1].edited_by, alice.subject);
++ assert.strictEqual(v.versions[2].edited_by, mod.subject);
++ await svc.update(alice, pub, { visibility: 'unlisted' });
++ assert.strictEqual(svc.versions(alice, pub).revision, 3, 'visibility changes are not text revisions');
++ await rejects(Promise.resolve().then(() => svc.versions(bob, unl)), 403);
++ });
++
++ await check('delete: owner or staff; soft delete keeps the slug reserved; gone from reads and lists', async () => {
++ await rejects(Promise.resolve().then(() => svc.remove(bob, unl)), 403);
++ assert.deepStrictEqual(svc.remove(alice, unl), { success: true });
++ await rejects(svc.get(alice, unl), 404);
++ assert.deepStrictEqual(svc.remove(mod, priv), { success: true }, 'staff');
++ const row = db.prepare('SELECT slug, content, deleted_at FROM pastes WHERE slug = ?').get(unl);
++ assert.ok(row && row.deleted_at && row.content === null);
++ assert.ok(!(await svc.list(alice, { username: 'alice', include_unlisted: '1', limit: 200 })).pastes.some((p) => p.slug === unl));
++ });
++
++ await check('staff tools: stats, forks, bulk, AI write-back', async () => {
++ const stats = svc.stats().stats;
++ assert.ok(stats.total > 0 && stats.forks >= 1 && stats.textPastes > 0);
++ assert.ok(svc.forks({}).forks.length >= 1);
++ const b = svc.bulk({ slugs: [pub, 'no-such-slug'], action: 'unlisted' });
++ assert.deepStrictEqual(b, { done: 1, skipped: 1 });
++ assert.throws(() => svc.bulk({ slugs: [], action: 'delete' }), /No slugs provided/);
++ assert.throws(() => svc.bulk({ slugs: [pub], action: 'nuke' }), /Invalid action/);
++ const ai = await svc.setAi(mod, pub, { ai_summary: 's'.repeat(3000), ai_tags: ['a', 'b'] });
++ assert.strictEqual(ai.ok, true);
++ assert.strictEqual(ai.paste.ai_summary.length, 2000);
++ assert.strictEqual(ai.paste.ai_tags, '["a","b"]');
++ assert.ok(ai.paste.ai_analyzed_at);
++ const del = svc.deleteForks();
++ assert.ok(del.deleted >= 1 && del.success === true);
++ });
++
++ await check('generateSlug never reuses a slug, even a deleted one', async () => {
++ const seen = new Set(db.prepare('SELECT slug FROM pastes').all().map((r) => r.slug));
++ for (let i = 0; i < 200; i++) { const s = store.generateSlug(db); assert.ok(!seen.has(s)); }
++ });
++
++ db.close();
++ done();
++})();
ddiff --git a/test/strip-metadata.test.js b/test/strip-metadata.test.js
nnew file mode 100644
iindex 0000000..75bc87e
---- /dev/null
++++ b/test/strip-metadata.test.js
@@@ -0,0 +1,61 @@
++'use strict';
++/** Uploaded images lose EXIF/XMP/text metadata without being re-encoded (server/media/strip-metadata.js). */
++const assert = require('assert');
++const zlib = require('zlib');
++const { stripImageMetadata } = require('../server/media/strip-metadata');
++const { check, done } = require('./helpers/app');
++
++function crc32(buf) {
++ let c, crc = 0xffffffff;
++ for (let n = 0; n < buf.length; n++) {
++ c = (crc ^ buf[n]) & 0xff;
++ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
++ crc = (crc >>> 8) ^ c;
++ }
++ return (crc ^ 0xffffffff) >>> 0;
++}
++function pngChunk(type, data) {
++ const len = Buffer.alloc(4); len.writeUInt32BE(data.length);
++ const body = Buffer.concat([Buffer.from(type, 'latin1'), data]);
++ const crc = Buffer.alloc(4); crc.writeUInt32BE(crc32(body));
++ return Buffer.concat([len, body, crc]);
++}
++function riffChunk(type, data) {
++ const head = Buffer.alloc(8); head.write(type, 0, 'latin1'); head.writeUInt32LE(data.length, 4);
++ return Buffer.concat([head, data, data.length & 1 ? Buffer.alloc(1) : Buffer.alloc(0)]);
++}
++
++(async () => {
++ await check('PNG: tEXt / eXIf / iTXt chunks removed, image chunks kept byte-for-byte', async () => {
++ const ihdr = pngChunk('IHDR', Buffer.from([0, 0, 0, 1, 0, 0, 0, 1, 8, 0, 0, 0, 0]));
++ const idat = pngChunk('IDAT', zlib.deflateSync(Buffer.from([0, 0])));
++ const png = Buffer.concat([Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]), ihdr,
++ pngChunk('tEXt', Buffer.from('Author\0Secret Person', 'latin1')), pngChunk('eXIf', Buffer.from('MM\0*GPS', 'latin1')),
++ idat, pngChunk('iTXt', Buffer.from('XML:com.adobe.xmp\0\0\0\0\0<gps/>', 'latin1')), pngChunk('IEND', Buffer.alloc(0))]);
++ const out = stripImageMetadata(png, 'image/png');
++ assert.ok(!out.includes(Buffer.from('Secret')) && !out.includes(Buffer.from('GPS')) && !out.includes(Buffer.from('<gps/>')));
++ assert.ok(out.includes(ihdr) && out.includes(idat));
++ assert.ok(out.subarray(out.length - 12).equals(pngChunk('IEND', Buffer.alloc(0))));
++ });
++
++ await check('WebP: EXIF and XMP chunks removed, VP8X flags cleared, RIFF size fixed', async () => {
++ const vp8x = Buffer.alloc(10); vp8x[0] = 0x0c | 0x10; // EXIF + XMP + alpha flags
++ const body = Buffer.concat([riffChunk('VP8X', vp8x), riffChunk('VP8L', Buffer.from('imagebytes')), riffChunk('EXIF', Buffer.from('GPS-DATA')), riffChunk('XMP ', Buffer.from('<x/>'))]);
++ const head = Buffer.alloc(12); head.write('RIFF', 0, 'latin1'); head.writeUInt32LE(body.length + 4, 4); head.write('WEBP', 8, 'latin1');
++ const out = stripImageMetadata(Buffer.concat([head, body]), 'image/webp');
++ assert.ok(!out.includes(Buffer.from('GPS-DATA')) && !out.includes(Buffer.from('<x/>')));
++ assert.ok(out.includes(Buffer.from('imagebytes')));
++ assert.strictEqual(out.readUInt32LE(4), out.length - 8);
++ assert.strictEqual(out[20] & 0x0c, 0, 'EXIF/XMP flags cleared');
++ assert.strictEqual(out[20] & 0x10, 0x10, 'other flags kept');
++ });
++
++ await check('GIFs and anything unparseable pass through unchanged', async () => {
++ const gif = Buffer.from('GIF89a....');
++ assert.strictEqual(stripImageMetadata(gif, 'image/gif'), gif);
++ const junk = Buffer.from([0xff, 0xd8, 0xff, 0xe1, 0xff, 0xff]);
++ assert.strictEqual(stripImageMetadata(junk, 'image/jpeg'), junk);
++ });
++
++ done();
++})();