mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat: Sync to OSS repo
This commit is contained in:
34
apps/api/.gitignore
vendored
Normal file
34
apps/api/.gitignore
vendored
Normal 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
107
apps/api/app/index.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
271
apps/api/app/middleware/auth.ts
Normal file
271
apps/api/app/middleware/auth.ts
Normal 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();
|
||||
};
|
||||
207
apps/api/app/routes/access-token.ts
Normal file
207
apps/api/app/routes/access-token.ts
Normal file
@@ -0,0 +1,207 @@
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Member } from '@nestri/core/team/member';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Personal access tokens.
|
||||
*
|
||||
* The credential for anything that is not a browser: a nessh box registering
|
||||
* itself, or a script driving the API. A session JWT cannot do this job — it
|
||||
* is short-lived and cannot be revoked without rotating signing keys for
|
||||
* everyone, which is wrong for something that sits in a config file for
|
||||
* months.
|
||||
*
|
||||
* Minting requires a *user session*, deliberately. Allowing the admin token to
|
||||
* mint one for an arbitrary user would turn a credential that can read and
|
||||
* write all API data into one that can *become* any user, and that boundary is
|
||||
* the reason `Actor.userID` refuses admin at all.
|
||||
*/
|
||||
/**
|
||||
* Decide what a new token is scoped to.
|
||||
*
|
||||
* Team scope is the default, because a box or a script is nearly always doing
|
||||
* team work and a user-scoped token silently cannot see any of it. But the
|
||||
* default only applies when it is *unambiguous*: with several teams, guessing
|
||||
* would hand out a token reaching resources the caller did not have in mind.
|
||||
*
|
||||
* Note team scope is broader than user scope, never narrower — hence `null` as
|
||||
* an explicit way to ask for the narrow one. It still cannot exceed what the
|
||||
* user themselves may do: the grant is re-checked against live membership on
|
||||
* every request, with their own role.
|
||||
*
|
||||
* @param requested `undefined` to take the default, `null` to force user
|
||||
* scope, or a team id to name one.
|
||||
*/
|
||||
async function resolveTeamScope(requested: string | null | undefined): Promise<string | null> {
|
||||
if (requested === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (requested !== undefined) {
|
||||
// Verified here rather than trusted from the body: a token is only ever
|
||||
// as scoped as what was checked at the moment it was made.
|
||||
const membership = await Member.findByTeamAndUser({
|
||||
teamId: requested,
|
||||
userId: Actor.userID
|
||||
});
|
||||
if (!membership) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You are not a member of that team'
|
||||
);
|
||||
}
|
||||
return requested;
|
||||
}
|
||||
|
||||
const memberships = await Member.listByUser(Actor.userID);
|
||||
if (memberships.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (memberships.length > 1) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
'You belong to several teams — name the one this token is for, or pass teamId: null to scope it to yourself',
|
||||
'teamId'
|
||||
);
|
||||
}
|
||||
return memberships[0]!.teamId;
|
||||
}
|
||||
|
||||
export namespace AccessTokenApi {
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'Create a personal access token',
|
||||
description:
|
||||
'Mint a long-lived, revocable token for the calling user. The token is returned once and never again — only its digest is stored.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
id: z.string().meta({ example: Examples.AccessToken.id }),
|
||||
token: z.string().meta({
|
||||
description: 'Shown once. Store it now; it cannot be retrieved.'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'A freshly minted token'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
name: z.string().min(1).max(64).meta({
|
||||
description: 'What this token is for, so it can be recognised in a list',
|
||||
example: Examples.AccessToken.name
|
||||
}),
|
||||
teamId: z.string().nullable().optional().meta({
|
||||
description:
|
||||
'Team to scope the token to. Omit to default to your team when you have exactly one; pass null to force a token scoped to you alone.'
|
||||
}),
|
||||
expiresInDays: z.number().int().min(1).max(365).optional().meta({
|
||||
description: 'Optional lifetime. Omit for a token that does not expire.'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { name, teamId, expiresInDays } = c.req.valid('json');
|
||||
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Creating an access token requires a user session'
|
||||
);
|
||||
}
|
||||
|
||||
const scopedTeamId = await resolveTeamScope(teamId);
|
||||
|
||||
const created = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: Actor.userID,
|
||||
teamId: scopedTeamId,
|
||||
name,
|
||||
expiresInDays
|
||||
});
|
||||
|
||||
return c.json({ data: { id: created.id, token: created.token } });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'List your access tokens',
|
||||
description: 'Returns metadata only. The token values are not stored and cannot be shown.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(AccessToken.Info)) } },
|
||||
description: 'Tokens belonging to the caller'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ data: await AccessToken.listByOwner(Actor.userID) });
|
||||
}
|
||||
)
|
||||
.delete(
|
||||
'/:id',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['AccessToken'],
|
||||
summary: 'Revoke an access token',
|
||||
description:
|
||||
'Revokes immediately. Revocation is the reason these exist rather than long-lived JWTs.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.object({ id: z.string() })) } },
|
||||
description: 'The token no longer works'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
// Scoped to the owner in the query itself, so revoking someone
|
||||
// else's token is a 404 rather than a permission check that
|
||||
// could be forgotten.
|
||||
const revoked = await AccessToken.revoke({
|
||||
id: c.req.param('id'),
|
||||
ownerUserId: Actor.userID
|
||||
});
|
||||
if (!revoked) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such token, or it is not yours'
|
||||
);
|
||||
}
|
||||
return c.json({ data: { id: revoked.id } });
|
||||
}
|
||||
);
|
||||
}
|
||||
614
apps/api/app/routes/game.ts
Normal file
614
apps/api/app/routes/game.ts
Normal file
@@ -0,0 +1,614 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Depot } from '@nestri/core/game/depot';
|
||||
import { GameDownload } from '@nestri/core/game/download';
|
||||
import { GameDownloadStatus } from '@nestri/core/game/download.sql';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
|
||||
|
||||
const SyncGameSchema = z.object({
|
||||
steamAppId: z.number().int(),
|
||||
name: z.string(),
|
||||
type: z.string().optional(),
|
||||
clientIcon: z.string().optional(),
|
||||
icon: z.string().optional(),
|
||||
shortDescription: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
developers: z.array(z.string()).optional(),
|
||||
publishers: z.array(z.string()).optional(),
|
||||
primaryGenre: z.string().optional(),
|
||||
genres: z.array(z.string()).optional(),
|
||||
categories: z.array(z.string()).optional(),
|
||||
oslist: z.array(z.string()).optional(),
|
||||
sizeDownload: z.number().optional(),
|
||||
sizeOnDisk: z.number().optional(),
|
||||
controllerSupport: z.string().optional(),
|
||||
steamDeckCompat: z.string().optional(),
|
||||
reviewScorePercent: z.number().int().optional(),
|
||||
reviewCount: z.number().int().optional(),
|
||||
metacriticScore: z.number().int().optional(),
|
||||
steamChangeNumber: z.number().int().optional(),
|
||||
publicBuildId: z.number().int().optional(),
|
||||
releaseDate: z.string().optional(),
|
||||
enriched: z.boolean().default(false),
|
||||
depots: z
|
||||
.array(
|
||||
z.object({
|
||||
depotId: z.number().int(),
|
||||
branch: z.string().default('public'),
|
||||
steamManifestId: z.string().optional(),
|
||||
steamBuildId: z.number().int().optional(),
|
||||
sizeDownload: z.number().optional(),
|
||||
sizeOnDisk: z.number().optional(),
|
||||
oslist: z.string().optional()
|
||||
})
|
||||
)
|
||||
.optional()
|
||||
});
|
||||
|
||||
const SyncLibrarySchema = z.object({
|
||||
steamAppId: z.number().int(),
|
||||
playtimeForeverMin: z.number().int().optional(),
|
||||
playtime2WeeksMin: z.number().int().optional(),
|
||||
lastPlayed: z.string().optional()
|
||||
});
|
||||
|
||||
export namespace GameApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'List games',
|
||||
description: 'List all games in the catalog, with optional search',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Game.Info).meta({
|
||||
description: 'All games matching the optional query',
|
||||
example: [Examples.Game]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'List of games'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'query',
|
||||
z.object({
|
||||
q: z.string().optional().meta({
|
||||
description: 'Search query to filter games by name',
|
||||
example: 'Counter-Strike'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { q } = c.req.valid('query');
|
||||
const games = await Game.searchByName(q ?? '');
|
||||
return c.json({ data: games });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:id',
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Get a game by ID',
|
||||
description: 'Retrieve a single game from the catalog',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
Game.Info.meta({
|
||||
description: 'The game',
|
||||
example: Examples.Game
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'The game'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({
|
||||
description: 'ID of the game',
|
||||
example: Examples.Game.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const game = await Game.fromID(id);
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game ${id} not found`
|
||||
);
|
||||
}
|
||||
return c.json({ data: game });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/sync',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Batch sync games, library entries, and depots',
|
||||
description:
|
||||
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
gamesSynced: z.number(),
|
||||
libraryEntries: z.number(),
|
||||
depotEntries: z.number(),
|
||||
failedEntries: z.array(z.number())
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Sync result'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
userId: z.string(),
|
||||
games: z.array(SyncGameSchema).default([]),
|
||||
library: z.array(SyncLibrarySchema).default([])
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { userId, games, library } = c.req.valid('json');
|
||||
|
||||
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||
const gameIdByAppId = new Map<number, string>();
|
||||
|
||||
const failedSteamIDs = new Set<number>();
|
||||
const gamePromises = [];
|
||||
|
||||
// 1. Queue Games
|
||||
for (const g of games) {
|
||||
const existing = existingByAppId.get(g.steamAppId);
|
||||
const gameId = existing?.id ?? Identifier.ascending('game');
|
||||
|
||||
gameIdByAppId.set(g.steamAppId, gameId);
|
||||
|
||||
const slug =
|
||||
g.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
|
||||
const now = new Date().toISOString();
|
||||
const { enriched } = g;
|
||||
|
||||
gamePromises.push(
|
||||
Game.upsert({
|
||||
id: gameId,
|
||||
steamAppId: g.steamAppId,
|
||||
slug,
|
||||
name: g.name,
|
||||
type: g.type ?? null,
|
||||
clientIcon: g.clientIcon ?? null,
|
||||
icon: g.icon ?? null,
|
||||
shortDescription: g.shortDescription ?? null,
|
||||
description: g.description ?? null,
|
||||
developers: g.developers ?? null,
|
||||
publishers: g.publishers ?? null,
|
||||
primaryGenre: g.primaryGenre ?? null,
|
||||
genres: g.genres ?? null,
|
||||
categories: g.categories ?? null,
|
||||
oslist: g.oslist ?? null,
|
||||
sizeDownload: g.sizeDownload ?? null,
|
||||
sizeOnDisk: g.sizeOnDisk ?? null,
|
||||
controllerSupport: g.controllerSupport ?? null,
|
||||
steamDeckCompat: g.steamDeckCompat ?? null,
|
||||
reviewScorePercent: g.reviewScorePercent ?? null,
|
||||
reviewCount: g.reviewCount ?? null,
|
||||
metacriticScore: g.metacriticScore ?? null,
|
||||
steamChangeNumber: g.steamChangeNumber ?? null,
|
||||
publicBuildId: g.publicBuildId ?? null,
|
||||
releaseDate: g.releaseDate ?? null,
|
||||
timeEnriched: enriched ? now : (existing?.timeEnriched?.toISOString() ?? null)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const gameResults = await Promise.allSettled(gamePromises);
|
||||
let gamesSynced = 0;
|
||||
|
||||
const depotPromises = [];
|
||||
const depotSteamIds = [];
|
||||
const libraryPromises = [];
|
||||
const librarySteamIds = [];
|
||||
|
||||
// 2. Evaluate Games & Queue Dependents
|
||||
for (let i = 0; i < gameResults.length; i++) {
|
||||
const g = games[i];
|
||||
|
||||
if (gameResults[i].status === 'rejected') {
|
||||
failedSteamIDs.add(g.steamAppId);
|
||||
// Drop it from the map so the Library loop below ignores it
|
||||
gameIdByAppId.delete(g.steamAppId);
|
||||
continue;
|
||||
}
|
||||
|
||||
gamesSynced++;
|
||||
|
||||
if (g.depots) {
|
||||
const gameId = gameIdByAppId.get(g.steamAppId)!;
|
||||
for (const d of g.depots) {
|
||||
const depotId = Identifier.ascending('gameDepot');
|
||||
depotPromises.push(
|
||||
Depot.upsert({
|
||||
id: depotId,
|
||||
gameId: gameId,
|
||||
depotId: d.depotId,
|
||||
branch: d.branch,
|
||||
steamManifestId: d.steamManifestId ?? null,
|
||||
steamBuildId: d.steamBuildId ?? null,
|
||||
sizeDownload: d.sizeDownload ?? null,
|
||||
sizeOnDisk: d.sizeOnDisk ?? null,
|
||||
oslist: d.oslist ?? null,
|
||||
status: 'pending' as const
|
||||
})
|
||||
);
|
||||
depotSteamIds.push(g.steamAppId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const l of library) {
|
||||
// This naturally filters out entries for games that failed in step 2
|
||||
const gameId = gameIdByAppId.get(l.steamAppId);
|
||||
if (!gameId) continue;
|
||||
|
||||
const entryId = Identifier.ascending('userLibrary');
|
||||
libraryPromises.push(
|
||||
Library.upsert({
|
||||
id: entryId,
|
||||
userId,
|
||||
gameId,
|
||||
playtime2w: l.playtime2WeeksMin ?? null,
|
||||
playtimeForever: l.playtimeForeverMin ?? null,
|
||||
lastPlayed: l.lastPlayed ?? null
|
||||
})
|
||||
);
|
||||
librarySteamIds.push(l.steamAppId);
|
||||
}
|
||||
|
||||
// 3. Execute Dependents in parallel
|
||||
const [depotResults, libraryResults] = await Promise.all([
|
||||
Promise.allSettled(depotPromises),
|
||||
Promise.allSettled(libraryPromises)
|
||||
]);
|
||||
|
||||
let depotEntries = 0;
|
||||
for (let i = 0; i < depotResults.length; i++) {
|
||||
if (depotResults[i].status === 'rejected') {
|
||||
failedSteamIDs.add(depotSteamIds[i]);
|
||||
} else {
|
||||
depotEntries++;
|
||||
}
|
||||
}
|
||||
|
||||
let libraryEntries = 0;
|
||||
for (let i = 0; i < libraryResults.length; i++) {
|
||||
if (libraryResults[i].status === 'rejected') {
|
||||
failedSteamIDs.add(librarySteamIds[i]);
|
||||
} else {
|
||||
libraryEntries++;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
gamesSynced,
|
||||
libraryEntries,
|
||||
depotEntries,
|
||||
failedEntries: Array.from(failedSteamIDs)
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:id/download-state',
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Get download states for a game',
|
||||
description:
|
||||
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(GameDownload.Info).meta({
|
||||
description: 'Download states for the game',
|
||||
example: [Examples.GameDownload]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Download states'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({
|
||||
description: 'ID of the game',
|
||||
example: Examples.Game.id
|
||||
})
|
||||
})
|
||||
),
|
||||
validator(
|
||||
'query',
|
||||
z.object({
|
||||
hostId: z.string().optional().meta({
|
||||
description: 'Optional host ID to filter by',
|
||||
example: Examples.GameDownload.hostId
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { id } = c.req.valid('param');
|
||||
const { hostId } = c.req.valid('query');
|
||||
|
||||
const game = await Game.fromID(id);
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game ${id} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const rows = hostId
|
||||
? await GameDownload.findByHostAndGame({ hostId, gameId: id }).then((row) =>
|
||||
row ? [row] : []
|
||||
)
|
||||
: await GameDownload.listByGame(id);
|
||||
const data = rows.map((row) => GameDownload.serialize(row));
|
||||
return c.json({ data });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/download-state',
|
||||
machineOrAdmin,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Report a download state change',
|
||||
description:
|
||||
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
downloadId: z.string(),
|
||||
download: GameDownload.Info
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Download state updated'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
hostId: z.string().optional().meta({
|
||||
description:
|
||||
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
|
||||
example: Examples.GameDownload.hostId
|
||||
}),
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
status: z.enum(GameDownloadStatus.enumValues).meta({
|
||||
description: 'New download status',
|
||||
example: Examples.GameDownload.status
|
||||
}),
|
||||
progressBytes: z.number().int().optional().meta({
|
||||
description: 'Bytes downloaded so far',
|
||||
example: Examples.GameDownload.progressBytes
|
||||
}),
|
||||
totalBytes: z.number().int().optional().meta({
|
||||
description: 'Total bytes to download',
|
||||
example: Examples.GameDownload.totalBytes
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Error message if status is failed',
|
||||
example: null
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
|
||||
c.req.valid('json');
|
||||
|
||||
// A machine reports as itself. Taking the id from the body would
|
||||
// mean any holder of a shared secret could write download state
|
||||
// under any box's id, which is the whole reason boxes register.
|
||||
const actor = Actor.use();
|
||||
let reportingHostId: string;
|
||||
if (actor.type === 'machine') {
|
||||
if (hostId && hostId !== actor.properties.machineID) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'A machine may only report its own download state'
|
||||
);
|
||||
}
|
||||
reportingHostId = actor.properties.machineID;
|
||||
} else {
|
||||
if (!hostId) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
'hostId is required when reporting on behalf of a host',
|
||||
'hostId'
|
||||
);
|
||||
}
|
||||
reportingHostId = hostId;
|
||||
}
|
||||
|
||||
const game = await Game.fromSteamAppID(steamAppId);
|
||||
if (!game) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`Game with steamAppId ${steamAppId} not found`
|
||||
);
|
||||
}
|
||||
|
||||
const row = await GameDownload.upsertState({
|
||||
hostId: reportingHostId,
|
||||
gameId: game.id,
|
||||
status,
|
||||
progressBytes: progressBytes ?? undefined,
|
||||
totalBytes: totalBytes ?? undefined,
|
||||
errorMessage: errorMessage ?? undefined
|
||||
});
|
||||
|
||||
return c.json({
|
||||
data: { downloadId: row.id, download: GameDownload.serialize(row) }
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['Games'],
|
||||
summary: 'Create or update a game',
|
||||
description: 'Upsert a game by Steam app ID. Admin only.',
|
||||
responses: {
|
||||
201: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
Game.Info.meta({
|
||||
description: 'The created or updated game',
|
||||
example: Examples.Game
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Game created or updated'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'Game title',
|
||||
example: Examples.Game.name
|
||||
}),
|
||||
slug: z.string().optional().meta({
|
||||
description: 'URL-friendly slug',
|
||||
example: Examples.Game.slug
|
||||
}),
|
||||
type: z.string().nullable().optional().meta({
|
||||
description: 'Content type',
|
||||
example: Examples.Game.type
|
||||
}),
|
||||
clientIcon: z.string().nullable().optional().meta({
|
||||
description: 'Steam client icon hash (256×256 square)',
|
||||
example: Examples.Game.clientIcon
|
||||
}),
|
||||
icon: z.string().nullable().optional().meta({
|
||||
description: 'Steam icon hash (32×32)',
|
||||
example: Examples.Game.icon
|
||||
}),
|
||||
shortDescription: z.string().nullable().optional().meta({
|
||||
description: 'Short description',
|
||||
example: Examples.Game.shortDescription
|
||||
}),
|
||||
description: z.string().nullable().optional().meta({
|
||||
description: 'Full description',
|
||||
example: Examples.Game.description
|
||||
}),
|
||||
developers: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game developers',
|
||||
example: Examples.Game.developers
|
||||
}),
|
||||
publishers: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game publishers',
|
||||
example: Examples.Game.publishers
|
||||
}),
|
||||
genres: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Game genres',
|
||||
example: Examples.Game.genres
|
||||
}),
|
||||
oslist: z.array(z.string()).nullable().optional().meta({
|
||||
description: 'Supported OS list',
|
||||
example: Examples.Game.oslist
|
||||
}),
|
||||
releaseDate: z.string().nullable().optional().meta({
|
||||
description: 'Release date ISO string',
|
||||
example: Examples.Game.releaseDate
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const id = Identifier.ascending('game');
|
||||
const slug =
|
||||
body.slug ??
|
||||
body.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
const game = await Game.upsert({ ...body, id, slug });
|
||||
return c.json({ data: game[0] }, 201);
|
||||
}
|
||||
);
|
||||
}
|
||||
19
apps/api/app/routes/index.ts
Normal file
19
apps/api/app/routes/index.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { Database } from '@nestri/core/db/index';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Hono } from 'hono';
|
||||
|
||||
export namespace IndexApi {
|
||||
export const route = new Hono()
|
||||
.get('/', (c) => c.text('Hello World!'))
|
||||
.get('/health', async (c) => {
|
||||
const ok = await Database.ping();
|
||||
if (!ok) {
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.DEPENDENCY_FAILURE,
|
||||
'Database connection failed'
|
||||
);
|
||||
}
|
||||
return c.json({ status: 'ok' });
|
||||
});
|
||||
}
|
||||
211
apps/api/app/routes/library.ts
Normal file
211
apps/api/app/routes/library.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { GameDownload } from '@nestri/core/game/download';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, adminOnly, notPublic, Result, validator } from '../utils';
|
||||
|
||||
export namespace LibraryApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Library'],
|
||||
summary: "List the user's Steam library",
|
||||
description:
|
||||
"Returns all games in the authenticated user's library with playtime info and shared per-host download states.",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z
|
||||
.array(
|
||||
z.object({
|
||||
id: Library.Info.shape.id,
|
||||
game: Game.Info,
|
||||
playtime2w: Library.Info.shape.playtime2w,
|
||||
playtimeForever: Library.Info.shape.playtimeForever,
|
||||
lastPlayed: Library.Info.shape.lastPlayed,
|
||||
download: GameDownload.Info.nullable()
|
||||
})
|
||||
)
|
||||
.meta({
|
||||
description: 'Library entries with game data',
|
||||
example: [Examples.Library]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Library entries'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const data = await Library.listByUserWithGames(Actor.userID);
|
||||
return c.json({ data });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/sync',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['Library'],
|
||||
summary: "Sync a user's Steam library",
|
||||
description:
|
||||
'Batch upsert games and library entries for a user from Steam owned games data. Admin only.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
gamesSynced: z.number(),
|
||||
libraryEntries: z.number(),
|
||||
failedEntries: z.array(z.number())
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Sync result'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
userId: z.string().meta({
|
||||
description: 'The user to sync library for',
|
||||
example: Examples.User.id
|
||||
}),
|
||||
games: z
|
||||
.array(
|
||||
z.object({
|
||||
steamAppId: z.number().int().meta({
|
||||
description: 'Steam application ID',
|
||||
example: Examples.Game.steamAppId
|
||||
}),
|
||||
name: z.string().meta({
|
||||
description: 'Game title',
|
||||
example: Examples.Game.name
|
||||
}),
|
||||
playtimeForever: z.number().int().optional().meta({
|
||||
description: 'Total playtime in minutes',
|
||||
example: Examples.Library.playtimeForever
|
||||
}),
|
||||
playtime2w: z.number().int().optional().meta({
|
||||
description: 'Playtime in last 2 weeks in minutes',
|
||||
example: Examples.Library.playtime2w
|
||||
}),
|
||||
rtimeLastPlayed: z.number().int().optional().meta({
|
||||
description: 'Last played unix timestamp',
|
||||
example: 1_700_000_000
|
||||
})
|
||||
})
|
||||
)
|
||||
.meta({
|
||||
description: 'Games to sync',
|
||||
example: [Examples.Game]
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { userId, games } = c.req.valid('json');
|
||||
|
||||
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
|
||||
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
|
||||
|
||||
const failedSteamIDs = new Set<number>();
|
||||
const gamePromises = [];
|
||||
const gameIds = []; // Storing generated IDs to use in the next step
|
||||
|
||||
// 1. Queue Games
|
||||
for (const g of games) {
|
||||
const existing = existingByAppId.get(g.steamAppId);
|
||||
const gameId = existing?.id ?? Identifier.ascending('game');
|
||||
gameIds.push(gameId); // Aligns with games array index
|
||||
|
||||
const slug =
|
||||
g.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || `app-${g.steamAppId}`;
|
||||
|
||||
gamePromises.push(
|
||||
Game.upsert({
|
||||
id: gameId,
|
||||
steamAppId: g.steamAppId,
|
||||
slug,
|
||||
name: g.name
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const gameResults = await Promise.allSettled(gamePromises);
|
||||
let gamesSynced = 0;
|
||||
|
||||
const libraryPromises = [];
|
||||
const librarySteamIds = []; // To track which promise belongs to which app
|
||||
|
||||
// 2. Evaluate Games & Queue Libraries for Successes
|
||||
for (let i = 0; i < gameResults.length; i++) {
|
||||
const g = games[i];
|
||||
|
||||
if (gameResults[i].status === 'rejected') {
|
||||
failedSteamIDs.add(g.steamAppId);
|
||||
continue; // Skip queuing library upsert if the game failed
|
||||
}
|
||||
|
||||
gamesSynced++;
|
||||
|
||||
const entryId = Identifier.ascending('userLibrary');
|
||||
const lastPlayed = g.rtimeLastPlayed
|
||||
? new Date(g.rtimeLastPlayed * 1000).toISOString()
|
||||
: null;
|
||||
|
||||
libraryPromises.push(
|
||||
Library.upsert({
|
||||
id: entryId,
|
||||
userId,
|
||||
gameId: gameIds[i],
|
||||
playtime2w: g.playtime2w ?? null,
|
||||
playtimeForever: g.playtimeForever ?? null,
|
||||
lastPlayed
|
||||
})
|
||||
);
|
||||
librarySteamIds.push(g.steamAppId);
|
||||
}
|
||||
|
||||
// 3. Execute Libraries
|
||||
const libraryResults = await Promise.allSettled(libraryPromises);
|
||||
let libraryEntries = 0;
|
||||
|
||||
for (let i = 0; i < libraryResults.length; i++) {
|
||||
if (libraryResults[i].status === 'rejected') {
|
||||
failedSteamIDs.add(librarySteamIds[i]);
|
||||
} else {
|
||||
libraryEntries++;
|
||||
}
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: {
|
||||
gamesSynced,
|
||||
libraryEntries,
|
||||
failedEntries: Array.from(failedSteamIDs)
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
231
apps/api/app/routes/machine.ts
Normal file
231
apps/api/app/routes/machine.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { Member } from '@nestri/core/team/member';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Host registration.
|
||||
*
|
||||
* A box does not get to say who it is. It registers once against its owner's
|
||||
* session, is handed an id and a secret, and authenticates as itself from then
|
||||
* on — so `hostId` on a download report is something the API assigned rather
|
||||
* than a free-form string any holder of a shared secret could invent.
|
||||
*/
|
||||
export namespace MachineApi {
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/register',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'Register a nessh host',
|
||||
description:
|
||||
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
machineId: z.string().meta({ example: Examples.Machine.id }),
|
||||
secret: z.string().meta({
|
||||
description: 'Shown once. Store it on the box; it cannot be retrieved.'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'The box is registered'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
label: z.string().min(1).max(64).meta({
|
||||
description: 'Human-readable name for the box',
|
||||
example: Examples.Machine.label
|
||||
}),
|
||||
teamId: z.string().optional().meta({
|
||||
description: 'Register the box into a team rather than to the user alone'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { label, teamId } = c.req.valid('json');
|
||||
|
||||
// `notPublic` also admits admin, which has no user to own the box.
|
||||
// Registering is an act of ownership, so it needs a real one.
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Registering a machine requires a user session'
|
||||
);
|
||||
}
|
||||
|
||||
const registered = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: Actor.userID,
|
||||
teamId: teamId ?? (actor.type === 'member' ? actor.properties.teamID : null),
|
||||
label
|
||||
});
|
||||
|
||||
return c.json({ data: { machineId: registered.id, secret: registered.secret } });
|
||||
}
|
||||
)
|
||||
.patch(
|
||||
'/:id',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'Move a box into a team, or out of one',
|
||||
description:
|
||||
'Scope a machine you own to a team you belong to, or pass teamId: null to make it yours alone again. This is not ownership transfer — the owner does not change.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||
description: 'The machine, rescoped'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
teamId: z.string().nullable().meta({
|
||||
description: 'Team to scope the box to, or null to scope it to you alone'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { teamId } = c.req.valid('json');
|
||||
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Rescoping a machine requires a user session'
|
||||
);
|
||||
}
|
||||
|
||||
// Verified before the write. `setTeam` scopes to the owner but
|
||||
// knows nothing about who belongs to the target team, so this is
|
||||
// the only place that check exists.
|
||||
if (teamId) {
|
||||
const membership = await Member.findByTeamAndUser({
|
||||
teamId,
|
||||
userId: Actor.userID
|
||||
});
|
||||
if (!membership) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You are not a member of that team'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const machine = await Machine.setTeam({
|
||||
id: c.req.param('id'),
|
||||
ownerUserId: Actor.userID,
|
||||
teamId
|
||||
});
|
||||
if (!machine) {
|
||||
// Owner-scoped in the query, so someone else's machine is a
|
||||
// 404 rather than a 403 — no way to probe for ids.
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such machine, or it is not yours'
|
||||
);
|
||||
}
|
||||
return c.json({ data: machine });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/entitlement',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'Ask whether a user may use this box',
|
||||
description:
|
||||
'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Machine.Entitlement) } },
|
||||
description: 'Whether the user may use this machine, and why'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
validator('query', z.object({ userId: z.string().min(1) })),
|
||||
async (c) => {
|
||||
const { userId } = c.req.valid('query');
|
||||
return c.json({
|
||||
data: await Machine.entitlement({ machineId: Actor.machineID, userId })
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/me',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'Describe the calling machine',
|
||||
description:
|
||||
'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||||
description: 'The calling machine'
|
||||
},
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const machine = await Machine.fromID(Actor.machineID);
|
||||
if (!machine) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'This machine no longer exists'
|
||||
);
|
||||
}
|
||||
return c.json({ data: machine });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Machine'],
|
||||
summary: 'List your registered hosts',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Machine.Info)) } },
|
||||
description: 'Machines owned by the caller'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ data: await Machine.listByOwner(Actor.userID) });
|
||||
}
|
||||
);
|
||||
}
|
||||
148
apps/api/app/routes/pairing-code.ts
Normal file
148
apps/api/app/routes/pairing-code.ts
Normal file
@@ -0,0 +1,148 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { PairingCode } from '@nestri/core/pairing-code/index';
|
||||
import { Fingerprint } from '@nestri/core/user/fingerprint';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { adminOnly, ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* Device enrolment.
|
||||
*
|
||||
* A pairing code says "this SSH key is also me". It is deliberately not the
|
||||
* same thing as an invite, which says "you may use my box" — same shape of
|
||||
* secret, completely different authority, and merging them would let one be
|
||||
* redeemed for the other.
|
||||
*
|
||||
* Generating requires an authenticated session; claiming is done by nessh on
|
||||
* behalf of a device that has no identity yet, so it authenticates with the
|
||||
* shared admin token instead.
|
||||
*/
|
||||
export namespace PairingCodeApi {
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/',
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['PairingCode'],
|
||||
summary: 'Generate a pairing code',
|
||||
description:
|
||||
'Create a short-lived, single-use code that enrols another SSH key onto the current user.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
code: z.string().meta({ example: Examples.PairingCode.code }),
|
||||
expiresInMinutes: z.number()
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'A freshly generated pairing code'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
ttlMinutes: z.number().int().min(1).max(60).default(10).meta({
|
||||
description: 'How long the code stays valid. Short by design.'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { ttlMinutes } = c.req.valid('json');
|
||||
const code = await PairingCode.create({
|
||||
id: Identifier.ascending('pairingCode'),
|
||||
targetUserId: Actor.userID,
|
||||
ttlMinutes
|
||||
});
|
||||
|
||||
return c.json({ data: { code, expiresInMinutes: ttlMinutes } });
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/claim',
|
||||
adminOnly,
|
||||
describeRoute({
|
||||
tags: ['PairingCode'],
|
||||
summary: 'Claim a pairing code for an SSH key',
|
||||
description:
|
||||
'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
userId: z.string().meta({ example: Examples.PairingCode.targetUserId })
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'The fingerprint now belongs to this user'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
code: z.string().min(1).meta({ example: Examples.PairingCode.code }),
|
||||
fingerprint: z.string().min(1).meta({
|
||||
description: 'SSH public key fingerprint of the device being enrolled'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { code, fingerprint } = c.req.valid('json');
|
||||
|
||||
// Refuse before claiming: a code is single-use, so burning one on
|
||||
// a device that cannot be enrolled would strand the user.
|
||||
const existing = await Fingerprint.findByFingerprint(fingerprint);
|
||||
|
||||
const claimed = await PairingCode.claim({ code, fingerprint });
|
||||
if (!claimed) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'That pairing code is unknown, already used, or expired'
|
||||
);
|
||||
}
|
||||
|
||||
if (existing && existing.userId !== claimed.targetUserId) {
|
||||
// Handing a device between accounts is a different operation
|
||||
// with its own consequences for anything already linked to it;
|
||||
// `Steam.resolveSshIdentity` refuses the same case.
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'That SSH key is already enrolled to another user'
|
||||
);
|
||||
}
|
||||
|
||||
if (existing) {
|
||||
await Fingerprint.touchLastSeen(existing.id);
|
||||
} else {
|
||||
await Fingerprint.create({
|
||||
id: Identifier.ascending('userFingerprint'),
|
||||
userId: claimed.targetUserId,
|
||||
fingerprint,
|
||||
name: null
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ data: { userId: claimed.targetUserId } });
|
||||
}
|
||||
);
|
||||
}
|
||||
86
apps/api/app/routes/steam.ts
Normal file
86
apps/api/app/routes/steam.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Steam } from '@nestri/core/steam/index';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
export namespace SteamApi {
|
||||
export const route = new Hono().use(notPublic).post(
|
||||
'/link',
|
||||
describeRoute({
|
||||
tags: ['Steam'],
|
||||
summary: 'Link a Steam account',
|
||||
description: 'Link a Steam account to a user (admin) or yourself (user)',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({
|
||||
linkedAccountId: z.string().meta({
|
||||
description: 'The ID of the linked account',
|
||||
example: Examples.LinkedAccount.id
|
||||
}),
|
||||
steamId: z.string().meta({
|
||||
description: 'The Steam ID that was linked',
|
||||
example: '76561197960287930'
|
||||
})
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Steam account linked'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
steamId: z.string().min(1).meta({
|
||||
description: 'Steam ID to link',
|
||||
example: '76561197960287930'
|
||||
}),
|
||||
userId: z.string().optional().meta({
|
||||
description: 'User ID to link to (admin only; omitted when linking your own account)',
|
||||
example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
}),
|
||||
profile: z
|
||||
.record(z.string(), z.unknown())
|
||||
.optional()
|
||||
.meta({
|
||||
description: 'Steam profile data',
|
||||
example: { personaname: 'Player', avatarfull: 'https://...' }
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
const actor = Actor.use();
|
||||
|
||||
if (body.userId && actor.type !== 'admin') {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||||
'Only admin can link a Steam account for another user'
|
||||
);
|
||||
}
|
||||
|
||||
const linkedAccountID = await Steam.link({
|
||||
steamId: body.steamId,
|
||||
profile: body.profile,
|
||||
userId: body.userId
|
||||
});
|
||||
return c.json({
|
||||
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
104
apps/api/app/routes/user.ts
Normal file
104
apps/api/app/routes/user.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { User } from '@nestri/core/user/index';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
export namespace UserApi {
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['User'],
|
||||
summary: 'Get current user',
|
||||
description: "Get the authenticated user's profile",
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
User.Info.meta({
|
||||
description: 'Current user profile',
|
||||
example: Examples.User
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Current user'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
404: ErrorResponses[404],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const user = await User.fromID(Actor.userID);
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'Authenticated user not found'
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({ data: user });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/:id',
|
||||
describeRoute({
|
||||
tags: ['User'],
|
||||
summary: 'Get user',
|
||||
description: 'Get a user by their ID',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
User.Info.meta({
|
||||
description: 'User details',
|
||||
example: Examples.User
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'User details'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
429: ErrorResponses[429]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({
|
||||
description: 'ID of the user to get',
|
||||
example: Examples.User.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const userID = c.req.valid('param').id;
|
||||
|
||||
const user = await User.fromID(userID);
|
||||
|
||||
if (!user) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
`User ${userID} does not exist`
|
||||
);
|
||||
}
|
||||
|
||||
return c.json({
|
||||
data: user
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
1
apps/api/app/utils/auth.ts
Normal file
1
apps/api/app/utils/auth.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js';
|
||||
127
apps/api/app/utils/error.ts
Normal file
127
apps/api/app/utils/error.ts
Normal 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.'
|
||||
}
|
||||
};
|
||||
64
apps/api/app/utils/hook.ts
Normal file
64
apps/api/app/utils/hook.ts
Normal 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 };
|
||||
4
apps/api/app/utils/index.ts
Normal file
4
apps/api/app/utils/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './auth';
|
||||
export * from './error';
|
||||
export * from './result';
|
||||
export * from './validator';
|
||||
6
apps/api/app/utils/result.ts
Normal file
6
apps/api/app/utils/result.ts
Normal 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 }));
|
||||
}
|
||||
70
apps/api/app/utils/validator.ts
Normal file
70
apps/api/app/utils/validator.ts
Normal 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
23
apps/api/package.json
Normal 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:"
|
||||
}
|
||||
}
|
||||
542
apps/api/test/routes.test.ts
Normal file
542
apps/api/test/routes.test.ts
Normal 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 else’s 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
16
apps/api/test/setup.ts
Normal 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
13
apps/api/tsconfig.json
Normal 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
20
apps/auth/package.json
Normal 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
112
apps/auth/src/index.ts
Normal 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);
|
||||
}
|
||||
};
|
||||
227
apps/auth/test/worker.test.ts
Normal file
227
apps/auth/test/worker.test.ts
Normal 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
11
apps/auth/tsconfig.json
Normal 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"]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user