mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
207
apps/api/app/routes/access-token.ts
Normal file
207
apps/api/app/routes/access-token.ts
Normal 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
614
apps/api/app/routes/game.ts
Normal 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);
|
||||
}
|
||||
);
|
||||
}
|
||||
19
apps/api/app/routes/index.ts
Normal file
19
apps/api/app/routes/index.ts
Normal 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' });
|
||||
});
|
||||
}
|
||||
211
apps/api/app/routes/library.ts
Normal file
211
apps/api/app/routes/library.ts
Normal 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)
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
231
apps/api/app/routes/machine.ts
Normal file
231
apps/api/app/routes/machine.ts
Normal 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) });
|
||||
}
|
||||
);
|
||||
}
|
||||
148
apps/api/app/routes/pairing-code.ts
Normal file
148
apps/api/app/routes/pairing-code.ts
Normal 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 } });
|
||||
}
|
||||
);
|
||||
}
|
||||
86
apps/api/app/routes/steam.ts
Normal file
86
apps/api/app/routes/steam.ts
Normal 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
104
apps/api/app/routes/user.ts
Normal 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
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user