openvibers/openvibe.community Commits

Locked pastes: the pilot (people, password and link locks) (#267)

OP OpenVibers committed 7 days ago · d0e6ac85f5 · 22 files, +861 −65

* Locked pastes: the pilot (people, password and link locks)

A new text paste can be locked: the browser seals it (Locker, openvibe-sdk
0.89.0 and openvibe-shared 4.116.0) and Community stores only the envelope.
POST /api/pastes takes locked + envelope instead of content; the title stays
inside the envelope unless the owner shows it; a locked paste is never
public (no list, feed, sitemap, llms, Search or JSON-LD); the page opens it
with OVLocker and raw/download serve the bytes as .ovlk; people locks carry a
key slot per member, checked against Network's directory, and a removal
re-seals through onReseal. Comments stay open and say they are not locked.

Checked: test/locked-pastes.test.js (real envelopes: storage, refusals, the
closed surfaces, .ovlk, add and remove with real keys, edits/forks refused,
comments), plus the paste, sharing, search, seo, ssr, n-1, openapi and
perf-budget tests and a browser smoke of the /new Lock it flow.

* Trigger CI for the locked pastes PR

* openvibe-shared v4.116.1 (a transfer of a people-locked item carries the slot)

* Locked pastes: Opus's review (the audience line, the page's actions and meta)

- /new with Lock it on: the Visibility column is replaced by one read-only
  line naming the lock's audience, and the select comes back when it is off.
- The locked page's primary action is the card's own; Raw/Download become one
  quiet "Download the locked file (.ovlk)" line under the card, with the hint
  that it opens only with the key. Share stays in the actions.
- The meta line drops the language badge and the line count (both describe
  what is inside the envelope); author, date, views, locked and audience stay.
- The screenshots are viewport shots now, so the sticky header is at the top;
  the phone's mid-page navbar was the full-page capture of a sticky bar.

* Merge main into the locked pastes pilot: VIS-6's paste review first, the lock migration renumbered to 0019

main took 0017/0018 (paste reviews) while this branch held 0017_locked_pastes; it is now 0019. Kept both sides in the
paste presenter: VIS-6's review fields, and no AI summary or tags on a locked paste. Pins: contracts v0.294.0 (main),
sdk v0.89.0 and shared v4.116.1 (the Locker release); STATUS and README follow.

* Locked pastes: the house lock glyph for the download line; the paste config names lockedMaxSizeKb

fa-file-shield is not among the OpenVibe glyphs (no-font-awesome test); fa-lock is. store.test.js pins the config's
keys and now includes the new lockedMaxSizeKb.

* Merge main (openvibe-sdk v0.91.0, #268) into the locked pastes pilot; its migration becomes 0020

The SDK pin round added 0019_shares_lock_expand.sql with the share lock columns this branch also carried (identical,
idempotent); 0020_locked_pastes.sql keeps only the paste's own lock columns. Pins: contracts v0.294.0, sdk v0.91.0,
shared v4.116.1.

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex cac5eab..de5a728 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,31 @@
## Unreleased
++- **Locked pastes** (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). A new text paste can be
++ locked: the browser seals it, Community stores the envelope and never sees the content key.
++ - `POST /api/pastes` takes `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`; a
++ request with both, a bad envelope, a public audience, a burn-after-reading lock or a screenshot is refused.
++ `migrations/0020_locked_pastes.sql` adds `locked`, `lock_kind`, `envelope` and `title_shown` to `pastes`
++ (expand-only), and openvibe-sdk/share's own columns since 0.85.0 (`shares.lock`, the members' `key_slot`, the
++ transfers' `key_slot`, `reseal_nonce_base`).
++ - The title lives inside the envelope unless the owner ticks "Show the title to people who can open it". A people
++ lock names its people with a key slot each, checked against Network's key directory; a password or link lock is
++ opened from the link. The audience follows the lock (`people` or `link`), and a locked paste is never public: no
++ list, feed, sitemap, `llms.txt`, Search document, JSON-LD or related list ever has it.
++ - The page opens the envelope with `OVLocker.open` (`/shared/ov-locker.js`) and shows the plaintext there; raw and
++ download serve the bytes as `application/octet-stream`, named `<slug>.ovlk`. No highlight, summary, preview or AI
++ pass. Comments stay open and say they are not locked. Copy, Share, Delete remain; Fork, edit and burn are refused.
++ - Removing somebody from a people lock re-seals the content under a fresh key (`onReseal` stores the bytes in the
++ same transaction, before the member goes).
++ - `docs/locked-pastes.md` at `/docs/locked-pastes`, linked from `llms.txt`, with the document format and the agent
++ path (`openvibe-sdk/locker`).
++ - `openvibe-sdk` v0.89.0 and `openvibe-shared` v4.116.1 (the share sheet’s Locked part, `OVLocker.seal`, and a transfer’s key slot).
++ - After Opus's review: while Lock it is on, /new's Visibility column becomes one read-only line naming the lock's
++ audience ("For the people on the list", "Anyone with the password", "Anyone with the link") and comes back when it
++ is off; the locked page's primary action is the card's own (unlock, password, link key), its file is one quiet
++ "Download the locked file (.ovlk)" line under the card with the hint that it opens only with the key, and its meta
++ line keeps the author, date, views, locked and audience chips but no language or line count.
++
- The screenshot move script (2026-11-06) no longer reads a 404 from Media's community tenant as "already gone": for a
`legacy:live:paste:<slug>` object it keeps `previous_media_ref` and retires the object through Media's internal legacy-paste
route. `--apply` pages with an id cursor and reaches every paste in one run, `--retire-ids <file>` retires named objects
ddiff --git a/README.md b/README.md
iindex 8e1cf49..fe9ee15 100644
---- a/README.md
++++ b/README.md
@@@ -41,8 +41,8 @@ little progressive JavaScript for pagination, copy buttons and the upload path.
- OpenVibe.Media (screenshot and attachment uploads), OpenVibe.VIP (members-only gates), OpenVibe.Chat
(a space's room), OpenVibe.Events (the outbox relay and the Pulse and account subscriptions)
- OpenVibe.Live only for old VOD/clip comment imports (it reads and writes no pastes here any more)
--- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.91.0 (events outbox, per-actor limits, no-looks, share),
-- `openvibe-shared` v4.115.1 (the Share sheet), pinned by release tarball
++- `openvibe-contracts` v0.294.0, `openvibe-sdk` v0.89.0 (events outbox, per-actor limits, no-looks, share and the
++ Locker), `openvibe-shared` v4.116.1 (the Share sheet's Locked part and `ov-locker.js`), pinned by release tarball
## How it fits the network
@@@ -865,5 +865,5 @@ release after paste comments moved onto the typed comment threads. [Submissions]
<!-- versions:start -->
- openvibe-contracts: v0.294.0
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.115.1
++- openvibe-shared: v4.116.1
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 4fa1513..41fb02a 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,7 +9,7 @@
"domain": "openvibe.community",
"contracts": "openvibe-contracts v0.294.0",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.115.1",
++ "shared": "openvibe-shared v4.116.1",
"capabilities": [
"community.paste.create",
"community.paste.write",
ddiff --git a/docs/locked-pastes.md b/docs/locked-pastes.md
nnew file mode 100644
iindex 0000000..9af4f63
---- /dev/null
++++ b/docs/locked-pastes.md
@@@ -0,0 +1,89 @@
++# Locked pastes on OpenVibe Community (Locker)
++
++A locked paste is sealed in the browser before it is sent. Community stores a `locker.envelope.v1` and never the text,
++and never the content key: we cannot read it, index it, summarise it or recover it. This page is for people and agents;
++the share sheet's **Locked** part is described in `openvibe-shared`'s `docs/share.md`.
++
++## The three locks
++
++| Lock | Who can open it | What the link carries |
++|---|---|---|
++| **For the people on the list** (`people`) | you and the people you name, each with their own Locker keys | the paste's address; no key |
++| **With a password** (`password`) | anyone you give the password to | the paste's address; the password is not in it |
++| **With the link** (`link`) | anyone with the whole link | the key, after `#k=` (browsers never send the `#` part to us) |
++
++A locked paste is **never public**: no feeds, sitemap, `llms.txt`, search, previews, thumbnails, AI pass or fork. It does
++not appear in anybody's lists but its owner's. Its comments are ordinary comments and are **not** locked (the page says
++so). There is no recovery without the password or the locker keys.
++
++## Inside the envelope
++
++The plaintext is a JSON document, UTF-8:
++
++```json
++{ "v": 1, "type": "paste", "title": "Bridge notes", "content": "…", "language": "javascript" }
++```
++
++The title is inside it. If the owner ticks **Show the title to people who can open it**, the request also carries
++`show_title: true` with the title, and Community stores that title outside the envelope (the page and its link preview
++may then show it; the content still never leaves the envelope).
++
++`GET /api/pastes/:slug` answers `locked: true`, `lock`, `title_shown` and `envelope_url`; `content` is always `null`.
++`GET /p/:slug/raw` and `/p/:slug/download` serve the envelope exactly as uploaded, as `application/octet-stream`,
++named `<slug>.ovlk`.
++
++## Making one (a browser)
++
++1. Seal the document with `OVLocker.seal(...)` (openvibe-shared's `ov-locker.js`), which keeps the content key in the
++ tab.
++2. `POST /api/pastes` with `locked: true` and `envelope: <base64>`, **instead of** `content`. Both at once is refused.
++3. For `people`, send `members: [{ name, role, key_slot }]`, the slot taken from the sealed envelope's header for each
++ person; the audience is `people`. For `password` and `link` the audience is `link`.
++4. A `link` seal comes back with `linkKey`: put it in the address you share as `#k=<linkKey>`.
++
++## Making one (an agent)
++
++```js
++const locker = require('openvibe-sdk/locker');
++const doc = JSON.stringify({ v: 1, type: 'paste', title: 'Notes', content: 'the words', language: 'text' });
++const sealed = await locker.seal(doc, { lock: 'link' });
++const res = await fetch('https://openvibe.community/api/pastes', {
++ method: 'POST',
++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
++ body: JSON.stringify({ locked: true, envelope: Buffer.from(sealed.bytes).toString('base64'), audience: 'link' }),
++});
++const { slug } = await res.json();
++const link = `https://openvibe.community/p/${slug}#k=${sealed.linkKey}`;
++```
++
++A **people** lock takes one public key per recipient, from Network's key directory, plus your own if you want to open it
++later, and each member's `key_slot` from `sealed.header.slots.people`:
++
++```js
++const keysOf = async (name) => (await fetch(`https://openvibe.network/api/v1/people/@${name}/keys`,
++ { headers: { authorization: `Bearer ${networkToken}` } })).json(); // { subject, ecdh_public, fingerprint }
++const maticus = await keysOf('maticus');
++const sealed = await locker.seal(doc, { lock: 'people', keys: [
++ { subject: { type: 'user', id: mySubject }, publicKey: myEcdhPublic },
++ { subject: maticus.subject, publicKey: maticus.ecdh_public },
++] });
++const slot = sealed.header.slots.people.find((s) => s.subject.id === maticus.subject.id);
++```
++
++A person without a Locker cannot be added: they make one the first time they open a locked item.
++
++## Sharing a locked paste
++
++- **Adding** someone wraps the content key for their key and saves the member with the slot
++ (`PUT /api/v1/paste/:slug/share` with `key_slot`).
++- **Removing** someone needs the content sealed again (a fresh key) for those who stay: send the new `envelope` with the
++ member list, and the service stores it before the member goes (`onReseal`). The sheet says they keep what they already
++ opened; they do.
++- A transfer wraps the content key for the recipient; accepting removes the old owner's slot unless they kept access.
++
++## Limits
++
++- Locking needs a signed-in account, and only text pastes are locked today (screenshots stay on the media proxy).
++- The envelope may be at most the paste limit (512 KB), which bounds the plaintext a little lower because of padding.
++- A locked paste cannot burn after reading, be edited, be forked, or have its lock changed: the lock is set when the
++ paste is made.
ddiff --git a/docs/sharing.md b/docs/sharing.md
iindex 297ed39..c3805ad 100644
---- a/docs/sharing.md
++++ b/docs/sharing.md
@@@ -14,6 +14,19 @@ link preview asks the same access check (`canView`). A reader who is not on a `p
404 and a gate ("Ask for access", or "Sign in"), never the title, the content, the screenshot or
the comments.
++## Locked pastes
++
++A text paste can also be **locked**: sealed in the browser before it is sent, so OpenVibe stores an
++envelope and can never read the text ([`docs/locked-pastes.md`](locked-pastes.md)). A locked paste
++is shared the same way, with two rules:
++
++- It is **never public**: its audience is `people` (a people lock) or `link` (a password or link lock).
++- Adding someone wraps the content key for their Locker; removing someone re-seals the content under
++ a fresh key for those who stay (they keep what they already opened).
++
++The share sheet shows a **Locked** part for one: what the lock means, each person's eight-word
++fingerprint, and a warning when somebody's key changed.
++
## From the page
The paste page's **Share** button opens the shared sheet: Send it (the link, the phone's own share
ddiff --git a/migrations/0020_locked_pastes.sql b/migrations/0020_locked_pastes.sql
nnew file mode 100644
iindex 0000000..4152526
---- /dev/null
++++ b/migrations/0020_locked_pastes.sql
@@@ -0,0 +1,15 @@
++-- phase: expand
++-- Locked pastes (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"). The browser seals the content
++-- before it is sent; Community stores the envelope and never sees the content key. `lock_kind` repeats what the
++-- envelope's header says so a list never has to open it; `envelope` is the base64 locker.envelope.v1 exactly as it
++-- came; `title_shown` is the owner's choice to keep the title outside the envelope.
++ALTER TABLE pastes ADD COLUMN IF NOT EXISTS locked boolean NOT NULL DEFAULT false;
++ALTER TABLE pastes ADD COLUMN IF NOT EXISTS lock_kind text;
++ALTER TABLE pastes ADD COLUMN IF NOT EXISTS envelope text;
++ALTER TABLE pastes ADD COLUMN IF NOT EXISTS title_shown boolean NOT NULL DEFAULT false;
++DO $$ BEGIN
++ ALTER TABLE pastes ADD CONSTRAINT pastes_lock_kind_allowed CHECK (lock_kind IS NULL OR lock_kind IN ('people', 'password', 'link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++
++-- The SDK's share lock columns (shares.lock, key slots, reseal_nonce_base) are 0019_shares_lock_expand.sql, added by the
++-- openvibe-sdk v0.91.0 pin round; this migration adds only the paste's own lock columns.
ddiff --git a/package-lock.json b/package-lock.json
iindex 2c47c84..a14da78 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.116.1",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1236,9 +1236,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.115.1",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
-- "integrity": "sha512-xmdNHGlZSJdGoeruXoz6ZtDicY5472mhwo7uuv0Ct1HwyZdyhv9Qn1GLPgz0heues+0w6Q759dXMwHZ52zuDbg==",
++ "version": "4.116.1",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.116.1",
++ "integrity": "sha512-6tNrdmGoR1gxBkItVzu/j1Rm3a/jm8f0Qdk7UzgjjivZmBAD8jXgM+MsbShY2T2fh0ZkIb0Sb9y8c20fSRcOMg==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex aec1587..7215fd5 100644
---- a/package.json
++++ b/package.json
@@@ -2,7 +2,7 @@
"name": "openvibe-community",
"version": "1.0.0",
"private": true,
-- "description": "OpenVibe.Community \u2014 the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, Pulse and submissions (clips, art, ideas and reports for community review). Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",
++ "description": "OpenVibe.Community — the people of OpenVibe. Home of pastes, the forum (spaces, threads, posts), the comment threads other OpenVibe products embed, Pulse and submissions (clips, art, ideas and reports for community review). Part of the OpenVibe network (identity by OpenVibe.Network; Community is the only authority for pastes).",
"main": "server/index.js",
"scripts": {
"start": "node server/index.js",
@@@ -31,7 +31,7 @@
"multer": "^2.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.294.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.91.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.115.1",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.116.1",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/css/community.css b/public/css/community.css
iindex 625eb9d..a321aa3 100644
---- a/public/css/community.css
++++ b/public/css/community.css
@@@ -456,3 +456,34 @@ button.rx:hover { border-color: var(--accent); }
color: var(--text-secondary, #a8b3c7); background: color-mix(in srgb, var(--text-primary, #e6edf7) 7%, transparent); border: 1px solid var(--border, rgba(255,255,255,.1)); }
.ovk-label--adult { color: var(--warning-text, #fbbf24); border-color: color-mix(in srgb, var(--warning-text, #fbbf24) 45%, transparent); background: color-mix(in srgb, var(--warning-text, #fbbf24) 9%, transparent); }
.thread-head .ovk-labels { margin: 2px 0 6px; }
++
++/* ── Locked pastes (Locker, design 2026-11-06): the create form's Lock it, the locked card, the opened text ── */
++.lock-it { margin: 14px 0; padding: 14px; border: 1px solid var(--border); border-radius: 12px; background: var(--bg-input); display: grid; gap: 10px; }
++.lock-it[hidden], .lock-it [hidden] { display: none !important; } /* the section and its choices are JS-only */
++.paste-form .field[hidden] { display: none !important; }
++.lock-audience { margin: 0; padding: 9px 12px; border: 1px dashed var(--border); border-radius: 9px; color: var(--text-primary); background: none; font-size: .95rem; }
++.lock-audience[hidden] { display: none !important; }
++.lock-it-switch { display: grid; grid-template-columns: auto auto 1fr; align-items: center; gap: 4px 8px; font-size: .95rem; }
++.lock-it-switch .muted { grid-column: 1 / -1; }
++.lock-it-switch strong { white-space: nowrap; }
++.lock-it-switch strong { color: var(--text-primary); }
++.lock-it-switch i { color: var(--accent-light); }
++.lock-it-body { display: grid; gap: 12px; padding-top: 4px; border-top: 1px solid var(--border); }
++.lock-it-body p { margin: 0; }
++.lock-kinds { display: grid; gap: 8px; }
++.lock-kind { display: flex; gap: 10px; align-items: flex-start; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; }
++.lock-kind:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
++.lock-kind input { margin-top: 2px; accent-color: var(--accent); }
++.lock-kind span { display: grid; gap: 2px; }
++.lock-kind strong { font-size: .9rem; }
++.lock-kind small { color: var(--text-secondary); font-size: .8rem; line-height: 1.4; }
++.card-locked { display: flex; align-items: center; gap: 8px; padding: 18px 14px; min-height: 118px; color: var(--text-secondary); font-size: .85rem; background: var(--bg-input); border-bottom: 1px solid var(--border); }
++.card-locked i, .badge-lock { color: var(--accent-light); }
++.badge-lock { border-color: var(--accent); color: var(--accent-light); font-size: .7rem; vertical-align: middle; }
++.paste-locked { margin: 16px 0; }
++.paste-opened { display: grid; gap: 10px; }
++.paste-opened .btn { justify-self: start; }
++.paste-plain { margin: 0; padding: 14px; max-height: 70vh; overflow: auto; background: var(--bg-input); border: 1px solid var(--border); border-radius: 10px; font: 13px/1.55 var(--mono); white-space: pre-wrap; word-break: break-word; }
++.paste-plain:focus-visible { outline: 2px solid rgba(var(--accent-rgb), .5); outline-offset: 2px; }
++.locked-file { margin: 0 0 10px; }
++.locked-file a { white-space: nowrap; }
ddiff --git a/public/js/community.js b/public/js/community.js
iindex d54b96b..33f879f 100644
---- a/public/js/community.js
++++ b/public/js/community.js
@@@ -109,6 +109,13 @@
var status = $('[data-status]', form);
var tabs = $$('.tab', form); var mode = 'text';
tabs.forEach(function (t) { t.hidden = false; });
++ var lockSection = $('[data-lock-it]', form);
++ var visField = $('[data-vis-field]', form);
++ var audienceLine = $('[data-lock-audience]', form);
++ var audienceText = $('[data-lock-audience-text]', form);
++ var LOCK_AUDIENCE = { people: 'For the people on the list', password: 'Anyone with the password', link: 'Anyone with the link' };
++ var syncLock = null;
++ var lockOn = function () { return !!(form.elements.lock_it && form.elements.lock_it.checked); };
function setMode(m) {
mode = m;
tabs.forEach(function (t) { var on = t.getAttribute('data-tab') === m; t.classList.toggle('active', on); t.setAttribute('aria-selected', on ? 'true' : 'false'); });
@@@ -117,6 +124,31 @@
if (content) content.required = m === 'text';
if (file) { file.disabled = m !== 'image'; file.required = m === 'image'; }
if (lang) lang.closest('.field').style.display = m === 'image' ? 'none' : '';
++ // A screenshot stays on the proxy: locking is offered for text only.
++ if (lockSection) lockSection.hidden = m !== 'text';
++ if (syncLock) syncLock();
++ }
++ // Lock it (Locker): revealed here, never without JavaScript, because the sealing happens in this browser.
++ if (lockSection) {
++ lockSection.hidden = false;
++ var lockBody = $('[data-lock-body]', form);
++ syncLock = function () {
++ var on = lockOn() && mode === 'text';
++ if (lockBody) lockBody.hidden = !on;
++ // The audience is the lock's (Specific people, or the link), so the column is replaced by one read-only line
++ // naming it, and comes back when Lock it is off.
++ var vis = form.elements.visibility;
++ var kind = (form.querySelector('input[name="lock_kind"]:checked') || {}).value || 'people';
++ if (vis) vis.disabled = on;
++ if (visField) visField.hidden = on;
++ if (audienceLine) audienceLine.hidden = !on;
++ if (audienceText) audienceText.textContent = LOCK_AUDIENCE[kind] || kind;
++ if (!on) return;
++ $$('[data-lock-for]', form).forEach(function (d) { d.hidden = d.getAttribute('data-lock-for') !== kind; });
++ };
++ form.elements.lock_it.addEventListener('change', syncLock);
++ $$('input[name="lock_kind"]', form).forEach(function (r) { r.addEventListener('change', syncLock); });
++ syncLock();
}
tabs.forEach(function (t) { t.addEventListener('click', function () { setMode(t.getAttribute('data-tab')); }, { signal }); });
setMode('text');
@@@ -126,10 +158,95 @@
form.addEventListener('drop', function (e) { if (e.dataTransfer && e.dataTransfer.files[0]) { e.preventDefault(); takeFile(e.dataTransfer.files[0]); } }, { signal });
document.addEventListener('paste', function (e) { var items = e.clipboardData && e.clipboardData.items; if (!items) return; for (var i = 0; i < items.length; i++) { if (items[i].kind === 'file') { takeFile(items[i].getAsFile()); e.preventDefault(); return; } } }, { signal });
++ // The Locker, loaded only when a locked paste is actually being made.
++ function loadLocker() {
++ return new Promise(function (resolve, reject) {
++ if (window.OVLocker) return resolve(window.OVLocker);
++ window.OpenVibeLockerConfig = { network: form.getAttribute('data-lock-network') || 'https://openvibe.network' };
++ var s = document.createElement('script');
++ s.src = '/shared/ov-locker.js';
++ s.onload = function () { window.OVLocker ? resolve(window.OVLocker) : reject(new Error('The Locker could not start.')); };
++ s.onerror = function () { reject(new Error('The Locker could not load. Check your connection and try again.')); };
++ document.head.appendChild(s);
++ });
++ }
++ function b64(u8) { var s = ''; for (var i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
++
++ /**
++ * A locked paste: the plaintext never leaves this page. The document is sealed here, the envelope is what goes
++ * up, and a people lock names who it is wrapped for (their public keys, each checked before anything is sent).
++ */
++ async function submitLocked(el) {
++ var content = String(el.content.value || '');
++ if (!content.trim()) throw new Error('Paste something first — the content is empty.');
++ var kind = (form.querySelector('input[name="lock_kind"]:checked') || {}).value || 'people';
++ var L = await loadLocker();
++ await L.ensure();
++ var pub = await L.publicKeys();
++ var doc = JSON.stringify({ v: 1, type: 'paste', title: String(el.title.value || ''), content: content, language: String(el.language.value || 'auto') });
++ var body = {
++ locked: true, title: String(el.title.value || ''), show_title: !!(el.show_title && el.show_title.checked),
++ is_nsfw: !!el.is_nsfw.checked,
++ };
++ var sealed;
++ if (kind === 'people') {
++ var names = String(el.lock_people.value || '').split(/[\s,;]+/).map(function (n) { return n.trim().replace(/^@+/, ''); }).filter(Boolean);
++ if (!names.length) throw new Error('Name at least one person to lock it for.');
++ var me = { type: 'user', id: form.getAttribute('data-subject') };
++ if (!me.id) throw new Error('Sign in again to lock a paste.');
++ var keys = [{ subject: me, publicKey: pub.ecdh }];
++ var people = [];
++ for (var i = 0; i < names.length; i++) {
++ status.textContent = 'Checking @' + names[i] + '…';
++ var found = await api('/api/v1/share/people?name=' + encodeURIComponent(names[i]));
++ if (!found.person) throw new Error('No OpenVibe account is called @' + names[i] + '.');
++ var k = await L.keys(found.person.subject);
++ if (!k) throw new Error('@' + names[i] + ' has no locker yet: they make one the first time they open a locked item.');
++ keys.push({ subject: found.person.subject, publicKey: k.ecdh_public || k.ecdh });
++ people.push({ name: names[i], role: 'viewer', subject: found.person.subject });
++ }
++ status.textContent = 'Sealing on your device…';
++ sealed = await L.seal(doc, { lock: 'people', keys: keys });
++ var slots = (sealed.header.slots && sealed.header.slots.people) || [];
++ body.members = people.map(function (p) {
++ var slot = null;
++ for (var j = 0; j < slots.length; j++) if (slots[j].subject.id === p.subject.id && slots[j].subject.type === p.subject.type) slot = slots[j];
++ return slot ? { name: p.name, role: p.role, key_slot: slot } : null;
++ });
++ if (body.members.some(function (m) { return !m; })) throw new Error('A key is missing; try again.');
++ body.audience = 'people';
++ } else if (kind === 'password') {
++ var password = String(el.lock_password.value || '');
++ if (password.length < 12) throw new Error('Make the password longer: 12 characters or more, or a few words.');
++ status.textContent = 'Sealing on your device…';
++ sealed = await L.seal(doc, { lock: 'password', password: password });
++ body.audience = 'link';
++ } else {
++ status.textContent = 'Sealing on your device…';
++ sealed = await L.seal(doc, { lock: 'link' });
++ body.audience = 'link';
++ }
++ body.envelope = b64(sealed.bytes);
++ var out = await api('/api/pastes', { method: 'POST', json: body });
++ var slug = out && (out.slug || (out.paste && out.paste.slug));
++ if (!slug) throw new Error('No paste came back');
++ status.textContent = 'Done — opening your paste…';
++ location.href = '/p/' + encodeURIComponent(slug) + (sealed.linkKey ? '#k=' + sealed.linkKey : '');
++ }
++
form.addEventListener('submit', function (e) {
e.preventDefault();
-- var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status'; status.textContent = 'Creating…';
++ var btn = $('button[type="submit"]', form); btn.disabled = true; status.className = 'form-status';
var el = form.elements; var p;
++ if (lockOn() && mode === 'text') {
++ status.textContent = 'Preparing your locker…';
++ submitLocked(el).catch(function (err) {
++ btn.disabled = false; status.className = 'form-status error';
++ status.textContent = err.status === 429 && err.data && err.data.cooldown ? 'Slow down — try again in ' + err.data.cooldown + 's.' : err.message;
++ });
++ return;
++ }
++ status.textContent = 'Creating…';
var common = { title: el.title.value, visibility: el.visibility.value, burn_after_read: el.burn_after_read.checked, is_nsfw: el.is_nsfw.checked };
if (mode === 'image') {
var fd = new FormData();
ddiff --git a/server/app.js b/server/app.js
iindex f2dea03..20d3d25 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -319,6 +319,8 @@ async function createApp(opts = {}) {
{
const shareApi = express.Router();
shareApi.use(viewers.middleware());
++ // The whole share call runs as the person it is for, so a locker key lookup asks as them (server/sharing.js).
++ shareApi.use((req, res, next) => sharing.withActor(req.viewer, next));
shareApi.use(sharing.shares.router({
service: 'community',
authenticate: (req) => sharing.shareViewer(req.viewer),
@@@ -370,6 +372,10 @@ async function createApp(opts = {}) {
app.get('/docs/sharing', (_req, res) => res.type('text/plain; charset=utf-8')
.set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
.send(require('fs').readFileSync(path.join(__dirname, '..', 'docs', 'sharing.md'), 'utf8')));
++ // Locked pastes (Locker design 2026-11-06): the format, the three locks and the agent path, one source.
++ app.get('/docs/locked-pastes', (_req, res) => res.type('text/plain; charset=utf-8')
++ .set('Cache-Control', cache.htmlHeaders({ maxAge: 3600 }))
++ .send(require('fs').readFileSync(path.join(__dirname, '..', 'docs', 'locked-pastes.md'), 'utf8')));
app.get('/sitemap.xml', async (_req, res) => {
let rows;
try { rows = await discovery.sitemapRows(); } catch { return res.status(503).end(); }
@@@ -610,6 +616,8 @@ async function createApp(opts = {}) {
const out = await 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', cache.htmlHeaders({ private: true }));
++ // A locked paste's raw is its envelope, exactly as it was uploaded: `.ovlk`, never a text body.
++ if (out.bytes) return res.type('application/octet-stream').set('Content-Disposition', `inline; filename="${out.filename}"`).send(out.bytes);
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.');
@@@ -642,6 +650,14 @@ async function createApp(opts = {}) {
let paste;
try { paste = await source.getPaste(slug, { ...ctxOf(req), noView: true }); }
catch (err) { if (err.status === 404 || err.status === 410) return res.status(err.status).type('text/plain').send('Not found'); throw err; }
++ if (paste.locked) {
++ try {
++ const out = await app.locals.pastes.envelope(req.viewer, slug);
++ return res.set('Cache-Control', cache.htmlHeaders({ private: true }))
++ .set('Content-Disposition', `attachment; filename="${out.filename}"`)
++ .type('application/octet-stream').send(out.bytes);
++ } catch (err) { if (err.status === 404) return res.status(404).type('text/plain').send('Not found'); throw err; }
++ }
if (paste.type === 'screenshot') return res.redirect(302, `/p/${encodeURIComponent(slug)}/screenshot`);
res.set('Content-Disposition', `attachment; filename="${slug}.${extensionFor(paste.language)}"`);
res.set('Cache-Control', cache.htmlHeaders({ private: true }));
ddiff --git a/server/discovery.js b/server/discovery.js
iindex 9c8fba0..74e84ea 100644
---- a/server/discovery.js
++++ b/server/discovery.js
@@@ -44,6 +44,11 @@ function llmsSections() {
{ title: 'Accept, decline or cancel a transfer', url: `${u}/api/v1/paste/{slug}/share/transfer/{accept,decline,cancel}`,
note: `POST {"transfer_id":"<uuid>"} as the recipient (accept/decline) or the owner (cancel)` },
] },
++ // Locked pastes (Locker design 2026-11-06): sealed in the browser, never readable by us.
++ { title: 'Locked pastes', links: [
++ { title: 'Locked pastes on OpenVibe Community', url: `${u}/docs/locked-pastes`,
++ note: 'people, password and link locks; the envelope format; the agent path with openvibe-sdk/locker' },
++ ] },
],
optional: [
{ title: 'Sitemap', url: `${u}/sitemap.xml` },
ddiff --git a/server/openapi.js b/server/openapi.js
iindex 784088f..ec445d0 100644
---- a/server/openapi.js
++++ b/server/openapi.js
@@@ -93,6 +93,9 @@ route('post', '/api/pastes', {
metadata: { type: ['object', 'string'], description: 'Services only: a JSON object kept with the paste' },
ai_summary: str({ maxLength: 2000, description: 'A service with community.paste.moderate' }),
ai_tags: { type: ['array', 'string'], description: 'A service with community.paste.moderate' },
++ locked: { type: 'boolean', description: 'A Locker paste: send envelope instead of content (see /docs/locked-pastes)' },
++ envelope: { type: 'string', description: 'Base64 locker.envelope.v1, instead of content. Community stores it and never sees the content key' },
++ show_title: { type: 'boolean', description: 'With locked: keep the title outside the envelope (it is otherwise inside it)' },
} } },
'multipart/form-data': { schema: { type: 'object', required: ['file'], properties: {
file: { type: 'string', format: 'binary' }, title: str(), description: str(), page_url: str(), visibility: pasteVisibility, audience: pasteAudience,
@@@ -697,6 +700,10 @@ function openapi() {
id: { type: 'integer' }, app_id: { const: 'community' }, slug: str(), user_id: nullable('string'), owner_subject: nullable('string'),
origin: str(), type: { type: 'string', enum: ['paste', 'screenshot'] }, title: nullable('string'), content: nullable('string'),
language: nullable('string'), visibility: { type: 'string', enum: ['public', 'unlisted', 'private'] },
++ locked: { type: 'boolean', description: 'A Locker paste: content is null and envelope_url serves the bytes' },
++ lock: nullable('string', { enum: ['people', 'password', 'link'] }),
++ title_shown: { type: 'boolean', description: 'A locked paste whose title is outside its envelope' },
++ envelope_url: nullable('string', { description: 'A locked paste only: /p/{slug}/raw, application/octet-stream' }),
stream_id: nullable('string'), stream_ref: nullable('object'), screenshot_url: nullable('string'),
burn_after_read: { type: 'boolean' }, forked_from: nullable('string'), pinned: { type: 'boolean' },
views: { type: 'integer' }, unique_views: { type: 'integer' }, copies: { type: 'integer' }, likes: { type: 'integer' },
ddiff --git a/server/pastes/service.js b/server/pastes/service.js
iindex 181e11d..4d2ea69 100644
---- a/server/pastes/service.js
++++ b/server/pastes/service.js
@@@ -24,6 +24,7 @@ const { discussionModerator } = require('../identity/capabilities');
const { stripImageMetadata } = require('../media/strip-metadata');
const { sniffImage } = require('../media/sniff-image');
const { capabilities } = require('openvibe-contracts');
++const { inspectEnvelope } = require('openvibe-sdk/locker/server');
const blocks = require('../identity/blocks');
const DEFAULT_LIMITS = {
@@@ -43,6 +44,9 @@ const SERVICE_SLUG_RE = /^[A-Za-z0-9_-]{3,80}$/;
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';
++/** What a locked paste is called when its owner kept the title inside the envelope. */
++const LOCKED_TITLE = 'A locked paste';
++const LOCK_KINDS = ['people', 'password', 'link'];
class PasteError extends Error {
constructor(status, body) {
@@@ -170,9 +174,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
* transaction): a paste exists only with its audience. An ownerless paste (anonymous or AI) is
* registered to Community itself, which nobody can sign in as.
*/
-- async function registerShare(v, row, audience) {
++ async function registerShare(v, row, audience, lock = 'none') {
if (!shares) return;
-- await shares.shares.register(shareRef(row), { owner_subject: subjectOf(v) || { type: 'service', id: 'community' }, audience });
++ await shares.shares.register(shareRef(row), { owner_subject: subjectOf(v) || { type: 'service', id: 'community' }, audience, lock });
}
/** The optional members/transfer in a create (and the uniform API's job, done here for one call). */
@@@ -225,6 +229,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
*/
function shape(p, v, projections) {
const ref = parseJson(p.stream_ref);
++ // A locked paste's plaintext is not ours: the content is never in a response, and the title is there only if
++ // its owner chose to keep it outside the envelope. The envelope's address is how a browser opens it.
++ const locked = !!p.locked;
++ const titleShown = !locked || !!p.title_shown;
return {
unique_views: p.unique_views || 0,
id: p.id,
@@@ -234,10 +242,14 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
owner_subject: p.owner_subject || null,
origin: p.origin,
type: p.type,
-- title: p.title,
-- content: p.content,
-- language: p.language,
++ title: titleShown ? p.title : null,
++ content: locked ? null : p.content,
++ language: locked ? 'text' : p.language,
visibility: p.visibility,
++ locked,
++ lock: locked ? (p.lock_kind || null) : null,
++ title_shown: locked ? !!p.title_shown : true,
++ envelope_url: locked ? `/p/${encodeURIComponent(p.slug)}/raw` : null,
stream_id: streamIdOf(ref),
stream_ref: ref,
screenshot_url: p.screenshot_url || null,
@@@ -253,9 +265,9 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
review_status: p.review_status || null,
review_report_id: p.review_report_id || null,
reviewed_at: p.reviewed_at || null,
-- ai_summary: p.ai_summary || null,
-- ai_tags: p.ai_tags || null,
-- ai_analyzed_at: p.ai_analyzed_at || null,
++ ai_summary: locked ? null : (p.ai_summary || null),
++ ai_tags: locked ? null : (p.ai_tags || null),
++ ai_analyzed_at: locked ? null : (p.ai_analyzed_at || null),
revision: p.revision,
url: `/p/${p.slug}`,
raw_url: `/p/${p.slug}/raw`,
@@@ -275,7 +287,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
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
++ if (preview && !r.locked) out.content = r.type === 'paste' ? String(r.content || '').slice(0, 300) : null; // preview only in lists
return out;
});
}
@@@ -398,6 +410,60 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return slug;
}
++ /**
++ * A locked text paste (Locker design 2026-11-06, the pilot): the browser sealed the content before it was sent.
++ * The request carries `locked: true` and `envelope: <base64 locker.envelope.v1>` **instead of** `content`;
++ * a request with both is refused, and so is an envelope that is not one. Community stores the bytes, checks
++ * the header's shape with openvibe-sdk/locker/server, and never receives or sees the content key.
++ */
++ async function lockedCreate(v, body = {}) {
++ await pasteRateCheck(v);
++ if (!v.subject) fail(401, 'Sign in to lock a paste');
++ if (body.content !== undefined && String(body.content).length) fail(400, 'A locked paste carries the envelope, not the content');
++ const raw = body.envelope;
++ if (typeof raw !== 'string' || !raw.length) fail(400, 'A locked paste needs its envelope');
++ const bytes = Buffer.from(raw, 'base64');
++ if (!bytes.length || bytes.toString('base64') !== raw.replace(/\s+/g, '')) fail(400, 'The envelope is not base64');
++ if (bytes.length > L.maxSizeKb * 1024) fail(400, `Locked paste too large (max ${L.maxSizeKb} KB)`);
++ const read = inspectEnvelope(new Uint8Array(bytes));
++ if (!read.valid) fail(400, `The envelope is not a Locker envelope: ${read.errors[0]}`);
++ if (!LOCK_KINDS.includes(read.lock)) fail(400, 'The envelope’s lock is not one we know');
++ const lock = read.lock;
++ // The audience follows the lock: a people lock lives on a list of people; a password or link lock is
++ // opened by whoever holds the link. Either way it can never be public.
++ const audience = lock === 'people' ? 'people' : 'link';
++ if (body.audience !== undefined && body.audience !== '' && audienceOf(v, body.audience) !== audience) {
++ fail(400, lock === 'people'
++ ? 'A people-locked paste is shared with specific people'
++ : 'A password- or link-locked paste is opened with its link');
++ }
++ if (truthy(body.burn_after_read)) fail(400, 'A locked paste cannot burn after reading');
++ // The title stays inside the envelope unless its owner ticks "Show the title to people who can open it".
++ const showTitle = truthy(body.show_title);
++ if (showTitle && !String(body.title || '').trim()) fail(400, 'A title to show needs a title');
++ const row = await db.tx(async () => {
++ const r = await store.insertPaste(db, {
++ slug: await store.generateSlug(db, { secret: true }),
++ owner_subject: v.subject,
++ origin: v.origin === 'ai' ? 'ai' : 'user',
++ type: 'paste',
++ title: showTitle ? store.sanitizeTitle(body.title) : LOCKED_TITLE,
++ content: null,
++ language: 'text',
++ visibility: visibilityFor(audience),
++ locked: true,
++ lock_kind: lock,
++ envelope: bytes.toString('base64'),
++ title_shown: showTitle ? 1 : 0,
++ is_nsfw: truthy(body.is_nsfw) ? 1 : 0,
++ });
++ await registerShare(v, r, audience, lock);
++ return r;
++ });
++ const sharing = await applyShareRequest(v, row.slug, audience, body);
++ return await created(row, { paste: { ...(await shapeOne(row, v)), audience }, ...(sharing || {}) });
++ }
++
async function created(row, extra = {}) {
await tell((p) => p.pasteCreated(row));
pingIndexNow(row);
@@@ -469,12 +535,27 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return { paste: { ...(await shapeOne(p, v)), liked, audience: share.audience, can_manage: share.can_manage, transfer: share.transfer } };
},
++ /**
++ * A locked paste's envelope, or 404. The bytes are served exactly as they were uploaded; nothing here opens
++ * them, and they go only to somebody canView already allows.
++ */
++ async envelope(v, slug) {
++ const p = await store.getBySlug(db, String(slug));
++ if (!p || !p.locked || !p.envelope || !await mayView(v, p)) fail(404, 'Not found');
++ return { bytes: Buffer.from(p.envelope, 'base64'), filename: `${p.slug}.ovlk` };
++ },
++
/** GET /p/:slug/raw — plain text, Media's raw rules (a burn paste survives exactly one read). */
async raw(v, slug, ctx = {}) {
const p = await store.getBySlug(db, String(slug));
if (!p || !await mayView(v, p)) fail(404, 'Not found');
if (p.type === 'screenshot') return { redirect: `/p/${encodeURIComponent(p.slug)}/screenshot` };
if (p.type !== 'paste') fail(404, 'Not found');
++ // A locked paste's raw is its envelope, as bytes: application/octet-stream, `.ovlk`.
++ if (p.locked) {
++ if (!isOwner(v, p)) await countView(v, p, ctx);
++ return { bytes: Buffer.from(String(p.envelope || ''), 'base64'), filename: `${p.slug}.ovlk` };
++ }
if (!isOwner(v, p)) {
if (p.burn_after_read) {
if (p.views > 0) await burn(p);
@@@ -497,8 +578,13 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
return p.screenshot_url;
},
++ /** POST /api/pastes with locked: true */
++ async createLocked(v, body = {}) { return await lockedCreate(v, body); },
++
/** POST /api/pastes (text) */
async createText(v, body = {}) {
++ if (truthy(body.locked)) return await lockedCreate(v, body);
++ if (body.envelope !== undefined && body.envelope !== '') fail(400, 'Send locked: true with an envelope');
await pasteRateCheck(v);
const content = body.content;
if (!content || typeof content !== 'string' || content.trim().length === 0) fail(400, 'Content is required');
@@@ -544,6 +630,8 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
/** POST /api/pastes/screenshot (and multipart POST /api/pastes) */
async createScreenshot(v, body = {}, file, ctx = {}) {
++ // Screenshots stay on the proxy: only a text paste is sealed in the browser (design 2026-11-06, the pilot).
++ if (truthy(body.locked)) fail(400, 'Only text pastes can be locked: a screenshot stays on the proxy');
await pasteRateCheck(v);
if (!file || !file.buffer) fail(400, 'No screenshot uploaded');
// The type comes from the bytes, never the client's declared Content-Type: a JPEG declaring
@@@ -618,6 +706,10 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
needIdentity(v);
const p = await visible(v, slug);
if (!isOwner(v, p) && !isStaff(v)) fail(403, 'Not authorized for this paste');
++ // A locked paste's title and content live inside the envelope; only who can open it can change.
++ if (p.locked && (body.title !== undefined || body.content !== undefined || body.language !== undefined)) {
++ fail(400, 'A locked paste cannot be edited: its content is not ours to read');
++ }
const patch = {};
if (body.title !== undefined) patch.title = store.sanitizeTitle(body.title);
if (body.content !== undefined && p.type === 'paste') {
@@@ -680,6 +772,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
/** POST /api/pastes/:slug/fork */
async fork(v, slug) {
const original = await visible(v, slug);
++ if (original.locked) fail(400, 'A locked paste cannot be forked: its content is not ours to read');
if (original.type !== 'paste') fail(400, 'Only text pastes can be forked');
// A lasting copy would outlive the burn; only the owner (or staff) may fork one.
if (original.burn_after_read && !canSeeHidden(v, original)) fail(403, 'Burn-after-read pastes cannot be forked');
@@@ -798,6 +891,7 @@ function createPasteService({ db, network = null, media = null, config = {}, lim
screenshotMaxSizeMb: L.screenshotMaxSizeMb,
cooldownSeconds: L.cooldownSeconds,
maxPerUserPerDay: L.maxPerUserPerDay,
++ lockedMaxSizeKb: L.maxSizeKb,
todayCount: v.subject ? await store.countOwnerSince(db, v.subject, '-1 day') : 0,
};
},
ddiff --git a/server/pastes/store.js b/server/pastes/store.js
iindex b292785..e261428 100644
---- a/server/pastes/store.js
++++ b/server/pastes/store.js
@@@ -134,7 +134,7 @@ async function getById(db, id) {
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'];
++ 'ai_summary', 'ai_tags', 'ai_analyzed_at', 'locked', 'lock_kind', 'envelope', 'title_shown'];
/** Insert a new paste (fields already validated); returns the stored row. */
async function insertPaste(db, fields) {
ddiff --git a/server/render/pages.js b/server/render/pages.js
iindex 7cdbec4..728650c 100644
---- a/server/render/pages.js
++++ b/server/render/pages.js
@@@ -109,14 +109,17 @@ function pagerHref(state, page) {
// ── Paste card (grid item) ───────────────────────────────────
function pasteCard(p) {
const isShot = p.type === 'screenshot';
-- const preview = isShot
-- ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(screenshotSrc(p))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
-- : `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
-- const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
-- return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}">
++ const locked = !!p.locked;
++ const preview = locked
++ ? `<div class="card-locked"><i class="fa-solid fa-lock" aria-hidden="true"></i> Locked — OpenVibe can’t read what’s inside</div>`
++ : isShot
++ ? (p.screenshot_url ? `<div class="card-shot"><img src="${esc(screenshotSrc(p))}" alt="${esc(p.title || 'Screenshot')}" loading="lazy"></div>` : '')
++ : `<pre class="card-code" aria-hidden="true">${esc(String(p.content || '').slice(0, 220))}${String(p.content || '').length > 220 ? '…' : ''}</pre>`;
++ const flags = `${p.pinned ? '<i class="fa-solid fa-thumbtack" title="Pinned"></i> ' : ''}${p.burn_after_read ? '<i class="fa-solid fa-fire" title="Burns after reading"></i> ' : ''}${locked ? '<i class="fa-solid fa-lock" title="Locked"></i> ' : ''}${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}`;
++ return `<article class="card${p.is_nsfw ? ' card-nsfw' : ''}${locked ? ' card-is-locked' : ''}">
<a class="card-link" href="/p/${esc(p.slug)}">
${preview}
-- <h3 class="card-title">${flags}${esc(p.title || 'Untitled')}</h3>
++ <h3 class="card-title">${flags}${esc(p.title || (locked ? 'A locked paste' : 'Untitled'))}</h3>
</a>
<div class="card-meta">
${authorHtml(p)}
@@@ -375,6 +378,7 @@ function browsePage(result, languages) {
// ── Paste page ───────────────────────────────────────────────
function pasteDescription(p) {
++ if (p.locked) return 'A locked paste on OpenVibe Community. Only the people it is locked for can read it.';
if (p.ai_summary) return ld.clean(p.ai_summary, 200);
if (p.type === 'screenshot') return ld.clean(p.content || `A screenshot shared by ${authorName(p)} on ${SITE_NAME}.`, 200);
return ld.clean(`${p.title || 'Paste'} — ${languageLabel(p.language)} paste by ${authorName(p)}: ${String(p.content || '').slice(0, 200)}`, 200);
@@@ -397,16 +401,22 @@ function withoutTitle(content, title) {
function pastePage({ paste: p, related, user, comments = null, commentSort = 'new', commentsAfter = null, share = null }) {
const isShot = p.type === 'screenshot';
-- const title = p.title || (isShot ? 'Screenshot' : 'Untitled paste');
++ // A locked paste (Locker design 2026-11-06, the pilot): Community holds an envelope and never the plaintext. The
++ // title is here only if its owner chose to show it; the page opens the envelope in the browser.
++ const locked = !!p.locked;
++ const title = p.title || (locked ? 'A locked paste' : isShot ? 'Screenshot' : 'Untitled paste');
const description = pasteDescription(p);
// The screenshot is Community's own address: the proxy checks canView, never the Media object's URL.
const shot = isShot ? screenshotSrc(p) : null;
// Specific people and Only me get a generic link preview, even for someone allowed to read them:
-- // a crawler or a chat client must never learn the title, the description or the picture.
++ // a crawler or a chat client must never learn the title, the description or the picture. A locked paste always
++ // does: its words are not ours to describe.
const restricted = !!(share && (share.audience === 'people' || share.audience === 'private'));
-- const metaTitle = restricted ? 'A paste on OpenVibe Community' : title;
-- const metaDescription = restricted ? 'A paste shared with specific people on OpenVibe Community.' : description;
-- const metaImage = restricted ? undefined : (shot || undefined);
++ const lockedMeta = locked || restricted;
++ const metaTitle = locked ? 'A locked paste on OpenVibe Community' : restricted ? 'A paste on OpenVibe Community' : title;
++ const metaDescription = locked ? 'A locked paste on OpenVibe Community. Only the people it is locked for can read it.'
++ : restricted ? 'A paste shared with specific people on OpenVibe Community.' : description;
++ const metaImage = lockedMeta ? undefined : (shot || undefined);
// Pastes name their owner by Network subject; the projection cache supplies the display fields.
const isOwner = !!(user && (p.is_owner === true
|| (p.owner_subject && user.subject_id && p.owner_subject === user.subject_id)
@@@ -416,7 +426,13 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
const isDoc = !!hl && hl.language === 'markdown';
let bodyBlock;
-- if (isShot) {
++ if (locked) {
++ // The card ov-locker.js draws: a link lock opens with the key after #, a password with its password, a people
++ // lock with the reader's own locker. The plaintext is written here, on the device, and nowhere else.
++ bodyBlock = `<div id="paste-locked" class="paste-locked" aria-live="polite">
++ <p class="muted">Opening on your device…</p>
++ </div>`;
++ } else if (isShot) {
bodyBlock = `<figure class="shot${p.is_nsfw ? ' nsfw' : ''}">
<a href="${esc(shot)}" target="_blank" rel="noopener"><img src="${esc(shot)}" alt="${esc(title)}"></a>
${p.content ? `<figcaption>${esc(p.content)}</figcaption>` : ''}
@@@ -438,7 +454,12 @@ function pastePage({ paste: p, related, user, comments = null, commentSort = 'ne
</details>` : code;
}
-- const actions = `<div class="actions">
++ // The primary action of a locked paste is the card's own (unlock your locker, the password, the link's key): the
++ // ordinary actions here are Share, and Delete for its owner. The envelope as a file is a quiet line under the card.
++ const actions = locked ? `<div class="actions">
++ <button type="button" class="btn btn-sm" data-ov-share-sheet data-title="${esc(title)}" data-kind="paste" data-id="${esc(p.slug)}"><i class="fa-solid fa-share-nodes" aria-hidden="true"></i> Share</button>
++ ${isOwner ? `<button type="button" class="btn btn-sm btn-danger" data-delete="${esc(p.slug)}"><i class="fa-solid fa-trash" aria-hidden="true"></i> Delete</button>` : ''}
++ </div>` : `<div class="actions">
${!isShot ? `<button type="button" class="btn btn-sm btn-primary" data-copy-content="${esc(p.slug)}"><i class="fa-solid fa-copy" aria-hidden="true"></i> Copy</button>` : ''}
${!isShot ? `<a class="btn btn-sm" href="/p/${esc(p.slug)}/raw"><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Raw</a>` : `<a class="btn btn-sm" href="/p/${esc(p.slug)}/screenshot"><i class="fa-solid fa-image" aria-hidden="true"></i> Full image</a>`}
<a class="btn btn-sm" href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.${esc(isShot ? 'png' : ext)}"><i class="fa-solid fa-download" aria-hidden="true"></i> Download</a>
@@@ -457,10 +478,11 @@ ${offer}
<h1 itemprop="headline">${p.is_nsfw ? '<span class="badge badge-nsfw">NSFW</span> ' : ''}${esc(title)}</h1>
<p class="paste-meta">
${authorHtml(p)}
-- <span class="sep">·</span>${langBadge(p)}
++ ${locked ? '' : `<span class="sep">·</span>${langBadge(p)}`}
<span class="sep">·</span><time datetime="${esc(ld.isoDate(p.created_at) || '')}" itemprop="datePublished">${esc(fmtDate(p.created_at))}</time>
<span class="sep">·</span><span class="stat" title="Views"><i class="fa-solid fa-eye" aria-hidden="true"></i> ${num(p.views)} views</span>
-- ${!isShot && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
++ ${!isShot && !locked && hl ? `<span class="sep">·</span><span class="stat">${num(hl.lines)} lines</span>` : ''}
++ ${locked ? `<span class="sep">·</span><span class="badge badge-lock"><i class="fa-solid fa-lock" aria-hidden="true"></i> locked</span>` : ''}
${share && share.audience && share.audience !== 'public' ? `<span class="sep">·</span><span class="badge badge-vis"><i class="fa-solid fa-${share.audience === 'private' ? 'lock' : share.audience === 'people' ? 'users' : 'eye-slash'}" aria-hidden="true"></i> ${esc(share.audience === 'link' ? 'anyone with the link' : share.audience === 'people' ? 'specific people' : 'only me')}</span>` : ''}
${p.burn_after_read ? `<span class="sep">·</span><span class="badge badge-burn"><i class="fa-solid fa-fire" aria-hidden="true"></i> burns after reading</span>` : ''}
</p>
@@@ -468,19 +490,57 @@ ${offer}
${actions}
</header>
${bodyBlock}
++ ${locked ? `<p class="locked-file muted small"><a href="/p/${esc(p.slug)}/download" download="${esc(p.slug)}.ovlk"><i class="fa-solid fa-lock" aria-hidden="true"></i> Download the locked file (.ovlk)</a> — it opens only with its key, in the Locker.</p>` : ''}
<footer class="paste-foot">
<p class="muted small">Stored by <a href="${esc(config.mediaUrl)}" rel="noopener">OpenVibe.Media</a>, account by <a href="${NETWORK_URL}" rel="noopener">OpenVibe.Network</a>. Something wrong with this paste? <a href="${esc(config.liveUrl)}/dmca">Report it</a>.</p>
</footer>
</article>
${p.burn_after_read ? '' : `<section class="section" id="comments">
++ ${locked ? '<p class="muted small">Comments are not locked: anyone who can open this paste can read them, and so can OpenVibe.</p>' : ''}
<div class="section-head"><h2>Comments${comments && comments.thread.comment_count ? ` <span class="muted small">${num(comments.thread.comment_count)}</span>` : ''}</h2></div>
${require('./comments').commentPanel({ page: comments, user, base: `/p/${p.slug}`, action: `/p/${p.slug}/comments`, after: commentsAfter, sort: commentSort })}
</section>`}
--${related && related.length ? `<section class="section" id="related">
++${!locked && related && related.length ? `<section class="section" id="related">
<div class="section-head"><h2>More like this</h2><a class="more" href="${!isShot && p.language && p.language !== 'text' ? `/pastes?lang=${encodeURIComponent(p.language)}` : '/pastes'}">Browse <i class="fa-solid fa-arrow-right" aria-hidden="true"></i></a></div>
${cardGrid(related)}
</section>` : ''}
--<script src="${ovServe.url('ov-share.js')}" defer></script>
++<script src="${ovServe.url('ov-share.js')}" defer></script>${locked ? `
++<script>window.OpenVibeLockerConfig = { network: ${JSON.stringify(config.networkUrl)} };</script>
++<script src="${ovServe.url('ov-locker.js')}" defer></script>
++<script>
++window.addEventListener('DOMContentLoaded', function () {
++ var box = document.getElementById('paste-locked');
++ if (!box || !window.OVLocker) return;
++ OVLocker.open(box, { url: ${JSON.stringify(`/p/${p.slug}/raw`)}, link: location.href, title: ${JSON.stringify(p.title_shown ? title : '')}, onOpen: function (bytes) {
++ var text = new TextDecoder().decode(bytes), doc = null;
++ try { doc = JSON.parse(text); } catch (e) { doc = null; }
++ var shown = doc && typeof doc.content === 'string' ? doc.content : text;
++ var named = doc && typeof doc.title === 'string' ? doc.title : '';
++ if (named) { var h = document.querySelector('.paste-head h1'); if (h) h.textContent = named; document.title = named + ' — OpenVibe Community'; }
++ var wrap = document.createElement('div');
++ wrap.className = 'paste-opened';
++ var say = document.createElement('p');
++ say.className = 'muted small';
++ say.textContent = 'Opened on your device. OpenVibe never had the key, so it never saw this.';
++ var copy = document.createElement('button');
++ copy.type = 'button'; copy.className = 'btn btn-sm btn-primary';
++ copy.appendChild(document.createTextNode('Copy'));
++ copy.addEventListener('click', function () {
++ var done = function () { copy.textContent = 'Copied'; setTimeout(function () { copy.textContent = 'Copy'; }, 1600); };
++ if (navigator.clipboard) navigator.clipboard.writeText(shown).then(done, function () { copy.textContent = 'Select and copy'; });
++ else copy.textContent = 'Select and copy';
++ });
++ var pre = document.createElement('pre');
++ pre.className = 'code paste-plain';
++ pre.tabIndex = 0;
++ pre.setAttribute('role', 'region');
++ pre.setAttribute('aria-label', 'The opened paste');
++ pre.textContent = shown;
++ wrap.appendChild(say); wrap.appendChild(copy); wrap.appendChild(pre);
++ box.replaceChildren(wrap);
++ } });
++});
++</script>` : ''}
<script>
window.addEventListener('DOMContentLoaded', function () {
if (!window.OVShare) return;
@@@ -511,7 +571,7 @@ window.addEventListener('DOMContentLoaded', function () {
historyType: 'paste',
historyTitle: title,
footerVariant: 'compact',
-- jsonLd: restricted ? [] : [
++ jsonLd: lockedMeta ? [] : [
ld.pasteLd(p, { description, image: shot || DEFAULT_OG_IMAGE }),
ld.breadcrumbLd([{ name: 'Home', url: '/' }, { name: 'Pastes', url: '/pastes' }, { name: title, url: `/p/${p.slug}` }]),
],
@@@ -530,7 +590,7 @@ function newPage({ user, values = {}, error = null, fork = null }) {
<p class="muted">${user ? `Posting as <strong>${esc(user.display_name || user.username)}</strong> — your pastes collect under <a href="/my">My pastes</a>.` : `Posting anonymously. <a href="/auth/login?next=%2Fnew">Sign in</a> to keep your pastes together and delete them later.`}</p>
</header>
${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
--<form class="paste-form" method="post" action="/new" data-new-paste>
++<form class="paste-form" method="post" action="/new" data-new-paste data-lock-network="${esc(config.networkUrl)}"${user ? ` data-subject="${esc(user.subject_id || '')}"` : ''}>
<div class="tabs" role="tablist">
<button type="button" class="tab active" data-tab="text" role="tab" aria-selected="true"><i class="fa-solid fa-code" aria-hidden="true"></i> Text or code</button>
<button type="button" class="tab" data-tab="image" role="tab" aria-selected="false" hidden><i class="fa-solid fa-image" aria-hidden="true"></i> Screenshot</button>
@@@ -538,11 +598,12 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="field"><span>Title</span><input type="text" name="title" maxlength="200" value="${esc(v.title)}" placeholder="Untitled"></label>
<div class="field-row">
<label class="field"><span>Language</span><select name="language">${langOpts}</select></label>
-- <label class="field"><span>Visibility</span><select name="visibility">
++ <label class="field" data-vis-field><span>Visibility</span><select name="visibility">
<option value="public"${v.visibility === 'public' ? ' selected' : ''}>Public — listed and searchable</option>
<option value="unlisted"${v.visibility === 'unlisted' ? ' selected' : ''}>Unlisted — link only</option>
${user ? `<option value="private"${v.visibility === 'private' ? ' selected' : ''}>Private — only you</option>` : ''}
</select></label>
++ ${user ? `<div class="field" data-lock-audience hidden><span>Who can open it</span><p class="lock-audience" data-lock-audience-text></p></div>` : ''}
</div>
<div data-pane="text">
<label class="field"><span>Content</span><textarea name="content" rows="18" spellcheck="false" placeholder="Paste it here…" required>${esc(v.content)}</textarea></label>
@@@ -555,6 +616,33 @@ ${error ? `<p class="alert alert-error" role="alert">${esc(error)}</p>` : ''}
<label class="check"><input type="checkbox" name="burn_after_read" value="1"${v.burn_after_read ? ' checked' : ''}> Burn after reading</label>
<label class="check"><input type="checkbox" name="is_nsfw" value="1"${v.is_nsfw ? ' checked' : ''}> Mark as NSFW</label>
</div>
++ ${user ? `<section class="lock-it" data-lock-it hidden>
++ <label class="check lock-it-switch"><input type="checkbox" name="lock_it" value="1" data-lock-switch>
++ <i class="fa-solid fa-lock" aria-hidden="true"></i> <strong>Lock it</strong>
++ <span class="muted small">OpenVibe can’t read what’s inside.</span></label>
++ <div class="lock-it-body" data-lock-body hidden>
++ <p class="muted small">Choose how people open it. Your browser seals the content before it leaves this page; the key never reaches OpenVibe.</p>
++ <div class="lock-kinds" role="radiogroup" aria-label="How to lock it">
++ <label class="lock-kind"><input type="radio" name="lock_kind" value="people" checked>
++ <span><strong>For the people on the list</strong><small>Only you and the people you name can open it, each with their own keys. It switches the audience to Specific people.</small></span></label>
++ <label class="lock-kind"><input type="radio" name="lock_kind" value="password">
++ <span><strong>With a password</strong><small>Anyone you give the password to can open it. If the password is lost, nobody can open it, including OpenVibe.</small></span></label>
++ <label class="lock-kind"><input type="radio" name="lock_kind" value="link">
++ <span><strong>With the link</strong><small>The key travels in the link, after the #, which browsers never send to OpenVibe. Anyone with the whole link can open it.</small></span></label>
++ </div>
++ <div data-lock-for="people">
++ <label class="field"><span>People</span>
++ <input type="text" name="lock_people" placeholder="@maticus, @quinn" autocomplete="off" spellcheck="false">
++ </label>
++ <p class="muted small">Their OpenVibe names. Each needs a Locker: they make one the first time they open a locked item.</p>
++ </div>
++ <label class="field" data-lock-for="password" hidden><span>Password</span>
++ <input type="password" name="lock_password" autocomplete="new-password" spellcheck="false">
++ </label>
++ <label class="check"><input type="checkbox" name="show_title" value="1"> Show the title to people who can open it</label>
++ <p class="muted small">Lose the password, or your locker passphrase and recovery kit, and nobody can open this, including OpenVibe. There is no reset.</p>
++ </div>
++ </section>` : ''}
<div class="form-actions">
<button class="btn btn-primary" type="submit"><i class="fa-solid fa-paper-plane" aria-hidden="true"></i> Create paste</button>
<span class="muted small">By posting you agree to the <a href="${esc(config.liveUrl)}/tos">rules</a>. Say it, own it.</span>
ddiff --git a/server/search/documents.js b/server/search/documents.js
iindex 9b6815e..a56fc02 100644
---- a/server/search/documents.js
++++ b/server/search/documents.js
@@@ -93,7 +93,8 @@ function createSearchDocuments({ db }) {
/** The document for one paste (by row), `{ deleted: true }` when Search must not hold it. */
function pasteDocument(p) {
-- if (!p || p.deleted_at || p.visibility !== 'public' || Number(p.burn_after_read)) return { deleted: true };
++ // A locked paste is never in Search: its text is not ours, and it can never be public (Locker design 2026-11-06).
++ if (!p || p.deleted_at || p.locked || p.visibility !== 'public' || Number(p.burn_after_read)) return { deleted: true };
let tags = [];
try { tags = (JSON.parse(p.ai_tags || '[]') || []).filter((x) => typeof x === 'string').map((x) => x.slice(0, 200)).slice(0, 50); } catch { /* none */ }
const shot = p.type === 'screenshot';
ddiff --git a/server/sharing.js b/server/sharing.js
iindex 96f62cc..7e36774 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -23,6 +23,7 @@
* The gate: a person not on a `people` list gets `gate()` — the audience, and their own pending
* transfer if they are the recipient — never a title, content, screenshot or comment count.
*/
++const { AsyncLocalStorage } = require('node:async_hooks');
const { createShares } = require('openvibe-sdk/share');
const { serviceAuth } = require('openvibe-contracts');
const config = require('./config');
@@@ -32,6 +33,13 @@ const SERVICE = 'community';
const KIND = 'paste';
const AUDIENCES = ['public', 'link', 'people', 'private'];
const VISIBILITY = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++/**
++ * Who is acting, while a share call runs. Network's locker key directory (GET /api/v1/people/:id/keys) is a *person*
++ * route: a service token is refused there, while openvibe-sdk/share asks for a person's key with its own service
++ * client. So the acting person's Network token travels with the call: the lookup is one they may make themselves, and
++ * only public keys come back. The token is never stored or logged.
++ */
++const actor = new AsyncLocalStorage();
/** A paste's share ref: { service: 'community', kind: 'paste', id: <slug> }. */
const refOf = (slug) => ({ service: SERVICE, kind: KIND, id: String(slug) });
@@@ -58,7 +66,12 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
async function networkJson({ path, method = 'GET', json, query } = {}) {
const url = new URL(`${base}${path}`);
for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
-- const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ // A person's public keys, as the person asking: Network answers this route for a signed-in account, not a service.
++ const keysPath = /^\/api\/v1\/people\/[^/]+\/keys$/.test(path);
++ const acting = actor.getStore();
++ const headers = { Accept: 'application/json', ...(keysPath && acting && acting.token
++ ? { Authorization: `Bearer ${acting.token}` }
++ : await tokens.authHeaders()) };
if (json !== undefined) headers['Content-Type'] = 'application/json';
const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
const body = await res.json().catch(() => null);
@@@ -67,6 +80,11 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
err.status = res.status;
throw err;
}
++ // Network names a person's public keys ecdh_public/signing_public (server/locker/keys.js); openvibe-sdk/share's
++ // key slots read ecdh/signing. One spelling here, so the Locker's own names stay what Network publishes.
++ if (keysPath && body && body.ecdh_public && !body.ecdh) {
++ return { ...body, ecdh: body.ecdh_public, signing: body.signing_public };
++ }
return body;
}
@@@ -137,6 +155,17 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
}
}
++ /**
++ * A re-sealed envelope (openvibe-sdk/share): removing somebody from a people-locked paste seals the content again
++ * under a fresh key, and the service stores those bytes before the member row goes. Community keeps them in the
++ * paste's own row; it never opens them.
++ */
++ async function onReseal(ref, bytes, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ await tx.exec('UPDATE pastes SET envelope = $1, updated_at = ov_now() WHERE slug = $2 AND deleted_at IS NULL',
++ [Buffer.from(bytes).toString('base64'), ref.id]);
++ }
++
const shares = createShares({
db,
network: { json: networkJson },
@@@ -144,6 +173,7 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
notify,
onOwnerChange,
onAudienceChange,
++ onReseal,
});
/** The share row's audience, for the page's gate and its generic link preview. Null when unregistered. */
@@@ -186,27 +216,31 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
return !(p.visibility === 'private' && !(v.staff || (v.subject && p.owner_subject && v.subject === p.owner_subject)));
}
-- // The SDK calls, from a Community viewer (the router does its own conversion).
++ // The SDK calls, from a Community viewer (the router does its own conversion). Each runs with the acting person in
++ // the async context, so a locker key lookup asks as them (see `actor` above).
const sv = (viewer) => shareViewer(viewer);
const at = (slug) => refOf(slug);
++ const as = (viewer, fn) => actor.run({ token: viewer && viewer.kind === 'user' ? viewer.token : null }, fn);
return {
++ /** Run a whole request (the share router) as the person it is for. */
++ withActor: (viewer, fn) => as(viewer, fn),
shares, canView, canViewPaste, gate, shareOf, pendingTransfer, shareViewer, refOf,
canEdit: async (viewer, slug) => await shares.canEdit(sv(viewer), at(slug)),
-- get: async (viewer, slug) => await shares.get(sv(viewer), at(slug)),
-- getWithRevision: async (viewer, slug) => await shares.getWithRevision(sv(viewer), at(slug)),
-- setAudience: async (viewer, slug, audience) => await shares.setAudience(sv(viewer), at(slug), audience),
-- addMembers: async (viewer, slug, list) => await shares.addMembers(sv(viewer), at(slug), list),
-- removeMember: async (viewer, slug, subject) => await shares.removeMember(sv(viewer), at(slug), subject),
-- update: async (viewer, slug, body, opts) => await shares.update(sv(viewer), at(slug), body, opts),
-- requestAccess: async (viewer, slug) => await shares.requestAccess(sv(viewer), at(slug)),
-- dismiss: async (viewer, slug, id) => await shares.dismiss(sv(viewer), at(slug), id),
-- transfer: async (viewer, slug, input) => await shares.transfer(sv(viewer), at(slug), input),
-- accept: async (viewer, slug, id) => await shares.accept(sv(viewer), at(slug), id),
-- decline: async (viewer, slug, id) => await shares.decline(sv(viewer), at(slug), id),
-- cancel: async (viewer, slug, id) => await shares.cancel(sv(viewer), at(slug), id),
-- lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
-- sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ get: async (viewer, slug) => as(viewer, async () => await shares.get(sv(viewer), at(slug))),
++ getWithRevision: async (viewer, slug) => as(viewer, async () => await shares.getWithRevision(sv(viewer), at(slug))),
++ setAudience: async (viewer, slug, audience) => as(viewer, async () => await shares.setAudience(sv(viewer), at(slug), audience)),
++ addMembers: async (viewer, slug, list) => as(viewer, async () => await shares.addMembers(sv(viewer), at(slug), list)),
++ removeMember: async (viewer, slug, subject) => as(viewer, async () => await shares.removeMember(sv(viewer), at(slug), subject)),
++ update: async (viewer, slug, body, opts) => as(viewer, async () => await shares.update(sv(viewer), at(slug), body, opts)),
++ requestAccess: async (viewer, slug) => as(viewer, async () => await shares.requestAccess(sv(viewer), at(slug))),
++ dismiss: async (viewer, slug, id) => as(viewer, async () => await shares.dismiss(sv(viewer), at(slug), id)),
++ transfer: async (viewer, slug, input) => as(viewer, async () => await shares.transfer(sv(viewer), at(slug), input)),
++ accept: async (viewer, slug, id) => as(viewer, async () => await shares.accept(sv(viewer), at(slug), id)),
++ decline: async (viewer, slug, id) => as(viewer, async () => await shares.decline(sv(viewer), at(slug), id)),
++ cancel: async (viewer, slug, id) => as(viewer, async () => await shares.cancel(sv(viewer), at(slug), id)),
++ lookup: async (viewer, name) => as(viewer, async () => await shares.lookup(sv(viewer), name)),
++ sharedWithMe: async (viewer, opts) => as(viewer, async () => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) })),
useSearch: (docs) => { searchDocs = docs || null; },
/** Expire members and transfers, then send queued events and notifications. */
async work({ flush = true } = {}) {
@@@ -219,13 +253,28 @@ function createSharing({ db, media = null, fetchImpl = globalThis.fetch, log = c
};
}
--/** The uniform API's describe(ref): the sheet shows the paste's title and link to its owner. */
++/**
++ * The uniform API's describe(ref): the sheet shows the paste's title and link to its owner. A locked paste also says
++ * where its envelope is, and which audiences make sense: it can never be public, and a people lock is opened by its
++ * people, not by whoever holds a link.
++ */
function describeRef(db) {
return async (ref) => {
if (ref.service !== SERVICE || ref.kind !== KIND) return {};
-- const p = await db.maybe('SELECT slug, title FROM pastes WHERE slug = $1 AND deleted_at IS NULL', [ref.id]);
++ const p = await db.maybe('SELECT slug, title, locked, lock_kind, title_shown FROM pastes WHERE slug = $1 AND deleted_at IS NULL', [ref.id]);
if (!p) return {};
-- return { title: p.title || 'Untitled', link: `${config.baseUrl}/p/${encodeURIComponent(p.slug)}`, roles: ['viewer'], audiences: AUDIENCES, transferable: true };
++ const out = {
++ title: !p.locked || p.title_shown ? (p.title || 'Untitled') : 'A locked paste',
++ link: `${config.baseUrl}/p/${encodeURIComponent(p.slug)}`,
++ roles: ['viewer'],
++ audiences: AUDIENCES,
++ transferable: true,
++ };
++ if (p.locked) {
++ out.audiences = p.lock_kind === 'people' ? ['people', 'private'] : ['link', 'private'];
++ out.envelope_url = `${config.baseUrl}/p/${encodeURIComponent(p.slug)}/raw`;
++ }
++ return out;
};
}
ddiff --git a/test/helpers/mock-network.js b/test/helpers/mock-network.js
iindex c591235..30184fc 100644
---- a/test/helpers/mock-network.js
++++ b/test/helpers/mock-network.js
@@@ -21,6 +21,8 @@ function start({ clientSecret = 'shh' } = {}) {
const grants = [];
const resolveCalls = [];
const pushes = []; // POST /internal/notifications/push bodies
++ const keyCalls = []; // GET /api/v1/people/:subject/keys: who asked, with which token (the Locker's directory)
++ const lockerKeys = {}; // subject id → { subject, ecdh_public, signing_public, fingerprint }
const issued = []; // every client-credentials access token handed out (security-secrets.test.js looks for them)
let issuer = 'http://network.test';
@@@ -64,6 +66,17 @@ function start({ clientSecret = 'shh' } = {}) {
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 });
++ // The Locker key directory: a PERSON route (Network server/locker/keys.js), never a service token.
++ const keyPath = /^\/api\/v1\/people\/([^/]+)\/keys$/.exec(req.url);
++ if (keyPath && req.method === 'GET') {
++ const subject = decodeURIComponent(keyPath[1]);
++ const auth = String(req.headers.authorization || '');
++ keyCalls.push({ subject, authorization: auth });
++ try { jwt.verify(auth.slice(7), publicPem, { algorithms: ['RS256'], issuer }); }
++ catch { return json(401, { error: 'unauthorized' }); }
++ const found = lockerKeys[subject];
++ return found ? json(200, found) : json(404, { code: 'locker.no_keys', error: 'no keys' });
++ }
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' });
@@@ -124,7 +137,11 @@ function start({ clientSecret = 'shh' } = {}) {
return new Promise((resolve) => server.listen(0, '127.0.0.1', () => {
const url = `http://127.0.0.1:${server.address().port}`;
issuer = url;
-- resolve({ url, grants, issued, resolveCalls, pushes, directory, addUser, sign, signService, publicPem, close: () => new Promise((r) => server.close(r)) });
++ resolve({
++ url, grants, issued, resolveCalls, pushes, keyCalls, directory, addUser, sign, signService, publicPem,
++ setKeys: (subjectId, keys) => { lockerKeys[subjectId] = keys; },
++ close: () => new Promise((r) => server.close(r)),
++ });
}));
}
ddiff --git a/test/locked-pastes.test.js b/test/locked-pastes.test.js
nnew file mode 100644
iindex 0000000..12a1f9f
---- /dev/null
++++ b/test/locked-pastes.test.js
@@@ -0,0 +1,229 @@
++'use strict';
++/**
++ * Locked pastes (Locker design 2026-11-06, "The pilot, spelled out: locked pastes"): Community stores only the
++ * envelope, never the plaintext or a content key; the page opens it in the browser; raw and download are the bytes;
++ * removing a member re-seals it; and a locked paste is never public — no list, feed, sitemap, llms, search or JSON-LD.
++ *
++ * The envelopes and slots are real (openvibe-sdk/locker, P-256), so the people-lock paths are the browser's.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/app');
++const locker = require('openvibe-sdk/locker');
++const searchDocsModule = require('../server/search/documents');
++
++(async () => {
++ const t = await boot({ pasteLimits: { cooldownSeconds: 0, commentCooldownSeconds: 0 }, appOpts: { startSharingWorker: false } });
++ const net = t.network;
++ const alex = net.addUser({ network_user_id: 31, username: 'alex', display_name: 'Alex' });
++ const maticus = net.addUser({ network_user_id: 32, username: 'maticus', display_name: 'Maticus' });
++ const quinn = net.addUser({ network_user_id: 33, username: 'quinn', display_name: 'Quinn' });
++ const jwt = (u) => net.sign({ id: u.network_user_id, subject_id: u.subject_id, username: u.username, display_name: u.display_name, role: 'user' });
++ const alexJwt = jwt(alex), maticusJwt = jwt(maticus), quinnJwt = jwt(quinn);
++ const SUBJ = {
++ alex: { type: 'user', id: alex.subject_id },
++ maticus: { type: 'user', id: maticus.subject_id },
++ quinn: { type: 'user', id: quinn.subject_id },
++ };
++ const people = {
++ alex: await locker.generatePersonKeys(),
++ maticus: await locker.generatePersonKeys(),
++ quinn: await locker.generatePersonKeys(),
++ };
++ const pubs = {};
++ for (const [name, person] of Object.entries(people)) {
++ pubs[name] = await locker.exportPersonKeys(person);
++ net.setKeys(SUBJ[name].id, { subject: SUBJ[name], ecdh_public: pubs[name].ecdh, signing_public: pubs[name].signing, fingerprint: pubs[name].fingerprint });
++ }
++
++ const call = (pathname, { method = 'GET', cookie, json, headers = {} } = {}) => {
++ const h = { ...headers };
++ let body;
++ if (json !== undefined) { h['content-type'] = 'application/json'; body = JSON.stringify(json); }
++ return t.get(pathname, { method, headers: h, body, cookies: cookie ? [`ov_token=${cookie}`] : [] });
++ };
++ const share = (slug, opts = {}) => call(`/api/v1/paste/${encodeURIComponent(slug)}/share`, { headers: { 'x-requested-with': 'ov-share' }, ...opts });
++ const DOC = { v: 1, type: 'paste', title: 'Bridge notes', content: 'the secret words', language: 'text' };
++ const plain = JSON.stringify(DOC);
++ const b64 = (u8) => Buffer.from(u8).toString('base64');
++ const peopleSealed = async (names) => await locker.seal(plain, { lock: 'people', keys: names.map((n) => ({ subject: SUBJ[n], publicKey: pubs[n].ecdh })) });
++ const row = async (slug) => await t.db.prepare('SELECT * FROM pastes WHERE slug = ?').get(slug);
++ const shareRow = async (slug) => await t.db.prepare('SELECT * FROM shares WHERE service = ? AND kind = ? AND resource_id = ?').get('community', 'paste', slug);
++ const slotOf = (sealed, name) => sealed.header.slots.people.find((s) => s.subject.id === SUBJ[name].id);
++
++ let peopleSlug; // alex's, for alex and maticus
++
++ await check('a people-locked paste stores only the envelope; no response carries the text', async () => {
++ const sealed = await peopleSealed(['alex', 'maticus']);
++ const r = await call('/api/pastes', {
++ method: 'POST', cookie: alexJwt,
++ json: {
++ locked: true, envelope: b64(sealed.bytes), audience: 'people', title: DOC.title,
++ members: [
++ { name: 'maticus', role: 'viewer', key_slot: slotOf(sealed, 'maticus') },
++ ],
++ },
++ });
++ assert.strictEqual(r.status, 201, r.text);
++ peopleSlug = r.json().slug;
++
++ const stored = await row(peopleSlug);
++ assert.strictEqual(stored.locked, true);
++ assert.strictEqual(stored.lock_kind, 'people');
++ assert.strictEqual(stored.title, 'A locked paste', 'the title is inside the envelope unless the owner shows it');
++ assert.strictEqual(stored.title_shown, false);
++ assert.strictEqual(stored.content, null, 'no plaintext column');
++ assert.strictEqual(stored.envelope, b64(sealed.bytes), 'the envelope is stored as it came');
++ assert.ok(!JSON.stringify(stored).includes('the secret words'), 'nothing of the plaintext is in the row');
++ assert.strictEqual((await shareRow(peopleSlug)).lock, 'people', 'the share knows how it is sealed');
++
++ const asOwner = (await call(`/api/pastes/${peopleSlug}`, { cookie: alexJwt })).json().paste;
++ assert.strictEqual(asOwner.content, null);
++ assert.strictEqual(asOwner.title, null, 'the hidden title is not in the API either');
++ assert.strictEqual(asOwner.locked, true);
++ assert.strictEqual(asOwner.lock, 'people');
++ assert.strictEqual(asOwner.envelope_url, `/p/${peopleSlug}/raw`);
++
++ const asMember = (await call(`/api/pastes/${peopleSlug}`, { cookie: maticusJwt })).json().paste;
++ assert.strictEqual(asMember.content, null);
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}`, { cookie: quinnJwt })).status, 404, 'not on the list: 404');
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}`)).status, 404, 'signed out: 404');
++
++ const page = await call(`/p/${peopleSlug}`, { cookie: maticusJwt });
++ assert.strictEqual(page.status, 200);
++ assert.ok(!page.text.includes('the secret words'), 'the page never has the text');
++ assert.ok(!page.text.includes(DOC.title), 'nor the title, when it is hidden');
++ assert.match(page.text, /paste-locked/, 'the page has the locker card');
++ assert.match(page.text, /\/shared\/ov-locker\.js/);
++ assert.match(page.text, new RegExp(`/p/${peopleSlug.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}/raw`), 'the card is given the envelope address');
++ // The meta line describes the paste, not its contents: no "Plain text" and no line count, which would both
++ // describe what is inside the envelope. The actions are Share (and Delete); the file is one quiet line.
++ assert.ok(!/\b0 lines\b/.test(page.text), 'no line count on a locked paste');
++ assert.ok(!/>Plain text<\/a>/.test(page.text), 'no language badge on a locked paste');
++ assert.match(page.text, /specific people/, 'the audience chip is there');
++ assert.doesNotMatch(page.text, /Raw envelope/, 'the envelope is not a primary action');
++ assert.match(page.text, /Download the locked file \(\.ovlk\)[^<]*<\/a> — it opens only with its key/);
++
++ // The key directory was asked as the owner (Network refuses a service token here), never with Community's.
++ assert.ok(net.keyCalls.length > 0, 'the owner GET asked for the members’ keys');
++ assert.ok(net.keyCalls.every((c) => /^Bearer /.test(c.authorization)), 'the lookups carried a person token');
++ });
++
++ await check('a locked paste refuses plaintext, a bad envelope, a public audience and a screenshot', async () => {
++ const sealed = await locker.seal(plain, { lock: 'link' });
++ const base = { locked: true, envelope: b64(sealed.bytes) };
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { ...base, content: 'plain' } })).status, 400, 'plaintext and an envelope together');
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { locked: true, envelope: 'not-an-envelope' } })).status, 400);
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { ...base, audience: 'public' } })).status, 400, 'never public');
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { ...base, burn_after_read: true } })).status, 400, 'no burn');
++ assert.strictEqual((await call('/api/pastes', { method: 'POST', json: base })).status, 401, 'locking needs an account');
++ });
++
++ await check('a locked paste is never in lists, feeds, the sitemap, llms, search or JSON-LD', async () => {
++ const sealed = await locker.seal(plain, { lock: 'link' });
++ const made = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { locked: true, envelope: b64(sealed.bytes), audience: 'link', show_title: true, title: 'Shown title' } });
++ assert.strictEqual(made.status, 201, made.text);
++ const slug = made.json().slug;
++ const stored = await row(slug);
++ assert.strictEqual(stored.visibility, 'unlisted', 'a link lock is unlisted, never public');
++ assert.strictEqual(stored.title, 'Shown title', 'the owner chose to show this one');
++
++ for (const [where, path] of [['the list', '/api/pastes?limit=200'], ['the feed', '/feed.xml'], ['the sitemap', '/sitemap.xml'], ['llms', '/llms-full.txt']]) {
++ const out = await call(path);
++ assert.ok(!out.text.includes(slug), `${where} does not carry the locked paste`);
++ }
++ const docs = searchDocsModule.createSearchDocuments({ db: t.db });
++ assert.deepStrictEqual(docs.pasteDocument(stored), { deleted: true }, 'Search never holds it');
++ const page = await call(`/p/${slug}`);
++ assert.ok(!page.text.includes('the secret words'));
++ assert.ok(!page.text.includes('"@type":"Article"') && !page.text.includes('"@type": "Article"'), 'no Article JSON-LD for a locked paste');
++ assert.match(page.text, /Shown title/, 'a shown title is on the page');
++ });
++
++ await check('raw and download are the envelope bytes, named .ovlk', async () => {
++ const sealed = await locker.seal(plain, { lock: 'password', password: 'correct horse battery staple' });
++ const made = await call('/api/pastes', { method: 'POST', cookie: alexJwt, json: { locked: true, envelope: b64(sealed.bytes), audience: 'link' } });
++ const slug = made.json().slug;
++ const raw = await call(`/p/${slug}/raw`, { cookie: alexJwt });
++ assert.strictEqual(raw.status, 200);
++ assert.match(raw.headers.get('content-type'), /application\/octet-stream/);
++ assert.match(raw.headers.get('content-disposition'), new RegExp(`${slug}\\.ovlk`));
++ assert.strictEqual(Number(raw.headers.get('content-length')), sealed.bytes.length, 'the bytes served are exactly the envelope');
++ assert.ok(raw.text.startsWith('OVLK'), 'a Locker envelope, not text');
++ const dl = await call(`/p/${slug}/download`, { cookie: alexJwt });
++ assert.strictEqual(dl.status, 200);
++ assert.match(dl.headers.get('content-disposition'), /^attachment; filename="/);
++ assert.strictEqual(Number(dl.headers.get('content-length')), sealed.bytes.length);
++ assert.strictEqual((await call(`/p/${slug}/raw`, { cookie: quinnJwt })).status, 200, 'a link lock fetches for anyone');
++ });
++
++ await check('adding someone wraps for them; removing someone re-seals and their key opens nothing', async () => {
++ const current = await row(peopleSlug);
++ const bytes = Buffer.from(current.envelope, 'base64');
++ // Add quinn: the content key is wrapped for their key; quinn's slot comes from the new envelope.
++ const added = await locker.rewrap(bytes, {
++ add: [{ subject: SUBJ.quinn, publicKey: pubs.quinn.ecdh }],
++ credentials: { privateKey: people.alex.ecdh.privateKey, subject: SUBJ.alex },
++ });
++ const stored = await t.db.prepare('SELECT key_slot FROM share_members WHERE service = ? AND kind = ? AND resource_id = ? AND subject = ?::jsonb')
++ .get('community', 'paste', peopleSlug, JSON.stringify(SUBJ.maticus));
++ const matSlot = stored.key_slot;
++ let r = await share(peopleSlug, {
++ method: 'PUT', cookie: alexJwt,
++ json: { audience: 'people', members: [
++ { subject: SUBJ.maticus, role: 'viewer', key_slot: matSlot },
++ { subject: SUBJ.quinn, role: 'viewer', key_slot: slotOf(added, 'quinn') },
++ ], envelope: b64(added.bytes) },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(added.bytes, { privateKey: people.quinn.ecdh.privateKey, subject: SUBJ.quinn }))], [...Buffer.from(plain)], 'quinn opens it');
++
++ // Remove maticus: a fresh key for those who stay, stored before the member goes.
++ const removed = await locker.rewrap(added.bytes, {
++ remove: [SUBJ.maticus],
++ keys: [{ subject: SUBJ.alex, publicKey: pubs.alex.ecdh }, { subject: SUBJ.quinn, publicKey: pubs.quinn.ecdh }],
++ credentials: { privateKey: people.alex.ecdh.privateKey, subject: SUBJ.alex },
++ });
++ r = await share(peopleSlug, {
++ method: 'PUT', cookie: alexJwt,
++ json: { audience: 'people', members: [
++ { subject: SUBJ.quinn, role: 'viewer', key_slot: slotOf(removed, 'quinn') },
++ ], envelope: b64(removed.bytes) },
++ });
++ assert.strictEqual(r.status, 200, r.text);
++ const after = await row(peopleSlug);
++ assert.strictEqual(after.envelope, b64(removed.bytes), 'the re-sealed envelope replaced the stored one');
++ assert.ok(!locker.inspect(removed.bytes).recipients.includes(await locker.jwkThumbprint(pubs.maticus.ecdh)), 'maticus’s slot is gone from the envelope');
++ await assert.rejects(async () => await locker.open(removed.bytes, { privateKey: people.maticus.ecdh.privateKey, subject: SUBJ.maticus }), 'maticus opens nothing');
++ assert.deepStrictEqual([...new Uint8Array(await locker.open(removed.bytes, { privateKey: people.quinn.ecdh.privateKey, subject: SUBJ.quinn }))], [...Buffer.from(plain)], 'quinn still opens it');
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}`, { cookie: maticusJwt })).status, 404, 'maticus is off the list');
++
++ // The owner's sheet now sees quinn's fingerprint (asked as alex) and no warning.
++ const view = (await share(peopleSlug, { cookie: alexJwt })).json();
++ assert.strictEqual(view.lock, 'people');
++ const members = view.members.filter((m) => m.subject && m.role === 'viewer');
++ assert.strictEqual(members.length, 1);
++ assert.strictEqual(members[0].key_fingerprint, pubs.quinn.fingerprint);
++ assert.strictEqual(members[0].fingerprint_changed, false);
++ assert.strictEqual(view.envelope_url, `https://openvibe.community/p/${peopleSlug}/raw`);
++ });
++
++ await check('a locked paste cannot be edited, forked, or have its body read', async () => {
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}`, { method: 'PUT', cookie: alexJwt, json: { content: 'rewrite' } })).status, 400);
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}`, { method: 'PUT', cookie: alexJwt, json: { title: 'new title' } })).status, 400);
++ assert.strictEqual((await call(`/api/pastes/${peopleSlug}/fork`, { method: 'POST', cookie: alexJwt, json: {} })).status, 400);
++ const versions = await call(`/api/pastes/${peopleSlug}/versions`, { cookie: alexJwt });
++ assert.strictEqual(versions.status, 200);
++ assert.strictEqual(versions.json().versions[0].content, null, 'no content in the history either');
++ });
++
++ await check('comments are allowed and are not locked', async () => {
++ const r = await call(`/api/pastes/${peopleSlug}/comments`, { method: 'POST', cookie: quinnJwt, json: { message: 'read it, thanks' } });
++ assert.strictEqual(r.status, 201, r.text);
++ const page = await call(`/p/${peopleSlug}`, { cookie: quinnJwt });
++ assert.ok(page.text.includes('read it, thanks'));
++ assert.match(page.text, /Comments are not locked/);
++ });
++
++ await t.close();
++ done();
++})();
ddiff --git a/test/perf-budget.test.js b/test/perf-budget.test.js
iindex d5d4e34..3c5f298 100644
---- a/test/perf-budget.test.js
++++ b/test/perf-budget.test.js
@@@ -18,8 +18,8 @@ const BUDGETS = {
jsRawKB: 409, // 408.2 with openvibe-shared 4.77.0: the navbar loads the glyphs when a page first shows an icon (2026-11-01); 408: 408: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4; Font Awesome's CSS and font were external, uncounted, and heavier); 339.1 with openvibe-shared 4.66.0: the mark with depth and motion, ov-fx loaded on request (2026-10-31); 338.3 with openvibe-shared 4.64.0: the look travels between sites, the Look panel in the menu (2026-10-31); 337.5 with openvibe-shared 4.59.0: the Pair mark beside the name, set in the brand face (2026-10-31); 335.4 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 332.4 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 329.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 322 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 314 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 306.5 openvibe-shared 4.22.0: the navbar's magnifier search button and More dropdown (search.js and loading.js load only when used); 300.7 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 286.6 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 276.8 with shared 4.11.0's seasonal navbar; 267.5 with openvibe-shared 4.9.0 (status text twins in every theme, 2026-10-23); 251.3 (was 208.8 before the shell's web-runtime.js)
jsBrotliKB: 100.5, // 100.5: /shared/ov-glyphs.js in place of Font Awesome (2026-11-01, plan L54 K4); 83.1 with openvibe-shared 4.53.0: the footer's columns fold on phones (2026-10-30); 82.3 with openvibe-shared 4.52.0: the Dock's loader and its Chats tab (2026-10-30); 81.7 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 80.2 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 78.1 openvibe-shared 4.25.0: the menu's orb beside the brand, both balances on phones, messages, the account sheet on phones and the bar stepping away on phones; 74.4 Shared 4.17.0: OpenCoins and Vibes chips (wallet: true) in the navbar; 71.7 openvibe-shared 4.15 footer: the compact footer opens into the whole one, and the pulse line; 69.1 Shared 4.12.0: the light/dark switch, the seasonal mark and the theme pairs; 65.5 with shared 4.11.0's seasonal navbar; 59.9 (was 48.9)
cssFiles: 5, // 5 the short home: + kit.css (numbers, lists, chips, the fold) and room.css (cards and rails), the shared kit's look; 3
-- cssRawKB: 131, // 130.1 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 129.4 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 127.8 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 126.2 with openvibe-shared 4.83.0: the -text status tokens in kit.css and room.css (2026-11-01); 125.4 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 123.3 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 117.7 openvibe-shared 4.71.0 (2026-11-01: the navbar in OpenVibe glyphs, the icons setting); 114.3 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 109.0 the phone home (2026-10-30: the chips and the fold rules); 104.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 102.6 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 97.3 the short home (2026-10-29): kit.css 25.6 KB + room.css 15.7 KB, while community.css sheds the old shelves' rules and the page 10 of its 15 phone screens; 54.9 openvibe-shared 4.26.1 (hero visual); 47.2 with Shared 4.13.0 (2026-10-26); 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase home
-- cssBrotliKB: 28, // 27.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 27.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 26.7 with openvibe-shared 4.83.0: the -text status tokens in kit.css and room.css (2026-11-01); 26.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 26.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 25.9 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 23.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 22.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 21.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 20.4 the short home (kit.css + room.css, as above; was 11.5 once Kit 2 had its own files in openvibe-shared v4.37.0); 9.5 with Shared 4.13.0 (2026-10-26); 7.7 // raised 11 -> 12 for the openvibe-shared Kit 2 pin (kit.css grew)
++ cssRawKB: 134, // 133.0 with openvibe-shared 4.116.0: the locked paste's styles (the Lock it form, the locked card, the opened text) (2026-11-06); 130.1 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 129.4 with 2026-11-02: openvibe-shared 4.100.1 (the conversation kit's parts, date covers, room CSS) (2026-11-02); 127.8 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 126.2 with openvibe-shared 4.83.0: the -text status tokens in kit.css and room.css (2026-11-01); 125.4 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 123.3 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 117.7 openvibe-shared 4.71.0 (2026-11-01: the navbar in OpenVibe glyphs, the icons setting); 114.3 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 109.0 the phone home (2026-10-30: the chips and the fold rules); 104.8 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 102.6 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 97.3 the short home (2026-10-29): kit.css 25.6 KB + room.css 15.7 KB, while community.css sheds the old shelves' rules and the page 10 of its 15 phone screens; 54.9 openvibe-shared 4.26.1 (hero visual); 47.2 with Shared 4.13.0 (2026-10-26); 40.6 on 10-22 (the home shelves' styles in community.css); was 40 at 36.5 with the showcase home
++ cssBrotliKB: 28.5, // 28.2 with openvibe-shared 4.116.0: the locked paste's styles (2026-11-06); 27.6 with openvibe-shared 4.103.0 (2026-11-02: the puzzle glyph, two-line sidebar rows, boost lets parsed pages go, eight solids) (2026-11-02); 27.1 with openvibe-shared 4.93.2: content labels, glyph fallback, footer counts (2026-11-01); 26.7 with openvibe-shared 4.83.0: the -text status tokens in kit.css and room.css (2026-11-01); 26.8 with openvibe-shared 4.82.0: room over the rounded measure (2026-11-01); 26.5 with openvibe-shared 4.82.0: the motion tokens in kit.css and room.css (2026-11-01); 25.9 with openvibe-shared 4.81.0: shape and elevation tokens in kit.css and room.css (2026-11-01); 23.8 with openvibe-shared 4.62.0: the shared sidebar in kit.css, the glyphs and their fonts (2026-10-31); 22.1 with openvibe-shared 4.48.0 (the site switcher on the mark, the menu's Start tab and quick tools; Kit's touch hit areas and the phone navbar fix, 4.44-4.47.3); 21.7 with openvibe-shared 4.43.0 (room.css carries the kit tokens, 4.39.1; the kit's 4.39.0 rules); 20.4 the short home (kit.css + room.css, as above; was 11.5 once Kit 2 had its own files in openvibe-shared v4.37.0); 9.5 with Shared 4.13.0 (2026-10-26); 7.7 // raised 11 -> 12 for the openvibe-shared Kit 2 pin (kit.css grew)
externalFiles: 2, // 1
};
ddiff --git a/test/store.test.js b/test/store.test.js
iindex b686304..992eab0 100644
---- a/test/store.test.js
++++ b/test/store.test.js
@@@ -215,7 +215,7 @@ async function rejects(p, status, message) {
// 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(await capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'todayCount']);
++ assert.deepStrictEqual(Object.keys(await capped.config(dave)), ['maxSizeKb', 'screenshotMaxSizeMb', 'cooldownSeconds', 'maxPerUserPerDay', 'lockedMaxSizeKb', 'todayCount']);
assert.strictEqual((await capped.config(dave)).todayCount, 1);
});