Files
netris-nestri/apps/api/app/routes/library.ts
Wanjohi 40b4270161 refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only
credential several routes accepted, and it had no caller left: the device
pairing it existed for is on hold, and nothing in this tree or any client
sent it. What remained was a key that bypassed authentication entirely,
required to boot, and checked by nobody.

Every route behind it had a better answer available:

- Library and game sync move to host credentials. Both took a `userId` in
  the body, which meant one secret could write into anybody's library. A
  host now says which of its enrolled users a batch is for, and that claim
  is checked against the Steam sign-ins it actually holds — one box carries
  several people's accounts, so the pair is the unit.
- Download-state reporting narrows to hosts alone, and the body that could
  name a different host is gone. Which host is reporting comes from its own
  credentials, and a body that still names one is refused rather than
  ignored.
- Linking a Steam account is always for the caller.
- Creating a game by hand is deleted; syncing already upserts the catalogue.
- Reading the waitlist is deleted. Every address on it belongs to someone
  who has not agreed to anything, and answering it over HTTP made that list
  something a leaked key could drain.
- The pairing-code routes are deleted with the flow they served. The domain
  module and its table stay, so returning to it is a route file rather than
  a migration.

Nothing in the API now accepts a credential that stands for more than one
caller: every request resolves to a specific user or a specific host, which
is what lets a route say "the caller's own library" and mean it.

BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and
`ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and
the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync`
now require host credentials and take `userId` in the body; `POST /steam/link`
no longer accepts `userId`; `POST /games/download-state` no longer accepts
`hostId`.
2026-09-18 22:59:06 +03:00

213 lines
5.8 KiB
TypeScript

import { Actor } from '@nestri/core/actor';
import { Examples } from '@nestri/core/examples';
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 { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
export namespace LibraryApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['Library'],
summary: "List the user's Steam library",
description:
"Returns all games in the authenticated user's library with playtime info and shared per-host download states.",
responses: {
200: {
content: {
'application/json': {
schema: Result(
z
.array(
z.object({
id: Library.Info.shape.id,
game: Game.Info,
playtime2w: Library.Info.shape.playtime2w,
playtimeForever: Library.Info.shape.playtimeForever,
lastPlayed: Library.Info.shape.lastPlayed,
download: GameDownload.Info.nullable()
})
)
.meta({
description: 'Library entries with game data',
example: [Examples.Library]
})
)
}
},
description: 'Library entries'
},
400: ErrorResponses[400],
401: ErrorResponses[401]
}
}),
async (c) => {
const data = await Library.listByUserWithGames(Actor.userID);
return c.json({ data });
}
)
.post(
'/sync',
machineOnly,
describeRoute({
tags: ['Library'],
summary: "Sync a user's Steam library",
description:
'Batch upsert games and library entries from Steam owned games data. The library synced is the caller\u2019s own; there is no field for naming another user.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
gamesSynced: z.number(),
libraryEntries: 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().meta({
description: 'Which of the host\u2019s enrolled users this library belongs to',
example: Examples.User.id
}),
games: z
.array(
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
}),
playtimeForever: z.number().int().optional().meta({
description: 'Total playtime in minutes',
example: Examples.Library.playtimeForever
}),
playtime2w: z.number().int().optional().meta({
description: 'Playtime in last 2 weeks in minutes',
example: Examples.Library.playtime2w
}),
rtimeLastPlayed: z.number().int().optional().meta({
description: 'Last played unix timestamp',
example: 1_700_000_000
})
})
)
.meta({
description: 'Games to sync',
example: [Examples.Game]
})
})
),
async (c) => {
const { games } = c.req.valid('json');
const userId = await enrolledUser(c.req.valid('json').userId);
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
const failedSteamIDs = new Set<number>();
const gamePromises = [];
const gameIds = []; // Storing generated IDs to use in the next step
// 1. Queue Games
for (const g of games) {
const existing = existingByAppId.get(g.steamAppId);
const gameId = existing?.id ?? Identifier.ascending('game');
gameIds.push(gameId); // Aligns with games array index
const slug =
g.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
gamePromises.push(
Game.upsert({
id: gameId,
steamAppId: g.steamAppId,
slug,
name: g.name
})
);
}
const gameResults = await Promise.allSettled(gamePromises);
let gamesSynced = 0;
const libraryPromises = [];
const librarySteamIds = []; // To track which promise belongs to which app
// 2. Evaluate Games & Queue Libraries for Successes
for (let i = 0; i < gameResults.length; i++) {
const g = games[i];
if (gameResults[i].status === 'rejected') {
failedSteamIDs.add(g.steamAppId);
continue; // Skip queuing library upsert if the game failed
}
gamesSynced++;
const entryId = Identifier.ascending('userLibrary');
const lastPlayed = g.rtimeLastPlayed
? new Date(g.rtimeLastPlayed * 1000).toISOString()
: null;
libraryPromises.push(
Library.upsert({
id: entryId,
userId,
gameId: gameIds[i],
playtime2w: g.playtime2w ?? null,
playtimeForever: g.playtimeForever ?? null,
lastPlayed
})
);
librarySteamIds.push(g.steamAppId);
}
// 3. Execute Libraries
const libraryResults = await Promise.allSettled(libraryPromises);
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,
failedEntries: Array.from(failedSteamIDs)
}
});
}
);
}