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)
Changes
@@@ -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 |@@@ -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', '*');@@@ -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 });@@@ -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&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>@@@ -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);
@@ -100,6 +100,7 @@ Migrations run at boot.
@@ -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`, `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 /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 |
| `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 |
| `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 |
| `POST /titles/:title/scores` | `play.result.write` | `{ board, ref, entries: [{ player, name, score }] }`, up to 100 |
@@ -140,10 +140,10 @@ async function createApp(opts = {}) {
@@ -140,10 +140,10 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── 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.
// is answered here.
- const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?)$/;
+ const PUBLIC_READ = /^\/(?:ping|titles(?:\/[^/]+(?:\/boards\/[^/]+)?)?|people\/[^/]+\/achievements)$/;
app.use('/api/v1', (req, res, next) => {
app.use('/api/v1', (req, res, next) => {
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
if (!PUBLIC_READ.test(req.path) || !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Origin', '*');
@@ -7,6 +7,8 @@
@@ -7,6 +7,8 @@
* GET /titles public every title, with its achievements and boards
* GET /titles public every title, with its achievements and boards
* GET /titles/:title public one title
* GET /titles/:title public one title
* GET /titles/:title/boards/:board?period=&page= public a leaderboard: rank, name, score (never a player id)
* 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
* GET /me/achievements a person their own unlocks across titles
* PUT /titles/:title play.title.manage register or update a title the caller manages
* 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)
* 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) {
@@ -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 }));
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 ────────────────────────────────────
// ── A person's own ────────────────────────────────────
r.get('/me/achievements', reads, wrap(async (req, res) => {
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 });
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 });
@@ -240,6 +240,7 @@ ${saves.length ? html`<section><h2>Your saves</h2><p class="pl-muted">What games
@@ -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</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</code>: one game.</li>
<li><code>GET /titles/:title/boards/:board?period=week|all&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 /titles/:title/boards/:board?period=week|all&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>
<li><code>GET /me/achievements</code>: with a person's Network token, their own unlocks in every game.</li>
</ul>
</ul>
<h2>Writing (the game's own token)</h2>
<h2>Writing (the game's own token)</h2>
@@ -129,6 +129,19 @@ const DAY = 86_400_000;
@@ -129,6 +129,19 @@ const DAY = 86_400_000;
assert.strictEqual((await t.get('/api/v1/me/achievements')).status, 401);
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 () => {
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('/');
const home = await t.get('/');
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);