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

34
apps/api/.gitignore vendored Normal file
View File

@@ -0,0 +1,34 @@
# dependencies (bun install)
node_modules
# output
out
dist
*.tgz
# code coverage
coverage
*.lcov
# logs
logs
_.log
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# caches
.eslintcache
.cache
*.tsbuildinfo
# IntelliJ based IDEs
.idea
# Finder (MacOS) folder config
.DS_Store

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

23
apps/api/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "api",
"type": "module",
"dependencies": {
"@hono/zod-validator": "^0.9.0",
"@nestri/auth": "workspace:",
"@nestri/core": "workspace:",
"hono": "catalog:",
"hono-openapi": "^0.4.8",
"jose": "^6.2.3",
"redis": "^6.0.0",
"zod": "catalog:",
"zod-openapi": "^6.0.0"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"peerDependencies": {
"typescript": "catalog:"
}
}

View File

@@ -0,0 +1,542 @@
import { describe, expect, test } from 'bun:test';
import { app } from '../app/index';
import { TEST_ADMIN_SECRET } from './setup';
import './setup';
function adminHeaders(): Record<string, string> {
return { 'x-nestri-admin-token': TEST_ADMIN_SECRET };
}
describe('Index', () => {
test('GET / returns hello world', async () => {
const res = await app.request('/');
expect(res.status).toBe(200);
expect(await res.text()).toBe('Hello World!');
});
});
describe('Auth middleware', () => {
test('public access to a protected route returns 401', async () => {
const res = await app.request('/games');
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.type).toBe('authentication');
expect(body.code).toBe('unauthorized');
});
test('admin token gains access to protected routes', async () => {
const res = await app.request('/games', {
headers: adminHeaders()
});
expect(res.status).toBe(200);
});
test('wrong admin token is treated as public → 401', async () => {
const res = await app.request('/games', {
headers: { 'x-nestri-admin-token': 'wrong-secret' }
});
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.type).toBe('authentication');
});
test('a bearer token that cannot be verified is unauthenticated, not a server error', async () => {
// A token nobody can verify makes the *caller* unauthenticated; it does
// not make the request a server fault. `verify` reports a malformed or
// expired token in `err`, but throws when it cannot reach the auth
// service at all, and that throw used to surface as a 500.
const res = await app.request('/games', {
headers: { authorization: 'Bearer not-a-real-token' }
});
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.type).toBe('authentication');
});
test('missing authorization on admin-only route returns 401', async () => {
const res = await app.request('/games/sync', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({})
});
// notPublic runs before adminOnly → 401
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.code).toBe('unauthorized');
});
});
describe('Validation', () => {
test('malformed JSON body returns 400', async () => {
const res = await app.request('/games/sync', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: '{not-json'
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('missing required fields returns 400 with code', async () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: JSON.stringify({ status: 'downloading' })
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('invalid status enum in download-state returns 400', async () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
steamAppId: 440,
status: 'bogus_status'
})
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('non-existent game returns 404', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
expect(body.type).toBe('not_found');
});
test('missing content-type header returns 400', async () => {
const res = await app.request('/games/sync', {
method: 'POST',
headers: adminHeaders(),
body: JSON.stringify({})
});
expect(res.status).toBe(400);
});
});
describe('Error response shape', () => {
test('404 on unknown game has standard error shape', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
expect(body).toHaveProperty('type');
expect(body).toHaveProperty('code');
expect(body).toHaveProperty('message');
});
test('429 error responses have standard shape', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
expect(body.type).toBe('not_found');
expect(body.code).toBe('resource_not_found');
});
});
describe('OpenAPI doc', () => {
test('GET /doc returns 200 with JSON', async () => {
const res = await app.request('/doc');
expect(res.status).toBe(200);
const body = (await res.json()) as any;
expect(body).toHaveProperty('openapi');
expect(body.info.title).toBe('Nestri API');
});
test('GET /doc contains expected route paths', async () => {
const res = await app.request('/doc');
const body = (await res.json()) as any;
const paths = Object.keys(body.paths);
expect(paths).toContain('/games');
expect(paths).toContain('/games/sync');
expect(paths).toContain('/games/{id}');
expect(paths).toContain('/games/{id}/download-state');
expect(paths).toContain('/games/download-state');
expect(paths).toContain('/library');
expect(paths).toContain('/library/sync');
expect(paths).toContain('/steam/link');
expect(paths).toContain('/user');
});
test('doc has security schemes defined', async () => {
const res = await app.request('/doc');
const body = (await res.json()) as any;
expect(body.components.securitySchemes.Bearer).toMatchObject({
type: 'http',
scheme: 'bearer'
});
});
});
describe('CORS', () => {
test('CORS preflight returns headers', async () => {
const res = await app.request('/games', {
method: 'OPTIONS',
headers: {
origin: 'http://localhost:5173',
'access-control-request-method': 'GET'
}
});
expect(res.status).toBe(204);
expect(res.headers.get('access-control-allow-origin')).toBeTruthy();
});
test('response includes cache-control no-store', async () => {
const res = await app.request('/');
expect(res.headers.get('cache-control')).toBe('no-store');
});
});
describe('Download state route', () => {
test('POST /games/download-state requires hostId and steamAppId', async () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
status: 'downloading'
// missing steamAppId
})
});
expect(res.status).toBe(400);
});
test('POST /games/download-state validates status enum', async () => {
const valid = ['pending', 'verifying', 'downloading', 'ready', 'failed'] as const;
for (const status of valid) {
//eslint-disable-next-line
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
steamAppId: 440,
status
})
});
// Validation should pass (200 or 404 if game not in DB)
expect(res.status).not.toBe(400);
}
});
test('an unauthenticated caller cannot report download state', async () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ hostId: 'mch_test', steamAppId: 440, status: 'ready' })
});
// The route group's `notPublic` runs first, so this is 401 rather than
// the 403 `machineOrAdmin` would give an authenticated non-host.
expect(res.status).toBe(401);
});
test('an admin caller must say which host it is reporting for', async () => {
// hostId is optional in the schema now because a machine supplies it
// from its own identity. Admin has no identity to take it from, so
// leaving it out has to fail rather than write under an empty host.
const res = await app.request('/games/download-state', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ steamAppId: 440, status: 'ready' })
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.code).toBe('missing_required_field');
expect(body.param).toBe('hostId');
});
});
describe('Access tokens', () => {
test('creating a token requires authentication', async () => {
const res = await app.request('/access-token', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ name: 'living-room-box' })
});
expect(res.status).toBe(401);
});
test('the admin token cannot mint a token for anyone', async () => {
// This is the boundary that makes admin safe to hand out for tooling:
// it reads and writes API data but cannot *become* a user. Minting a
// PAT on someone's behalf would erase exactly that.
const res = await app.request('/access-token', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ name: 'living-room-box' })
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('user session');
});
test('a token needs a name', async () => {
const res = await app.request('/access-token', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ name: '' })
});
expect(res.status).toBe(400);
});
test('expiry is capped at a year', async () => {
const res = await app.request('/access-token', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ name: 'box', expiresInDays: 4000 })
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('teamId accepts null to force a token scoped to the user alone', async () => {
// Team scope is the default and is *broader* than user scope, so there
// has to be an explicit way to ask for the narrow one. Null is it;
// omitting the field means "take the default", which is not the same.
const res = await app.request('/access-token', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ name: 'box', teamId: null })
});
// Admin is refused at the handler, but only after validation — so a
// 403 here proves null passed the schema rather than being rejected.
expect(res.status).toBe(403);
});
test('revoking someone elses token requires authentication', async () => {
const res = await app.request('/access-token/pat_whatever', { method: 'DELETE' });
expect(res.status).toBe(401);
});
test('an unknown access token is unauthenticated, not a server error', async () => {
// A `pat_` prefix routes to the database rather than JWT verification.
// A miss there must read as "not signed in", the same as a bad JWT.
const res = await app.request('/games', {
headers: { authorization: 'Bearer pat_nosuchtokenvalue' }
});
expect(res.status).toBe(401);
const body = (await res.json()) as any;
expect(body.type).toBe('authentication');
});
});
describe('Box access', () => {
test('rescoping a machine requires authentication', async () => {
const res = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ teamId: null })
});
expect(res.status).toBe(401);
});
test('the admin token cannot rescope a machine', async () => {
// Rescoping is an owner action and the query is scoped to a user id;
// admin has none, so it must be refused rather than 500 later.
const res = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ teamId: null })
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('user session');
});
test('teamId is required on the body, and may be null', async () => {
// Null is "make it mine alone" — a different thing from omitting the
// field, which would leave the scope ambiguous.
const missing = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({})
});
expect(missing.status).toBe(400);
const explicitNull = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ teamId: null })
});
// Past validation, refused at the handler for being admin.
expect(explicitNull.status).toBe(403);
});
test('entitlement requires machine credentials, not a user session', async () => {
// The machine is taken from its credentials, never the query, so a box
// cannot ask about another box.
const res = await app.request('/machine/entitlement?userId=usr_x', {
headers: adminHeaders()
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('Machine credentials');
});
test('entitlement needs a userId to answer about', async () => {
const res = await app.request('/machine/entitlement');
// machineOnly refuses before validation; either way it does not answer.
expect([400, 403]).toContain(res.status);
});
});
describe('Machine registration', () => {
test('registering a machine requires authentication', async () => {
const res = await app.request('/machine/register', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ label: 'living-room-box' })
});
expect(res.status).toBe(401);
});
test('the admin token cannot register a machine', async () => {
// Registering is an act of ownership and the resulting row references a
// user. Admin is authenticated but owns nothing, so it must be refused
// here rather than fail later on a null owner.
const res = await app.request('/machine/register', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ label: 'living-room-box' })
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('user session');
});
test('registering a machine requires a label', async () => {
const res = await app.request('/machine/register', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ label: '' })
});
expect(res.status).toBe(400);
const body = (await res.json()) as any;
expect(body.type).toBe('validation');
});
test('describing yourself requires machine credentials', async () => {
const res = await app.request('/machine/me', { headers: adminHeaders() });
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('Machine credentials');
});
});
describe('Steam routes', () => {
test('POST /steam/link requires auth', async () => {
const res = await app.request('/steam/link', { method: 'POST' });
expect(res.status).toBe(401);
});
test('POST /steam/link validates steamId', async () => {
const res = await app.request('/steam/link', {
method: 'POST',
headers: {
...adminHeaders(),
'content-type': 'application/json'
},
body: JSON.stringify({})
});
expect(res.status).toBe(400);
});
});
describe('User routes', () => {
test('GET /user requires auth', async () => {
const res = await app.request('/user');
expect(res.status).toBe(401);
});
});
describe('Library routes', () => {
test('GET /library requires auth', async () => {
const res = await app.request('/library');
expect(res.status).toBe(401);
});
});
describe('Pairing code routes', () => {
test('POST /pairing-code requires auth', async () => {
// Generating a code says "this key is also me", so it can only be done
// from a session that already is that user.
const res = await app.request('/pairing-code', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({})
});
expect(res.status).toBe(401);
});
test('POST /pairing-code/claim rejects an unauthenticated caller', async () => {
// Claiming is done for a device with no identity yet, so it carries the
// admin token rather than a user session. Without it, no.
const res = await app.request('/pairing-code/claim', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ code: 'NESSH-7F2Q', fingerprint: 'aa:bb' })
});
expect([401, 403]).toContain(res.status);
});
test('POST /pairing-code/claim requires both a code and a fingerprint', async () => {
for (const body of [{}, { code: 'NESSH-7F2Q' }, { fingerprint: 'aa:bb' }]) {
// eslint-disable-next-line
const res = await app.request('/pairing-code/claim', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify(body)
});
expect(res.status).toBe(400);
}
});
test('POST /pairing-code/claim rejects an empty code', async () => {
// An empty string must not be treated as "any code".
const res = await app.request('/pairing-code/claim', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ code: '', fingerprint: 'aa:bb' })
});
expect(res.status).toBe(400);
});
test('POST /pairing-code caps how long a code stays valid', async () => {
// Short-lived by design; a long-lived code is a shared password.
const res = await app.request('/pairing-code', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
body: JSON.stringify({ ttlMinutes: 60 * 24 })
});
expect(res.status).toBe(400);
});
});

16
apps/api/test/setup.ts Normal file
View File

@@ -0,0 +1,16 @@
import { beforeEach } from 'bun:test';
import { Env } from '@nestri/core/env';
const TEST_ADMIN_SECRET = 'test-admin-secret-42';
const TEST_FRONTEND_URL = 'http://localhost:5173';
beforeEach(() => {
Env.init({
NODE_ENV: 'test',
ADMIN_SHARED_SECRET: TEST_ADMIN_SECRET,
FRONTEND_URL: TEST_FRONTEND_URL
});
});
export { TEST_ADMIN_SECRET, TEST_FRONTEND_URL };

13
apps/api/tsconfig.json Normal file
View File

@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"lib": ["esnext"],
"types": ["@cloudflare/workers-types", "node", "@types/bun"],
"noEmit": true,
"skipLibCheck": true
}
}

20
apps/auth/package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "auth",
"type": "module",
"scripts": {
"dev": "vite"
},
"dependencies": {
"@nestri/auth": "workspace:",
"@nestri/core": "workspace:"
},
"devDependencies": {
"@cloudflare/workers-types": "catalog:",
"@tsconfig/node22": "catalog:",
"@types/bun": "catalog:",
"@types/node": "catalog:"
},
"peerDependencies": {
"typescript": "catalog:"
}
}

112
apps/auth/src/index.ts Normal file
View File

@@ -0,0 +1,112 @@
import type { Hyperdrive, KVNamespace } from '@cloudflare/workers-types';
import { issuer } from '@nestri/auth/index';
import { SshProvider } from '@nestri/auth/provider/ssh';
import { SteamProvider } from '@nestri/auth/provider/steam';
import { CloudflareStorage } from '@nestri/auth/storage/cloudflare';
import { subjects } from '@nestri/core/auth/subjects';
import { Database } from '@nestri/core/db/index';
import { Env } from '@nestri/core/env';
import { Identifier } from '@nestri/core/id';
import { Steam } from '@nestri/core/steam/index';
import { User } from '@nestri/core/user/index';
import { LinkedAccount } from '@nestri/core/user/linked-account';
type Env = {
AuthStorage: KVNamespace;
HYPERDRIVE: Hyperdrive;
STEAM_API_KEY: string;
SSH_AUTH_KEY: string;
};
export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext) {
Env.init(env as unknown as Record<string, unknown>);
const inner = issuer({
subjects,
storage: CloudflareStorage({
namespace: env.AuthStorage
}),
providers: {
steam: SteamProvider(),
ssh: SshProvider({ sshAuthKey: env.SSH_AUTH_KEY })
},
async success(context, response) {
if (response.provider === 'steam') {
const { steamid } = response;
const profileUrl = new URL(
'https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/'
);
profileUrl.searchParams.set('key', env.STEAM_API_KEY);
profileUrl.searchParams.set('steamids', steamid);
const profileRes = await fetch(profileUrl.toString());
const profileData = (await profileRes.json()) as {
response?: { players?: Array<Record<string, unknown>> };
};
const player = profileData?.response?.players?.[0] as any;
const personaname: string = player?.personaname ?? 'Player';
const avatarfull: string = player?.avatarfull;
const { userID, linkedAccountID } = await Database.transaction(async () => {
const existing = await LinkedAccount.findByProvider({
provider: 'steam',
providerAccountId: steamid
});
if (existing) {
const user = await User.fromID(existing.userId);
if (!user) throw new Error('User not found for linked account');
return { userID: user.id, linkedAccountID: existing.id };
}
const newUserID = Identifier.ascending('user');
await User.create({
id: newUserID,
name: personaname,
email: undefined,
emailVerified: false,
image: avatarfull ?? null
});
const newLinkedAccountID = Identifier.ascending('linkedAccount');
await LinkedAccount.create({
id: newLinkedAccountID,
userId: newUserID,
provider: 'steam',
providerAccountId: steamid,
profile: player ?? {}
});
return { userID: newUserID, linkedAccountID: newLinkedAccountID };
});
return context.subject('user', {
userID,
linkedAccountID
});
}
if (response.provider === 'ssh') {
const { fingerprint, steamId, username, profile } = response;
const { userID, linkedAccountID } = await Steam.resolveSshIdentity({
fingerprint,
steamId,
username,
profile
});
return context.subject('user', {
userID,
linkedAccountID,
fingerprint
});
}
throw new Error('Unknown provider');
}
});
return inner.fetch(request, env, ctx);
}
};

View File

@@ -0,0 +1,227 @@
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
import { createClient } from '@nestri/auth/client';
import { issuer } from '@nestri/auth/index';
import { SshProvider } from '@nestri/auth/provider/ssh';
import { SteamProvider } from '@nestri/auth/provider/steam';
import { MemoryStorage } from '@nestri/auth/storage/memory';
import { subjects } from '@nestri/core/auth/subjects';
const storage = MemoryStorage();
const auth = issuer({
subjects,
storage,
allow: async () => true,
providers: {
steam: SteamProvider(),
ssh: SshProvider({ sshAuthKey: 'test-ssh-key' })
},
async success(context, response) {
if (response.provider === 'steam') {
return context.subject('user', {
userID: 'usr_test123',
linkedAccountID: 'lac_test456'
});
}
if (response.provider === 'ssh') {
return context.subject('user', {
userID: 'usr_test123',
linkedAccountID: 'lac_test456',
fingerprint: response.fingerprint
});
}
throw new Error('unknown provider');
}
});
beforeEach(() => {
globalThis.fetch = mock(async (input: string | URL | Request, _init?: RequestInit) => {
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
if (url.includes('steamcommunity.com/openid/login')) {
return new Response('ns:http://specs.openid.net/auth/2.0\nis_valid:true\n', { status: 200 });
}
if (url.includes('api.steampowered.com')) {
return new Response(
JSON.stringify({
response: {
players: [
{
personaname: 'TestPlayer',
avatarfull:
'https://steamcdn-a.akamaihd.net/steamcommunity/public/images/avatars/fe/fef49e7fa7e1997310d705b2a6158ff8dc1cdfeb.jpg',
steamid: '76561197960287956'
}
]
}
}),
{ status: 200 }
);
}
return new Response('not found', { status: 404 });
}) as unknown as typeof fetch;
});
afterEach(() => {
globalThis.fetch = fetch;
});
describe('Steam auth flow', () => {
test('authorize redirects to Steam OpenID', async () => {
const response = await auth.request('https://auth.internal/steam/authorize');
expect(response.status).toBe(302);
expect(response.headers.get('location')).toMatch(/steamcommunity\.com\/openid/);
});
test('full code flow and token verification', async () => {
const client = createClient({
issuer: 'https://auth.internal',
clientID: 'api',
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
});
const { challenge, url } = await client.authorize(
'https://client.example.com/callback',
'code',
{ pkce: true, provider: 'steam' }
);
// Step 1: hit the authorize URL → redirects to Steam OpenID
const authResponse = await auth.request(url);
expect(authResponse.status).toBe(302);
const cookie = authResponse.headers.get('set-cookie')!;
expect(cookie).toBeDefined();
// Step 2: simulate Steam redirecting back to our callback with valid OpenID params
const callbackUrl =
'https://auth.internal/steam/callback?' +
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
'openid.mode=id_res&' +
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
const callbackResponse = await auth.request(callbackUrl, {
headers: { cookie }
});
expect(callbackResponse.status).toBe(302);
const location = new URL(callbackResponse.headers.get('location')!);
const code = location.searchParams.get('code');
expect(code).not.toBeNull();
const exchanged = await client.exchange(
code!,
'https://client.example.com/callback',
challenge.verifier
);
if (exchanged.err) throw exchanged.err;
const tokens = exchanged.tokens!;
expect(tokens.access).toBeString();
expect(tokens.refresh).toBeString();
const verified = await client.verify(subjects, tokens.access);
if (verified.err) throw verified.err;
expect(verified.subject).toEqual({
type: 'user',
properties: {
userID: 'usr_test123',
linkedAccountID: 'lac_test456'
}
});
});
});
describe('SSH login', () => {
test('valid login returns tokens', async () => {
const loginResponse = await auth.request('https://auth.internal/ssh/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer test-ssh-key'
},
body: JSON.stringify({
fingerprint: 'SHA256:abc123',
steamId: '76561198012345678'
})
});
expect(loginResponse.status).toBe(200);
const body: any = await loginResponse.json();
expect(body.accessToken).toBeString();
expect(body.refreshToken).toBeString();
});
test('invalid auth key returns 401', async () => {
const response = await auth.request('https://auth.internal/ssh/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: 'Bearer wrong-key'
},
body: JSON.stringify({
fingerprint: 'SHA256:abc123',
steamId: '76561198012345678'
})
});
expect(response.status).toBe(401);
});
});
describe('User info', () => {
async function getTokens() {
const client = createClient({
issuer: 'https://auth.internal',
clientID: 'api',
fetch: (input: any, init: any) => Promise.resolve(auth.request(input, init))
});
const { challenge, url } = await client.authorize(
'https://client.example.com/callback',
'code',
{ pkce: true, provider: 'steam' }
);
const authResponse = await auth.request(url);
const cookie = authResponse.headers.get('set-cookie')!;
const callbackUrl =
'https://auth.internal/steam/callback?' +
'openid.ns=http%3A%2F%2Fspecs.openid.net%2Fauth%2F2.0&' +
'openid.mode=id_res&' +
'openid.return_to=https%3A%2F%2Fauth.internal%2Fsteam%2Fcallback&' +
'openid.claimed_id=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956&' +
'openid.identity=https%3A%2F%2Fsteamcommunity.com%2Fopenid%2Fid%2F76561197960287956';
const callbackResponse = await auth.request(callbackUrl, { headers: { cookie } });
const location = new URL(callbackResponse.headers.get('location')!);
const code = location.searchParams.get('code');
const exchanged = await client.exchange(
code!,
'https://client.example.com/callback',
challenge.verifier
);
if (exchanged.err) throw exchanged.err;
return { client, tokens: exchanged.tokens! };
}
test('returns subject properties for valid access token', async () => {
const { tokens } = await getTokens();
const infoRes = await auth.request('https://auth.internal/userinfo', {
headers: { Authorization: `Bearer ${tokens.access}` }
});
expect(infoRes.status).toBe(200);
const userinfo = await infoRes.json();
expect(userinfo).toMatchObject({
userID: 'usr_test123',
linkedAccountID: 'lac_test456'
});
});
});

11
apps/auth/tsconfig.json Normal file
View File

@@ -0,0 +1,11 @@
{
"$schema": "https://json.schemastore.org/tsconfig",
"extends": "@tsconfig/node22/tsconfig.json",
"compilerOptions": {
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "preserve",
"jsxImportSource": "react",
"types": ["@cloudflare/workers-types", "node", "bun"]
}
}