mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
Checkout, the customer portal, and the webhook that moves a team's plan. Nothing money-shaped is stored. No price, no currency, no card detail — a subscription's existence and its state are the whole of what crosses back, because they are the only two facts the product needs and anything more would be a second copy of a record somebody else is authoritative for. Currency is deliberately not ours to hold. A product carries a price per currency on their side and the customer's location picks one at checkout, so there is no figure in this API that could drift from the one somebody is charged. The team id travels as the customer's external id, which keeps the mapping on their side rather than putting a foreign primary key in our schema. Access follows their state, and the interesting cases are where that is not the same as "paying right now". Cancelling keeps the plan: they paid to the end of the period and turning them off when they click it takes something they bought. A failed card keeps it too, because a retry that ends in payment should not have cost them access in the middle. Only a revoked subscription takes it away, which is the one moment nothing is left that was paid for. An event we do not recognise changes nothing at all — new types are added by people who do not know what we do with them, and a default that moved a plan would eventually cancel an account nobody cancelled. The webhook is the only route here no session protects, because its caller has no account and never will. A signature over the raw body stands in for one, and it is checked before the body is looked at — a body that has been parsed and re-serialized is not the body that was signed. With no secret configured it refuses everything rather than accepting anything, since otherwise knowing the URL would be enough to set somebody's plan. Note also what is absent: no route sets a plan, so there is no endpoint for granting yourself a subscription. The product is written down as a definition with a script rather than clicked into a dashboard, because the two environments are separate servers and nothing made in one can be moved to the other. Promoting it is running the same script with the other token, which is the only version of that which cannot drift. It writes nothing without --apply and refuses to add a second product with a name already taken.
138 lines
4.0 KiB
TypeScript
138 lines
4.0 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 { BillingApi } from './routes/billing.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('/billing', BillingApi.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);
|
|
}
|
|
};
|