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).
Changes
@@@ -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 (@@@ -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 },++ ])})})@@@ -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 }++ }++} | nullconst { 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: unknownsecrets: 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 = 0if (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'}@@@ -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[]}@@@ -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:')@@@ -86,9 +86,40 @@ export function createIdentityRepository(db: Database.Database): IdentityRepositreturn { 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
@@ -179,6 +179,8 @@ async function main(): Promise<void> {
@@ -179,6 +179,8 @@ async function main(): Promise<void> {
db: store.db,
db: store.db,
secrets: config.platform.eventsSecrets,
secrets: config.platform.eventsSecrets,
onRevoked: (subject, validAfterMs) => game.revokeSubject(subject, validAfterMs),
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,
log: platformLog,
})
})
if (
if (
@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net'
@@ -3,7 +3,7 @@ import type { AddressInfo } from 'node:net'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
import { createConsoleLogger } from '@openvibe/shared'
import { createConsoleLogger } from '@openvibe/shared'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
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
* POST /internal/events for network.user.token_valid_after: signature v2 only, loopback only, the
@@ -19,6 +19,7 @@ const SECRET = 'x'.repeat(40)
@@ -19,6 +19,7 @@ const SECRET = 'x'.repeat(40)
const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
const log = createConsoleLogger({ app: 'test' }, 'error')
const log = createConsoleLogger({ app: 'test' }, 'error')
const closedFor: [string, number][] = []
const closedFor: [string, number][] = []
+const mergedCalls: [string, string, string][] = []
const events = createRevocationEvents({
const events = createRevocationEvents({
db: new Database(':memory:'),
db: new Database(':memory:'),
secrets: [SECRET],
secrets: [SECRET],
@@ -26,6 +27,10 @@ const events = createRevocationEvents({
@@ -26,6 +27,10 @@ const events = createRevocationEvents({
closedFor.push([s, ms])
closedFor.push([s, ms])
return 2
return 2
},
},
+ onMerged: (from, into, id) => {
+ mergedCalls.push([from, into, id])
+ return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
+ },
log,
log,
})
})
let server: Server
let server: Server
@@ -68,6 +73,18 @@ describe('revocation events', () => {
@@ -68,6 +73,18 @@ describe('revocation events', () => {
expect(closedFor.length).toBe(1)
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 () => {
it('refuses forged, proxied and non-POST requests', async () => {
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), {}, 'y'.repeat(40))).status).toBe(401)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
expect((await post(envelope(), { 'x-forwarded-for': '1.2.3.4' })).status).toBe(403)
@@ -86,6 +103,9 @@ describe('revocation events', () => {
@@ -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 }
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('created')
expect(await ensureRevocationSubscription(opts)).toBe('exists')
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 },
+ ])
})
})
})
})
@@ -8,6 +8,10 @@
@@ -8,6 +8,10 @@
* store (only ever forward; anything not from Network is ignored), then `onRevoked` closes the game
* 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
* 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 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 type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
@@ -25,7 +29,14 @@ type ParseDelivery = (
@@ -25,7 +29,14 @@ type ParseDelivery = (
headers: IncomingMessage['headers'],
headers: IncomingMessage['headers'],
secret: string,
secret: string,
opts: { requireV2: boolean },
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 {
const { createRevocationStore } = req('openvibe-sdk/auth') as {
createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
createRevocationStore(db: unknown, opts?: { table?: string }): RevocationStore
@@ -33,6 +44,10 @@ const { createRevocationStore } = req('openvibe-sdk/auth') as {
@@ -33,6 +44,10 @@ const { createRevocationStore } = req('openvibe-sdk/auth') as {
const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDelivery }
export const TOPIC = 'network.user.token_valid_after'
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 PATH = '/internal/events'
const MAX_BODY = 256 * 1024
const MAX_BODY = 256 * 1024
@@ -47,6 +62,8 @@ export function createRevocationEvents(opts: {
@@ -47,6 +62,8 @@ export function createRevocationEvents(opts: {
db: unknown
db: unknown
secrets: string[]
secrets: string[]
onRevoked: (subjectId: string, validAfterMs: number) => number
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
log: Logger
}): RevocationEvents {
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@ -91,6 +108,21 @@ export function createRevocationEvents(opts: {
@@ -91,6 +108,21 @@ export function createRevocationEvents(opts: {
}
}
stats.received++
stats.received++
const event = delivery.event
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)
const outcome = store.apply(event)
let closed = 0
let closed = 0
if (outcome === 'revoked') {
if (outcome === 'revoked') {
@@ -109,7 +141,7 @@ export function createRevocationEvents(opts: {
@@ -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.
* disabled by an operator, is left as it is). Needs the grant games events.subscription.manage.
*/
*/
export async function ensureRevocationSubscription(opts: {
export async function ensureRevocationSubscription(opts: {
@@ -126,15 +158,18 @@ export async function ensureRevocationSubscription(opts: {
@@ -126,15 +158,18 @@ export async function ensureRevocationSubscription(opts: {
const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
const list = await f(`${opts.eventsUrl}/api/v1/subscriptions`, { headers })
if (!list.ok) throw new Error(`Events answered ${list.status} listing subscriptions`)
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 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'
}
}
@@ -81,6 +81,18 @@ export interface IdentityRepository {
@@ -81,6 +81,18 @@ export interface IdentityRepository {
subjectId: string,
subjectId: string,
now: number,
now: number,
): { moved: number; slot: number | null; full: boolean }
): { 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. */
/** Distinct account keys under a legacy prefix that still own characters. */
legacyAccountKeys(prefix: string): string[]
legacyAccountKeys(prefix: string): string[]
}
}
@@ -59,6 +59,21 @@ describe('guest conversion (WS-B task 8)', () => {
@@ -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)', () => {
describe('identity repository (legacy -> canonical subject)', () => {
it('moves every legacy character to the subject once and records the map', () => {
it('moves every legacy character to the subject once and records the map', () => {
const store = openSqliteStore(':memory:')
const store = openSqliteStore(':memory:')
@@ -86,9 +86,40 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
@@ -86,9 +86,40 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
return { moved, slot, full: false }
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 {
return {
adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
adopt(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),
adoptGuestCharacter: (guestToken, subjectId, now) => adoptGuest(guestToken, subjectId, now),
subjectForLegacy(legacyKey) {
subjectForLegacy(legacyKey) {
return mapped.get(legacyKey)?.subject_id ?? null
return mapped.get(legacyKey)?.subject_id ?? null