openvibers/openvibe.games Commits

Games follows account merges (roadmap WS-B task 5, ADR-029). The platform events subscription now also asks for network.subject.merged; a delivery from Network with a valid payload calls store.identity.mergeSubject(from, into, merge_id), once per merge_id (subject_map row merge:<id>). The folded-in account's characters move to the survivor in one transaction: first each one whose own slot is free there, then the rest into the slots left (0-2); what does not fit stays with the folded-in account and is counted as kept. test: platformRepositories (own slot kept, clash placed, overflow kept, redelivery a no-op) and revocationEvents (the merge dispatch, other sources and bad payloads ignored, both topics subscribed).

OP OpenVibers committed 27 Sept 2026 · 9058cddeb6 · 6 files, +130 −15


Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex c3a231d..6ff427a 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -179,6 +179,8 @@ async function main(): Promise<void> {
db: store.db,
secrets: config.platform.eventsSecrets,
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
++ // Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
++ onMerged: (from, into, mergeId) => store.identity.mergeSubject(from, into, mergeId, Date.now()),
log: platformLog,
})
if (
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
iindex d9f9551..737fb64 100644
---- a/apps/server/src/platform/revocationEvents.test.ts
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net'
import { createRequire } from 'node:module'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
--import { createRevocationEvents, ensureRevocationSubscription, TOPIC } from './revocationEvents.js'
++import { createRevocationEvents, ensureRevocationSubscription, MERGE_TOPIC, TOPIC } from './revocationEvents.js'
/**
* POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the
@@@ -19,6 +19,7 @@ const SECRET = 'x'.repeat(40)
const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
const log = createConsoleLogger({ app: 'test' }, 'error')
const closedFor: [string, number][] = []
++const mergedCalls: [string, string, string][] = []
const events = createRevocationEvents({
db: new Database(':memory:'),
secrets: [SECRET],
@@@ -26,6 +27,10 @@ const events = createRevocationEvents({
closedFor.push([s, ms])
return 2
},
++ onMerged: (from, into, id) => {
++ mergedCalls.push([from, into, id])
++ return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
++ },
log,
})
let server: Server
@@@ -68,6 +73,18 @@ describe('revocation events', () => {
expect(closedFor.length).toBe(1)
})
++ it('an account merge (ADR-029) moves characters through onMerged; foreign or malformed ones are ignored', async () => {
++ const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4'
++ const merged = (over: Record<string, unknown> = {}) =>
++ envelope({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5', event_type: MERGE_TOPIC, payload: { merge_id: 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', from: FROM, into: SUBJECT, merged_at: new Date(at).toISOString(), initiated_by: 'person' }, ...over })
++ expect((await post(merged({ source: 'live' }))).body.outcome).toBe('ignored:source')
++ expect((await post(merged({ payload: { merge_id: 'nope', from: FROM, into: SUBJECT } }))).body.outcome).toBe('ignored:payload')
++ expect(mergedCalls).toEqual([])
++ expect((await post(merged())).body.outcome).toBe('merged')
++ expect(mergedCalls).toEqual([[FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6']])
++ expect((await post(merged())).body.outcome).toBe('unchanged')
++ })
++
it('refuses forged, proxied and non-POST requests', async () => {
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
@@@ -86,6 +103,9 @@ describe('revocation events', () => {
const opts = { eventsUrl: 'http://events.test', endpoint: 'http://127.0.0.1:8000/internal/events', secret: SECRET, tokens: { getToken: async () => 'tok' }, fetchImpl: fake, log }
expect(await ensureRevocationSubscription(opts)).toBe('created')
expect(await ensureRevocationSubscription(opts)).toBe('exists')
-- expect(subs).toEqual([{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET }])
++ expect(subs).toEqual([
++ { topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
++ { topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },
++ ])
})
})
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
iindex 20a36df..155fa4e 100644
---- a/apps/server/src/platform/revocationEvents.ts
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -8,6 +8,10 @@
* store (only ever forward; anything not from Network is ignored), then `onRevoked` closes the game
* sessions they opened with an older sign-in. New sign-ins already fail: Games checks every token with
* the Network's /api/auth/me, which applies the same cutoff.
++ *
++ * The same endpoint takes network.subject.merged (roadmap WS-B task 5, ADR-029): two accounts became one, and
++ * `onMerged` moves the folded-in account's characters to the survivor (store.identity.mergeSubject), once per
++ * merge. One subscription per topic (TOPICS).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
@@@ -25,7 +29,14 @@ type ParseDelivery = (
headers: IncomingMessage['headers'],
secret: string,
opts: { requireV2: boolean },
--) => { event?: { event_id?: string; event_type?: string; payload?: { subject?: { id?: string }; valid_after?: string } } } | null
++) => {
++ event?: {
++ event_id?: string
++ event_type?: string
++ source?: string
++ payload?: { subject?: { id?: string }; valid_after?: string; merge_id?: string; from?: string; into?: string }
++ }
++} | null
const { createRevocationStore } = req('openvibe-sdk/auth') as {
createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
@@@ -33,6 +44,10 @@ const { createRevocationStore } = req('openvibe-sdk/auth') as {
const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
export const TOPIC = 'network.user.token_valid_after'
++export const MERGE_TOPIC = 'network.subject.merged'
++export const TOPICS = [TOPIC, MERGE_TOPIC] as const
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
++const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const PATH = '/internal/events'
const MAX_BODY = 256 * 1024
@@@ -47,6 +62,8 @@ export function createRevocationEvents(opts: {
db: unknown
secrets: string[]
onRevoked: (subjectId: string, validAfterMs: number) => number
++ /** An account merge: move `from`'s characters to `into` (returns what moved; `already` when applied before). */
++ onMerged?: (from: string, into: string, mergeId: string) => { moved: number; kept: number; already: boolean }
log: Logger
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@@ -91,6 +108,21 @@ export function createRevocationEvents(opts: {
}
stats.received++
const event = delivery.event
++ if (event.event_type === MERGE_TOPIC) {
++ const p = event.payload ?? {}
++ let outcome = 'merged'
++ if (event.source !== 'network') outcome = 'ignored:source'
++ else if (!opts.onMerged) outcome = 'ignored:no_handler'
++ else if (!MERGE_RE.test(String(p.merge_id ?? '')) || !SUBJECT_RE.test(String(p.from ?? '')) || !SUBJECT_RE.test(String(p.into ?? '')) || p.from === p.into) outcome = 'ignored:payload'
++ else {
++ const r = opts.onMerged(String(p.from), String(p.into), String(p.merge_id))
++ outcome = r.already ? 'unchanged' : 'merged'
++ if (!r.already) opts.log.info('account merged: characters moved', { moved: r.moved, kept: r.kept })
++ }
++ if (outcome.startsWith('ignored')) stats.ignored++
++ send(res, 200, { event_id: event.event_id ?? null, outcome })
++ return
++ }
const outcome = store.apply(event)
let closed = 0
if (outcome === 'revoked') {
@@@ -109,7 +141,7 @@ export function createRevocationEvents(opts: {
}
/**
-- * Create Games' subscription to TOPIC at Events if it is missing (idempotent: an existing one, even
++ * Create Games' subscriptions to TOPICS at Events where missing (idempotent: an existing one, even
* disabled by an operator, is left as it is). Needs the grant games events.subscription.manage.
*/
export async function ensureRevocationSubscription(opts: {
@@@ -126,15 +158,18 @@ export async function ensureRevocationSubscription(opts: {
const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)
const body = (await list.json()) as { subscriptions?: { id: string; topic_pattern: string; endpoint: string }[] }
-- const found = (body.subscriptions ?? []).find((s) => s.topic_pattern === TOPIC && s.endpoint === opts.endpoint)
-- if (found) return 'exists'
-- const r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {
-- method: 'POST',
-- headers: { ...headers, 'content-type': 'application/json' },
-- body: JSON.stringify({ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: opts.secret }),
-- })
-- if (r.status === 409) return 'exists'
-- if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${TOPIC} subscription`)
-- opts.log.info('events subscription created', { topic: TOPIC, endpoint: opts.endpoint })
-- return 'created'
++ let created = 0
++ for (const topic of TOPICS) {
++ if ((body.subscriptions ?? []).some((s) => s.topic_pattern === topic && s.endpoint === opts.endpoint)) continue
++ const r = await f(`${opts.eventsUrl}/api/v1/subscriptions`, {
++ method: 'POST',
++ headers: { ...headers, 'content-type': 'application/json' },
++ body: JSON.stringify({ topic_pattern: topic, endpoint: opts.endpoint, secret: opts.secret }),
++ })
++ if (r.status === 409) continue
++ if (!r.ok) throw new Error(`Events answered ${r.status} creating the ${topic} subscription`)
++ opts.log.info('events subscription created', { topic, endpoint: opts.endpoint })
++ created++
++ }
++ return created ? 'created' : 'exists'
}
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex f55f51e..a099dda 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -81,6 +81,18 @@ export interface IdentityRepository {
subjectId: string,
now: number,
): { moved: number; slot: number | null; full: boolean }
++ /**
++ * Account merge (roadmap WS-B task 5, ADR-029; network.subject.merged): the folded-in subject's characters
++ * move to the survivor, each into its own slot when free there, else the first free one (0..2). A character
++ * with no free slot stays under the folded-in subject (kept, never deleted). Once per merge id; `already`
++ * when that merge was applied before.
++ */
++ mergeSubject(
++ fromSubject: string,
++ intoSubject: string,
++ mergeId: string,
++ now: number,
++ ): { moved: number; kept: number; already: boolean }
/** Distinct account keys under a legacy prefix that still own characters. */
legacyAccountKeys(prefix: string): string[]
}
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.test.ts b/packages/persistence/src/sqlite/platformRepositories.test.ts
iindex a2b3c06..3d6efc4 100644
---- a/packages/persistence/src/sqlite/platformRepositories.test.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.test.ts
@@@ -59,6 +59,21 @@ describe('guest conversion (WS-B task 8)', () => {
})
})
++describe('account merge (ADR-029)', () => {
++ const FROM = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
++ it('moves the folded-in characters into free slots, keeps what does not fit, once per merge', () => {
++ const store = openSqliteStore(':memory:')
++ // The survivor has slot 0; the folded-in account has slots 0, 1 and 2.
++ store.players.upsertMany([player('s0', SUBJECT, 0), player('f0', FROM, 0), player('f1', FROM, 1), player('f2', FROM, 2)])
++ expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5)).toEqual({ moved: 2, kept: 1, already: false })
++ expect(store.players.listByToken(SUBJECT).map((p) => [p.id, p.charSlot]).sort()).toEqual([['f1', 1], ['f2', 2], ['s0', 0]])
++ expect(store.players.listByToken(FROM).map((p) => p.id)).toEqual(['f0'])
++ expect(store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6)).toEqual({ moved: 0, kept: 0, already: true })
++ expect(store.players.listByToken(FROM).length).toBe(1)
++ store.close()
++ })
++})
++
describe('identity repository (legacy -> canonical subject)', () => {
it('moves every legacy character to the subject once and records the map', () => {
const store = openSqliteStore(':memory:')
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.ts b/packages/persistence/src/sqlite/platformRepositories.ts
iindex 68068a5..dd3a6a9 100644
---- a/packages/persistence/src/sqlite/platformRepositories.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.ts
@@@ -86,9 +86,40 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
return { moved, slot, full: false }
})
++ // Account merge (ADR-029): the folded-in subject's characters join the survivor, once per merge.
++ const mergeAccount = db.transaction((from: string, into: string, mergeId: string, now: number) => {
++ const key = `merge:${mergeId}`
++ if (mapped.get(key)) return { moved: 0, kept: 0, already: true }
++ const taken = new Set(takenSlots.all(into).map((r) => r.char_slot))
++ const rows = legacyRows.all(from)
++ let moved = 0
++ // First every character whose own slot is free there, then the rest into what is left.
++ const clashing = rows.filter((row) => {
++ if (taken.has(row.char_slot)) return true
++ moveToSlot.run(into, into, row.char_slot, row.id)
++ taken.add(row.char_slot)
++ moved++
++ return false
++ })
++ let kept = 0
++ for (const row of clashing) {
++ const slot = [0, 1, 2].find((s) => !taken.has(s))
++ if (slot === undefined) {
++ kept++
++ continue
++ }
++ moveToSlot.run(into, into, slot, row.id)
++ taken.add(slot)
++ moved++
++ }
++ recordMap.run(key, into, 'merge', moved, kept, now)
++ return { moved, kept, already: false }
++ })
++
return {
adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
adopt(legacyKey, subjectId, source, now),
++ mergeSubject: (from, into, mergeId, now) => mergeAccount(from, into, mergeId, now),
adoptGuestCharacter: (guestToken, subjectId, now) => adoptGuest(guestToken, subjectId, now),
subjectForLegacy(legacyKey) {
return mapped.get(legacyKey)?.subject_id ?? null