release: library downloads only from your boxes' hosts, named by GPU (#384)

GET /library picked each game's most recent download row from any host,
so one person's library could show another person's machine id and
progress. It now reads only hosts the caller's boxes are on.

Boxes and library downloads gain `gpu`, the host's first reported card
model, so clients name a place by hardware rather than a machine id.
Null until a host reports one.

Tests: core 240 pass (3 new), api 171 pass. The leak test was checked to
fail with the host filter removed.
This commit is contained in:
Wanjohi
2026-10-10 07:12:10 +00:00
committed by GitHub
7 changed files with 106 additions and 9 deletions
+11
View File
@@ -3,6 +3,7 @@ import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Machine } from '../machine/index.js';
import { Box } from './index.js';
const sql = testDb();
@@ -27,6 +28,16 @@ afterAll(async () => {
});
describe('Box', () => {
test("a person's boxes say which card they run on, and nothing until the host reports one", async () => {
const o = await newOwner('box-gpu');
const machineId = await Fixtures.machine(o, 'box-gpu-host');
await Box.create({ id: Identifier.ascending('box'), userId: o.userId, machineId, label: 'gpu', tier: 'sm' });
expect((await Box.listByUser(o.userId))[0]?.gpu).toBeNull();
await Machine.setGpus({ id: machineId, gpus: [{ model: 'AMD Radeon RX 9060 XT', pciId: '1002:7590' }] });
expect((await Box.listByUser(o.userId))[0]?.gpu).toBe('AMD Radeon RX 9060 XT');
});
test('a new box starts created, sm, and with nothing to explain', async () => {
const owner = await newOwner('box-defaults');
const machineId = await Fixtures.machine(owner);
+13 -5
View File
@@ -4,6 +4,7 @@ import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { Machine } from '../machine/index.js';
import { MachineTable } from '../machine/machine.sql.js';
import { BoxState, BoxTable, BoxTier } from './box.sql.js';
import { Placement } from './placement.js';
@@ -50,6 +51,11 @@ export namespace Box {
stopClean: z.boolean().nullable().optional().meta({
description: 'Whether that stop was clean. Null if it never ran',
example: Examples.Box.stopClean
}),
gpu: z.string().nullable().optional().meta({
description:
'The card it runs on, as its host reports it: what a person recognises the box by. Null until the host has reported one',
example: Examples.Box.gpu
})
})
.meta({
@@ -118,11 +124,12 @@ export namespace Box {
export const listByUser = fn(Info.shape.userId, async (userId) => {
return Database.use(async (tx) => {
return tx
.select()
.select({ box: BoxTable, gpus: MachineTable.gpus })
.from(BoxTable)
.leftJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId))
.where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted)))
.orderBy(BoxTable.timeCreated)
.then((rows) => rows.map(serialize));
.then((rows) => rows.map((r) => serialize(r.box, Machine.gpuName(r.gpus))));
});
});
@@ -160,7 +167,7 @@ export namespace Box {
.from(BoxTable)
.where(and(eq(BoxTable.machineId, machineId), isNull(BoxTable.timeDeleted)))
.orderBy(BoxTable.timeCreated)
.then((rows) => rows.map(serialize));
.then((rows) => rows.map((row) => serialize(row)));
});
});
@@ -378,7 +385,7 @@ export namespace Box {
}
);
export function serialize(input: typeof BoxTable.$inferSelect): z.infer<typeof Info> {
export function serialize(input: typeof BoxTable.$inferSelect, gpu: string | null = null): z.infer<typeof Info> {
return {
id: input.id,
userId: input.userId,
@@ -387,7 +394,8 @@ export namespace Box {
tier: input.tier as Info['tier'],
state: input.state as Info['state'],
stopReason: input.stopReason,
stopClean: input.stopClean
stopClean: input.stopClean,
gpu
};
}
}
+4 -2
View File
@@ -161,7 +161,8 @@ export namespace Examples {
tier: 'sm' as const,
state: 'created' as const,
stopReason: null,
stopClean: null
stopClean: null,
gpu: 'NVIDIA GeForce RTX 3060'
};
export const Session = {
@@ -188,7 +189,8 @@ export namespace Examples {
totalBytes: 5000000000,
timeStarted: '2026-07-28T12:00:00.000Z',
timeCompleted: null,
errorMessage: null
errorMessage: null,
gpu: 'NVIDIA GeForce RTX 3060'
};
export const WaitlistEntry = {
+5
View File
@@ -59,6 +59,11 @@ export namespace GameDownload {
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: Examples.GameDownload.errorMessage
}),
gpu: z.string().nullable().optional().meta({
description:
'The card on the host doing the download, as the host reports it. Set where the caller is shown a place, null where it is not known',
example: Examples.GameDownload.gpu
})
})
.meta({
+9
View File
@@ -59,6 +59,15 @@ export namespace Machine {
export type Gpu = z.infer<typeof Gpu>;
/**
* What a person calls a host: its first card's model, as the host reported
* it. Null until a report names one — never a machine id, which nobody
* recognises ref(d-0081), and never a guess.
*/
export function gpuName(gpus: readonly { model: string }[] | null | undefined): string | null {
return gpus?.[0]?.model ?? null;
}
export const Info = z
.object({
id: z.string().meta({
+44
View File
@@ -1,6 +1,9 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Box } from '../box/index.js';
import { Fixtures } from '../db/fixtures.js';
import { GameDownload } from '../game/download.js';
import { Machine } from '../machine/index.js';
import { testDb } from '../db/test.js';
import { Game } from '../game/index.js';
import { Identifier } from '../id.js';
@@ -28,7 +31,12 @@ async function own(userId: string, gameId: string) {
}
afterAll(async () => {
if (createdGameIds.length > 0) {
await sql`delete from "game_download" where game_id in ${sql(createdGameIds)}`;
}
if (createdUserIds.length > 0) {
// box→machine is `restrict`: boxes go before the users their machines cascade from.
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user_library" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
}
@@ -63,3 +71,39 @@ describe('Library.prune', () => {
expect((await Library.listByUser(owner.userId)).length).toBe(1);
});
});
describe('Library.listByUserWithGames', () => {
test("shows a download only from a host the person's box is on, named by its card", async () => {
const me = await Fixtures.owner('library-mine');
const them = await Fixtures.owner('library-theirs');
createdUserIds.push(me.userId, them.userId);
const game = await newGame(9_100_004);
await own(me.userId, game);
const mine = await Fixtures.machine(me, 'library-mine-host');
const theirs = await Fixtures.machine(them, 'library-theirs-host');
await Machine.setGpus({ id: mine, gpus: [{ model: 'NVIDIA GeForce RTX 3060', pciId: '10de:2504' }] });
await Box.create({ id: Identifier.ascending('box'), userId: me.userId, machineId: mine, label: 'mine', tier: 'sm' });
// Someone else's host has it, and is the most recent: still not shown.
await GameDownload.upsertState({ hostId: mine, gameId: game, status: 'downloading' });
await GameDownload.upsertState({ hostId: theirs, gameId: game, status: 'ready' });
const [entry] = await Library.listByUserWithGames(me.userId);
expect(entry?.download?.hostId).toBe(mine);
expect(entry?.download?.status).toBe('downloading');
expect(entry?.download?.gpu).toBe('NVIDIA GeForce RTX 3060');
});
test('with no box, no download is shown at all', async () => {
const me = await Fixtures.owner('library-boxless');
const them = await Fixtures.owner('library-boxless-other');
createdUserIds.push(me.userId, them.userId);
const game = await newGame(9_100_005);
await own(me.userId, game);
await GameDownload.upsertState({ hostId: await Fixtures.machine(them), gameId: game, status: 'ready' });
const [entry] = await Library.listByUserWithGames(me.userId);
expect(entry?.download).toBeNull();
});
});
+20 -2
View File
@@ -8,6 +8,9 @@ import { GameDownload } from '../game/download.js';
import { GameDownloadTable } from '../game/download.sql.js';
import { GameTable } from '../game/game.sql.js';
import { Game } from '../game/index.js';
import { BoxTable } from '../box/box.sql.js';
import { Machine } from '../machine/index.js';
import { MachineTable } from '../machine/machine.sql.js';
import { UserLibraryTable } from './library.sql.js';
export namespace Library {
@@ -147,14 +150,27 @@ export namespace Library {
const gameIds = [
...new Set(rows.filter((row) => row.game !== null).map((row) => row.library.gameId))
];
// Only the hosts this person's boxes are on. A download elsewhere is
// someone else's machine: its id and its progress are not theirs to
// see, and it is not where their game would run ref(d-0081).
const hosts = await tx
.selectDistinct({ id: MachineTable.id, gpus: MachineTable.gpus })
.from(BoxTable)
.innerJoin(MachineTable, eq(MachineTable.id, BoxTable.machineId))
.where(and(eq(BoxTable.userId, userId), isNull(BoxTable.timeDeleted)));
const gpuByHost = new Map(hosts.map((h) => [h.id, Machine.gpuName(h.gpus)]));
const downloadRows =
gameIds.length > 0
gameIds.length > 0 && hosts.length > 0
? await tx
.select()
.from(GameDownloadTable)
.where(
and(
inArray(GameDownloadTable.gameId, gameIds),
inArray(
GameDownloadTable.hostId,
hosts.map((h) => h.id)
),
isNull(GameDownloadTable.timeDeleted)
)
)
@@ -180,7 +196,9 @@ export namespace Library {
playtime2w: row.library.playtime2w,
playtimeForever: row.library.playtimeForever,
lastPlayed: row.library.lastPlayed?.toISOString() ?? null,
download: download ? GameDownload.serialize(download) : null
download: download
? { ...GameDownload.serialize(download), gpu: gpuByHost.get(download.hostId) ?? null }
: null
};
});
});