openvibers/openvibe.play Commits
Remove account merge handling (#209)
Changes
@@@ -24,7 +24,7 @@ its boards. The rules of each game stay in the game; Play keeps the record and aMonday 00:00 UTC) or all time, 25 a page, and a tie goes to whoever got there first. A board's `unit` says what itsscores mean: `"ms"` for `time`, `"points"` for the others.- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts-- them and their unlocks and scores become the person's. When two OpenVibe accounts are merged (`network.subject.merged`),++ them and their unlocks and scores become the person's.the folded account's unlocks and scores become the survivor's in every title.- **Cloud saves** (`migrations/0004_saves.sql`): a player's progress follows them to any device. Up to 8 slots perplayer per title, each any JSON value of at most 64 KB, versioned: a write names the version it read and a stale one@@@ -68,7 +68,7 @@ game, to the player's account page and to any page that wants a board.- **OpenVibe.Network:** service and app tokens (audience `openvibe.play`), the JWKS that verifies them, grants for the`play.*` capabilities, and person sign-in for "your achievements".--- **OpenVibe.Events:** the account subscriptions (export, deletion, `network.subject.merged`), created at boot when++- **OpenVibe.Events:** the account subscriptions (export and deletion), created at boot when`EVENTS_URL`, `PLAY_EVENTS_SECRET` and the client secret are set.- **PostgreSQL** (`ov_play`; embedded PGlite in development and tests), Valkey for per-caller limits when configured.@@@ -91,7 +91,7 @@ adoption, secret achievements, retired definitions, a person's own achievements,(the page redirects and the crawl files), `time-boards.test.js`(time boards, their ties, validation and units), `time-migration.test.js` (the kind CHECK widening over existingboards), `account-data.test.js`--(export, erasure and account merges), the auth, limits, redirects, open-redirect, nginx and security tests.++(export and erasure), the auth, limits, redirects, open-redirect, nginx and security tests.## Security@@@ -141,7 +141,6 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.```server/play/store.js titles, achievements, boards, scores, unlocks, adoptionserver/play/account-data.js account export and deletion (ADR-033)--server/play/subject-merge.js account merges (ADR-029)server/http/api.js /api/v1server/http/moved.js the old page addresses, 301 to OpenVibe.Gamesserver/http/discovery.js robots.txt and llms.txt (sitemap.xml and llms-full.txt redirect to Games)@@@ -20,7 +20,7 @@"Titles registered and updated by their own principal (play.title.manage); a definition left out is retired, never deleted; a board never changes kind","Score posts to a board, idempotent by ref (up to 100 entries), and unlocks that answer the new ones (play.result.write)","Guest adoption: a guest's unlocks and scores become the person's when they sign in",-- "Account merges (network.subject.merged): the folded account's unlocks, scores and saves become the survivor's in every title",++ "","Cloud saves: up to 8 versioned slots per player per title (a stale write is refused), listed and deleted by the person at /me/saves","Boards: each player's best, total or fastest time (milliseconds, ascending), this week (from Monday UTC) or all time, 25 a page, names and scores only","Pages: home (every game, this week's leaders), a title, a board, your achievements, the API docs, /updates",@@@ -42,7 +42,7 @@"leaderboards (best, sum, time; week, all time)","idempotent results","guest adoption",-- "account merges",++"cloud saves","account export and deletion"],@@@ -33,7 +33,6 @@ const { createServiceReadiness } = require('./observability');const { createCallerLimits } = require('./http/caller-limits');const { createNetworkSender } = require('openvibe-sdk/account-data');const { createPlay } = require('./play/store');--const subjectMerge = require('./play/subject-merge');const accountDataLib = require('./play/account-data');const { assetVersion } = require('./render/layout');@@@ -129,10 +128,8 @@ async function createApp(opts = {}) {// ── OpenVibe.Events → Play (loopback only: nginx answers 404 for /internal/) ──// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,// which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy;-- // network.subject.merged (ADR-029) reaches it as another topic and folds the merged account's rows (subject-merge.js).app.post('/internal/events', accountData.consumer({secrets: config.events.secrets, send: accountSend, log,-- onEvent: (ev) => subjectMerge.apply(ev, { play, log }),}));// ── Machine endpoints ───────────────────────────────────@@@ -8,7 +8,6 @@ const { createApp } = require('./app');const { gracefulStop } = require('openvibe-sdk/service');const { startTelemetryRetention } = require('openvibe-sdk/telemetry');const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');--const subjectMerge = require('./play/subject-merge');/*** The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions stop,@@@ -40,11 +39,11 @@ async function start() {ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);ctx.telemetryTick.unref();ctx.keys.client.start();-- // The account subscriptions at OpenVibe.Events (ADR-033) and account merges (ADR-029), created when missing; off++ // The account subscriptions at OpenVibe.Events (ADR-033), created when missing; off// without EVENTS_URL, PLAY_EVENTS_SECRET or the client secret.const subscriptions = startSubscriptions({eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],-- topics: [...TOPICS, subjectMerge.TOPIC],++ topics: [...TOPICS],networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,});createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });@@@ -11,8 +11,6 @@* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in* putSave / getSave / listSaves / deleteSave (owner, titleId, player, slot…) a player's cloud save slots in a title* mySaves(person) / deleteMySave(person, titleId, slot) a person's own slots, every title-- * mergeSubject({ mergeId, from, into }) network.subject.merged: the folded account's rows, in every title, become the-- * survivor's (once per merge)* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id* title(id) / titles() the public definitions, with how many players unlocked each achievement* playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)@@@ -229,16 +227,6 @@ function createPlay({ s }) {return { unlocks, scores, saves };}-- async function mergeSubject({ mergeId, from, into }) {-- return await s.tx(async () => {-- if (await db.maybe('SELECT 1 FROM play_subject_merges WHERE merge_id = $1', [mergeId])) return { already: true, unlocks: 0, scores: 0 };-- const moved = await moveRows(null, from, into);-- await db.exec('INSERT INTO play_subject_merges (merge_id, from_subject, into_subject, unlocks, scores, merged_at) VALUES ($1, $2, $3, $4, $5, $6)',-- [mergeId, from, into, moved.unlocks, moved.scores, s.now()]);-- return { already: false, ...moved };-- });-- }--async function board(titleId, boardId, { period = 'all', page = 1 } = {}) {const b = await db.maybe(`SELECT b.*, t.name AS title_name FROM play_boards b JOIN play_titles t ON t.id = b.title_idWHERE b.title_id = $1 AND b.id = $2`, [String(titleId || ''), String(boardId || '')]);@@@ -365,7 +353,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, removeSandbox, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle,++ return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle,putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };}ddeleted file mode 100644@@@ -1,26 +0,0 @@--'use strict';----/**-- * network.subject.merged → Play (ADR-029). Two Network accounts became one: `from` is now an alias of `into`. Every-- * unlock and score of `from`, in every title, becomes `into`'s (store.mergeSubject: the earliest unlock time wins, and-- * a round both accounts posted keeps the survivor's score), once per merge_id. Delivered to POST /internal/events with-- * the account topics (openvibe-sdk/account-data's consumer hands it here as an event of another topic).-- */--const TOPIC = 'network.subject.merged';--const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;--const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;----/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */--async function apply(ev, { play, log = console }) {-- if (!ev || ev.event_type !== TOPIC) return 'ignored:type';-- if (ev.source !== 'network') return 'ignored:source';-- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};-- const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');-- if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';-- const r = await play.mergeSubject({ mergeId, from, into });-- if (r.already) return 'unchanged';-- log.log(`[Merge] ${mergeId}: ${r.unlocks} unlock(s) and ${r.scores} score(s) now the survivor's`);-- return 'merged';--}----module.exports = { apply, TOPIC };@@@ -91,25 +91,6 @@ async function startNetworkStub() {assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');});-- await check('a merge folds bob into carol in every title, once; a payload that is not one is ignored', async () => {-- const carol = 'usr_01JZ0000000000000000000CA1';-- await play.recordScores('service:games', 'salvage-rush', { board: 'best', ref: 'r2', entries: [-- { player: bob.subject, name: 'Bob', score: 350 }, { player: carol, name: 'Carol', score: 500 },-- ] });-- await play.unlock('service:games', 'salvage-rush', { player: carol, achievements: ['first-haul'] });-- const event = ev('evt_01JZ0000000000000000000M01', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG1', from: bob.subject, into: carol });-- const r = await deliver(event);-- assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'merged'], JSON.stringify(r.json));-- assert.strictEqual(await count('SELECT count(*) FROM play_scores WHERE player = $1', [bob.subject]), 0);-- assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [bob.subject]), 0);-- assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [carol]), 1, 'one unlock of first-haul: the two accounts\' are one');-- assert.deepStrictEqual((await t.ctx.s.db.many('SELECT ref, score FROM play_scores WHERE player = $1 ORDER BY ref', [carol])).map((x) => [x.ref, Number(x.score)]),-- [['r1', 300], ['r2', 500]], 'bob\'s round 1 is carol\'s now; in round 2 both played and carol\'s own score stays');-- assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');-- const bad = ev('evt_01JZ0000000000000000000M02', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG2', from: carol, into: carol });-- assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');-- });--await check('the route refuses a bad signature and a request that came through a proxy', async () => {const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
@@ -24,7 +24,7 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
@@ -24,7 +24,7 @@ its boards. The rules of each game stay in the game; Play keeps the record and a
Monday 00:00 UTC) or all time, 25 a page, and a tie goes to whoever got there first. A board's `unit` says what its
Monday 00:00 UTC) or all time, 25 a page, and a tie goes to whoever got there first. A board's `unit` says what its
scores mean: `"ms"` for `time`, `"points"` for the others.
scores mean: `"ms"` for `time`, `"points"` for the others.
- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
- **Players** are people (`usr_…`) or guests a game names (`guest:<opaque>`). When a guest signs in, the game adopts
- them and their unlocks and scores become the person's. When two OpenVibe accounts are merged (`network.subject.merged`),
+ them and their unlocks and scores become the person's.
the folded account's unlocks and scores become the survivor's in every title.
the folded account's unlocks and scores become the survivor's in every title.
- **Cloud saves** (`migrations/0004_saves.sql`): a player's progress follows them to any device. Up to 8 slots per
- **Cloud saves** (`migrations/0004_saves.sql`): a player's progress follows them to any device. Up to 8 slots per
player per title, each any JSON value of at most 64 KB, versioned: a write names the version it read and a stale one
player per title, each any JSON value of at most 64 KB, versioned: a write names the version it read and a stale one
@@ -68,7 +68,7 @@ game, to the player's account page and to any page that wants a board.
@@ -68,7 +68,7 @@ game, to the player's account page and to any page that wants a board.
- **OpenVibe.Network:** service and app tokens (audience `openvibe.play`), the JWKS that verifies them, grants for the
- **OpenVibe.Network:** service and app tokens (audience `openvibe.play`), the JWKS that verifies them, grants for the
`play.*` capabilities, and person sign-in for "your achievements".
`play.*` capabilities, and person sign-in for "your achievements".
-- **OpenVibe.Events:** the account subscriptions (export, deletion, `network.subject.merged`), created at boot when
+- **OpenVibe.Events:** the account subscriptions (export and deletion), created at boot when
`EVENTS_URL`, `PLAY_EVENTS_SECRET` and the client secret are set.
`EVENTS_URL`, `PLAY_EVENTS_SECRET` and the client secret are set.
- **PostgreSQL** (`ov_play`; embedded PGlite in development and tests), Valkey for per-caller limits when configured.
- **PostgreSQL** (`ov_play`; embedded PGlite in development and tests), Valkey for per-caller limits when configured.
@@ -91,7 +91,7 @@ adoption, secret achievements, retired definitions, a person's own achievements,
@@ -91,7 +91,7 @@ adoption, secret achievements, retired definitions, a person's own achievements,
(the page redirects and the crawl files), `time-boards.test.js`
(the page redirects and the crawl files), `time-boards.test.js`
(time boards, their ties, validation and units), `time-migration.test.js` (the kind CHECK widening over existing
(time boards, their ties, validation and units), `time-migration.test.js` (the kind CHECK widening over existing
boards), `account-data.test.js`
boards), `account-data.test.js`
-(export, erasure and account merges), the auth, limits, redirects, open-redirect, nginx and security tests.
+(export and erasure), the auth, limits, redirects, open-redirect, nginx and security tests.
## Security
## Security
@@ -141,7 +141,6 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.
@@ -141,7 +141,6 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.
```
```
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
server/play/account-data.js account export and deletion (ADR-033)
server/play/account-data.js account export and deletion (ADR-033)
-server/play/subject-merge.js account merges (ADR-029)
server/http/api.js /api/v1
server/http/api.js /api/v1
server/http/moved.js the old page addresses, 301 to OpenVibe.Games
server/http/moved.js the old page addresses, 301 to OpenVibe.Games
server/http/discovery.js robots.txt and llms.txt (sitemap.xml and llms-full.txt redirect to Games)
server/http/discovery.js robots.txt and llms.txt (sitemap.xml and llms-full.txt redirect to Games)
@@ -20,7 +20,7 @@
@@ -20,7 +20,7 @@
"Titles registered and updated by their own principal (play.title.manage); a definition left out is retired, never deleted; a board never changes kind",
"Titles registered and updated by their own principal (play.title.manage); a definition left out is retired, never deleted; a board never changes kind",
"Score posts to a board, idempotent by ref (up to 100 entries), and unlocks that answer the new ones (play.result.write)",
"Score posts to a board, idempotent by ref (up to 100 entries), and unlocks that answer the new ones (play.result.write)",
"Guest adoption: a guest's unlocks and scores become the person's when they sign in",
"Guest adoption: a guest's unlocks and scores become the person's when they sign in",
- "Account merges (network.subject.merged): the folded account's unlocks, scores and saves become the survivor's in every title",
+ "",
"Cloud saves: up to 8 versioned slots per player per title (a stale write is refused), listed and deleted by the person at /me/saves",
"Cloud saves: up to 8 versioned slots per player per title (a stale write is refused), listed and deleted by the person at /me/saves",
"Boards: each player's best, total or fastest time (milliseconds, ascending), this week (from Monday UTC) or all time, 25 a page, names and scores only",
"Boards: each player's best, total or fastest time (milliseconds, ascending), this week (from Monday UTC) or all time, 25 a page, names and scores only",
"Pages: home (every game, this week's leaders), a title, a board, your achievements, the API docs, /updates",
"Pages: home (every game, this week's leaders), a title, a board, your achievements, the API docs, /updates",
@@ -42,7 +42,7 @@
@@ -42,7 +42,7 @@
"leaderboards (best, sum, time; week, all time)",
"leaderboards (best, sum, time; week, all time)",
"idempotent results",
"idempotent results",
"guest adoption",
"guest adoption",
- "account merges",
+
"cloud saves",
"cloud saves",
"account export and deletion"
"account export and deletion"
],
],
@@ -33,7 +33,6 @@ const { createServiceReadiness } = require('./observability');
@@ -33,7 +33,6 @@ const { createServiceReadiness } = require('./observability');
const { createCallerLimits } = require('./http/caller-limits');
const { createCallerLimits } = require('./http/caller-limits');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createNetworkSender } = require('openvibe-sdk/account-data');
const { createPlay } = require('./play/store');
const { createPlay } = require('./play/store');
-const subjectMerge = require('./play/subject-merge');
const accountDataLib = require('./play/account-data');
const accountDataLib = require('./play/account-data');
const { assetVersion } = require('./render/layout');
const { assetVersion } = require('./render/layout');
@@ -129,10 +128,8 @@ async function createApp(opts = {}) {
@@ -129,10 +128,8 @@ async function createApp(opts = {}) {
// ── OpenVibe.Events → Play (loopback only: nginx answers 404 for /internal/) ──
// ── OpenVibe.Events → Play (loopback only: nginx answers 404 for /internal/) ──
// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
// network.account.export_requested and network.account.deleted, answered by openvibe-sdk/account-data's consumer,
// which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy;
// which reads the raw body itself (the v2 signature covers it) and refuses a request that came through a proxy;
- // network.subject.merged (ADR-029) reaches it as another topic and folds the merged account's rows (subject-merge.js).
app.post('/internal/events', accountData.consumer({
app.post('/internal/events', accountData.consumer({
secrets: config.events.secrets, send: accountSend, log,
secrets: config.events.secrets, send: accountSend, log,
- onEvent: (ev) => subjectMerge.apply(ev, { play, log }),
}));
}));
// ── Machine endpoints ───────────────────────────────────
// ── Machine endpoints ───────────────────────────────────
@@ -8,7 +8,6 @@ const { createApp } = require('./app');
@@ -8,7 +8,6 @@ const { createApp } = require('./app');
const { gracefulStop } = require('openvibe-sdk/service');
const { gracefulStop } = require('openvibe-sdk/service');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { startTelemetryRetention } = require('openvibe-sdk/telemetry');
const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
const { startSubscriptions, TOPICS } = require('openvibe-sdk/account-data');
-const subjectMerge = require('./play/subject-merge');
/**
/**
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions stop,
* The process stop (openvibe-sdk/service): the HTTP drain runs, then the timers clear, the Events subscriptions stop,
@@ -40,11 +39,11 @@ async function start() {
@@ -40,11 +39,11 @@ async function start() {
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick = setInterval(() => { ctx.telemetry.flush().catch(() => {}); }, 60_000);
ctx.telemetryTick.unref();
ctx.telemetryTick.unref();
ctx.keys.client.start();
ctx.keys.client.start();
- // The account subscriptions at OpenVibe.Events (ADR-033) and account merges (ADR-029), created when missing; off
+ // The account subscriptions at OpenVibe.Events (ADR-033), created when missing; off
// without EVENTS_URL, PLAY_EVENTS_SECRET or the client secret.
// without EVENTS_URL, PLAY_EVENTS_SECRET or the client secret.
const subscriptions = startSubscriptions({
const subscriptions = startSubscriptions({
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
eventsUrl: config.events.url, endpoint: `http://127.0.0.1:${config.port}/internal/events`, secret: config.events.secrets[0],
- topics: [...TOPICS, subjectMerge.TOPIC],
+ topics: [...TOPICS],
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret,
});
});
createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
createLifecycle({ server, ctx, extra: [() => { if (subscriptions) subscriptions.stop(); }] });
@@ -11,8 +11,6 @@
@@ -11,8 +11,6 @@
* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
* adopt(owner, titleId, guest, person) move a guest's unlocks and scores to the person who signed in
* putSave / getSave / listSaves / deleteSave (owner, titleId, player, slot…) a player's cloud save slots in a title
* putSave / getSave / listSaves / deleteSave (owner, titleId, player, slot…) a player's cloud save slots in a title
* mySaves(person) / deleteMySave(person, titleId, slot) a person's own slots, every title
* mySaves(person) / deleteMySave(person, titleId, slot) a person's own slots, every title
- * mergeSubject({ mergeId, from, into }) network.subject.merged: the folded account's rows, in every title, become the
- * survivor's (once per merge)
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
* board(titleId, boardId, { period, page }) ranked entries: name and score, never a player id
* title(id) / titles() the public definitions, with how many players unlocked each achievement
* title(id) / titles() the public definitions, with how many players unlocked each achievement
* playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)
* playerAchievements(player, { titleId }) a player's unlocks across titles (or in one)
@@ -229,16 +227,6 @@ function createPlay({ s }) {
@@ -229,16 +227,6 @@ function createPlay({ s }) {
return { unlocks, scores, saves };
return { unlocks, scores, saves };
}
}
- async function mergeSubject({ mergeId, from, into }) {
- return await s.tx(async () => {
- if (await db.maybe('SELECT 1 FROM play_subject_merges WHERE merge_id = $1', [mergeId])) return { already: true, unlocks: 0, scores: 0 };
- const moved = await moveRows(null, from, into);
- await db.exec('INSERT INTO play_subject_merges (merge_id, from_subject, into_subject, unlocks, scores, merged_at) VALUES ($1, $2, $3, $4, $5, $6)',
- [mergeId, from, into, moved.unlocks, moved.scores, s.now()]);
- return { already: false, ...moved };
- });
- }
-
async function board(titleId, boardId, { period = 'all', page = 1 } = {}) {
async function board(titleId, boardId, { period = 'all', page = 1 } = {}) {
const b = await db.maybe(`SELECT b.*, t.name AS title_name FROM play_boards b JOIN play_titles t ON t.id = b.title_id
const b = await db.maybe(`SELECT b.*, t.name AS title_name FROM play_boards b JOIN play_titles t ON t.id = b.title_id
WHERE b.title_id = $1 AND b.id = $2`, [String(titleId || ''), String(boardId || '')]);
WHERE b.title_id = $1 AND b.id = $2`, [String(titleId || ''), String(boardId || '')]);
@@ -365,7 +353,7 @@ function createPlay({ s }) {
@@ -365,7 +353,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 (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, removeSandbox, recordScores, unlock, adopt, mergeSubject, board, title, titles, playerAchievements, ownTitle,
+ return { define, removeSandbox, recordScores, unlock, adopt, board, title, titles, playerAchievements, ownTitle,
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
putSave, getSave, listSaves, deleteSave, mySaves, deleteMySave };
}
}
deleted file mode 100644
deleted file mode 100644
@@ -1,26 +0,0 @@
@@ -1,26 +0,0 @@
-'use strict';
-
-/**
- * network.subject.merged → Play (ADR-029). Two Network accounts became one: `from` is now an alias of `into`. Every
- * unlock and score of `from`, in every title, becomes `into`'s (store.mergeSubject: the earliest unlock time wins, and
- * a round both accounts posted keeps the survivor's score), once per merge_id. Delivered to POST /internal/events with
- * the account topics (openvibe-sdk/account-data's consumer hands it here as an event of another topic).
- */
-const TOPIC = 'network.subject.merged';
-const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
-const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/;
-
-/** One envelope → 'merged' | 'unchanged' | 'ignored:<why>'. */
-async function apply(ev, { play, log = console }) {
- if (!ev || ev.event_type !== TOPIC) return 'ignored:type';
- if (ev.source !== 'network') return 'ignored:source';
- const p = ev.payload && typeof ev.payload === 'object' ? ev.payload : {};
- const mergeId = String(p.merge_id || ''), from = String(p.from || ''), into = String(p.into || '');
- if (!MERGE_RE.test(mergeId) || !SUBJECT_RE.test(from) || !SUBJECT_RE.test(into) || from === into) return 'ignored:payload';
- const r = await play.mergeSubject({ mergeId, from, into });
- if (r.already) return 'unchanged';
- log.log(`[Merge] ${mergeId}: ${r.unlocks} unlock(s) and ${r.scores} score(s) now the survivor's`);
- return 'merged';
-}
-
-module.exports = { apply, TOPIC };
@@ -91,25 +91,6 @@ async function startNetworkStub() {
@@ -91,25 +91,6 @@ async function startNetworkStub() {
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
});
});
- await check('a merge folds bob into carol in every title, once; a payload that is not one is ignored', async () => {
- const carol = 'usr_01JZ0000000000000000000CA1';
- await play.recordScores('service:games', 'salvage-rush', { board: 'best', ref: 'r2', entries: [
- { player: bob.subject, name: 'Bob', score: 350 }, { player: carol, name: 'Carol', score: 500 },
- ] });
- await play.unlock('service:games', 'salvage-rush', { player: carol, achievements: ['first-haul'] });
- const event = ev('evt_01JZ0000000000000000000M01', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG1', from: bob.subject, into: carol });
- const r = await deliver(event);
- assert.deepStrictEqual([r.status, r.json && r.json.outcome], [200, 'merged'], JSON.stringify(r.json));
- assert.strictEqual(await count('SELECT count(*) FROM play_scores WHERE player = $1', [bob.subject]), 0);
- assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [bob.subject]), 0);
- assert.strictEqual(await count('SELECT count(*) FROM play_unlocks WHERE player = $1', [carol]), 1, 'one unlock of first-haul: the two accounts\' are one');
- assert.deepStrictEqual((await t.ctx.s.db.many('SELECT ref, score FROM play_scores WHERE player = $1 ORDER BY ref', [carol])).map((x) => [x.ref, Number(x.score)]),
- [['r1', 300], ['r2', 500]], 'bob\'s round 1 is carol\'s now; in round 2 both played and carol\'s own score stays');
- assert.strictEqual((await deliver(event)).json.outcome, 'unchanged');
- const bad = ev('evt_01JZ0000000000000000000M02', 'network.subject.merged', { merge_id: 'mrg_01JZ0000000000000000000MG2', from: carol, into: carol });
- assert.strictEqual((await deliver(bad)).json.outcome, 'ignored:payload');
- });
-
await check('the route refuses a bad signature and a request that came through a proxy', async () => {
await check('the route refuses a bad signature and a request that came through a proxy', async () => {
const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
const event = ev('evt_01JZ0000000000000000000E02', 'network.account.export_requested', { export_id: 'exp_01JZ0000000000000000000EX2', subject: alice.subject });
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);
assert.strictEqual((await deliver(event, { secret: `whsec_${'mismatch'.repeat(5)}` })).status, 401);