openvibers/openvibe.games Commits

Remove account merge handling (#359)

OP OpenVibers committed 3 days ago · d0cef687f8 · 10 files, +18 −199

* 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

* Remove merge callback from revocation tests

* Remove merge-only imports

* Update creator asset erasure expectation

Changes

ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 223ecd0..3d30de1 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -937,12 +937,6 @@ async function main(): Promise<void> {
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
0,
),
-- // Account merge (ADR-029): the folded-in account's characters join the survivor's free slots.
-- onMerged: async (from, into, mergeId) => {
-- const result = await store.identity.mergeSubject(from, into, mergeId, Date.now())
-- settleRewards(into)
-- return result
-- },
// Account export and deletion (ADR-033): the part goes to Network; a deleted account's sessions close (each
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
ddiff --git a/apps/server/src/platform/inventoryRewards.test.ts b/apps/server/src/platform/inventoryRewards.test.ts
iindex 7688862..294ec21 100644
---- a/apps/server/src/platform/inventoryRewards.test.ts
++++ b/apps/server/src/platform/inventoryRewards.test.ts
@@@ -214,7 +214,7 @@ describe('Inventory achievement rewards', () => {
}
})
-- it('exports grant state and moves it with account achievements', async () => {
++ it('exports grant state and erases it with account deletion', async () => {
const { store, items } = await setup()
const from = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1'
await recordHaul(store.db, from, 'haul-merge', 'Player', 100, 1000)
@@@ -226,13 +226,8 @@ describe('Inventory achievement rewards', () => {
item_instance: 'iti_1',
}),
])
-- await store.identity.mergeSubject(from, subject, 'merge-1', 2000)
-- expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toMatchObject({
-- item_state: 'granted',
-- })
++ await store.identity.eraseSubjects([from])
expect(await state(store.db, from, 'scraplandia', 'first-haul')).toBeNull()
-- await store.identity.eraseSubjects([subject])
-- expect(await state(store.db, subject, 'scraplandia', 'first-haul')).toBeNull()
})
it('does nothing when off or the achievement has no reward', async () => {
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
iindex 3528948..efb8785 100644
---- a/apps/server/src/platform/revocationEvents.test.ts
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -8,7 +8,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
createRevocationEvents,
ensureRevocationSubscription,
-- MERGE_TOPIC,
TOPIC,
} from './revocationEvents.js'
@@@ -25,7 +24,6 @@ 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 accountCalls: string[] = []
const modGrantCalls: [string, string, string][] = []
const events = createRevocationEvents({
@@@ -35,10 +33,6 @@ const events = createRevocationEvents({
closedFor.push([s, ms])
return 2
},
-- onMerged: async (from, into, id) => {
-- mergedCalls.push([from, into, id])
-- return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
-- },
onModGrants: async (p) => {
modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
return 'mod:enabled'
@@@ -107,32 +101,6 @@ 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('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {
const exp = (id: string) =>
envelope({
@@@ -257,7 +225,6 @@ describe('revocation events', () => {
expect(await ensureRevocationSubscription(opts)).toBe('exists')
expect(subs).toEqual([
{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
-- { topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },
{
topic_pattern: 'network.account.export_requested',
endpoint: opts.endpoint,
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
iindex 35b1095..ad7e7d6 100644
---- a/apps/server/src/platform/revocationEvents.ts
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -9,9 +9,7 @@
* 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. network.account.export_requested and network.account.deleted (roadmap WS-B task 7, ADR-033) go to
++ * network.account.export_requested and network.account.deleted (roadmap WS-B task 7, ADR-033) go to
* `onAccountEvent` (./accountData.ts), answered once it resolved (500 when it failed, so Events retries).
* network.mod.grants_changed (WS-M task 3, ADR-013) goes to `onModGrants`: the install's copy of its grants follows
* its principal in Network. One subscription per topic (TOPICS).
@@@ -43,9 +41,6 @@ type ParseDelivery = (
payload?: {
subject?: { id?: string } | string
valid_after?: string
-- merge_id?: string
-- from?: string
-- into?: string
export_id?: string
deletion_id?: string
aliases?: unknown
@@@ -59,17 +54,13 @@ const { createPgRevocationStore } = 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 MOD_GRANTS_TOPIC = 'network.mod.grants_changed'
export const TOPICS = [
TOPIC,
-- MERGE_TOPIC,
'network.account.export_requested',
'network.account.deleted',
MOD_GRANTS_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
@@@ -84,12 +75,6 @@ export function createRevocationEvents(opts: {
db: Db
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,
-- ) => Promise<{ moved: number; kept: number; already: boolean }>
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
onAccountEvent?: (
event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
@@@ -205,29 +190,7 @@ export function createRevocationEvents(opts: {
send(res, 200, { event_id: event.event_id ?? null, outcome })
return
}
-- 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 = await 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 = await store.apply(event)
++ const outcome = await store.apply(event)
let closed = 0
if (outcome === 'revoked') {
stats.revoked++
ddiff --git a/docs/games.md b/docs/games.md
iindex 2ed2c8d..62bac84 100644
---- a/docs/games.md
++++ b/docs/games.md
@@@ -139,7 +139,7 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
cannot publish it again. Deleting a world removes its place's rows. A world's first publication is the event
`games.world.published` (OpenVibe.Quest's "Publish a creator world"); staff hiding, restoring or deleting someone
else's world is a `games.moderation.action` (`world.hidden`, `world.restored`, `world.deleted`). Worlds are in the account export, follow an
-- account merge and go with an account deletion.
++ account deletion.
- **Pages and Search:** a published world has its own page at `/worlds/<id>` (server-rendered, the link to share)
and is an OpenVibe.Search document (`games.index_document.upserted`, contracts 0.195.0, through
`openvibe-publishing/search-feed` and the event outbox; sequencer `games_index_revisions`, migration 0011). A
ddiff --git a/packages/persistence/src/pg/creatorAssets.test.ts b/packages/persistence/src/pg/creatorAssets.test.ts
iindex 83c2b20..93eaa12 100644
---- a/packages/persistence/src/pg/creatorAssets.test.ts
++++ b/packages/persistence/src/pg/creatorAssets.test.ts
@@@ -79,7 +79,7 @@ describe('creator assets store', () => {
expect(await db.many(sql`SELECT * FROM creator_asset_uses`)).toEqual([])
})
-- it('follows the account: export, merge into the survivor (uses recounted) and erasure', async () => {
++ it('exports and erases account-owned assets and uses', async () => {
const db = await openTestDb()
const store = createCreatorAssetStore(db)
const identity = createIdentityRepository(db)
@@@ -105,16 +105,12 @@ describe('creator assets store', () => {
expect(out.creatorAssets.map((a) => a.id)).toEqual([kims.id])
expect(out.creatorAssetUses.map((u) => u.asset_id).sort()).toEqual([lees.id, zeds.id].sort())
-- // KIM folds into LEE: KIM's build is LEE's; KIM's use of LEE's own build no longer counts, and ZED's build
-- // counts one use by LEE, not two.
-- await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
-- expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
-- expect((await store.get(lees.id))?.uses).toBe(0)
-- expect((await store.get(zeds.id))?.uses).toBe(1)
--
-- expect(await identity.eraseSubjects([LEE])).toMatchObject({ creator_assets: 2 })
-- expect((await store.listByOwner(LEE)).length).toBe(0)
-- expect(await db.many(sql`SELECT subject FROM creator_asset_uses`)).toEqual([])
++ expect(await identity.eraseSubjects([KIM])).toMatchObject({ creator_assets: 1 })
++ expect((await store.listByOwner(KIM)).length).toBe(0)
++ expect((await store.get(lees.id))?.ownerSubject).toBe(LEE)
++ // Erasure clears use identities but preserves the lifetime use count.
++ expect((await store.get(zeds.id))?.uses).toBe(2)
expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
++ expect(await db.many(sql`SELECT subject FROM creator_asset_uses`)).toEqual([{ subject: LEE }])
})
})
ddiff --git a/packages/persistence/src/pg/creatorWorlds.test.ts b/packages/persistence/src/pg/creatorWorlds.test.ts
iindex 308cf90..a356680 100644
---- a/packages/persistence/src/pg/creatorWorlds.test.ts
++++ b/packages/persistence/src/pg/creatorWorlds.test.ts
@@@ -117,7 +117,7 @@ describe('creator worlds store', () => {
expect(await db.maybe(sql`SELECT 1 FROM world_meta WHERE place_id = ${place}`)).toBeNull()
})
-- it('follows the account: export, merge and erasure (ADR-033, ADR-029)', async () => {
++ it('exports and erases the account-owned world and team places (ADR-033)', async () => {
const db = await openTestDb()
const store = createCreatorWorldStore(db)
const identity = createIdentityRepository(db)
@@@ -158,17 +158,12 @@ describe('creator worlds store', () => {
(await identity.exportSubject(KIM)).creatorWorldTeams.map((t) => t.world_id).sort(),
).toEqual([zeds.id, lees.id].sort())
-- await identity.mergeSubject(KIM, LEE, 'merge-1', 10)
-- expect((await store.get(kims.id))?.ownerSubject).toBe(LEE)
-- // KIM's place on ZED's team is LEE's now; on LEE's own world it is gone (an owner needs no team place).
-- expect((await store.members(zeds.id)).map((m) => m.subject)).toEqual([LEE])
-- expect(await store.members(lees.id)).toEqual([])
--
-- const erased = await identity.eraseSubjects([LEE])
-- expect(erased.creator_worlds).toBe(2)
++ const erased = await identity.eraseSubjects([KIM])
++ expect(erased.creator_worlds).toBe(1)
expect(await store.get(kims.id)).toBeNull()
-- // Erased from every team, and ZED's world stays ZED's.
expect(await store.members(zeds.id)).toEqual([])
++ expect(await store.members(lees.id)).toEqual([])
expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
++ expect((await store.get(lees.id))?.ownerSubject).toBe(LEE)
})
})
ddiff --git a/packages/persistence/src/pg/platformRepositories.test.ts b/packages/persistence/src/pg/platformRepositories.test.ts
iindex fed8a3b..f8c96fb 100644
---- a/packages/persistence/src/pg/platformRepositories.test.ts
++++ b/packages/persistence/src/pg/platformRepositories.test.ts
@@@ -86,47 +86,6 @@ 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', async () => {
-- const store = await openTestStore()
-- // The survivor has slot 0; the folded-in account has slots 0, 1 and 2. Its 0 clashes and takes the next free place.
-- await store.players.upsertMany([
-- player('s0', SUBJECT, 0),
-- player('f0', FROM, 0),
-- player('f1', FROM, 1),
-- player('f2', FROM, 2),
-- ])
-- expect(
-- await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 5),
-- ).toEqual({ moved: 3, kept: 0, already: false })
-- expect(
-- (await store.players.listByToken(SUBJECT)).map((p) => [p.id, p.charSlot]).sort(),
-- ).toEqual([
-- ['f0', 3],
-- ['f1', 1],
-- ['f2', 2],
-- ['s0', 0],
-- ])
-- expect((await store.players.listByToken(FROM)).length).toBe(0)
-- expect(
-- await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2G9', 6),
-- ).toEqual({ moved: 0, kept: 0, already: true })
-- })
--
-- it('keeps on the folded-in account what does not fit a full survivor', async () => {
-- const store = await openTestStore()
-- await store.players.upsertMany([
-- ...Array.from({ length: MAX_CHARACTERS }, (_, i) => player(`s${i}`, SUBJECT, i)),
-- player('f0', FROM, 0),
-- ])
-- expect(
-- await store.identity.mergeSubject(FROM, SUBJECT, 'mrg_01J8Z3Q4R5S6T7V8W9X0Y1Z2H1', 5),
-- ).toEqual({ moved: 0, kept: 1, already: false })
-- expect((await store.players.listByToken(FROM)).map((p) => p.id)).toEqual(['f0'])
-- })
--})
--
describe('account export and deletion (ADR-033)', () => {
const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
it("exports the subject's characters without the account key, then erases them and the alias's; structures stay unowned", async () => {
ddiff --git a/packages/persistence/src/pg/platformRepositories.ts b/packages/persistence/src/pg/platformRepositories.ts
iindex 550595d..8357b1d 100644
---- a/packages/persistence/src/pg/platformRepositories.ts
++++ b/packages/persistence/src/pg/platformRepositories.ts
@@@ -17,9 +17,8 @@ import {
eraseCreatorWorlds,
exportCreatorWorlds,
exportWorldTeams,
-- moveCreatorWorlds,
} from './creatorWorlds.js'
--import { eraseCreatorAssets, exportCreatorAssets, moveCreatorAssets } from './creatorAssets.js'
++import { eraseCreatorAssets, exportCreatorAssets } from './creatorAssets.js'
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
@@@ -144,43 +143,6 @@ export function createIdentityRepository(db: Db): IdentityRepository {
})
},
-- mergeSubject(from, into, mergeId, now) {
-- return db.tx(async () => {
-- const key = `merge:${mergeId}`
-- if (await adopted(key)) return { moved: 0, kept: 0, already: true }
-- const taken = await takenSlots(into)
-- const rows = await rowsFor(from)
-- let moved = 0
-- // First every character whose own slot is free there, then the rest into what is left.
-- const clashing: { id: string; slot: number }[] = []
-- for (const row of rows) {
-- if (taken.has(row.slot)) {
-- clashing.push(row)
-- continue
-- }
-- await moveToSlot(row.id, into, into, row.slot)
-- taken.add(row.slot)
-- moved++
-- }
-- let kept = 0
-- for (const row of clashing) {
-- const slot = SLOTS.find((s) => !taken.has(s))
-- if (slot === undefined) {
-- kept++
-- continue
-- }
-- await moveToSlot(row.id, into, into, slot)
-- taken.add(slot)
-- moved++
-- }
-- await moveProgress(from, into)
-- await moveCreatorWorlds(db, from, into)
-- await moveCreatorAssets(db, from, into)
-- await recordAdoption(key, into, 'merge', moved, kept, now)
-- return { moved, kept, already: false }
-- })
-- },
--
async exportSubject(subject) {
const characters = await db.many<Record<string, unknown>>(
sql`SELECT * FROM characters WHERE account_key = ${subject} OR subject_id = ${subject} ORDER BY slot`,
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex c302bb9..48cfe48 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -65,18 +65,6 @@ export interface IdentityRepository {
subjectId: string,
now: number,
): Promise<{ 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,
-- ): Promise<{ moved: number; kept: number; already: boolean }>
/**
* What Games keeps about a subject, for their data export (roadmap WS-B task 7, ADR-033): their characters (without
* the account key) and the world structures those characters own.