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

107
apps/api/app/index.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { Api } from '../../../alchemy.run.ts';
import type { InferEnv } from 'alchemy/Cloudflare';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Hono } from 'hono';
import { openAPISpecs } from 'hono-openapi';
import { cors } from 'hono/cors';
import { HTTPException } from 'hono/http-exception';
import { logger } from 'hono/logger';
import { type ContentfulStatusCode } from 'hono/utils/http-status';
import { auth } from './middleware/auth.js';
import { AccessTokenApi } from './routes/access-token.js';
import { GameApi } from './routes/game.js';
import { IndexApi } from './routes/index.js';
import { LibraryApi } from './routes/library.js';
import { MachineApi } from './routes/machine.js';
import { PairingCodeApi } from './routes/pairing-code.js';
import { SteamApi } from './routes/steam.js';
import { UserApi } from './routes/user.js';
export const app = new Hono();
app
.use(logger())
.use(async (c, next) => {
c.header('Cache-Control', 'no-store');
return next();
})
.use(
cors({
origin: () => Env.get().FRONTEND_URL || 'http://localhost:5173',
credentials: true
})
)
.use(auth);
const routes = app
.route('/', IndexApi.route)
.route('/user', UserApi.route)
.route('/steam', SteamApi.route)
.route('/library', LibraryApi.route)
.route('/games', GameApi.route)
.route('/pairing-code', PairingCodeApi.route)
.route('/machine', MachineApi.route)
.route('/access-token', AccessTokenApi.route)
.onError((error, c) => {
if (error instanceof VisibleError) {
// eslint-disable-next-line no-console
console.error('api error:', error);
return c.json(error.toResponse(), error.statusCode() as ContentfulStatusCode);
}
if (error instanceof HTTPException) {
// eslint-disable-next-line no-console
console.error('http error:', error);
return c.json(
{
type: 'validation',
code: ErrorCodes.Validation.INVALID_PARAMETER,
message: 'Invalid request'
},
error.status
);
}
// eslint-disable-next-line no-console
console.error('unhandled error:', error);
return c.json(
{
type: 'internal',
code: ErrorCodes.Server.INTERNAL_ERROR,
message: 'Internal server error'
},
500
);
});
app.get(
'/doc',
openAPISpecs(routes, {
documentation: {
info: {
title: 'Nestri API',
description: 'API',
version: '0.0.1'
},
components: {
securitySchemes: {
Bearer: {
type: 'http',
scheme: 'bearer',
bearerFormat: 'JWT'
}
}
},
security: [{ Bearer: [] }]
}
})
);
export default {
fetch(request: Request, env: InferEnv<typeof Api>, ctx: ExecutionContext) {
Env.init(env as unknown as Record<string, unknown>);
return app.fetch(request, env, ctx);
}
};

View File

@@ -0,0 +1,271 @@
import { createClient } from '@nestri/auth/client';
import { AccessToken } from '@nestri/core/access-token/index';
import { Actor } from '@nestri/core/actor';
import { subjects } from '@nestri/core/auth/subjects';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Machine } from '@nestri/core/machine/index';
import { Member } from '@nestri/core/team/member';
import type { MiddlewareHandler } from 'hono';
/**
* Reaches the auth worker over its service binding.
*
* The origin has to survive. A binding routes by binding rather than by
* hostname, so the host is arbitrary — but `new Request` still demands an
* absolute URL, and stripping down to a bare path threw `Invalid URL` before
* the token was even looked at.
*/
function bindingFetch(env: Record<string, unknown>) {
return (input: RequestInfo | URL, init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
return (env.AUTH as { fetch: typeof fetch }).fetch(new Request(url, init));
};
}
/**
* The issuer must be the auth worker's **public** URL.
*
* `verify` checks a token's `iss` claim against the issuer the client was
* built with, and the auth worker derives what it advertises from the URL it
* was reached on. Tokens are minted through the public URL, so they carry it.
* A placeholder like `https://auth.internal` addresses the binding perfectly
* well — the hostname is ignored there — and then disagrees with every real
* token. Discovery through the binding does not help: it answers with the
* placeholder too, because that is the host it was asked on.
*
* The failure is silent by nature. A rejected claim is reported as `err`,
* which is indistinguishable from an expired or forged token, so the whole
* bearer path returns 401 and looks like ordinary auth working correctly.
* Hence the explicit throw rather than a fallback: a misconfiguration here
* takes down every user session, and it should say so.
*/
function getClient(env: Record<string, unknown>) {
const configured = Env.get().AUTH_ISSUER_URL;
if (!configured) {
throw new Error(
'AUTH_ISSUER_URL is not configured; every bearer token would be rejected as unsigned'
);
}
// The trailing slash matters twice, and both failures are quiet. It is
// appended to build the discovery URL, where `…:1337//.well-known/…` is a
// 404; and it is compared literally against the `iss` claim, which carries
// no trailing slash. A worker URL from the platform arrives with one.
const issuer = configured.replace(/\/+$/, '');
return createClient({
issuer,
clientID: 'api',
fetch: bindingFetch(env)
});
}
export const auth: MiddlewareHandler = async (c, next) => {
const adminToken = c.req.header('x-nestri-admin-token');
if (adminToken && adminToken === Env.get().ADMIN_SHARED_SECRET) {
return Actor.with({ type: 'admin', properties: {} }, next);
}
// A registered nessh host proves it is itself, rather than asserting an id
// nobody checks. Wrong credentials fall through to public rather than
// erroring, so probing tells an attacker nothing about which ids exist.
const machineId = c.req.header('x-nestri-machine-id');
const machineSecret = c.req.header('x-nestri-machine-secret');
if (machineId && machineSecret) {
const machine = await Machine.authenticate({ id: machineId, secret: machineSecret });
if (machine) {
await Machine.touchLastSeen(machine.id);
return Actor.with(
{
type: 'machine',
properties: {
machineID: machine.id,
ownerUserID: machine.ownerUserId,
...(machine.teamId ? { teamID: machine.teamId } : {})
}
},
next
);
}
return Actor.with({ type: 'public', properties: {} }, next);
}
const authHeader = c.req.header('authorization');
if (!authHeader) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const match = authHeader.match(/^Bearer (.+)$/);
if (!match) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const token = match[1];
// A personal access token is resolved from the database, never through JWT
// verification. The prefix decides which, so a PAT does not pay for a
// well-known lookup and a JWT does not pay for a query.
if (AccessToken.looksLikeToken(token)) {
const pat = await AccessToken.authenticate(token);
if (!pat) {
return Actor.with({ type: 'public', properties: {} }, next);
}
await AccessToken.touchLastUsed(pat.id);
if (pat.teamId) {
// The team grant is re-checked against live membership rather than
// trusted from the row, so someone removed from a team loses what
// their old token carried without anyone remembering to revoke it.
const membership = await Member.findByTeamAndUser({
teamId: pat.teamId,
userId: pat.ownerUserId
});
if (!membership) {
return Actor.with({ type: 'public', properties: {} }, next);
}
return Actor.with(
{
type: 'member',
properties: {
userID: pat.ownerUserId,
role: membership.role,
teamID: pat.teamId
}
},
next
);
}
return Actor.with(
{
type: 'user',
properties: {
userID: pat.ownerUserId,
// A PAT is tied to neither a Steam account nor a device, so
// it carries neither. A route needing those must read them
// from the user rather than assume the caller came by SSH.
linkedAccountID: '',
fingerprint: undefined
}
},
next
);
}
// A token that cannot be verified — malformed, expired, or because the
// auth service is unreachable — makes the caller unauthenticated, not the
// request a server fault. `verify` reports the first two in `err` and
// *throws* the third, and an uncaught throw turned a bad token into a 500.
let verified;
try {
verified = await getClient(c.env).verify(subjects, token);
} catch (error) {
// eslint-disable-next-line no-console
console.error('token verification failed:', error);
return Actor.with({ type: 'public', properties: {} }, next);
}
if (verified.err) {
return Actor.with({ type: 'public', properties: {} }, next);
}
const { subject } = verified;
if (subject.type === 'user') {
const teamID = c.req.header('x-nestri-team');
if (teamID) {
const membership = await Member.findByTeamAndUser({
teamId: teamID,
userId: subject.properties.userID
});
if (membership) {
return Actor.with(
{
type: 'member',
properties: {
userID: subject.properties.userID,
role: membership.role,
teamID
}
},
next
);
}
}
return Actor.with(
{
type: 'user',
properties: {
userID: subject.properties.userID,
linkedAccountID: subject.properties.linkedAccountID,
fingerprint: subject.properties.fingerprint
}
},
next
);
}
return Actor.with({ type: 'public', properties: {} }, next);
};
/**
* Requires an authenticated caller of any kind, machines included.
*
* It deliberately does *not* single machines out: `/games` applies this to the
* whole group, and download-state — the one route a box exists to call — sits
* inside it. What stops a box from acting as its owner is `Actor.userID`,
* which refuses a machine outright, so a route written for a human cannot
* silently accept a box no matter which guard it sits behind.
*/
export const notPublic: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type === 'public') {
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.UNAUTHORIZED,
'Missing authorization header'
);
}
return next();
};
/** Requires credentials belonging to a registered nessh host. */
export const machineOnly: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'machine') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Machine credentials required'
);
}
return next();
};
/**
* A box reporting about itself, or an operator reaching in.
*
* The two are not equivalent and routes behind this must not treat them so: a
* machine may only speak for itself, while admin still has to say which host
* it means. Keeping admin is what lets an operator repair state by hand.
*/
export const machineOrAdmin: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'machine' && actor.type !== 'admin') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Machine or admin credentials required'
);
}
return next();
};
export const adminOnly: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'admin') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Admin access required'
);
}
return next();
};

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
});
}
);
}

View File

@@ -0,0 +1 @@
export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js';

127
apps/api/app/utils/error.ts Normal file
View File

@@ -0,0 +1,127 @@
import { ErrorResponse } from '@nestri/core/error';
import { resolver } from 'hono-openapi/zod';
export const ErrorResponses = {
400: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Validation error',
example: {
type: 'validation',
code: 'invalid_parameter',
message: 'The request was invalid',
param: 'email'
}
})
)
}
},
description:
'Bad Request - The request could not be understood or was missing required parameters.'
},
401: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Authentication error',
example: {
type: 'authentication',
code: 'unauthorized',
message: 'Authentication required'
}
})
)
}
},
description:
'Unauthorized - Authentication is required and has failed or has not been provided.'
},
403: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Permission error',
example: {
type: 'forbidden',
code: 'permission_denied',
message: 'You do not have permission to access this resource'
}
})
)
}
},
description: 'Forbidden - You do not have permission to access this resource.'
},
404: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Not found error',
example: {
type: 'not_found',
code: 'resource_not_found',
message: 'The requested resource could not be found'
}
})
)
}
},
description: 'Not Found - The requested resource does not exist.'
},
409: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Conflict Error',
example: {
type: 'already_exists',
code: 'resource_already_exists',
message: 'The resource could not be created because it already exists'
}
})
)
}
},
description: 'Conflict - The resource could not be created because it already exists.'
},
429: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Rate limit error',
example: {
type: 'rate_limit',
code: 'too_many_requests',
message: 'Rate limit exceeded'
}
})
)
}
},
description: 'Too Many Requests - You have made too many requests in a short period of time.'
},
500: {
content: {
'application/json': {
schema: resolver(
ErrorResponse.meta({
description: 'Server error',
example: {
type: 'internal',
code: 'internal_error',
message: 'Internal server error'
}
})
)
}
},
description: 'Internal Server Error - Something went wrong on our end.'
}
};

View File

@@ -0,0 +1,64 @@
import type {
Env,
ValidationTargets,
Context,
TypedResponse,
Input,
MiddlewareHandler
} from 'hono';
import { ZodError, ZodSchema, z } from 'zod';
type Hook<
T,
E extends Env,
P extends string,
Target extends keyof ValidationTargets = keyof ValidationTargets,
O = {}
> = (
result: (
| {
success: true;
data: T;
}
| {
success: false;
error: ZodError;
data: T;
}
) & {
target: Target;
},
c: Context<E, P>
) => Response | void | TypedResponse<O> | Promise<Response | void | TypedResponse<O>>;
type HasUndefined<T> = undefined extends T ? true : false;
declare const zValidator: <
T extends ZodSchema<any, z.ZodTypeDef, any>,
Target extends keyof ValidationTargets,
E extends Env,
P extends string,
In = z.input<T>,
Out = z.output<T>,
I extends Input = {
in: HasUndefined<In> extends true
? {
[K in Target]?:
| (In extends ValidationTargets[K]
? In
: { [K2 in keyof In]?: ValidationTargets[K][K2] | undefined })
| undefined;
}
: {
[K_1 in Target]: In extends ValidationTargets[K_1]
? In
: { [K2_1 in keyof In]: ValidationTargets[K_1][K2_1] };
};
out: { [K_2 in Target]: Out };
},
V extends I = I
>(
target: Target,
schema: T,
hook?: Hook<z.TypeOf<T>, E, P, Target, {}> | undefined
) => MiddlewareHandler<E, P, V>;
export { type Hook, zValidator };

View File

@@ -0,0 +1,4 @@
export * from './auth';
export * from './error';
export * from './result';
export * from './validator';

View File

@@ -0,0 +1,6 @@
import { resolver } from 'hono-openapi/zod';
import { z } from 'zod';
export function Result<T extends z.ZodTypeAny>(schema: T) {
return resolver(z.object({ data: schema }));
}

View File

@@ -0,0 +1,70 @@
import { ErrorCodes } from '@nestri/core/error';
import type { MiddlewareHandler, ValidationTargets } from 'hono';
import { validator as zodValidator } from 'hono-openapi/zod';
import { z, ZodSchema } from 'zod';
import type { Hook } from './hook';
type ZodIssueExtended = z.ZodIssue & {
expected?: unknown;
received?: unknown;
};
export const validator = <T extends ZodSchema, Target extends keyof ValidationTargets>(
target: Target,
schema: T
): MiddlewareHandler<
Record<string, unknown>,
string,
{
in: {
[K in Target]: z.input<T>;
};
out: {
[K in Target]: z.output<T>;
};
}
> => {
const standardErrorHandler: Hook<z.infer<T>, any, any, Target> = (result, c) => {
if (!result.success) {
const issues = result.error.issues || result.error.errors || [];
const firstIssue = issues[0];
const fieldPath = Array.isArray(firstIssue?.path)
? firstIssue.path.join('.')
: firstIssue?.path;
let errorCode = ErrorCodes.Validation.INVALID_PARAMETER;
if (firstIssue?.code === 'invalid_type' && firstIssue?.received === 'undefined') {
errorCode = ErrorCodes.Validation.MISSING_REQUIRED_FIELD;
} else if (
['invalid_string', 'invalid_date', 'invalid_regex'].includes(firstIssue?.code as string)
) {
errorCode = ErrorCodes.Validation.INVALID_FORMAT;
}
const response = {
type: 'validation',
code: errorCode,
message: firstIssue?.message,
param: fieldPath,
details:
issues.length > 1
? {
issues: issues.map((issue: ZodIssueExtended) => ({
path: Array.isArray(issue.path) ? issue.path.join('.') : issue.path,
code: issue.code,
message: issue.message,
expected: issue.expected,
received: issue.received
}))
}
: undefined
};
console.log('Validation error in validator:', response);
return c.json(response, 400);
}
};
return zodValidator(target, schema, standardErrorHandler);
};