openvibers/openvibe.exchange Commits

Sharing on chats, characters and Arena rounds: the one Share sheet, Only me by default (#97)

OP OpenVibers committed 6 days ago · 92b125122c · 21 files, +912 −229

* Sharing on chats, characters and Arena rounds: the one Share sheet, Only me by default

Chats with models, character cards and Arena rounds now carry openvibe-sdk/share's one Share sheet
(openvibe-shared ov-share.js) and its one check, canView. A new chat or round is Only me; a card is
Only me while it is written. Each item can be Everyone, Anyone with the link, Specific people or
Only me, and each product page offers the person's default for new items with "Apply to all".

- server/sharing.js: the wiring for three kinds (character, chat, arena-round), reporting
  share.* through Events and Network's notifications, with a 15 s delivery worker.
- Read paths: /chat/:id serves a shared conversation without its composer; /arena/:id serves a
  shared round with both names and no vote; /c/:handle/:character already checked canView.
  Specific people who are not on the list get the sheet's gate, never the title or a turn.
- Sealed chats stay Only me and say why, in the page and from the API (share.invalid).
- migrations 0011 (backfill existing rows; a card maps its old audience column) and 0012 (per-kind
  default), both expand-only. Account deletion erases the sharing rows.

* The share gate from markup on openvibe-shared 4.123.0

The "not on the list" gate was an inline script. An inline script needs a CSP nonce on most of the
network's sites, and a JSON.stringify'd value inside an HTML-escaping template breaks it, so the
gate never drew on some sites. 4.123.0 draws it from [data-ov-share-gate] instead: the page sends
markup, ov-share.js starts the gate when it loads, and every value is escaped like any other
attribute.

- Chat, character and Arena-round gates now render data-kind/data-id/data-signed-in-as/
  data-sign-in-url on the element and keep only the <script src=…ov-share.js> tag.
- openvibe-shared v4.118.0 → v4.123.0 in package.json, package-lock.json, README and STATUS.
- test/share-gate-chrome.test.js boots the app and drives headless Chrome: signed out the gate
  offers Sign in with the login link; signed in as somebody not on the list it offers Ask for
  access and names the account; no console error and no CSP refusal.

* Merge remote-tracking branch 'origin/main' into feat/visibility-step2-exchange

# Conflicts:
#	README.md
#	STATUS.json
#	package-lock.json
#	package.json

* Account data covers the share default; the Chrome gate check skips without a browser

The full test suite's account-data guard found exchange_share_defaults (the person's default audience) uncovered, so the person's row now belongs to their export and deletion, and the share rows they own are erased with the rest.

The headless-Chrome gate check now skips (exit 0) when Playwright or Chrome is absent, as Shared's own browser tests do: the CI runner has neither. It still runs on this machine.

Changes

ddiff --git a/README.md b/README.md
iindex b607b11..4c881d5 100644
---- a/README.md
++++ b/README.md
@@@ -72,6 +72,6 @@ AGPL-3.0-only. See [LICENSE](LICENSE).
<!-- versions:start -->
- openvibe-contracts: v0.289.1
- openvibe-sdk: v0.91.0
--- openvibe-shared: v4.122.0
++- openvibe-shared: v4.123.0
- openvibe-publishing: v1.5.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 7863249..12155c5 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -10,11 +10,11 @@
"domain": "openvibe.exchange",
"contracts": "openvibe-contracts v0.289.1",
"sdk": "openvibe-sdk v0.91.0",
-- "shared": "openvibe-shared v4.122.0",
++ "shared": "openvibe-shared v4.123.0",
"packages": [
"openvibe-contracts v0.289.1",
"openvibe-sdk v0.91.0",
-- "openvibe-shared v4.122.0"
++ "openvibe-shared v4.123.0"
],
"summary": "OpenVibe.Exchange is the network's AI front. It reads OpenVibe.AI's public catalog and aggregate usage and renders: the home (popular now, cheapest per million, uncensored, community machines, live numbers), /models and /m/<org>/<model> (every offer with price in/out, measured p50/p95, uptime and data policy, a Try it box, curl and the SDK), /chat (one to three models side by side, system prompt, temperature, history in the account, on openvibe-shared/thread), /api, /sell (Node's offer to the network; what is not sold, said plainly), /uncensored (an 18+ confirmation on the account and the hard floor) and /ai (which products run which models, and the daily cost). Chat and Try it are ai.complete runs on the person's behalf (on_behalf_of) with the service's ai.run.create token. Exchange v1 (free/swap listings, passports, sandboxes, installs, offers) is retired: every v1 page answers 410 with a pointer, its JSON API answers 410 exchange.retired, and its tables stay read-only; its tests live in test/retired/.",
"works": [
ddiff --git a/migrations/0011_share_backfill.sql b/migrations/0011_share_backfill.sql
nnew file mode 100644
iindex 0000000..f5b005e
---- /dev/null
++++ b/migrations/0011_share_backfill.sql
@@@ -0,0 +1,28 @@
++-- phase: expand
++-- Visibility step 2 (design 2026-11-06): every chat, arena round and character card that already exists gets its
++-- sharing row, Only me. New items are registered as they are made (server/sharing.js); this one-time insert means an
++-- item from before this change is still its owner's, and an old reader that never asks the sharing system fails
++-- closed rather than opening it. The tables themselves are 0007_sharing.sql, kept verbatim from openvibe-sdk/share.
++-- Repeating this file is a no-op.
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'exchange', 'chat', id,
++ jsonb_build_object('type', CASE WHEN owner LIKE 'gst_%' THEN 'guest' ELSE 'user' END, 'id', owner), 'private'
++FROM exchange_chats
++WHERE owner IS NOT NULL
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'exchange', 'arena-round', id,
++ jsonb_build_object('type', CASE WHEN owner LIKE 'gst_%' THEN 'guest' ELSE 'user' END, 'id', owner), 'private'
++FROM exchange_arena_rounds
++WHERE owner IS NOT NULL
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
++
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT 'exchange', 'character', id,
++ jsonb_build_object('type', CASE WHEN owner LIKE 'gst_%' THEN 'guest' ELSE 'user' END, 'id', owner),
++ CASE audience WHEN 'public' THEN 'public' WHEN 'unlisted' THEN 'link' ELSE 'private' END
++FROM exchange_characters
++WHERE owner IS NOT NULL
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/migrations/0012_share_defaults.sql b/migrations/0012_share_defaults.sql
nnew file mode 100644
iindex 0000000..3cdd560
---- /dev/null
++++ b/migrations/0012_share_defaults.sql
@@@ -0,0 +1,12 @@
++-- phase: expand
++-- A person's default audience per kind (design 2026-11-06, rule 1: a default per product, with a per-item
++-- override). The product page reads "New chats: Only me ▾"; the Share sheet still changes one item. Without a row
++-- the default is Only me. Expand-only.
++
++CREATE TABLE IF NOT EXISTS exchange_share_defaults (
++ owner text COLLATE "C" NOT NULL,
++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('character', 'chat', 'arena-round')),
++ audience text COLLATE "C" NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
++ updated_at timestamptz NOT NULL DEFAULT now(),
++ PRIMARY KEY (owner, kind)
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex ee826d4..c3bac37 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -19,7 +19,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.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.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
},
"devDependencies": {
@@@ -1249,9 +1249,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.122.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.122.0",
-- "integrity": "sha512-/BneZ1PYDWUM/DBEb8EodvTbsK9a+5EGY+bM8zJi1vgL+D8ohau7EixB4uNcqD0RLXJMu4IUYtSdwb/QPPyPMA==",
++ "version": "4.123.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
++ "integrity": "sha512-9XLlRm24VEf/amaSHm+3OEKYKKrDj9ic4Fii9qsKwtUV9HilQ8cw6H1TNi27m/VIdJcvFkZASpkmxnIhQW+rkw==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0",
ddiff --git a/package.json b/package.json
iindex 35a3e66..8a2da65 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.289.1",
"openvibe-publishing": "https://codeload.github.com/OpenVibers/OpenVibe.Publishing/tar.gz/refs/tags/v1.5.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.122.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.123.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/server/app.js b/server/app.js
iindex eebf926..6029e38 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -34,7 +34,7 @@ const { createArenaStore } = require('./arena/store');
const { createCatalogHistory } = require('./catalog-history');
const { createChatStore, createAdultStore } = require('./chat/store');
const { createCharacterStore, createCompanionChatStore } = require('./companions/store');
--const { createSharing } = require('./companions/sharing');
++const { createSharing } = require('./sharing');
const { createAdultAccount } = require('./companions/adult');
const { createMemoryClient, createCompanionNotes } = require('./companions/memory');
const { createLorebooks } = require('./companions/lorebook');
@@@ -175,6 +175,28 @@ async function createApp(opts = {}) {
},
}));
++ // ── /api/v1/<kind>/<id>/share — the one Share sheet's API (openvibe-sdk/share) ──
++ // GET/PUT /api/v1/chat/:id/share, /character/:id/share, /arena-round/:id/share, and their requests and
++ // transfers, plus GET /api/v1/share/people?name= and /api/v1/shared-with-me. Access is still decided by
++ // canView on every read; the router only answers its own paths.
++ {
++ const shareApi = express.Router();
++ shareApi.use(sharing.shares.router({
++ service: 'exchange',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: sharing.describe,
++ }));
++ const SHARE_PATH = /^\/api\/v1\/(?:shared-with-me$|share\/people$|[^/]+\/[^/]+\/share(?:\/|$))/;
++ app.use((req, res, next) => (SHARE_PATH.test(req.path) ? shareApi(req, res, next) : next()));
++ }
++
++ // Expire members and transfers and send queued share events/notifications. Unref'd: it never holds a test open.
++ if (opts.startSharingWorker !== false) {
++ const timer = setInterval(() => { sharing.work().catch(() => { /* logged inside */ }); }, 15_000);
++ if (timer.unref) timer.unref();
++ app.locals.sharingTimer = timer;
++ }
++
// ── Exchange v1's JSON API: gone ────────────────────────
app.use('/api/v1', (req, res) => {
res.set('Cache-Control', 'public, max-age=86400');
ddiff --git a/server/arena/store.js b/server/arena/store.js
iindex e2f9941..e238b2d 100644
---- a/server/arena/store.js
++++ b/server/arena/store.js
@@@ -28,6 +28,11 @@ function createArenaStore(s) {
return parse(await db.prepare('SELECT * FROM exchange_arena_rounds WHERE id = ? AND owner = ?').get(String(id || ''), owner));
}
++ /** A round without its owner in the query, for the sharing check to decide who may read it. */
++ async function find(id) {
++ return parse(await db.prepare('SELECT * FROM exchange_arena_rounds WHERE id = ?').get(String(id || '')));
++ }
++
async function recent(owner, limit = 8) {
const rows = await db.prepare('SELECT id, prompt, category, created_at FROM exchange_arena_rounds WHERE owner = ? ORDER BY created_at DESC LIMIT ?')
.all(owner, Math.max(1, Math.min(30, limit)));
@@@ -58,7 +63,7 @@ function createArenaStore(s) {
return Number(row && row.n) || 0;
}
-- return { create, get, recent, voteOf, castVote, askedToday, votesLastHour };
++ return { create, get, find, recent, voteOf, castVote, askedToday, votesLastHour };
}
module.exports = { createArenaStore };
ddiff --git a/server/chat/store.js b/server/chat/store.js
iindex d88e3b6..5a9c451 100644
---- a/server/chat/store.js
++++ b/server/chat/store.js
@@@ -21,6 +21,17 @@ function createChatStore(s) {
return row ? { ...row, models: parseModels(row.models) } : null;
}
++ /** A chat without its owner in the query, for the sharing check to decide who may read it. */
++ async function find(id) {
++ const row = await db.prepare('SELECT * FROM exchange_chats WHERE id = ?').get(String(id || ''));
++ return row ? { ...row, models: parseModels(row.models) } : null;
++ }
++
++ async function allMessages(chatId) {
++ const rows = await db.prepare('SELECT * FROM exchange_chat_messages WHERE chat_id = ? ORDER BY created_at, id').all(String(chatId || ''));
++ return rows.map((r) => ({ ...r, meta: parseJson(r.meta) }));
++ }
++
async function list(owner, limit = 60) {
const rows = await db.prepare('SELECT id, title, models, sealed, strict_privacy, updated_at FROM exchange_chats WHERE owner = ? ORDER BY updated_at DESC LIMIT ?')
.all(owner, Math.max(1, Math.min(200, limit)));
@@@ -101,7 +112,7 @@ function createChatStore(s) {
return Number(row && row.n) || 0;
}
-- return { create, get, list, messages, append, rename, settings, clear, remove, countToday, setPrivacy, sealGet, sealPut, createSealed };
++ return { create, get, find, list, messages, allMessages, append, rename, settings, clear, remove, countToday, setPrivacy, sealGet, sealPut, createSealed };
}
function createAdultStore(s) {
ddiff --git a/server/companions/sharing.js b/server/companions/sharing.js
ddeleted file mode 100644
iindex 9e88ee2..0000000
---- a/server/companions/sharing.js
++++ /dev/null
@@@ -1,160 +0,0 @@
--'use strict';
--
--/**
-- * Sharing for character cards (Opus's design, 2026-11-06), through openvibe-sdk/share.
-- *
-- * The SDK owns the model and the rules: the shares row next to each card, the one access check (canView),
-- * members, access requests, transfers, the append-only log and the delivery queue. This file is the wiring
-- * a service supplies, and it is the same wiring OpenVibe.Community gives its pastes:
-- *
-- * - network.json Network's identity lookups (@name to a subject, and a person's card for the page)
-- * - events.publish OpenVibe.Events, so share.* envelopes go out through the same queue
-- * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
-- * - onAudienceChange keeps the card's own audience column in step, so an old reader that only knows the
-- * column fails closed (private stays private)
-- *
-- * The audiences: a card is private by default, unlisted (a link, not in the gallery) or public.
-- * The SDK speaks public, link, people and private; unlisted is its link.
-- */
--const { createShares } = require('openvibe-sdk/share');
--const { serviceAuth } = require('openvibe-contracts');
--
--const SERVICE = 'exchange';
--const KIND = 'character';
--const AUDIENCES = ['public', 'link', 'people', 'private'];
--/** Exchange's audience words to the SDK's, and back. */
--const TO_SDK = { public: 'public', unlisted: 'link', private: 'private' };
--const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
--
--/** A card's share ref: { service: 'exchange', kind: 'character', id: <card id> }. */
--const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
--
--/** Exchange's viewer as the SDK wants it: a subject object, and the staff bit. */
--function shareViewer(v) {
-- if (!v || !v.subject) return null;
-- if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
-- if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
-- return null;
--}
--
--function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
-- const base = String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '');
-- const tokens = config.oauth.clientSecret ? serviceAuth.createTokenClient({
-- tokenUrl: base + '/oauth/token',
-- clientId: config.oauth.clientId,
-- clientSecret: config.oauth.clientSecret,
-- audience: 'openvibe.network',
-- scope: 'identity.subject.resolve network.notifications.push',
-- fetchImpl,
-- }) : null;
--
-- async function networkJson({ path, method = 'GET', json, query } = {}) {
-- if (!tokens) throw new Error('Network is not configured for this service');
-- const url = new URL(base + path);
-- for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
-- const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
-- if (json !== undefined) headers['Content-Type'] = 'application/json';
-- const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
-- const body = await res.json().catch(() => null);
-- if (!res.ok) {
-- const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
-- err.status = res.status;
-- throw err;
-- }
-- return body;
-- }
--
-- /** One notification, addressed by canonical subject. It never carries a card's text. */
-- async function notify(n) {
-- const to = n && n.to;
-- if (!to || to.type !== 'user') return;
-- const words = {
-- 'share.added': ['A character was shared with you', 'Open it on OpenVibe.Exchange.'],
-- 'share.access_requested': ['Someone asked for access to your character', 'Review the request on OpenVibe.Exchange.'],
-- 'share.transfer.requested': ['Someone wants to give you a character', 'Accept or decline it on OpenVibe.Exchange.'],
-- 'share.transfer.answered': ['A character transfer was answered', 'See what happened on OpenVibe.Exchange.'],
-- }[n.kind] || ['Sharing on OpenVibe.Exchange', 'There is something new for you.'];
-- try {
-- await networkJson({
-- path: '/internal/notifications/push', method: 'POST',
-- json: {
-- subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
-- service: 'exchange', url: n.ref && n.ref.id ? config.baseUrl + '/c/' + n.ref.id : config.baseUrl + '/companions',
-- category: 'social',
-- },
-- });
-- } catch (err) {
-- log.warn('[Companions] notification not sent:', err.message);
-- }
-- }
--
-- /** OpenVibe.Events. Exchange has no outbox of its own yet, so the envelope goes straight out. */
-- async function publish(envelope) {
-- if (!config.events.url) { log.warn('[Companions] no events URL configured; share envelope not sent'); return; }
-- const res = await fetchImpl(config.events.url, {
-- method: 'POST',
-- headers: { 'Content-Type': 'application/json' },
-- body: JSON.stringify(envelope),
-- signal: AbortSignal.timeout(8000),
-- });
-- if (!res.ok) throw new Error('Events answered ' + res.status);
-- }
--
-- const shares = createShares({
-- db,
-- network: { json: networkJson },
-- events: { publish },
-- notify,
-- onAudienceChange: async (ref, audience, tx) => {
-- if (ref.service !== SERVICE || ref.kind !== KIND) return;
-- await tx.exec('UPDATE exchange_characters SET audience = $1, updated_at = now() WHERE id = $2', [FROM_SDK[audience] || 'private', ref.id]);
-- },
-- onOwnerChange: async (ref, from, to, tx) => {
-- if (ref.service !== SERVICE || ref.kind !== KIND) return;
-- const owner = to && to.type === 'user' ? to.id : null;
-- await tx.exec('UPDATE exchange_characters SET owner = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
-- },
-- });
--
-- const sv = (viewer) => shareViewer(viewer);
-- const at = (id) => refOf(id);
--
-- return {
-- shares,
-- SERVICE,
-- KIND,
-- AUDIENCES,
-- TO_SDK,
-- FROM_SDK,
-- refOf,
-- shareViewer,
-- networkJson,
-- /** The SDK's own DDL, run by the migration role (server/db.js). */
-- migrate: async () => await shares.migrate(),
-- /** Register a card with the sharing system, private until its owner says otherwise. */
-- register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
-- owner_subject: { type: 'user', id: String(viewer.subject) },
-- audience: TO_SDK[audience] || 'private',
-- }),
-- canView: async (viewer, id) => await shares.canView(sv(viewer), at(id)),
-- canEdit: async (viewer, id) => await shares.canEdit(sv(viewer), at(id)),
-- get: async (viewer, id) => await shares.get(sv(viewer), at(id)),
-- getWithRevision: async (viewer, id) => await shares.getWithRevision(sv(viewer), at(id)),
-- setAudience: async (viewer, id, audience) => await shares.setAudience(sv(viewer), at(id), TO_SDK[audience] || 'private'),
-- addMembers: async (viewer, id, list) => await shares.addMembers(sv(viewer), at(id), list),
-- removeMember: async (viewer, id, subject) => await shares.removeMember(sv(viewer), at(id), subject),
-- requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),
-- lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
-- sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
-- /** Expire members and transfers, then send the queued events and notifications. */
-- work: async ({ flush = true } = {}) => {
-- let expired = 0;
-- try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Companions] expiry sweep:', err.message); }
-- let sent = 0;
-- if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Companions] deliveries:', err.message); } }
-- return { expired, sent };
-- },
-- };
--}
--
--module.exports = { createSharing, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
ddiff --git a/server/http/arena.js b/server/http/arena.js
iindex 2849afd..83e6752 100644
---- a/server/http/arena.js
++++ b/server/http/arena.js
@@@ -15,12 +15,14 @@
*/
const express = require('express');
const cache = require('openvibe-shared/cache-policy');
++const ovServe = require('openvibe-shared/serve');
const kit = require('openvibe-shared/kit');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
--const { raw } = require('../render/html');
++const { esc, raw } = require('../render/html');
const { send } = require('../render/layout');
const views = require('../views/arena');
++const exchangeViews = require('../views/exchange');
const { classify, CATEGORIES } = require('../arena/category');
const { ARENA_KIND } = require('../bench');
@@@ -66,7 +68,7 @@ function pickPair(candidates) {
}
function createArenaRoutes(ctx) {
-- const { config, ai, bench, arenaStore, log } = ctx;
++ const { config, ai, bench, arenaStore, sharing, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -80,29 +82,61 @@ function createArenaRoutes(ctx) {
title: 'Not from this site', index: false,
body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'shield', title: 'That came from somewhere else', text: 'Use the form on OpenVibe.Exchange.' })}</div>`),
}, 403);
++ const shareHead = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
++ const shareButton = (round) => `<button type="button" class="ovk-btn ovk-btn--quiet" data-ov-share-sheet data-kind="arena-round" data-id="${esc(round.id)}" data-title="An Arena round">Share</button>`;
++ const notFound = (req, res) => {
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account, or the link is old.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
++ };
++ /** Specific people, and this reader is not on the list: the audience, never the prompt or an answer. */
++ const gate = (req, res, id) => {
++ const signedInAs = req.viewer && req.viewer.kind === 'user' ? (req.viewer.username || '') : '';
++ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
++ return page(req, res, {
++ title: 'Shared with specific people', index: false, head: shareHead,
++ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline
++ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.
++ body: raw(`<div class="ex-wrap ex-narrow"><div id="share-gate" data-ov-share-gate data-kind="arena-round" data-id="${esc(id)}" data-signed-in-as="${esc(signedInAs)}" data-sign-in-url="${esc(signInUrl)}">This round is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div></div>`),
++ }, 404);
++ };
async function renderHome(req, res, { problem = null, status = 200 } = {}) {
const [catalog, rounds] = await Promise.all([
ai.catalog(),
signedIn(req) ? arenaStore.recent(owner(req), 6) : Promise.resolve([]),
]);
++ const defaults = signedIn(req)
++ ? exchangeViews.audienceDefaults({
++ kind: 'arena-round', label: 'rounds', action: '/arena/defaults', signedIn: true,
++ current: await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private'),
++ }) : '';
return page(req, res, {
title: 'The Arena', index: !signedIn(req), cache: signedIn(req) ? null : PUBLIC_CACHE,
description: 'One prompt, two models, side by side and unnamed. Vote, then see both names, their prices and their receipts.',
-- body: raw(views.arenaHome({ viewer: req.viewer, catalog, rounds, problem, config })),
++ body: raw(views.arenaHome({ viewer: req.viewer, catalog, rounds, problem, config, defaults })),
}, status);
}
async function renderRound(req, res, round, { problem = null, status = 200, notice = null } = {}) {
const vote = await arenaStore.voteOf(owner(req), round.id);
return page(req, res, {
-- title: 'The Arena', index: false,
-- body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config })),
++ title: 'The Arena', index: false, head: shareHead,
++ body: raw(views.arenaRound({ round, vote, revealed: Boolean(vote), problem: problem || notice, config, action: shareButton(round) })),
}, status);
}
r.get('/arena', async (req, res) => renderHome(req, res));
++ /** The person's default for new rounds ("New rounds: Only me"), and what to do with the existing ones. */
++ r.post('/arena/defaults', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Farena');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const body = req.body || {};
++ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
++ await sharing.setDefault(owner(req), 'arena-round', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Arena] the round default was not saved:', err.message));
++ return res.redirect(303, '/arena');
++ });
++
r.post('/arena/ask', async (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to enter the Arena', text: 'Two models answer your prompt, blind.', next: '/arena' });
if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
@@@ -181,14 +215,33 @@ function createArenaRoutes(ctx) {
benchRoundId: built.round && built.round.id,
benchLeft: left.id, benchRight: right.id,
});
++ // The person's default for new rounds (Only me unless they changed it), then the sheet for this one.
++ const audience = await sharing.defaultOf(owner(req), 'arena-round').catch(() => 'private');
++ try { await sharing.register({ subject: owner(req) }, 'arena-round', round.id, audience); } catch (err) { log.warn('[Arena] the round was kept but not registered with the sharing system:', err.message); }
return res.redirect(303, `/arena/${round.id}`);
});
++ /**
++ * A round is its owner's page. Someone it was shared with reads the pair, both names and the receipts, and
++ * cannot vote (the owner's vote is the one Bench counted). Anybody else gets the gate for Specific people, or
++ * "not found" when it is Only me.
++ */
r.get('/arena/:id', async (req, res) => {
-- if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to see this round', text: 'Your rounds are yours.', next: `/arena/${encodeURIComponent(req.params.id)}` });
-- const round = await arenaStore.get(owner(req), req.params.id);
-- if (!round) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such round', text: 'It may belong to another account, or the link is old.', action: { href: '/arena', label: 'The Arena' } })}</div>`) }, 404);
-- return renderRound(req, res, round);
++ const round = await arenaStore.find(req.params.id);
++ if (!round) return notFound(req, res);
++ if (signedIn(req) && round.owner === owner(req)) return renderRound(req, res, round);
++ const allowed = await sharing.canView(req.viewer, 'arena-round', round.id).catch(() => false);
++ if (!allowed) {
++ const audience = await sharing.audienceOf('arena-round', round.id).catch(() => null);
++ if (audience === 'people') return gate(req, res, round.id);
++ return notFound(req, res);
++ }
++ const sharedRound = { ...round };
++ return page(req, res, {
++ title: 'A shared Arena round', index: false,
++ head: shareHead,
++ body: raw(views.arenaRound({ round: sharedRound, revealed: true, shared: true, config })),
++ });
});
r.post('/arena/:id/vote', async (req, res) => {
ddiff --git a/server/http/companions.js b/server/http/companions.js
iindex 54a0767..9ad87c2 100644
---- a/server/http/companions.js
++++ b/server/http/companions.js
@@@ -20,12 +20,14 @@
*/
const express = require('express');
const kit = require('openvibe-shared/kit');
++const ovServe = require('openvibe-shared/serve');
const thread = require('openvibe-shared/thread');
const { asyncRouter } = require('./router');
const { sameOrigin } = require('./principal');
const { esc, raw } = require('../render/html');
const { send } = require('../render/layout');
const views = require('../views/companions');
++const exchangeViews = require('../views/exchange');
const floor = require('../companions/floor');
const codec = require('../companions/codec');
const { tokensOf, KINDS: NOTE_KINDS } = require('../companions/memory');
@@@ -46,6 +48,18 @@ function createCompanionRoutes(ctx) {
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
const signedIn = (req) => Boolean(req.viewer && req.viewer.kind === 'user' && req.viewer.subject);
const owner = (req) => req.viewer.subject;
++ const shareHead = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
++ /** Specific people, and this reader is not on the list: the audience, never the card's text. */
++ function gatePage(req, res, id) {
++ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';
++ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
++ return page(req, res, {
++ title: 'Shared with specific people', index: false, head: shareHead,
++ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline
++ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.
++ body: raw(`<div class="ex-wrap ex-narrow"><div id="share-gate" data-ov-share-gate data-kind="character" data-id="${esc(id)}" data-signed-in-as="${esc(signedInAs)}" data-sign-in-url="${esc(signInUrl)}">This character is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its creator for access.</div></div>`),
++ }, 404);
++ }
const handle = (req) => handleOf(req.viewer && (req.viewer.username || req.viewer.displayName));
const notFromHere = (req, res) => page(req, res, {
title: 'Not from this site', index: false,
@@@ -227,10 +241,26 @@ function createCompanionRoutes(ctx) {
index: true,
title: q ? 'Characters: ' + q : 'Characters',
description: 'Character cards on OpenVibe.Exchange: bring a card in as Character Card V2 or V3, talk to it, and take it with you again.',
-- body: raw(views.galleryPage({ items: gallery.items, total: gallery.total, q, tags, sort, tagList, viewer: req.viewer, adult: adultOk, mine })),
++ body: raw(views.galleryPage({
++ items: gallery.items, total: gallery.total, q, tags, sort, tagList, viewer: req.viewer, adult: adultOk, mine,
++ defaults: signedIn(req) ? exchangeViews.audienceDefaults({
++ kind: 'character', label: 'characters', action: '/companions/defaults', signedIn: true,
++ current: await sharing.defaultOf(owner(req), 'character').catch(() => 'private'),
++ }) : '',
++ })),
});
});
++ /** The creator's default for new cards ("New characters: Only me"), and what to do with the existing ones. */
++ r.post('/companions/defaults', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fcompanions');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const body = req.body || {};
++ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
++ await sharing.setDefault(owner(req), 'character', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Companions] the card default was not saved:', err.message));
++ return res.redirect(303, '/companions');
++ });
++
// ── Make a card ──────────────────────────────────────────────────────
r.get('/companions/new', (req, res) => {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to make a character', text: 'Your cards stay in your account until you share them.', next: '/companions/new' });
@@@ -272,7 +302,7 @@ function createCompanionRoutes(ctx) {
}
const made = await characters.create({ owner: owner(req), handle: handle(req), ...card });
try {
-- await sharing.register({ subject: owner(req) }, made.id, 'private');
++ await sharing.register({ subject: owner(req) }, 'character', made.id, await sharing.defaultOf(owner(req), 'character').catch(() => 'private'));
} catch (err) {
log.warn('[Companions] the card was saved but not registered with the sharing system:', err.message);
}
@@@ -302,7 +332,7 @@ function createCompanionRoutes(ctx) {
const audience = ['private', 'unlisted', 'public'].includes(req.body && req.body.audience) ? req.body.audience : 'private';
const allowRemix = !!(req.body && req.body.allow_remix);
try {
-- await sharing.setAudience({ subject: owner(req) }, card.id, audience);
++ await sharing.setAudience({ subject: owner(req) }, 'character', card.id, audience);
} catch (err) {
log.warn('[Companions] the audience could not be changed:', err.message);
}
@@@ -332,7 +362,7 @@ function createCompanionRoutes(ctx) {
tags: original.tags, remix_of: original.id, original_author: original.handle,
});
try {
-- await sharing.register({ subject: owner(req) }, copy.id, 'private');
++ await sharing.register({ subject: owner(req) }, 'character', copy.id, await sharing.defaultOf(owner(req), 'character').catch(() => 'private'));
} catch (err) {
log.warn('[Companions] the remix was saved but not registered with the sharing system:', err.message);
}
@@@ -362,9 +392,11 @@ function createCompanionRoutes(ctx) {
return page(req, res, { title: '18+', index: false, body: raw(views.adultGate()) }, 403);
}
const mine = signedIn(req) && card.owner === owner(req);
-- const canSee = await sharing.canView(req.viewer, card.id).catch(() => false);
++ const canSee = await sharing.canView(req.viewer, 'character', card.id).catch(() => false);
if (!canSee && !mine) {
res.set('X-Robots-Tag', 'noindex, nofollow');
++ const audience = await sharing.audienceOf('character', card.id).catch(() => null);
++ if (audience === 'people') return gatePage(req, res, card.id);
return page(req, res, { title: 'Private', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'shield', title: 'This character is private', text: 'Its creator has not shared it yet.' }) + '</div>') }, 403);
}
await characters.addView(card.id, signedIn(req) ? owner(req) : null);
@@@ -373,7 +405,11 @@ function createCompanionRoutes(ctx) {
index: card.rating !== 'adult',
title: card.name,
description: String(card.description || card.name).slice(0, 160),
-- body: raw(views.characterPage({ card, tags, viewer: req.viewer, mine, remixes, allowRemix: card.allow_remix })),
++ head: shareHead,
++ body: raw(views.characterPage({
++ card, tags, viewer: req.viewer, mine, remixes, allowRemix: card.allow_remix,
++ share: mine ? `<button type="button" class="ex-btn" data-ov-share-sheet data-kind="character" data-id="${esc(card.id)}" data-title="${esc(card.name)}">Share</button>` : '',
++ })),
});
});
@@@ -409,7 +445,7 @@ function createCompanionRoutes(ctx) {
return null;
}
const mine = signedIn(req) && card.owner === owner(req);
-- const canSee = await sharing.canView(req.viewer, card.id).catch(() => false);
++ const canSee = await sharing.canView(req.viewer, 'character', card.id).catch(() => false);
if (!canSee && !mine) {
res.set('X-Robots-Tag', 'noindex, nofollow');
page(req, res, { title: 'Private', index: false, body: raw('<div class="ex-wrap ex-narrow">' + kit.empty({ icon: 'shield', title: 'This character is private', text: 'Its creator has not shared it yet.' }) + '</div>') }, 403);
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex cd1b41b..5bb3f40 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -19,6 +19,7 @@ const seo = require('openvibe-shared/seo');
const cache = require('openvibe-shared/cache-policy');
const kit = require('openvibe-shared/kit');
const thread = require('openvibe-shared/thread');
++const ovServe = require('openvibe-shared/serve');
const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { sameOrigin } = require('./principal');
@@@ -33,7 +34,7 @@ const RETIRED_PATHS = ['/listings', '/listings/*', '/submit', '/sandbox', '/sand
const textOf = (s) => esc(String(s || '')).replace(/\n/g, '<br>');
function createPageRoutes(ctx) {
-- const { config, ai, history, openapi, chats, adult, log } = ctx;
++ const { config, ai, history, openapi, chats, adult, sharing, log } = ctx;
const r = asyncRouter();
r.use(express.urlencoded({ extended: false, limit: '64kb' }));
const page = (req, res, o, status = 200) => send(res, status, { viewer: req.viewer, config, path: req.originalUrl, ...o });
@@@ -231,35 +232,83 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return `<li class="ovt-turn ex-round"><div class="ex-answer-grid" data-count="${list.length}">${cells}</div></li>`;
}
-- function chatPage({ req, res, chat = null, chats = [], messages = [], models, problem = null, status = 200 }) {
++ // ── The one Share sheet (openvibe-shared ov-share.js) ────────────────────
++ const shareHead = `<link rel="stylesheet" href="${ovServe.url('ov-share.css')}"><script src="${ovServe.url('ov-share.js')}" defer></script>`;
++ const shareButton = ({ kind, id, title }) => `<button type="button" class="ovk-btn ovk-btn--quiet" data-ov-share-sheet data-kind="${esc(kind)}" data-id="${esc(id)}" data-title="${esc(title)}">Share</button>`;
++ /** The gate a `people` item shows somebody who is not on its list: the audience, never the title or a turn. */
++ function gatePage(req, res, { kind, id }) {
++ const signedInAs = signedIn(req) ? (req.viewer.username || '') : '';
++ const signInUrl = `/auth/login?next=${encodeURIComponent(req.originalUrl)}`;
++ return page(req, res, {
++ title: 'Shared with specific people', index: false, head: shareHead,
++ // The gate draws itself from this markup (openvibe-shared 4.123.0's [data-ov-share-gate]): no inline
++ // script, so a strict CSP needs nothing, and every value is escaped like any other attribute.
++ body: raw(`<div class="ex-wrap ex-narrow"><div id="share-gate" data-ov-share-gate data-kind="${esc(kind)}" data-id="${esc(id)}" data-signed-in-as="${esc(signedInAs)}" data-sign-in-url="${esc(signInUrl)}">This is shared with specific people. Sign in with the OpenVibe account it was shared with, or ask its owner for access.</div></div>`),
++ }, 404);
++ }
++
++ /** An item that is not shared at all: no title, no content, no hint. */
++ function privatePage(req, res) {
++ res.set('X-Robots-Tag', 'noindex, nofollow');
++ return page(req, res, {
++ title: 'Not found', index: false,
++ body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'search', title: 'No such chat', text: 'It may have been deleted, or it belongs to another account.' })}</div>`),
++ }, 404);
++ }
++
++ function turnsFor(messages, models) {
const bySlug = new Map((models || []).map((m) => [m.slug, m]));
const turns = [];
-- if (chat) {
-- let i = 0;
-- while (i < messages.length) {
-- const m = messages[i];
-- if (m.role === 'user') {
-- turns.push(thread.turn({ id: m.id, you: { text: m.content }, reply: null }));
-- i += 1;
-- const answers = [];
-- while (i < messages.length && messages[i].role === 'assistant') { answers.push(messages[i]); i += 1; }
-- if (answers.length) turns.push(answerRound(answers, bySlug));
-- } else {
-- turns.push(answerRound([m], bySlug));
-- i += 1;
-- }
++ let i = 0;
++ while (i < messages.length) {
++ const m = messages[i];
++ if (m.role === 'user') {
++ turns.push(thread.turn({ id: m.id, you: { text: m.content }, reply: null }));
++ i += 1;
++ const answers = [];
++ while (i < messages.length && messages[i].role === 'assistant') { answers.push(messages[i]); i += 1; }
++ if (answers.length) turns.push(answerRound(answers, bySlug));
++ } else {
++ turns.push(answerRound([m], bySlug));
++ i += 1;
}
}
++ return turns;
++ }
++
++ /** A chat somebody else shared: the conversation, read-only, with the same words and no composer. */
++ function sharedChatPage({ req, res, chat, messages, models }) {
++ return page(req, res, {
++ title: chat.title, index: false, class: 'ex-chat', app: true,
++ head: `<link rel="stylesheet" href="${ovServe.url('thread.css')}"><script src="${ovServe.url('ov-thread.js')}" defer></script>${shareHead}`,
++ body: raw(thread.page({
++ head: thread.head({
++ title: chat.title,
++ actions: shareButton({ kind: 'chat', id: chat.id, title: chat.title }),
++ }),
++ turns: turnsFor(messages, models),
++ empty: '',
++ composer: '',
++ label: 'A shared chat',
++ })),
++ });
++ }
++
++ function chatPage({ req, res, chat = null, chats = [], messages = [], models, problem = null, status = 200, defaults = '' }) {
++ const turns = chat ? turnsFor(messages, models) : [];
const empty = `<div class="ovt-welcome"><h2>Every model, one chat</h2><p>Pick one model, or up to three to compare side by side.</p><p class="ex-muted ex-small">The floor applies on every model: no sexual content involving minors, no non-consensual sexual content (fiction included) and no incest, no help with mass harm.</p></div>`;
return page(req, res, {
title: chat ? chat.title : 'Chat', index: false,
class: 'ex-chat', app: true,
-- head: `<link rel="stylesheet" href="${require('openvibe-shared/serve').url('thread.css')}"><script src="${require('openvibe-shared/serve').url('ov-thread.js')}" defer></script>`,
++ head: `<link rel="stylesheet" href="${ovServe.url('thread.css')}"><script src="${ovServe.url('ov-thread.js')}" defer></script>${shareHead}`,
body: raw(thread.page({
sidebar: chatSidebar(chats, chat ? chat.id : null),
-- head: thread.head({ title: chat ? chat.title : 'Chat', newHref: '/chat' }),
++ head: thread.head({
++ title: chat ? chat.title : 'Chat', newHref: '/chat',
++ actions: chat && !chat.sealed ? shareButton({ kind: 'chat', id: chat.id, title: chat.title }) : '',
++ }),
turns,
-- empty,
++ empty: empty + defaults,
center: !chat,
composer: composerFor({ chat, models, problem, action: chat ? `/chat/${chat.id}/send` : '/chat/send' }),
})),
@@@ -312,6 +361,8 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
const envelope = envelopeOf(req.body);
if (!envelope) return jsonRefusal(res, 422, 'A sealed chat needs the sealed envelope.');
const chat = await chats.createSealed({ owner: owner(req), envelope });
++ // A sealed chat stays Only me: its turns are sealed to this person's Locker, so there is nothing to share.
++ try { await sharing.register({ subject: owner(req) }, 'chat', chat.id, 'private'); } catch (err) { log.warn('[Exchange] the sealed chat was saved but not registered with the sharing system:', err.message); }
return res.status(201).json({ id: chat.id, url: `/chat/${chat.id}` });
});
@@@ -383,7 +434,7 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
const list = await loadChats(req);
if (!list.length) return signInIfGuest(req, res, async () => {
const { models } = await chatCatalog();
-- return chatPage({ req, res, chats: [], models });
++ return chatPage({ req, res, chats: [], models, defaults: await chatDefaults(req) });
});
return res.redirect(303, `/chat/${list[0].id}`);
});
@@@ -393,20 +444,52 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
return fn();
}
++ /** The person's default for new chats, on the page where a chat starts ("New chats: Only me"). */
++ async function chatDefaults(req) {
++ if (!signedIn(req)) return '';
++ const current = await sharing.defaultOf(owner(req), 'chat').catch(() => 'private');
++ return views.audienceDefaults({ kind: 'chat', label: 'chats', current, action: '/chat/defaults', signedIn: true });
++ }
++
++ r.post('/chat/defaults', async (req, res) => {
++ if (!signedIn(req)) return res.redirect(303, '/auth/login?next=%2Fchat');
++ if (!sameOrigin(req, config.baseUrl)) return notFromHere(req, res);
++ const body = req.body || {};
++ const audience = ['private', 'people', 'link', 'public'].includes(body.audience) ? body.audience : 'private';
++ await sharing.setDefault(owner(req), 'chat', audience, { applyAll: String(body.apply_all || '') === '1' }).catch((err) => log.warn('[Exchange] the chat default was not saved:', err.message));
++ return res.redirect(303, '/chat');
++ });
++
r.get('/chat/new', async (req, res) => signInIfGuest(req, res, async () => {
-- const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
++ const [list, cat, defaults] = await Promise.all([chats.list(owner(req)), chatCatalog(), chatDefaults(req)]);
if (req.query.sealed === '1') return sealedPage({ req, res, models: cat.models });
-- return chatPage({ req, res, chats: list, models: cat.models });
++ return chatPage({ req, res, chats: list, models: cat.models, defaults });
}));
-- r.get('/chat/:id', async (req, res) => signInIfGuest(req, res, async () => {
-- const chat = await chats.get(owner(req), req.params.id);
-- if (!chat) return page(req, res, { title: 'Not found', index: false, body: raw(`<div class="ex-wrap ex-narrow">${kit.empty({ icon: 'chat', title: 'No such chat', text: 'It may have been deleted.', action: { href: '/chat', label: 'Your chats' } })}</div>`) }, 404);
-- const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
-- if (chat.sealed) return sealedPage({ req, res, chat, models: cat.models });
-- const messages = await chats.messages(owner(req), chat.id);
-- return chatPage({ req, res, chat, chats: list, messages, models: cat.models });
-- }));
++ /**
++ * A chat is its owner's page. Someone it was shared with reads the same conversation without the composer;
++ * anybody else gets the gate for Specific people, or "not found" when it is Only me.
++ */
++ r.get('/chat/:id', async (req, res) => {
++ const chat = await chats.find(req.params.id);
++ if (!chat) return privatePage(req, res);
++ if (signedIn(req) && chat.owner === owner(req)) {
++ const [list, cat] = await Promise.all([chats.list(owner(req)), chatCatalog()]);
++ if (chat.sealed) return sealedPage({ req, res, chat, models: cat.models });
++ const messages = await chats.messages(owner(req), chat.id);
++ return chatPage({ req, res, chat, chats: list, messages, models: cat.models });
++ }
++ // A sealed chat is never shared: its turns are the owner's Locker's, so nobody else can read it anyway.
++ if (chat.sealed) return privatePage(req, res);
++ const allowed = await sharing.canView(req.viewer, 'chat', chat.id).catch(() => false);
++ if (!allowed) {
++ const audience = await sharing.audienceOf('chat', chat.id).catch(() => null);
++ if (audience === 'people') return gatePage(req, res, { kind: 'chat', id: chat.id });
++ return privatePage(req, res);
++ }
++ const [messages, cat] = await Promise.all([chats.allMessages(chat.id), chatCatalog()]);
++ return sharedChatPage({ req, res, chat, messages, models: cat.models });
++ });
async function sendMessage(req, res, chatId = null) {
if (!signedIn(req)) return signInPage(req, res, { title: 'Sign in to chat', text: 'Your history stays in your account.', next: '/chat' });
@@@ -447,7 +530,12 @@ ${m.meta && m.meta.preset ? `<dt>Preset</dt><dd>${esc(m.meta.preset)}</dd>` : ''
}
const isNew = !chat;
-- if (!chat) chat = await chats.create({ owner: owner(req), models: chosen, title: text.replace(/\s+/g, ' ').slice(0, 60), strictPrivacy: strict });
++ if (!chat) {
++ chat = await chats.create({ owner: owner(req), models: chosen, title: text.replace(/\s+/g, ' ').slice(0, 60), strictPrivacy: strict });
++ // The person's default for new chats (Only me unless they changed it), then the sheet for this one.
++ const audience = await sharing.defaultOf(owner(req), 'chat').catch(() => 'private');
++ try { await sharing.register({ subject: owner(req) }, 'chat', chat.id, audience); } catch (err) { log.warn('[Exchange] the chat was saved but not registered with the sharing system:', err.message); }
++ }
else {
await chats.settings(owner(req), chat.id, { models: chosen, systemPrompt: system, temperature });
await chats.setPrivacy(owner(req), chat.id, { strictPrivacy: strict });
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 5cf1a16..9f78f56 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -42,6 +42,8 @@ const TABLES = [
{ table: 'exchange_lorebook_entries', subject: 'owner', value: (usr) => usr, file: 'lorebook-entries.json', erase: 'delete' },
{ table: 'exchange_lorebooks', subject: 'owner', value: (usr) => usr, file: 'lorebooks.json', erase: 'delete' },
{ table: 'exchange_companion_locks', subject: 'owner', value: (usr) => usr, file: 'companion-locks.json', erase: 'delete' },
++ // The person's default audience for new items (design 2026-11-06): what their next chat, card or round starts as.
++ { table: 'exchange_share_defaults', subject: 'owner', value: (usr) => usr, file: 'share-defaults.json', erase: 'delete' },
];
function create({ db, log = console } = {}) {
@@@ -52,7 +54,10 @@ function create({ db, log = console } = {}) {
const forms = subjects.map((s) => JSON.stringify({ type: 'user', id: s }));
const members = await t.exec(`DELETE FROM share_members WHERE subject = ANY($1::jsonb[])`, [forms]);
const requests = await t.exec(`DELETE FROM share_access_requests WHERE subject = ANY($1::jsonb[])`, [forms]);
-- if (Number(members) || Number(requests)) add(erased, 'share_rows', Number(members) + Number(requests));
++ const owned = await t.exec("DELETE FROM shares WHERE service = 'exchange' AND owner_subject = ANY($1::jsonb[])", [forms]);
++ if (Number(members) || Number(requests) || Number(owned)) {
++ add(erased, 'share_rows', Number(members) + Number(requests) + Number(owned));
++ }
// The table map anonymised every v1 listing whose owner is one of the subjects; close the public ones and
// delete the drafts outright. Nothing is published anywhere any more (the Search relay left with v1).
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..206437f
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,274 @@
++'use strict';
++
++/**
++ * Sharing on OpenVibe.Exchange (design 2026-11-06, visibility step 2), through openvibe-sdk/share.
++ *
++ * The SDK owns the model and the rules: the shares row next to each item, the one access check (canView),
++ * members, access requests, transfers, the append-only log and the delivery queue. This file is the wiring a
++ * service supplies, and it is the same wiring OpenVibe.Community gives its pastes:
++ *
++ * - network.json Network's identity lookups (@name to a subject, and a person's card for the page)
++ * - events.publish OpenVibe.Events, so share.* envelopes go out through the same queue
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps a character card's own audience column in step, so an old reader that only
++ * knows the column fails closed (private stays private)
++ * - onOwnerChange moves the item's own owner column with a transfer
++ *
++ * Three kinds, one sheet, the person's default plus per-item override:
++ *
++ * character private until the creator shares it; Specific people covers "look at this while I write it".
++ * chat Only me until the owner shares it; a sealed chat stays Only me and says why (its history is
++ * sealed to the owner's Locker, so no other account could read it anyway).
++ * arena-round Only me until the owner shares it, so a demo of a pair can be shown.
++ *
++ * The audiences are the SDK's: Everyone (public), Anyone with the link (link), Specific people (people), Only
++ * me (private).
++ */
++const { createShares, ShareError } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'exchange';
++const KINDS = Object.freeze(['character', 'chat', 'arena-round']);
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++const DEFAULT_AUDIENCE = 'private';
++/** Exchange's audience words to the SDK's, and back. */
++const TO_SDK = { public: 'public', unlisted: 'link', private: 'private', people: 'people', link: 'link' };
++const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
++
++const KIND_LABEL = Object.freeze({ character: 'character', chat: 'chat', 'arena-round': 'arena round' });
++
++/** An item's share ref: { service: 'exchange', kind, id }. */
++const refOf = (kind, id) => ({ service: SERVICE, kind, id: String(id) });
++
++/** Exchange's viewer as the SDK wants it: a subject object, and the staff bit. */
++function shareViewer(v) {
++ if (!v || !v.subject) return null;
++ if (String(v.subject).startsWith('usr_')) return { subject: { type: 'user', id: v.subject }, staff: !!v.staff };
++ if (String(v.subject).startsWith('gst_')) return { subject: { type: 'guest', id: v.subject }, staff: !!v.staff };
++ return null;
++}
++
++function createSharing({ db, config, fetchImpl = globalThis.fetch, log = console } = {}) {
++ const base = String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '');
++ const tokens = config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: base + '/oauth/token',
++ clientId: config.oauth.clientId,
++ clientSecret: config.oauth.clientSecret,
++ audience: 'openvibe.network',
++ scope: 'identity.subject.resolve network.notifications.push',
++ fetchImpl,
++ }) : null;
++
++ async function networkJson({ path, method = 'GET', json, query } = {}) {
++ if (!tokens) throw new Error('Network is not configured for this service');
++ const url = new URL(base + path);
++ for (const [k, v] of Object.entries(query || {})) if (v != null) url.searchParams.set(k, String(v));
++ const headers = { Accept: 'application/json', ...(await tokens.authHeaders()) };
++ if (json !== undefined) headers['Content-Type'] = 'application/json';
++ const res = await fetchImpl(url, { method, headers, body: json === undefined ? undefined : JSON.stringify(json), signal: AbortSignal.timeout(8000) });
++ const body = await res.json().catch(() => null);
++ if (!res.ok) {
++ const err = new Error('Network ' + method + ' ' + path + ' ' + res.status + ': ' + ((body && (body.detail || body.error)) || 'failed'));
++ err.status = res.status;
++ throw err;
++ }
++ return body;
++ }
++
++ /** The page a notification opens, by kind. Never carries a title. */
++ function linkOf(ref, row) {
++ if (ref.kind === 'character') return config.baseUrl + '/c/' + encodeURIComponent(row.handle || '') + '/' + encodeURIComponent(row.slug || '');
++ if (ref.kind === 'chat') return config.baseUrl + '/chat/' + encodeURIComponent(ref.id);
++ if (ref.kind === 'arena-round') return config.baseUrl + '/arena/' + encodeURIComponent(ref.id);
++ return config.baseUrl + '/';
++ }
++
++ async function itemRow(kind, id) {
++ if (kind === 'character') return (await db.prepare('SELECT handle, slug FROM exchange_characters WHERE id = ?').get(String(id))) || {};
++ if (kind === 'chat') return (await db.prepare('SELECT sealed FROM exchange_chats WHERE id = ?').get(String(id))) || {};
++ return {};
++ }
++
++ /** One notification, addressed by canonical subject. It never carries an item's text. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const kind = n.ref && n.ref.kind;
++ const label = KIND_LABEL[kind] || 'thing';
++ const words = {
++ 'share.added': ['A ' + label + ' was shared with you', 'Open it on OpenVibe.Exchange.'],
++ 'share.access_requested': ['Someone asked for access to your ' + label, 'Review the request on OpenVibe.Exchange.'],
++ 'share.transfer.requested': ['Someone wants to give you a ' + label, 'Accept or decline it on OpenVibe.Exchange.'],
++ 'share.transfer.answered': ['A ' + label + ' transfer was answered', 'See what happened on OpenVibe.Exchange.'],
++ }[n.kind] || ['Sharing on OpenVibe.Exchange', 'There is something new for you.'];
++ try {
++ const row = n.ref ? await itemRow(n.ref.kind, n.ref.id) : {};
++ await networkJson({
++ path: '/internal/notifications/push', method: 'POST',
++ json: {
++ subject: to.id, type: 'GENERIC', title: words[0], message: words[1],
++ service: 'exchange', url: n.ref && n.ref.id ? linkOf(n.ref, row) : config.baseUrl + '/',
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ /** OpenVibe.Events. Exchange has no outbox of its own yet, so the envelope goes straight out. */
++ async function publish(envelope) {
++ if (!config.events.url) { log.warn('[Sharing] no events URL configured; share envelope not sent'); return; }
++ const res = await fetchImpl(config.events.url, {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json' },
++ body: JSON.stringify(envelope),
++ signal: AbortSignal.timeout(8000),
++ });
++ if (!res.ok) throw new Error('Events answered ' + res.status);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onAudienceChange: async (ref, audience, tx) => {
++ if (ref.service !== SERVICE) return;
++ if (ref.kind === 'character') {
++ await tx.exec('UPDATE exchange_characters SET audience = $1, updated_at = now() WHERE id = $2', [FROM_SDK[audience] || 'private', ref.id]);
++ return;
++ }
++ if (ref.kind === 'chat') {
++ // A sealed chat is never shared: its turns never reach this database in plain text, and the one
++ // who holds the key is its owner. Refuse the change rather than promising something unreadable.
++ if (audience !== 'private') {
++ const row = await tx.maybe('SELECT sealed FROM exchange_chats WHERE id = $1', [ref.id]);
++ if (row && row.sealed) throw new ShareError(400, 'share.invalid', 'A sealed chat stays Only me: only your Locker can read it.');
++ }
++ }
++ },
++ onOwnerChange: async (ref, from, to, tx) => {
++ if (ref.service !== SERVICE) return;
++ const owner = to && (to.type === 'user' || to.type === 'guest') ? to.id : null;
++ if (ref.kind === 'character') {
++ await tx.exec('UPDATE exchange_characters SET owner = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
++ } else if (ref.kind === 'chat') {
++ const row = await tx.maybe('SELECT sealed FROM exchange_chats WHERE id = $1', [ref.id]);
++ if (row && row.sealed) throw new ShareError(400, 'share.invalid', 'A sealed chat stays with you: it cannot be given away.');
++ await tx.exec('UPDATE exchange_chats SET owner = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
++ } else if (ref.kind === 'arena-round') {
++ await tx.exec('UPDATE exchange_arena_rounds SET owner = $1 WHERE id = $2', [owner, ref.id]);
++ }
++ },
++ });
++
++ const sv = (viewer) => shareViewer(viewer);
++
++ return {
++ shares,
++ SERVICE,
++ KINDS,
++ AUDIENCES,
++ TO_SDK,
++ FROM_SDK,
++ DEFAULT_AUDIENCE,
++ refOf,
++ shareViewer,
++ networkJson,
++ /** The SDK's own DDL, run by the migration role (server/db.js). */
++ migrate: async () => await shares.migrate(),
++ /** Register an item with the sharing system, Only me until its owner says otherwise. */
++ register: async (viewer, kind, id, audience = DEFAULT_AUDIENCE) => await shares.register(refOf(kind, id), {
++ owner_subject: { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: String(viewer.subject) },
++ audience: TO_SDK[audience] || DEFAULT_AUDIENCE,
++ }),
++ /** The person's default for new items of a kind ("New chats: Only me"). Only me when unset. */
++ defaultOf: async (owner, kind) => {
++ if (!owner) return DEFAULT_AUDIENCE;
++ const row = await db.prepare('SELECT audience FROM exchange_share_defaults WHERE owner = ? AND kind = ?')
++ .get(String(owner), kind);
++ return row && AUDIENCES.includes(row.audience) ? row.audience : DEFAULT_AUDIENCE;
++ },
++ /**
++ * Set the default, and with `applyAll` move what exists too (rule 2). A sealed chat is never moved off
++ * Only me; the character's own audience column follows whatever the share rows say.
++ */
++ setDefault: async (owner, kind, audience, { applyAll = false } = {}) => {
++ if (!owner || !KINDS.includes(kind)) return false;
++ const want = AUDIENCES.includes(audience) ? audience : DEFAULT_AUDIENCE;
++ await db.prepare(`INSERT INTO exchange_share_defaults (owner, kind, audience, updated_at) VALUES (?, ?, ?, now())
++ ON CONFLICT (owner, kind) DO UPDATE SET audience = excluded.audience, updated_at = now()`)
++ .run(String(owner), kind, want);
++ if (!applyAll) return true;
++ const subject = { type: String(owner).startsWith('gst_') ? 'guest' : 'user', id: String(owner) };
++ await db.tx(async (tx) => {
++ if (kind === 'chat') {
++ await tx.exec(`UPDATE shares SET audience = $1, revision = revision + 1
++ WHERE service = $2 AND kind = 'chat' AND owner_subject->>'id' = $3 AND owner_subject->>'type' = $4
++ AND NOT EXISTS (SELECT 1 FROM exchange_chats c WHERE c.id = resource_id AND c.sealed)`,
++ [want, SERVICE, subject.id, subject.type]);
++ } else {
++ await tx.exec(`UPDATE shares SET audience = $1, revision = revision + 1
++ WHERE service = $2 AND kind = $3 AND owner_subject->>'id' = $4 AND owner_subject->>'type' = $5`,
++ [want, SERVICE, kind, subject.id, subject.type]);
++ }
++ if (kind === 'character') {
++ await tx.exec('UPDATE exchange_characters SET audience = $1, updated_at = now() WHERE owner = $2',
++ [FROM_SDK[want] || 'private', String(owner)]);
++ }
++ });
++ return true;
++ },
++ /** Just the audience, for deciding between "Only me" (no hint) and "Specific people" (the gate). */
++ audienceOf: async (kind, id) => {
++ const row = await db.prepare('SELECT audience FROM shares WHERE service = ? AND kind = ? AND resource_id = ?')
++ .get(SERVICE, kind, String(id));
++ return row ? row.audience : null;
++ },
++ canView: async (viewer, kind, id) => await shares.canView(sv(viewer), refOf(kind, id)),
++ canEdit: async (viewer, kind, id) => await shares.canEdit(sv(viewer), refOf(kind, id)),
++ get: async (viewer, kind, id) => await shares.get(sv(viewer), refOf(kind, id)),
++ /** The owner's answer for the page, or null for anybody else. */
++ ownerView: async (viewer, kind, id) => {
++ try { return await shares.get(sv(viewer), refOf(kind, id)); } catch { return null; }
++ },
++ setAudience: async (viewer, kind, id, audience) => await shares.setAudience(sv(viewer), refOf(kind, id), TO_SDK[audience] || DEFAULT_AUDIENCE),
++ addMembers: async (viewer, kind, id, list) => await shares.addMembers(sv(viewer), refOf(kind, id), list),
++ removeMember: async (viewer, kind, id, subject) => await shares.removeMember(sv(viewer), refOf(kind, id), subject),
++ requestAccess: async (viewer, kind, id) => await shares.requestAccess(sv(viewer), refOf(kind, id)),
++ lookup: async (viewer, name) => await shares.lookup(sv(viewer), name),
++ sharedWithMe: async (viewer, opts) => await shares.sharedWithMe(sv(viewer), { service: SERVICE, ...(opts || {}) }),
++ /** The uniform API's describe(ref): the sheet shows the item's title and link to someone who may open it. */
++ describe: async (ref) => {
++ if (ref.service !== SERVICE) return {};
++ const id = String(ref.id);
++ if (ref.kind === 'character') {
++ const row = await db.prepare('SELECT name, handle, slug FROM exchange_characters WHERE id = ?').get(id);
++ if (!row) return {};
++ return { title: row.name, link: linkOf(ref, row), roles: ['viewer'], transferable: true };
++ }
++ if (ref.kind === 'chat') {
++ const row = await db.prepare('SELECT title, sealed FROM exchange_chats WHERE id = ?').get(id);
++ if (!row) return {};
++ return { title: row.sealed ? 'A sealed chat' : row.title, link: linkOf(ref, row), roles: ['viewer'], transferable: !row.sealed };
++ }
++ if (ref.kind === 'arena-round') {
++ const row = await db.prepare('SELECT category FROM exchange_arena_rounds WHERE id = ?').get(id);
++ if (!row) return {};
++ return { title: 'An Arena round: ' + row.category, link: linkOf(ref, row), roles: ['viewer'], transferable: true };
++ }
++ return {};
++ },
++ /** Expire members and transfers, then send the queued events and notifications. */
++ work: async ({ flush = true } = {}) => {
++ let expired = 0;
++ try { expired = await shares.expireMembers(); } catch (err) { log.warn('[Sharing] expiry sweep:', err.message); }
++ let sent = 0;
++ if (flush) { try { sent = await shares.flush(); } catch (err) { log.warn('[Sharing] deliveries:', err.message); } }
++ return { expired, sent };
++ },
++ };
++}
++
++module.exports = { createSharing, shareViewer, refOf, KINDS, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, DEFAULT_AUDIENCE, KIND_LABEL };
ddiff --git a/server/views/arena.js b/server/views/arena.js
iindex c52f26f..67bdc80 100644
---- a/server/views/arena.js
++++ b/server/views/arena.js
@@@ -37,7 +37,7 @@ function panel({ title, note = '', id, body, cls = '' }) {
}
// ── The Arena ──────────────────────────────────────────────
--function arenaHome({ viewer, catalog, rounds = [], problem = null, config }) {
++function arenaHome({ viewer, catalog, rounds = [], problem = null, config, defaults = '' }) {
const signedIn = viewer && viewer.kind === 'user' && viewer.subject;
const models = (catalog.models || []).filter((m) => !m.uncensored);
const ask = signedIn
@@@ -65,6 +65,7 @@ ${panel({ title: 'How it works', id: 'how', body: `<ol class="ex-list ex-arena-s
</ol><p class="ex-prose">The rankings put those ratings beside the price per million tokens, the measured speed and the uptime, so “worth it” is visible. One vote is one sample: the board marks a model provisional until it has enough games.</p>
<p class="ex-prose"><strong>It is public.</strong> An arena prompt and both answers are recorded in Bench's open arena dataset, without your name, so anyone can check a vote. Do not paste anything private.</p>` })}
${recent ? panel({ title: 'Your last rounds', id: 'recent', body: recent }) : ''}
++${defaults}
${models.length ? `<p class="ex-muted ex-arena-count">${models.length} models in the catalogue; two are chosen for each prompt.</p>` : ''}
</div>`;
}
@@@ -90,11 +91,14 @@ ${receipt}
</article>`;
}
--function arenaRound({ round, vote = null, revealed = false, problem = null, config }) {
++function arenaRound({ round, vote = null, revealed = false, problem = null, config, shared = false, action = '' }) {
const [left, right] = round.models || [];
const voted = Boolean(vote);
const outcome = vote ? vote.outcome : null;
-- const voteForm = voted
++ const voteForm = shared
++ ? `<p class="ex-muted">This round was shared with you${
++ round.shared_by ? ` by <strong>${esc(round.shared_by)}</strong>` : ''}. Its owner voted; only their vote counts.</p>`
++ : voted
? `<p class="ex-arena-voted">${kit.badge(outcome === 'left' ? 'You chose A' : outcome === 'right' ? 'You chose B' : outcome === 'tie' ? 'You called it a tie' : 'You said both were bad', 'ok')} It is counted. ${kit.button({ href: '/arena', label: 'Ask another', kind: 'quiet' })}</p>`
: `<form class="ex-arena-vote" method="post" action="/arena/${esc(round.id)}/vote">
${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
@@@ -109,13 +113,13 @@ ${problem ? kit.notice({ kind: 'error', text: problem }) : ''}
</form>`;
return `<div class="ex-wrap ex-page">
--${kit.head({ title: voted ? 'Your vote, revealed' : 'Which one is better?', sub: `Filed under ${labelOf(round.category)}. One prompt, two answers, no names until you vote.`, back: { href: '/arena', label: 'Arena' } })}
++${kit.head({ title: shared ? 'A shared Arena round' : voted ? 'Your vote, revealed' : 'Which one is better?', sub: shared ? `Filed under ${labelOf(round.category)}. One prompt, two answers, both names and receipts.` : `Filed under ${labelOf(round.category)}. One prompt, two answers, no names until you vote.`, back: { href: '/arena', label: 'Arena' }, actions: action ? [action] : [] })}
${panel({ title: 'Your prompt', id: 'prompt', body: `<p class="ex-arena-prompt">${esc(round.prompt).replace(/\n/g, '<br>')}</p><p class="ex-muted">${kit.badge(labelOf(round.category))} ${voted ? '' : 'The models are unnamed until you vote.'}</p>` })}
${panel({ title: 'Two answers', id: 'pair', body: `<div class="ex-arena-grid" data-count="2">
${answerColumn({ side: 'left', answer: left && left.answer, model: left, revealed, index: 0 })}
${answerColumn({ side: 'right', answer: right && right.answer, model: right, revealed, index: 1 })}
</div>${voteForm}` })}
--${voted ? panel({ title: 'What it counted as', id: 'counted', body: `<p class="ex-prose">This vote is a <strong>Bench human_pairwise judgement</strong> for your account, in the <strong>${esc(labelOf(round.category))}</strong> category. It moves both models' Elo on <a href="/rankings?category=${encodeURIComponent(round.category)}">the rankings</a>, beside their price per million and their measured speed and uptime.</p>` }) : ''}
++${voted && !shared ? panel({ title: 'What it counted as', id: 'counted', body: `<p class="ex-prose">This vote is a <strong>Bench human_pairwise judgement</strong> for your account, in the <strong>${esc(labelOf(round.category))}</strong> category. It moves both models' Elo on <a href="/rankings?category=${encodeURIComponent(round.category)}">the rankings</a>, beside their price per million and their measured speed and uptime.</p>` }) : ''}
</div>`;
}
ddiff --git a/server/views/companions.js b/server/views/companions.js
iindex ac9f193..cceca9a 100644
---- a/server/views/companions.js
++++ b/server/views/companions.js
@@@ -56,7 +56,7 @@ function cardTile(card) {
}
/** The gallery: search, tags, the three sorts, and a counted fold. Never tabs. */
--function galleryPage({ items = [], total = 0, q = '', tags = [], sort = 'new', tagList = [], viewer = null, adult = false, mine = [], problem = null } = {}) {
++function galleryPage({ items = [], total = 0, q = '', tags = [], sort = 'new', tagList = [], viewer = null, adult = false, mine = [], problem = null, defaults = '' } = {}) {
const shown = items.slice(0, 12);
const rest = items.slice(12);
const hidden = Math.max(0, total - shown.length);
@@@ -65,6 +65,7 @@ function galleryPage({ items = [], total = 0, q = '', tags = [], sort = 'new', t
+ '<input type="hidden" name="tags" value="' + esc(tags.join(',')) + '">'
+ SORTS.map((s) => '<button type="submit" name="sort" value="' + esc(s.id) + '" class="ex-sort' + (sort === s.id ? ' is-on' : '') + '">' + esc(s.label) + '</button>').join('')
+ '</form>';
++ const defaultsHtml = defaults || '';
const searchForm = '<form class="ex-search" method="get" action="/companions">'
+ '<input type="hidden" name="sort" value="' + esc(sort) + '">'
+ '<label class="ovk-sr" for="ex-q">Search characters</label>'
@@@ -83,6 +84,7 @@ function galleryPage({ items = [], total = 0, q = '', tags = [], sort = 'new', t
// Making or importing a card is always one tap away, not only from "Your characters" once you have one.
+ '<p class="ex-head-actions"><a class="ovk-btn ovk-btn--primary" href="/companions/new">Make or import a character</a></p></header>'
+ (problem ? kit.notice({ kind: 'error', text: problem }) : '')
++ + defaultsHtml
+ '<div class="ex-gallery-bar">' + searchForm + sortForm + '</div>'
+ (tagChips ? '<div class="ex-gallery-tags">' + tagChips + '</div>' : '')
+ (empty || '<ul class="ex-card-grid">' + shown.map((c) => cardTile(c)).join('') + '</ul>')
@@@ -98,7 +100,7 @@ function section(title, body) {
}
/** A character page. */
--function characterPage({ card, tags = [], viewer = null, mine = false, problem = null, remixes = 0, notice = null, allowRemix = true } = {}) {
++function characterPage({ card, tags = [], viewer = null, mine = false, problem = null, remixes = 0, notice = null, allowRemix = true, share = '' } = {}) {
const list = (tags.length ? tags : (card.tags || [])).map((t) => '<a class="ex-chip" href="/companions?tags=' + encodeURIComponent(t) + '">' + esc(t) + '</a>').join('');
const talk = '<form method="post" action="' + esc(pathOf(card)) + '/talk"><button type="submit" class="ex-btn ex-btn-primary">Talk to ' + esc(card.name) + '</button></form>';
const remix = (mine || !allowRemix) ? '' : '<form method="post" action="' + esc(pathOf(card)) + '/remix" class="ex-inline"><button type="submit" class="ex-btn">Remix this character</button></form>';
@@@ -112,7 +114,7 @@ function characterPage({ card, tags = [], viewer = null, mine = false, problem =
+ (notice ? kit.notice({ kind: 'info', text: notice }) : '')
+ (card.description ? '<p class="ex-companion-desc">' + esc(card.description) + '</p>' : '')
+ (list ? '<p class="ex-companion-tags">' + list + '</p>' : '')
-- + '<div class="ex-companion-acts">' + talk + remix
++ + '<div class="ex-companion-acts">' + talk + remix + (share || '')
+ '<a class="ex-btn" href="' + esc(pathOf(card) + '/memory') + '">Memory</a>'
+ '<a class="ex-btn" href="' + esc(pathOf(card) + '/lorebook') + '">Lorebooks</a></div>'
+ section('Personality', card.personality)
ddiff --git a/server/views/exchange.js b/server/views/exchange.js
iindex da4e92f..d8af76c 100644
---- a/server/views/exchange.js
++++ b/server/views/exchange.js
@@@ -377,6 +377,7 @@ ${kit.head({ title: chat ? 'A sealed chat' : 'A new sealed chat', sub: 'Your his
<p class="ovk-msg" data-sealed-msg aria-live="polite"></p>
</form>
</div>
++<p class="ex-sealed-note"><strong>Only me, always.</strong> This chat has no Share sheet: it stays yours, because only your Locker can read it. Sharing it would send people a conversation they could not open.</p>
<p class="ex-sealed-note"><strong>Said plainly:</strong> OpenVibe stores only the sealed envelope and cannot read it. The model still receives the text of the turn you send, and the earlier turns your browser sends with it, while it answers; that is privacy at rest, not from the model. Your passphrase, recovery words and keys never leave this tab.</p>
</div>`;
}
@@@ -677,6 +678,30 @@ ${panel({ title: 'Requests today', body: usage.unavailable ? '<p class="ex-prose
</div>`;
}
++
++// ── A person's default audience, on their product page (design rule 1) ──
++const AUDIENCE_TEXT = { public: 'Everyone', link: 'Anyone with the link', people: 'Specific people', private: 'Only me' };
++/**
++ * "New chats: Only me ▾", with "Apply to all my existing chats". The Share sheet still changes one item; this is
++ * only what the next one starts as.
++ * kind, label (plural noun), current, action, signedIn
++ */
++function audienceDefaults({ kind, label, current = 'private', action, signedIn = false }) {
++ if (!signedIn) return '';
++ const options = ['private', 'people', 'link', 'public']
++ .map((a) => `<option value="${a}"${a === current ? ' selected' : ''}>${AUDIENCE_TEXT[a]}</option>`).join('');
++ return `<form class="ex-share-defaults ovk-form" method="post" action="${esc(action)}">
++<input type="hidden" name="kind" value="${esc(kind)}">
++<label class="ex-label" for="share-default-${esc(kind)}">New ${esc(label)}</label>
++<div class="ex-share-defaults-row">
++<select id="share-default-${esc(kind)}" name="audience">${options}</select>
++${kit.button({ label: 'Save', type: 'submit', kind: 'quiet' })}
++<label class="ex-check ovk-check"><input type="checkbox" name="apply_all" value="1"> <span>Apply to all my existing ${esc(label)}</span></label>
++</div>
++<p class="ex-muted ex-small">The Share sheet on an item changes just that one. Only me until you choose otherwise.</p>
++</form>`;
++}
++
// ── Retired v1 ─────────────────────────────────────────────
function retired(pathName) {
return `<div class="ex-wrap ex-page ex-narrow">
@@@ -693,4 +718,4 @@ ${panel({ title: 'Where those things live now', body: `<ul class="ex-list">
</div>`;
}
--module.exports = { home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
++module.exports = { audienceDefaults, home, modelsPage, modelPage, apiPage, sellPage, uncensoredPage, aiPage, statusPage, retired, sealedChat, privacyOf, answerPrivacy, providerLabel, familyLabel, chatModels, ENDPOINT };
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 5f8f6f5..d3650a9 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -139,7 +139,22 @@ async function startNetwork() {
if (!u) return problem(json, 404, 'identity.subject_not_found', 'no subject for that id');
return json(200, { subject_id: subject, adult: Boolean(u.adult), confirmed_at: u.adult ? '2026-11-06T00:00:00.000Z' : null });
}
-- if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') return json(200, { results: {} });
++ // The sharing system's name lookups (openvibe-sdk/share): one exact @name, and the page cards for ids.
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = String(url.searchParams.get('username') || '').toLowerCase();
++ const u = st.byUsername.get(name);
++ if (!u) return problem(json, 404, 'identity.name_not_found', 'no such person');
++ return json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name });
++ }
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const ids = JSON.parse(raw.toString('utf8') || '{}').subject_ids || [];
++ const results = {};
++ for (const subjectId of ids) {
++ const u = [...st.users.values()].find((x) => x.subject === subjectId);
++ if (u) results[subjectId] = { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name };
++ }
++ return json(200, { results });
++ }
if (url.pathname === '/internal/notifications/push' && req.method === 'POST') return json(200, { ok: true });
// Network's Locker key directory (Network PR #577, server/locker/keys.js): a person's public keys
ddiff --git a/test/share-gate-chrome.test.js b/test/share-gate-chrome.test.js
nnew file mode 100644
iindex 0000000..a849453
---- /dev/null
++++ b/test/share-gate-chrome.test.js
@@@ -0,0 +1,95 @@
++'use strict';
++/**
++ * The "not on the list" gate in a real headless Chrome (openvibe-shared 4.123.0's [data-ov-share-gate]): the page
++ * carries markup only, ov-share.js draws the gate when it loads, signed out it offers Sign in, and a signed-in
++ * person who is not on the list is offered Ask for access — with no console error and no CSP refusal.
++ *
++ * Skipped (exit 0) without Chrome, or with OV_SKIP_BROWSER=1. The page is served by this repo's own app on a
++ * temporary database, so the CSP headers the browser sees are Exchange's.
++ */
++const assert = require('assert');
++const fs = require('fs');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++
++let playwright = null;
++try { playwright = require('playwright'); } catch {
++ try { playwright = require(process.env.PLAYWRIGHT_MODULE || '/home/workstation/openvibe/work/OpenVibe.Games/node_modules/playwright'); } catch { playwright = null; }
++}
++const CHROME = process.env.CHROME_BIN || '/usr/bin/google-chrome';
++if (process.env.OV_SKIP_BROWSER === '1' || !playwright || !fs.existsSync(CHROME)) {
++ console.log('share-gate-chrome: skipped (no Chrome; set CHROME_BIN)');
++ process.exit(0);
++}
++
++/** Console errors that mean the gate did not draw, rather than an incidental failed favicon. */
++const REFUSED = /Content Security Policy|Refused to (?:load|execute|apply)|Uncaught SyntaxError/i;
++
++(async () => {
++ const ai = createFakeAi();
++ const t = await boot({ ai });
++ const alice = t.network.addUser('alice');
++ const carol = t.network.addUser('carol');
++ const cookieOf = (user) => {
++ const [name, ...rest] = t.signIn(user).split('=');
++ return { name, value: rest.join('='), url: t.base };
++ };
++
++ let browser;
++ const errors = [];
++ const watch = (page) => {
++ page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); });
++ page.on('pageerror', (e) => errors.push(String(e)));
++ };
++ const settled = async (page) => {
++ await page.waitForSelector('#share-gate .ov-share-gate', { timeout: 8000 });
++ return await page.$eval('#share-gate', (el) => el.textContent.replace(/\s+/g, ' ').trim());
++ };
++
++ try {
++ let chatId;
++ await check('a chat shared with Specific people, with nobody on the list yet', async () => {
++ const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });
++ assert.strictEqual(made.status, 303, made.text);
++ chatId = made.headers.get('location').split('/').pop();
++ await t.ctx.sharing.setAudience({ subject: alice.subject }, 'chat', chatId, 'people');
++ const mine = await t.get(`/chat/${chatId}`, { as: alice });
++ assert.strictEqual(mine.status, 200, mine.text);
++ });
++
++ await check('headless Chrome: the gate draws itself, signed out, with Sign in', async () => {
++ browser = await playwright.chromium.launch({ executablePath: CHROME, headless: true, args: ['--no-sandbox', '--disable-dev-shm-usage'] });
++ const ctx = await browser.newContext();
++ const page = await ctx.newPage();
++ watch(page);
++ const res = await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });
++ assert.strictEqual(res.status(), 404, 'the gate answers 404, never the chat');
++ const text = await settled(page);
++ assert.ok(/Sign in/.test(text), `signed out the gate offers Sign in, got: ${text}`);
++ const href = await page.$eval('#share-gate .ov-share-gate a', (a) => a.getAttribute('href'));
++ assert.match(href, /^\/auth\/login\?next=/);
++ await ctx.close();
++ });
++
++ await check('headless Chrome: signed in as somebody not on the list, Ask for access', async () => {
++ const ctx = await browser.newContext();
++ await ctx.addCookies([cookieOf(carol)]);
++ const page = await ctx.newPage();
++ watch(page);
++ await page.goto(`${t.base}/chat/${chatId}`, { waitUntil: 'load', timeout: 15000 });
++ const text = await settled(page);
++ assert.ok(/Ask for access/.test(text), `a stranger is offered Ask for access, got: ${text}`);
++ assert.ok(/@carol/.test(text), 'the gate names the account it is signed in as');
++ await ctx.close();
++ });
++
++ await check('headless Chrome: no console error and no CSP refusal on the gate page', async () => {
++ const refused = errors.filter((e) => REFUSED.test(e));
++ assert.deepStrictEqual(refused, [], `the gate page was refused: ${refused.join(' | ')}`);
++ });
++ } finally {
++ if (browser) await browser.close().catch(() => {});
++ await t.close();
++ done();
++ }
++})();
ddiff --git a/test/sharing.test.js b/test/sharing.test.js
nnew file mode 100644
iindex 0000000..9518606
---- /dev/null
++++ b/test/sharing.test.js
@@@ -0,0 +1,168 @@
++'use strict';
++/**
++ * Sharing on Exchange (design 2026-11-06, visibility step 2): chats, arena rounds and character cards use the one
++ * Share sheet (openvibe-sdk/share) and the one check, canView, on every read path.
++ *
++ * - a new chat and a new round are Only me; the Share sheet changes just that one;
++ * - Anyone with the link reads a chat without its composer, and a shared round names both models and has no vote;
++ * - Specific people shows the gate, never the title or a turn, to somebody not on the list;
++ * - a sealed chat has no Share sheet and cannot be moved off Only me;
++ * - the person's default for new items, and "apply to all", work per kind.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++const { createFakeAi } = require('./helpers/ai');
++const { createFakeBench } = require('./helpers/bench');
++
++(async () => {
++ const ai = createFakeAi({
++ chat: (args) => ({
++ content: `An answer from ${args.model}.`,
++ model: args.model, provider: args.model.startsWith('deepseek') ? 'deepseek' : 'novita',
++ run_id: `run_${args.model}`, cost_usd: 0.0002, tokens_in: 12, tokens_out: 30, latency_ms: 500,
++ why: ['cheapest healthy offer'],
++ }),
++ });
++ const bench = createFakeBench();
++ const t = await boot({ ai, bench });
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const carol = t.network.addUser('carol');
++
++ const call = (pathname, o = {}) => { const { headers, ...rest } = o; return t.get(pathname, { ...rest, headers: { 'x-requested-with': 'ov-share', ...(headers || {}) } }); };
++ const share = (kind, id, o = {}) => call(`/api/v1/${kind}/${encodeURIComponent(id)}/share`, o);
++ const setShare = async (kind, id, body, as) => {
++ const first = await share(kind, id, { as });
++ assert.strictEqual(first.status, 200, first.text);
++ const etag = first.headers.get('etag');
++ assert.ok(etag, 'the share read carries an ETag');
++ return await share(kind, id, { as, method: 'PUT', json: body, headers: { 'if-match': etag } });
++ };
++ const audienceRow = async (kind, id) => await t.ctx.s.db.maybe(
++ 'SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['exchange', kind, id]);
++
++ try {
++ let chatId;
++ await check('a new chat is Only me, and its page carries the one Share sheet', async () => {
++ const made = await t.get('/chat/send', { as: alice, form: { text: 'What is a semaphore?', 'models_deepseek/deepseek-chat': '1' } });
++ assert.strictEqual(made.status, 303, made.text);
++ chatId = made.headers.get('location').split('/').pop();
++ assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'private' });
++ const owner = await t.get(`/chat/${chatId}`, { as: alice });
++ assert.strictEqual(owner.status, 200, owner.text);
++ assert.ok(owner.text.includes('data-ov-share-sheet') && owner.text.includes('data-kind="chat"'), 'the Share sheet is mounted');
++ assert.ok(owner.text.includes('ov-share.js'), 'the sheet loads with the page');
++ const other = await t.get(`/chat/${chatId}`, { as: bob });
++ assert.strictEqual(other.status, 404, 'another account is told nothing');
++ assert.ok(!other.text.includes('What is a semaphore?'), 'never the title');
++ const anon = await t.get(`/chat/${chatId}`);
++ assert.strictEqual(anon.status, 404);
++ });
++
++ await check('Anyone with the link reads the conversation, and it has no composer', async () => {
++ const saved = await setShare('chat', chatId, { audience: 'link' }, alice);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(saved.json().audience, 'link');
++ const anon = await t.get(`/chat/${chatId}`);
++ assert.strictEqual(anon.status, 200, anon.text);
++ assert.ok(anon.text.includes('What is a semaphore?'));
++ assert.ok(anon.text.includes('An answer from deepseek/deepseek-chat@v1.'));
++ assert.ok(!anon.text.includes('ovt-composer'), 'a reader cannot write into somebody else\'s chat');
++ assert.ok(!anon.text.includes('name="text"'), 'no send box');
++ });
++
++ await check('Specific people: the person on the list reads it, another gets the gate and no title', async () => {
++ const saved = await setShare('chat', chatId, { audience: 'people', members: [{ name: 'bob', role: 'viewer' }] }, alice);
++ assert.strictEqual(saved.status, 200, saved.text);
++ assert.strictEqual(saved.json().audience, 'people');
++ const member = await t.get(`/chat/${chatId}`, { as: bob });
++ assert.strictEqual(member.status, 200, member.text);
++ assert.ok(member.text.includes('What is a semaphore?'));
++ const stranger = await t.get(`/chat/${chatId}`, { as: carol });
++ assert.strictEqual(stranger.status, 404);
++ assert.ok(stranger.text.includes('shared with specific people'));
++ assert.ok(!stranger.text.includes('What is a semaphore?'), 'the gate never reveals the title');
++ const anon = await t.get(`/chat/${chatId}`);
++ assert.strictEqual(anon.status, 404);
++ assert.ok(anon.text.includes('shared with specific people'));
++ });
++
++ await check('a sealed chat has no Share sheet and cannot leave Only me', async () => {
++ const envelope = Buffer.alloc(48, 7).toString('base64');
++ const made = await t.get('/chat/sealed/new', { as: alice, json: { envelope } });
++ assert.strictEqual(made.status, 201, made.text);
++ const sealedId = made.json().id;
++ const page = await t.get(`/chat/${sealedId}`, { as: alice });
++ assert.strictEqual(page.status, 200);
++ assert.ok(page.text.includes('Only me, always'));
++ assert.ok(!page.text.includes('data-ov-share-sheet'), 'no Share sheet on a sealed chat');
++ const refused = await setShare('chat', sealedId, { audience: 'public' }, alice);
++ assert.strictEqual(refused.status, 400, refused.text);
++ assert.strictEqual(refused.json().code, 'share.invalid');
++ assert.deepStrictEqual(await audienceRow('chat', sealedId), { audience: 'private' });
++ const other = await t.get(`/chat/${sealedId}`, { as: bob });
++ assert.strictEqual(other.status, 404);
++ });
++
++ let roundId;
++ await check('a new Arena round is Only me, and a shared round names both models with no vote', async () => {
++ const asked = await t.get('/arena/ask', { as: alice, form: { prompt: 'Write a function that reads a CSV and sums a column' } });
++ assert.strictEqual(asked.status, 303, asked.text);
++ roundId = asked.headers.get('location').split('/').pop();
++ assert.deepStrictEqual(await audienceRow('arena-round', roundId), { audience: 'private' });
++ const hidden = await t.get(`/arena/${roundId}`, { as: bob });
++ assert.strictEqual(hidden.status, 404);
++ const saved = await setShare('arena-round', roundId, { audience: 'public' }, alice);
++ assert.strictEqual(saved.status, 200, saved.text);
++ const seen = await t.get(`/arena/${roundId}`, { as: bob });
++ assert.strictEqual(seen.status, 200, seen.text);
++ assert.ok(seen.text.includes('A shared Arena round'));
++ assert.ok(seen.text.includes('DeepSeek Chat') && seen.text.includes('Qwen3 32B'), 'both names are shown');
++ assert.ok(!seen.text.includes('name="outcome"'), 'a reader does not vote on somebody else\'s round');
++ });
++
++ await check('the person\'s default applies to the next chat, and "apply to all" moves the existing ones', async () => {
++ const saved = await t.get('/chat/defaults', { as: alice, form: { audience: 'public', apply_all: '1' } });
++ assert.strictEqual(saved.status, 303, saved.text);
++ assert.deepStrictEqual(await audienceRow('chat', chatId), { audience: 'public' });
++ const next = await t.get('/chat/send', { as: alice, form: { text: 'A second question', 'models_deepseek/deepseek-chat': '1' } });
++ const nextId = next.headers.get('location').split('/').pop();
++ assert.deepStrictEqual(await audienceRow('chat', nextId), { audience: 'public' });
++ const anon = await t.get(`/chat/${nextId}`);
++ assert.strictEqual(anon.status, 200);
++ assert.ok(anon.text.includes('A second question'));
++ const start = await t.get('/chat/new', { as: alice });
++ assert.ok(start.text.includes('New chats'), 'the default is on the product page');
++ });
++
++ await check('characters: Only me while being written, Specific people to show one, the default for new cards', async () => {
++ const FORM = { name: 'Ines', description: 'A cartographer', personality: 'patient', scenario: 'a map room', first_message: 'Where to?', rating: 'everyone' };
++ const made = await t.get('/companions/new', { as: alice, form: FORM });
++ assert.strictEqual(made.status, 303, made.text);
++ const card = (await t.ctx.characters.listByOwner(alice.subject)).find((c) => c.name === 'Ines');
++ assert.deepStrictEqual(await audienceRow('character', card.id), { audience: 'private' });
++ const other = await t.get(made.headers.get('location'), { as: bob });
++ assert.strictEqual(other.status, 403);
++ assert.ok(!other.text.includes('A cartographer'));
++ const saved = await setShare('character', card.id, { audience: 'people', members: [{ name: 'bob', role: 'viewer' }] }, alice);
++ assert.strictEqual(saved.status, 200, saved.text);
++ const reader = await t.get(made.headers.get('location'), { as: bob });
++ assert.strictEqual(reader.status, 200, reader.text);
++ assert.ok(reader.text.includes('A cartographer'));
++ const stranger = await t.get(made.headers.get('location'), { as: carol });
++ assert.strictEqual(stranger.status, 404);
++ assert.ok(stranger.text.includes('shared with specific people'));
++ assert.ok(!stranger.text.includes('A cartographer'));
++
++ const defaults = await t.get('/companions/defaults', { as: alice, form: { audience: 'link' } });
++ assert.strictEqual(defaults.status, 303);
++ const second = await t.get('/companions/new', { as: alice, form: { ...FORM, name: 'Second Ines' } });
++ const card2 = (await t.ctx.characters.listByOwner(alice.subject)).find((c) => c.name === 'Second Ines');
++ assert.deepStrictEqual(await audienceRow('character', card2.id), { audience: 'link' });
++ assert.ok(second.status === 303);
++ });
++ } finally {
++ await t.close();
++ done();
++ }
++})();