Billing: burn windows, organisations, and payment (#342)

Nine commits, in the order they are easiest to read.

## The shared operator secret is gone

`x-nestri-admin-token` had no caller left — the device pairing it
existed for is
on hold, and nothing in any tree sent it. What remained was a key that
bypassed
authentication entirely and was required to boot.

Every route behind it had a better answer. The two Steam sync routes
took a
`userId` **in the body**, so one secret could write into anybody's
library; they
now authenticate as the host holding that person's Steam sign-in, and
the claim
is checked against the enrolment record. Download-state narrows to hosts
alone.
Creating a game by hand is deleted (syncing already upserts the
catalogue), as is
reading the waitlist — every address on it belongs to someone who has
not agreed
to anything, and answering it over HTTP made that list something a
leaked key
could drain.

Nothing in the API now accepts a credential standing for more than one
caller.

## An organisation owns hardware

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 had to be
registered under
an employee's personal team — where their account going away took it
with them.

Ownership is now an either/or, enforced by a check constraint rather
than a
convention: both null is a host nothing can bill, and both set is two
answers to
"whose is this?". Membership of an organisation is derived from a
verified email
domain rather than stored, so signing in with a personal address still
gets an
ordinary personal account.

Not a billing subject. A team pays for what it uses either way.

## Burn, and the three windows

The unit is one second of a reference session, so an allowance is
measured in
time and a bar prints the stored number rather than converting into it.

Counters are stored beside the time they began, and a total whose stamp
has
rolled outside its window reads as zero — so a window clears without
anything
running. No schedule to misfire, no race between a reset and a write.

Two rules on the allowances are enforced rather than remembered: an
allowance
must exceed its own window, or one uninterrupted session hits a wall;
and each
longer one must sit under what the shorter already permits, or it never
binds and
is decoration.

Burn is recorded as segments at one rate, because a run's rate does not
survive
its own lifetime. On our own hardware a bigger tier costs more; on a
caller's own
card it does not, since there is no share of a card of ours being spent.

The gate is at the one moment it may speak — before a run starts, never
again. A
limit refuses the next run and never interrupts one going.

## Payment

Checkout, portal, and a webhook. No price, no currency and no card
detail is
stored: a subscription's existence and its state are the whole of what
crosses
back. Free is a real subscription too, created outright since nothing a
month
needs no payment, so an upgrade changes a subscription rather than
inventing a
customer.

The webhook is the only route no session protects. A signature over the
raw body
stands in for one, checked before the body is parsed, and with no secret
configured it refuses everything.

## Breaking

- `x-nestri-admin-token` is no longer accepted; `ADMIN_SHARED_SECRET` is
no longer read
- `POST /games`, `GET /waitlist` and the `/pairing-code` routes are gone
- `POST /games/sync` and `POST /library/sync` now need host credentials
and take `userId` in the body
- `POST /steam/link` no longer accepts `userId`; `POST
/games/download-state` no longer accepts `hostId`
- `POST /session` responds `{ data, billing }` rather than `{ data }`

## Checks

342 tests pass. Typecheck unchanged from before the branch — the two
pre-existing
errors in `utils/hook.ts` and `utils/validator.ts` are untouched.

Billing is inert until configured: unset `POLAR_*` means every team is
free, no
checkout starts, and the webhook refuses every delivery.
This commit is contained in:
Wanjohi
2026-09-18 22:13:48 +00:00
committed by GitHub
81 changed files with 33737 additions and 25581 deletions

View File

@@ -281,4 +281,3 @@ Route handler
└─ returns normally ─────► c.json({ data: … })
```

View File

@@ -50,7 +50,6 @@ COPY packages/auth packages/auth
# DATABASE_URL postgres://… required
# AUTH_ISSUER_URL the issuer's public URL required
# STEAM_API_KEY for linking an account
# ADMIN_SHARED_SECRET operator access
ENV NODE_ENV=production
ENV PORT=3000
EXPOSE 3000

View File

@@ -11,17 +11,17 @@ and returns `{ data: ... }`. All business logic lives in the core package.
Routes:
| Prefix | Purpose |
| ----------------- | ------------------------------------------------------------- |
| `/` | Health check |
| `/user` | Current user profile, fingerprints, linked accounts |
| `/steam` | Link / sync / unlink a Steam account |
| `/library` | Owned games with playtime |
| `/games` | Game catalog |
| `/pairing-code` | Device pairing codes |
| `/machine` | Host machines |
| `/access-token` | Short-lived access tokens |
| `/doc` | Generated OpenAPI spec |
| Prefix | Purpose |
| --------------- | --------------------------------------------------- |
| `/` | Health check |
| `/user` | Current user profile, fingerprints, linked accounts |
| `/steam` | Link / sync / unlink a Steam account |
| `/library` | Owned games with playtime |
| `/games` | Game catalog |
| `/pairing-code` | Device pairing codes |
| `/machine` | Host machines |
| `/access-token` | Short-lived access tokens |
| `/doc` | Generated OpenAPI spec |
## Structure
@@ -29,7 +29,7 @@ Routes:
app/
index.ts # The handler: middleware, routes, error handler, /doc
server.ts # The same handler behind a listening socket
middleware/auth.ts # Bearer JWT + admin shared-secret auth → Actor
middleware/auth.ts # Bearer JWT, access token or host credentials → Actor
routes/*.ts # Thin route namespaces (UserApi, SteamApi, ...)
utils/ # ErrorResponses, Result(), validator wrapping
wrangler.jsonc # Worker configuration, one environment per stage
@@ -39,12 +39,11 @@ test/ # Route tests
## Key details
- Auth: `Authorization: Bearer <JWT>` verified against `@nestri/auth`; or the `x-nestri-admin-token` header
carrying `ADMIN_SHARED_SECRET`, which bypasses JWT verification entirely and is required — it has no
default anywhere. It is what authenticates the callers that have no user identity to present:
`POST /pairing-code/claim` (a device being paired has no identity yet, which is the whole point),
`POST /games`, `POST /games/sync`, `POST /library/sync`, `GET /waitlist`, `POST /steam/link` on behalf
of another user, and `POST /games/download-state` when an operator is repairing state a box reported.
- Auth: `Authorization: Bearer …`, carrying either a session token verified against `@nestri/auth`
or a personal access token resolved from the database; or a registered host's own
`x-nestri-machine-id` and `x-nestri-machine-secret`. There is no shared secret and no credential
that stands for more than one caller, so every route resolves to a specific user or a specific
host — which is what lets a route say "the caller's own library" and mean it.
- Errors: centralized `VisibleError` → typed JSON responses.
- Settings arrive as bindings or as environment variables, and two of them have one spelling of
each: Postgres is `HYPERDRIVE` or `DATABASE_URL`, and the route to the issuer is an `AUTH`
@@ -60,4 +59,4 @@ bun run serve # as a plain process, on $PORT (default 3000)
```
Needs a Postgres database and a reachable issuer. Full list and deployment steps:
[`docs/deploy.md`](../../docs/deploy.md).
[`docs/deploy.md`](../../docs/deploy.md).

View File

@@ -10,12 +10,13 @@ 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 { PairingCodeApi } from './routes/pairing-code.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';
@@ -43,7 +44,8 @@ const routes = app
.route('/steam', SteamApi.route)
.route('/library', LibraryApi.route)
.route('/games', GameApi.route)
.route('/pairing-code', PairingCodeApi.route)
.route('/billing', BillingApi.route)
.route('/organisation', OrganisationApi.route)
.route('/machine', MachineApi.route)
.route('/machine', SessionApi.machineRoute)
.route('/machine', EnrolmentApi.route)
@@ -125,7 +127,6 @@ export type ApiEnv = {
AUTH_INTERNAL_URL?: string;
HYPERDRIVE?: Hyperdrive;
DATABASE_URL?: string;
ADMIN_SHARED_SECRET?: string;
};
export default {

View File

@@ -85,11 +85,6 @@ function getClient(env: Record<string, unknown>) {
}
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.
@@ -105,7 +100,8 @@ export const auth: MiddlewareHandler = async (c, next) => {
properties: {
machineID: machine.id,
ownerUserID: machine.ownerUserId,
teamID: machine.teamId
teamID: machine.teamId,
organisationID: machine.organisationId
}
},
next
@@ -263,34 +259,3 @@ export const machineOnly: MiddlewareHandler = async (_, next) => {
}
return next();
};
/**
* A box reporting about itself, or an operator reaching in.
*
* The two are not equivalent and routes behind this must not treat them so: a
* machine may only speak for itself, while admin still has to say which host
* it means. Keeping admin is what lets an operator repair state by hand.
*/
export const machineOrAdmin: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'machine' && actor.type !== 'admin') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Machine or admin credentials required'
);
}
return next();
};
export const adminOnly: MiddlewareHandler = async (_, next) => {
const actor = Actor.use();
if (actor.type !== 'admin') {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
'Admin access required'
);
}
return next();
};

View File

@@ -0,0 +1,197 @@
import { Actor } from '@nestri/core/actor';
import { Billing } from '@nestri/core/billing/index';
import { Polar } from '@nestri/core/billing/polar';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Team } from '@nestri/core/team/index';
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';
/**
* Paying, and seeing what has been spent.
*
* The webhook at the bottom is **the only route in this app that no session
* protects**, and that is not an oversight: it is called by somebody else's
* server, which has no account here and never will. What stands in for a
* session is a signature over the raw body, and it is checked before the body
* is looked at.
*
* Everything else is ordinary and team-scoped. Note what is missing: there is
* no route that sets a plan. A plan is what the provider says it is, so the
* only thing that writes one is a delivery that proved it came from them —
* anything else would be an endpoint for granting yourself a subscription.
*/
export namespace BillingApi {
/** The team the caller is acting for, which is who the bill belongs to. */
async function payingTeam(): Promise<string> {
const actor = Actor.use();
if (actor.type === 'member') {
return actor.properties.teamID;
}
const team = await Team.personalFor(Actor.userID);
if (!team) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'You have no team to bill'
);
}
return team.id;
}
function mustBeConfigured() {
if (!Polar.configured()) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured on this deployment'
);
}
}
export const route = new Hono()
.post(
'/webhook',
describeRoute({
tags: ['Billing'],
summary: 'Receive a subscription event',
description:
'Called by the payment provider, not by you. Authenticated by a signature over the raw body rather than by a session, because the caller has no account here. Deliveries for a customer we did not create are acknowledged and ignored — a retry loop against a delivery nothing can act on helps nobody.',
responses: {
200: { description: 'Delivery accepted' },
401: ErrorResponses[401]
}
}),
async (c) => {
mustBeConfigured();
// The raw body, before any parsing. A body that has been through
// `JSON.parse` and re-serialized is not the body that was signed,
// and the signature is the whole of this route's authentication.
const body = await c.req.text();
const headers: Record<string, string> = {};
c.req.raw.headers.forEach((value, key) => {
headers[key] = value;
});
const delivery = Polar.receive({ body, headers });
// Acknowledged rather than refused. A delivery we cannot act on is
// still a delivery that arrived intact, and answering an error
// would have them retry it for days against a thing that will
// never become actionable.
if (!delivery.teamId || !delivery.standing) {
return c.json({ data: { applied: false, type: delivery.type } });
}
const updated = await Team.setPlan({
id: delivery.teamId,
plan: delivery.standing.plan,
subscriptionStatus: delivery.standing.status
});
return c.json({
data: { applied: Boolean(updated), type: delivery.type }
});
}
)
.get(
'/',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'What you are on, and what you have spent',
description:
'The plan, and where each of the three windows stands. This is what a meter is drawn from — the percentages here are the same numbers that decide whether a run may start, so a full bar and a refusal cannot disagree.',
responses: {
200: {
content: { 'application/json': { schema: Result(Billing.State) } },
description: 'Your standing'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
async (c) => c.json({ data: await Billing.state({ teamId: await payingTeam() }) })
)
.post(
'/checkout',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'Start paying',
description:
'Returns a URL to send the person to. The price they are shown is set on the providers side per currency and chosen from where they are, so nothing here names an amount — there is no figure in this API that could drift from the one they are charged.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
id: z.string().meta({ description: 'The checkout' }),
url: z.url().meta({ description: 'Where to send the person' })
})
)
}
},
description: 'A checkout to send them to'
},
401: ErrorResponses[401],
404: ErrorResponses[404],
500: ErrorResponses[500]
}
}),
validator(
'json',
z
.object({
successUrl: z.url().optional().meta({
description: 'Where to return to once they have paid'
})
})
.strict()
),
async (c) => {
mustBeConfigured();
const teamId = await payingTeam();
const user = await User.fromID(Actor.userID);
return c.json({
data: await Polar.checkout({
teamId,
email: user?.email ?? undefined,
successUrl: c.req.valid('json').successUrl
})
});
}
)
.get(
'/portal',
notPublic,
describeRoute({
tags: ['Billing'],
summary: 'Manage what you are paying',
description:
'A URL to the providers own portal, where a card is changed, an invoice is read and a subscription is cancelled. None of that is rebuilt here, because rebuilding it would mean holding payment details in order to show them.',
responses: {
200: {
content: {
'application/json': {
schema: Result(z.object({ url: z.url() }))
}
},
description: 'Where to manage it'
},
401: ErrorResponses[401],
404: ErrorResponses[404],
500: ErrorResponses[500]
}
}),
async (c) => {
mustBeConfigured();
return c.json({ data: await Polar.portal({ teamId: await payingTeam() }) });
}
);
}

View File

@@ -10,7 +10,7 @@ import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
import { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
const SyncGameSchema = z.object({
steamAppId: z.number().int(),
@@ -150,12 +150,12 @@ export namespace GameApi {
.post(
'/sync',
notPublic,
adminOnly,
machineOnly,
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.',
'Bulk upsert games, library entries and depot info from a Steam sync. Entries land in the caller\u2019s own library; there is no field for naming another user.',
responses: {
200: {
content: {
@@ -180,13 +180,17 @@ export namespace GameApi {
validator(
'json',
z.object({
userId: z.string(),
userId: z.string().meta({
description: 'Which of the host\u2019s enrolled users this sync is for',
example: Examples.User.id
}),
games: z.array(SyncGameSchema).default([]),
library: z.array(SyncLibrarySchema).default([])
})
),
async (c) => {
const { userId, games, library } = c.req.valid('json');
const { games, library } = c.req.valid('json');
const userId = await enrolledUser(c.req.valid('json').userId);
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
@@ -347,7 +351,7 @@ export namespace GameApi {
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.',
'Returns the per-host download states for a game, optionally filtered to one host. This is the recorded state, written by hosts as they report progress; a client holding a live connection to a host has a fresher answer from the host itself.',
responses: {
200: {
content: {
@@ -409,12 +413,12 @@ export namespace GameApi {
.post(
'/download-state',
notPublic,
machineOrAdmin,
machineOnly,
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.',
'Update the shared per-host download state for a game, on terminal events (start/verifying/complete/fail). A host reports as itself \u2014 which host it is comes from its own credentials, and there is no field that could name another.',
responses: {
200: {
content: {
@@ -437,63 +441,42 @@ export namespace GameApi {
}),
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(GameDownload.Status).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
z
.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
status: z.enum(GameDownload.Status).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
})
})
})
// A body naming a host is refused rather than ignored. It used
// to carry one, so a caller that still sends it is saying
// something this route no longer honours, and accepting it
// quietly would look like it had been.
.strict()
),
async (c) => {
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
c.req.valid('json');
const { 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;
}
// A host reports as itself, and `machineOnly` is what makes that
// the only possibility: with the id read from its credentials
// there is no body field to disagree with them.
const reportingHostId = Actor.machineID;
const game = await Game.fromSteamAppID(steamAppId);
if (!game) {
@@ -517,102 +500,5 @@ export namespace GameApi {
data: { downloadId: row.id, download: GameDownload.serialize(row) }
});
}
)
.post(
'/',
notPublic,
adminOnly,
describeRoute({
tags: ['Games'],
summary: 'Create or update a game',
description: 'Upsert a game by Steam app ID. Admin only.',
responses: {
201: {
content: {
'application/json': {
schema: Result(
Game.Info.meta({
description: 'The created or updated game',
example: Examples.Game
})
)
}
},
description: 'Game created or updated'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
name: z.string().meta({
description: 'Game title',
example: Examples.Game.name
}),
slug: z.string().optional().meta({
description: 'URL-friendly slug',
example: Examples.Game.slug
}),
type: z.string().nullable().optional().meta({
description: 'Content type',
example: Examples.Game.type
}),
clientIcon: z.string().nullable().optional().meta({
description: 'Steam client icon hash (256×256 square)',
example: Examples.Game.clientIcon
}),
icon: z.string().nullable().optional().meta({
description: 'Steam icon hash (32×32)',
example: Examples.Game.icon
}),
shortDescription: z.string().nullable().optional().meta({
description: 'Short description',
example: Examples.Game.shortDescription
}),
description: z.string().nullable().optional().meta({
description: 'Full description',
example: Examples.Game.description
}),
developers: z.array(z.string()).nullable().optional().meta({
description: 'Game developers',
example: Examples.Game.developers
}),
publishers: z.array(z.string()).nullable().optional().meta({
description: 'Game publishers',
example: Examples.Game.publishers
}),
genres: z.array(z.string()).nullable().optional().meta({
description: 'Game genres',
example: Examples.Game.genres
}),
oslist: z.array(z.string()).nullable().optional().meta({
description: 'Supported OS list',
example: Examples.Game.oslist
}),
releaseDate: z.string().nullable().optional().meta({
description: 'Release date ISO string',
example: Examples.Game.releaseDate
})
})
),
async (c) => {
const body = c.req.valid('json');
const id = Identifier.ascending('game');
const slug =
body.slug ??
body.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const game = await Game.upsert({ ...body, id, slug });
return c.json({ data: game[0] }, 201);
}
);
}

View File

@@ -8,7 +8,7 @@ import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, adminOnly, notPublic, Result, validator } from '../utils';
import { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
export namespace LibraryApi {
export const route = new Hono()
@@ -56,12 +56,12 @@ export namespace LibraryApi {
)
.post(
'/sync',
adminOnly,
machineOnly,
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.',
'Batch upsert games and library entries from Steam owned games data. The library synced is the caller\u2019s own; there is no field for naming another user.',
responses: {
200: {
content: {
@@ -86,7 +86,7 @@ export namespace LibraryApi {
'json',
z.object({
userId: z.string().meta({
description: 'The user to sync library for',
description: 'Which of the host\u2019s enrolled users this library belongs to',
example: Examples.User.id
}),
games: z
@@ -121,7 +121,8 @@ export namespace LibraryApi {
})
),
async (c) => {
const { userId, games } = c.req.valid('json');
const { games } = c.req.valid('json');
const userId = await enrolledUser(c.req.valid('json').userId);
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));

View File

@@ -4,6 +4,7 @@ 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 { Organisation } from '@nestri/core/organisation/index';
import { Team } from '@nestri/core/team/index';
import { Member } from '@nestri/core/team/member';
import { Hono } from 'hono';
@@ -27,9 +28,9 @@ export namespace MachineApi {
notPublic,
describeRoute({
tags: ['Machine'],
summary: 'Register a nessh host',
summary: 'Register a 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.',
'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. Hardware belongs either to a team, which is the default and covers a host somebody brings, or to an organisation the caller belongs to, which is how hardware bought to serve other people is registered so that it is nobody\u2019s personal property.',
responses: {
200: {
content: {
@@ -64,15 +65,28 @@ export namespace MachineApi {
}),
teamId: z.string().optional().meta({
description:
'Team to own this hardware. Defaults to the callers personal team, which always exists'
'Team to own this hardware. Defaults to the caller\u2019s personal team, which always exists. Mutually exclusive with organisationId'
}),
organisationId: z.string().optional().meta({
description:
'Organisation to own this hardware outright, for a host that serves other people rather than its registrant. The caller must belong to it, and no team is recorded'
})
})
),
async (c) => {
const { label, teamId } = c.req.valid('json');
const { label, teamId, organisationId } = 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.
if (teamId && organisationId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.INVALID_PARAMETER,
'Hardware belongs to a team or to an organisation, not to both',
'organisationId'
);
}
// `notPublic` also admits a machine, which has no user to own a
// 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(
@@ -82,11 +96,39 @@ export namespace MachineApi {
);
}
// A team has to be resolved rather than defaulted to null, because
// `machine.teamId` is notNull. The order is: what the caller asked
// for, then the team they are acting inside, then their personal
// team — which `ensurePersonal` makes if this is an older user who
// has none. ref(d-0048)
// Naming an organisation registers fleet hardware: owned outright,
// with no team and no person behind it, so that it survives the
// account of whoever happened to run the command.
if (organisationId) {
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
// Membership is the verified address, so this refuses the
// same way for an organisation that does not exist and one
// the caller simply is not in — there is nothing to learn
// from the difference.
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'You do not belong to that organisation'
);
}
const fleet = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId,
label
});
return c.json({
data: { machineId: fleet.id, slug: fleet.slug, secret: fleet.secret }
});
}
// Otherwise a team has to be resolved rather than left null, since
// hardware with neither owner is hardware nothing can bill. The
// order is: what the caller asked for, then the team they are
// acting inside, then their personal team — which `ensurePersonal`
// makes if this is an older user who has none. ref(d-0048)
const owningTeam =
teamId ??
(actor.type === 'member'

View File

@@ -0,0 +1,89 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { Machine } from '@nestri/core/machine/index';
import { Organisation } from '@nestri/core/organisation/index';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, notPublic, Result } from '../utils';
/**
* The organisation a caller belongs to, and the hardware it owns.
*
* Read-only on purpose. Organisations are made by hand and their domains are
* verified by hand, because the thing a verified domain grants is membership —
* and a route that mints one would be a route that hands out membership of any
* domain somebody types. When that changes, verification is what has to be
* built first, not this file.
*
* There is no organisation to name in a path: membership is derived from the
* caller's verified address, so there is exactly one answer and asking about
* anybody else's is not a question this API takes.
*/
export namespace OrganisationApi {
/** The caller's organisation, or a 404 that says what that means. */
async function mine() {
const organisation = await Organisation.forUser(Actor.userID);
if (!organisation) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'Your address does not belong to a verified organisation domain'
);
}
return organisation;
}
export const route = new Hono()
.use(notPublic)
.get(
'/',
describeRoute({
tags: ['Organisation'],
summary: 'The organisation you belong to',
description:
'Derived from the domain of your verified email address. A personal address has no organisation, which is not an error in the product — it is the ordinary consumer account — but it is a 404 here because there is nothing to return.',
responses: {
200: {
content: { 'application/json': { schema: Result(Organisation.Info) } },
description: 'Your organisation'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
async (c) => c.json({ data: await mine() })
)
.get(
'/machines',
describeRoute({
tags: ['Organisation'],
summary: 'The hardware your organisation owns',
description:
'Every host the organisation owns outright. These belong to no team and no person, which is what separates them from a host somebody brought — those appear under the team that owns them instead.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(Machine.Info).meta({
description: 'The fleet',
example: [Examples.Machine]
})
)
}
},
description: 'The fleet'
},
401: ErrorResponses[401],
404: ErrorResponses[404]
}
}),
async (c) => {
const organisation = await mine();
return c.json({ data: await Machine.listByOrganisation(organisation.id) });
}
);
}

View File

@@ -1,178 +0,0 @@
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()
.get(
'/',
notPublic,
describeRoute({
tags: ['PairingCode'],
summary: 'List your pairing codes',
description: 'Every pairing code the current user has generated, newest first.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(PairingCode.Info).meta({
description: 'All pairing codes for the current user',
example: [Examples.PairingCode]
})
)
}
},
description: 'Pairing codes'
},
401: ErrorResponses[401],
429: ErrorResponses[429]
}
}),
async (c) => {
const rows = await PairingCode.listByUser(Actor.userID);
return c.json({ data: rows.map((row) => PairingCode.serialize(row)) });
}
)
.post(
'/',
notPublic,
describeRoute({
tags: ['PairingCode'],
summary: 'Generate a pairing code',
description:
'Create a short-lived, single-use code that enrols another SSH key onto the current user.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
code: z.string().meta({ example: Examples.PairingCode.code }),
expiresInMinutes: z.number()
})
)
}
},
description: 'A freshly generated pairing code'
},
401: ErrorResponses[401],
429: ErrorResponses[429]
}
}),
validator(
'json',
z.object({
ttlMinutes: z.number().int().min(1).max(60).default(10).meta({
description: 'How long the code stays valid. Short by design.'
})
})
),
async (c) => {
const { ttlMinutes } = c.req.valid('json');
const code = await PairingCode.create({
id: Identifier.ascending('pairingCode'),
targetUserId: Actor.userID,
ttlMinutes
});
return c.json({ data: { code, expiresInMinutes: ttlMinutes } });
}
)
.post(
'/claim',
adminOnly,
describeRoute({
tags: ['PairingCode'],
summary: 'Claim a pairing code for an SSH key',
description:
'Redeem a code and bind the supplied SSH fingerprint to the user who generated it. Admin only: the calling device has no identity yet, which is the entire point.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.object({
userId: z.string().meta({ example: Examples.PairingCode.targetUserId })
})
)
}
},
description: 'The fingerprint now belongs to this user'
},
400: ErrorResponses[400],
403: ErrorResponses[403],
404: ErrorResponses[404]
}
}),
validator(
'json',
z.object({
code: z.string().min(1).meta({ example: Examples.PairingCode.code }),
fingerprint: z.string().min(1).meta({
description: 'SSH public key fingerprint of the device being enrolled'
})
})
),
async (c) => {
const { code, fingerprint } = c.req.valid('json');
// Refuse before claiming: a code is single-use, so burning one on
// a device that cannot be enrolled would strand the user.
const existing = await Fingerprint.findByFingerprint(fingerprint);
const claimed = await PairingCode.claim({ code, fingerprint });
if (!claimed) {
throw new VisibleError(
'not_found',
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
'That pairing code is unknown, already used, or expired'
);
}
if (existing && existing.userId !== claimed.targetUserId) {
// Handing a device between accounts is a different operation
// with its own consequences for anything already linked to it;
// `Steam.resolveSshIdentity` refuses the same case.
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'That SSH key is already enrolled to another user'
);
}
if (existing) {
await Fingerprint.touchLastSeen(existing.id);
} else {
await Fingerprint.create({
id: Identifier.ascending('userFingerprint'),
userId: claimed.targetUserId,
fingerprint,
name: null
});
}
return c.json({ data: { userId: claimed.targetUserId } });
}
);
}

View File

@@ -1,4 +1,5 @@
import { Actor } from '@nestri/core/actor';
import { Billing } from '@nestri/core/billing/index';
import { Box } from '@nestri/core/box/index';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
@@ -11,7 +12,14 @@ import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
import {
ErrorResponses,
machineOnly,
notPublic,
Result,
ResultWithBilling,
validator
} from '../utils';
/**
* Requesting a run, and carrying one out.
@@ -89,17 +97,22 @@ export namespace SessionApi {
tags: ['Session'],
summary: 'Ask for a run of a box',
description:
'Creates the run in state `requested`, which is the work order the boxs host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one.',
'Creates the run in state `requested`, which is the work order the boxs host picks up. This makes no decision about where the run happens: a box already names the hardware it is placed on, so the run inherits it. Poll the run to watch it start, and re-read its ticket rather than keeping the first one. The response carries where the accounts allowance stands and what it is now spending per second, including what one more run would cost — a spent allowance refuses this call with a 429 and never interrupts a run already going.',
responses: {
201: {
content: { 'application/json': { schema: Result(Session.Info) } },
content: {
'application/json': {
schema: ResultWithBilling(Session.Info, Billing.State.nullable())
}
},
description: 'The run has been requested'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403],
404: ErrorResponses[404],
409: ErrorResponses[409]
409: ErrorResponses[409],
429: ErrorResponses[429]
}
}),
validator(
@@ -217,13 +230,28 @@ export namespace SessionApi {
conflict(Session.BOX_BUSY);
}
// The allowance is checked here and nowhere later. A limit refuses
// the next run; it never stops one already going, so this is the
// only moment it may speak. The answer comes back rather than
// being discarded, because the caller has to be told what it will
// cost and what remains — and asking a second time would let the
// number shown and the number billed disagree.
const payer = await Billing.teamForBox(box.id);
const billing = payer
? await Billing.assertMayStart({
teamId: payer.teamId,
nextTier: payer.tier,
nextHostClass: payer.hostClass
})
: null;
const session = await Session.request({
id: Identifier.ascending('session'),
boxId: box.id,
gameId: game.id,
linkedAccountId
});
return c.json({ data: session }, 201);
return c.json({ data: session, billing }, 201);
}
)
.get(

View File

@@ -79,7 +79,7 @@ export namespace SteamApi {
describeRoute({
tags: ['Steam'],
summary: 'Link a Steam account',
description: 'Link a Steam account to a user (admin) or yourself (user)',
description: 'Link a Steam account to the calling user.',
responses: {
200: {
content: {
@@ -113,13 +113,6 @@ export namespace SteamApi {
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: Examples.Id('user')
}),
profile: z
.record(z.string(), z.unknown())
.optional()
@@ -131,20 +124,14 @@ export namespace SteamApi {
),
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'
);
}
// Linking is always for the caller. It once accepted a `userId`,
// which meant one credential could attach a Steam account to any
// user \u2014 and a linked account is how a library is reached.
const linkedAccountID = await Steam.link({
steamId: body.steamId,
profile: body.profile,
userId: body.userId
userId: Actor.userID
});
return c.json({
data: { linkedAccountId: linkedAccountID, steamId: body.steamId }

View File

@@ -2,8 +2,8 @@ import { Actor } from '@nestri/core/actor';
import { Env } from '@nestri/core/env';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Examples } from '@nestri/core/examples';
import { User } from '@nestri/core/user/index';
import { Fingerprint } from '@nestri/core/user/fingerprint';
import { User } from '@nestri/core/user/index';
import { VERIFICATION_TTL_MINUTES, Verification } from '@nestri/core/user/verification';
import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
@@ -159,9 +159,7 @@ export namespace UserApi {
200: {
content: {
'application/json': {
schema: Result(
z.object({ verified: z.boolean() })
)
schema: Result(z.object({ verified: z.boolean() }))
}
},
description: 'Email verified'

View File

@@ -4,83 +4,58 @@ import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { adminOnly, ErrorResponses, Result, validator } from '../utils';
import { ErrorResponses, Result, validator } from '../utils';
/**
* Public signups for not-yet-launched features (the machines waitlist).
*
* Deliberately unauthenticated: a visitor without an account should be able
* to leave an email. The list itself is admin-only so a scraper cannot mine
* every address out of the response.
* to leave an email. There is no route that reads the list back — every
* address on it belongs to someone who has not agreed to anything yet, so it
* is answered from the database by whoever is sending the announcement rather
* than exposed as a response a scraper could mine.
*/
export namespace WaitlistApi {
export const route = new Hono()
.post(
'/',
describeRoute({
tags: ['Waitlist'],
summary: 'Join the waitlist',
description: 'Leave an email to be notified when a feature launches. Public.',
responses: {
201: {
content: {
'application/json': {
schema: Result(
Waitlist.Info.meta({
description: 'The waitlist entry (the existing one if already joined)',
example: Examples.WaitlistEntry
})
)
}
},
description: 'Joined the waitlist'
export const route = new Hono().post(
'/',
describeRoute({
tags: ['Waitlist'],
summary: 'Join the waitlist',
description: 'Leave an email to be notified when a feature launches. Public.',
responses: {
201: {
content: {
'application/json': {
schema: Result(
Waitlist.Info.meta({
description: 'The waitlist entry (the existing one if already joined)',
example: Examples.WaitlistEntry
})
)
}
},
400: ErrorResponses[400]
}
}),
validator(
'json',
z.object({
email: z.email().meta({
description: 'The email to notify',
example: Examples.WaitlistEntry.email
}),
source: z.string().default('machines').meta({
description: 'What the signup is for',
example: Examples.WaitlistEntry.source
})
})
),
async (c) => {
const { email, source } = c.req.valid('json');
const entry = await Waitlist.join({ email, source });
return c.json({ data: entry }, 201);
description: 'Joined the waitlist'
},
400: ErrorResponses[400]
}
)
.get(
'/',
adminOnly,
describeRoute({
tags: ['Waitlist'],
summary: 'List waitlist entries',
description: 'Every email currently on the waitlist. Admin only.',
responses: {
200: {
content: {
'application/json': {
schema: Result(
z.array(Waitlist.Info).meta({
description: 'All waitlist entries',
example: [Examples.WaitlistEntry]
})
)
}
},
description: 'Waitlist entries'
},
403: ErrorResponses[403]
}
}),
async (c) => c.json({ data: await Waitlist.list() })
);
}),
validator(
'json',
z.object({
email: z.email().meta({
description: 'The email to notify',
example: Examples.WaitlistEntry.email
}),
source: z.string().default('machines').meta({
description: 'What the signup is for',
example: Examples.WaitlistEntry.source
})
})
),
async (c) => {
const { email, source } = c.req.valid('json');
const entry = await Waitlist.join({ email, source });
return c.json({ data: entry }, 201);
}
);
}

View File

@@ -1 +1 @@
export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js';
export { auth, notPublic, machineOnly } from '../middleware/auth.js';

View File

@@ -0,0 +1,32 @@
import { Actor } from '@nestri/core/actor';
import { ErrorCodes, VisibleError } from '@nestri/core/error';
import { Enrolment } from '@nestri/core/steam/enrolment';
/**
* The user a host is allowed to speak about, or a refusal.
*
* One box carries several people's Steam sign-ins, so which user a batch
* belongs to has to be said rather than inferred from the credentials — and
* then checked, because a body field naming a user is otherwise a way to write
* into any library. The enrolment record is what it is checked against:
* holding a refresh token is what lets a host enumerate those games at all, so
* a host without one is reporting something it could not have observed.
*
* Shared by the two sync routes deliberately. The check is the whole boundary
* between "a host reporting what it can see" and "a host writing wherever it
* likes", and two copies of it are two things to keep in agreement.
*/
export async function enrolledUser(userId: string): Promise<string> {
const enrolment = await Enrolment.findByMachineAndUser({
machineId: Actor.machineID,
userId
});
if (!enrolment) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'This host holds no Steam sign-in for that user'
);
}
return userId;
}

View File

@@ -1,4 +1,5 @@
export * from './auth';
export * from './enrolment';
export * from './error';
export * from './result';
export * from './validator';

View File

@@ -4,3 +4,18 @@ import { z } from 'zod';
export function Result<T extends z.ZodTypeAny>(schema: T) {
return resolver(z.object({ data: schema }));
}
/**
* A result that also carries where the caller's allowance stands.
*
* Every surface that can start a run has to show what it will cost and what
* remains, so the answer travels with the thing that spends it rather than
* needing a second call nobody will make. It sits beside `data` and not inside
* it, because it describes the account rather than the resource.
*/
export function ResultWithBilling<T extends z.ZodTypeAny, B extends z.ZodTypeAny>(
schema: T,
billing: B
) {
return resolver(z.object({ data: schema, billing }));
}

View File

@@ -1,18 +1,37 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
import { app } from '../app/index';
import { TEST_ADMIN_SECRET } from './setup';
import './setup';
const sql = testDb();
const createdUserIds: string[] = [];
/**
* A signed-in person, as a personal access token.
*
* The tests below use it to prove `machineOnly` refuses a human: it needs a
* caller who is authenticated and is not a host, and a user session is the
* only kind there is.
*/
async function signedInHeaders(label: string): Promise<Record<string, string>> {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
teamId: null,
name: label
});
return { authorization: `Bearer ${pat.token}` };
}
/** A Steam ID is 17 digits; these are distinct and obviously not real. */
function steamId(n: number) {
return `765611980000${String(n).padStart(5, '0')}`;
@@ -185,7 +204,10 @@ describe('POST /machine/enrolment', () => {
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
method: 'POST',
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
headers: {
...(await signedInHeaders('enrol-nomachine')),
'content-type': 'application/json'
},
body: JSON.stringify({ userId: Identifier.ascending('user'), steamId: steamId(7) })
});
expect(res.status).toBe(403);
@@ -240,7 +262,10 @@ describe('POST /machine/enrolment/stale', () => {
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment/stale', {
method: 'POST',
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET, 'content-type': 'application/json' },
headers: {
...(await signedInHeaders('stale-nomachine')),
'content-type': 'application/json'
},
body: JSON.stringify({ userId: Identifier.ascending('user') })
});
expect(res.status).toBe(403);
@@ -272,7 +297,7 @@ describe('GET /machine/enrolment', () => {
test('machine credentials are required', async () => {
const res = await app.request('/machine/enrolment', {
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET }
headers: await signedInHeaders('list-nomachine')
});
expect(res.status).toBe(403);
});

View File

@@ -1,5 +1,6 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { testDb } from '@nestri/core/db/test';
import { Identifier } from '@nestri/core/id';
@@ -12,6 +13,25 @@ const sql = testDb();
const createdUserIds: string[] = [];
/**
* A signed-in person, as a personal access token.
*
* The tests below use it to prove `machineOnly` refuses a human: it needs a
* caller who is authenticated and is not a host, and a user session is the
* only kind there is.
*/
async function signedInHeaders(label: string): Promise<Record<string, string>> {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
teamId: null,
name: label
});
return { authorization: `Bearer ${pat.token}` };
}
/**
* A registered host, with the secret kept — which registration returns exactly
* once, so a test that needs to authenticate as a machine has to hold onto it
@@ -188,7 +208,7 @@ describe('POST /machine/heartbeat', () => {
// driven by whoever owns it.
const res = await app.request('/machine/heartbeat', {
method: 'POST',
headers: { 'x-nestri-admin-token': 'test-admin-secret-42' }
headers: await signedInHeaders('beat-nomachine')
});
expect(res.status).toBe(403);
});

View File

@@ -1,11 +1,58 @@
import { describe, expect, test } from 'bun:test';
import { AccessToken } from '@nestri/core/access-token/index';
import { Fixtures } from '@nestri/core/db/fixtures';
import { Identifier } from '@nestri/core/id';
import { Machine } from '@nestri/core/machine/index';
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 };
/**
* A signed-in person and a registered host.
*
* Between them they are every credential the API accepts, so the validation
* tests below have to pick one. There is no longer a credential that stands
* for "some authenticated caller" in general — reaching a handler means being
* a specific someone, which is the property these fixtures preserve.
*
* Built once, lazily, because the settings they need are installed by a
* `beforeEach` that has not run when a `beforeAll` would.
*/
let built: Promise<{ user: Record<string, string>; host: Record<string, string> }> | undefined;
function credentials() {
built ??= (async () => {
const owner = await Fixtures.owner('routes');
const pat = await AccessToken.create({
id: Identifier.ascending('accessToken'),
ownerUserId: owner.userId,
teamId: null,
name: 'routes'
});
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: owner.userId,
teamId: owner.teamId,
label: 'routes'
});
return {
user: { authorization: `Bearer ${pat.token}` },
host: {
'x-nestri-machine-id': registered.id,
'x-nestri-machine-secret': registered.secret
}
};
})();
return built;
}
async function userHeaders(): Promise<Record<string, string>> {
return (await credentials()).user;
}
async function hostHeaders(): Promise<Record<string, string>> {
return (await credentials()).host;
}
describe('Index', () => {
@@ -32,22 +79,6 @@ describe('Auth middleware', () => {
expect(res.status).toBe(200);
});
test('admin token gains access to protected routes', async () => {
const res = await app.request('/waitlist', {
headers: adminHeaders()
});
expect(res.status).toBe(200);
});
test('wrong admin token is treated as public → 401', async () => {
const res = await app.request('/library', {
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
@@ -61,13 +92,12 @@ describe('Auth middleware', () => {
expect(body.type).toBe('authentication');
});
test('missing authorization on admin-only route returns 401', async () => {
test('missing authorization on a protected 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');
@@ -79,7 +109,7 @@ describe('Validation', () => {
const res = await app.request('/games/sync', {
method: 'POST',
headers: {
...adminHeaders(),
...(await hostHeaders()),
'content-type': 'application/json'
},
body: '{not-json'
@@ -93,7 +123,7 @@ describe('Validation', () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
...(await hostHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({ status: 'downloading' })
@@ -107,11 +137,10 @@ describe('Validation', () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
...(await hostHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
steamAppId: 440,
status: 'bogus_status'
})
@@ -123,7 +152,7 @@ describe('Validation', () => {
test('non-existent game returns 404', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
headers: await userHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
@@ -133,7 +162,7 @@ describe('Validation', () => {
test('missing content-type header returns 400', async () => {
const res = await app.request('/games/sync', {
method: 'POST',
headers: adminHeaders(),
headers: await hostHeaders(),
body: JSON.stringify({})
});
expect(res.status).toBe(400);
@@ -143,7 +172,7 @@ describe('Validation', () => {
describe('Error response shape', () => {
test('404 on unknown game has standard error shape', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
headers: await userHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
@@ -154,7 +183,7 @@ describe('Error response shape', () => {
test('429 error responses have standard shape', async () => {
const res = await app.request('/games/gam_nonexistent', {
headers: adminHeaders()
headers: await userHeaders()
});
expect(res.status).toBe(404);
const body = (await res.json()) as any;
@@ -189,7 +218,6 @@ describe('OpenAPI doc', () => {
expect(paths).toContain('/user');
expect(paths).toContain('/user/email');
expect(paths).toContain('/user/devices');
expect(paths).toContain('/pairing-code');
expect(paths).toContain('/waitlist');
});
@@ -223,15 +251,14 @@ describe('CORS', () => {
});
describe('Download state route', () => {
test('POST /games/download-state requires hostId and steamAppId', async () => {
test('POST /games/download-state requires steamAppId', async () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
...(await hostHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
status: 'downloading'
// missing steamAppId
})
@@ -239,6 +266,20 @@ describe('Download state route', () => {
expect(res.status).toBe(400);
});
test('a host cannot name the host it is reporting for', async () => {
// Which host this is comes from the credentials. The body once carried
// it, so a caller could write download state under any box's id.
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...(await hostHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({ hostId: 'mch_someoneelse', steamAppId: 440, status: 'ready' })
});
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) {
@@ -246,11 +287,10 @@ describe('Download state route', () => {
const res = await app.request('/games/download-state', {
method: 'POST',
headers: {
...adminHeaders(),
...(await hostHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({
hostId: 'hst_test',
steamAppId: 440,
status
})
@@ -264,26 +304,11 @@ describe('Download state route', () => {
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');
// The route group's `notPublic` runs first, so this is 401 rather than
// the 403 `machineOnly` would give an authenticated non-host.
expect(res.status).toBe(401);
});
});
@@ -297,24 +322,10 @@ describe('Access tokens', () => {
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' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ name: '' })
});
expect(res.status).toBe(400);
@@ -323,7 +334,7 @@ describe('Access tokens', () => {
test('expiry is capped at a year', async () => {
const res = await app.request('/access-token', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ name: 'box', expiresInDays: 4000 })
});
expect(res.status).toBe(400);
@@ -337,12 +348,10 @@ describe('Access tokens', () => {
// 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' },
headers: { ...(await userHeaders()), '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);
expect(res.status).toBe(200);
});
test('revoking someone elses token requires authentication', async () => {
@@ -372,17 +381,17 @@ describe('Box access', () => {
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.
test('rescoping onto a team you do not belong to is refused', async () => {
// Naming a team is how hardware would otherwise be parked in somebody
// else's, so membership is checked rather than taken from the body.
const res = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ teamId: 'tem_whatever' })
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('user session');
expect(body.message).toContain('not a member');
});
test('teamId is required on the body, and null is no longer a value', async () => {
@@ -392,32 +401,31 @@ describe('Box access', () => {
// error rather than a meaning.
const missing = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), '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' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ teamId: null })
});
expect(explicitNull.status).toBe(400);
const named = await app.request('/machine/mch_whatever', {
method: 'PATCH',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ teamId: 'tem_whatever' })
});
// Past validation, refused at the handler for being admin.
expect(named.status).toBe(403);
expect([403, 404]).toContain(named.status);
});
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()
headers: await userHeaders()
});
expect(res.status).toBe(403);
const body = (await res.json()) as any;
@@ -441,24 +449,10 @@ describe('Machine registration', () => {
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' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ label: '' })
});
expect(res.status).toBe(400);
@@ -467,7 +461,7 @@ describe('Machine registration', () => {
});
test('describing yourself requires machine credentials', async () => {
const res = await app.request('/machine/me', { headers: adminHeaders() });
const res = await app.request('/machine/me', { headers: await userHeaders() });
expect(res.status).toBe(403);
const body = (await res.json()) as any;
expect(body.message).toContain('Machine credentials');
@@ -484,7 +478,7 @@ describe('Steam routes', () => {
const res = await app.request('/steam/link', {
method: 'POST',
headers: {
...adminHeaders(),
...(await userHeaders()),
'content-type': 'application/json'
},
body: JSON.stringify({})
@@ -507,67 +501,6 @@ describe('Library routes', () => {
});
});
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);
});
test('GET /pairing-code requires auth', async () => {
const res = await app.request('/pairing-code');
expect(res.status).toBe(401);
});
});
describe('Email routes', () => {
test('POST /user/email requires auth', async () => {
const res = await app.request('/user/email', {
@@ -581,7 +514,7 @@ describe('Email routes', () => {
test('POST /user/email rejects a malformed address', async () => {
const res = await app.request('/user/email', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ email: 'not-an-email' })
});
expect(res.status).toBe(400);
@@ -606,7 +539,7 @@ describe('Email routes', () => {
test('POST /user/email/verify requires a 6-digit code', async () => {
const res = await app.request('/user/email/verify', {
method: 'POST',
headers: { ...adminHeaders(), 'content-type': 'application/json' },
headers: { ...(await userHeaders()), 'content-type': 'application/json' },
body: JSON.stringify({ code: '12' })
});
expect(res.status).toBe(400);
@@ -662,9 +595,4 @@ describe('Waitlist routes', () => {
});
expect(res.status).toBe(400);
});
test('GET /waitlist is admin-only', async () => {
const res = await app.request('/waitlist');
expect(res.status).toBe(403);
});
});

View File

@@ -112,6 +112,13 @@ async function requestSession(s: Awaited<ReturnType<typeof scene>>) {
afterAll(async () => {
if (createdUserIds.length > 0) {
// `burn_segment` holds a session with `restrict` — deleting a run must
// not erase what it cost — so the record goes before the runs do.
await sql`delete from "burn_segment" where session_id in (
select s.id from "session" s
join "box" b on b.id = s.box_id
where b.user_id in ${sql(createdUserIds)}
)`;
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
@@ -131,7 +138,20 @@ describe('POST /session', () => {
// The field names are the contract. A rename on either side produces a
// host that starts, reads nothing, and reports success — so the shape
// is asserted whole rather than field by field.
expect(Object.keys(body)).toEqual(['data']);
// `billing` rides alongside `data` on purpose: every surface that can
// start a run has to show what it costs and what remains, and a second
// call for that is a call nobody makes.
expect(Object.keys(body)).toEqual(['data', 'billing']);
expect(body.billing.exhausted).toBe(false);
expect(body.billing.windows.map((w: { window: string }) => w.window)).toEqual([
'fiveHour',
'sevenDay',
'thirtyDay'
]);
// Nothing is live yet, so nothing is being spent — and one more run
// would cost exactly one unit per second.
expect(body.billing.rateMilli).toBe(0);
expect(body.billing.rateMilliIfOneMore).toBe(1000);
expect(body.data).toEqual({
id: body.data.id,
boxId: s.box.id,

View File

@@ -2,15 +2,13 @@ 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 };
export { TEST_FRONTEND_URL };