openvibers/openvibe.games Commits
Remove account merge handling (#359)
Changes
@@@ -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) } : {}),@@@ -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 () => {@@@ -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,@@@ -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 } | stringvalid_after?: string-- merge_id?: string-- from?: string-- into?: stringexport_id?: stringdeletion_id?: stringaliases?: 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: Dbsecrets: 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 = 0if (outcome === 'revoked') {stats.revoked++@@@ -139,7 +139,7 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built incannot 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 someoneelse'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@@@ -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 }])})})@@@ -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)})})@@@ -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 () => {@@@ -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`,@@@ -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.
@@ -937,12 +937,6 @@ async function main(): Promise<void> {
@@ -937,12 +937,6 @@ async function main(): Promise<void> {
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
(count, room) => count + room.game.revokeSubject(subject, validAfterMs),
0,
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
// 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.
// close saves its character) before its characters are erased.
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
...(accountData ? { onAccountEvent: (event) => accountData.apply(event as AccountEvent) } : {}),
@@ -214,7 +214,7 @@ describe('Inventory achievement rewards', () => {
@@ -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 { store, items } = await setup()
const from = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1'
const from = 'usr_01JABCDEFGHJKMNPQRSTVWXYZ1'
await recordHaul(store.db, from, 'haul-merge', 'Player', 100, 1000)
await recordHaul(store.db, from, 'haul-merge', 'Player', 100, 1000)
@@ -226,13 +226,8 @@ describe('Inventory achievement rewards', () => {
@@ -226,13 +226,8 @@ describe('Inventory achievement rewards', () => {
item_instance: 'iti_1',
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()
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 () => {
it('does nothing when off or the achievement has no reward', async () => {
@@ -8,7 +8,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
@@ -8,7 +8,6 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import {
import {
createRevocationEvents,
createRevocationEvents,
ensureRevocationSubscription,
ensureRevocationSubscription,
- MERGE_TOPIC,
TOPIC,
TOPIC,
} from './revocationEvents.js'
} from './revocationEvents.js'
@@ -25,7 +24,6 @@ const SECRET = 'x'.repeat(40)
@@ -25,7 +24,6 @@ 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 accountCalls: string[] = []
const accountCalls: string[] = []
const modGrantCalls: [string, string, string][] = []
const modGrantCalls: [string, string, string][] = []
const events = createRevocationEvents({
const events = createRevocationEvents({
@@ -35,10 +33,6 @@ const events = createRevocationEvents({
@@ -35,10 +33,6 @@ const events = createRevocationEvents({
closedFor.push([s, ms])
closedFor.push([s, ms])
return 2
return 2
},
},
- onMerged: async (from, into, id) => {
- mergedCalls.push([from, into, id])
- return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
- },
onModGrants: async (p) => {
onModGrants: async (p) => {
modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
modGrantCalls.push([p.mod_id, p.status, p.approved.join(',')])
return 'mod:enabled'
return 'mod:enabled'
@@ -107,32 +101,6 @@ describe('revocation events', () => {
@@ -107,32 +101,6 @@ 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('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {
it('account export and deletion (ADR-033) go to onAccountEvent, answered once it resolved (500 when it failed)', async () => {
const exp = (id: string) =>
const exp = (id: string) =>
envelope({
envelope({
@@ -257,7 +225,6 @@ describe('revocation events', () => {
@@ -257,7 +225,6 @@ describe('revocation events', () => {
expect(await ensureRevocationSubscription(opts)).toBe('exists')
expect(await ensureRevocationSubscription(opts)).toBe('exists')
expect(subs).toEqual([
expect(subs).toEqual([
{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
{ topic_pattern: TOPIC, endpoint: opts.endpoint, secret: SECRET },
- { topic_pattern: MERGE_TOPIC, endpoint: opts.endpoint, secret: SECRET },
{
{
topic_pattern: 'network.account.export_requested',
topic_pattern: 'network.account.export_requested',
endpoint: opts.endpoint,
endpoint: opts.endpoint,
@@ -9,9 +9,7 @@
@@ -9,9 +9,7 @@
* 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. 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).
* `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
* 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).
* its principal in Network. One subscription per topic (TOPICS).
@@ -43,9 +41,6 @@ type ParseDelivery = (
@@ -43,9 +41,6 @@ type ParseDelivery = (
payload?: {
payload?: {
subject?: { id?: string } | string
subject?: { id?: string } | string
valid_after?: string
valid_after?: string
- merge_id?: string
- from?: string
- into?: string
export_id?: string
export_id?: string
deletion_id?: string
deletion_id?: string
aliases?: unknown
aliases?: unknown
@@ -59,17 +54,13 @@ const { createPgRevocationStore } = req('openvibe-sdk/auth') as {
@@ -59,17 +54,13 @@ const { createPgRevocationStore } = 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 MOD_GRANTS_TOPIC = 'network.mod.grants_changed'
export const MOD_GRANTS_TOPIC = 'network.mod.grants_changed'
export const TOPICS = [
export const TOPICS = [
TOPIC,
TOPIC,
- MERGE_TOPIC,
'network.account.export_requested',
'network.account.export_requested',
'network.account.deleted',
'network.account.deleted',
MOD_GRANTS_TOPIC,
MOD_GRANTS_TOPIC,
] as const
] 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
@@ -84,12 +75,6 @@ export function createRevocationEvents(opts: {
@@ -84,12 +75,6 @@ export function createRevocationEvents(opts: {
db: Db
db: Db
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,
- ) => Promise<{ moved: number; kept: number; already: boolean }>
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
/** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
onAccountEvent?: (
onAccountEvent?: (
event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>,
@@ -205,29 +190,7 @@ export function createRevocationEvents(opts: {
@@ -205,29 +190,7 @@ export function createRevocationEvents(opts: {
send(res, 200, { event_id: event.event_id ?? null, outcome })
send(res, 200, { event_id: event.event_id ?? null, outcome })
return
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
let closed = 0
if (outcome === 'revoked') {
if (outcome === 'revoked') {
stats.revoked++
stats.revoked++
@@ -139,7 +139,7 @@ five (`GAMES_CREATOR_WORLDS_PER_PERSON`). A world is made at `/worlds`, built in
@@ -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
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
`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
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)
- **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
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
`openvibe-publishing/search-feed` and the event outbox; sequencer `games_index_revisions`, migration 0011). A
@@ -79,7 +79,7 @@ describe('creator assets store', () => {
@@ -79,7 +79,7 @@ describe('creator assets store', () => {
expect(await db.many(sql`SELECT * FROM creator_asset_uses`)).toEqual([])
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 db = await openTestDb()
const store = createCreatorAssetStore(db)
const store = createCreatorAssetStore(db)
const identity = createIdentityRepository(db)
const identity = createIdentityRepository(db)
@@ -105,16 +105,12 @@ describe('creator assets store', () => {
@@ -105,16 +105,12 @@ describe('creator assets store', () => {
expect(out.creatorAssets.map((a) => a.id)).toEqual([kims.id])
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())
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 store.get(zeds.id))?.ownerSubject).toBe(ZED)
+ expect(await db.many(sql`SELECT subject FROM creator_asset_uses`)).toEqual([{ subject: LEE }])
})
})
})
})
@@ -117,7 +117,7 @@ describe('creator worlds store', () => {
@@ -117,7 +117,7 @@ describe('creator worlds store', () => {
expect(await db.maybe(sql`SELECT 1 FROM world_meta WHERE place_id = ${place}`)).toBeNull()
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 db = await openTestDb()
const store = createCreatorWorldStore(db)
const store = createCreatorWorldStore(db)
const identity = createIdentityRepository(db)
const identity = createIdentityRepository(db)
@@ -158,17 +158,12 @@ describe('creator worlds store', () => {
@@ -158,17 +158,12 @@ describe('creator worlds store', () => {
(await identity.exportSubject(KIM)).creatorWorldTeams.map((t) => t.world_id).sort(),
(await identity.exportSubject(KIM)).creatorWorldTeams.map((t) => t.world_id).sort(),
).toEqual([zeds.id, lees.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()
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(zeds.id)).toEqual([])
+ expect(await store.members(lees.id)).toEqual([])
expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
expect((await store.get(zeds.id))?.ownerSubject).toBe(ZED)
+ expect((await store.get(lees.id))?.ownerSubject).toBe(LEE)
})
})
})
})
@@ -86,47 +86,6 @@ describe('guest conversion (WS-B task 8)', () => {
@@ -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)', () => {
describe('account export and deletion (ADR-033)', () => {
const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
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 () => {
it("exports the subject's characters without the account key, then erases them and the alias's; structures stay unowned", async () => {
@@ -17,9 +17,8 @@ import {
@@ -17,9 +17,8 @@ import {
eraseCreatorWorlds,
eraseCreatorWorlds,
exportCreatorWorlds,
exportCreatorWorlds,
exportWorldTeams,
exportWorldTeams,
- moveCreatorWorlds,
} from './creatorWorlds.js'
} 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). */
/** The character places of one account, in order (protocol MAX_CHARACTERS). */
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
const SLOTS = Array.from({ length: MAX_CHARACTERS }, (_, i) => i)
@@ -144,43 +143,6 @@ export function createIdentityRepository(db: Db): IdentityRepository {
@@ -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) {
async exportSubject(subject) {
const characters = await db.many<Record<string, unknown>>(
const characters = await db.many<Record<string, unknown>>(
sql`SELECT * FROM characters WHERE account_key = ${subject} OR subject_id = ${subject} ORDER BY slot`,
sql`SELECT * FROM characters WHERE account_key = ${subject} OR subject_id = ${subject} ORDER BY slot`,
@@ -65,18 +65,6 @@ export interface IdentityRepository {
@@ -65,18 +65,6 @@ export interface IdentityRepository {
subjectId: string,
subjectId: string,
now: number,
now: number,
): Promise<{ moved: number; slot: number | null; full: boolean }>
): 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
* 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.
* the account key) and the world structures those characters own.