openvibers/openvibe.play Commits

A sandbox title is deleted by its app with everything in it (DELETE /titles/:title); a production title never is (#42)

OP OpenVibers committed 19 days ago · f445996969 · 5 files, +28 −3

Changes

ddiff --git a/README.md b/README.md
iindex af4d370..c0de594 100644
---- a/README.md
++++ b/README.md
@@@ -11,7 +11,8 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
- **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
++ counted or indexed: readable at their id, marked `sandbox: true`, and deleted by their app (`DELETE /titles/:title`;
++ a production title is never deleted). 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
retired, never deleted: what players earned stays.
ddiff --git a/server/http/api.js b/server/http/api.js
iindex e4e2a10..2613988 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -11,6 +11,7 @@
* OpenVibe.Network's /@name and any site's showcase)
* GET /me/achievements a person their own unlocks across titles
* PUT /titles/:title play.title.manage register or update a title the caller manages
++ * DELETE /titles/:title play.title.manage delete a sandbox title the caller manages (204)
* POST /titles/:title/scores play.result.write post a round's scores to a board (idempotent by ref)
* POST /titles/:title/unlocks play.result.write unlock achievements for a player (idempotent)
* POST /titles/:title/players/:guest/adopt play.result.write a guest signed in: their rows become the person's
@@@ -101,6 +102,10 @@ function createApi(ctx) {
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.delete('/titles/:title', principal.requireCapability('play.title.manage'), titleOwner, limits.budget('play.define'), wrap(async (req, res) => {
++ await play.removeSandbox(req.principal.requester, req.params.title);
++ return res.status(204).end();
++ }));
r.post('/titles/:title/scores', principal.requireCapability('play.result.write'), titleOwner, limits.budget('play.write'), wrap(async (req, res) => {
res.json(await play.recordScores(req.principal.requester, req.params.title, req.body || {}));
}));
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex f2c3d3e..c7674f1 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -268,7 +268,7 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<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>`,
++<p>A sandbox app registers up to ${String(MAX_SANDBOX_TITLES)} titles, each id starting with <code>sandbox-</code>, and deletes one with everything in it by <code>DELETE /titles/:title</code> (<code>play.title.manage</code>; a production title is never deleted) (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, {
ddiff --git a/server/play/store.js b/server/play/store.js
iindex 5326a83..5a5cb1c 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -5,6 +5,7 @@
*
* define(owner, titleId, doc) register or update a title the caller manages; definitions missing from doc
* are retired, never deleted (the unlocks and scores stay)
++ * removeSandbox(owner, titleId) delete a sandbox title with everything in it (a production title stays)
* recordScores(owner, titleId, post) { board, ref, entries: [{ player, name, score }], at? } → { recorded }
* unlock(owner, titleId, post) { player, achievements: [id…], at? } → { unlocked: [newly unlocked ids] }
* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
@@@ -133,6 +134,14 @@ function createPlay({ s }) {
});
}
++ /** A sandbox title's app deletes it with everything in it: its players are guests, so no one's record changes. A
++ * production title is never deleted: what players earned stays on their profiles. */
++ async function removeSandbox(owner, titleId) {
++ const t = await ownTitle(owner, titleId);
++ if (!isSandbox(t.id)) fail(409, 'play.title_kept', 'a production title is never deleted: what players earned stays (leave achievements and boards out of an update to retire them)');
++ await db.exec('DELETE FROM play_titles WHERE id = $1 AND owner = $2', [t.id, owner]);
++ }
++
async function recordScores(owner, titleId, post = {}) {
await ownTitle(owner, titleId);
const board = await db.maybe('SELECT * FROM play_boards WHERE title_id = $1 AND id = $2', [titleId, String(post.board || '')]);
@@@ -342,7 +351,7 @@ function createPlay({ s }) {
return (await db.exec('DELETE FROM play_saves WHERE title_id = $1 AND player = $2 AND slot = $3', [titleId, player(person), slotOf(slot)])) > 0;
}
-- return { define, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle,
++ return { define, removeSandbox, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle,
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
ddiff --git a/test/sandbox.test.js b/test/sandbox.test.js
iindex 03218b6..d025e72 100644
---- a/test/sandbox.test.js
++++ b/test/sandbox.test.js
@@@ -64,6 +64,16 @@ const CAPS = ['play.title.manage', 'play.result.write', 'play.player.read'];
assert.ok(!(await t.get('/sitemap.xml')).text.includes('sandbox-'), 'not in the sitemap');
});
++ await check('a sandbox title is deleted by its app with everything in it; a production title never', async () => {
++ const del = (id, auth) => t.get(`/api/v1/titles/${id}`, { ...auth, method: 'DELETE' });
++ assert.deepStrictEqual(await code(del('gem-run', production)), [409, 'play.title_kept']);
++ assert.deepStrictEqual(await code(del('sandbox-gem-run', production)), [404, 'play.unknown_title'], 'another app is told it does not exist');
++ assert.strictEqual((await del('sandbox-gem-run', sandbox)).status, 204);
++ assert.strictEqual((await t.get('/api/v1/titles/sandbox-gem-run')).status, 404);
++ assert.strictEqual((await t.get('/t/sandbox-gem-run/b/best')).status, 404, 'its boards went with it');
++ assert.strictEqual((await put('sandbox-four', sandbox)).status, 201, 'the room it took is free again');
++ });
++
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>/);