feat: Sync to OSS repo

This commit is contained in:
Wanjohi
2026-08-06 22:13:51 +03:00
parent 46d2a56180
commit 3faac3008f
144 changed files with 27561 additions and 0 deletions

View File

@@ -0,0 +1,207 @@
import { AccessToken } from '@nestri/core/access-token/index';
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Identifier } from '@nestri/core/id';
import { Member } from '@nestri/core/team/member';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
/**
* Personal access tokens.
*
* The credential for anything that is not a browser: a nessh box registering
* itself, or a script driving the API. A session JWT cannot do this job — it
* is short-lived and cannot be revoked without rotating signing keys for
* everyone, which is wrong for something that sits in a config file for
* months.
*
* Minting requires a *user session*, deliberately. Allowing the admin token to
* mint one for an arbitrary user would turn a credential that can read and
* write all API data into one that can *become* any user, and that boundary is
* the reason `Actor.userID` refuses admin at all.
*/
/**
* Decide what a new token is scoped to.
*
* Team scope is the default, because a box or a script is nearly always doing
* team work and a user-scoped token silently cannot see any of it. But the
* default only applies when it is *unambiguous*: with several teams, guessing
* would hand out a token reaching resources the caller did not have in mind.
*
* Note team scope is broader than user scope, never narrower — hence `null` as
* an explicit way to ask for the narrow one. It still cannot exceed what the
* user themselves may do: the grant is re-checked against live membership on
* every request, with their own role.
*
* @param requested `undefined` to take the default, `null` to force user
* scope, or a team id to name one.
*/
async function resolveTeamScope(requested: string | null | undefined): Promise<string | null> {
if (requested === null) {
return null;
}
if (requested !== undefined) {
// Verified here rather than trusted from the body: a token is only ever
// as scoped as what was checked at the moment it was made.
const membership = await Member.findByTeamAndUser({
teamId: requested,
userId: Actor.userID
});
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
return requested;
}
const memberships = await Member.listByUser(Actor.userID);
if (memberships.length === 0) {
return null;
}
if (memberships.length > 1) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'You belong to several teams — name the one this token is for, or pass teamId: null to scope it to yourself',
'teamId'
);
}
return memberships[0]!.teamId;
}
export namespace AccessTokenApi {
export const route = new Hono()
.post(
'/',
notPublic,
describeRoute({
tags: ['AccessToken'],
summary: 'Create a personal access token',
description:
'Mint a long-lived, revocable token for the calling user. The token is returned once and never again — only its digest is stored.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
id: z.string().meta({ example: Examples.AccessToken.id }),
token: z.string().meta({
description: 'Shown once. Store it now; it cannot be retrieved.'
})
})
)
}
},
description: 'A freshly minted token'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
name: z.string().min(1).max(64).meta({
description: 'What this token is for, so it can be recognised in a list',
example: Examples.AccessToken.name
}),
teamId: z.string().nullable().optional().meta({
description:
'Team to scope the token to. Omit to default to your team when you have exactly one; pass null to force a token scoped to you alone.'
}),
expiresInDays: z.number().int().min(1).max(365).optional().meta({
description: 'Optional lifetime. Omit for a token that does not expire.'
})
})
),
async (c) => {
const { name, teamId, expiresInDays } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Creating an access token requires a user session'
);
}
const scopedTeamId = await resolveTeamScope(teamId);
const created = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: Actor.userID,
teamId: scopedTeamId,
name,
expiresInDays
});
return c.json({ data: { id: created.id, token: created.token } });
}
)
.get(
'/',
notPublic,
describeRoute({
tags: ['AccessToken'],
summary: 'List your access tokens',
description: 'Returns metadata only. The token values are not stored and cannot be shown.',
responses: {
200: {
content: { 'application/json': { schema: Result(z.array(AccessToken.Info)) } },
description: 'Tokens belonging to the caller'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
async (c) => {
return c.json({ data: await AccessToken.listByOwner(Actor.userID) });
}
)
.delete(
'/:id',
notPublic,
describeRoute({
tags: ['AccessToken'],
summary: 'Revoke an access token',
description:
'Revokes immediately. Revocation is the reason these exist rather than long-lived JWTs.',
responses: {
200: {
content: { 'application/json': { schema: Result(z.object({ id: z.string() })) } },
description: 'The token no longer works'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
async (c) => {
// Scoped to the owner in the query itself, so revoking someone
// else's token is a 404 rather than a permission check that
// could be forgotten.
const revoked = await AccessToken.revoke({
id: c.req.param('id'),
ownerUserId: Actor.userID
});
if (!revoked) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such token, or it is not yours'
);
}
return c.json({ data: { id: revoked.id } });
}
);
}

614
apps/api/app/routes/game.ts Normal file
View File

@@ -0,0 +1,614 @@
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 { GameDownloadStatus } from '@nestri/core/game/download.sql';
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(),
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()
.use(notPublic)
.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',
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,
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',
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',
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(GameDownloadStatus.enumValues).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(
'/',
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);
}
);
}

View File

@@ -0,0 +1,19 @@
import { Database } from '@nestri/core/db/index';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Hono } from 'hono';
export namespace IndexApi {
export const route = new Hono()
.get('/', (c) => c.text('Hello World!'))
.get('/health', async (c) => {
const ok = await Database.ping();
if (!ok) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Database connection failed'
);
}
return c.json({ status: 'ok' });
});
}

View File

@@ -0,0 +1,211 @@
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 { ErrorResponses, adminOnly, 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',
adminOnly,
describeRoute({
tags: ['Library'],
summary: "Sync a user's Steam library",
description:
'Batch upsert games and library entries for a user from Steam owned games data. Admin only.',
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: 'The user to sync library for',
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 { userId, games } = 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 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)
}
});
}
);
}

View File

@@ -0,0 +1,231 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { Member } from '@nestri/core/team/member';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
/**
* Host registration.
*
* A box does not get to say who it is. It registers once against its owner's
* session, is handed an id and a secret, and authenticates as itself from then
* on — so `hostId` on a download report is something the API assigned rather
* than a free-form string any holder of a shared secret could invent.
*/
export namespace MachineApi {
export const route = new Hono()
.post(
'/register',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Register a nessh host',
description:
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
machineId: z.string().meta({ example: Examples.Machine.id }),
secret: z.string().meta({
description: 'Shown once. Store it on the box; it cannot be retrieved.'
})
})
)
}
},
description: 'The box is registered'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
label: z.string().min(1).max(64).meta({
description: 'Human-readable name for the box',
example: Examples.Machine.label
}),
teamId: z.string().optional().meta({
description: 'Register the box into a team rather than to the user alone'
})
})
),
async (c) => {
const { label, teamId } = c.req.valid('json');
// `notPublic` also admits admin, which has no user to own the box.
// Registering is an act of ownership, so it needs a real one.
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Registering a machine requires a user session'
);
}
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: Actor.userID,
teamId: teamId ?? (actor.type === 'member' ? actor.properties.teamID : null),
label
});
return c.json({ data: { machineId: registered.id, secret: registered.secret } });
}
)
.patch(
'/:id',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Move a box into a team, or out of one',
description:
'Scope a machine you own to a team you belong to, or pass teamId: null to make it yours alone again. This is not ownership transfer — the owner does not change.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Info) } },
description: 'The machine, rescoped'
},
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z.object({
teamId: z.string().nullable().meta({
description: 'Team to scope the box to, or null to scope it to you alone'
})
})
),
async (c) => {
const { teamId } = c.req.valid('json');
const actor = Actor.use();
if (actor.type !== 'user' && actor.type !== 'member') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Rescoping a machine requires a user session'
);
}
// Verified before the write. `setTeam` scopes to the owner but
// knows nothing about who belongs to the target team, so this is
// the only place that check exists.
if (teamId) {
const membership = await Member.findByTeamAndUser({
teamId,
userId: Actor.userID
});
if (!membership) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You are not a member of that team'
);
}
}
const machine = await Machine.setTeam({
id: c.req.param('id'),
ownerUserId: Actor.userID,
teamId
});
if (!machine) {
// Owner-scoped in the query, so someone else's machine is a
// 404 rather than a 403 — no way to probe for ids.
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'No such machine, or it is not yours'
);
}
return c.json({ data: machine });
}
)
.get(
'/entitlement',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Ask whether a user may use this box',
description:
'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Entitlement) } },
description: 'Whether the user may use this machine, and why'
},
403: ErrorResponses[403]
}
}),
validator('query', z.object({ userId: z.string().min(1) })),
async (c) => {
const { userId } = c.req.valid('query');
return c.json({
data: await Machine.entitlement({ machineId: Actor.machineID, userId })
});
}
)
.get(
'/me',
machineOnly,
describeRoute({
tags: ['Machine'],
summary: 'Describe the calling machine',
description:
'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.',
responses: {
200: {
content: { 'application/json': { schema: Result(Machine.Info) } },
description: 'The calling machine'
},
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
async (c) => {
const machine = await Machine.fromID(Actor.machineID);
if (!machine) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'This machine no longer exists'
);
}
return c.json({ data: machine });
}
)
.get(
'/',
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'List your registered hosts',
responses: {
200: {
content: { 'application/json': { schema: Result(z.array(Machine.Info)) } },
description: 'Machines owned by the caller'
},
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
async (c) => {
return c.json({ data: await Machine.listByOwner(Actor.userID) });
}
);
}

View File

@@ -0,0 +1,148 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Identifier } from '@nestri/core/id';
import { PairingCode } from '@nestri/core/pairing-code/index';
import { Fingerprint } from '@nestri/core/user/fingerprint';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../utils';
/**
* Device enrolment.
*
* A pairing code says "this SSH key is also me". It is deliberately not the
* same thing as an invite, which says "you may use my box" — same shape of
* secret, completely different authority, and merging them would let one be
* redeemed for the other.
*
* Generating requires an authenticated session; claiming is done by nessh on
* behalf of a device that has no identity yet, so it authenticates with the
* shared admin token instead.
*/
export namespace PairingCodeApi {
export const route = new Hono()
.post(
'/',
notPublic,
describeRoute({
tags: ['PairingCode'],
summary: 'Generate a pairing code',
description:
'Create a short-lived, single-use code that enrols another SSH key onto the current user.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
code: z.string().meta({ example: Examples.PairingCode.code }),
expiresInMinutes: z.number()
})
)
}
},
description: 'A freshly generated pairing code'
},
401: ErrorResponses[401],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
ttlMinutes: z.number().int().min(1).max(60).default(10).meta({
description: 'How long the code stays valid. Short by design.'
})
})
),
async (c) => {
const { ttlMinutes } = c.req.valid('json');
const code = await PairingCode.create({
id: Identifier.ascending('pairingCode'),
targetUserId: Actor.userID,
ttlMinutes
});
return c.json({ data: { code, expiresInMinutes: ttlMinutes } });
}
)
.post(
'/claim',
adminOnly,
describeRoute({
tags: ['PairingCode'],
summary: 'Claim a pairing code for an SSH key',
description:
'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
userId: z.string().meta({ example: Examples.PairingCode.targetUserId })
})
)
}
},
description: 'The fingerprint now belongs to this user'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z.object({
code: z.string().min(1).meta({ example: Examples.PairingCode.code }),
fingerprint: z.string().min(1).meta({
description: 'SSH public key fingerprint of the device being enrolled'
})
})
),
async (c) => {
const { code, fingerprint } = c.req.valid('json');
// Refuse before claiming: a code is single-use, so burning one on
// a device that cannot be enrolled would strand the user.
const existing = await Fingerprint.findByFingerprint(fingerprint);
const claimed = await PairingCode.claim({ code, fingerprint });
if (!claimed) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'That pairing code is unknown, already used, or expired'
);
}
if (existing && existing.userId !== claimed.targetUserId) {
// Handing a device between accounts is a different operation
// with its own consequences for anything already linked to it;
// `Steam.resolveSshIdentity` refuses the same case.
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That SSH key is already enrolled to another user'
);
}
if (existing) {
await Fingerprint.touchLastSeen(existing.id);
} else {
await Fingerprint.create({
id: Identifier.ascending('userFingerprint'),
userId: claimed.targetUserId,
fingerprint,
name: null
});
}
return c.json({ data: { userId: claimed.targetUserId } });
}
);
}

View File

@@ -0,0 +1,86 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Steam } from '@nestri/core/steam/index';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
export namespace SteamApi {
export const route = new Hono().use(notPublic).post(
'/link',
describeRoute({
tags: ['Steam'],
summary: 'Link a Steam account',
description: 'Link a Steam account to a user (admin) or yourself (user)',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
linkedAccountId: z.string().meta({
description: 'The ID of the linked account',
example: Examples.LinkedAccount.id
}),
steamId: z.string().meta({
description: 'The Steam ID that was linked',
example: '76561197960287930'
})
})
)
}
},
description: 'Steam account linked'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
steamId: z.string().min(1).meta({
description: 'Steam ID to link',
example: '76561197960287930'
}),
userId: z.string().optional().meta({
description: 'User ID to link to (admin only; omitted when linking your own account)',
example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX'
}),
profile: z
.record(z.string(), z.unknown())
.optional()
.meta({
description: 'Steam profile data',
example: { personaname: 'Player', avatarfull: 'https://...' }
})
})
),
async (c) => {
const body = c.req.valid('json');
const actor = Actor.use();
if (body.userId && actor.type !== 'admin') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Only admin can link a Steam account for another user'
);
}
const linkedAccountID = await Steam.link({
steamId: body.steamId,
profile: body.profile,
userId: body.userId
});
return c.json({
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }
});
}
);
}

104
apps/api/app/routes/user.ts Normal file
View File

@@ -0,0 +1,104 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { User } from '@nestri/core/user/index';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result, validator } from '../utils';
export namespace UserApi {
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['User'],
summary: 'Get current user',
description: "Get the authenticated user's profile",
responses: {
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'Current user profile',
example: Examples.User
})
)
}
},
description: 'Current user'
},
400: ErrorResponses[400],
404: ErrorResponses[404],
429: ErrorResponses[429]
}
}),
async (c) => {
const user = await User.fromID(Actor.userID);
if (!user) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'Authenticated user not found'
);
}
return c.json({ data: user });
}
)
.get(
'/:id',
describeRoute({
tags: ['User'],
summary: 'Get user',
description: 'Get a user by their ID',
responses: {
200: {
content: {
'application/json': {
schema: Result(
User.Info.meta({
description: 'User details',
example: Examples.User
})
)
}
},
description: 'User details'
},
400: ErrorResponses[400],
429: ErrorResponses[429]
}
}),
validator(
'param',
z.object({
id: z.string().meta({
description: 'ID of the user to get',
example: Examples.User.id
})
})
),
async (c) => {
const userID = c.req.valid('param').id;
const user = await User.fromID(userID);
if (!user) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
`User ${userID} does not exist`
);
}
return c.json({
data: user
});
}
);
}