diff --git a/apps/api/app/routes/session.ts b/apps/api/app/routes/session.ts index afb1065f..eed46f48 100644 --- a/apps/api/app/routes/session.ts +++ b/apps/api/app/routes/session.ts @@ -137,18 +137,27 @@ export namespace SessionApi { ); } - // A run launches as a Steam account that has to own the game, so - // a game outside the caller's library is a box that starts, tries - // to launch, and fails minutes later with nothing to point at. - // Refusing here is the same answer sooner. + // A game nobody has synced for this person is a box that starts, + // tries to launch and fails minutes later with nothing to point + // at. Refusing here is the same answer sooner. // // Told apart from a game that does not exist rather than hidden: // the catalog is public, so there is nothing to hide, and "you do - // not own this" is the sentence a person can act on. + // not own this" is a sentence a person can act on. // - // The library is a synced copy, so this refuses a game bought - // since the last sync. That is a staleness bug in the sync and - // not a reason to launch runs that cannot work. + // **This is a weaker check than the one that matters.** A run + // launches as one account, but a library entry records only the + // person, so what is verified is "somebody this person has linked + // owns it" and not "the account playing owns it". For the one + // linked account most people have those are the same sentence; + // for two they are not, and the second account can ask for a game + // only the first owns. Answering the real question needs the + // account recorded on the library entry, which is a decision + // about what a library *is* and not something to infer here. + // + // The library is also a synced copy, so this refuses a game + // bought since the last sync. Both gaps let a launch fail late; + // neither is a reason to start runs already known to fail. const owned = await Library.findByUserAndGame({ userId, gameId: game.id }); if (!owned) { throw new VisibleError( diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index b151e32d..c63e12a2 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -9,6 +9,7 @@ import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; import { Session } from '@nestri/core/session/index'; import { Library } from '@nestri/core/user/library'; +import { LinkedAccount } from '@nestri/core/user/linked-account'; import { app } from '../app/index'; import './setup'; @@ -265,6 +266,39 @@ describe('POST /session', () => { expect(await Session.listByBox(s.box.id)).toHaveLength(0); }); + test('the library check is per person, not per account it plays as', async () => { + const s = await scene('route-multilink', 5562); + // A second Steam account on the same person. The unique index is on + // (provider, providerAccountId) and is global rather than per user, so + // nothing stops this — but a fixed id here would collide with its own + // previous run, hence one shaped like a SteamID64 and unique per run. + const second = await LinkedAccount.create({ + id: Identifier.ascending('linkedAccount'), + userId: s.owner.userId, + provider: 'steam', + providerAccountId: `7656119${Date.now()}`.slice(0, 17), + profile: null + }); + + const res = await app.request('/session', { + method: 'POST', + headers: s.user, + body: JSON.stringify({ + boxId: s.box.id, + gameId: s.gameId, + linkedAccountId: second + }) + }); + + // Accepted, and this pins a known gap rather than asserting it is + // right: a library entry records the person and not the account the + // games came from, so "the account playing owns this" cannot be asked. + // The run will fail at launch exactly as it did before the check + // existed. Closing it means recording the account on the library + // entry, which changes what a library is and what the sync must send. + expect(res.status).toBe(201); + }); + test('an unknown game is a 404 and not a foreign key crash', async () => { const s = await scene('route-nogame', 5507); const res = await app.request('/session', {