openvibers/openvibe.bench Commits

Bench: the four audiences on benchmarks (sharing step 4) (#132)

OP OpenVibers committed 6 days ago · b347609f58 · 13 files, +468 −13

* Bench: the four audiences on benchmarks (sharing step 4)

Every benchmark gets an openvibe-sdk/share row next to it, built from the
registry's own visibility (public → Everyone, unlisted → the link, private →
Only me; an unlisted benchmark with a share token stays token-only). The Share
button in the Sharing section opens the sheet for its owner, canGive names a
teammate a private benchmark, and every read (the page, the JSON/API, runs and
compare) asks canView. The registry's visibility form keeps the row in step.
The uniform API is mounted at /api/v1/benchmark/:id/share.

Migrations 0013 (the SDK's tables) and 0014 (the backfill).

* Account export and deletion cover the share rows; keep drafts and members pages closed

* Sharing: a row written before the model falls back to the old column; the second account-data guard too

Changes

ddiff --git a/migrations/0013_sharing.sql b/migrations/0013_sharing.sql
nnew file mode 100644
iindex 0000000..8fa9da9
---- /dev/null
++++ b/migrations/0013_sharing.sql
@@@ -0,0 +1,78 @@
++-- phase: expand
++-- Sharing (design 2026-11-06, VISIBILITY-EVERYWHERE step 4): the tables openvibe-sdk/share v0.91.0's
++-- sharesSchema() creates, kept verbatim so Bench's database has the same shape as every other
++-- service that adopts it. Expand only: nothing is dropped or rewritten.
++
++-- phase: expand
++-- The one sharing system (openvibe-sdk/share v0.91.0), for the companions characters.
++-- Kept verbatim from openvibe-sdk src/share/schema.js, so the SDKs own store finds the tables it expects, and
++-- the test database (which applies this directory) has them too. The characters own rows live in
++-- exchange_characters; these rows are the audience, the members, the log and the delivery queue the SDK writes.
++
++
++CREATE TABLE IF NOT EXISTS shares (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ owner_subject jsonb NOT NULL, audience text NOT NULL CHECK (audience IN ('public','link','people','private')),
++ revision integer NOT NULL DEFAULT 1,
++ lock text NOT NULL DEFAULT 'none',
++ reseal_nonce_base text,
++ PRIMARY KEY (service, kind, resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_members (
++ service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, role text NOT NULL CHECK (role IN ('viewer','editor')),
++ expires_at timestamptz, added_by jsonb NOT NULL, added_at timestamptz NOT NULL,
++ key_slot jsonb,
++ PRIMARY KEY (service, kind, resource_id, subject),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE TABLE IF NOT EXISTS share_transfers (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ from_subject jsonb NOT NULL, to_subject jsonb NOT NULL, keep_access boolean NOT NULL DEFAULT false,
++ requested_at timestamptz NOT NULL, expires_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','accepted','declined','cancelled','expired')),
++ key_slot jsonb,
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_transfer_pending ON share_transfers(service,kind,resource_id) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_access_requests (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ subject jsonb NOT NULL, requested_at timestamptz NOT NULL,
++ state text NOT NULL CHECK (state IN ('pending','granted','dismissed')),
++ FOREIGN KEY (service,kind,resource_id) REFERENCES shares(service,kind,resource_id)
++);
++CREATE UNIQUE INDEX IF NOT EXISTS share_request_pending ON share_access_requests(service,kind,resource_id,subject) WHERE state='pending';
++CREATE TABLE IF NOT EXISTS share_log (
++ id uuid PRIMARY KEY, service text NOT NULL, kind text NOT NULL, resource_id text NOT NULL,
++ actor jsonb NOT NULL, action text NOT NULL, details jsonb NOT NULL, occurred_at timestamptz NOT NULL,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE OR REPLACE FUNCTION share_log_append_only() RETURNS trigger LANGUAGE plpgsql AS $$
++BEGIN RAISE EXCEPTION 'share_log is append-only'; END $$;
++DO $$ BEGIN
++ IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname='share_log_no_changes' AND tgrelid='share_log'::regclass) THEN
++ CREATE TRIGGER share_log_no_changes BEFORE UPDATE OR DELETE OR TRUNCATE ON share_log
++ FOR EACH STATEMENT EXECUTE FUNCTION share_log_append_only();
++ END IF;
++END $$;
++CREATE TABLE IF NOT EXISTS share_deliveries (
++ id uuid PRIMARY KEY, channel text NOT NULL CHECK (channel IN ('event','notify')),
++ body jsonb NOT NULL, created_at timestamptz NOT NULL, delivered_at timestamptz,
++ sequence bigint GENERATED ALWAYS AS IDENTITY UNIQUE
++);
++CREATE TABLE IF NOT EXISTS share_name_limits (
++ caller jsonb PRIMARY KEY, window_start bigint NOT NULL, attempts integer NOT NULL
++);
++CREATE INDEX IF NOT EXISTS share_members_subject ON share_members(subject);
++CREATE INDEX IF NOT EXISTS share_transfers_recipient ON share_transfers(to_subject) WHERE state='pending';
++CREATE INDEX IF NOT EXISTS share_deliveries_pending ON share_deliveries(created_at) WHERE delivered_at IS NULL;
++-- Expand-only for an existing deployment: the lock kind on the share, the member's key slot and the
++-- slot a transfer was offered with. Repeating this migration is a no-op.
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS lock text NOT NULL DEFAULT 'none';
++DO $$ BEGIN
++ ALTER TABLE shares ADD CONSTRAINT shares_lock_allowed CHECK (lock IN ('none','people','password','link'));
++EXCEPTION WHEN duplicate_object THEN NULL; END $$;
++ALTER TABLE share_members ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE share_transfers ADD COLUMN IF NOT EXISTS key_slot jsonb;
++ALTER TABLE shares ADD COLUMN IF NOT EXISTS reseal_nonce_base text;
++
ddiff --git a/migrations/0014_sharing_backfill.sql b/migrations/0014_sharing_backfill.sql
nnew file mode 100644
iindex 0000000..7135942
---- /dev/null
++++ b/migrations/0014_sharing_backfill.sql
@@@ -0,0 +1,25 @@
++-- phase: migrate
++-- Every benchmark that existed before sharing is registered once, so canView has a row for all of them.
++--
++-- visibility public → public (Everyone)
++-- visibility unlisted → link
++-- visibility unlisted with a share token → private (the token-only benchmark keeps its restriction)
++-- visibility private → private (Only me)
++--
++-- The owner is created_by. A project-owned or service benchmark gets Bench itself, which nobody
++-- can sign in as.
++INSERT INTO shares (service, kind, resource_id, owner_subject, audience)
++SELECT
++ 'bench', 'benchmark', b.id,
++ CASE
++ WHEN b.created_by LIKE 'usr\_%' THEN jsonb_build_object('type', 'user', 'id', b.created_by)
++ WHEN b.created_by LIKE 'gst\_%' THEN jsonb_build_object('type', 'guest', 'id', b.created_by)
++ ELSE jsonb_build_object('type', 'service', 'id', 'bench')
++ END,
++ CASE
++ WHEN b.visibility = 'public' THEN 'public'
++ WHEN b.visibility = 'unlisted' AND b.share_token_hash IS NULL THEN 'link'
++ ELSE 'private'
++ END
++FROM bench_benchmarks b
++ON CONFLICT (service, kind, resource_id) DO NOTHING;
ddiff --git a/server/app.js b/server/app.js
iindex 49b5143..053b1c2 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -82,7 +82,9 @@ async function createApp(opts = {}) {
Object.defineProperty(config.bench, 'variantEvidence', { enumerable: true, configurable: true, get: () => evidenceConfig.get('enabled') });
const bench = opts.bench || createStore(s, { config, log, random: opts.random });
-- const registry = opts.registry || createRegistry(s, { log });
++ const { createSharing, describeRef } = require('./sharing');
++ const sharing = opts.sharing || createSharing({ db: s.db, config, fetchImpl, log });
++ const registry = opts.registry || createRegistry(s, { log, sharing });
// B12: bringing your own results, and the operator's mirror.
const imports = require('./bench/imports').createImports({ s, registry, log });
const mirror = require('./bench/mirror').createMirror({ s, registry, fetchImpl, log });
@@@ -107,7 +109,7 @@ async function createApp(opts = {}) {
const variantEvidence = require('./bench/variant-evidence').createVariantEvidence({ s, log });
const ctx = {
config, s, keys, sso, principal, bench, registry, launcher, ai, jobs, publisher, accountData, accountSend, log, fetchImpl,
-- anon, daily, variantEvidence, evidenceConfig,
++ anon, daily, variantEvidence, evidenceConfig, sharing,
imports, mirror,
random: opts.random || Math.random,
};
@@@ -238,6 +240,18 @@ async function createApp(opts = {}) {
// ── API and MCP ─────────────────────────────────────────
app.use('/api/v1', rateLimit({ windowMs: 60_000, limit: Number(process.env.BENCH_API_RATE_LIMIT_PER_MIN) || 240, standardHeaders: true, legacyHeaders: false, handler: (req, res) => contracts.http.sendProblem(res, 429, 'rate_limited', { detail: 'too many requests from this address; retry shortly', ctx: req.ov }) }));
++ // ── /api/v1/<kind>/<id>/share — the uniform share API (openvibe-sdk/share) ────
++ {
++ const shareApi = express.Router();
++ shareApi.use(ctx.principal.middleware);
++ shareApi.use(sharing.shares.router({
++ service: 'bench',
++ authenticate: (req) => sharing.shareViewer(req.viewer),
++ describe: describeRef(s.db, config),
++ }));
++ 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()));
++ }
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createRegistryApi(ctx));
app.use('/api/v1', createVariantEvidenceApi(ctx));
ddiff --git a/server/bench/registry.js b/server/bench/registry.js
iindex cbbfe9d..cffd730 100644
---- a/server/bench/registry.js
++++ b/server/bench/registry.js
@@@ -297,7 +297,7 @@ function parseRef(ref) {
return { id: text, version: null };
}
--function createRegistry(s, { log = console } = {}) {
++function createRegistry(s, { log = console, sharing = null } = {}) {
const db = s.db;
// ── Benchmarks ───────────────────────────────────────────
@@@ -318,6 +318,7 @@ function createRegistry(s, { log = console } = {}) {
[id, String(owner || 'openvibe'), text, String(description || '').slice(0, 4000), kindId,
JSON.stringify(list), vis, parent || null, JSON.stringify(definition || {}), String(subject || owner || 'openvibe'), s.iso()],
);
++ if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject || '')) ? String(subject) : null }, id, vis);
return { ok: true, benchmark: await getBenchmark(id, { revealDraft: true }) };
}
@@@ -577,6 +578,8 @@ function createRegistry(s, { log = console } = {}) {
`UPDATE bench_benchmarks SET visibility = $1, share_token_hash = $2, updated_at = $3::timestamptz
WHERE id = $4 RETURNING *`,
[vis, token ? shareTokenHash(token) : null, s.iso(), benchmark.id]);
++ // The uniform share row keeps the registry's own visibility (and every read that filters on it) in step.
++ if (sharing) await sharing.syncAudience(benchmark.id, vis);
return {
ok: true,
benchmark: wireBenchmark(row),
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 960caed..019a0e5 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -77,7 +77,10 @@ function createPageRoutes(ctx) {
const readableBenchmark = async (req, ref) => {
const out = await registry.getDefinition(String(ref || ''), { reveal: false });
if (!out) return null;
-- return canViewBenchmark(out.benchmark, { subject: subjectOf(req) }) ? out : null;
++ if (canViewBenchmark(out.benchmark, { subject: subjectOf(req) })) return out;
++ // A benchmark shared with specific people: its row says private, the uniform share row names them.
++ const shared = ctx.sharing ? await ctx.sharing.canView({ subject: subjectOf(req) }, out.benchmark.id).catch(() => false) : false;
++ return shared ? out : null;
};
/** The public board of a kind: the first public benchmark with verified runs, else the first public one. */
ddiff --git a/server/http/registry.js b/server/http/registry.js
iindex 06f7cfd..8e82ea4 100644
---- a/server/http/registry.js
++++ b/server/http/registry.js
@@@ -73,7 +73,13 @@ function createRegistryApi(ctx) {
subject: subjectOf(req),
executor: req.principal.kind === 'app' && req.principal.hasCapability('bench.run.publish'),
});
-- const mayRead = (req, benchmark) => canViewBenchmark(benchmark, viewerOf(req));
++ // The registry's own check first (public, the owner, the executor, a shared unlisted token), then the
++ // uniform share row for a benchmark that is shared with specific people (its column says private).
++ const mayRead = async (req, benchmark) => {
++ if (canViewBenchmark(benchmark, viewerOf(req))) return true;
++ if (!ctx.sharing) return false;
++ return await ctx.sharing.canView({ subject: subjectOf(req) }, benchmark.id).catch(() => false);
++ };
/** The owner, including a service token that created the benchmark, may see its held-out task text. */
const owns = (req, benchmark) => {
@@@ -143,7 +149,7 @@ function createRegistryApi(ctx) {
const subject = subjectOf(req) || req.principal.requester;
try {
const bm = await registry.getBenchmark(String(req.params.id || ''));
-- if (!bm || !mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ if (!bm || !await mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
const out = await registry.forkBenchmark(String(req.params.id || ''), {
version: b.version == null ? null : Number(b.version),
title: b.title || null,
@@@ -192,7 +198,7 @@ function createRegistryApi(ctx) {
if (!out) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
// A private benchmark answers only its owner and the executor; a shared unlisted one only its owner by
// id (the token link is the way in). Everyone else gets a 404, not a "forbidden".
-- if (!mayRead(req, out.benchmark)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ if (!await mayRead(req, out.benchmark)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
const reveal = mayReveal(req) || owns(req, out.benchmark);
let definition = out.version.definition;
let taskMeta = out.task_meta;
@@@ -253,7 +259,7 @@ function createRegistryApi(ctx) {
// A run may only be published on a benchmark its author may read: another person's private one is a 404.
const bmId = String(doc.benchmark_id || doc.benchmark || '').split('@')[0];
const bm = bmId ? await registry.getBenchmark(bmId) : null;
-- if (bm && !mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ if (bm && !await mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
const out = await registry.publishRun(doc, { provenance, subject });
if (!out.ok) return problem(res, out.status || 400, out.code || 'bench.invalid', out.message || 'invalid run');
res.set('Location', `/api/v1/runs/${out.run.id}`);
@@@ -285,7 +291,7 @@ function createRegistryApi(ctx) {
let allPublic = true;
for (const run of out.runs) {
const bm = await registry.getBenchmark(run.benchmark_id);
-- if (!bm || mayRead(req, bm)) allowed.push(run);
++ if (!bm || await mayRead(req, bm)) allowed.push(run);
if (!bm || bm.visibility !== 'public') allPublic = false;
}
if (!allowed.length) return problem(res, 404, 'bench.run_not_found', 'No such run.');
@@@ -316,7 +322,7 @@ function createRegistryApi(ctx) {
}
try {
const bm = await registry.getBenchmark(benchmark.split('@')[0]);
-- if (!bm || !mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ if (!bm || !await mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
const out = await registry.rankings({
benchmark,
metric,
@@@ -338,7 +344,7 @@ function createRegistryApi(ctx) {
if (req.query.score == null) return invalid(res, 'score is required: ?score=…');
try {
const bm = await registry.getBenchmark(benchmark.split('@')[0]);
-- if (!bm || !mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
++ if (!bm || !await mayRead(req, bm)) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
const out = await registry.percentile({ benchmark, model, score: Number(req.query.score) });
if (!out) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
res.set('Cache-Control', bm.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 26d7be8..521146e 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -46,6 +46,23 @@ const TABLES = [
const GUARDED = ['bench_benchmarks', 'bench_benchmark_versions'];
/** The benchmarks of the person: what they own and what they wrote, with their versions and their private runs. */
++
++/** The sharing system's rows (openvibe-sdk/share): jsonb subjects, so the SDK's table map cannot name them. */
++async function shareExport(db, subject) {
++ const owned = await db.many("SELECT * FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['bench', subject]);
++ const member = await db.many("SELECT * FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['bench', subject]);
++ const asked = await db.many("SELECT * FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['bench', subject]);
++ return owned.length || member.length || asked.length ? [{ name: 'shares.json', content: { owned, member_of: member, requests: asked } }] : [];
++}
++async function shareErase(t, subjects) {
++ for (const subject of subjects) {
++ await t.exec("DELETE FROM share_members WHERE service = $1 AND subject->>'id' = $2", ['bench', subject]);
++ await t.exec("DELETE FROM share_access_requests WHERE service = $1 AND subject->>'id' = $2", ['bench', subject]);
++ await t.exec("DELETE FROM share_transfers WHERE service = $1 AND (from_subject->>'id' = $2 OR to_subject->>'id' = $2)", ['bench', subject]);
++ await t.exec("DELETE FROM shares WHERE service = $1 AND owner_subject->>'id' = $2", ['bench', subject]);
++ }
++}
++
async function extraExport(db, subject) {
const files = [];
const benchmarks = await db.many(`SELECT id, owner, title, description, kind, tags, visibility, parent, latest_version, draft, created_by, created_at, updated_at
@@@ -68,10 +85,12 @@ async function extraExport(db, subject) {
FROM bench_import_files f JOIN bench_imports i ON i.id = f.import_id
WHERE i.owner = $1 OR i.created_by = $1 ORDER BY i.created_at DESC LIMIT 5000`, [subject]);
if (uploads.length) files.push({ name: 'import_files.json', content: uploads });
++ files.push(...await shareExport(db, subject));
return files;
}
async function extraErase(t, subjects, counts) {
++ await shareErase(t, subjects);
// A private benchmark, or one made from the person's own pasted prompts (tagged your-prompts), is nothing but
// their text: it goes with its versions and every run on it, wherever it was shared. A public or unlisted
// benchmark another person ran stays as a tombstone, so their runs keep their place.
ddiff --git a/server/sharing.js b/server/sharing.js
nnew file mode 100644
iindex 0000000..f8755b3
---- /dev/null
++++ b/server/sharing.js
@@@ -0,0 +1,207 @@
++'use strict';
++
++/**
++ * Sharing for Bench benchmarks (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ *
++ * The SDK owns the model and the rules: the `shares` row next to each benchmark, the one access check
++ * (canView), members, access requests, transfers, the append-only log and the delivery queue. This
++ * file is the wiring a service supplies:
++ *
++ * - network.json Network's identity lookups (@name → subject, and a person's card for the sheet)
++ * - events.publish Bench's Events publisher, so share.* envelopes go out with every other bench.* event
++ * - notify Network's notification push; a refusal is logged, never allowed to stall the queue
++ * - onAudienceChange keeps bench_benchmarks.visibility in step (public→public, link→unlisted, people/private→private), so
++ * the registry, the sitemap and Search drop it at once
++ * - onOwnerChange moves bench_benchmarks.created_by to the new owner
++ *
++ * A private benchmark is registered Only me. The sheet on it can name people ("look at this before
++ * I share it"); a public one is Everyone, an unlisted one is Anyone with the link. The row's own
++ * visibility is what every access check reads (bench/registry.js canViewBenchmark).
++ */
++const { createShares } = require('openvibe-sdk/share');
++const { serviceAuth } = require('openvibe-contracts');
++
++const SERVICE = 'bench';
++const KIND = 'benchmark';
++const AUDIENCES = ['public', 'link', 'people', 'private'];
++/** Bench's visibility 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' };
++
++const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
++
++/** Blog'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, now = Date.now, fetchImpl = globalThis.fetch, log = console } = {}) {
++ // Bench has no generic outbox for arbitrary envelopes: share.* events go to Events with their own token.
++ const eventsToken = config.events && config.events.url && config.oauth && config.oauth.clientSecret ? serviceAuth.createTokenClient({
++ tokenUrl: `${String(config.networkInternalUrl || config.networkUrl).replace(/\/+$/, '')}/oauth/token`,
++ clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, audience: 'openvibe.events', scope: 'events.event.publish', fetchImpl,
++ }) : null;
++ async function publish(envelope) {
++ if (!eventsToken) return;
++ const res = await fetchImpl(config.events.url, { method: 'POST', headers: { ...(await eventsToken.authHeaders()), 'Content-Type': 'application/json' },
++ body: JSON.stringify(envelope), signal: AbortSignal.timeout(8000) });
++ if (!res.ok) throw new Error(`Events answered ${res.status}`);
++ }
++ const base = String(config.networkInternalUrl || config.networkUrl || '').replace(/\/+$/, '');
++ const tokens = config.oauth && 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 post's title or text. */
++ async function notify(n) {
++ const to = n && n.to;
++ if (!to || to.type !== 'user') return;
++ const words = {
++ 'share.added': ['A benchmark was shared with you', 'Open it on OpenVibe.Bench.'],
++ 'share.access_requested': ['Someone asked for access to your benchmark', 'Review the request on OpenVibe.Bench.'],
++ 'share.transfer.requested': ['Someone wants to give you a benchmark', 'Accept or decline it on OpenVibe.Bench.'],
++ 'share.transfer.answered': ['A benchmark transfer was answered', 'See what happened on OpenVibe.Bench.'],
++ }[n.kind] || ['Sharing on OpenVibe.Bench', '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: 'bench', url: n.ref && n.ref.id ? `${config.baseUrl}/benchmarks/${encodeURIComponent(n.ref.id)}` : config.baseUrl,
++ category: 'social',
++ },
++ });
++ } catch (err) {
++ log.warn('[Sharing] notification not sent:', err.message);
++ }
++ }
++
++ /** The row's audience change keeps the registry column (and every read that filters on it) in step. */
++ async function onAudienceChange(ref, audience, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const row = await tx.maybe('SELECT visibility FROM bench_benchmarks WHERE id = $1', [ref.id]);
++ if (!row) return;
++ await tx.exec('UPDATE bench_benchmarks SET visibility = $1, updated_at = now() WHERE id = $2', [FROM_SDK[audience] || 'private', ref.id]);
++ }
++
++ async function onOwnerChange(ref, from, to, tx) {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return;
++ const owner = to && to.type === 'user' ? to.id : null;
++ await tx.exec('UPDATE bench_benchmarks SET created_by = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
++ }
++
++ const shares = createShares({
++ db,
++ network: { json: networkJson },
++ events: { publish },
++ notify,
++ onAudienceChange,
++ onOwnerChange,
++ });
++
++ const sv = (viewer) => shareViewer(viewer);
++ const at = (id) => refOf(id);
++
++ return {
++ shares,
++ SERVICE,
++ KIND,
++ AUDIENCES,
++ refOf,
++ shareViewer,
++ /** Register a post with the sharing system (inside the post's own transaction). */
++ register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
++ owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
++ audience: TO_SDK[audience] || 'private',
++ }),
++ /**
++ * The service's own audience move (a create form, the editor's select, a publish). The caller
++ * has already passed access.canWrite and owns the column and the Search sync; this only keeps
++ * the share row in step. The SDK's owner check is for people using the sheet.
++ */
++ syncAudience: async (id, audience) => {
++ const next = TO_SDK[audience] || 'private';
++ await db.tx(async (t) => {
++ const row = await t.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3 FOR UPDATE', [SERVICE, KIND, String(id)]);
++ if (!row || row.audience === next) return;
++ await t.exec('UPDATE shares SET audience = $1, revision = revision + 1 WHERE service = $2 AND kind = $3 AND resource_id = $4', [next, SERVICE, KIND, String(id)]);
++ });
++ },
++ /** A claimed guest draft's share row moves to the person who claimed it (never through the sheet). */
++ adopt: async (id, viewer) => await db.exec(`UPDATE shares SET owner_subject = $1::jsonb WHERE service = $2 AND kind = $3 AND resource_id = $4`,
++ [JSON.stringify({ type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject }), SERVICE, KIND, String(id)]),
++ shareOf: async (id) => await db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, String(id)]),
++ // Bench's own rule survives: an unlisted benchmark with a share token opens only through that token,
++ // never by its id, whatever the sheet says. The owner is decided before this (bench/registry.js).
++ canView: async (viewer, id) => {
++ const row = await db.maybe('SELECT visibility, share_token_hash FROM bench_benchmarks WHERE id = $1', [String(id)]);
++ if (row && row.visibility === 'unlisted' && row.share_token_hash) return false;
++ return 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),
++ update: async (viewer, id, body, opts) => await shares.update(sv(viewer), at(id), body, opts),
++ requestAccess: async (viewer, id) => await shares.requestAccess(sv(viewer), at(id)),
++ dismiss: async (viewer, id, requestId) => await shares.dismiss(sv(viewer), at(id), requestId),
++ transfer: async (viewer, id, input) => await shares.transfer(sv(viewer), at(id), input),
++ accept: async (viewer, id, transferId) => await shares.accept(sv(viewer), at(id), transferId),
++ decline: async (viewer, id, transferId) => await shares.decline(sv(viewer), at(id), transferId),
++ cancel: async (viewer, id, transferId) => await shares.cancel(sv(viewer), at(id), transferId),
++ 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 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 };
++ },
++ };
++}
++
++/** The uniform API's describe(ref): the sheet shows the post's title and link to its owner. */
++function describeRef(db, config) {
++ return async (ref) => {
++ if (ref.service !== SERVICE || ref.kind !== KIND) return {};
++ const benchmark = await db.maybe('SELECT id, title, visibility FROM bench_benchmarks WHERE id = $1', [ref.id]);
++ if (!benchmark) return {};
++ return {
++ title: benchmark.title || 'Untitled benchmark',
++ link: `${String(config.baseUrl || '').replace(/\/+$/, '')}/benchmarks/${encodeURIComponent(benchmark.id)}`,
++ roles: ['viewer'],
++ audiences: AUDIENCES,
++ transferable: true,
++ };
++ };
++}
++
++module.exports = { createSharing, describeRef, shareViewer, refOf, AUDIENCES, TO_SDK, FROM_SDK, SERVICE, KIND };
ddiff --git a/server/views/results.js b/server/views/results.js
iindex a49e7ef..4a99a40 100644
---- a/server/views/results.js
++++ b/server/views/results.js
@@@ -9,6 +9,7 @@
const { html, raw, render } = require('../render/html');
const { benchmarkUseThis, modelUseThis } = require('./usethis');
const renderLib = require('../bench/render');
++const kit = require('openvibe-shared/kit');
const MEDIA_ORIGIN = 'https://openvibe.media';
@@@ -329,7 +330,7 @@ function benchmarkCard(b) {
}
/** The owner's sharing controls: private, an unlisted link, or public. Plain form posts, no JavaScript. */
--function shareBox({ benchmark, shareUrl = null, error = null }) {
++function shareBox({ benchmark, shareUrl = null, error = null, baseUrl = '' }) {
const visibility = benchmark.visibility || 'private';
const note = visibility === 'private'
? 'Private: only you can see this benchmark and its prompts. No link works.'
@@@ -340,6 +341,7 @@ function shareBox({ benchmark, shareUrl = null, error = null }) {
<div class="br-fold br-share-details"><input class="br-fold-toggle" type="checkbox" id="br-share-open"><label class="br-fold-h" for="br-share-open"><h2 id="br-share-h">Sharing</h2><span class="br-chip br-vis">${visibility}</span><span class="br-fold-mark" aria-hidden="true"></span></label>
<div class="br-fold-body">
<p class="br-fine">${note}</p>
++<p class="br-share-sheet">${raw(kit.share({ title: benchmark.title, url: `${String(baseUrl || '').replace(/\/+$/, '')}/benchmarks/${encodeURIComponent(benchmark.id)}`, text: benchmark.description || '', kind: 'benchmark', id: benchmark.id }))}</p>
${error ? html`<div class="bm-note is-bad" role="alert">${error}</div>` : ''}
${shareUrl ? html`<div class="bm-note br-share-now" role="status"><p>Your unlisted link is ready. It is shown once; copy it now. Share it again for a new link; the old one stops working.</p><label class="br-field"><span>The link</span><input class="br-share-link" type="text" readonly value="${shareUrl}" onfocus="this.select()"></label></div>` : ''}
<form class="br-share-form" method="post" action="/benchmarks/${encodeURIComponent(benchmark.id)}/share">
@@@ -413,7 +415,7 @@ function benchmarkPage({ benchmark, definition, version, rankings, metric = 'sco
<div class="br-head-act">${runYourself}<p class="br-fine">${canRun ? 'On openvibe.codes, with your own keys or the free pool through OpenVibe.AI. The result is published here.' : codesRunUrl ? 'Only the owner starts runs on this benchmark. Its results are readable here.' : 'Running it on openvibe.codes with your own keys is coming. Today a run is published through the API.'}</p></div></header>
${privateNote}
<dl class="br-facts"><div><dt>Tasks</dt><dd>${tasks.length}${hidden ? html` <span class="br-fine">(${hidden} hidden)</span>` : ''}</dd></div><div><dt>Judged by</dt><dd>${judges.length ? judges.map((j) => String(j.type).replace(/_/g, ' ')).join(', ') : '—'}</dd></div><div><dt>Limits</dt><dd>${[limits.ms ? fmtMs(limits.ms) : null, limits.tokens ? `${fmtTokens(limits.tokens)} tokens` : null, limits.usd ? fmtUsd(limits.usd) : null].filter(Boolean).join(' · ') || '—'}</dd></div><div><dt>Verified runs</dt><dd>${entries.reduce((n, e) => n + e.runs, 0)}</dd></div></dl>
--${owner ? shareBox({ benchmark, shareUrl: share && share.shareUrl, error: share && share.error }) : ''}
++${owner ? shareBox({ benchmark, shareUrl: share && share.shareUrl, error: share && share.error, baseUrl }) : ''}
<div class="br-sections">
<nav class="br-sec-pick" aria-label="Benchmark sections"><a href="#br-rank">Rankings</a><a href="#br-tasks">Tasks</a><a href="#br-runs">Runs</a></nav>
${ownBoards
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 128f72d..d283448 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -43,6 +43,8 @@ const ulid = (prefix, tail = 'AAA') => `${prefix}_01JZ${'0'.repeat(19)}${tail}`;
account_data_events: 'the ledger of the requests themselves: which export and deletion Bench applied',
bench_ratings: 'the ratings are an aggregate over the models: the subject column names a model, not a person',
config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
});
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
ddiff --git a/test/bench-sharing.test.js b/test/bench-sharing.test.js
nnew file mode 100644
iindex 0000000..df293d2
---- /dev/null
++++ b/test/bench-sharing.test.js
@@@ -0,0 +1,80 @@
++'use strict';
++/**
++ * Sharing on Bench benchmarks (design 2026-11-06, VISIBILITY-EVERYWHERE step 4; openvibe-sdk/share).
++ * A private benchmark is Only me; its owner can name people. The registry's own visibility (public,
++ * unlisted, private) stays the column every list and sitemap reads, and the share row is the check.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++(async () => {
++ const t = await boot();
++ const alice = t.network.addUser('alice');
++ const bob = t.network.addUser('bob');
++ const sam = t.network.addUser('sam');
++ const prompts = ['What is 2 + 2?', 'Say hello in French.', 'Name a primary colour.', 'What is 3 × 3?', 'Finish: sun—', 'Name a planet.'];
++ const post = (p, body, o = {}) => t.get(p, { as: alice, body: new URLSearchParams(body).toString(), headers: { 'content-type': 'application/x-www-form-urlencoded' }, ...o });
++ const make = async (title) => {
++ const res = await post('/benchmarks/new', { title, kind: 'text.answer', format: 'lines', prompts: prompts.join('\n') });
++ assert.strictEqual(res.status, 303, res.text.slice(0, 200));
++ return res.headers.get('location').split('/').pop();
++ };
++ const share = (id, o = {}) => t.get(`/api/v1/benchmark/${id}/share`, { as: alice, headers: { 'x-requested-with': 'ov-share' }, ...o });
++ const putShare = (id, body) => share(id, { method: 'PUT', json: body });
++ const row = async (id) => await t.ctx.s.db.maybe('SELECT * FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', ['bench', 'benchmark', id]);
++
++ await check('a new benchmark is private and registered Only me, owned by its maker', async () => {
++ const id = await make('My private prompts');
++ const s = await row(id);
++ assert.strictEqual(s.audience, 'private');
++ assert.deepStrictEqual(s.owner_subject, { type: 'user', id: alice.subject });
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`, { as: alice })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`, { as: bob })).status, 404);
++ assert.strictEqual((await t.get(`/benchmarks/${id}`, { as: bob })).status, 404);
++ assert.strictEqual((await t.get(`/benchmarks/${id}`, { as: alice })).status, 200);
++ });
++
++ await check('Specific people: the named person reads it; a stranger gets 404 and the page never leaks the title', async () => {
++ const id = await make('Board pack bench');
++ const put = await putShare(id, { audience: 'people', members: [{ subject: { type: 'user', id: bob.subject }, role: 'viewer' }] });
++ assert.strictEqual(put.status, 200, put.text);
++ assert.strictEqual(put.json().audience, 'people');
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`, { as: bob })).status, 200);
++ assert.strictEqual((await t.get(`/benchmarks/${id}`, { as: bob })).status, 200);
++ const stranger = await t.get(`/benchmarks/${id}`, { as: sam });
++ assert.strictEqual(stranger.status, 404);
++ assert.ok(!stranger.text.includes('Board pack bench'));
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`, { as: sam })).status, 404);
++ assert.strictEqual((await t.get(`/api/v1/benchmark/${id}/share`, { as: sam })).status, 404, 'the sheet is the owner’s');
++ });
++
++ await check('Everyone publishes it into the list; Only me takes it out; the old column follows the sheet', async () => {
++ const id = await make('Going public');
++ assert.strictEqual((await putShare(id, { audience: 'public' })).status, 200);
++ assert.strictEqual((await row(id)).audience, 'public');
++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT visibility FROM bench_benchmarks WHERE id = $1', [id])).visibility, 'public');
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`)).status, 200);
++ const list = (await t.get('/benchmarks')).text;
++ assert.ok(list.includes('Going public'));
++ assert.strictEqual((await putShare(id, { audience: 'private' })).status, 200);
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`)).status, 404);
++ assert.ok(!(await t.get('/benchmarks')).text.includes('Going public'));
++ assert.strictEqual((await t.get(`/benchmarks/${id}`, { as: alice })).status, 200, 'the owner still reads it');
++ });
++
++ await check('the sheet lists the members to the owner, and the old form keeps the row in step', async () => {
++ const id = await make('Two readers');
++ await putShare(id, { audience: 'people', members: [{ subject: { type: 'user', id: sam.subject }, role: 'viewer' }] });
++ const mine = await share(id);
++ assert.strictEqual(mine.status, 200);
++ assert.strictEqual(mine.json().members[0].username, 'sam');
++ assert.strictEqual(mine.json().you.role, 'owner');
++ // The registry's own form (POST /benchmarks/:id/share) is the old way to change visibility.
++ const form = await t.get(`/benchmarks/${id}/share`, { as: alice, body: 'visibility=public', headers: { 'content-type': 'application/x-www-form-urlencoded', origin: 'https://bench.openvibe.services' } });
++ assert.ok([200, 303].includes(form.status), form.text.slice(0, 200));
++ assert.strictEqual((await row(id)).audience, 'public');
++ });
++
++ await done();
++ await t.close();
++})();
ddiff --git a/test/helpers/network.js b/test/helpers/network.js
iindex cb9fce5..d13bd20 100644
---- a/test/helpers/network.js
++++ b/test/helpers/network.js
@@@ -117,6 +117,20 @@ async function startNetwork() {
return json(400, { error: 'unsupported_grant_type' });
}
++ if (url.pathname === '/internal/identity/resolve-batch' && req.method === 'POST') {
++ const body = JSON.parse(raw.toString('utf8') || '{}');
++ const results = {};
++ for (const id of body.subject_ids || []) {
++ const u = [...st.users.values()].find((x) => x.subject === id);
++ results[id] = u ? { subject: { type: 'user', id }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false } : null;
++ }
++ return json(200, { results });
++ }
++ if (url.pathname === '/internal/identity/resolve' && req.method === 'GET') {
++ const name = (url.searchParams.get('username') || '').toLowerCase();
++ const u = [...st.users.values()].find((x) => x.username.toLowerCase() === name);
++ return u ? json(200, { subject: { type: 'user', id: u.subject }, username: u.username, display_name: u.display_name, avatar_url: null, banned: false }) : json(404, { error: 'no such person' });
++ }
return json(404, { error: 'not found' });
});
issuer = srv.url;
ddiff --git a/test/imports.test.js b/test/imports.test.js
iindex 53a66c1..44bf8fc 100644
---- a/test/imports.test.js
++++ b/test/imports.test.js
@@@ -324,6 +324,8 @@ function multipart(filename, bytes) {
account_data_events: 'the ledger of the requests themselves',
bench_ratings: 'the ratings are an aggregate over the models',
config_snapshots: 'D11 configuration revisions: created_by names the owner who changed a setting, not a person\'s account data',
++ share_members: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
++ share_access_requests: 'jsonb subject: the SDK table map cannot name it; extraExport/extraErase cover the share rows',
},
});
assert.deepStrictEqual(missing, [], 'a table with a person column is neither covered nor exempted: ' + missing.join(', '));