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).
Changes
@@@ -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",@@@ -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 (nnew file mode 100644@@@ -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()++ })++})nnew file mode 100644@@@ -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 }++ }++}@@@ -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 },])})})@@@ -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?: stringevent_type?: stringsource?: 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: ParseDeexport 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 constconst 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 = 0if (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 += closedif (closed) opts.log.info('signed out everywhere: sessions closed', { closed })@@@ -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. */@@@ -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:')@@@ -116,7 +116,56 @@ export function createIdentityRepository(db: Database.Database): IdentityRepositreturn { 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),@@@ -112,8 +112,8 @@ importers:specifier: ^11.9.1version: 11.10.0openvibe-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.0openvibe-sdk:specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0version: 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.0engines: {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.0ajv-formats: 3.0.1([email protected])
@@ -19,7 +19,7 @@
@@ -19,7 +19,7 @@
"@openvibe/shared": "workspace:*",
"@openvibe/shared": "workspace:*",
"ajv": "^8.17.1",
"ajv": "^8.17.1",
"better-sqlite3": "^11.9.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v1.23.0",
"tsx": "^4.19.3",
"tsx": "^4.19.3",
@@ -42,6 +42,7 @@ import {
@@ -42,6 +42,7 @@ import {
outboxSink,
outboxSink,
type EventSink,
type EventSink,
} from './platform/gameEvents.js'
} from './platform/gameEvents.js'
+import { createAccountData, networkSender, type AccountEvent } from './platform/accountData.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { MediaMirror } from './platform/mediaMirror.js'
import { createPlatformClient } from './platform/serviceClient.js'
import { createPlatformClient } from './platform/serviceClient.js'
import {
import {
@@ -173,6 +174,20 @@ async function main(): Promise<void> {
@@ -173,6 +174,20 @@ async function main(): Promise<void> {
...(progressSummary ? { progressSummary } : {}),
...(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
// 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).
// older game sessions; the subscription is created at boot when a secret is set (WS-B task 4).
const revocations = createRevocationEvents({
const revocations = createRevocationEvents({
@@ -181,6 +196,9 @@ async function main(): Promise<void> {
@@ -181,6 +196,9 @@ async function main(): Promise<void> {
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.
// 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()),
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,
log: platformLog,
})
})
if (
if (
new file mode 100644
new file mode 100644
@@ -0,0 +1,78 @@
@@ -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()
+ })
+})
new file mode 100644
new file mode 100644
@@ -0,0 +1,128 @@
@@ -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 }
+ }
+}
@@ -20,6 +20,7 @@ const SUBJECT = 'usr_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3'
@@ -20,6 +20,7 @@ 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 mergedCalls: [string, string, string][] = []
+const accountCalls: string[] = []
const events = createRevocationEvents({
const events = createRevocationEvents({
db: new Database(':memory:'),
db: new Database(':memory:'),
secrets: [SECRET],
secrets: [SECRET],
@@ -31,6 +32,11 @@ const events = createRevocationEvents({
@@ -31,6 +32,11 @@ const events = createRevocationEvents({
mergedCalls.push([from, into, id])
mergedCalls.push([from, into, id])
return { moved: 2, kept: 1, already: mergedCalls.length > 1 }
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,
log,
})
})
let server: Server
let server: Server
@@ -85,6 +91,14 @@ describe('revocation events', () => {
@@ -85,6 +91,14 @@ describe('revocation events', () => {
expect((await post(merged())).body.outcome).toBe('unchanged')
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 () => {
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)
@@ -106,6 +120,8 @@ describe('revocation events', () => {
@@ -106,6 +120,8 @@ describe('revocation events', () => {
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: 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 },
])
])
})
})
})
})
@@ -11,7 +11,9 @@
@@ -11,7 +11,9 @@
*
*
* The same endpoint takes network.subject.merged (roadmap WS-B task 5, ADR-029): two accounts became one, and
* 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
* `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 type { IncomingMessage, ServerResponse } from 'node:http'
import { createRequire } from 'node:module'
import { createRequire } from 'node:module'
@@ -34,7 +36,7 @@ type ParseDelivery = (
@@ -34,7 +36,7 @@ type ParseDelivery = (
event_id?: string
event_id?: string
event_type?: string
event_type?: string
source?: 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
} | null
@@ -45,7 +47,7 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
@@ -45,7 +47,7 @@ const { parseDelivery } = req('openvibe-sdk/events') as { parseDelivery: ParseDe
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 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 SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const MERGE_RE = /^mrg_[0-9A-HJKMNP-TV-Z]{26}$/
const PATH = '/internal/events'
const PATH = '/internal/events'
@@ -64,6 +66,8 @@ export function createRevocationEvents(opts: {
@@ -64,6 +66,8 @@ export function createRevocationEvents(opts: {
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). */
/** 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 }
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
log: Logger
}): RevocationEvents {
}): RevocationEvents {
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
const store = createRevocationStore(opts.db, { table: 'token_revocations' })
@@ -108,6 +112,24 @@ export function createRevocationEvents(opts: {
@@ -108,6 +112,24 @@ export function createRevocationEvents(opts: {
}
}
stats.received++
stats.received++
const event = delivery.event
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) {
if (event.event_type === MERGE_TOPIC) {
const p = event.payload ?? {}
const p = event.payload ?? {}
let outcome = 'merged'
let outcome = 'merged'
@@ -127,7 +149,8 @@ export function createRevocationEvents(opts: {
@@ -127,7 +149,8 @@ export function createRevocationEvents(opts: {
let closed = 0
let closed = 0
if (outcome === 'revoked') {
if (outcome === 'revoked') {
stats.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 ?? ''))
closed = opts.onRevoked(subject, Date.parse(event.payload?.valid_after ?? ''))
stats.closed += closed
stats.closed += closed
if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
if (closed) opts.log.info('signed out everywhere: sessions closed', { closed })
@@ -95,6 +95,21 @@ export interface IdentityRepository {
@@ -95,6 +95,21 @@ export interface IdentityRepository {
): { moved: number; kept: number; already: boolean }
): { 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[]
+ /**
+ * 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. */
/** Installed mods, their approved capabilities, placements and audit log. */
@@ -74,6 +74,30 @@ describe('account merge (ADR-029)', () => {
@@ -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)', () => {
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:')
@@ -116,7 +116,56 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
@@ -116,7 +116,56 @@ export function createIdentityRepository(db: Database.Database): IdentityReposit
return { moved, kept, already: false }
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 {
return {
+ exportSubject,
+ eraseSubjects: (subjects) => eraseSubjects(subjects),
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),
mergeSubject: (from, into, mergeId, now) => mergeAccount(from, into, mergeId, now),
@@ -112,8 +112,8 @@ importers:
@@ -112,8 +112,8 @@ importers:
specifier: ^11.9.1
specifier: ^11.9.1
version: 11.10.0
version: 11.10.0
openvibe-contracts:
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:
openvibe-sdk:
specifier: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
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
version: https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.11.0
@@ -1366,9 +1366,9 @@ packages:
@@ -1366,9 +1366,9 @@ packages:
resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==}
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'}
engines: {node: '>=20'}
hasBin: true
hasBin: true
@@ -2830,7 +2830,7 @@ snapshots:
@@ -2830,7 +2830,7 @@ snapshots:
dependencies:
dependencies:
wrappy: 1.0.2
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:
dependencies:
ajv: 8.20.0
ajv: 8.20.0
ajv-formats: 3.0.1([email protected])
ajv-formats: 3.0.1([email protected])