openvibers/openvibe.play Commits
Your own game, from the sandbox: a developer's sandbox app registers up to 3 sandbox- titles (guest players only, never adopted, listed, counted or indexed); a production app takes the real name (#41)
Changes
@@@ -8,6 +8,11 @@ its boards. The rules of each game stay in the game; Play keeps the record and a- **Titles** belong to the principal that registered them (a game's service or app token). Only that principal writes toa title; to anyone else it does not exist.++- **Your own game, from the sandbox** (ADR-014 opt-in): any developer makes a project on openvibe.services and a sandbox++ app with Play's three capabilities (in Network's default sandbox allowance). A sandbox app registers up to 3 titles++ whose ids start with `sandbox-`, with guest players only (nothing adopted, no person's record touched), never listed,++ counted or indexed: readable at their id, marked `sandbox: true`. A production app (staff turn production on for the++ project) registers the real name; it never takes a `sandbox-` id.- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update isretired, never deleted: what players earned stays.- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week@@@ -22,7 +22,8 @@* GET /me/saves, DELETE /me/saves/:title/:slot a person their own slots in every title; delete one** Writes come from the principal that registered the title (a game's service token); another one is told the title-- * does not exist. Public reads answer every origin without credentials. A refusal is RFC 9457 problem+json.++ * does not exist. A developer's sandbox app manages sandbox-… titles only (play/store.js). Public reads answer every++ * origin without credentials. A refusal is RFC 9457 problem+json.*/const express = require('express');const { http } = require('openvibe-contracts');@@@ -97,7 +98,7 @@ function createApi(ctx) {// ── A title's own principal ───────────────────────────r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {-- const out = await play.define(req.principal.requester, req.params.title, req.body || {});++ const out = await play.define(req.principal.requester, req.params.title, req.body || {}, { sandbox: req.principal.sandbox === true });return res.status(out.created ? 201 : 200).json(out);}));r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {@@@ -20,7 +20,7 @@ const { asyncRouter } = require('./router');const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');const { html, raw } = require('../render/html');const { send } = require('../render/layout');--const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, PER_PAGE } = require('../play/store');++const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, isSandbox, PER_PAGE } = require('../play/store');const SITE_NAME = 'OpenVibe.Play';const num = (n) => Number(n).toLocaleString('en-US');@@@ -34,6 +34,7 @@ const HOME_FAQ = [{ q: 'How do cloud saves avoid overwriting each other?', a: `A player has up to ${MAX_SLOTS} save slots per game, ${MAX_SAVE_BYTES / 1024} KB each. Every write names the version it read; if another device saved in between, the write is refused with play.save_conflict, so the game can merge instead of losing progress.` },{ q: 'What happens when a game changes its achievements?', a: 'An achievement or board the game stops listing is retired, never deleted: what players earned stays on their profile.' },{ q: 'How does a game use Play?', a: "Its server calls Play's API (or the openvibe-sdk/play client) with an OpenVibe token granted Play's capabilities. The app that registers a title is its only writer; posting the same round twice changes nothing." },++ { q: 'Can I add my own game?', a: `Yes. Make a project on openvibe.services with a sandbox app holding Play's three capabilities: it registers up to ${MAX_SANDBOX_TITLES} sandbox- titles (unlisted, guest players) to try everything. Once staff turn production on for the project, its production app takes the real name.` },{ q: 'Where do I see my own achievements?', a: 'Sign in and open Your achievements: every game, every unlock and when it happened.' },];function homeFaqJsonLd() {@@@ -118,7 +119,7 @@ ${raw(showcase.features({${raw(showcase.steps({title: 'How a game uses Play',items: [-- { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards.' },++ { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards. Yours starts in the sandbox.', href: '/docs#sandbox' },{ title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },{ title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },@@@ -173,10 +174,11 @@ ${raw(showcase.limits({const boards = [];for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));page(req, res, {-- index: true, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,++ index: !t.sandbox, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p><h1>${t.name}</h1>++${t.sandbox ? html`<p class="pl-muted">Sandbox: a developer is trying Play with this game. It is not listed anywhere, and its players are guests.</p>` : ''}${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}">All time</a></p>`)}</section>` : ''}@@@ -201,7 +203,7 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-};page(req, res, {canonicalPath: link({ page: pageNo }),-- index: true, cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,++ index: !isSandbox(b.title), cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p><h1>${b.name}</h1>@@@ -259,7 +261,14 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games<li><code>GET /titles/:title/players/:player/saves/:slot</code> (<code>play.player.read</code>): <code>{ slot, version, data, created_at, updated_at }</code>; <code>GET …/saves</code> lists the slots without their data; <code>DELETE …/saves/:slot</code> (<code>play.result.write</code>).</li><li><code>GET /me/saves</code> and <code>DELETE /me/saves/:title/:slot</code>: with a person's own Network token, their slots in every game.</li></ul>--<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>`,++<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>++<h2 id="sandbox">Your own game: start in the sandbox</h2>++<ol>++<li>Sign in on <a href="https://openvibe.services/projects">OpenVibe Services</a> and make a project (new projects are sandbox-only).</li>++<li>Add a sandbox app and ask for <code>play.title.manage</code>, <code>play.result.write</code> and <code>play.player.read</code>: a sandbox app gets them without waiting for anyone.</li>++<li>Get a token for audience <code>openvibe.play</code> (client credentials) and <code>PUT /titles/sandbox-your-game</code>.</li>++</ol>++<p>A sandbox app registers up to ${String(MAX_SANDBOX_TITLES)} titles, each id starting with <code>sandbox-</code> (anything else answers <code>400 play.sandbox_title</code>). Their players are guests only (<code>400 play.sandbox_guests_only</code> for a person), nothing is adopted, and they are never listed, counted or indexed: their pages and JSON answer at their id, marked <code>sandbox: true</code>, so you can check your boards. When your game is ready, staff turn production on for your project; its production app registers the real name and real players' progress counts on their profiles.</p>`,}));r.get('/updates', (req, res) => page(req, res, {@@@ -5,10 +5,13 @@** { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,* or this site's session cookie-- * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }++ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, sandbox, claims }* a Network app, agent or service token for* audience openvibe.play; each route names-- * ONE capability it needs++ * ONE capability it needs. `sandbox`: an++ * outside developer's sandbox app (ADR-014++ * opt-in), which manages sandbox titles only++ * (play/store.js)* { kind: 'anonymous' }** A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the@@@ -52,10 +55,12 @@ function createPrincipal({ config, keys }) {if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {const publicKey = await keys.pemForToken(token);if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };-- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });++ // Sandbox app tokens are accepted (ADR-014 opt-in): a sandbox app registers and writes sandbox titles only,++ // whose players are guests and which are never listed or counted (play/store.js).++ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;-- return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };++ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, sandbox: r.claims.env === 'sandbox', claims: r.claims } };}// A person's own Network token (the same kind this site's session holds).const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });@@@ -18,6 +18,11 @@** Every write names the title it is for, and only the principal that registered a title writes to it: another* principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).++ *++ * The sandbox (ADR-014 opt-in): any developer's sandbox app tries Play on titles of its own. Their ids start with++ * `sandbox-` (a sandbox app registers nothing else and a production app never one), an app has at most++ * MAX_SANDBOX_TITLES, their players are guests only (no person's record changes, nothing is adopted), and they are never++ * listed or counted: readable by their id, marked `sandbox: true`. The real name comes with a production app.*/const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;const SLOT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;@@@ -29,6 +34,9 @@ const PER_PAGE = 25;const MAX_ENTRIES = 100;const MAX_DEFS = 200;const EARLIEST = Date.UTC(2020, 0, 1);++const SANDBOX_PREFIX = 'sandbox-';++const MAX_SANDBOX_TITLES = 3;++const isSandbox = (titleId) => String(titleId || '').startsWith(SANDBOX_PREFIX);class PlayError extends Error {constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }@@@ -60,6 +68,12 @@ function createPlay({ s }) {if (!PLAYER_RE.test(String(p || ''))) fail(400, 'play.bad_player', 'player is a person (usr_…) or guest:<opaque> (8 to 128 of A-Z a-z 0-9 _ -)');return p;};++ /** A player of this title: a sandbox title's are guests only. */++ const playerIn = (titleId, p) => {++ player(p);++ if (isSandbox(titleId) && !String(p).startsWith('guest:')) fail(400, 'play.sandbox_guests_only', 'a sandbox title\'s players are guests (guest:<opaque>): no person\'s record changes until your game has a production app');++ return p;++ };function defsOf(list, what, extra) {if (list === undefined) return null;@@@ -75,8 +89,10 @@ function createPlay({ s }) {});}-- async function define(owner, titleId, doc = {}) {++ async function define(owner, titleId, doc = {}, { sandbox = false } = {}) {if (!TITLE_RE.test(String(titleId || ''))) fail(400, 'play.bad_title', 'a title id is 2 to 40 of a-z, 0-9 and -');++ if (sandbox && !isSandbox(titleId)) fail(400, 'play.sandbox_title', `a sandbox app's title id starts with ${SANDBOX_PREFIX} (the real name comes with a production app)`);++ if (!sandbox && isSandbox(titleId)) fail(400, 'play.sandbox_title', `${SANDBOX_PREFIX} ids are for sandbox apps`);const name = str(doc.name, 80);if (!name) fail(400, 'play.bad_title', 'a title needs a name');const url = doc.url == null || doc.url === '' ? null : String(doc.url);@@@ -90,6 +106,9 @@ function createPlay({ s }) {return await s.tx(async () => {const cur = await db.maybe('SELECT * FROM play_titles WHERE id = $1 FOR UPDATE', [titleId]);if (cur && cur.owner !== owner) fail(409, 'play.title_taken', 'another principal manages this title');++ if (!cur && sandbox && Number(await db.value('SELECT COUNT(*) FROM play_titles WHERE owner = $1', [owner])) >= MAX_SANDBOX_TITLES) {++ fail(409, 'play.sandbox_limit', `a sandbox app has at most ${MAX_SANDBOX_TITLES} titles`);++ }if (cur) await db.exec('UPDATE play_titles SET name = $2, url = $3, summary = $4, updated_at = $5 WHERE id = $1', [titleId, name, url, str(doc.summary, 300), now]);else await db.exec('INSERT INTO play_titles (id, owner, name, url, summary, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)', [titleId, owner, name, url, str(doc.summary, 300), now]);if (achievements) {@@@ -128,7 +147,7 @@ function createPlay({ s }) {if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);const name = str(e.name, 40);if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);-- return [player(e.player), name, score];++ return [playerIn(titleId, e.player), name, score];});let recorded = 0;await s.tx(async () => {@@@ -142,7 +161,7 @@ function createPlay({ s }) {async function unlock(owner, titleId, post = {}) {await ownTitle(owner, titleId);-- const p = player(post.player);++ const p = playerIn(titleId, post.player);const ids = post.achievements;if (!Array.isArray(ids) || !ids.length || ids.length > MAX_DEFS || !ids.every((x) => DEF_RE.test(String(x)))) fail(400, 'play.bad_unlock', 'achievements is a list of achievement ids');const known = new Set((await db.many('SELECT id FROM play_achievements WHERE title_id = $1 AND NOT retired', [titleId])).map((r) => r.id));@@@ -163,6 +182,7 @@ function createPlay({ s }) {/** A guest signed in: their unlocks and scores in this title become the person's (the earliest unlock wins). */async function adopt(owner, titleId, guest, person) {await ownTitle(owner, titleId);++ if (isSandbox(titleId)) fail(400, 'play.sandbox_guests_only', 'a sandbox title never adopts: its players stay guests');if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');return await s.tx(() => moveRows(titleId, guest, person));@@@ -240,7 +260,7 @@ function createPlay({ s }) {WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);return {-- id: t.id, name: t.name, url: t.url, summary: t.summary, players,++ id: t.id, name: t.name, url: t.url, summary: t.summary, players, ...(isSandbox(t.id) ? { sandbox: true } : {}),achievements: achievements.map((a) => (a.hidden? { id: a.id, hidden: true, unlocked_by: a.unlocked_by, name: 'A secret achievement', description: '' }: { id: a.id, name: a.name, description: a.description, unlocked_by: a.unlocked_by })),@@@ -250,7 +270,7 @@ function createPlay({ s }) {}async function titles() {-- const ids = (await db.many('SELECT id FROM play_titles ORDER BY created_at, id')).map((r) => r.id);++ const ids = (await db.many("SELECT id FROM play_titles WHERE id NOT LIKE 'sandbox-%' ORDER BY created_at, id")).map((r) => r.id);const out = [];for (const id of ids) out.push(await title(id));return out;@@@ -276,7 +296,7 @@ function createPlay({ s }) {/** Writes a slot. `version` (optional) is the version the writer read: a stale one is refused (409), a new slot takes 0. */async function putSave(owner, titleId, playerId, slot, { data, version } = {}) {await ownTitle(owner, titleId);-- player(playerId); slotOf(slot);++ playerIn(titleId, playerId); slotOf(slot);if (data === undefined) fail(400, 'play.bad_save', 'data is the save, any JSON value');const json = JSON.stringify(data);const size = Buffer.byteLength(json, 'utf8');@@@ -326,4 +346,4 @@ function createPlay({ s }) {putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };}--module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, weekStart, PER_PAGE };++module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, SANDBOX_PREFIX, isSandbox, weekStart, PER_PAGE };@@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%// headroom; file counts are the measured counts and external files stay at 0.const BUDGETS = {-- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)++ htmlRawKB: 28.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD); 27.8 with "Can I add my own game?" (10-25, also in the FAQPage JSON-LD)htmlBrotliKB: 6.9, // 6.2jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)jsRawKB: 264, // 239.8nnew file mode 100644@@@ -0,0 +1,75 @@++'use strict';++// Any developer tries Play from a sandbox app (ADR-014 opt-in): its titles' ids start with sandbox-, it has at most++// three, their players are guests only and never adopted, and they are never listed, counted or indexed (readable by++// their id, marked sandbox). A production app never takes a sandbox- id.++const assert = require('assert');++const crypto = require('crypto');++const { serviceAuth } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++const PROJECT = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';++const P1 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';++const G1 = 'guest:a1b2c3d4e5f6';++const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];++++(async () => {++ const t = await boot();++ try {++ const app = (id, env) => {++ const now = Math.floor(Date.now() / 1000);++ return { bearer: serviceAuth.signServiceToken({ iss: t.network.url, sub: `app:${id}`, actor_type: 'app', aud: ['openvibe.play'], cap: CAPS, ns: [PROJECT], project_id: PROJECT, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem) };++ };++ const sandbox = app('app_01JABCDEFGHJKMNPQRSTVWXYZ0', 'sandbox');++ const production = app('app_01JABCDEFGHJKMNPQRSTVWXYZ1', 'production');++ const DOC = { name: 'Gem Run', achievements: [{ id: 'first-gem', name: 'First Gem' }], boards: [{ id: 'best', name: 'Best', kind: 'best' }] };++ const put = (id, auth, body = DOC) => t.get(`/api/v1/titles/${id}`, { ...auth, method: 'PUT', json: body });++ const code = async (p) => { const r = await p; return [r.status, r.json().code]; };++++ await check('a sandbox app registers sandbox- titles only, at most three; a production app never one', async () => {++ assert.deepStrictEqual(await code(put('gem-run', sandbox)), [400, 'play.sandbox_title']);++ const made = await put('sandbox-gem-run', sandbox);++ assert.strictEqual(made.status, 201, made.text);++ assert.strictEqual(made.json().title.sandbox, true);++ assert.deepStrictEqual(await code(put('sandbox-other', production)), [400, 'play.sandbox_title']);++ assert.strictEqual((await put('gem-run', production)).status, 201, 'a production app takes the real name');++ assert.strictEqual((await put('sandbox-two', sandbox)).status, 201);++ assert.strictEqual((await put('sandbox-three', sandbox)).status, 201);++ assert.deepStrictEqual(await code(put('sandbox-four', sandbox)), [409, 'play.sandbox_limit']);++ assert.strictEqual((await put('sandbox-gem-run', sandbox, { ...DOC, summary: 'Updated.' })).status, 200, 'updating one is not a new one');++ });++++ await check('a sandbox title\'s players are guests only, and none is adopted', async () => {++ const post = (path, json) => t.get(`/api/v1/titles/sandbox-gem-run/${path}`, { ...sandbox, json });++ assert.deepStrictEqual(await code(post('scores', { board: 'best', ref: 'r1', entries: [{ player: P1, name: 'Ada', score: 5 }] })), [400, 'play.sandbox_guests_only']);++ assert.deepStrictEqual((await post('scores', { board: 'best', ref: 'r1', entries: [{ player: G1, name: 'Tester', score: 5 }] })).json(), { recorded: 1, repeated: 0 });++ assert.deepStrictEqual(await code(post('unlocks', { player: P1, achievements: ['first-gem'] })), [400, 'play.sandbox_guests_only']);++ assert.deepStrictEqual((await post('unlocks', { player: G1, achievements: ['first-gem'] })).json(), { unlocked: ['first-gem'] });++ assert.deepStrictEqual(await code(post(`players/${G1}/adopt`, { person: P1 })), [400, 'play.sandbox_guests_only']);++ const save = (player) => t.get(`/api/v1/titles/sandbox-gem-run/players/${player}/saves/main`, { ...sandbox, method: 'PUT', json: { data: { level: 1 } } });++ assert.deepStrictEqual(await code(save(P1)), [400, 'play.sandbox_guests_only']);++ assert.strictEqual((await save(G1)).status, 200);++ });++++ await check('a sandbox title is never listed or indexed, and answers at its id', async () => {++ const listed = (await t.get('/api/v1/titles')).json().titles.map((x) => x.id);++ assert.ok(listed.includes('gem-run') && !listed.some((id) => id.startsWith('sandbox-')), listed.join(','));++ assert.strictEqual((await t.get('/api/v1/titles/sandbox-gem-run')).json().title.sandbox, true);++ const pageOf = await t.get('/t/sandbox-gem-run');++ assert.strictEqual(pageOf.status, 200);++ assert.match(pageOf.text, /<meta name="robots" content="noindex/);++ assert.match(pageOf.text, /Sandbox: a developer is trying Play/);++ assert.match((await t.get('/t/sandbox-gem-run/b/best')).text, /<meta name="robots" content="noindex/);++ assert.match((await t.get('/t/gem-run')).text, /<meta name="robots" content="index/);++ assert.ok(!(await t.get('/')).text.includes('sandbox-gem-run'), 'not on the home');++ assert.ok(!(await t.get('/sitemap.xml')).text.includes('sandbox-'), 'not in the sitemap');++ });++++ await check('the docs and the home say how to start', async () => {++ const docs = await t.get('/docs');++ assert.match(docs.text, /<h2 id="sandbox">Your own game: start in the sandbox<\/h2>/);++ assert.match(docs.text, /https:\/\/openvibe\.services\/projects/);++ assert.match((await t.get('/')).text, /Can I add my own game\?/);++ });++ } finally { await t.close(); }++ done();++})();
@@ -8,6 +8,11 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
@@ -8,6 +8,11 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
- **Titles** belong to the principal that registered them (a game's service or app token). Only that principal writes to
- **Titles** belong to the principal that registered them (a game's service or app token). Only that principal writes to
a title; to anyone else it does not exist.
a title; to anyone else it does not exist.
+- **Your own game, from the sandbox** (ADR-014 opt-in): any developer makes a project on openvibe.services and a sandbox
+ app with Play's three capabilities (in Network's default sandbox allowance). A sandbox app registers up to 3 titles
+ whose ids start with `sandbox-`, with guest players only (nothing adopted, no person's record touched), never listed,
+ counted or indexed: readable at their id, marked `sandbox: true`. A production app (staff turn production on for the
+ project) registers the real name; it never takes a `sandbox-` id.
- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update is
- **Achievements**, including hidden ones whose name shows only once unlocked. A definition left out of an update is
retired, never deleted: what players earned stays.
retired, never deleted: what players earned stays.
- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
- **Leaderboards** of two kinds: `best` ranks each player's best score, `sum` the total of theirs. Each reads this week
@@ -22,7 +22,8 @@
@@ -22,7 +22,8 @@
* GET /me/saves, DELETE /me/saves/:title/:slot a person their own slots in every title; delete one
* GET /me/saves, DELETE /me/saves/:title/:slot a person their own slots in every title; delete one
*
*
* Writes come from the principal that registered the title (a game's service token); another one is told the title
* Writes come from the principal that registered the title (a game's service token); another one is told the title
- * does not exist. Public reads answer every origin without credentials. A refusal is RFC 9457 problem+json.
+ * does not exist. A developer's sandbox app manages sandbox-… titles only (play/store.js). Public reads answer every
+ * origin without credentials. A refusal is RFC 9457 problem+json.
*/
*/
const express = require('express');
const express = require('express');
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
@@ -97,7 +98,7 @@ function createApi(ctx) {
@@ -97,7 +98,7 @@ function createApi(ctx) {
// ── A title's own principal ───────────────────────────
// ── A title's own principal ───────────────────────────
r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
r.put('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
- const out = await play.define(req.principal.requester, req.params.title, req.body || {});
+ const out = await play.define(req.principal.requester, req.params.title, req.body || {}, { sandbox: req.principal.sandbox === true });
return res.status(out.created ? 201 : 200).json(out);
return res.status(out.created ? 201 : 200).json(out);
}));
}));
r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
@@ -20,7 +20,7 @@ const { asyncRouter } = require('./router');
@@ -20,7 +20,7 @@ const { asyncRouter } = require('./router');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { createDiscoveryRoutes, homeJsonLd } = require('./discovery');
const { html, raw } = require('../render/html');
const { html, raw } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
-const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, PER_PAGE } = require('../play/store');
+const { PlayError, PLAYER_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, isSandbox, PER_PAGE } = require('../play/store');
const SITE_NAME = 'OpenVibe.Play';
const SITE_NAME = 'OpenVibe.Play';
const num = (n) => Number(n).toLocaleString('en-US');
const num = (n) => Number(n).toLocaleString('en-US');
@@ -34,6 +34,7 @@ const HOME_FAQ = [
@@ -34,6 +34,7 @@ const HOME_FAQ = [
{ q: 'How do cloud saves avoid overwriting each other?', a: `A player has up to ${MAX_SLOTS} save slots per game, ${MAX_SAVE_BYTES / 1024} KB each. Every write names the version it read; if another device saved in between, the write is refused with play.save_conflict, so the game can merge instead of losing progress.` },
{ q: 'How do cloud saves avoid overwriting each other?', a: `A player has up to ${MAX_SLOTS} save slots per game, ${MAX_SAVE_BYTES / 1024} KB each. Every write names the version it read; if another device saved in between, the write is refused with play.save_conflict, so the game can merge instead of losing progress.` },
{ q: 'What happens when a game changes its achievements?', a: 'An achievement or board the game stops listing is retired, never deleted: what players earned stays on their profile.' },
{ q: 'What happens when a game changes its achievements?', a: 'An achievement or board the game stops listing is retired, never deleted: what players earned stays on their profile.' },
{ q: 'How does a game use Play?', a: "Its server calls Play's API (or the openvibe-sdk/play client) with an OpenVibe token granted Play's capabilities. The app that registers a title is its only writer; posting the same round twice changes nothing." },
{ q: 'How does a game use Play?', a: "Its server calls Play's API (or the openvibe-sdk/play client) with an OpenVibe token granted Play's capabilities. The app that registers a title is its only writer; posting the same round twice changes nothing." },
+ { q: 'Can I add my own game?', a: `Yes. Make a project on openvibe.services with a sandbox app holding Play's three capabilities: it registers up to ${MAX_SANDBOX_TITLES} sandbox- titles (unlisted, guest players) to try everything. Once staff turn production on for the project, its production app takes the real name.` },
{ q: 'Where do I see my own achievements?', a: 'Sign in and open Your achievements: every game, every unlock and when it happened.' },
{ q: 'Where do I see my own achievements?', a: 'Sign in and open Your achievements: every game, every unlock and when it happened.' },
];
];
function homeFaqJsonLd() {
function homeFaqJsonLd() {
@@ -118,7 +119,7 @@ ${raw(showcase.features({
@@ -118,7 +119,7 @@ ${raw(showcase.features({
${raw(showcase.steps({
${raw(showcase.steps({
title: 'How a game uses Play',
title: 'How a game uses Play',
items: [
items: [
- { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards.' },
+ { title: 'Register the title', text: 'A game\'s server registers its title with its own OpenVibe token: its achievements and its boards. Yours starts in the sandbox.', href: '/docs#sandbox' },
{ title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },
{ title: 'Post what happened', text: 'Scores for each round and the achievements a player unlocked, each idempotent, so a retry never counts twice.' },
{ title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
{ title: 'Read the boards anywhere', text: 'Boards are public JSON for any page: a best score or a total, this week or all time.' },
{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
{ title: 'Guests count', text: 'A guest plays at once; when they sign in, the game moves their progress to their account.', href: '/docs' },
@@ -173,10 +174,11 @@ ${raw(showcase.limits({
@@ -173,10 +174,11 @@ ${raw(showcase.limits({
const boards = [];
const boards = [];
for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
for (const b of t.boards) boards.push(await play.board(t.id, b.id, { period: 'week' }));
page(req, res, {
page(req, res, {
- index: true, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
+ index: !t.sandbox, cache: PUBLIC_CACHE, title: `${t.name}: achievements and leaderboards`,
description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,
description: `${t.name} on OpenVibe.Play: ${plural(t.achievements.length, 'achievement', 'achievements')} and ${plural(t.boards.length, 'leaderboard', 'leaderboards')}, this week and all time.`,
body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p>
body: html`<p class="pl-crumbs"><a href="/">Games</a> /</p>
<h1>${t.name}</h1>
<h1>${t.name}</h1>
+${t.sandbox ? html`<p class="pl-muted">Sandbox: a developer is trying Play with this game. It is not listed anywhere, and its players are guests.</p>` : ''}
${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}
${t.summary ? html`<p class="pl-lede">${t.summary}</p>` : ''}
${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}
${t.url ? html`<p><a class="pl-button" href="${t.url}">Play ${t.name}</a></p>` : ''}
${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}">All time</a></p>`)}</section>` : ''}
${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-h">Leaderboards this week</h2>${boards.map((b) => html`<h3>${b.name}</h3>${boardTable({ ...b, entries: b.entries.slice(0, 10) })}<p><a href="/t/${t.id}/b/${b.board}?period=week">This week</a> · <a href="/t/${t.id}/b/${b.board}">All time</a></p>`)}</section>` : ''}
@@ -201,7 +203,7 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
@@ -201,7 +203,7 @@ ${boards.length ? html`<section aria-labelledby="pl-boards-h"><h2 id="pl-boards-
};
};
page(req, res, {
page(req, res, {
canonicalPath: link({ page: pageNo }),
canonicalPath: link({ page: pageNo }),
- index: true, cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
+ index: !isSandbox(b.title), cache: PUBLIC_CACHE, title: `${b.name}, ${period === 'week' ? 'this week' : 'all time'} · ${b.title_name}`,
description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,
description: `The ${b.name} leaderboard of ${b.title_name}, ${period === 'week' ? 'this week' : 'all time'}: ${b.kind === 'sum' ? 'each player\'s total' : 'each player\'s best score'}.`,
body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p>
body: html`<p class="pl-crumbs"><a href="/">Games</a> / <a href="/t/${b.title}">${b.title_name}</a> /</p>
<h1>${b.name}</h1>
<h1>${b.name}</h1>
@@ -259,7 +261,14 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
@@ -259,7 +261,14 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<li><code>GET /titles/:title/players/:player/saves/:slot</code> (<code>play.player.read</code>): <code>{ slot, version, data, created_at, updated_at }</code>; <code>GET …/saves</code> lists the slots without their data; <code>DELETE …/saves/:slot</code> (<code>play.result.write</code>).</li>
<li><code>GET /titles/:title/players/:player/saves/:slot</code> (<code>play.player.read</code>): <code>{ slot, version, data, created_at, updated_at }</code>; <code>GET …/saves</code> lists the slots without their data; <code>DELETE …/saves/:slot</code> (<code>play.result.write</code>).</li>
<li><code>GET /me/saves</code> and <code>DELETE /me/saves/:title/:slot</code>: with a person's own Network token, their slots in every game.</li>
<li><code>GET /me/saves</code> and <code>DELETE /me/saves/:title/:slot</code>: with a person's own Network token, their slots in every game.</li>
</ul>
</ul>
-<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>`,
+<p>A <code>player</code> is a person's OpenVibe subject (<code>usr_…</code>) or a guest your game names, <code>guest:</code> followed by 8 to 128 letters, digits, <code>_</code> or <code>-</code>. Only people's rows are personal data: they are in the person's account export and erased with their account.</p>
+<h2 id="sandbox">Your own game: start in the sandbox</h2>
+<ol>
+<li>Sign in on <a href="https://openvibe.services/projects">OpenVibe Services</a> and make a project (new projects are sandbox-only).</li>
+<li>Add a sandbox app and ask for <code>play.title.manage</code>, <code>play.result.write</code> and <code>play.player.read</code>: a sandbox app gets them without waiting for anyone.</li>
+<li>Get a token for audience <code>openvibe.play</code> (client credentials) and <code>PUT /titles/sandbox-your-game</code>.</li>
+</ol>
+<p>A sandbox app registers up to ${String(MAX_SANDBOX_TITLES)} titles, each id starting with <code>sandbox-</code> (anything else answers <code>400 play.sandbox_title</code>). Their players are guests only (<code>400 play.sandbox_guests_only</code> for a person), nothing is adopted, and they are never listed, counted or indexed: their pages and JSON answer at their id, marked <code>sandbox: true</code>, so you can check your boards. When your game is ready, staff turn production on for your project; its production app registers the real name and real players' progress counts on their profiles.</p>`,
}));
}));
r.get('/updates', (req, res) => page(req, res, {
r.get('/updates', (req, res) => page(req, res, {
@@ -5,10 +5,13 @@
@@ -5,10 +5,13 @@
*
*
* { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
* { kind: 'user', requester: 'user:usr_…', project: null, viaSession } a person: their Network token as a Bearer,
* or this site's session cookie
* or this site's session cookie
- * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, claims }
+ * { kind: 'app', requester: 'app:app_…' | 'agent:agt_…' | 'service:x', project: 'prj_…' | null, sandbox, claims }
* a Network app, agent or service token for
* a Network app, agent or service token for
* audience openvibe.play; each route names
* audience openvibe.play; each route names
- * ONE capability it needs
+ * ONE capability it needs. `sandbox`: an
+ * outside developer's sandbox app (ADR-014
+ * opt-in), which manages sandbox titles only
+ * (play/store.js)
* { kind: 'anonymous' }
* { kind: 'anonymous' }
*
*
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
* A request that presents a token is judged on that token alone: a bad one is refused, never downgraded to the
@@ -52,10 +55,12 @@ function createPrincipal({ config, keys }) {
@@ -52,10 +55,12 @@ function createPrincipal({ config, keys }) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
if (payload && typeof payload.sub === 'string' && PRINCIPAL_SUB.test(payload.sub)) {
const publicKey = await keys.pemForToken(token);
const publicKey = await keys.pemForToken(token);
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
if (!publicKey) return { error: [503, 'identity.unavailable', 'the Network signing key is not loaded yet'] };
- const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience });
+ // Sandbox app tokens are accepted (ADR-014 opt-in): a sandbox app registers and writes sandbox titles only,
+ // whose players are guests and which are never listed or counted (play/store.js).
+ const r = serviceAuth.verifyServiceToken(token, { publicKey, issuer: config.networkIssuer, audience: config.audience, acceptSandbox: true });
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
if (!r.ok) return { error: [401, r.code || 'token.invalid', r.reason || 'the token does not verify'] };
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
const project = typeof r.claims.project_id === 'string' && PROJECT_RE.test(r.claims.project_id) ? r.claims.project_id : null;
- return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, claims: r.claims } };
+ return { principal: { kind: 'app', requester: requesterOfSub(r.claims.sub), project, sandbox: r.claims.env === 'sandbox', claims: r.claims } };
}
}
// A person's own Network token (the same kind this site's session holds).
// A person's own Network token (the same kind this site's session holds).
const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: config.oauth.sessionAudience });
@@ -18,6 +18,11 @@
@@ -18,6 +18,11 @@
*
*
* Every write names the title it is for, and only the principal that registered a title writes to it: another
* Every write names the title it is for, and only the principal that registered a title writes to it: another
* principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).
* principal is told the title does not exist. A player is a person (usr_…) or a guest the title names (guest:<opaque>).
+ *
+ * The sandbox (ADR-014 opt-in): any developer's sandbox app tries Play on titles of its own. Their ids start with
+ * `sandbox-` (a sandbox app registers nothing else and a production app never one), an app has at most
+ * MAX_SANDBOX_TITLES, their players are guests only (no person's record changes, nothing is adopted), and they are never
+ * listed or counted: readable by their id, marked `sandbox: true`. The real name comes with a production app.
*/
*/
const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;
const PLAYER_RE = /^(usr_[0-9A-HJKMNP-TV-Z]{26}|guest:[A-Za-z0-9_-]{8,128})$/;
const SLOT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
const SLOT_RE = /^[a-z0-9][a-z0-9_-]{0,31}$/;
@@ -29,6 +34,9 @@ const PER_PAGE = 25;
@@ -29,6 +34,9 @@ const PER_PAGE = 25;
const MAX_ENTRIES = 100;
const MAX_ENTRIES = 100;
const MAX_DEFS = 200;
const MAX_DEFS = 200;
const EARLIEST = Date.UTC(2020, 0, 1);
const EARLIEST = Date.UTC(2020, 0, 1);
+const SANDBOX_PREFIX = 'sandbox-';
+const MAX_SANDBOX_TITLES = 3;
+const isSandbox = (titleId) => String(titleId || '').startsWith(SANDBOX_PREFIX);
class PlayError extends Error {
class PlayError extends Error {
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
@@ -60,6 +68,12 @@ function createPlay({ s }) {
@@ -60,6 +68,12 @@ function createPlay({ s }) {
if (!PLAYER_RE.test(String(p || ''))) fail(400, 'play.bad_player', 'player is a person (usr_…) or guest:<opaque> (8 to 128 of A-Z a-z 0-9 _ -)');
if (!PLAYER_RE.test(String(p || ''))) fail(400, 'play.bad_player', 'player is a person (usr_…) or guest:<opaque> (8 to 128 of A-Z a-z 0-9 _ -)');
return p;
return p;
};
};
+ /** A player of this title: a sandbox title's are guests only. */
+ const playerIn = (titleId, p) => {
+ player(p);
+ if (isSandbox(titleId) && !String(p).startsWith('guest:')) fail(400, 'play.sandbox_guests_only', 'a sandbox title\'s players are guests (guest:<opaque>): no person\'s record changes until your game has a production app');
+ return p;
+ };
function defsOf(list, what, extra) {
function defsOf(list, what, extra) {
if (list === undefined) return null;
if (list === undefined) return null;
@@ -75,8 +89,10 @@ function createPlay({ s }) {
@@ -75,8 +89,10 @@ function createPlay({ s }) {
});
});
}
}
- async function define(owner, titleId, doc = {}) {
+ async function define(owner, titleId, doc = {}, { sandbox = false } = {}) {
if (!TITLE_RE.test(String(titleId || ''))) fail(400, 'play.bad_title', 'a title id is 2 to 40 of a-z, 0-9 and -');
if (!TITLE_RE.test(String(titleId || ''))) fail(400, 'play.bad_title', 'a title id is 2 to 40 of a-z, 0-9 and -');
+ if (sandbox && !isSandbox(titleId)) fail(400, 'play.sandbox_title', `a sandbox app's title id starts with ${SANDBOX_PREFIX} (the real name comes with a production app)`);
+ if (!sandbox && isSandbox(titleId)) fail(400, 'play.sandbox_title', `${SANDBOX_PREFIX} ids are for sandbox apps`);
const name = str(doc.name, 80);
const name = str(doc.name, 80);
if (!name) fail(400, 'play.bad_title', 'a title needs a name');
if (!name) fail(400, 'play.bad_title', 'a title needs a name');
const url = doc.url == null || doc.url === '' ? null : String(doc.url);
const url = doc.url == null || doc.url === '' ? null : String(doc.url);
@@ -90,6 +106,9 @@ function createPlay({ s }) {
@@ -90,6 +106,9 @@ function createPlay({ s }) {
return await s.tx(async () => {
return await s.tx(async () => {
const cur = await db.maybe('SELECT * FROM play_titles WHERE id = $1 FOR UPDATE', [titleId]);
const cur = await db.maybe('SELECT * FROM play_titles WHERE id = $1 FOR UPDATE', [titleId]);
if (cur && cur.owner !== owner) fail(409, 'play.title_taken', 'another principal manages this title');
if (cur && cur.owner !== owner) fail(409, 'play.title_taken', 'another principal manages this title');
+ if (!cur && sandbox && Number(await db.value('SELECT COUNT(*) FROM play_titles WHERE owner = $1', [owner])) >= MAX_SANDBOX_TITLES) {
+ fail(409, 'play.sandbox_limit', `a sandbox app has at most ${MAX_SANDBOX_TITLES} titles`);
+ }
if (cur) await db.exec('UPDATE play_titles SET name = $2, url = $3, summary = $4, updated_at = $5 WHERE id = $1', [titleId, name, url, str(doc.summary, 300), now]);
if (cur) await db.exec('UPDATE play_titles SET name = $2, url = $3, summary = $4, updated_at = $5 WHERE id = $1', [titleId, name, url, str(doc.summary, 300), now]);
else await db.exec('INSERT INTO play_titles (id, owner, name, url, summary, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)', [titleId, owner, name, url, str(doc.summary, 300), now]);
else await db.exec('INSERT INTO play_titles (id, owner, name, url, summary, created_at, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $6)', [titleId, owner, name, url, str(doc.summary, 300), now]);
if (achievements) {
if (achievements) {
@@ -128,7 +147,7 @@ function createPlay({ s }) {
@@ -128,7 +147,7 @@ function createPlay({ s }) {
if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);
if (!Number.isSafeInteger(score) || score < 0) fail(400, 'play.bad_scores', `entries[${i}].score is a whole number, 0 or more`);
const name = str(e.name, 40);
const name = str(e.name, 40);
if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);
if (!name) fail(400, 'play.bad_scores', `entries[${i}].name is the name to show`);
- return [player(e.player), name, score];
+ return [playerIn(titleId, e.player), name, score];
});
});
let recorded = 0;
let recorded = 0;
await s.tx(async () => {
await s.tx(async () => {
@@ -142,7 +161,7 @@ function createPlay({ s }) {
@@ -142,7 +161,7 @@ function createPlay({ s }) {
async function unlock(owner, titleId, post = {}) {
async function unlock(owner, titleId, post = {}) {
await ownTitle(owner, titleId);
await ownTitle(owner, titleId);
- const p = player(post.player);
+ const p = playerIn(titleId, post.player);
const ids = post.achievements;
const ids = post.achievements;
if (!Array.isArray(ids) || !ids.length || ids.length > MAX_DEFS || !ids.every((x) => DEF_RE.test(String(x)))) fail(400, 'play.bad_unlock', 'achievements is a list of achievement ids');
if (!Array.isArray(ids) || !ids.length || ids.length > MAX_DEFS || !ids.every((x) => DEF_RE.test(String(x)))) fail(400, 'play.bad_unlock', 'achievements is a list of achievement ids');
const known = new Set((await db.many('SELECT id FROM play_achievements WHERE title_id = $1 AND NOT retired', [titleId])).map((r) => r.id));
const known = new Set((await db.many('SELECT id FROM play_achievements WHERE title_id = $1 AND NOT retired', [titleId])).map((r) => r.id));
@@ -163,6 +182,7 @@ function createPlay({ s }) {
@@ -163,6 +182,7 @@ function createPlay({ s }) {
/** A guest signed in: their unlocks and scores in this title become the person's (the earliest unlock wins). */
/** A guest signed in: their unlocks and scores in this title become the person's (the earliest unlock wins). */
async function adopt(owner, titleId, guest, person) {
async function adopt(owner, titleId, guest, person) {
await ownTitle(owner, titleId);
await ownTitle(owner, titleId);
+ if (isSandbox(titleId)) fail(400, 'play.sandbox_guests_only', 'a sandbox title never adopts: its players stay guests');
if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
if (!/^guest:/.test(player(guest))) fail(400, 'play.bad_player', 'only a guest is adopted');
if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');
if (!/^usr_/.test(player(person))) fail(400, 'play.bad_player', 'a guest is adopted by a person (usr_…)');
return await s.tx(() => moveRows(titleId, guest, person));
return await s.tx(() => moveRows(titleId, guest, person));
@@ -240,7 +260,7 @@ function createPlay({ s }) {
@@ -240,7 +260,7 @@ function createPlay({ s }) {
WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);
WHERE a.title_id = $1 AND NOT a.retired GROUP BY a.title_id, a.id ORDER BY a.position, a.id`, [t.id]);
const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);
const boards = await db.many('SELECT id, name, kind FROM play_boards WHERE title_id = $1 AND NOT retired ORDER BY position, id', [t.id]);
return {
return {
- id: t.id, name: t.name, url: t.url, summary: t.summary, players,
+ id: t.id, name: t.name, url: t.url, summary: t.summary, players, ...(isSandbox(t.id) ? { sandbox: true } : {}),
achievements: achievements.map((a) => (a.hidden
achievements: achievements.map((a) => (a.hidden
? { id: a.id, hidden: true, unlocked_by: a.unlocked_by, name: 'A secret achievement', description: '' }
? { id: a.id, hidden: true, unlocked_by: a.unlocked_by, name: 'A secret achievement', description: '' }
: { id: a.id, name: a.name, description: a.description, unlocked_by: a.unlocked_by })),
: { id: a.id, name: a.name, description: a.description, unlocked_by: a.unlocked_by })),
@@ -250,7 +270,7 @@ function createPlay({ s }) {
@@ -250,7 +270,7 @@ function createPlay({ s }) {
}
}
async function titles() {
async function titles() {
- const ids = (await db.many('SELECT id FROM play_titles ORDER BY created_at, id')).map((r) => r.id);
+ const ids = (await db.many("SELECT id FROM play_titles WHERE id NOT LIKE 'sandbox-%' ORDER BY created_at, id")).map((r) => r.id);
const out = [];
const out = [];
for (const id of ids) out.push(await title(id));
for (const id of ids) out.push(await title(id));
return out;
return out;
@@ -276,7 +296,7 @@ function createPlay({ s }) {
@@ -276,7 +296,7 @@ function createPlay({ s }) {
/** Writes a slot. `version` (optional) is the version the writer read: a stale one is refused (409), a new slot takes 0. */
/** Writes a slot. `version` (optional) is the version the writer read: a stale one is refused (409), a new slot takes 0. */
async function putSave(owner, titleId, playerId, slot, { data, version } = {}) {
async function putSave(owner, titleId, playerId, slot, { data, version } = {}) {
await ownTitle(owner, titleId);
await ownTitle(owner, titleId);
- player(playerId); slotOf(slot);
+ playerIn(titleId, playerId); slotOf(slot);
if (data === undefined) fail(400, 'play.bad_save', 'data is the save, any JSON value');
if (data === undefined) fail(400, 'play.bad_save', 'data is the save, any JSON value');
const json = JSON.stringify(data);
const json = JSON.stringify(data);
const size = Buffer.byteLength(json, 'utf8');
const size = Buffer.byteLength(json, 'utf8');
@@ -326,4 +346,4 @@ function createPlay({ s }) {
@@ -326,4 +346,4 @@ function createPlay({ s }) {
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
}
-module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, weekStart, PER_PAGE };
+module.exports = { createPlay, PlayError, PLAYER_RE, TITLE_RE, SLOT_RE, MAX_SLOTS, MAX_SAVE_BYTES, MAX_SANDBOX_TITLES, SANDBOX_PREFIX, isSandbox, weekStart, PER_PAGE };
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
@@ -14,7 +14,7 @@ const { measure, check, format } = require('openvibe-shared/perf-budget');
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// Budgets are set from the numbers measured on 2026-10-08 (the real ones, fresh database), rounded up with about 10%
// headroom; file counts are the measured counts and external files stay at 0.
// headroom; file counts are the measured counts and external files stay at 0.
const BUDGETS = {
const BUDGETS = {
- htmlRawKB: 27.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD)
+ htmlRawKB: 28.5, // measured 24.9 (the home page: hero, features, steps, cta, JSON-LD); 27.8 with "Can I add my own game?" (10-25, also in the FAQPage JSON-LD)
htmlBrotliKB: 6.9, // 6.2
htmlBrotliKB: 6.9, // 6.2
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsFiles: 5, // 5 (theme-loader, web-runtime, navbar, footer, boost: openvibe-shared/shell)
jsRawKB: 264, // 239.8
jsRawKB: 264, // 239.8
new file mode 100644
new file mode 100644
@@ -0,0 +1,75 @@
@@ -0,0 +1,75 @@
+'use strict';
+// Any developer tries Play from a sandbox app (ADR-014 opt-in): its titles' ids start with sandbox-, it has at most
+// three, their players are guests only and never adopted, and they are never listed, counted or indexed (readable by
+// their id, marked sandbox). A production app never takes a sandbox- id.
+const assert = require('assert');
+const crypto = require('crypto');
+const { serviceAuth } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+const PROJECT = 'prj_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const P1 = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ0';
+const G1 = 'guest:a1b2c3d4e5f6';
+const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
+
+(async () => {
+ const t = await boot();
+ try {
+ const app = (id, env) => {
+ const now = Math.floor(Date.now() / 1000);
+ return { bearer: serviceAuth.signServiceToken({ iss: t.network.url, sub: `app:${id}`, actor_type: 'app', aud: ['openvibe.play'], cap: CAPS, ns: [PROJECT], project_id: PROJECT, env, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}` }, t.network.privatePem) };
+ };
+ const sandbox = app('app_01JABCDEFGHJKMNPQRSTVWXYZ0', 'sandbox');
+ const production = app('app_01JABCDEFGHJKMNPQRSTVWXYZ1', 'production');
+ const DOC = { name: 'Gem Run', achievements: [{ id: 'first-gem', name: 'First Gem' }], boards: [{ id: 'best', name: 'Best', kind: 'best' }] };
+ const put = (id, auth, body = DOC) => t.get(`/api/v1/titles/${id}`, { ...auth, method: 'PUT', json: body });
+ const code = async (p) => { const r = await p; return [r.status, r.json().code]; };
+
+ await check('a sandbox app registers sandbox- titles only, at most three; a production app never one', async () => {
+ assert.deepStrictEqual(await code(put('gem-run', sandbox)), [400, 'play.sandbox_title']);
+ const made = await put('sandbox-gem-run', sandbox);
+ assert.strictEqual(made.status, 201, made.text);
+ assert.strictEqual(made.json().title.sandbox, true);
+ assert.deepStrictEqual(await code(put('sandbox-other', production)), [400, 'play.sandbox_title']);
+ assert.strictEqual((await put('gem-run', production)).status, 201, 'a production app takes the real name');
+ assert.strictEqual((await put('sandbox-two', sandbox)).status, 201);
+ assert.strictEqual((await put('sandbox-three', sandbox)).status, 201);
+ assert.deepStrictEqual(await code(put('sandbox-four', sandbox)), [409, 'play.sandbox_limit']);
+ assert.strictEqual((await put('sandbox-gem-run', sandbox, { ...DOC, summary: 'Updated.' })).status, 200, 'updating one is not a new one');
+ });
+
+ await check('a sandbox title\'s players are guests only, and none is adopted', async () => {
+ const post = (path, json) => t.get(`/api/v1/titles/sandbox-gem-run/${path}`, { ...sandbox, json });
+ assert.deepStrictEqual(await code(post('scores', { board: 'best', ref: 'r1', entries: [{ player: P1, name: 'Ada', score: 5 }] })), [400, 'play.sandbox_guests_only']);
+ assert.deepStrictEqual((await post('scores', { board: 'best', ref: 'r1', entries: [{ player: G1, name: 'Tester', score: 5 }] })).json(), { recorded: 1, repeated: 0 });
+ assert.deepStrictEqual(await code(post('unlocks', { player: P1, achievements: ['first-gem'] })), [400, 'play.sandbox_guests_only']);
+ assert.deepStrictEqual((await post('unlocks', { player: G1, achievements: ['first-gem'] })).json(), { unlocked: ['first-gem'] });
+ assert.deepStrictEqual(await code(post(`players/${G1}/adopt`, { person: P1 })), [400, 'play.sandbox_guests_only']);
+ const save = (player) => t.get(`/api/v1/titles/sandbox-gem-run/players/${player}/saves/main`, { ...sandbox, method: 'PUT', json: { data: { level: 1 } } });
+ assert.deepStrictEqual(await code(save(P1)), [400, 'play.sandbox_guests_only']);
+ assert.strictEqual((await save(G1)).status, 200);
+ });
+
+ await check('a sandbox title is never listed or indexed, and answers at its id', async () => {
+ const listed = (await t.get('/api/v1/titles')).json().titles.map((x) => x.id);
+ assert.ok(listed.includes('gem-run') && !listed.some((id) => id.startsWith('sandbox-')), listed.join(','));
+ assert.strictEqual((await t.get('/api/v1/titles/sandbox-gem-run')).json().title.sandbox, true);
+ const pageOf = await t.get('/t/sandbox-gem-run');
+ assert.strictEqual(pageOf.status, 200);
+ assert.match(pageOf.text, /<meta name="robots" content="noindex/);
+ assert.match(pageOf.text, /Sandbox: a developer is trying Play/);
+ assert.match((await t.get('/t/sandbox-gem-run/b/best')).text, /<meta name="robots" content="noindex/);
+ assert.match((await t.get('/t/gem-run')).text, /<meta name="robots" content="index/);
+ assert.ok(!(await t.get('/')).text.includes('sandbox-gem-run'), 'not on the home');
+ assert.ok(!(await t.get('/sitemap.xml')).text.includes('sandbox-'), 'not in the sitemap');
+ });
+
+ await check('the docs and the home say how to start', async () => {
+ const docs = await t.get('/docs');
+ assert.match(docs.text, /<h2 id="sandbox">Your own game: start in the sandbox<\/h2>/);
+ assert.match(docs.text, /https:\/\/openvibe\.services\/projects/);
+ assert.match((await t.get('/')).text, /Can I add my own game\?/);
+ });
+ } finally { await t.close(); }
+ done();
+})();