openvibers/openvibe.games Commits

Games takes part in account export and deletion (roadmap WS-B task 7, ADR-033; apps/server openvibe-contracts 0.53.0 -> 0.71.0). POST /internal/events takes network.account.export_requested and network.account.deleted (the boot subscriptions now include both) and hands them to apps/server/src/platform/accountData.ts, answered once applied (500 when it failed, so Events retries). Each is applied once per export or deletion (account_data_events, kept by the adapter beside the outbox).

OP OpenVibers committed 27 Sept 2026 · fcb62c5e10 · 10 files, +362 −11

Export: Games' part goes to Network with the games principal's token: characters.json (their characters, JSON fields parsed, the sign-in token left out), structures.json and live_game_rows.json (rows imported from Live's old game).

Deletion: the person's game sessions close first (each close saves its character). Then store.identity.eraseSubjects removes, in one transaction, the characters of the subject and its merged-in aliases, their imported Live rows and identity-map rows; their structures stay in the world without an owner (counted as retained). Games then confirms with counts, and a failed confirmation is retried without erasing again.

Tests: persistence (export without the token, erase with aliases, structures unowned, idempotent), accountData (sessions closed first, the confirmation and its retry, redelivery, ignored events) and revocationEvents (dispatch, 500 on failure, four subscriptions). pnpm test 871/871, typecheck clean.

Changes

ddiff --git a/apps/server/package.json b/apps/server/package.json
iindex b64313c..f353b9f 100644
---- a/apps/server/package.json
++++ b/apps/server/package.json
@@@ -19,7 +19,7 @@
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.1",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"tsx": "^4.19.3",
ddiff --git a/apps/server/src/main.ts b/apps/server/src/main.ts
iindex 6ff427a..0b6c161 100644
---- a/apps/server/src/main.ts
++++ b/apps/server/src/main.ts
@@@ -42,6 +42,7 @@ import {
outboxSink,
type EventSink,
} from './platform/gameEvents.js'
++import { createAccountData, networkSender, type AccountEvent } from './platform/accountData.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { createPlatformClient } from './platform/serviceClient.js'
import {
@@@ -173,6 +174,20 @@ async function main(): Promise<void> {
...(progressSummary ? { progressSummary } : {}),
})
++ const accountData = platform
++ ? createAccountData({
++ db: store.db,
++ identity: store.identity,
++ send: networkSender({ networkUrl: config.platform.networkUrl, tokens: platform.tokens }),
++ closeSessions: async (subjects) => {
++ let closed = 0
++ for (const s of subjects) closed += game.revokeSubject(s, Number.MAX_SAFE_INTEGER)
++ if (closed) await new Promise((r) => setTimeout(r, 2000))
++ },
++ log: platformLog.child({ system: 'account-data' }),
++ })
++ : null
++
// Sign-out everywhere (network.user.token_valid_after): POST /internal/events closes the person's
// older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).
const revocations = createRevocationEvents({
@@@ -181,6 +196,9 @@ async function main(): Promise<void> {
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()),
++ // 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) } : {}),
log: platformLog,
})
if (
ddiff --git a/apps/server/src/platform/accountData.test.ts b/apps/server/src/platform/accountData.test.ts
nnew file mode 100644
iindex 0000000..3800e29
---- /dev/null
++++ b/apps/server/src/platform/accountData.test.ts
@@@ -0,0 +1,78 @@
++import { openSqliteStore } from '@openvibe/persistence/sqlite'
++import { createConsoleLogger } from '@openvibe/shared'
++import { createRequire } from 'node:module'
++import { describe, expect, it } from 'vitest'
++import { createAccountData, type AccountEvent } from './accountData.js'
++
++/**
++ * Account export and deletion in Games (roadmap WS-B task 7, ADR-033):
++ * - the export part holds the subject's characters without the sign-in token;
++ * - a deletion closes the sessions first, then erases the characters of the subject and its merged-in aliases;
++ * - Games confirms with counts; a failed confirmation is retried without erasing again; a redelivery sends nothing
++ * twice; foreign or malformed events are ignored.
++ */
++const req = createRequire(import.meta.url)
++const { validate } = req('openvibe-contracts') as { validate(id: string, v: unknown): { valid: boolean; errors?: unknown } }
++const log = createConsoleLogger({ app: 'test' }, 'error')
++const DANA = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
++const OLD = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2B4'
++const OTTO = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2C5'
++const char = (id: string, token: string) => ({
++ id, token, name: `char-${id}`, pos: [0, 1, 0] as [number, number, number], yaw: 0, inventory: { size: 24, hotbar: 6, slots: [] }, skills: { mining: 3 },
++ friends: [], appearance: null, charSlot: 0, armor: null, reputation: {}, unlocks: [], activeJob: null, stats: null, updatedAt: 1,
++})
++const ev = (type: string, payload: Record<string, unknown>, source = 'network'): AccountEvent => ({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2D6', event_type: type, source, payload })
++
++describe('account export and deletion (ADR-033)', () => {
++ it('exports, then closes sessions, erases and confirms once', async () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([char('d0', DANA), char('d1', OLD), char('o0', OTTO)])
++ const sent: { path: string; body: { files?: { name: string; content: unknown }[]; erased?: Record<string, number>; retained?: Record<string, number> } }[] = []
++ let failNext = false
++ const closed: string[][] = []
++ const data = createAccountData({
++ db: store.db,
++ identity: store.identity,
++ send: async (path, body) => {
++ if (failNext) {
++ failNext = false
++ return { ok: false, status: 503 }
++ }
++ sent.push({ path, body: body as (typeof sent)[number]['body'] })
++ return { ok: true, status: 200 }
++ },
++ closeSessions: async (subjects) => {
++ closed.push(subjects)
++ },
++ log,
++ })
++ const exp = ev('network.account.export_requested', { export_id: 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7', subject: DANA })
++ expect(await data.apply({ ...exp, source: 'live' })).toBe('ignored:source')
++ expect(await data.apply(ev('network.account.export_requested', { export_id: 'nope', subject: DANA }))).toBe('ignored:payload')
++ expect(await data.apply(exp)).toBe('exported')
++ const part = sent[0]!.body
++ expect(validate('network.account-export-part@1', { subject: DANA, files: part.files }).valid).toBe(true)
++ const chars = part.files![0]!.content as Record<string, unknown>[]
++ expect(chars.map((c) => c.id)).toEqual(['d0'])
++ expect(chars[0]).not.toHaveProperty('token')
++ expect(await data.apply(exp)).toBe('unchanged')
++ expect(sent.length).toBe(1)
++
++ const del = ev('network.account.deleted', { deletion_id: 'del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8', subject: DANA, aliases: [OLD] })
++ failNext = true
++ await expect(data.apply(del)).rejects.toThrow(/confirmation refused: 503/)
++ expect(closed).toEqual([[DANA, OLD]])
++ expect(store.players.listByToken(DANA).length + store.players.listByToken(OLD).length).toBe(0)
++ expect(store.players.listByToken(OTTO).map((p) => p.id)).toEqual(['o0'])
++ expect(await data.apply(del)).toBe('confirmed')
++ expect(closed.length).toBe(1)
++ const conf = sent[1]!
++ expect(conf.path).toBe('/internal/account-deletions/del_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8/confirmations')
++ expect(validate('network.account-deletion-confirmation@1', conf.body).valid).toBe(true)
++ expect(conf.body.erased).toEqual({ characters: 2, legacy_rows: 0, identity_rows: 0 })
++ expect(conf.body.retained).toEqual({ structures_unowned: 0 })
++ expect(await data.apply(del)).toBe('unchanged')
++ expect(sent.length).toBe(2)
++ store.close()
++ })
++})
ddiff --git a/apps/server/src/platform/accountData.ts b/apps/server/src/platform/accountData.ts
nnew file mode 100644
iindex 0000000..ffebcce
---- /dev/null
++++ b/apps/server/src/platform/accountData.ts
@@@ -0,0 +1,128 @@
++/**
++ * Account export and deletion → Games (roadmap WS-B task 7, ADR-033; Contracts 0.71.0). Both arrive at POST
++ * /internal/events (./revocationEvents.ts) and are applied once per export or deletion (account_data_events, kept by
++ * this adapter beside the outbox); the delivery is answered after Network took the part or the confirmation, so a
++ * failure is redelivered without erasing twice.
++ *
++ * network.account.export_requested Games' part (POST /internal/account-exports/:id/parts, service token):
++ * characters.json (without the sign-in token), structures.json and
++ * live_game_rows.json (rows imported from Live's old game).
++ * network.account.deleted the person's game sessions are closed first (each close saves its character),
++ * then store.identity.eraseSubjects removes their characters, imported rows and
++ * identity-map rows; their structures stay in the world without an owner. Games
++ * then confirms with counts.
++ */
++import type Database from 'better-sqlite3'
++import type { Logger } from '@openvibe/shared'
++import type { IdentityRepository } from '@openvibe/persistence'
++
++export const ACCOUNT_TOPICS = ['network.account.export_requested', 'network.account.deleted'] as const
++const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
++const EXPORT_RE = /^exp_[0-9A-HJKMNP-TV-Z]{26}$/
++const DELETION_RE = /^del_[0-9A-HJKMNP-TV-Z]{26}$/
++
++export interface AccountEvent {
++ event_id?: string
++ event_type?: string
++ source?: string
++ payload?: { export_id?: string; deletion_id?: string; subject?: unknown; aliases?: unknown }
++}
++export type Send = (path: string, body: unknown) => Promise<{ ok: boolean; status: number }>
++
++interface Record_ {
++ id: string
++ outcome: string | null
++ sent_at: string | null
++ applied_at: string
++}
++
++export function createAccountData(opts: {
++ db: Database.Database
++ identity: IdentityRepository
++ send: Send
++ /** Close the subjects' game sessions and resolve once their characters were saved (so the erasure comes after). */
++ closeSessions: (subjects: string[]) => Promise<void>
++ log: Logger
++}): { apply(event: AccountEvent): Promise<string> } {
++ opts.db.exec(`CREATE TABLE IF NOT EXISTS account_data_events (
++ id TEXT PRIMARY KEY,
++ kind TEXT NOT NULL,
++ subject TEXT NOT NULL,
++ outcome TEXT,
++ sent_at TEXT,
++ applied_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
++ )`)
++ const get = opts.db.prepare<[string], Record_>('SELECT id, outcome, sent_at, applied_at FROM account_data_events WHERE id = ?')
++ const markSent = opts.db.prepare('UPDATE account_data_events SET sent_at = ? WHERE id = ?')
++
++ async function apply(event: AccountEvent): Promise<string> {
++ if (!event || !ACCOUNT_TOPICS.includes(event.event_type as (typeof ACCOUNT_TOPICS)[number])) return 'ignored:type'
++ if (event.source !== 'network') return 'ignored:source'
++ const p = event.payload ?? {}
++ const subject = String(p.subject ?? '')
++ if (!SUBJECT_RE.test(subject)) return 'ignored:payload'
++ if (event.event_type === 'network.account.export_requested') {
++ const id = String(p.export_id ?? '')
++ if (!EXPORT_RE.test(id)) return 'ignored:payload'
++ if (get.get(id)?.sent_at) return 'unchanged'
++ const data = opts.identity.exportSubject(subject)
++ const files = [
++ { name: 'characters.json', content: data.characters },
++ ...(data.structures.length ? [{ name: 'structures.json', content: data.structures }] : []),
++ ...(data.legacyLiveRows.length ? [{ name: 'live_game_rows.json', content: data.legacyLiveRows }] : []),
++ ]
++ const res = await opts.send(`/internal/account-exports/${id}/parts`, { subject, files })
++ const outcome = res.ok ? 'exported' : res.status === 409 || res.status === 404 ? 'closed' : null
++ if (!outcome) throw new Error(`export part refused: ${res.status}`)
++ opts.db
++ .prepare('INSERT OR REPLACE INTO account_data_events (id, kind, subject, outcome, sent_at) VALUES (?, ?, ?, ?, ?)')
++ .run(id, 'export', subject, JSON.stringify({ result: outcome, characters: data.characters.length }), new Date().toISOString())
++ return outcome
++ }
++ const id = String(p.deletion_id ?? '')
++ if (!DELETION_RE.test(id)) return 'ignored:payload'
++ let rec = get.get(id)
++ let result = 'confirmed'
++ if (!rec) {
++ const aliases = Array.isArray(p.aliases) ? p.aliases.map(String).filter((s) => SUBJECT_RE.test(s)) : []
++ const subjects = [subject, ...aliases]
++ await opts.closeSessions(subjects)
++ const erased = opts.identity.eraseSubjects(subjects)
++ opts.db
++ .prepare('INSERT INTO account_data_events (id, kind, subject, outcome) VALUES (?, ?, ?, ?)')
++ .run(id, 'deletion', subject, JSON.stringify({ erased }))
++ opts.log.info('account deleted: game data erased', { ...erased })
++ rec = get.get(id) as Record_
++ result = 'erased'
++ }
++ if (rec.sent_at) return 'unchanged'
++ const o = JSON.parse(rec.outcome ?? '{}') as { erased?: Record<string, number> }
++ const erased = Object.fromEntries(Object.entries(o.erased ?? {}).filter(([k]) => k !== 'structures_unowned'))
++ const retained = { structures_unowned: o.erased?.structures_unowned ?? 0 }
++ const res = await opts.send(`/internal/account-deletions/${id}/confirmations`, { subject, completed_at: rec.applied_at, erased, retained })
++ if (!res.ok && res.status !== 404) throw new Error(`confirmation refused: ${res.status}`)
++ markSent.run(new Date().toISOString(), id)
++ return result
++ }
++
++ return { apply }
++}
++
++/** Network calls with the games principal's token (audience openvibe.network). */
++export function networkSender(opts: {
++ networkUrl: string
++ tokens: { getToken(ctx?: { audience?: string; scope?: string }): Promise<string> }
++ fetchImpl?: typeof fetch
++}): Send {
++ const f = opts.fetchImpl ?? fetch
++ return async (path, body) => {
++ const token = await opts.tokens.getToken({ audience: 'openvibe.network' })
++ const r = await f(`${opts.networkUrl}${path}`, {
++ method: 'POST',
++ headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
++ body: JSON.stringify(body),
++ signal: AbortSignal.timeout(30_000),
++ })
++ return { ok: r.ok, status: r.status }
++ }
++}
ddiff --git a/apps/server/src/platform/revocationEvents.test.ts b/apps/server/src/platform/revocationEvents.test.ts
iindex 737fb64..ba44f6f 100644
---- a/apps/server/src/platform/revocationEvents.test.ts
++++ b/apps/server/src/platform/revocationEvents.test.ts
@@@ -20,6 +20,7 @@ const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
const log = createConsoleLogger({ app: 'test' }, 'error')
const closedFor: [string, number][] = []
const mergedCalls: [string, string, string][] = []
++const accountCalls: string[] = []
const events = createRevocationEvents({
db: new Database(':memory:'),
secrets: [SECRET],
@@@ -31,6 +32,11 @@ const events = createRevocationEvents({
mergedCalls.push([from, into, id])
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
},
++ onAccountEvent: async (event) => {
++ accountCalls.push(String(event.event_type))
++ if (event.payload?.export_id === 'exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9') throw new Error('Network down')
++ return 'exported'
++ },
log,
})
let server: Server
@@@ -85,6 +91,14 @@ describe('revocation events', () => {
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({ event_id: 'evt_01J8Z3Q4R5S6T7V8W9X0Y1Z2E7', event_type: 'network.account.export_requested', payload: { export_id: id, subject: SUBJECT } })
++ expect(await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Y8'))).toMatchObject({ status: 200, body: { outcome: 'exported' } })
++ expect((await post(exp('exp_01J8Z3Q4R5S6T7V8W9X0Y1Z2Z9'))).status).toBe(500)
++ expect(accountCalls).toEqual(['network.account.export_requested', 'network.account.export_requested'])
++ })
++
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)
@@@ -106,6 +120,8 @@ describe('revocation events', () => {
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, secret: SECRET },
++ { topic_pattern: 'network.account.deleted', endpoint: opts.endpoint, secret: SECRET },
])
})
})
ddiff --git a/apps/server/src/platform/revocationEvents.ts b/apps/server/src/platform/revocationEvents.ts
iindex 155fa4e..5946196 100644
---- a/apps/server/src/platform/revocationEvents.ts
++++ b/apps/server/src/platform/revocationEvents.ts
@@@ -11,7 +11,9 @@
*
* 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).
++ * merge. 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). One
++ * subscription per topic (TOPICS).
*/
import type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
@@@ -34,7 +36,7 @@ type ParseDelivery = (
event_id?: string
event_type?: string
source?: string
-- payload?: { subject?: { id?: string }; valid_after?: string; merge_id?: string; from?: string; into?: string }
++ payload?: { subject?: { id?: string } | string; valid_after?: string; merge_id?: string; from?: string; into?: string; export_id?: string; deletion_id?: string; aliases?: unknown }
}
} | null
@@@ -45,7 +47,7 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
export const TOPIC = 'network.user.token_valid_after'
export const MERGE_TOPIC = 'network.subject.merged'
--export const TOPICS = [TOPIC, MERGE_TOPIC] as const
++export const TOPICS = [TOPIC, MERGE_TOPIC, 'network.account.export_requested', 'network.account.deleted'] 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'
@@@ -64,6 +66,8 @@ export function createRevocationEvents(opts: {
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 }
++ /** Account export or deletion (ADR-033): resolves to the outcome; a rejection is answered 500 and redelivered. */
++ onAccountEvent?: (event: NonNullable<NonNullable<ReturnType<ParseDelivery>>['event']>) => Promise<string>
log: Logger
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@@ -108,6 +112,24 @@ export function createRevocationEvents(opts: {
}
stats.received++
const event = delivery.event
++ if (event.event_type === 'network.account.export_requested' || event.event_type === 'network.account.deleted') {
++ if (!opts.onAccountEvent) {
++ stats.ignored++
++ send(res, 200, { event_id: event.event_id ?? null, outcome: 'ignored:no_handler' })
++ return
++ }
++ opts.onAccountEvent(event).then(
++ (outcome) => {
++ if (outcome.startsWith('ignored')) stats.ignored++
++ send(res, 200, { event_id: event.event_id ?? null, outcome })
++ },
++ (err: unknown) => {
++ opts.log.warn('account event failed (Events retries)', { type: event.event_type, error: String((err as Error)?.message ?? err) })
++ send(res, 500, { error: 'not applied' })
++ },
++ )
++ return
++ }
if (event.event_type === MERGE_TOPIC) {
const p = event.payload ?? {}
let outcome = 'merged'
@@@ -127,7 +149,8 @@ export function createRevocationEvents(opts: {
let closed = 0
if (outcome === 'revoked') {
stats.revoked++
-- const subject = event.payload?.subject?.id ?? ''
++ const sub = event.payload?.subject
++ const subject = (typeof sub === 'object' && sub ? sub.id : undefined) ?? ''
closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))
stats.closed += closed
if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
ddiff --git a/packages/persistence/src/repositories.ts b/packages/persistence/src/repositories.ts
iindex a099dda..cfe9957 100644
---- a/packages/persistence/src/repositories.ts
++++ b/packages/persistence/src/repositories.ts
@@@ -95,6 +95,21 @@ export interface IdentityRepository {
): { moved: number; kept: number; already: boolean }
/** Distinct account keys under a legacy prefix that still own characters. */
legacyAccountKeys(prefix: string): string[]
++ /**
++ * What Games keeps about a subject, for their data export (roadmap WS-B task 7, ADR-033): their characters (without
++ * the sign-in token), the world structures those characters own, and the rows imported from Live's old game.
++ */
++ exportSubject(subject: string): {
++ characters: Record<string, unknown>[]
++ structures: Record<string, unknown>[]
++ legacyLiveRows: Record<string, unknown>[]
++ }
++ /**
++ * Erase these subjects (a deleted account and the accounts merged into it; ADR-033), in one transaction: their
++ * characters, imported Live rows and identity-map rows go; the structures their characters built stay in the
++ * world without an owner. Returns counts.
++ */
++ eraseSubjects(subjects: string[]): { characters: number; structures_unowned: number; legacy_rows: number; identity_rows: number }
}
/** Installed mods, their approved capabilities, placements and audit log. */
ddiff --git a/packages/persistence/src/sqlite/platformRepositories.test.ts b/packages/persistence/src/sqlite/platformRepositories.test.ts
iindex 3d6efc4..1f3c974 100644
---- a/packages/persistence/src/sqlite/platformRepositories.test.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.test.ts
@@@ -74,6 +74,30 @@ describe('account merge (ADR-029)', () => {
})
})
++describe('account export and deletion (ADR-033)', () => {
++ const ALIAS = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2F8'
++ it('exports the subject\'s characters without the token, then erases them and the alias\'s; structures stay unowned', () => {
++ const store = openSqliteStore(':memory:')
++ store.players.upsertMany([player('d0', SUBJECT, 0), player('d1', ALIAS, 0), player('o0', OTHER, 0)])
++ const entity = store.db.prepare("INSERT INTO world_entities (id, kind, def_id, owner_id, pos_x, pos_y, pos_z, rot_x, rot_y, rot_z, rot_w, motion, state, updated_at) VALUES (?, 'structure', 'hut', ?, 0, 0, 0, 0, 0, 0, 1, 'static', '{}', 1)")
++ entity.run('e1', 'd0')
++ entity.run('e2', 'o0')
++ store.db.prepare("INSERT INTO identity_legacy_map (legacy_key, subject_id, source, adopted_at) VALUES ('ovn:9', ?, 'network', 1)").run(SUBJECT)
++ const out = store.identity.exportSubject(SUBJECT)
++ expect(out.characters.map((c) => c.id)).toEqual(['d0'])
++ expect(out.characters[0]).not.toHaveProperty('token')
++ expect(out.characters[0]?.skills).toEqual({ woodcutting: 10 })
++ expect(out.structures.map((e) => e.id)).toEqual(['e1'])
++ expect(store.identity.eraseSubjects([SUBJECT, ALIAS])).toEqual({ characters: 2, structures_unowned: 1, legacy_rows: 0, identity_rows: 1 })
++ expect(store.players.listByToken(SUBJECT)).toEqual([])
++ expect(store.players.listByToken(ALIAS)).toEqual([])
++ expect(store.players.listByToken(OTHER).map((p) => p.id)).toEqual(['o0'])
++ expect(store.db.prepare('SELECT id, owner_id FROM world_entities ORDER BY id').all()).toEqual([{ id: 'e1', owner_id: null }, { id: 'e2', owner_id: 'o0' }])
++ expect(store.identity.eraseSubjects([SUBJECT])).toEqual({ characters: 0, structures_unowned: 0, legacy_rows: 0, identity_rows: 0 })
++ 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 dd3a6a9..845210d 100644
---- a/packages/persistence/src/sqlite/platformRepositories.ts
++++ b/packages/persistence/src/sqlite/platformRepositories.ts
@@@ -116,7 +116,56 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
return { moved, kept, already: false }
})
++ // Account export and deletion (ADR-033). A character row keyed by the subject (token) or carrying it (subject_id).
++ const inList = (n: number): string => `(${Array.from({ length: n }, () => '?').join(',')})`
++ const hasTable = (name: string): boolean =>
++ !!db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(name)
++ const parsed = (row: Record<string, unknown>): Record<string, unknown> => {
++ const out: Record<string, unknown> = {}
++ for (const [k, v] of Object.entries(row)) {
++ if (k === 'token') continue
++ if (typeof v === 'string' && (v.startsWith('{') || v.startsWith('['))) {
++ try {
++ out[k] = JSON.parse(v)
++ continue
++ } catch {
++ // kept as text
++ }
++ }
++ out[k] = v
++ }
++ return out
++ }
++ const exportSubject = (subject: string) => {
++ const characters = db
++ .prepare('SELECT * FROM players WHERE token = ? OR subject_id = ? ORDER BY char_slot')
++ .all(subject, subject) as Record<string, unknown>[]
++ const ids = characters.map((c) => String(c.id))
++ const structures = ids.length
++ ? (db.prepare(`SELECT * FROM world_entities WHERE owner_id IN ${inList(ids.length)}`).all(...ids) as Record<string, unknown>[])
++ : []
++ const legacyLiveRows = hasTable('legacy_live_rows')
++ ? (db.prepare('SELECT source_table, source_key, payload, imported_at FROM legacy_live_rows WHERE subject_id = ?').all(subject) as Record<string, unknown>[])
++ : []
++ return { characters: characters.map(parsed), structures: structures.map(parsed), legacyLiveRows: legacyLiveRows.map(parsed) }
++ }
++ const eraseSubjects = db.transaction((subjects: string[]) => {
++ const out = { characters: 0, structures_unowned: 0, legacy_rows: 0, identity_rows: 0 }
++ if (!subjects.length) return out
++ const q = inList(subjects.length)
++ const ids = (db.prepare(`SELECT id FROM players WHERE token IN ${q} OR subject_id IN ${q}`).all(...subjects, ...subjects) as { id: string }[]).map((r) => r.id)
++ if (ids.length) {
++ out.structures_unowned = db.prepare(`UPDATE world_entities SET owner_id = NULL WHERE owner_id IN ${inList(ids.length)}`).run(...ids).changes
++ out.characters = db.prepare(`DELETE FROM players WHERE id IN ${inList(ids.length)}`).run(...ids).changes
++ }
++ if (hasTable('legacy_live_rows')) out.legacy_rows = db.prepare(`DELETE FROM legacy_live_rows WHERE subject_id IN ${q}`).run(...subjects).changes
++ out.identity_rows = db.prepare(`DELETE FROM identity_legacy_map WHERE subject_id IN ${q}`).run(...subjects).changes
++ return out
++ })
++
return {
++ exportSubject,
++ eraseSubjects: (subjects) => eraseSubjects(subjects),
adoptLegacyAccount: (legacyKey, subjectId, source, now) =>
adopt(legacyKey, subjectId, source, now),
mergeSubject: (from, into, mergeId, now) => mergeAccount(from, into, mergeId, now),
ddiff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
iindex 19cb7f7..5c028e3 100644
---- a/pnpm-lock.yaml
++++ b/pnpm-lock.yaml
@@@ -112,8 +112,8 @@ importers:
specifier: ^11.9.1
version: 11.10.0
openvibe-contracts:
-- specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0
-- version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0
++ specifier: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
++ version: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
@@@ -1366,9 +1366,9 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0:
-- resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0}
-- version: 0.53.0
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
++ resolution: {tarball: https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0}
++ version: 0.71.0
engines: {node: '>=20'}
hasBin: true
@@@ -2830,7 +2830,7 @@ snapshots:
dependencies:
wrappy: 1.0.2
-- openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.53.0:
++ openvibe-contracts@https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.71.0:
dependencies:
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])