openvibers/openvibe.bench Commits
Bench: the four audiences on benchmarks (sharing step 4) (#132)
Changes
nnew file mode 100644@@@ -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;++nnew file mode 100644@@@ -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;@@@ -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));@@@ -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::timestamptzWHERE 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),@@@ -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. */@@@ -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');@@@ -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_idWHERE 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.nnew file mode 100644@@@ -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 };@@@ -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@@@ -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(', ')}`);nnew file mode 100644@@@ -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();++})();@@@ -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;@@@ -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(', '));
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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;
+
new file mode 100644
new file mode 100644
@@ -0,0 +1,25 @@
@@ -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;
@@ -82,7 +82,9 @@ async function createApp(opts = {}) {
@@ -82,7 +82,9 @@ async function createApp(opts = {}) {
Object.defineProperty(config.bench, 'variantEvidence', { enumerable: true, configurable: true, get: () => evidenceConfig.get('enabled') });
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 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.
// B12: bringing your own results, and the operator's mirror.
const imports = require('./bench/imports').createImports({ s, registry, log });
const imports = require('./bench/imports').createImports({ s, registry, log });
const mirror = require('./bench/mirror').createMirror({ s, registry, fetchImpl, log });
const mirror = require('./bench/mirror').createMirror({ s, registry, fetchImpl, log });
@@ -107,7 +109,7 @@ async function createApp(opts = {}) {
@@ -107,7 +109,7 @@ async function createApp(opts = {}) {
const variantEvidence = require('./bench/variant-evidence').createVariantEvidence({ s, log });
const variantEvidence = require('./bench/variant-evidence').createVariantEvidence({ s, log });
const ctx = {
const ctx = {
config, s, keys, sso, principal, bench, registry, launcher, ai, jobs, publisher, accountData, accountSend, log, fetchImpl,
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,
imports, mirror,
random: opts.random || Math.random,
random: opts.random || Math.random,
};
};
@@ -238,6 +240,18 @@ async function createApp(opts = {}) {
@@ -238,6 +240,18 @@ async function createApp(opts = {}) {
// ── API and MCP ─────────────────────────────────────────
// ── 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 }) }));
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', createApi(ctx));
app.use('/api/v1', createRegistryApi(ctx));
app.use('/api/v1', createRegistryApi(ctx));
app.use('/api/v1', createVariantEvidenceApi(ctx));
app.use('/api/v1', createVariantEvidenceApi(ctx));
@@ -297,7 +297,7 @@ function parseRef(ref) {
@@ -297,7 +297,7 @@ function parseRef(ref) {
return { id: text, version: null };
return { id: text, version: null };
}
}
-function createRegistry(s, { log = console } = {}) {
+function createRegistry(s, { log = console, sharing = null } = {}) {
const db = s.db;
const db = s.db;
// ── Benchmarks ───────────────────────────────────────────
// ── Benchmarks ───────────────────────────────────────────
@@ -318,6 +318,7 @@ function createRegistry(s, { log = console } = {}) {
@@ -318,6 +318,7 @@ function createRegistry(s, { log = console } = {}) {
[id, String(owner || 'openvibe'), text, String(description || '').slice(0, 4000), kindId,
[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()],
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 }) };
return { ok: true, benchmark: await getBenchmark(id, { revealDraft: true }) };
}
}
@@ -577,6 +578,8 @@ function createRegistry(s, { log = console } = {}) {
@@ -577,6 +578,8 @@ function createRegistry(s, { log = console } = {}) {
`UPDATE bench_benchmarks SET visibility = $1, share_token_hash = $2, updated_at = $3::timestamptz
`UPDATE bench_benchmarks SET visibility = $1, share_token_hash = $2, updated_at = $3::timestamptz
WHERE id = $4 RETURNING *`,
WHERE id = $4 RETURNING *`,
[vis, token ? shareTokenHash(token) : null, s.iso(), benchmark.id]);
[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 {
return {
ok: true,
ok: true,
benchmark: wireBenchmark(row),
benchmark: wireBenchmark(row),
@@ -77,7 +77,10 @@ function createPageRoutes(ctx) {
@@ -77,7 +77,10 @@ function createPageRoutes(ctx) {
const readableBenchmark = async (req, ref) => {
const readableBenchmark = async (req, ref) => {
const out = await registry.getDefinition(String(ref || ''), { reveal: false });
const out = await registry.getDefinition(String(ref || ''), { reveal: false });
if (!out) return null;
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. */
/** The public board of a kind: the first public benchmark with verified runs, else the first public one. */
@@ -73,7 +73,13 @@ function createRegistryApi(ctx) {
@@ -73,7 +73,13 @@ function createRegistryApi(ctx) {
subject: subjectOf(req),
subject: subjectOf(req),
executor: req.principal.kind === 'app' && req.principal.hasCapability('bench.run.publish'),
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. */
/** The owner, including a service token that created the benchmark, may see its held-out task text. */
const owns = (req, benchmark) => {
const owns = (req, benchmark) => {
@@ -143,7 +149,7 @@ function createRegistryApi(ctx) {
@@ -143,7 +149,7 @@ function createRegistryApi(ctx) {
const subject = subjectOf(req) || req.principal.requester;
const subject = subjectOf(req) || req.principal.requester;
try {
try {
const bm = await registry.getBenchmark(String(req.params.id || ''));
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 || ''), {
const out = await registry.forkBenchmark(String(req.params.id || ''), {
version: b.version == null ? null : Number(b.version),
version: b.version == null ? null : Number(b.version),
title: b.title || null,
title: b.title || null,
@@ -192,7 +198,7 @@ function createRegistryApi(ctx) {
@@ -192,7 +198,7 @@ function createRegistryApi(ctx) {
if (!out) return problem(res, 404, 'bench.benchmark_not_found', 'No such benchmark.');
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
// 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".
// 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);
const reveal = mayReveal(req) || owns(req, out.benchmark);
let definition = out.version.definition;
let definition = out.version.definition;
let taskMeta = out.task_meta;
let taskMeta = out.task_meta;
@@ -253,7 +259,7 @@ function createRegistryApi(ctx) {
@@ -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.
// 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 bmId = String(doc.benchmark_id || doc.benchmark || '').split('@')[0];
const bm = bmId ? await registry.getBenchmark(bmId) : null;
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 });
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');
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}`);
res.set('Location', `/api/v1/runs/${out.run.id}`);
@@ -285,7 +291,7 @@ function createRegistryApi(ctx) {
@@ -285,7 +291,7 @@ function createRegistryApi(ctx) {
let allPublic = true;
let allPublic = true;
for (const run of out.runs) {
for (const run of out.runs) {
const bm = await registry.getBenchmark(run.benchmark_id);
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 (!bm || bm.visibility !== 'public') allPublic = false;
}
}
if (!allowed.length) return problem(res, 404, 'bench.run_not_found', 'No such run.');
if (!allowed.length) return problem(res, 404, 'bench.run_not_found', 'No such run.');
@@ -316,7 +322,7 @@ function createRegistryApi(ctx) {
@@ -316,7 +322,7 @@ function createRegistryApi(ctx) {
}
}
try {
try {
const bm = await registry.getBenchmark(benchmark.split('@')[0]);
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({
const out = await registry.rankings({
benchmark,
benchmark,
metric,
metric,
@@ -338,7 +344,7 @@ function createRegistryApi(ctx) {
@@ -338,7 +344,7 @@ function createRegistryApi(ctx) {
if (req.query.score == null) return invalid(res, 'score is required: ?score=…');
if (req.query.score == null) return invalid(res, 'score is required: ?score=…');
try {
try {
const bm = await registry.getBenchmark(benchmark.split('@')[0]);
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) });
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.');
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');
res.set('Cache-Control', bm.visibility === 'public' ? 'public, max-age=60' : 'private, no-store');
@@ -46,6 +46,23 @@ const TABLES = [
@@ -46,6 +46,23 @@ const TABLES = [
const GUARDED = ['bench_benchmarks', 'bench_benchmark_versions'];
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 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) {
async function extraExport(db, subject) {
const files = [];
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
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) {
@@ -68,10 +85,12 @@ async function extraExport(db, subject) {
FROM bench_import_files f JOIN bench_imports i ON i.id = f.import_id
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]);
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 });
if (uploads.length) files.push({ name: 'import_files.json', content: uploads });
+ files.push(...await shareExport(db, subject));
return files;
return files;
}
}
async function extraErase(t, subjects, counts) {
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
// 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
// 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.
// benchmark another person ran stays as a tombstone, so their runs keep their place.
new file mode 100644
new file mode 100644
@@ -0,0 +1,207 @@
@@ -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 };
@@ -9,6 +9,7 @@
@@ -9,6 +9,7 @@
const { html, raw, render } = require('../render/html');
const { html, raw, render } = require('../render/html');
const { benchmarkUseThis, modelUseThis } = require('./usethis');
const { benchmarkUseThis, modelUseThis } = require('./usethis');
const renderLib = require('../bench/render');
const renderLib = require('../bench/render');
+const kit = require('openvibe-shared/kit');
const MEDIA_ORIGIN = 'https://openvibe.media';
const MEDIA_ORIGIN = 'https://openvibe.media';
@@ -329,7 +330,7 @@ function benchmarkCard(b) {
@@ -329,7 +330,7 @@ function benchmarkCard(b) {
}
}
/** The owner's sharing controls: private, an unlisted link, or public. Plain form posts, no JavaScript. */
/** 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 visibility = benchmark.visibility || 'private';
const note = visibility === 'private'
const note = visibility === 'private'
? 'Private: only you can see this benchmark and its prompts. No link works.'
? 'Private: only you can see this benchmark and its prompts. No link works.'
@@ -340,6 +341,7 @@ function shareBox({ benchmark, shareUrl = null, error = null }) {
@@ -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 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">
<div class="br-fold-body">
<p class="br-fine">${note}</p>
<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>` : ''}
${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>` : ''}
${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">
<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
@@ -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>
<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}
${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>
<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">
<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>
<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
${ownBoards
@@ -43,6 +43,8 @@ const ulid = (prefix, tail = 'AAA') => `${prefix}_01JZ${'0'.repeat(19)}${tail}`;
@@ -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',
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',
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',
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(', ')}`);
assert.deepStrictEqual(missing, [], `a table with a person column is neither covered nor exempted: ${missing.join(', ')}`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,80 @@
@@ -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();
+})();
@@ -117,6 +117,20 @@ async function startNetwork() {
@@ -117,6 +117,20 @@ async function startNetwork() {
return json(400, { error: 'unsupported_grant_type' });
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' });
return json(404, { error: 'not found' });
});
});
issuer = srv.url;
issuer = srv.url;
@@ -324,6 +324,8 @@ function multipart(filename, bytes) {
@@ -324,6 +324,8 @@ function multipart(filename, bytes) {
account_data_events: 'the ledger of the requests themselves',
account_data_events: 'the ledger of the requests themselves',
bench_ratings: 'the ratings are an aggregate over the models',
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',
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(', '));
assert.deepStrictEqual(missing, [], 'a table with a person column is neither covered nor exempted: ' + missing.join(', '));