mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
Two kinds of machine were modelled as one. A host somebody brings is theirs, reached through a team, and should die with their account. A host bought to serve other people's workloads is none of those things — and there was nowhere to put it, so it had to be registered under an employee's personal team, where it was that person's property and their account going away took it with them. Ownership becomes an either/or. A machine names a team or an organisation, exactly one, enforced by a check constraint rather than by convention: both null is a host nothing can bill, and both set is two answers to "whose is this?" where whichever join a query happens to take decides who pays. Hardware an organisation owns has no team and no person at all, which is the point. The organisation is deliberately not a billing subject and has no plan columns. It says who owns the metal; a team pays for what it uses either way. Membership is derived from a verified email domain rather than stored. An address is already the root identity, so a second record of who belongs where is a second answer that can disagree with the first — and deriving it means signing in with a personal address still gets an ordinary personal account, which is what lets one person hold a company account and use the consumer product. Nothing is granted on an unverified domain or an unverified address: either one is a string somebody typed. Entitlement on fleet hardware refuses everyone for now, with a reason that says so. What grants a run on metered hardware is a plan, and there is nothing to ask yet, so it fails closed rather than giving the expensive case away. The branch is written out so the plan check has one obvious place to land. Routes are read-only, and nothing seeds an organisation. Creating one grants membership to everyone who can receive mail at a domain, so it is an operator action against the database — a migration that inserted one would insert it into every deployment, including ones we have nothing to do with. See docs/deploy.md.
136 lines
3.9 KiB
TypeScript
136 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 { OrganisationApi } from './routes/organisation.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('/organisation', OrganisationApi.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;
|
|
};
|
|
|
|
export default {
|
|
fetch(request: Request, env: ApiEnv, ctx?: ExecutionContext) {
|
|
Env.init(env as unknown as Record<string, unknown>);
|
|
return app.fetch(request, env, ctx);
|
|
}
|
|
};
|