Files
netris-nestri/apps/api/app/index.ts
Wanjohi 6429ec4ff7 feat(api): record which host holds a Steam token for whom
A host that signs a person into Steam ends up holding a refresh token. The
control plane needs to know that happened — to show it, and so a host that
lost its disk can find out what it is expected to hold — but it must not know
the credential, because the token is bound to the address that obtained it and
a copy anywhere else is the account-theft signal Steam watches for.

So `steam_enrolment` stores the outcome and has no token column, no encrypted
token column, and no column that could hold one later. The safeguard is that
the credential is never sent here at all; a nullable column would be the first
step in undoing it, so a test asserts the column list exactly and fails if one
appears. Three machine-authenticated routes go with it: report a completed
sign-in, report that Steam refused the token, and list what this host should
have. All three take the host from its own credentials, so a box can neither
report onto nor read another box's hardware. Their bodies are strict, so a
host that sends a token is told it is wrong rather than quietly believed —
which also keeps the value out of the request log.

The Steam id is deliberately not unique. One account signed in on two hosts is
two rows and two tokens, and a unique index there would look like hygiene while
refusing somebody their second box.

There is no `pending` state: a sign-in challenge lives about two minutes inside
one process, and nothing outside it needs to know it exists. Nothing revokes
yet, and `last_ok_at` has no writer — a successful logon happens where there is
no credential to report it with — so the column exists with the shape it will
need and stays null rather than being filled with the nearest event that was
easy to observe.
2026-09-06 13:27:51 +03:00

137 lines
3.9 KiB
TypeScript

import type { Hyperdrive } from '@cloudflare/workers-types';
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 { EnrolmentApi } from './routes/enrolment.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 { SessionApi } from './routes/session.js';
import { SteamApi } from './routes/steam.js';
import { UserApi } from './routes/user.js';
import { WaitlistApi } from './routes/waitlist.js';
export const app = new Hono();
app
.use(logger())
.use(async (c, next) => {
c.header('Cache-Control', 'no-store');
return next();
})
.use(
cors({
origin: () => '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('/machine', SessionApi.machineRoute)
.route('/machine', EnrolmentApi.route)
.route('/session', SessionApi.route)
.route('/access-token', AccessTokenApi.route)
.route('/waitlist', WaitlistApi.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: [] }]
}
})
);
/**
* Everything this app is handed, from a binding or from the environment.
*
* Two things here arrive one of two ways, and neither is a special case.
* `HYPERDRIVE` carries a connection string on a platform that pools
* connections for us, and `DATABASE_URL` says the same thing where nothing
* does. An `AUTH` binding is a route to the issuer that skips the internet,
* and `AUTH_INTERNAL_URL` is that route written out. Each pair is two
* spellings of one fact rather than two deployments, which is why nothing
* below branches on the runtime it is under.
*
* `AUTH_ISSUER_URL` is not part of either pair. It is the issuer's public
* *name*, it is required, and it is the same value however the issuer is
* reached — because it is what every token's `iss` claim is checked against.
*/
export type ApiEnv = {
AUTH?: { fetch: typeof fetch };
AUTH_ISSUER_URL?: string;
AUTH_INTERNAL_URL?: string;
HYPERDRIVE?: Hyperdrive;
DATABASE_URL?: string;
ADMIN_SHARED_SECRET?: string;
};
export default {
fetch(request: Request, env: ApiEnv, ctx?: ExecutionContext) {
Env.init(env as unknown as Record<string, unknown>);
return app.fetch(request, env, ctx);
}
};