From 7bdca1240f3e3644e87686a4be3546374c461800 Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 4 Sep 2026 22:27:20 +0300 Subject: [PATCH] fix(api): say what the library check actually proves MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A library entry records the person, not the account the games were synced from, and `POST /library/sync` is not even told which account a list came from. So the ownership check added for session requests asks "has somebody this person linked got this game?" and not "does the account about to play own it?" — for the one Steam account most people have those are the same sentence, and for two they are not. Confirmed rather than reasoned about: a person with two Steam links, a game synced at person level, and a request naming the second account is accepted today. The check stays, because it still turns a box that boots, tries to launch and fails minutes later into an immediate refusal, and it never refuses on account grounds that the data cannot support. What changes is the comment, which claimed the stronger property, and a test that pins the gap so it is found deliberately rather than by surprise. Closing it properly means recording the linked account on a library entry: a column, a sync contract that says which account a list belongs to, a uniqueness rule per account rather than per person, and a backfill with no correct answer for rows already written. That is a decision about what a library is, and inferring it here would be the kind of modelling taken by accident that this branch refuses elsewhere. --- apps/api/app/routes/session.ts | 25 +++++++++++++++++-------- apps/api/test/session.test.ts | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 51 insertions(+), 8 deletions(-) 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', {