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.
This commit is contained in:
Wanjohi
2026-10-09 12:50:44 +03:00
parent 7670988ece
commit 8054e62bd9
3 changed files with 118 additions and 0 deletions
+43
View File
@@ -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,
+35
View File
@@ -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);
});
});
+40
View File
@@ -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) => {