openvibers/openvibe.bench Commits
Add personal benchmark sharing defaults (#205)
Changes
nnew file mode 100644@@@ -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()++);@@@ -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 }) };}@@@ -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 });@@@ -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) => {@@@ -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' },@@@ -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.fetcasync 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.fetcconst 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.fetcAUDIENCES,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 },nnew file mode 100644@@@ -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); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -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()
+);
@@ -305,13 +305,15 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
@@ -305,13 +305,15 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
const db = s.db;
const db = s.db;
// ── Benchmarks ───────────────────────────────────────────
// ── 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();
const text = String(title == null ? '' : title).trim();
if (!text) return refusal(422, 'bench.title_required', 'A benchmark needs a title.');
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.');
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();
const kindId = String(kind == null ? '' : kind).trim();
if (!kindId) return refusal(422, 'bench.kind_required', 'A benchmark needs a kind.');
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(', ')}.`);
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 list = (Array.isArray(tags) ? tags : []).map((t) => String(t).trim()).filter(Boolean).slice(0, 32);
const id = s.newId('bmk');
const id = s.newId('bmk');
@@ -322,7 +324,7 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
@@ -322,7 +324,7 @@ function createRegistry(s, { log = console, sharing = null } = {}) {
[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);
+ 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 }) };
return { ok: true, benchmark: await getBenchmark(id, { revealDraft: true }) };
}
}
@@ -193,7 +193,7 @@ function createPageRoutes(ctx) {
@@ -193,7 +193,7 @@ function createPageRoutes(ctx) {
try {
try {
const made = await registry.createBenchmark({
const made = await registry.createBenchmark({
owner: subject, title: definition.title, description: definition.description, kind: definition.kind,
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.');
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 });
const frozen = await registry.freezeVersion(made.benchmark.id, { definition, subject, validate: validateBenchmark });
@@ -27,7 +27,7 @@ const { validateBenchmark, validateRun } = require('../bench/validate');
@@ -27,7 +27,7 @@ const { validateBenchmark, validateRun } = require('../bench/validate');
const { canViewBenchmark } = require('../bench/registry');
const { canViewBenchmark } = require('../bench/registry');
function createRegistryApi(ctx) {
function createRegistryApi(ctx) {
- const { config, principal, registry } = ctx;
+ const { config, principal, registry, sharing } = ctx;
const r = asyncRouter();
const r = asyncRouter();
r.use(express.json({ limit: '16mb' }));
r.use(express.json({ limit: '16mb' }));
@@ -44,6 +44,22 @@ function createRegistryApi(ctx) {
@@ -44,6 +44,22 @@ function createRegistryApi(ctx) {
const subjectOf = (req) => (req.principal && req.principal.kind === 'user' ? req.principal.subject : null);
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. */
/** A person (any signed-in user) or a service token with the named capability. */
function actor(cap) {
function actor(cap) {
return (req, res, next) => {
return (req, res, next) => {
@@ -32,6 +32,7 @@ const TABLES = [
@@ -32,6 +32,7 @@ const TABLES = [
{ table: 'bench_rounds', subject: 'created_by', file: 'bench_rounds.json', erase: 'delete' },
{ 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_tasks', subject: 'created_by', file: 'bench_tasks.json', erase: 'delete' },
{ table: 'bench_evals', subject: 'owner', file: 'bench_evals.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.
// 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_runs', subject: 'created_by', file: 'bench_runs.json', erase: 'delete' },
{ table: 'bench_watches', subject: 'subject', file: 'bench_watches.json', erase: 'delete' },
{ table: 'bench_watches', subject: 'subject', file: 'bench_watches.json', erase: 'delete' },
@@ -12,7 +12,7 @@
@@ -12,7 +12,7 @@
* - notify Network's notification push; a refusal is logged, never allowed to stall the queue
* - 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
* - 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
* 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
* 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
* 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';
@@ -25,7 +25,7 @@ const SERVICE = 'bench';
const KIND = 'benchmark';
const KIND = 'benchmark';
const AUDIENCES = ['public', 'link', 'people', 'private'];
const AUDIENCES = ['public', 'link', 'people', 'private'];
/** Bench's visibility words to the SDK's, and back. */
/** 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 FROM_SDK = { public: 'public', link: 'unlisted', people: 'private', private: 'private' };
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
const refOf = (id) => ({ service: SERVICE, kind: KIND, id: String(id) });
@@ -111,7 +111,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
@@ -111,7 +111,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
async function onOwnerChange(ref, from, to, tx) {
async function onOwnerChange(ref, from, to, tx) {
if (ref.service !== SERVICE || ref.kind !== KIND) return;
if (ref.service !== SERVICE || ref.kind !== KIND) return;
const owner = to && to.type === 'user' ? to.id : null;
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({
const shares = createShares({
@@ -126,6 +126,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
@@ -126,6 +126,33 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
const sv = (viewer) => shareViewer(viewer);
const sv = (viewer) => shareViewer(viewer);
const at = (id) => refOf(id);
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 {
return {
shares,
shares,
SERVICE,
SERVICE,
@@ -133,6 +160,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
@@ -133,6 +160,7 @@ function createSharing({ db, config, now = Date.now, fetchImpl = globalThis.fetc
AUDIENCES,
AUDIENCES,
refOf,
refOf,
shareViewer,
shareViewer,
+ defaultAudience, setDefaultAudience, applyDefault,
/** Register a post with the sharing system (inside the post's own transaction). */
/** Register a post with the sharing system (inside the post's own transaction). */
register: async (viewer, id, audience = 'private') => await shares.register(at(id), {
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 },
owner_subject: viewer && viewer.subject ? { type: String(viewer.subject).startsWith('gst_') ? 'guest' : 'user', id: viewer.subject } : { type: 'service', id: SERVICE },
new file mode 100644
new file mode 100644
@@ -0,0 +1,64 @@
@@ -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); });