openvibers/openvibe.play Commits

Remove account merge handling (#209)

OP OpenVibers committed 3 days ago · 9d8e5c4489 · 7 files, +8 −70

* Work in progress: remove account merge (unfinished)

Saved from a runner job that stopped before it finished (cx:account-merge-removal-cx2,
2026-11-10). The next job continues from this branch.

* Merge remote-tracking branch 'origin/main' into HEAD

* Remove account merge handling

Changes

ddiff --git a/README.md b/README.md
iindex f35339e..dd3b40b 100644
---- a/README.md
++++ b/README.md
@@@ -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
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
-- 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 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
@@@ -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 existing
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
@@@ -141,7 +141,6 @@ wildcard certificate, deployed with `sudo ovhost deploy play`.
```
server/play/store.js titles, achievements, boards, scores, unlocks, adoption
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/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)
ddiff --git a/STATUS.json b/STATUS.json
iindex b584857..55a01e2 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -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"
],
ddiff --git a/server/app.js b/server/app.js
iindex 0d2babc..ae6290e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ───────────────────────────────────
ddiff --git a/server/index.js b/server/index.js
iindex 5144fca..9a69bfd 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(); }] });
ddiff --git a/server/play/store.js b/server/play/store.js
iindex 75151aa..d3bd2e3 100644
---- a/server/play/store.js
++++ b/server/play/store.js
@@@ -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_id
WHERE 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 };
}
ddiff --git a/server/play/subject-merge.js b/server/play/subject-merge.js
ddeleted file mode 100644
iindex 7b822ae..0000000
---- a/server/play/subject-merge.js
++++ /dev/null
@@@ -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 };
ddiff --git a/test/account-data.test.js b/test/account-data.test.js
iindex 7d32b66..24f6751 100644
---- a/test/account-data.test.js
++++ b/test/account-data.test.js
@@@ -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);