From 8054e62bd9a338ac1d78020c03ac51500fefc1ea Mon Sep 17 00:00:00 2001 From: Wanjohi Date: Fri, 9 Oct 2026 12:50:44 +0300 Subject: [PATCH] feat(machine): let the owner read what their machine has downloaded GET /machine/:id/downloads lists every game the machine is downloading, has ready or failed to download, with progress and each game's name. The library already carries a download per game, but it is the most recent one across every host, which is not what a person watching their own machine set up wants to see. Owner-scoped in the query: a machine that is not yours answers 404, the same as one that does not exist. --- apps/api/app/routes/machine.ts | 43 ++++++++++++++++++++++++++++++ apps/api/test/session.test.ts | 35 ++++++++++++++++++++++++ packages/core/src/game/download.ts | 40 +++++++++++++++++++++++++++ 3 files changed, 118 insertions(+) diff --git a/apps/api/app/routes/machine.ts b/apps/api/app/routes/machine.ts index ceec598b..a3728798 100644 --- a/apps/api/app/routes/machine.ts +++ b/apps/api/app/routes/machine.ts @@ -2,6 +2,7 @@ import { Actor } from '@nestri/core/actor'; import { Box } from '@nestri/core/box/index'; import { ErrorCodes, VisibleError } from '@nestri/core/error'; import { Examples } from '@nestri/core/examples'; +import { GameDownload } from '@nestri/core/game/download'; import { Identifier } from '@nestri/core/id'; import { Machine } from '@nestri/core/machine/index'; import { InstallToken } from '@nestri/core/machine/install-token'; @@ -596,6 +597,48 @@ export namespace MachineApi { return c.json({ data: machine }); } ) + .get( + '/:id/downloads', + notPublic, + describeRoute({ + tags: ['Machine'], + summary: 'What a machine has downloaded', + description: + 'Every game this machine is downloading, has ready, or failed to download, with how far each has got. Only the machine’s owner can read it.', + responses: { + 200: { + content: { + 'application/json': { + schema: Result( + z.array( + GameDownload.Info.extend({ + game: z.object({ name: z.string(), steamAppId: z.number() }) + }) + ) + ) + } + }, + description: 'Downloads on this machine, least recently changed first' + }, + 401: ErrorResponses[401], + 404: ErrorResponses[404] + } + }), + async (c) => { + const data = await GameDownload.listForOwnedMachine({ + machineId: c.req.param('id'), + ownerUserId: Actor.userID + }); + if (!data) { + throw new VisibleError( + 'not_found', + ErrorCodes.NotFound.RESOURCE_NOT_FOUND, + 'No such machine, or it is not yours' + ); + } + return c.json({ data }); + } + ) .get( '/', notPublic, diff --git a/apps/api/test/session.test.ts b/apps/api/test/session.test.ts index 8cf5a073..7a21aa93 100644 --- a/apps/api/test/session.test.ts +++ b/apps/api/test/session.test.ts @@ -950,3 +950,38 @@ describe('POST /session/:id/stop', () => { expect(res.status).toBe(403); }); }); + +describe('GET /machine/:id/downloads', () => { + test('what a host says it is downloading is what its owner reads', async () => { + const s = await scene('downloads-owner', 5701); + const said = await app.request('/games/download-state', { + method: 'POST', + headers: s.host, + body: JSON.stringify({ + steamAppId: 5701, + status: 'downloading', + progressBytes: 40, + totalBytes: 100 + }) + }); + expect(said.status).toBe(200); + + const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: s.user }); + expect(res.status).toBe(200); + const data = ((await res.json()) as any).data; + expect(data).toHaveLength(1); + expect(data[0]).toMatchObject({ + status: 'downloading', + progressBytes: 40, + totalBytes: 100, + game: { steamAppId: 5701, name: 'Session Route 5701' } + }); + }); + + test('somebody else’s machine is not there to read', async () => { + const s = await scene('downloads-mine', 5702); + const other = await scene('downloads-theirs', 5703); + const res = await app.request(`/machine/${s.machineId}/downloads`, { headers: other.user }); + expect(res.status).toBe(404); + }); +}); diff --git a/packages/core/src/game/download.ts b/packages/core/src/game/download.ts index 24893286..2d629e92 100644 --- a/packages/core/src/game/download.ts +++ b/packages/core/src/game/download.ts @@ -5,6 +5,8 @@ import { Database } from '../db/index.js'; import { Examples } from '../examples.js'; import { fn } from '../fn.js'; import { Identifier } from '../id.js'; +import { MachineTable } from '../machine/machine.sql.js'; +import { GameTable } from './game.sql.js'; import { GameDownloadStatus, GameDownloadTable } from './download.sql.js'; export namespace GameDownload { @@ -151,6 +153,44 @@ export namespace GameDownload { }); }); + /** + * What one machine has downloaded or is downloading, with each game's name, + * for the person who owns it. Null when the machine is not there or not + * theirs: owner-scoped in the query, so the two cannot be told apart. + */ + export const listForOwnedMachine = fn( + z.object({ machineId: z.string(), ownerUserId: z.string() }), + async (input) => { + return Database.use(async (tx) => { + const [machine] = await tx + .select({ id: MachineTable.id }) + .from(MachineTable) + .where( + and( + eq(MachineTable.id, input.machineId), + eq(MachineTable.ownerUserId, input.ownerUserId), + isNull(MachineTable.timeDeleted) + ) + ); + if (!machine) return null; + return tx + .select({ download: GameDownloadTable, game: GameTable }) + .from(GameDownloadTable) + .innerJoin(GameTable, eq(GameDownloadTable.gameId, GameTable.id)) + .where( + and(eq(GameDownloadTable.hostId, machine.id), isNull(GameDownloadTable.timeDeleted)) + ) + .orderBy(GameDownloadTable.timeUpdated) + .then((rows) => + rows.map((row) => ({ + ...serialize(row.download), + game: { name: row.game.name, steamAppId: row.game.steamAppId } + })) + ); + }); + } + ); + export const listByGameIDs = fn(z.array(z.string()), async (gameIds) => { if (gameIds.length === 0) return []; return Database.use(async (tx) => {