mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
Six defects found by running the thing rather than reading it. The previous change was verified by bundling, by tests, and by the container images — none of which start a Worker, so every one of these was invisible. **`bun dev` did not start.** It ran one multi-config process, which does not connect a service binding between the workers it loads; the API reported `AUTH [not connected]` and could not verify a token. It is two processes now, which is what the dev registry connects, and the second is backgrounded with the first killed on exit so stopping the pair stops both. **Neither server could bind.** Wrangler resolves `localhost` and takes `::1` first; a host with no IPv6 address on its loopback dies with a bind error from inside the runtime that names neither the app nor the port. `dev.ip` is pinned to `127.0.0.1`, and `inspector_port` is now distinct per app — it is not derived from the port above, so the second server to start died on an address already in use. **The API worker failed to evaluate.** A specifier ending in `.sql` is claimed by the bundler as a module of its own, so the schema file was emitted verbatim beside the bundle and the runtime threw on an export it could not find. The route was reaching past the domain module into the schema to spell a status; it now asks the domain module, which is the rule everywhere else here and happens to also avoid the hazard. **Signing in failed on the second request that touched the database.** A pool is cached per connection string, and on a Worker an I/O object created while handling one request may not be touched while handling another. The first request always succeeded, which is why it went unnoticed — a sign-in is several. The cache is now kept only where a process outlives its requests, which is the case it was added for. **The images named a base that podman will not resolve.** A short name needs a registry; the database service alongside them already spelled one. **Compose pinned container names.** The name is not scoped to the project, so a second checkout got the same three, and `down` in one stopped the other's containers. This is not hypothetical — it stopped a running development database while this was being tested. Verified by signing in end to end against both dev servers: a code requested over HTTP, read from the issuer's log, redeemed, exchanged for tokens, and presented to the API, which resolved it to the account the sign-in had just created.
619 lines
17 KiB
TypeScript
619 lines
17 KiB
TypeScript
import { Actor } from '@nestri/core/actor';
|
||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||
import { Examples } from '@nestri/core/examples';
|
||
import { Depot } from '@nestri/core/game/depot';
|
||
import { GameDownload } from '@nestri/core/game/download';
|
||
import { Game } from '@nestri/core/game/index';
|
||
import { Identifier } from '@nestri/core/id';
|
||
import { Library } from '@nestri/core/user/library';
|
||
import { Hono } from 'hono';
|
||
import { describeRoute } from 'hono-openapi';
|
||
import { z } from 'zod';
|
||
|
||
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
|
||
|
||
const SyncGameSchema = z.object({
|
||
steamAppId: z.number().int(),
|
||
name: z.string(),
|
||
aliases: z.string().optional(),
|
||
type: z.string().optional(),
|
||
clientIcon: z.string().optional(),
|
||
icon: z.string().optional(),
|
||
shortDescription: z.string().optional(),
|
||
description: z.string().optional(),
|
||
developers: z.array(z.string()).optional(),
|
||
publishers: z.array(z.string()).optional(),
|
||
primaryGenre: z.string().optional(),
|
||
genres: z.array(z.string()).optional(),
|
||
categories: z.array(z.string()).optional(),
|
||
oslist: z.array(z.string()).optional(),
|
||
sizeDownload: z.number().optional(),
|
||
sizeOnDisk: z.number().optional(),
|
||
controllerSupport: z.string().optional(),
|
||
steamDeckCompat: z.string().optional(),
|
||
reviewScorePercent: z.number().int().optional(),
|
||
reviewCount: z.number().int().optional(),
|
||
metacriticScore: z.number().int().optional(),
|
||
steamChangeNumber: z.number().int().optional(),
|
||
publicBuildId: z.number().int().optional(),
|
||
releaseDate: z.string().optional(),
|
||
enriched: z.boolean().default(false),
|
||
depots: z
|
||
.array(
|
||
z.object({
|
||
depotId: z.number().int(),
|
||
branch: z.string().default('public'),
|
||
steamManifestId: z.string().optional(),
|
||
steamBuildId: z.number().int().optional(),
|
||
sizeDownload: z.number().optional(),
|
||
sizeOnDisk: z.number().optional(),
|
||
oslist: z.string().optional()
|
||
})
|
||
)
|
||
.optional()
|
||
});
|
||
|
||
const SyncLibrarySchema = z.object({
|
||
steamAppId: z.number().int(),
|
||
playtimeForeverMin: z.number().int().optional(),
|
||
playtime2WeeksMin: z.number().int().optional(),
|
||
lastPlayed: z.string().optional()
|
||
});
|
||
|
||
export namespace GameApi {
|
||
export const route = new Hono()
|
||
.get(
|
||
'/',
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'List games',
|
||
description: 'List all games in the catalog, with optional search',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.array(Game.Info).meta({
|
||
description: 'All games matching the optional query',
|
||
example: [Examples.Game]
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'List of games'
|
||
},
|
||
400: ErrorResponses[400],
|
||
401: ErrorResponses[401]
|
||
}
|
||
}),
|
||
validator(
|
||
'query',
|
||
z.object({
|
||
q: z.string().optional().meta({
|
||
description: 'Search query to filter games by name',
|
||
example: 'Counter-Strike'
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { q } = c.req.valid('query');
|
||
const games = await Game.searchByName(q ?? '');
|
||
return c.json({ data: games });
|
||
}
|
||
)
|
||
.get(
|
||
'/:id',
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'Get a game by ID',
|
||
description: 'Retrieve a single game from the catalog',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
Game.Info.meta({
|
||
description: 'The game',
|
||
example: Examples.Game
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'The game'
|
||
},
|
||
401: ErrorResponses[401],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
validator(
|
||
'param',
|
||
z.object({
|
||
id: z.string().meta({
|
||
description: 'ID of the game',
|
||
example: Examples.Game.id
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { id } = c.req.valid('param');
|
||
const game = await Game.fromID(id);
|
||
if (!game) {
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
`Game ${id} not found`
|
||
);
|
||
}
|
||
return c.json({ data: game });
|
||
}
|
||
)
|
||
.post(
|
||
'/sync',
|
||
notPublic,
|
||
adminOnly,
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'Batch sync games, library entries, and depots',
|
||
description:
|
||
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.object({
|
||
gamesSynced: z.number(),
|
||
libraryEntries: z.number(),
|
||
depotEntries: z.number(),
|
||
failedEntries: z.array(z.number())
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'Sync result'
|
||
},
|
||
400: ErrorResponses[400],
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z.object({
|
||
userId: z.string(),
|
||
games: z.array(SyncGameSchema).default([]),
|
||
library: z.array(SyncLibrarySchema).default([])
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { userId, games, library } = c.req.valid('json');
|
||
|
||
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||
const gameIdByAppId = new Map<number, string>();
|
||
|
||
const failedSteamIDs = new Set<number>();
|
||
const gamePromises = [];
|
||
|
||
// 1. Queue Games
|
||
for (const g of games) {
|
||
const existing = existingByAppId.get(g.steamAppId);
|
||
const gameId = existing?.id ?? Identifier.ascending('game');
|
||
|
||
gameIdByAppId.set(g.steamAppId, gameId);
|
||
|
||
const slug =
|
||
g.name
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
|
||
const now = new Date().toISOString();
|
||
const { enriched } = g;
|
||
|
||
gamePromises.push(
|
||
Game.upsert({
|
||
id: gameId,
|
||
steamAppId: g.steamAppId,
|
||
slug,
|
||
name: g.name,
|
||
aliases: g.aliases ?? null,
|
||
type: g.type ?? null,
|
||
clientIcon: g.clientIcon ?? null,
|
||
icon: g.icon ?? null,
|
||
shortDescription: g.shortDescription ?? null,
|
||
description: g.description ?? null,
|
||
developers: g.developers ?? null,
|
||
publishers: g.publishers ?? null,
|
||
primaryGenre: g.primaryGenre ?? null,
|
||
genres: g.genres ?? null,
|
||
categories: g.categories ?? null,
|
||
oslist: g.oslist ?? null,
|
||
sizeDownload: g.sizeDownload ?? null,
|
||
sizeOnDisk: g.sizeOnDisk ?? null,
|
||
controllerSupport: g.controllerSupport ?? null,
|
||
steamDeckCompat: g.steamDeckCompat ?? null,
|
||
reviewScorePercent: g.reviewScorePercent ?? null,
|
||
reviewCount: g.reviewCount ?? null,
|
||
metacriticScore: g.metacriticScore ?? null,
|
||
steamChangeNumber: g.steamChangeNumber ?? null,
|
||
publicBuildId: g.publicBuildId ?? null,
|
||
releaseDate: g.releaseDate ?? null,
|
||
timeEnriched: enriched ? now : (existing?.timeEnriched?.toISOString() ?? null)
|
||
})
|
||
);
|
||
}
|
||
|
||
const gameResults = await Promise.allSettled(gamePromises);
|
||
let gamesSynced = 0;
|
||
|
||
const depotPromises = [];
|
||
const depotSteamIds = [];
|
||
const libraryPromises = [];
|
||
const librarySteamIds = [];
|
||
|
||
// 2. Evaluate Games & Queue Dependents
|
||
for (let i = 0; i < gameResults.length; i++) {
|
||
const g = games[i];
|
||
|
||
if (gameResults[i].status === 'rejected') {
|
||
failedSteamIDs.add(g.steamAppId);
|
||
// Drop it from the map so the Library loop below ignores it
|
||
gameIdByAppId.delete(g.steamAppId);
|
||
continue;
|
||
}
|
||
|
||
gamesSynced++;
|
||
|
||
if (g.depots) {
|
||
const gameId = gameIdByAppId.get(g.steamAppId)!;
|
||
for (const d of g.depots) {
|
||
const depotId = Identifier.ascending('gameDepot');
|
||
depotPromises.push(
|
||
Depot.upsert({
|
||
id: depotId,
|
||
gameId: gameId,
|
||
depotId: d.depotId,
|
||
branch: d.branch,
|
||
steamManifestId: d.steamManifestId ?? null,
|
||
steamBuildId: d.steamBuildId ?? null,
|
||
sizeDownload: d.sizeDownload ?? null,
|
||
sizeOnDisk: d.sizeOnDisk ?? null,
|
||
oslist: d.oslist ?? null,
|
||
status: 'pending' as const
|
||
})
|
||
);
|
||
depotSteamIds.push(g.steamAppId);
|
||
}
|
||
}
|
||
}
|
||
|
||
for (const l of library) {
|
||
// This naturally filters out entries for games that failed in step 2
|
||
const gameId = gameIdByAppId.get(l.steamAppId);
|
||
if (!gameId) continue;
|
||
|
||
const entryId = Identifier.ascending('userLibrary');
|
||
libraryPromises.push(
|
||
Library.upsert({
|
||
id: entryId,
|
||
userId,
|
||
gameId,
|
||
playtime2w: l.playtime2WeeksMin ?? null,
|
||
playtimeForever: l.playtimeForeverMin ?? null,
|
||
lastPlayed: l.lastPlayed ?? null
|
||
})
|
||
);
|
||
librarySteamIds.push(l.steamAppId);
|
||
}
|
||
|
||
// 3. Execute Dependents in parallel
|
||
const [depotResults, libraryResults] = await Promise.all([
|
||
Promise.allSettled(depotPromises),
|
||
Promise.allSettled(libraryPromises)
|
||
]);
|
||
|
||
let depotEntries = 0;
|
||
for (let i = 0; i < depotResults.length; i++) {
|
||
if (depotResults[i].status === 'rejected') {
|
||
failedSteamIDs.add(depotSteamIds[i]);
|
||
} else {
|
||
depotEntries++;
|
||
}
|
||
}
|
||
|
||
let libraryEntries = 0;
|
||
for (let i = 0; i < libraryResults.length; i++) {
|
||
if (libraryResults[i].status === 'rejected') {
|
||
failedSteamIDs.add(librarySteamIds[i]);
|
||
} else {
|
||
libraryEntries++;
|
||
}
|
||
}
|
||
|
||
return c.json({
|
||
data: {
|
||
gamesSynced,
|
||
libraryEntries,
|
||
depotEntries,
|
||
failedEntries: Array.from(failedSteamIDs)
|
||
}
|
||
});
|
||
}
|
||
)
|
||
.get(
|
||
'/:id/download-state',
|
||
notPublic,
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'Get download states for a game',
|
||
description:
|
||
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.array(GameDownload.Info).meta({
|
||
description: 'Download states for the game',
|
||
example: [Examples.GameDownload]
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'Download states'
|
||
},
|
||
401: ErrorResponses[401],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
validator(
|
||
'param',
|
||
z.object({
|
||
id: z.string().meta({
|
||
description: 'ID of the game',
|
||
example: Examples.Game.id
|
||
})
|
||
})
|
||
),
|
||
validator(
|
||
'query',
|
||
z.object({
|
||
hostId: z.string().optional().meta({
|
||
description: 'Optional host ID to filter by',
|
||
example: Examples.GameDownload.hostId
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { id } = c.req.valid('param');
|
||
const { hostId } = c.req.valid('query');
|
||
|
||
const game = await Game.fromID(id);
|
||
if (!game) {
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
`Game ${id} not found`
|
||
);
|
||
}
|
||
|
||
const rows = hostId
|
||
? await GameDownload.findByHostAndGame({ hostId, gameId: id }).then((row) =>
|
||
row ? [row] : []
|
||
)
|
||
: await GameDownload.listByGame(id);
|
||
const data = rows.map((row) => GameDownload.serialize(row));
|
||
return c.json({ data });
|
||
}
|
||
)
|
||
.post(
|
||
'/download-state',
|
||
notPublic,
|
||
machineOrAdmin,
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'Report a download state change',
|
||
description:
|
||
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.object({
|
||
downloadId: z.string(),
|
||
download: GameDownload.Info
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'Download state updated'
|
||
},
|
||
400: ErrorResponses[400],
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z.object({
|
||
hostId: z.string().optional().meta({
|
||
description:
|
||
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
|
||
example: Examples.GameDownload.hostId
|
||
}),
|
||
steamAppId: z.number().int().meta({
|
||
description: 'Steam application ID',
|
||
example: Examples.Game.steamAppId
|
||
}),
|
||
status: z.enum(GameDownload.Status).meta({
|
||
description: 'New download status',
|
||
example: Examples.GameDownload.status
|
||
}),
|
||
progressBytes: z.number().int().optional().meta({
|
||
description: 'Bytes downloaded so far',
|
||
example: Examples.GameDownload.progressBytes
|
||
}),
|
||
totalBytes: z.number().int().optional().meta({
|
||
description: 'Total bytes to download',
|
||
example: Examples.GameDownload.totalBytes
|
||
}),
|
||
errorMessage: z.string().nullable().optional().meta({
|
||
description: 'Error message if status is failed',
|
||
example: null
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
|
||
c.req.valid('json');
|
||
|
||
// A machine reports as itself. Taking the id from the body would
|
||
// mean any holder of a shared secret could write download state
|
||
// under any box's id, which is the whole reason boxes register.
|
||
const actor = Actor.use();
|
||
let reportingHostId: string;
|
||
if (actor.type === 'machine') {
|
||
if (hostId && hostId !== actor.properties.machineID) {
|
||
throw new VisibleError(
|
||
'forbidden',
|
||
ErrorCodes.Permission.FORBIDDEN,
|
||
'A machine may only report its own download state'
|
||
);
|
||
}
|
||
reportingHostId = actor.properties.machineID;
|
||
} else {
|
||
if (!hostId) {
|
||
throw new VisibleError(
|
||
'validation',
|
||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||
'hostId is required when reporting on behalf of a host',
|
||
'hostId'
|
||
);
|
||
}
|
||
reportingHostId = hostId;
|
||
}
|
||
|
||
const game = await Game.fromSteamAppID(steamAppId);
|
||
if (!game) {
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
`Game with steamAppId ${steamAppId} not found`
|
||
);
|
||
}
|
||
|
||
const row = await GameDownload.upsertState({
|
||
hostId: reportingHostId,
|
||
gameId: game.id,
|
||
status,
|
||
progressBytes: progressBytes ?? undefined,
|
||
totalBytes: totalBytes ?? undefined,
|
||
errorMessage: errorMessage ?? undefined
|
||
});
|
||
|
||
return c.json({
|
||
data: { downloadId: row.id, download: GameDownload.serialize(row) }
|
||
});
|
||
}
|
||
)
|
||
.post(
|
||
'/',
|
||
notPublic,
|
||
adminOnly,
|
||
describeRoute({
|
||
tags: ['Games'],
|
||
summary: 'Create or update a game',
|
||
description: 'Upsert a game by Steam app ID. Admin only.',
|
||
responses: {
|
||
201: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
Game.Info.meta({
|
||
description: 'The created or updated game',
|
||
example: Examples.Game
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'Game created or updated'
|
||
},
|
||
400: ErrorResponses[400],
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z.object({
|
||
steamAppId: z.number().int().meta({
|
||
description: 'Steam application ID',
|
||
example: Examples.Game.steamAppId
|
||
}),
|
||
name: z.string().meta({
|
||
description: 'Game title',
|
||
example: Examples.Game.name
|
||
}),
|
||
slug: z.string().optional().meta({
|
||
description: 'URL-friendly slug',
|
||
example: Examples.Game.slug
|
||
}),
|
||
type: z.string().nullable().optional().meta({
|
||
description: 'Content type',
|
||
example: Examples.Game.type
|
||
}),
|
||
clientIcon: z.string().nullable().optional().meta({
|
||
description: 'Steam client icon hash (256×256 square)',
|
||
example: Examples.Game.clientIcon
|
||
}),
|
||
icon: z.string().nullable().optional().meta({
|
||
description: 'Steam icon hash (32×32)',
|
||
example: Examples.Game.icon
|
||
}),
|
||
shortDescription: z.string().nullable().optional().meta({
|
||
description: 'Short description',
|
||
example: Examples.Game.shortDescription
|
||
}),
|
||
description: z.string().nullable().optional().meta({
|
||
description: 'Full description',
|
||
example: Examples.Game.description
|
||
}),
|
||
developers: z.array(z.string()).nullable().optional().meta({
|
||
description: 'Game developers',
|
||
example: Examples.Game.developers
|
||
}),
|
||
publishers: z.array(z.string()).nullable().optional().meta({
|
||
description: 'Game publishers',
|
||
example: Examples.Game.publishers
|
||
}),
|
||
genres: z.array(z.string()).nullable().optional().meta({
|
||
description: 'Game genres',
|
||
example: Examples.Game.genres
|
||
}),
|
||
oslist: z.array(z.string()).nullable().optional().meta({
|
||
description: 'Supported OS list',
|
||
example: Examples.Game.oslist
|
||
}),
|
||
releaseDate: z.string().nullable().optional().meta({
|
||
description: 'Release date ISO string',
|
||
example: Examples.Game.releaseDate
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const body = c.req.valid('json');
|
||
const id = Identifier.ascending('game');
|
||
const slug =
|
||
body.slug ??
|
||
body.name
|
||
.toLowerCase()
|
||
.replace(/[^a-z0-9]+/g, '-')
|
||
.replace(/^-|-$/g, '');
|
||
const game = await Game.upsert({ ...body, id, slug });
|
||
return c.json({ data: game[0] }, 201);
|
||
}
|
||
);
|
||
}
|