openvibers/openvibe.play Commits

A person's achievements across every game, public and from any site (GET /api/v1/people/:subject/achievements): what their profile shows, Steam-style (#38)

OP OpenVibers committed 19 days ago · e5efbec005 · 5 files, +30 −3

Changes

ddiff --git a/README.md b/README.md
iindex 122f1a3..6043b60 100644
---- a/README.md
++++ b/README.md
@@@ -100,6 +100,7 @@ Migrations run at boot.
|---|---|---|
| `GET /titles`, `GET /titles/:title` | anyone (CORS) | the games, their achievements (with how many players have each) and boards |
| `GET /titles/:title/boards/:board?period=week\|all&page=` | anyone (CORS) | a board: `rank`, `name`, `score`, `signed_in` |
++| `GET /people/:subject/achievements` | anyone (CORS) | a person's unlocks in every game, `{ subject, total, titles }`: their profile on OpenVibe.Network and any site's showcase (only people, never a guest) |
| `GET /me/achievements` | a person | their unlocks in every game |
| `PUT /titles/:title` | `play.title.manage` | register or update a title: name, url, summary, achievements, boards |
| `POST /titles/:title/scores` | `play.result.write` | `{ board, ref, entries: [{ player, name, score }] }`, up to 100 |
ddiff --git a/server/app.js b/server/app.js
iindex ef8ee2f..aa1d091 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -140,10 +140,10 @@ async function createApp(opts = {}) {
}));
// ── API ─────────────────────────────────────────────────
-- // Public reads from any site: the titles and their leaderboards answer every origin, without credentials (a game's
-- // own page draws its board with them). Everything else keeps the same-site resource policy and no CORS. A preflight
++ // Public reads from any site: the titles, their leaderboards and a person's achievements answer every origin,
++ // without credentials (a game's own page draws its board with them; any site shows a profile's showcase). Everything else keeps the same-site resource policy and no CORS. A preflight
// is answered here.
-- const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?)$/;
++ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements)$/;
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 492caab..c5177b3 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -7,6 +7,8 @@
* GET /titles public every title, with its achievements and boards
* GET /titles/:title public one title
* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
++ * GET /people/:subject/achievements public a person's unlocks across every title (their profile:
++ * 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
* POST /titles/:title/scores play.result.write post a round's scores to a board (idempotent by ref)
@@@ -61,6 +63,16 @@ function createApi(ctx) {
return res.json(await play.board(req.params.title, req.params.board, { period: String(req.query.period || 'all'), page }));
}));
++ // A person's unlocks, for their public profile (Steam-style: what you earned shows wherever you are). Only people
++ // (usr_…): a guest's rows are a game's own until it adopts them. 404 for no unlocks, the same as for no such person.
++ r.get('/people/:subject/achievements', reads, wrap(async (req, res) => {
++ const subject = String(req.params.subject || '');
++ if (!/^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject)) return http.sendProblem(res, 404, 'play.unknown_person', { detail: 'no such person', ctx: req.ov });
++ const titles = await play.playerAchievements(subject);
++ res.set('Cache-Control', 'public, max-age=60');
++ return res.json({ subject, total: titles.reduce((n, t) => n + t.achievements.length, 0), titles });
++ }));
++
// ── A person's own ────────────────────────────────────
r.get('/me/achievements', reads, wrap(async (req, res) => {
if (req.principal.kind !== 'user') return http.sendProblem(res, 401, 'token.required', { detail: 'Sign in at play.openvibe.network, or send your Network token.', ctx: req.ov });
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 2e4843f..c82ec9e 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -240,6 +240,7 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
<li><code>GET /titles</code>: every game, with its achievements (how many players have each) and its boards.</li>
<li><code>GET /titles/:title</code>: one game.</li>
<li><code>GET /titles/:title/boards/:board?period=week|all&amp;page=1</code>: 25 entries a page: <code>rank</code>, <code>name</code>, <code>score</code>, and whether the player was signed in. Never a player id.</li>
++<li><code>GET /people/:subject/achievements</code>: anyone, from any site: a person's unlocks in every game (what their profile shows).</li>
<li><code>GET /me/achievements</code>: with a person's Network token, their own unlocks in every game.</li>
</ul>
<h2>Writing (the game's own token)</h2>
ddiff --git a/test/play-api.test.js b/test/play-api.test.js
iindex 94f15d6..ffffc63 100644
---- a/test/play-api.test.js
++++ b/test/play-api.test.js
@@@ -129,6 +129,19 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/me/achievements')).status, 401);
});
++ await check('anyone reads a person\'s achievements across games (their profile), from any site; never a guest\'s', async () => {
++ const r = await t.get(`/api/v1/people/${P1}/achievements`, { headers: { Origin: 'https://openvibe.games' } });
++ assert.strictEqual(r.status, 200);
++ assert.strictEqual(r.headers.get('access-control-allow-origin'), '*');
++ const body = r.json();
++ assert.strictEqual(body.subject, P1);
++ assert.deepStrictEqual(body.titles.map((x) => x.name), ['Salvage Rush']);
++ assert.strictEqual(body.total, body.titles[0].achievements.length);
++ assert.ok(body.total >= 2);
++ assert.strictEqual((await t.get('/api/v1/people/guest:abc/achievements')).status, 404, 'a guest\'s rows are the game\'s own');
++ assert.strictEqual((await t.get('/api/v1/people/not-a-subject/achievements')).status, 404);
++ });
++
await check('the pages: home, a title, a board with its tabs, the docs, and llms.txt name the game', async () => {
const home = await t.get('/');
assert.strictEqual(home.status, 200);