openvibers/openvibe.bench Commits

Add personal benchmark sharing defaults (#205)

OP OpenVibers committed 2 days ago · 113817af56 · 7 files, +126 −8

Changes

ddiff --git a/migrations/0017_benchmark_share_defaults.sql b/migrations/0017_benchmark_share_defaults.sql
nnew file mode 100644
iindex 0000000..6529d88
---- /dev/null
++++ b/migrations/0017_benchmark_share_defaults.sql
@@@ -0,0 +1,7 @@
++-- phase: expand
++-- Personal benchmarks and their runs start Only me unless their maker chooses another audience.
++CREATE TABLE IF NOT EXISTS bench_share_defaults (
++ owner_subject text COLLATE "C" PRIMARY KEY,
++ audience text NOT NULL CHECK (audience IN ('public', 'link', 'people', 'private')),
++ updated_at timestamptz NOT NULL DEFAULT now()
++);
ddiff --git a/server/bench/registry.js b/server/bench/registry.js
iindex ee058f7..2e6a974 100644
---- a/server/bench/registry.js
++++ b/server/bench/registry.js
@@@ -305,13 +305,15 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
const db = s.db;
// ── Benchmarks ───────────────────────────────────────────
-- async function createBenchmark({ owner, title, description = '', kind, tags = [], visibility = 'unlisted', parent = null, definition = null, subject }) {
++ async function createBenchmark({ owner, title, description = '', kind, tags = [], visibility, parent = null, definition = null, subject }) {
const text = String(title == null ? '' : title).trim();
if (!text) return refusal(422, 'bench.title_required', 'A benchmark needs a title.');
if (text.length > 160) return refusal(422, 'bench.title_too_long', 'The title must be at most 160 characters.');
const kindId = String(kind == null ? '' : kind).trim();
if (!kindId) return refusal(422, 'bench.kind_required', 'A benchmark needs a kind.');
-- const vis = String(visibility || 'unlisted');
++ const personal = /^usr_/.test(String(owner || '')) && String(subject || owner) === String(owner);
++ const preferred = visibility == null && personal && sharing ? await sharing.defaultAudience(owner) : null;
++ const vis = preferred ? (preferred === 'public' ? 'public' : preferred === 'link' ? 'unlisted' : 'private') : String(visibility || 'unlisted');
if (!VISIBILITIES.includes(vis)) return refusal(422, 'bench.invalid_visibility', `visibility is ${VISIBILITIES.join(', ')}.`);
const list = (Array.isArray(tags) ? tags : []).map((t) => String(t).trim()).filter(Boolean).slice(0, 32);
const id = s.newId('bmk');
@@@ -322,7 +324,7 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
[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);
++ if (sharing) await sharing.register({ subject: /^usr_/.test(String(subject || '')) ? String(subject) : null }, id, preferred || vis);
return { ok: true, benchmark: await getBenchmark(id, { revealDraft: true }) };
}
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 019a0e5..8d71976 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -193,7 +193,7 @@ function createPageRoutes(ctx) {
try {
const made = await registry.createBenchmark({
owner: subject, title: definition.title, description: definition.description, kind: definition.kind,
-- tags: definition.tags, visibility: 'private', definition, subject,
++ tags: definition.tags, definition, subject,
});
if (!made.ok) return fail(made.status || 422, made.message || 'The benchmark could not be made.');
const frozen = await registry.freezeVersion(made.benchmark.id, { definition, subject, validate: validateBenchmark });
ddiff --git a/server/http/registry.js b/server/http/registry.js
iindex df6cb28..f515d16 100644
---- a/server/http/registry.js
++++ b/server/http/registry.js
@@@ -27,7 +27,7 @@ const { validateBenchmark, validateRun } = require('../bench/validate');
const { canViewBenchmark } = require('../bench/registry');
function createRegistryApi(ctx) {
-- const { config, principal, registry } = ctx;
++ const { config, principal, registry, sharing } = ctx;
const r = asyncRouter();
r.use(express.json({ limit: '16mb' }));
@@@ -44,6 +44,22 @@ function createRegistryApi(ctx) {
const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);
++ r.get('/share-defaults/benchmark', async (req, res) => {
++ const subject = subjectOf(req);
++ if (!subject) return problem(res, 401, 'bench.sign_in_required', 'Sign in to read your benchmark default.');
++ return res.json({ kind: 'benchmark', audience: await sharing.defaultAudience(subject) });
++ });
++ r.put('/share-defaults/benchmark', express.json({ limit: '16kb' }), async (req, res) => {
++ const subject = subjectOf(req);
++ if (!subject) return problem(res, 401, 'bench.sign_in_required', 'Sign in to change your benchmark default.');
++ if (req.principal.viaSession && !sameOrigin(req, config)) return problem(res, 403, 'bench.cross_site', 'This change must come from Bench itself.');
++ const audience = String((req.body || {}).audience || '');
++ if (!sharing.AUDIENCES.includes(audience)) return problem(res, 422, 'bench.invalid_audience', 'Choose a sharing audience.');
++ await sharing.setDefaultAudience(subject, audience);
++ const changed = req.body && req.body.apply === true ? await sharing.applyDefault(subject, audience) : 0;
++ return res.json({ kind: 'benchmark', audience, changed });
++ });
++
/** A person (any signed-in user) or a service token with the named capability. */
function actor(cap) {
return (req, res, next) => {
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex 521146e..9b0a768 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -32,6 +32,7 @@ const TABLES = [
{ table: 'bench_rounds', subject: 'created_by', file: 'bench_rounds.json', erase: 'delete' },
{ table: 'bench_tasks', subject: 'created_by', file: 'bench_tasks.json', erase: 'delete' },
{ table: 'bench_evals', subject: 'owner', file: 'bench_evals.json', erase: 'delete' },
++ { table: 'bench_share_defaults', subject: 'owner_subject', file: 'bench_share_defaults.json', erase: 'delete' },
// The runs they submitted and the benchmarks they watch: theirs, so they go.
{ table: 'bench_runs', subject: 'created_by', file: 'bench_runs.json', erase: 'delete' },
{ table: 'bench_watches', subject: 'subject', file: 'bench_watches.json', erase: 'delete' },
ddiff --git a/server/sharing.js b/server/sharing.js
iindex f8755b3..df2676e 100644
---- a/server/sharing.js
++++ b/server/sharing.js
@@@ -12,7 +12,7 @@
* - 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
++ * - onOwnerChange moves bench_benchmarks.owner and 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
@@@ -25,7 +25,7 @@ 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 TO_SDK = { public: 'public', link: 'link', unlisted: 'link', people: 'people', private: 'private' };
const FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@@ -111,7 +111,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
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]);
++ await tx.exec('UPDATE bench_benchmarks SET owner = $1, created_by = $1, updated_at = now() WHERE id = $2', [owner, ref.id]);
}
const shares = createShares({
@@@ -126,6 +126,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
++ async function defaultAudience(owner) {
++ const row = await db.maybe('SELECT audience FROM bench_share_defaults WHERE owner_subject = $1', [owner]);
++ return row ? row.audience : 'private';
++ }
++ async function setDefaultAudience(owner, audience) {
++ if (!AUDIENCES.includes(audience)) throw new Error('Invalid benchmark default');
++ await db.exec(`INSERT INTO bench_share_defaults (owner_subject, audience) VALUES ($1, $2)
++ ON CONFLICT (owner_subject) DO UPDATE SET audience = excluded.audience, updated_at = now()`, [owner, audience]);
++ }
++ async function applyDefault(owner, audience) {
++ if (!AUDIENCES.includes(audience)) throw new Error('Invalid benchmark default');
++ const rows = await db.many('SELECT id FROM bench_benchmarks WHERE owner = $1', [owner]);
++ let changed = 0;
++ for (const row of rows) {
++ const current = await db.maybe('SELECT audience FROM shares WHERE service = $1 AND kind = $2 AND resource_id = $3', [SERVICE, KIND, row.id]);
++ if (!current) {
++ await shares.register(at(row.id), { owner_subject: { type: 'user', id: owner }, audience });
++ await onAudienceChange(at(row.id), audience, db);
++ changed++;
++ } else if (current.audience !== audience) {
++ await shares.setAudience(sv({ subject: owner }), at(row.id), audience);
++ changed++;
++ }
++ }
++ return changed;
++ }
++
return {
shares,
SERVICE,
@@@ -133,6 +160,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
AUDIENCES,
refOf,
shareViewer,
++ defaultAudience, setDefaultAudience, applyDefault,
/** 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 },
ddiff --git a/test/bench-share-defaults.test.js b/test/bench-share-defaults.test.js
nnew file mode 100644
iindex 0000000..272c640
---- /dev/null
++++ b/test/bench-share-defaults.test.js
@@@ -0,0 +1,64 @@
++'use strict';
++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 prompts = ['What is 2 + 2?', 'Say hello in French.', 'Name a primary colour.', 'What is 3 × 3?', 'Finish: sun—', 'Name a planet.'];
++ const make = async (user, title) => {
++ const r = await t.get('/benchmarks/new', {
++ as: user, method: 'POST', body: new URLSearchParams({ title, kind: 'text.answer', format: 'lines', prompts: prompts.join('\n') }).toString(),
++ headers: { 'content-type': 'application/x-www-form-urlencoded' },
++ });
++ assert.strictEqual(r.status, 303, r.text.slice(0, 200));
++ return r.headers.get('location').split('/').pop();
++ };
++ const row = (id) => t.ctx.s.db.maybe("SELECT audience FROM shares WHERE service = 'bench' AND kind = 'benchmark' AND resource_id = $1", [id]);
++ const put = (user, audience, apply = false) => t.get('/api/v1/share-defaults/benchmark', {
++ as: user, method: 'PUT', json: { audience, apply },
++ });
++ try {
++ await check('Only me is the default; a person can save Specific people and override one benchmark', async () => {
++ const initial = await t.get('/api/v1/share-defaults/benchmark', { as: alice });
++ assert.strictEqual(initial.json().audience, 'private');
++ assert.strictEqual((await put(alice, 'people')).status, 200);
++ const id = await make(alice, 'Named readers benchmark');
++ assert.strictEqual((await row(id)).audience, 'people');
++ assert.strictEqual((await t.ctx.s.db.maybe('SELECT visibility FROM bench_benchmarks WHERE id = $1', [id])).visibility, 'private');
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${id}`, { as: bob })).status, 404);
++ const one = await t.get(`/api/v1/benchmark/${id}/share`, {
++ as: alice, method: 'PUT', json: { audience: 'public' }, headers: { 'x-requested-with': 'ov-share' },
++ });
++ assert.strictEqual(one.status, 200, one.text);
++ assert.strictEqual((await row(id)).audience, 'public');
++ });
++
++ await check('apply to all changes only the owner’s benchmarks and removes public listings', async () => {
++ const own = await make(alice, 'Apply own benchmark');
++ const other = await make(bob, 'Bob benchmark');
++ const changed = await put(alice, 'private', true);
++ assert.strictEqual(changed.status, 200, changed.text);
++ assert.ok(changed.json().changed >= 1);
++ assert.strictEqual((await row(own)).audience, 'private');
++ assert.strictEqual((await row(other)).audience, 'private');
++ assert.ok(!(await t.get('/api/v1/benchmarks')).json().benchmarks.some((b) => b.id === own));
++ assert.strictEqual((await t.get(`/api/v1/benchmarks/${own}`, { as: alice })).status, 200);
++ const link = await put(alice, 'link', true);
++ assert.strictEqual(link.status, 200, link.text);
++ assert.strictEqual((await row(own)).audience, 'link');
++ assert.strictEqual((await row(other)).audience, 'private');
++ });
++
++ await check('invalid defaults and cross-site form writes are refused', async () => {
++ assert.strictEqual((await put(alice, 'nope')).status, 422);
++ const bad = await t.get('/api/v1/share-defaults/benchmark', {
++ as: alice, method: 'PUT', json: { audience: 'public' },
++ headers: { origin: 'https://elsewhere.example' },
++ });
++ assert.strictEqual(bad.status, 403);
++ });
++ } finally { await t.close(); }
++ done();
++})().catch((error) => { console.error(error); process.exit(1); });