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

@@ -28,10 +28,6 @@ AUTH_ISSUER_URL=http://localhost:1337
# public name is unroutable from where the API runs; docker compose sets it.
AUTH_INTERNAL_URL=
# Turns any request carrying it into an operator, so generate one rather than
# typing something: `openssl rand -hex 32`.
ADMIN_SHARED_SECRET=
# Mail delivery. All three together, or none of them plus EMAIL_DEV_LOG=true,
# which prints sign-in codes to the log instead of sending them. Printing them
# is a local-development convenience and nothing else.
@@ -39,3 +35,16 @@ EMAIL_SEND_URL=
EMAIL_API_KEY=
EMAIL_FROM=
EMAIL_DEV_LOG=true
# Billing. The provider's sandbox and production are separate servers with
# separate data, so a token from one is refused by the other and a product id
# from one means nothing to it. `POLAR_SERVER` says which you are talking to.
POLAR_SERVER=sandbox
POLAR_ACCESS_TOKEN=
POLAR_PRODUCT_ID=
# The product every team is put on at signup, priced at nothing. A free
# subscription needs no checkout, so it is created outright.
POLAR_FREE_PRODUCT_ID=
# Signs every webhook delivery. Without it the webhook route refuses everything,
# on purpose: nothing else stands in front of it.
POLAR_WEBHOOK_SECRET=

View File

@@ -138,7 +138,6 @@ jobs:
PORT=13999 HOST=127.0.0.1 \
DATABASE_URL="postgres://nobody@127.0.0.1:1/none" \
AUTH_ISSUER_URL=https://auth.nestri.io \
ADMIN_SHARED_SECRET=smoke \
./dist/nestri-api & api=$!
# NOT `/`, which the issuer answers 404 — it has no root route. NOT
# `openid-configuration` either, also a 404: this is an OAuth 2.0
@@ -168,7 +167,7 @@ jobs:
# 127.0.0.1, and a connection string containing it would satisfy the
# grep from the wrong line.
env -u HOST PORT=13997 DATABASE_URL="postgres://nobody@nowhere.invalid:1/none" \
ADMIN_SHARED_SECRET=smoke ./dist/nestri-api > /tmp/bind.log 2>&1 & probe=$!
./dist/nestri-api > /tmp/bind.log 2>&1 & probe=$!
sleep 3
kill $probe 2>/dev/null || true
grep -q "listening on http://127.0.0.1:" /tmp/bind.log || {

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`

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 };

View File

@@ -77,6 +77,7 @@
"name": "@nestri/core",
"dependencies": {
"@nestri/auth": "workspace:",
"@polar-sh/sdk": "catalog:",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.9",
"postgresql": "^0.0.1",
@@ -95,6 +96,7 @@
},
"catalog": {
"@cloudflare/workers-types": "^5.20260722.1",
"@polar-sh/sdk": "^0.49.0",
"@tsconfig/node22": "^22.0.5",
"@types/bun": "latest",
"@types/node": "^26.1.1",
@@ -383,6 +385,8 @@
"@oxlint/binding-win32-x64-msvc": ["@oxlint/binding-win32-x64-msvc@1.76.0", "", { "os": "win32", "cpu": "x64" }, "sha512-5qcirPHO8nKfkoowEVWtpAoVTcYDy6g0UT0NGic450Qv8J2NrOqg4uQ8QppRP4MDTC7Xx47lbZnmadTH03CGGA=="],
"@polar-sh/sdk": ["@polar-sh/sdk@0.49.0", "", { "dependencies": { "standardwebhooks": "^1.0.0", "zod": "^3.25.65 || ^4.0.0" } }, "sha512-9UYb70iKjJCtWYlu0OF5HLYBLmkxHwqr2RlXwuxXQgRGqq56IQWlVG+NO7e1YJ7I5GW0CBHhGIjRbQ9hYM6ycQ=="],
"@poppinss/colors": ["@poppinss/colors@4.1.6", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-H9xkIdFswbS8n1d6vmRd8+c10t2Qe+rZITbbDHHkQixH5+2x1FDGmi/0K+WgWiqQFKPSlIYB7jlH6Kpfn6Fleg=="],
"@poppinss/dumper": ["@poppinss/dumper@0.6.5", "", { "dependencies": { "@poppinss/colors": "^4.1.5", "@sindresorhus/is": "^7.0.2", "supports-color": "^10.0.0" } }, "sha512-NBdYIb90J7LfOI32dOewKI1r7wnkiH6m920puQ3qHUeZkxNkQiFnXVWoE6YtFSv6QOiPPf7ys6i+HWWecDz7sw=="],
@@ -403,13 +407,15 @@
"@speed-highlight/core": ["@speed-highlight/core@1.2.24", "", {}, "sha512-qeW2e1l78afw8VhRPfPQ1Gjj+KU5XFQ/OFV5ti6eTa9bruO7mJyZtA4vw0ofqmA3tKCkROE9xLk3VZoeRc98nw=="],
"@stablelib/base64": ["@stablelib/base64@1.0.1", "", {}, "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ=="],
"@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
"@sveltejs/acorn-typescript": ["@sveltejs/acorn-typescript@1.0.11", "", { "peerDependencies": { "acorn": "^8.9.0" } }, "sha512-LFuZUkjJ9iF7JZye/aG5XM0SFcQ5VyL0oVX4WJ9dc0Va3R3s0OauX1BESVCb+YN/ol8TAfqGDDAQsTG627Y5kw=="],
"@tsconfig/node22": ["@tsconfig/node22@22.0.5", "", {}, "sha512-hLf2ld+sYN/BtOJjHUWOk568dvjFQkHnLNa6zce25GIH+vxKfvTgm3qpaH6ToF5tu/NN0IH66s+Bb5wElHrLcw=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/bun": ["@types/bun@1.4.2", "", { "dependencies": { "bun-types": "1.4.2" } }, "sha512-GimotNn7+ZV0uVArItBbriZsR1oNf0+WTzPkdcFrzShI7k2norL0uzEaJT8T33dWr7O/c9ZDuAFQrctKCi72oQ=="],
"@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="],
@@ -485,7 +491,7 @@
"buffer-from": ["buffer-from@1.1.2", "", {}, "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"bun-types": ["bun-types@1.4.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-bxV1FgK7yBIzjRe5zBozIM4Bem11ZJcCXSrjWRG3YWLt8yFDePu4cLjpebO8OvPeIE9trbyPF4fuj3Cia4Fj3w=="],
"clone": ["clone@2.1.2", "", {}, "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w=="],
@@ -519,6 +525,8 @@
"fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
"fast-sha256": ["fast-sha256@1.3.0", "", {}, "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ=="],
"find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
@@ -637,6 +645,8 @@
"sql-escaper": ["sql-escaper@1.5.1", "", {}, "sha512-4toX5E1fQbBrpfXidaHnF0669nkAdETeIPTs2SUjxxD7RRIs9ICG4gtpmfc68JCEKehsdwLFqBu9VlQqZ1P1gg=="],
"standardwebhooks": ["standardwebhooks@1.1.1", "", { "dependencies": { "@stablelib/base64": "^1.0.0", "fast-sha256": "^1.3.0" } }, "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ=="],
"supports-color": ["supports-color@10.2.2", "", {}, "sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g=="],
"svelte": ["svelte@5.56.7", "", { "dependencies": { "@jridgewell/remapping": "^2.3.4", "@jridgewell/sourcemap-codec": "^1.5.0", "@sveltejs/acorn-typescript": "^1.0.10", "@types/estree": "^1.0.5", "@types/trusted-types": "^2.0.7", "acorn": "^8.12.1", "aria-query": "5.3.1", "axobject-query": "^4.1.0", "clsx": "^2.1.1", "devalue": "^5.8.1", "esm-env": "^1.2.1", "esrap": "^2.2.12", "is-reference": "^3.0.3", "locate-character": "^3.0.0", "magic-string": "^0.30.11", "zimmerframe": "^1.1.2" } }, "sha512-5qERUZX80oQj6XrDMUmD2Uhd/cIpCPDWWKBK3ZHmyRUC9apPyamWM8xMo31mbWsIQxwG2hVoSnOJ/EcnhVkkzQ=="],

View File

@@ -20,8 +20,7 @@
# Every one is read from `.env`, and compose refuses to start naming the
# variable it wanted rather than falling back to something. A default is worth
# less than it looks: the deployment that never set the variable is exactly the
# one where the default is a publicly known value, and `ADMIN_SHARED_SECRET`
# below bypasses authentication entirely.
# one where the default is a publicly known value.
#
# Migrations are not run for you — `bun run db:migrate` against DATABASE_URL,
# because a container that migrates on boot races with the second copy of
@@ -104,9 +103,6 @@ services:
# here. `AUTH_INTERNAL_URL` is how this container actually gets there.
AUTH_ISSUER_URL: ${AUTH_ISSUER_URL:?set AUTH_ISSUER_URL in .env}
AUTH_INTERNAL_URL: http://auth:1337
# A shared secret that turns any request carrying it into an operator.
# Required, with no default, for that reason.
ADMIN_SHARED_SECRET: ${ADMIN_SHARED_SECRET:?set ADMIN_SHARED_SECRET in .env to a value you generated}
# Every interface *inside the container*, which is what makes the
# loopback publication below reachable. The processes default to
# 127.0.0.1 because a bare process on a host has no such wrapper and a

View File

@@ -89,15 +89,11 @@ cd apps/auth
bunx wrangler secret put EMAIL_SEND_URL --env production
bunx wrangler secret put EMAIL_API_KEY --env production
bunx wrangler secret put EMAIL_FROM --env production
cd ../api
bunx wrangler secret put ADMIN_SHARED_SECRET --env production
```
`ADMIN_SHARED_SECRET` turns any request carrying it into an operator, so
generate it rather than choosing it — `openssl rand -hex 32` — and never give
it a default anywhere. What it is for is listed in
[`apps/api/README.md`](../apps/api/README.md).
The API has no secrets of its own to put here: every caller it accepts proves
who it is — a session token from the issuer, a personal access token, or a
registered host's own credentials — so there is nothing shared to leak.
The issuer refuses to send a sign-in code with its mail settings half
configured or absent, rather than falling back to printing codes to the log —
@@ -116,6 +112,45 @@ binding names a script that has to exist. The custom domains in the config are
what create the DNS records — there is no separate step, and no separate tool
holding the other half of that fact.
## Organisations, and why none are created for you
An organisation owns hardware outright — a host that serves other people's
workloads rather than its registrant's — and gathers the teams whose members
sign in with its email domain. Membership is derived from that domain, so the
`domain_verified` flag is the whole of the access decision: an address on a
verified domain *is* membership, and nothing grants anything on an unverified
one.
**Nothing seeds one, deliberately, and it must stay that way.** A migration
that inserted a row here would insert it into every deployment, including
somebody else's — handing every account on that domain membership of a
deployment its owners have nothing to do with. Seeding business data is what
makes a schema migration a back door.
So it is an operator action, run once against the database, by whoever is
allowed to decide that a domain is really theirs:
```sql
INSERT INTO organisation (id, name, slug, domain, domain_verified)
VALUES (
'org_' || substr(replace(gen_random_uuid()::text, '-', ''), 1, 26),
'Example',
'example',
'example.com',
true
);
```
Two things to get right, because nothing checks them for you. The domain is
lower-cased and has no `@` — it is compared literally against the domain half
of an address. And `domain_verified` should be `true` only for a domain you
control: everyone who can receive mail at it becomes a member the next time
they sign in, with no further step.
Hardware is then registered to it by a member, with `organisationId` instead of
a team on `POST /machine/register`. Such a host has no owner and no team, which
is the point — it outlives the account of whoever ran the command.
## Containers
```sh
@@ -139,7 +174,6 @@ Both images are stateless and hold no configuration. What they need:
| `AUTH_INTERNAL_URL` | — | only if that URL is unroutable from here |
| `EMAIL_SEND_URL` `EMAIL_API_KEY` `EMAIL_FROM` | all three, or none | — |
| `EMAIL_DEV_LOG` | `true` prints codes instead of sending | — |
| `ADMIN_SHARED_SECRET` | — | required; operator access |
| `PORT` | default `1337` | default `3000` |
[`docker-compose.yml`](../docker-compose.yml) at the root wires all of it

View File

@@ -14,7 +14,8 @@
"@types/node": "^26.1.1",
"hono": "^4.12.31",
"typescript": "^7.0.1-rc",
"zod": "^4.4.3"
"zod": "^4.4.3",
"@polar-sh/sdk": "^0.49.0"
}
},
"type": "module",

View File

@@ -14,14 +14,14 @@ src/<parent>/
### Sub-modules nested under parents
| File | Namespace | Why |
| ----------------------- | --------------- | ------------------------------------- |
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
| `user/library.*` | `Library` | User's owned games with playtime |
| `team/member.*` | `Member` | Team membership with role |
| `game/depot.*` | `Depot` | Platform-specific game content depots |
| File | Namespace | Why |
| ----------------------- | --------------- | --------------------------------------- |
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
| `user/library.*` | `Library` | User's owned games with playtime |
| `team/member.*` | `Member` | Team membership with role |
| `game/depot.*` | `Depot` | Platform-specific game content depots |
| `steam/enrolment.*` | `Enrolment` | Which host holds a Steam token for whom |
Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`.
@@ -446,7 +446,9 @@ Game ───1:N─── Download ← per-host game depot downloads
- **User**: Person record. Email is nullable (gaming accounts don't provide one).
- **LinkedAccount**: A gaming/OAuth identity. `(provider, providerAccountId)` is unique.
- **Team**: Organization for billing/collaboration. First team is auto-created as "personal" team.
- **Team**: The billing subject, and how people collaborate. The first one is auto-created as a "personal" team.
- **Organisation**: A company. Owns hardware outright — a host serving other people's workloads, belonging to no team and no person — and gathers teams under a verified email domain. Membership is derived from that domain rather than stored. **Not a billing subject**: a team pays for what it uses whether it sits under an organisation or not.
- **Machine**: A registered host. Owned by a team (a host somebody brought) or by an organisation (fleet hardware), never both and never neither — a check constraint, not a convention.
- **TeamMember**: Joins User → Team with a role. `(teamId, userId)` is unique.
---
@@ -500,7 +502,15 @@ type ActorInfo =
properties: { userID: string; teamID: string; role: 'owner' | 'admin' | 'member' };
}
| { type: 'system'; properties: { teamID: string } }
| { type: 'admin'; properties: {} };
| {
type: 'machine';
properties: {
machineID: string;
ownerUserID: string | null;
teamID: string | null;
organisationID: string | null;
};
};
```
### API
@@ -509,8 +519,8 @@ type ActorInfo =
Actor.use(); // → ActorInfo (throws if no context set)
Actor.with(value, fn); // Run fn in the given actor context
Actor.assert(type); // Assert current actor type, returns narrowed type
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'admin'
Actor.userID; // → string (user/member only)
Actor.type; // → 'public' | 'user' | 'member' | 'system' | 'machine'
Actor.userID; // → string (user/member only; refuses a machine outright)
Actor.linkedAccountID; // → string (user only)
Actor.useTeam; // → string (member/system only — the teamID)
Actor.role; // → 'owner' | 'admin' | 'member' (member only)
@@ -573,25 +583,19 @@ A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success
4. If no memberships, calls `Team.createPersonal({ displayName })`
5. Issues JWT via `context.subject('user', { userID, linkedAccountID })`
### Admin Auth via Shared Secret
Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks.
Configure `ADMIN_SHARED_SECRET` in `.env`. It has no default anywhere — a known value here is an authentication bypass, so nothing falls back to one.
### Auth Middleware (`apps/api/app/middleware/auth.ts`)
Hono middleware that runs on every API request:
1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately
2. Otherwise, reads `Authorization: Bearer <token>` header
1. Checks `x-nestri-machine-id` / `x-nestri-machine-secret` — a registered host authenticates as itself, and bad credentials fall through to `public` rather than erroring
2. Otherwise, reads `Authorization: Bearer <token>` header — a personal access token is resolved from the database, anything else is verified as a JWT
3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client`
4. If valid `user` subject:
- Checks `x-nestri-team` header for team-scoped access
- If team header present, verifies membership via `Member.findByTeamAndUser`
- Sets actor to `member` (with role) or `user` type
5. If no token/invalid: sets actor to `public` type
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route.
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. It admits machines too; what stops a host acting as its owner is `Actor.userID`, which refuses a `machine` outright.
### OpenAuth Subjects (`src/auth/subjects.ts`)

View File

@@ -5,16 +5,16 @@ serialization lives here. The API and auth workers are thin pass-through transla
## What it contains
| Area | Files | Purpose |
| ---- | ----- | ------- |
| **db** | `db/index.ts`, `db/types.ts`, `db/test.ts` | Drizzle + Postgres (`Database.use/transaction`), ULID column helpers |
| **users** | `user/*` | Users, linked accounts, fingerprints, library |
| **teams** | `team/*` | Teams + membership with roles (`team_member`) |
| **games** | `game/*` | Game catalog, depot content, per-host downloads |
| **steam** | `steam/index.ts` | Steam API integration & SSH identity resolution |
| **auth** | `auth/subjects.ts` | JWT subjects shared with the auth worker |
| **infra** | `env.ts`, `context.ts`, `actor.ts`, `fn.ts`, `id.ts`, `error.ts`, `examples.ts` | Environment, Actor model, zod-typed `fn()` wrappers, IDs, error types, examples |
| **migrations** | `migrations/` | Drizzle-kit SQL migrations for Postgres schema |
| Area | Files | Purpose |
| -------------- | ------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- |
| **db** | `db/index.ts`, `db/types.ts`, `db/test.ts` | Drizzle + Postgres (`Database.use/transaction`), ULID column helpers |
| **users** | `user/*` | Users, linked accounts, fingerprints, library |
| **teams** | `team/*` | Teams + membership with roles (`team_member`) |
| **games** | `game/*` | Game catalog, depot content, per-host downloads |
| **steam** | `steam/index.ts` | Steam API integration & SSH identity resolution |
| **auth** | `auth/subjects.ts` | JWT subjects shared with the auth worker |
| **infra** | `env.ts`, `context.ts`, `actor.ts`, `fn.ts`, `id.ts`, `error.ts`, `examples.ts` | Environment, Actor model, zod-typed `fn()` wrappers, IDs, error types, examples |
| **migrations** | `migrations/` | Drizzle-kit SQL migrations for Postgres schema |
## Conventions

View File

@@ -0,0 +1,57 @@
-- Hardware an organisation owns, rather than a person.
--
-- Two kinds of machine were being 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 until
-- now it had to be registered under some employee's personal team -- so the
-- company's card was that employee's personal property, and their account
-- going away took it. ref(d-0048)
--
-- So ownership becomes an either/or. `team_id` for a host somebody brought,
-- `organisation_id` for one a company owns outright, exactly one of them set,
-- and a check constraint rather than a convention -- because 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 would decide who pays.
--
-- `owner_user_id` becomes nullable so that fleet hardware can have no person
-- behind it at all. Its ON DELETE CASCADE is deliberately left alone: it now
-- only ever fires for a host somebody brought, where a box dying with its
-- owner's account is what that owner expects, and it cannot reach fleet
-- hardware because the column it follows is null there.
--
-- Note the check is safe to apply in one step. Every existing row has a team
-- and no organisation, so all of them already satisfy it -- which is only true
-- because `team_id` was NOT NULL before this migration relaxed it.
--
-- `organisation.domain` is what makes someone a member, and membership is
-- derived from it 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. `domain_verified` defaults false because an unverified claim
-- is a string somebody typed, and nothing may be granted on one.
--
-- The organisation deliberately has no plan or subscription columns. It says
-- who owns the metal, not who owes money; a team pays for what it uses whether
-- it sits under an organisation or not.
CREATE TABLE "organisation" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"name" text NOT NULL,
"slug" text NOT NULL,
"domain" text NOT NULL,
"domain_verified" boolean DEFAULT false NOT NULL
);
--> statement-breakpoint
ALTER TABLE "machine" ALTER COLUMN "owner_user_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "machine" ALTER COLUMN "team_id" DROP NOT NULL;--> statement-breakpoint
ALTER TABLE "machine" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
ALTER TABLE "team" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
CREATE UNIQUE INDEX "organisation_slug_unique" ON "organisation" USING btree ("slug");--> statement-breakpoint
CREATE UNIQUE INDEX "organisation_domain_unique" ON "organisation" USING btree ("domain");--> statement-breakpoint
ALTER TABLE "machine" ADD CONSTRAINT "machine_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "team" ADD CONSTRAINT "team_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "machine_organisation_idx" ON "machine" USING btree ("organisation_id");--> statement-breakpoint
CREATE INDEX "team_organisation_idx" ON "team" USING btree ("organisation_id");--> statement-breakpoint
ALTER TABLE "machine" ADD CONSTRAINT "machine_one_owner" CHECK (("machine"."team_id" is null) != ("machine"."organisation_id" is null));

View File

@@ -0,0 +1,70 @@
-- What a team has spent, and the record it is derived from.
--
-- Two tables because they answer different questions and are written at very
-- different rates. `burn_counter` is one row per team holding a running total
-- per window; `burn_segment` is the append-only record those totals come from.
-- The totals are disposable and can be rebuilt from the record, which is what
-- makes zeroing one a support action rather than data loss.
--
-- **Each total is stored beside the time it began.** A total whose stamp has
-- fallen outside its window reads as zero, so a window rolls clear without
-- anything running -- no schedule to misfire, and no race between a reset and a
-- write arriving together. The same rule on the way in is a single statement:
-- add to the total if the stamp is still inside the window, otherwise start
-- again from this amount.
--
-- Counters live apart from `team` on purpose. This row is written every time
-- anything ticks, while `team` is read on a great many paths with nothing to do
-- with billing, and keeping the hot write off the row everyone reads is worth
-- the join.
--
-- A segment is one stretch of one run at one unchanging rate. Not a row per
-- session, because a session's rate does not survive its own lifetime -- a
-- second run changes what the account spends per second while the first is
-- still going. Not a row per event either, because burn accrues continuously
-- against an envelope that is held rather than per thing consumed. So the rate
-- is stamped at the moment it applied and never edited, and the number shown is
-- the number billed because no later pass could reach a different one.
--
-- `rate_milli` is the per-second rate times a thousand, so fractional factors
-- never make any of this floating point.
--
-- The partial unique index is load-bearing: two open segments for one session
-- would double-count every tick, for as long as both stayed open, silently.
--
-- Sessions and teams are `restrict` on the segment, because deleting a run or a
-- team must not erase what it cost. ref(d-0048)
CREATE TABLE "burn_counter" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"five_hour_usage" bigint DEFAULT 0 NOT NULL,
"five_hour_at" timestamp with time zone,
"seven_day_usage" bigint DEFAULT 0 NOT NULL,
"seven_day_at" timestamp with time zone,
"thirty_day_usage" bigint DEFAULT 0 NOT NULL,
"thirty_day_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "burn_segment" (
"id" char(30) PRIMARY KEY NOT NULL,
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
"time_deleted" timestamp with time zone,
"team_id" char(30) NOT NULL,
"session_id" char(30) NOT NULL,
"rate_milli" integer NOT NULL,
"started_at" timestamp with time zone NOT NULL,
"ended_at" timestamp with time zone
);
--> statement-breakpoint
ALTER TABLE "burn_counter" ADD CONSTRAINT "burn_counter_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "burn_segment" ADD CONSTRAINT "burn_segment_session_id_session_id_fk" FOREIGN KEY ("session_id") REFERENCES "public"."session"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
CREATE UNIQUE INDEX "burn_counter_team_unique" ON "burn_counter" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "burn_segment_team_idx" ON "burn_segment" USING btree ("team_id");--> statement-breakpoint
CREATE INDEX "burn_segment_session_idx" ON "burn_segment" USING btree ("session_id");--> statement-breakpoint
CREATE UNIQUE INDEX "burn_segment_one_open_per_session" ON "burn_segment" USING btree ("session_id") WHERE "burn_segment"."ended_at" is null;

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,111 +1,125 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784801002476,
"tag": "0000_quick_dark_phoenix",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785312635128,
"tag": "0001_opposite_senator_kelly",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785379712946,
"tag": "0002_light_mesmero",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785382013687,
"tag": "0003_many_pyro",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785588097470,
"tag": "0004_remove_user_download_add_game_download",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785909838801,
"tag": "0005_flaky_may_parker",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786205230097,
"tag": "0006_waitlist_verification_game_aliases",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788460224524,
"tag": "0007_box_session_team_notnull",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788547836146,
"tag": "0008_session_one_active_run_per_box",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788555252186,
"tag": "0009_email_is_the_root_identity",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788590292860,
"tag": "0010_device_authorization_grant",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788691753961,
"tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788725541386,
"tag": "0013_machine_endpoint_id",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789680491539,
"tag": "0014_machine_public_label",
"breakpoints": true
}
]
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1784801002476,
"tag": "0000_quick_dark_phoenix",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1785312635128,
"tag": "0001_opposite_senator_kelly",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1785379712946,
"tag": "0002_light_mesmero",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1785382013687,
"tag": "0003_many_pyro",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1785588097470,
"tag": "0004_remove_user_download_add_game_download",
"breakpoints": true
},
{
"idx": 5,
"version": "7",
"when": 1785909838801,
"tag": "0005_flaky_may_parker",
"breakpoints": true
},
{
"idx": 6,
"version": "7",
"when": 1786205230097,
"tag": "0006_waitlist_verification_game_aliases",
"breakpoints": true
},
{
"idx": 7,
"version": "7",
"when": 1788460224524,
"tag": "0007_box_session_team_notnull",
"breakpoints": true
},
{
"idx": 8,
"version": "7",
"when": 1788547836146,
"tag": "0008_session_one_active_run_per_box",
"breakpoints": true
},
{
"idx": 9,
"version": "7",
"when": 1788555252186,
"tag": "0009_email_is_the_root_identity",
"breakpoints": true
},
{
"idx": 10,
"version": "7",
"when": 1788590292860,
"tag": "0010_device_authorization_grant",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788607804606,
"tag": "0011_auth_state_in_postgres",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788691753961,
"tag": "0012_steam_enrolment_without_a_token",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1788725541386,
"tag": "0013_machine_endpoint_id",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789680491539,
"tag": "0014_machine_public_label",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789762221718,
"tag": "0015_organisation_owns_fleet_hardware",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789765075037,
"tag": "0016_burn_counters_and_rate_segments",
"breakpoints": true
}
]
}

View File

@@ -18,6 +18,7 @@
},
"dependencies": {
"@nestri/auth": "workspace:",
"@polar-sh/sdk": "catalog:",
"drizzle-orm": "^0.45.2",
"postgres": "^3.4.9",
"postgresql": "^0.0.1",

View File

@@ -24,9 +24,9 @@ const out = path.join(here, '..', 'src', 'migrations.generated.ts');
type JournalEntry = { idx: number; when: number; tag: string; breakpoints: boolean };
const journal = JSON.parse(
fs.readFileSync(path.join(folder, 'meta', '_journal.json'), 'utf8')
) as { entries: JournalEntry[] };
const journal = JSON.parse(fs.readFileSync(path.join(folder, 'meta', '_journal.json'), 'utf8')) as {
entries: JournalEntry[];
};
// Deliberately identical to drizzle-orm's own `readMigrationFiles`: the same
// `--> statement-breakpoint` split, the same sha256 over the whole file, the

View File

@@ -0,0 +1,113 @@
/**
* Create the paid product, against whichever environment you point it at.
*
* Sandbox and production are separate servers with separate data, so nothing
* made in one can be moved to the other — a product designed in sandbox has to
* be *recreated* in production, and two things typed twice are two things that
* drift. So the product is written down once here, and promoting it is running
* this again with the other token.
*
* POLAR_ACCESS_TOKEN=$(cat ~/.polar_sandbox_token) \
* POLAR_SERVER=sandbox \
* bun run packages/core/scripts/polar-product.ts
*
* Add `--apply` to actually create it. Without it nothing is written and the
* script prints what it would do, because this talks to a live payment account
* and a product created by accident is visible to customers.
*
* It refuses to make a second product with the same name rather than quietly
* making a duplicate — two products called the same thing is how a checkout
* ends up pointing at the wrong one.
*/
import { Polar } from '@polar-sh/sdk';
import type { PresentmentCurrency } from '@polar-sh/sdk/models/components/presentmentcurrency.js';
/**
* The paid rung of the self-serve ladder.
*
* One recurring monthly product, priced per currency. The customer's location
* picks which price they see, so these are *presentment* prices rather than a
* conversion of one another — that is the point of listing three rather than
* charging one and letting a card issuer decide.
*
* Amounts are in minor units: 2000 is 20.00.
*/
const PRODUCT = {
name: 'Nestri Pro',
description: 'Cloud sessions on Nestri hardware, and a larger burn allowance.',
recurringInterval: 'month' as const,
prices: [
{ currency: 'usd', amount: 2000 },
{ currency: 'eur', amount: 2000 },
{ currency: 'gbp', amount: 2000 }
] satisfies { currency: PresentmentCurrency; amount: number }[]
};
const apply = process.argv.includes('--apply');
const accessToken = process.env.POLAR_ACCESS_TOKEN;
const server = (process.env.POLAR_SERVER ?? 'sandbox') as 'sandbox' | 'production';
if (!accessToken) {
console.error('POLAR_ACCESS_TOKEN is not set');
process.exit(1);
}
const polar = new Polar({ accessToken, server });
/**
* An organization token already names its organization.
*
* Sending `organizationId` alongside one is refused outright rather than
* ignored, so which kind of token this is has to be known before the call. A
* personal token can see several organizations and must say which.
*/
const scopedToOrganization = accessToken.startsWith('polar_oat_');
const organizations = await polar.organizations.listOrganizations({ limit: 2 });
const organization = organizations.result.items.at(0);
if (!organization) {
console.error('that token can see no organization');
process.exit(1);
}
if (organizations.result.items.length > 1) {
// Which one to use would be a guess, and the wrong guess bills the wrong
// company.
console.error('that token can see more than one organization; refusing to choose');
process.exit(1);
}
console.log(`server: ${server}`);
console.log(`organization: ${organization.name} (${organization.id})`);
const existing = await polar.products.list({ organizationId: organization.id, limit: 100 });
const clash = existing.result.items.find((p) => p.name === PRODUCT.name && !p.isArchived);
if (clash) {
console.log(`\nalready there: ${PRODUCT.name} (${clash.id})`);
console.log('nothing to do. Archive it first if you meant to replace it.');
process.exit(0);
}
console.log(`\nwould create: ${PRODUCT.name}, every ${PRODUCT.recurringInterval}`);
for (const price of PRODUCT.prices) {
console.log(` ${price.currency.toUpperCase()} ${(price.amount / 100).toFixed(2)}`);
}
if (!apply) {
console.log('\nnothing written. Re-run with --apply to create it.');
process.exit(0);
}
const created = await polar.products.create({
...(scopedToOrganization ? {} : { organizationId: organization.id }),
name: PRODUCT.name,
description: PRODUCT.description,
recurringInterval: PRODUCT.recurringInterval,
prices: PRODUCT.prices.map((price) => ({
amountType: 'fixed' as const,
priceCurrency: price.currency,
priceAmount: price.amount
}))
});
console.log(`\ncreated: ${created.id}`);
console.log(`set POLAR_PRODUCT_ID=${created.id} for the ${server} deployment.`);

View File

@@ -33,11 +33,6 @@ const System = z.object({
})
});
const Admin = z.object({
type: z.literal('admin'),
properties: z.object({})
});
/**
* A registered nessh host, authenticated by its own credentials.
*
@@ -50,15 +45,20 @@ const Machine = z.object({
type: z.literal('machine'),
properties: z.object({
machineID: z.string(),
ownerUserID: z.string(),
// Not optional: `machine.teamId` is notNull, so a host that authenticated
// always has a team, and the branch that used to handle its absence was
// handling a state that can no longer exist.
teamID: z.string()
// All three of these describe *who owns the hardware*, and a host is
// owned one of two ways. A box somebody brought carries an owner and a
// team; hardware an organisation owns outright carries neither and
// carries an organisation instead. Exactly one of `teamID` and
// `organisationID` is ever set, which the database enforces rather than
// this schema — so read the one you mean and do not infer it from the
// other being absent.
ownerUserID: z.string().nullable(),
teamID: z.string().nullable(),
organisationID: z.string().nullable()
})
});
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Admin, Machine]);
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Machine]);
type ActorInfo = z.infer<typeof ActorInfo>;
const _context = Context.create<ActorInfo>();

View File

@@ -86,7 +86,8 @@ describe('PostgresCodeStore', () => {
await store.create(hash(), record(), 60);
const [row] = await sql`select count(*)::int as n from authorization_code where code_hash = ${stale}`;
const [row] =
await sql`select count(*)::int as n from authorization_code where code_hash = ${stale}`;
expect(row!.n).toBe(0);
});
});

View File

@@ -0,0 +1,99 @@
import { sql } from 'drizzle-orm';
import { bigint, index, integer, pgTable, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { SessionTable } from '../session/session.sql.js';
import { TeamTable } from '../team/team.sql.js';
/**
* What a team has spent, per window, and when that total started.
*
* Its own table rather than columns on `team`, because this row is written
* every time anything ticks while `team` is read on a great many paths that
* have nothing to do with billing. Keeping the hot write off the row everyone
* reads is the whole reason for the join.
*
* **Each total is stored beside the time it began**, and a total whose stamp
* has fallen outside its window reads as zero. That is what replaces a reset
* job: nothing has to run for a window to roll clear, so there is no schedule
* to misfire and no race between a reset and a write arriving together. The
* same rule on the way in — add to the total if the stamp is still inside the
* window, otherwise start again from this amount — is one statement.
*
* Totals are derived and disposable. {@link BurnSegmentTable} is the record;
* these can be rebuilt from it, which is why zeroing one by hand is a support
* action rather than data loss.
*/
export const BurnCounterTable = pgTable(
'burn_counter',
{
...id,
...timestamps,
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'cascade' }),
// Reference-seconds, so these are readable as time. `bigint` because a
// busy team on a long window is well past what an int holds.
fiveHourUsage: bigint('five_hour_usage', { mode: 'number' }).notNull().default(0),
fiveHourAt: utc('five_hour_at'),
sevenDayUsage: bigint('seven_day_usage', { mode: 'number' }).notNull().default(0),
sevenDayAt: utc('seven_day_at'),
thirtyDayUsage: bigint('thirty_day_usage', { mode: 'number' }).notNull().default(0),
thirtyDayAt: utc('thirty_day_at')
},
(t) => [uniqueIndex('burn_counter_team_unique').on(t.teamId)]
);
/**
* One stretch of one run at one unchanging rate.
*
* Not a row per session and not a row per event. A session's rate is fixed
* when it starts — every factor is knowable before the run, which is what lets
* a person be told the cost before they commit to it — but it does not stay
* fixed for the session's life, because starting a second run changes what the
* account spends per second while the first is still going.
*
* So the record is a segment: opened when the rate becomes true, closed when it
* stops being true, and never edited afterwards. Burn is the sum of duration
* times rate over segments, every rate stamped at the moment it applied, and
* "the number shown is the number billed" holds because there is no later
* recalculation that could reach a different answer.
*/
export const BurnSegmentTable = pgTable(
'burn_segment',
{
...id,
...timestamps,
// The billing subject. Denormalized from the session's box on purpose:
// which team paid is a fact about the moment, and re-deriving it later
// through hardware that may since have moved would answer differently.
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'restrict' }),
// `restrict`, because deleting a run must not erase what it cost.
sessionId: ulid('session_id')
.notNull()
.references(() => SessionTable.id, { onDelete: 'restrict' }),
/**
* Units of burn per second, times a thousand.
*
* Scaled so the factors can be fractional without any of this becoming
* floating point: a rate of 1.5x is 1500. Burn is then
* `seconds * rate_milli / 1000`, in integers, and two readers of the
* same row cannot disagree in the last digit.
*/
rateMilli: integer('rate_milli').notNull(),
startedAt: utc('started_at').notNull(),
/** Null while the segment is the current one for that run. */
endedAt: utc('ended_at')
},
(t) => [
index('burn_segment_team_idx').on(t.teamId),
index('burn_segment_session_idx').on(t.sessionId),
// At most one open segment per run: a second would double-count every
// tick for as long as both stayed open, and silently.
uniqueIndex('burn_segment_one_open_per_session')
.on(t.sessionId)
.where(sql`${t.endedAt} is null`)
]
);

View File

@@ -0,0 +1,266 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Box } from '../box/index.js';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Game } from '../game/index.js';
import { Identifier } from '../id.js';
import { Machine } from '../machine/index.js';
import { Session } from '../session/index.js';
import { Burn } from './burn.js';
import { Window } from './window.js';
const sql = testDb();
const createdUserIds: string[] = [];
const createdGameIds: string[] = [];
async function scene(label: string, steamAppId: number) {
const owner = await Fixtures.owner(label);
createdUserIds.push(owner.userId);
const machine = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: owner.userId,
teamId: owner.teamId,
label
});
const gameId = Identifier.ascending('game');
await Game.upsert({ id: gameId, steamAppId, slug: `${label}-${steamAppId}`, name: label });
createdGameIds.push(gameId);
async function newRun() {
const box = await Box.create({
id: Identifier.ascending('box'),
userId: owner.userId,
machineId: machine.id,
label,
tier: 'sm'
});
return Session.request({
id: Identifier.ascending('session'),
boxId: box.id,
gameId,
linkedAccountId: owner.linkedAccountId
});
}
return { owner, teamId: owner.teamId, newRun };
}
afterAll(async () => {
if (createdUserIds.length > 0) {
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;
}
if (createdGameIds.length > 0) {
await sql`delete from "game" where id in ${sql(createdGameIds)}`;
createdGameIds.length = 0;
}
});
const MINUTE = 60;
function at(secondsFromStart: number, base: Date) {
return new Date(base.getTime() + secondsFromStart * 1000);
}
describe('Segments', () => {
test('a run accrues one unit a second while it is the only one', async () => {
const s = await scene('burn-solo', 7100);
const run = await s.newRun();
const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
const banked = await Burn.stop({
teamId: s.teamId,
sessionId: run.id,
at: at(10 * MINUTE, t0)
});
expect(banked).toBe(10 * MINUTE);
const counters = await Burn.counters(s.teamId);
expect(Number(counters?.fiveHourUsage)).toBe(10 * MINUTE);
expect(Number(counters?.sevenDayUsage)).toBe(10 * MINUTE);
expect(Number(counters?.thirtyDayUsage)).toBe(10 * MINUTE);
});
test('two at once cost two a second between them, not four', async () => {
// The factor is on the total. Two deadline guarantees cost twice one,
// and reading it per-session would have made this four.
const s = await scene('burn-pair', 7101);
const first = await s.newRun();
const second = await s.newRun();
const t0 = new Date();
await Burn.start({
teamId: s.teamId,
sessionId: first.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
await Burn.start({
teamId: s.teamId,
sessionId: second.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
await Burn.stop({ teamId: s.teamId, sessionId: first.id, at: at(MINUTE, t0) });
await Burn.stop({ teamId: s.teamId, sessionId: second.id, at: at(MINUTE, t0) });
const counters = await Burn.counters(s.teamId);
expect(Number(counters?.fiveHourUsage)).toBe(2 * MINUTE);
});
test('a run only pays the higher rate for the time the sibling was there', async () => {
// The reason segments exist. A rate that applied from the moment the
// second run started must not be backdated over the first run's solo
// time, and must not linger after the sibling has gone.
const s = await scene('burn-overlap', 7102);
const long = await s.newRun();
const brief = await s.newRun();
const t0 = new Date();
await Burn.start({
teamId: s.teamId,
sessionId: long.id,
tier: 'sm',
hostClass: 'byo',
at: t0
});
// One minute alone.
await Burn.start({
teamId: s.teamId,
sessionId: brief.id,
tier: 'sm',
hostClass: 'byo',
at: at(MINUTE, t0)
});
// One minute together, which costs two.
await Burn.stop({ teamId: s.teamId, sessionId: brief.id, at: at(2 * MINUTE, t0) });
// One minute alone again.
await Burn.stop({ teamId: s.teamId, sessionId: long.id, at: at(3 * MINUTE, t0) });
// 60 solo + 120 shared + 60 solo.
const counters = await Burn.counters(s.teamId);
expect(Number(counters?.fiveHourUsage)).toBe(4 * MINUTE);
});
test('stopping twice does not bill twice', async () => {
const s = await scene('burn-idempotent', 7103);
const run = await s.newRun();
const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(MINUTE, t0) });
const second = await Burn.stop({
teamId: s.teamId,
sessionId: run.id,
at: at(2 * MINUTE, t0)
});
expect(second).toBe(0);
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(MINUTE);
});
test('resegmenting mid-run banks what has been spent without stopping it', async () => {
// A long run has to land incrementally: burn that only arrives when a
// session ends is burn that cannot refuse the next one.
const s = await scene('burn-tick', 7104);
const run = await s.newRun();
const t0 = new Date();
await Burn.start({ teamId: s.teamId, sessionId: run.id, tier: 'sm', hostClass: 'byo', at: t0 });
await Burn.resegment({ teamId: s.teamId, at: at(5 * MINUTE, t0) });
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(5 * MINUTE);
expect((await Burn.openSegments(s.teamId)).length).toBe(1);
await Burn.stop({ teamId: s.teamId, sessionId: run.id, at: at(6 * MINUTE, t0) });
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(6 * MINUTE);
});
});
describe('The counters', () => {
test('the first tick and the thousandth are the same call', async () => {
// There is no row until something burns, and a read-then-insert would
// race two first ticks into two rows the unique index then refuses —
// turning an ordinary heartbeat into an error.
const s = await scene('burn-first', 7105);
expect(await Burn.counters(s.teamId)).toBeNull();
await Burn.record({ teamId: s.teamId, amount: 30 });
await Burn.record({ teamId: s.teamId, amount: 12 });
expect(Number((await Burn.counters(s.teamId))?.fiveHourUsage)).toBe(42);
});
test('a total whose window has rolled past starts again rather than adding', async () => {
// The staleness rule on the write side. Asserted through the stored
// stamp, because this is the behaviour that replaces a reset job.
const s = await scene('burn-stale', 7106);
await Burn.record({ teamId: s.teamId, amount: 100 });
// Age the five-hour stamp past its window, leaving the others fresh.
await sql`
update "burn_counter"
set five_hour_at = now() - make_interval(secs => ${Window.FIVE_HOURS + 60})
where team_id = ${s.teamId}
`;
await Burn.record({ teamId: s.teamId, amount: 7 });
const counters = await Burn.counters(s.teamId);
// Started again from the new amount alone.
expect(Number(counters?.fiveHourUsage)).toBe(7);
// The windows that had not rolled kept accumulating.
expect(Number(counters?.sevenDayUsage)).toBe(107);
expect(Number(counters?.thirtyDayUsage)).toBe(107);
});
test('recording nothing writes nothing', async () => {
const s = await scene('burn-zero', 7107);
await Burn.record({ teamId: s.teamId, amount: 0 });
expect(await Burn.counters(s.teamId)).toBeNull();
});
});
describe('Rates', () => {
test('on our hardware a bigger tier costs more, superlinearly', () => {
const rate = (tier: Burn.Tier) => Burn.baseRateMilli({ tier, hostClass: 'fleet' });
expect(rate('sm')).toBe(Burn.SCALE);
expect(rate('xl')).toBeGreaterThan(rate('lg'));
// A tier buys a share of a card, and the ladder has to pinch harder
// than the share grows or running a small title at the top of it is
// cheaper than it costs us.
expect(rate('xl') / rate('sm')).toBeGreaterThan(4);
});
test('on the caller\u2019s own hardware the tier changes nothing', () => {
// There is no share of a card of ours being spent, so charging more for
// a bigger one would be a tax on hardware they bought.
for (const tier of ['xs', 'sm', 'md', 'lg', 'xl'] as const) {
expect(Burn.baseRateMilli({ tier, hostClass: 'byo' })).toBe(Burn.SCALE);
}
});
test('a run costs the same whatever plan is paying for it', () => {
// The plan buys an allowance, never a discount on the meter. If the
// rate moved with the tier somebody is on, an upgrade would change what
// past runs cost and the bars would stop being comparable.
expect(Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' })).toBe(
Burn.baseRateMilli({ tier: 'md', hostClass: 'fleet' })
);
});
test('burn is whole seconds, never a fraction of one', () => {
expect(Burn.amountFor(90, 1500)).toBe(135);
expect(Burn.amountFor(1, 1)).toBe(0);
expect(Burn.amountFor(-5, Burn.SCALE)).toBe(0);
});
});

View File

@@ -0,0 +1,296 @@
import { and, eq, isNull, sql, type SQL } from 'drizzle-orm';
import type { PgColumn } from 'drizzle-orm/pg-core';
import z from 'zod';
import { Database } from '../db/index.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { BurnCounterTable, BurnSegmentTable } from './burn.sql.js';
import { Limits } from './limits.js';
import { Window } from './window.js';
/**
* Recording what a team spends, as segments at a constant rate.
*
* The rate of a run is knowable before it starts — that is what lets somebody
* be told the cost before they commit — but it does not stay fixed, because a
* second run changes what the account spends per second while the first is
* still going. So burn is recorded as stretches at one rate: opened when the
* rate becomes true, closed when it stops being, never edited after.
*
* Closing a segment is what moves burn into the counters, so a long run lands
* incrementally rather than all at the end. That matters for more than
* freshness: burn that only arrives when a session stops is burn that cannot
* stop the *next* session from starting, and a bar that does not move while
* something is running is a bar nobody believes.
*/
export namespace Burn {
/** Rates are scaled by this so fractional factors stay integers. */
export const SCALE = 1000;
/** Whose hardware a run is on, which is what decides the cost basis. */
export const HostClass = z.enum(['byo', 'fleet']);
export type HostClass = z.infer<typeof HostClass>;
export const Tier = z.enum(['xs', 'sm', 'md', 'lg', 'xl']);
export type Tier = z.infer<typeof Tier>;
/**
* What one run costs per second, before anything else is running.
*
* **On our own hardware the tier decides it**, because a tier buys a share
* of a card we paid for and a bigger share is more of something real being
* spent. On the caller's own hardware it does not: there is no share of a
* card of ours in play, so a run costs one unit a second whatever size it
* asked for. Charging somebody more for taking more of their own GPU is a
* tax on hardware they bought, and avoiding that is most of the point.
*
* Note what is *not* here: the number of other runs. Concurrency is on the
* account's total, not on any one run — two deadline guarantees cost twice
* one, so two runs cost the sum of their two rates and neither of them gets
* more expensive because the other started. That is why this rate is fixed
* for a run's whole life, and why a sibling starting does not have to
* rewrite anything.
*/
export const baseRateMilli = fn(
z.object({ tier: Tier, hostClass: HostClass }),
(input): number => {
if (input.hostClass === 'byo') {
return SCALE;
}
return Limits.get().factors.size[input.tier];
}
);
/** Burn from one closed stretch, in whole reference-seconds. */
export function amountFor(seconds: number, rateMilli: number): number {
return Math.floor((Math.max(0, seconds) * rateMilli) / SCALE);
}
/**
* Add to a window's total, or start it again, in one statement.
*
* The `CASE` is the whole staleness rule on the write side: if the stamp is
* still inside the window the amount joins the total and the stamp is left
* where it was; if it has rolled out, the total *becomes* this amount and
* the stamp moves to now. Reading and then deciding would be two statements
* with a gap in between, and the gap is where a concurrent tick doubles or
* vanishes.
*/
function windowSet(
usageColumn: PgColumn,
atColumn: PgColumn,
windowSeconds: number,
amount: number
): { usage: SQL; at: SQL } {
const fresh = sql`${atColumn} >= now() - make_interval(secs => ${windowSeconds})`;
return {
usage: sql`case when ${fresh} then ${usageColumn} + ${amount} else ${amount} end`,
at: sql`case when ${fresh} then ${atColumn} else now() end`
};
}
/** Apply one amount of burn to all three of a team's windows. */
export const record = fn(
z.object({ teamId: z.string(), amount: z.number().int().nonnegative() }),
async (input) => {
if (input.amount === 0) {
return;
}
const five = windowSet(
BurnCounterTable.fiveHourUsage,
BurnCounterTable.fiveHourAt,
Window.FIVE_HOURS,
input.amount
);
const seven = windowSet(
BurnCounterTable.sevenDayUsage,
BurnCounterTable.sevenDayAt,
Window.SEVEN_DAYS,
input.amount
);
const thirty = windowSet(
BurnCounterTable.thirtyDayUsage,
BurnCounterTable.thirtyDayAt,
Window.THIRTY_DAYS,
input.amount
);
await Database.use(async (tx) => {
await tx
.insert(BurnCounterTable)
.values({
id: Identifier.ascending('burnCounter'),
teamId: input.teamId,
fiveHourUsage: input.amount,
fiveHourAt: sql`now()`,
sevenDayUsage: input.amount,
sevenDayAt: sql`now()`,
thirtyDayUsage: input.amount,
thirtyDayAt: sql`now()`
})
// The first tick for a team and the thousandth are the same
// call. A read-then-insert would race two first ticks into two
// rows, which the unique index would then refuse — turning an
// ordinary heartbeat into an error.
.onConflictDoUpdate({
target: BurnCounterTable.teamId,
set: {
fiveHourUsage: five.usage,
fiveHourAt: five.at,
sevenDayUsage: seven.usage,
sevenDayAt: seven.at,
thirtyDayUsage: thirty.usage,
thirtyDayAt: thirty.at
}
});
});
}
);
/** A team's three totals, or nulls where nothing has been recorded. */
export const counters = fn(z.string(), async (teamId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(BurnCounterTable)
.where(eq(BurnCounterTable.teamId, teamId))
.then((rows) => rows.at(0) ?? null);
});
});
/** Every run currently accruing for a team. */
export const openSegments = fn(z.string(), async (teamId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(BurnSegmentTable)
.where(and(eq(BurnSegmentTable.teamId, teamId), isNull(BurnSegmentTable.endedAt)))
.orderBy(BurnSegmentTable.startedAt);
});
});
/**
* Close every open stretch for a team and start new ones at the new rate.
*
* Called whenever the number of running sessions changes, and periodically
* while they run so the counters do not lag a long session. It is one
* operation rather than a close and an open, because between them the
* account would be spending nothing — and a tick that lands in that gap
* would record a rate nobody was ever charged.
*
* Safe to call when nothing has changed: a segment closed and reopened at
* the same rate bills identically, it is just two rows instead of one.
*/
export const resegment = fn(
z.object({ teamId: z.string(), at: z.date().optional() }),
async (input) => {
return Database.transaction(async (tx) => {
const now = input.at ?? new Date();
const open = await tx
.select()
.from(BurnSegmentTable)
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
if (open.length === 0) {
return 0;
}
let total = 0;
for (const segment of open) {
const seconds = Math.floor((now.getTime() - segment.startedAt.getTime()) / 1000);
total += amountFor(seconds, segment.rateMilli);
}
await tx
.update(BurnSegmentTable)
.set({ endedAt: now })
.where(and(eq(BurnSegmentTable.teamId, input.teamId), isNull(BurnSegmentTable.endedAt)));
// Each run keeps its own rate. It is a property of what that run
// is — its tier, and whose hardware it sits on — and none of that
// changed because the clock ticked or a sibling appeared.
// Recomputing a single shared rate here would quietly reprice an
// `xl` run as whatever the last one to start was.
await tx.insert(BurnSegmentTable).values(
open.map((segment) => ({
id: Identifier.ascending('burnSegment'),
teamId: segment.teamId,
sessionId: segment.sessionId,
rateMilli: segment.rateMilli,
startedAt: now
}))
);
await record({ teamId: input.teamId, amount: total });
return total;
});
}
);
/**
* Start accruing for a run.
*
* Existing runs are resegmented first, so their old rate is banked before
* the new count applies to anybody — otherwise the change would be
* backdated over time that was spent under the old one.
*/
export const start = fn(
z.object({
teamId: z.string(),
sessionId: z.string(),
tier: Tier,
hostClass: HostClass,
at: z.date().optional()
}),
async (input) => {
return Database.transaction(async (tx) => {
const now = input.at ?? new Date();
// Bank what the runs already going have spent, so the moment this
// one appears is a clean boundary in the record rather than a
// point inside somebody else's open stretch.
await resegment({ teamId: input.teamId, at: now });
await tx.insert(BurnSegmentTable).values({
id: Identifier.ascending('burnSegment'),
teamId: input.teamId,
sessionId: input.sessionId,
rateMilli: baseRateMilli({ tier: input.tier, hostClass: input.hostClass }),
startedAt: now
});
});
}
);
/**
* Stop accruing for a run, banking what it spent.
*
* The remaining runs are resegmented afterwards, so they stop paying for a
* sibling that has gone. Idempotent: a run with nothing open is a run that
* already stopped, and saying so twice must not bill twice.
*/
export const stop = fn(
z.object({ teamId: z.string(), sessionId: z.string(), at: z.date().optional() }),
async (input) => {
return Database.transaction(async (tx) => {
const now = input.at ?? new Date();
const closed = await tx
.update(BurnSegmentTable)
.set({ endedAt: now })
.where(
and(eq(BurnSegmentTable.sessionId, input.sessionId), isNull(BurnSegmentTable.endedAt))
)
.returning();
let total = 0;
for (const segment of closed) {
const seconds = Math.floor((now.getTime() - segment.startedAt.getTime()) / 1000);
total += amountFor(seconds, segment.rateMilli);
}
await record({ teamId: input.teamId, amount: total });
await resegment({ teamId: input.teamId, at: now });
return total;
});
}
);
}

View File

@@ -0,0 +1,157 @@
import z from 'zod';
import { Box } from '../box/index.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { Machine } from '../machine/index.js';
import { Team } from '../team/index.js';
import { Burn } from './burn.js';
import { Limits } from './limits.js';
import { Window } from './window.js';
/**
* Whether a run may start, and what it will cost to have started it.
*
* The one rule this must never break: **a limit refuses the next run, it never
* stops one already going.** Someone losing a session mid-game to a meter does
* not come back, and no amount of correct arithmetic makes that a good trade.
* So everything here is asked before a run begins and never again.
*/
export namespace Billing {
export const WindowState = Window.State.extend({
window: z.enum(['fiveHour', 'sevenDay', 'thirtyDay']),
label: z.string()
});
export const State = z.object({
teamId: z.string(),
plan: z.string(),
/** True when any window is spent. */
exhausted: z.boolean(),
/**
* What the account is spending per second right now, times a thousand,
* and what it would spend with one more run.
*
* Both, because the rule is that a cost is shown *before* it is
* incurred: a person about to start a third session needs to be told
* what that does to the rate while they can still not do it.
*/
rateMilli: z.number().int(),
rateMilliIfOneMore: z.number().int(),
windows: z.array(WindowState)
});
export type State = z.infer<typeof State>;
/**
* The team that pays for a box.
*
* A box runs on a host, and the host says who owns it. Fleet hardware
* belongs to an organisation, which is not a billing subject — nothing is
* placed there yet, and when it is, what grants it is a plan rather than
* this lookup.
*/
export const teamForBox = fn(z.string(), async (boxId) => {
const box = await Box.fromID(boxId);
if (!box) {
return null;
}
const machine = await Machine.fromID(box.machineId);
if (!machine?.teamId) {
return null;
}
return {
teamId: machine.teamId,
tier: box.tier as Burn.Tier,
// Whose hardware decides the cost basis, and the machine is the only
// thing that knows. A host an organisation owns is ours to pay for;
// anything else is the caller's own card.
hostClass: (machine.organisationId ? 'fleet' : 'byo') as Burn.HostClass
};
});
/** Where a team stands, in every window, with the rates to show beside it. */
export const state = fn(
z.object({
teamId: z.string(),
/** The run being considered, so "one more" can be costed honestly. */
nextTier: Burn.Tier.optional(),
nextHostClass: Burn.HostClass.optional()
}),
async (input): Promise<State> => {
const teamId = input.teamId;
const team = await Team.fromID(teamId);
const plan = team?.plan ?? 'free';
const allowances = Limits.forPlan(plan);
const counters = await Burn.counters(teamId);
const open = await Burn.openSegments(teamId);
const windows = Window.ALL.map((window) => {
const usage = counters ? Number(counters[`${window.key}Usage`] ?? 0) : 0;
const at = counters ? (counters[`${window.key}At`] ?? null) : null;
return {
window: window.key,
label: window.label,
...Window.analyze({
allowance: allowances[window.key],
windowSeconds: window.seconds,
usage,
timeUpdated: at
})
};
});
// The account's total is the sum of what each run costs, not a count
// times one rate: an `xl` run and an `xs` one alongside it are not
// two of anything. Concurrency shows up here, as there being more to
// add, rather than as a multiplier on any of them.
const rateMilli = open.reduce((total, segment) => total + segment.rateMilli, 0);
const next = Burn.baseRateMilli({
tier: input.nextTier ?? 'sm',
hostClass: input.nextHostClass ?? 'byo'
});
return {
teamId,
plan,
exhausted: windows.some((w) => w.exhausted),
rateMilli,
rateMilliIfOneMore: rateMilli + next,
windows
};
}
);
/**
* Refuse a new run when any window is spent.
*
* Every window is checked, not the shortest: they protect different things
* over different spans, and a set where only one could ever fire is a set
* with two decorative numbers in it.
*
* The refusal names the window and when it clears, because a limit a person
* cannot plan around is the one they resent. `QUOTA_EXCEEDED` maps to 429,
* which is the honest status — this is a rate limit the customer experiences
* as a budget, and it will succeed later without anything changing.
*/
export const assertMayStart = fn(
z.object({
teamId: z.string(),
nextTier: Burn.Tier.optional(),
nextHostClass: Burn.HostClass.optional()
}),
async (input) => {
const current = await state(input);
const spent = current.windows.find((w) => w.exhausted);
if (!spent) {
return current;
}
const minutes = Math.ceil(spent.resetInSec / 60);
throw new VisibleError(
'rate_limit',
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
`Your ${spent.label} allowance is spent. It clears in about ${minutes} minute${minutes === 1 ? '' : 's'}. Runs already going are not affected.`
);
}
);
}

View File

@@ -0,0 +1,179 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { Env } from '../env.js';
import { Limits } from './limits.js';
import { Window } from './window.js';
const HOUR = 60 * 60;
/** Allowances in hours, which is how anybody actually reasons about them. */
function hours(fiveHour: number, sevenDay: number, thirtyDay: number) {
return {
fiveHour: fiveHour * HOUR,
sevenDay: sevenDay * HOUR,
thirtyDay: thirtyDay * HOUR
};
}
afterEach(() => {
Env.init({});
Limits.reset();
});
describe('The floor: one uninterrupted session must never hit a wall', () => {
test('an allowance at or below its own window is refused', () => {
// A rolling window means a single continuous session asymptotes at
// exactly the window length. So an allowance of five hours over a
// five-hour window is a wall that someone playing alone will meet, and
// meeting it is the one outcome this model may not produce.
expect(() => Limits.check(hours(5, 300, 1000), 'free')).toThrow(/must exceed the window/);
expect(() => Limits.check(hours(4, 300, 1000), 'free')).toThrow(/5-hour/);
expect(() => Limits.check(hours(10, 168, 1000), 'free')).toThrow(/7-day/);
expect(() => Limits.check(hours(10, 300, 720), 'free')).toThrow(/30-day/);
});
test('just above the window is accepted, because the rule is the floor', () => {
// Deliberately close to every bound at once: each allowance barely
// clears its own window, and each still sits under what the shorter
// window permits (170h of weekly is under 33.6 x 5.5h = 184.8h; 725h of
// monthly is under 4.29 x 170h = 728.6h). A set this tight is legal and
// miserable, which is the point — the rules bound the space, they do not
// choose within it.
expect(() => Limits.check(hours(5.5, 170, 725), 'free')).not.toThrow();
});
test('the floors are the window lengths, stated in seconds', () => {
// Spelled out so the relationship is visible rather than implied: the
// floor is not a chosen number, it is the window.
expect(Window.FIVE_HOURS).toBe(5 * HOUR);
expect(Window.SEVEN_DAYS).toBe(168 * HOUR);
expect(Window.THIRTY_DAYS).toBe(720 * HOUR);
});
});
describe('The nesting rule: every window has to bind', () => {
test('a longer allowance the shorter window already caps is refused', () => {
// The 5-hour window permits 10h per 5h sustained, which is 336h over a
// week. A 7-day allowance of 400h could never be reached, so it would
// read like a limit and never once fire.
expect(() => Limits.check(hours(10, 400, 1000), 'free')).toThrow(/never be reached/);
expect(() => Limits.check(hours(10, 300, 1400), 'free')).toThrow(/30-day/);
});
test('the ceiling is exclusive, because equality never binds either', () => {
// 33.6 x 10h is exactly 336h; at exactly the ceiling the window fires
// only in the limit, which is the same as not firing.
expect(() => Limits.check(hours(10, 336, 1000), 'free')).toThrow(/never be reached/);
expect(() => Limits.check(hours(10, 335, 1000), 'free')).not.toThrow();
});
test('the reference tier has to cost exactly one unit a second', () => {
// The unit *is* a second of a reference session, so moving `sm` off 1
// would silently redefine every allowance — the same stored number
// would be a different number of hours.
expect(() =>
Limits.checkFactors({ size: { xs: 500, sm: 900, md: 2200, lg: 5000, xl: 12000 } })
).toThrow(/reference tier/);
expect(() => Limits.checkFactors(Limits.PLACEHOLDER.factors)).not.toThrow();
});
test('the placeholder set satisfies both rules', () => {
// It is not a pricing decision, but it has to be a coherent one, or
// nothing downstream can be tested against it.
expect(() => Limits.validate(Limits.PLACEHOLDER)).not.toThrow();
});
});
describe('Configuration', () => {
test('unset takes the placeholder set', () => {
Env.init({});
Limits.reset();
expect(Limits.get()).toEqual(Limits.PLACEHOLDER);
});
test('the environment overrides it, and is validated on the way in', () => {
Env.init({
BURN_LIMITS: JSON.stringify({
free: hours(12, 350, 1200),
paid: hours(40, 1200, 4000),
factors: Limits.PLACEHOLDER.factors
})
});
Limits.reset();
expect(Limits.get().free.fiveHour).toBe(12 * HOUR);
});
test('a configured set that would not bind is refused rather than used', () => {
// The whole reason the check is code: these get retuned by whoever is
// closest to the burn data, and a set that quietly stops binding is not
// visible from the numbers.
Env.init({
BURN_LIMITS: JSON.stringify({
free: hours(10, 400, 1000),
paid: hours(30, 900, 3000),
factors: Limits.PLACEHOLDER.factors
})
});
Limits.reset();
expect(() => Limits.get()).toThrow(/never be reached/);
});
test('malformed JSON is refused, not ignored', () => {
Env.init({ BURN_LIMITS: '{not json' });
Limits.reset();
expect(() => Limits.get()).toThrow(/not valid JSON/);
});
test('anything that is not the paid plan gets the free allowance', () => {
Env.init({});
Limits.reset();
for (const plan of ['free', null, undefined, 'something-we-retired']) {
expect(Limits.forPlan(plan)).toEqual(Limits.PLACEHOLDER.free);
}
expect(Limits.forPlan('paid')).toEqual(Limits.PLACEHOLDER.paid);
});
});
describe('What the placeholder set actually means', () => {
// These are the sentences the numbers are supposed to say. If a retune
// breaks one, the retune changed the product and should say so.
const free = Limits.PLACEHOLDER.free;
test('one session running continuously never exhausts any window', () => {
for (const window of Window.ALL) {
// One session burns one unit per second, so over any window it has
// spent exactly the window length.
const state = Window.analyze({
allowance: free[window.key],
windowSeconds: window.seconds,
usage: window.seconds,
timeUpdated: new Date()
});
expect(state.exhausted).toBe(false);
}
});
test('two at once bites, and the five-hour window bites first', () => {
const now = new Date();
const twoForFiveHours = 2 * Window.FIVE_HOURS;
expect(
Window.analyze({
allowance: free.fiveHour,
windowSeconds: Window.FIVE_HOURS,
usage: twoForFiveHours,
timeUpdated: now
}).exhausted
).toBe(true);
// The same burn is nowhere near the weekly allowance, which is what
// makes the three windows do different jobs rather than one job thrice.
expect(
Window.analyze({
allowance: free.sevenDay,
windowSeconds: Window.SEVEN_DAYS,
usage: twoForFiveHours,
timeUpdated: now
}).exhausted
).toBe(false);
});
});

View File

@@ -0,0 +1,215 @@
import z from 'zod';
import { Env } from '../env.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { memo } from '../utils/memo.js';
import { Window } from './window.js';
/**
* How much burn a plan is allowed, per window.
*
* **The unit is one second of a reference session** — the baseline size, on the
* baseline card, running alone, on hardware we own. Every factor is a multiple
* of that, so an allowance is measured in *time* and a bar can print
* `6h 20m of 12h` from the stored number instead of converting into it.
* Integers throughout; burn is never a float.
*
* On a caller's own hardware the size and hardware factors are 1, because what
* they price — a share of a card we paid for — is not being spent. Burn there
* is duration multiplied by how much is running at once, and nothing about
* their GPU enters into it. Nothing is ever measured on somebody's machine.
*
* **Configuration, not constants.** These numbers are not settled and will be
* retuned against real burn far more often than this code changes. A rate that
* needs a deploy is a rate that stays wrong for a week, so they are read from
* the environment and validated at read time.
*/
export namespace Limits {
const Allowances = z.object({
fiveHour: z.number().int().positive(),
sevenDay: z.number().int().positive(),
thirtyDay: z.number().int().positive()
});
export type Allowances = z.infer<typeof Allowances>;
/**
* What a size tier costs per second, times a thousand, on our own hardware.
*
* A tier buys a share of a card, so a bigger one spends more of something
* we paid for. These must be **superlinear in that share**: a small title
* asked to run at the top of the ladder has to cost what a whole card
* costs, or the ladder is gamed and the density that makes any of this
* priceable is theoretical.
*
* They do not apply on a caller's own hardware. See {@link Factors}.
*/
const SizeFactors = z.object({
xs: z.number().int().positive(),
sm: z.number().int().positive(),
md: z.number().int().positive(),
lg: z.number().int().positive(),
xl: z.number().int().positive()
});
/**
* How much a running session costs per second, before concurrency.
*
* **Only on hardware we own.** The size factor prices a share of a card we
* bought; on somebody else's card there is no such share being spent, so a
* session there costs one unit a second whatever tier it asked for. Charging
* more for taking more of their own GPU would be a tax on hardware they paid
* for, which is the complaint this whole model is shaped to avoid.
*
* There is no hardware factor here yet, and its absence is deliberate rather
* than an oversight: nothing records which card a host has, so a table keyed
* on a model would be keyed on nothing. A faster card should cost more, and
* that starts with a column, not a number.
*/
export const Factors = z.object({
size: SizeFactors
});
export type Factors = z.infer<typeof Factors>;
export const Config = z.object({
free: Allowances,
paid: Allowances,
factors: Factors
});
export type Config = z.infer<typeof Config>;
/**
* Placeholder numbers, and deliberately labelled as such.
*
* They satisfy every rule {@link check} enforces, so the mechanism runs and
* can be tested end to end, and they are not a pricing decision. The free
* set says "one box around the clock, with room to double up now and then";
* the paid set is the same shape, larger. Both want replacing with numbers
* chosen against measured burn.
*/
export const PLACEHOLDER: Config = {
free: {
fiveHour: 10 * 60 * 60,
sevenDay: 300 * 60 * 60,
thirtyDay: 1000 * 60 * 60
},
paid: {
fiveHour: 30 * 60 * 60,
sevenDay: 900 * 60 * 60,
thirtyDay: 3000 * 60 * 60
},
// Superlinear, and no more principled than that. `sm` is the reference
// and is 1 by definition; the rest roughly double per step so the shape
// is visible in tests. Real values come from what a card-hour costs us
// divided by the share a tier holds.
factors: {
size: { xs: 500, sm: 1000, md: 2200, lg: 5000, xl: 12000 }
}
};
/**
* The two rules that make a set of allowances mean anything.
*
* **A window's allowance must exceed the window itself.** Because the
* windows roll, one continuously-running session does not creep — it
* asymptotes at exactly the window length and stays there. So an allowance
* at or below its own window is one where a single uninterrupted session
* hits a wall, which is the one outcome the model may not produce: someone
* playing alone on hardware they own must never be stopped.
*
* **Each longer window must be smaller than what the shorter one already
* permits.** Sustained burn allowed by a window is `allowance ÷ window` per
* second, so a longer allowance above `allowance × (longer ÷ shorter)` can
* never be reached and is decoration — a number that looks like a limit,
* reads like a promise, and never fires.
*
* Both are cheap, and checking them here rather than in somebody's head is
* the point: these get retuned by whoever is closest to the burn data, and
* a set that quietly stops binding is not visible from the numbers.
*/
export function check(allowances: Allowances, plan: string): void {
for (const window of Window.ALL) {
const allowance = allowances[window.key];
if (allowance <= window.seconds) {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`${plan}: the ${window.label} allowance (${allowance}s) must exceed the window itself (${window.seconds}s), or one uninterrupted session hits a wall`
);
}
}
for (let i = 1; i < Window.ALL.length; i++) {
const shorter = Window.ALL[i - 1]!;
const longer = Window.ALL[i]!;
const ceiling = (allowances[shorter.key] * longer.seconds) / shorter.seconds;
if (allowances[longer.key] >= ceiling) {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`${plan}: the ${longer.label} allowance (${allowances[longer.key]}s) can never be reached, because the ${shorter.label} window already caps it at ${Math.floor(ceiling)}s — it would never bind`
);
}
}
}
/**
* The reference tier costs exactly one unit a second, by definition.
*
* The unit *is* a second of a reference session, so a size factor that made
* `sm` anything other than 1 would silently redefine what every allowance
* means — the same stored number would be a different number of hours.
*/
export function checkFactors(factors: Factors): void {
if (factors.size.sm !== 1000) {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
`the reference tier must cost exactly one unit a second (1000), not ${factors.size.sm} \u2014 it is what every allowance is denominated in`
);
}
}
export function validate(config: unknown): Config {
const parsed = Config.parse(config);
check(parsed.free, 'free');
check(parsed.paid, 'paid');
checkFactors(parsed.factors);
return parsed;
}
const _get = memo((): Config => {
const raw = Env.get().BURN_LIMITS;
if (!raw) {
return validate(PLACEHOLDER);
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new VisibleError(
'internal',
ErrorCodes.Server.INTERNAL_ERROR,
'BURN_LIMITS is not valid JSON'
);
}
return validate(parsed);
});
export function get(): Config {
return _get();
}
/** Reset the memo. Tests change the environment between cases. */
export function reset(): void {
_get.reset();
}
/** The allowances a plan gets. Anything not `paid` is free. */
export function forPlan(plan: string | null | undefined): Allowances {
const config = get();
return plan === 'paid' ? config.paid : config.free;
}
}

View File

@@ -0,0 +1,139 @@
import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
import { Env } from '../env.js';
import { Polar } from './polar.js';
const PAID = 'prod_paid_notreal';
const FREE = 'prod_free_notreal';
function configure(extra: Record<string, string> = {}) {
Env.init({
POLAR_ACCESS_TOKEN: 'polar_oat_notreal',
POLAR_PRODUCT_ID: PAID,
POLAR_FREE_PRODUCT_ID: FREE,
...extra
});
Polar.reset();
}
beforeEach(() => configure());
afterEach(() => {
Env.init({});
Polar.reset();
});
describe('Which plan a subscription is', () => {
test('the product decides it, not the event', () => {
// Free is a real subscription here, so it announces itself with the same
// `subscription.created` a paid one does. Reading the type alone would
// put every new signup on the paid allowance.
expect(Polar.standingFor('subscription.created', FREE)).toEqual({
plan: 'free',
status: 'active'
});
expect(Polar.standingFor('subscription.created', PAID)).toEqual({
plan: 'paid',
status: 'active'
});
});
test('a product we do not sell changes nothing', () => {
// Somebody selling something else through the same account must not be
// able to change what a team may run by doing so.
expect(Polar.standingFor('subscription.active', 'prod_somethingelse')).toBeNull();
expect(Polar.standingFor('subscription.active', null)).toBeNull();
});
});
describe('What an event means for access', () => {
test('a live subscription keeps its plan', () => {
for (const type of [
'subscription.created',
'subscription.active',
'subscription.updated',
'subscription.uncanceled'
]) {
expect(Polar.standingFor(type, PAID)).toEqual({ plan: 'paid', status: 'active' });
}
});
test('cancelling keeps the plan until the period is actually over', () => {
// They paid to the end of the period. Turning them off the moment they
// click cancel is taking something they bought.
expect(Polar.standingFor('subscription.canceled', PAID)).toEqual({
plan: 'paid',
status: 'canceled'
});
});
test('a failed card keeps the plan while it is being retried', () => {
// A card that failed may yet work, and a retry cycle that ends in
// payment should not have cost them access in the middle of it.
expect(Polar.standingFor('subscription.past_due', PAID)).toEqual({
plan: 'paid',
status: 'past_due'
});
});
test('revoked drops to free, whatever it was before', () => {
// The provider saying the period is over and unpaid, which is the only
// moment there is nothing left that was paid for.
expect(Polar.standingFor('subscription.revoked', PAID)).toEqual({
plan: 'free',
status: 'revoked'
});
expect(Polar.standingFor('subscription.revoked', FREE)).toEqual({
plan: 'free',
status: 'revoked'
});
});
test('an unrecognised event changes nothing', () => {
// New types get added by people who do not know what we do with them. A
// default that moved somebody's plan would eventually cancel an account
// nobody cancelled.
for (const type of ['subscription.something_new', 'order.created', '', 'customer.updated']) {
expect(Polar.standingFor(type, PAID)).toBeNull();
}
});
});
describe('Configuration', () => {
test('unconfigured is a state, not a crash', () => {
Env.init({});
Polar.reset();
expect(Polar.configured()).toBe(false);
});
test('a token without a product is still not configured', () => {
// Half-configured is the dangerous one: a checkout with no product to
// sell would fail at the provider, after the person clicked pay.
Env.init({ POLAR_ACCESS_TOKEN: 'polar_oat_notreal' });
Polar.reset();
expect(Polar.configured()).toBe(false);
});
test('both together is configured', () => {
expect(Polar.configured()).toBe(true);
});
});
describe('Webhooks', () => {
test('a body that is not signed is refused', () => {
configure({ POLAR_WEBHOOK_SECRET: 'whsec_notreal' });
expect(() =>
Polar.receive({
body: JSON.stringify({ type: 'subscription.active', data: {} }),
headers: { 'webhook-id': 'x', 'webhook-timestamp': '1', 'webhook-signature': 'v1,no' }
})
).toThrow();
});
test('no secret configured is a refusal, never an unchecked delivery', () => {
// This route has no session in front of it. If the secret is missing the
// only safe answer is to refuse, because accepting would mean anybody
// who knows the URL can set anybody's plan.
expect(() => Polar.receive({ body: '{}', headers: {} })).toThrow(/not configured/);
});
});

View File

@@ -0,0 +1,290 @@
import { Polar as PolarSdk } from '@polar-sh/sdk';
import { validateEvent, WebhookVerificationError } from '@polar-sh/sdk/webhooks';
import z from 'zod';
import { Env } from '../env.js';
import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { memo } from '../utils/memo.js';
/**
* The payment provider, and the only part of this codebase that talks to one.
*
* Everything money-shaped that is *not* here is deliberate. We store no price,
* no currency and no card detail: a subscription's existence and its state are
* the whole of what crosses back, because those 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 not our problem, by design.** A product carries a price per
* currency on their side and the customer's location picks one at checkout. So
* there is no currency in this file, none in the database, and no place where a
* rate could be stale — the alternative is holding prices in three currencies
* and discovering one of them is wrong from a customer.
*
* The team id goes over as the customer's `externalCustomerId`, which makes the
* mapping theirs to keep. A Polar customer id in our schema would be a foreign
* primary key we would then have to keep in step with a system we do not
* control.
*/
export namespace Polar {
/** Which Polar instance. Sandbox is a separate server with separate data. */
export const Server = z.enum(['sandbox', 'production']);
export type Server = z.infer<typeof Server>;
function settings() {
const env = Env.get();
if (!env.POLAR_ACCESS_TOKEN) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
return {
accessToken: env.POLAR_ACCESS_TOKEN,
server: Server.parse(env.POLAR_SERVER ?? 'sandbox'),
productId: env.POLAR_PRODUCT_ID,
freeProductId: env.POLAR_FREE_PRODUCT_ID,
webhookSecret: env.POLAR_WEBHOOK_SECRET
};
}
/** Whether billing can run at all. Every route here checks it first. */
export function configured(): boolean {
const env = Env.get();
return Boolean(env.POLAR_ACCESS_TOKEN && env.POLAR_PRODUCT_ID);
}
const client = memo(() => {
const { accessToken, server } = settings();
return new PolarSdk({ accessToken, server });
});
/** Reset the memo. Tests change the environment between cases. */
export function reset(): void {
client.reset();
}
/**
* Put a team on the free plan with the provider, without a checkout.
*
* A subscription at nothing a month needs no payment, so it is created
* outright rather than by sending somebody to pay zero — a checkout for a
* free account is a step that exists only to be got through.
*
* The point of doing it at all is that every team then exists on their side,
* with our team id as its external id. Free accounts show up in the same
* places paid ones do, an upgrade changes a subscription rather than
* inventing a customer, and there is one question to ask about anybody
* rather than two.
*
* **Idempotent, and quiet when it fails.** It runs after a team is created
* and must never be able to undo that: signing up is not allowed to depend
* on a third party being reachable, so a failure here leaves a team that is
* free anyway — which is exactly what it would have been — and the next call
* fixes it. That is also why it is safe to call on a team that already has
* one.
*/
export const ensureFree = fn(z.object({ teamId: z.string() }), async (input) => {
const { freeProductId } = settings();
if (!freeProductId) {
return { created: false, reason: 'no free product configured' as const };
}
try {
const existing = await client().customers.getStateExternal({
externalId: input.teamId
});
if (existing.activeSubscriptions.length > 0) {
return { created: false, reason: 'already subscribed' as const };
}
} catch {
// No such customer yet, which is the ordinary case the first time.
// Creating the subscription below makes one.
}
await client().subscriptions.create({
productId: freeProductId,
externalCustomerId: input.teamId
});
return { created: true, reason: 'created' as const };
});
/**
* A checkout for a team, as the customer they already are.
*
* The team id travels as `externalCustomerId`, so a second checkout for the
* same team reaches the same customer rather than making another one — which
* is what keeps one team from ending up with two subscriptions and two
* invoices for the same month.
*/
export const checkout = fn(
z.object({
teamId: z.string(),
email: z.email().optional(),
successUrl: z.url().optional()
}),
async (input) => {
const { productId } = settings();
if (!productId) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
const created = await client().checkouts.create({
products: [productId],
externalCustomerId: input.teamId,
customerEmail: input.email,
successUrl: input.successUrl
});
return { id: created.id, url: created.url };
}
);
/**
* A link to where somebody manages what they are already paying.
*
* Cancelling, changing a card and reading an invoice all live there rather
* than here. Rebuilding any of it would mean holding payment details to show
* them, which is the one thing this integration exists to avoid.
*/
export const portal = fn(z.object({ teamId: z.string() }), async (input) => {
const session = await client().customerSessions.create({
externalCustomerId: input.teamId
});
return { url: session.customerPortalUrl };
});
/** The plan and status a team is on, as far as the provider is concerned. */
export const Standing = z.object({
plan: z.enum(['free', 'paid']),
status: z.string()
});
export type Standing = z.infer<typeof Standing>;
/**
* What a subscription event means for what a team may do.
*
* The rule is that **access follows the provider's own state and nothing
* else**, and the interesting cases are the ones where that is not the same
* as "are they paying right now":
*
* - `canceled` keeps the plan. Somebody who cancels has paid to the end of
* the period and turning them off the moment they click it would be taking
* something they bought.
* - `past_due` also keeps it. A failed card is a card that may yet work, and
* a retry cycle that ends in payment should not have cost them access in
* the middle of it.
* - `revoked` is the one that takes it away. That is the provider saying the
* period is over and unpaid, which is the only moment there is nothing
* left that was paid for.
*
* An unknown type returns null rather than guessing. New event types get
* added by people who do not know what we do with them, and a default that
* changed somebody's plan would be a default that eventually cancels an
* account nobody cancelled.
*/
export function standingFor(eventType: string, productId: string | null): Standing | null {
// Which plan a subscription *is* comes from the product, never from the
// event. Free is a real subscription here, so it announces itself with
// the same `subscription.created` a paid one does — reading the type
// alone would put every new signup on the paid allowance.
const { productId: paidProduct, freeProductId } = settings();
const plan: Standing['plan'] | null =
productId && productId === paidProduct
? 'paid'
: productId && productId === freeProductId
? 'free'
: null;
// A product we do not recognise is left alone rather than guessed at.
// Somebody selling something else through the same account should not be
// able to change what a team may run by doing so.
if (!plan) {
return null;
}
switch (eventType) {
case 'subscription.created':
case 'subscription.active':
case 'subscription.updated':
case 'subscription.uncanceled':
return { plan, status: 'active' };
case 'subscription.canceled':
return { plan, status: 'canceled' };
case 'subscription.past_due':
return { plan, status: 'past_due' };
case 'subscription.revoked':
// Whatever it was, it is over. Free is where everybody lands.
return { plan: 'free', status: 'revoked' };
default:
return null;
}
}
export interface Delivery {
type: string;
/** The team this is about, from the customer's external id. */
teamId: string | null;
standing: Standing | null;
}
/**
* Check a webhook is really from them, and say what it means.
*
* The signature is checked over the **raw body**, before anything is parsed:
* this is the one route in the API that no session protects, so the
* signature is the whole of its authentication, and a body that has been
* through `JSON.parse` and back is not the body that was signed.
*
* A bad signature is an authentication failure and not a server fault — it
* is what an attacker gets, and it must read the same as a stale secret so
* that neither tells anybody which it was.
*/
export const receive = fn(
z.object({ body: z.string(), headers: z.record(z.string(), z.string()) }),
(input): Delivery => {
const { webhookSecret } = settings();
if (!webhookSecret) {
throw new VisibleError(
'internal',
ErrorCodes.Server.DEPENDENCY_FAILURE,
'Billing is not configured'
);
}
let event;
try {
event = validateEvent(input.body, input.headers, webhookSecret);
} catch (error) {
if (error instanceof WebhookVerificationError) {
throw new VisibleError(
'authentication',
ErrorCodes.Authentication.INVALID_TOKEN,
'Signature does not match'
);
}
throw error;
}
const data = (event as { data?: Record<string, unknown> }).data ?? {};
const customer = data.customer as { externalId?: string | null } | undefined;
// `externalId` is the team id we put on the customer. A delivery
// without one is about a customer created some other way — by hand in
// their dashboard, most likely — and there is nothing here it can
// change.
const teamId = customer?.externalId ?? null;
// Both spellings, because which one a payload carries depends on
// whether the product was expanded into it.
const product = data.product as { id?: string } | undefined;
const productId = (data.productId as string | undefined) ?? product?.id ?? null;
return { type: event.type, teamId, standing: standingFor(event.type, productId) };
}
);
}

View File

@@ -0,0 +1,115 @@
import { describe, expect, test } from 'bun:test';
import { Window } from './window.js';
const HOUR = 60 * 60;
const NOW = new Date('2026-09-18T12:00:00.000Z');
function ago(seconds: number) {
return new Date(NOW.getTime() - seconds * 1000);
}
function fiveHour(usage: number, timeUpdated: Date | null, allowance = 10 * HOUR) {
return Window.analyze({
allowance,
windowSeconds: Window.FIVE_HOURS,
usage,
timeUpdated,
now: NOW
});
}
describe('The staleness rule', () => {
test('a counter older than the window reads as zero', () => {
// This is what replaces a reset job. Nothing has to run for the window
// to roll clear, so there is no cron to misfire and no race between a
// reset and a write landing at the same moment.
const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1));
expect(state.used).toBe(0);
expect(state.percent).toBe(0);
expect(state.exhausted).toBe(false);
expect(state.remaining).toBe(10 * HOUR);
});
test('a counter inside the window is counted', () => {
const state = fiveHour(9 * HOUR, ago(Window.FIVE_HOURS - 60));
expect(state.used).toBe(9 * HOUR);
expect(state.exhausted).toBe(false);
});
test('nothing ever recorded reads as zero rather than throwing', () => {
expect(fiveHour(0, null).used).toBe(0);
});
test('the boundary belongs to the window, not outside it', () => {
// Exactly one window old is the oldest moment still in view. Getting
// this backwards would silently forgive a window's worth of burn.
expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS)).used).toBe(9 * HOUR);
});
});
describe('Exhaustion', () => {
test('at the allowance is exhausted, not just above it', () => {
expect(fiveHour(10 * HOUR, NOW).exhausted).toBe(true);
expect(fiveHour(10 * HOUR - 1, NOW).exhausted).toBe(false);
});
test('overrun is reported, but remaining never goes negative', () => {
// A run is never stopped mid-session, so burn past the allowance is a
// real and expected state — it just has nothing left to offer.
const state = fiveHour(14 * HOUR, NOW);
expect(state.used).toBe(14 * HOUR);
expect(state.remaining).toBe(0);
expect(state.percent).toBe(100);
});
test('a zero allowance is exhausted rather than dividing by zero', () => {
const state = fiveHour(0, NOW, 0);
expect(state.exhausted).toBe(true);
expect(state.percent).toBe(100);
});
});
describe('What the bar shows is what the gate reads', () => {
test('the percent the bar draws is the same number the check uses', () => {
// The complaint about usage limits is almost never the limit, it is
// being surprised by it. One arithmetic means a full bar and a refusal
// cannot disagree.
const state = fiveHour(5 * HOUR, NOW);
expect(state.percent).toBe(50);
expect(state.exhausted).toBe(false);
expect(state.remaining).toBe(5 * HOUR);
expect(state.used + state.remaining).toBe(state.allowance);
});
test('percent floors rather than rounds, so it reads 99 until it is done', () => {
expect(fiveHour(10 * HOUR - 1, NOW).percent).toBe(99);
});
test('reset counts from when the burn rolls out of view', () => {
const state = fiveHour(3 * HOUR, ago(HOUR));
expect(state.resetInSec).toBe(Window.FIVE_HOURS - HOUR);
});
test('a cleared window has nothing to wait for', () => {
expect(fiveHour(9 * HOUR, ago(Window.FIVE_HOURS + 1)).resetInSec).toBe(0);
});
});
describe('The three windows are the same arithmetic', () => {
test('one function, parameterised by length', () => {
// Their monthly window is calendar-anchored; ours rolls. Reaching for a
// month-bounds helper here would be a subtle and expensive mistake.
for (const window of Window.ALL) {
const state = Window.analyze({
allowance: 2 * window.seconds,
windowSeconds: window.seconds,
usage: window.seconds,
timeUpdated: NOW,
now: NOW
});
expect(state.percent).toBe(50);
expect(state.exhausted).toBe(false);
}
});
});

View File

@@ -0,0 +1,116 @@
import z from 'zod';
import { fn } from '../fn.js';
/**
* Reading a rolling window, without a job that resets it.
*
* A counter is stored next to the time it was last written, and a counter
* whose timestamp falls outside the current window simply **reads as zero**.
* Nothing resets anything on a schedule: the reset is implied by the clock, so
* there is no cron to misfire and no race between a reset and a concurrent
* write. The same rule applied on the write side — increment if the stamp is
* inside the window, otherwise start again from this amount — makes the whole
* thing one statement.
*
* Everything here is pure. It takes numbers and gives an answer, which is what
* lets the meter a person sees and the check that stops them be the same
* arithmetic rather than two implementations that agree for now.
*/
export namespace Window {
/** Seconds. Named so a caller cannot pass minutes by accident. */
export const FIVE_HOURS = 5 * 60 * 60;
export const SEVEN_DAYS = 7 * 24 * 60 * 60;
export const THIRTY_DAYS = 30 * 24 * 60 * 60;
/**
* The three, shortest first.
*
* Order is load-bearing: the nesting rule that keeps each allowance
* meaningful is stated between neighbours, and the bars are read top-down.
*/
export const ALL = [
{ key: 'fiveHour' as const, seconds: FIVE_HOURS, label: '5-hour' },
{ key: 'sevenDay' as const, seconds: SEVEN_DAYS, label: '7-day' },
{ key: 'thirtyDay' as const, seconds: THIRTY_DAYS, label: '30-day' }
];
export type Key = (typeof ALL)[number]['key'];
export const State = z.object({
/** Whether a new run may start. A live one is never stopped by this. */
exhausted: z.boolean(),
/** Burn already spent in this window, after the staleness rule. */
used: z.number().int(),
/** The allowance it is spent against. Never reported without `used`. */
allowance: z.number().int(),
/** What is left, floored at zero — overrun is real but never negative. */
remaining: z.number().int(),
/**
* Whole percent used, 0100.
*
* For the bar, and deliberately the same number the gate reads, so a
* full bar and a refusal cannot disagree.
*/
percent: z.number().int(),
/** Seconds until this window has rolled clear of the current usage. */
resetInSec: z.number().int()
});
export type State = z.infer<typeof State>;
/**
* Where one window stands.
*
* `usage` and `timeUpdated` are the stored pair. A `timeUpdated` older than
* the window means everything recorded in it has rolled out of view, so the
* answer is a clean zero rather than a stale total — this is the staleness
* rule, and it is why nothing has to be reset.
*/
export const analyze = fn(
z.object({
allowance: z.number().int().nonnegative(),
windowSeconds: z.number().int().positive(),
usage: z.number().int().nonnegative(),
/** Null when nothing has ever been recorded, which reads as zero. */
timeUpdated: z.date().nullable(),
/** Injected so the arithmetic is testable without waiting. */
now: z.date().optional()
}),
(input): State => {
const now = input.now ?? new Date();
const windowMs = input.windowSeconds * 1000;
const windowStart = now.getTime() - windowMs;
// Rolled clear: the stored total describes a window that has passed.
if (!input.timeUpdated || input.timeUpdated.getTime() < windowStart) {
return {
exhausted: false,
used: 0,
allowance: input.allowance,
remaining: input.allowance,
percent: 0,
resetInSec: 0
};
}
const used = input.usage;
const remaining = Math.max(0, input.allowance - used);
const percent =
input.allowance === 0 ? 100 : Math.min(100, Math.floor((used / input.allowance) * 100));
// When the last write rolls out of the window, this usage is gone.
const clearsAt = input.timeUpdated.getTime() + windowMs;
const resetInSec = Math.max(0, Math.ceil((clearsAt - now.getTime()) / 1000));
return {
exhausted: used >= input.allowance,
used,
allowance: input.allowance,
remaining,
percent,
resetInSec
};
}
);
}

View File

@@ -3,8 +3,8 @@ import { afterAll, describe, expect, test } from 'bun:test';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Placement } from './placement.js';
import { Box } from './index.js';
import { Placement } from './placement.js';
const sql = testDb();
@@ -46,9 +46,7 @@ describe('Placement', () => {
// `box.machineId` is notNull, so a placer with no candidate must refuse
// rather than hand back something the insert would reject.
await expect(
Placement.choose({ userId: owner.userId, tier: 'sm' })
).rejects.toThrow();
await expect(Placement.choose({ userId: owner.userId, tier: 'sm' })).rejects.toThrow();
});
test('more than one candidate is refused rather than picked silently', async () => {
@@ -59,9 +57,7 @@ describe('Placement', () => {
// There is no policy for choosing between hosts yet. Inventing one here
// is how a placement decision ends up buried in the caller: the refusal
// is what keeps the choice in one replaceable place.
await expect(
Placement.choose({ userId: owner.userId, tier: 'sm' })
).rejects.toThrow();
await expect(Placement.choose({ userId: owner.userId, tier: 'sm' })).rejects.toThrow();
});
test('the placer is swappable without touching box creation', async () => {

View File

@@ -127,10 +127,7 @@ export namespace Database {
// callback ran on — harmless by luck, since neither was a real
// transaction, and twice the pools either way.
const db = client();
const result = await TransactionContext.provide(
{ effects, tx: db },
() => callback(db)
);
const result = await TransactionContext.provide({ effects, tx: db }, () => callback(db));
await Promise.all(effects.map((x) => x()));
return result;
}

View File

@@ -27,9 +27,29 @@ export namespace Env {
*/
AUTH_INTERNAL_URL: z.string().optional(),
SSH_AUTH_KEY: z.string().optional(),
/**
* Burn allowances per plan, as JSON. Unset takes the placeholder set.
*
* Configuration rather than constants because these are retuned against
* real burn far more often than the code that reads them changes, and a
* rate that needs a deploy is a rate that stays wrong until the next one.
*/
BURN_LIMITS: z.string().optional(),
ADMIN_SHARED_SECRET: z.string().optional(),
/**
* The payment provider.
*
* `POLAR_SERVER` picks the instance and the two are entirely separate
* servers with separate data, so a token from one is refused by the
* other and a product id from one means nothing to it. Getting this
* wrong fails loudly rather than quietly charging somebody.
*/
POLAR_ACCESS_TOKEN: z.string().optional(),
POLAR_WEBHOOK_SECRET: z.string().optional(),
POLAR_PRODUCT_ID: z.string().optional(),
/** The product a team is put on at signup, priced at nothing. */
POLAR_FREE_PRODUCT_ID: z.string().optional(),
POLAR_SERVER: z.enum(['sandbox', 'production']).optional(),
DATABASE_URL: z.string().optional()
});

View File

@@ -24,11 +24,20 @@ export namespace Examples {
profile: { personaname: 'John Doe', avatarfull: 'https://avatars.steamstatic.com/xxxx.jpg' }
};
export const Organisation = {
id: Id('organisation'),
name: 'Initech',
slug: 'initech',
domain: 'initech.example',
domainVerified: true
};
export const Team = {
id: Id('team'),
name: 'The A Team',
slug: 'the-a-team',
ownerId: Id('user'),
organisationId: null,
billingEmail: 'billing@example.com',
plan: 'free',
subscriptionStatus: 'active',

View File

@@ -6,6 +6,7 @@ export namespace Identifier {
export const prefixes = {
user: 'usr',
linkedAccount: 'lac',
organisation: 'org',
team: 'tem',
teamMember: 'mem',
verification: 'ver',
@@ -20,6 +21,8 @@ export namespace Identifier {
gameDepot: 'gdp',
gameDownload: 'gdl',
waitlistEntry: 'wle',
burnCounter: 'bct',
burnSegment: 'bsg',
deviceGrant: 'dvg',
authKv: 'akv',
authKey: 'aky',

View File

@@ -43,15 +43,21 @@ export namespace Machine {
description: 'Unique identifier for the machine',
example: Examples.Machine.id
}),
ownerUserId: z.string().meta({
description: 'The user who registered this machine',
ownerUserId: z.string().nullable().meta({
description:
'The user who registered this machine, or null for hardware an organisation owns outright — a company card is nobody\u2019s personal property',
example: Examples.Machine.ownerUserId
}),
teamId: z.string().meta({
teamId: z.string().nullable().meta({
description:
'The team that owns this hardware. Always set — every user has a personal team',
'The team that owns this hardware, for a host somebody brought. Null exactly when organisationId is set',
example: Examples.Machine.teamId
}),
organisationId: z.string().nullable().meta({
description:
'The organisation that owns this hardware outright, for a host serving workloads rather than its owner\u2019s. Null exactly when teamId is set',
example: null
}),
label: z.string().meta({
description: 'Human-readable name for the box',
example: Examples.Machine.label
@@ -114,7 +120,11 @@ export namespace Machine {
* than looking it up.
*/
export const register = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true }),
Info.pick({ id: true, ownerUserId: true, teamId: true, label: true })
.extend({ organisationId: Info.shape.organisationId.optional() })
.refine((v) => (v.teamId === null) !== ((v.organisationId ?? null) === null), {
message: 'A machine belongs to a team or to an organisation, and not to both'
}),
async (input) => {
const secret = generateSecret();
const secretHash = await hashSecret(secret);
@@ -132,6 +142,7 @@ export namespace Machine {
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
organisationId: input.organisationId ?? null,
label: input.label,
slug,
secretHash,
@@ -236,7 +247,7 @@ export namespace Machine {
* is left to re-registration until renting makes it worth building.
*/
export const setTeam = fn(
Info.pick({ id: true, ownerUserId: true, teamId: true }),
Info.pick({ id: true }).extend({ ownerUserId: z.string(), teamId: z.string() }),
async (input) => {
return Database.use(async (tx) => {
return tx
@@ -367,8 +378,11 @@ export namespace Machine {
/** Why a user may — or may not — use a box. */
export const Entitlement = z.object({
entitled: z.boolean(),
/** `owner`, `team`, or `none`. Present so a refusal can explain itself. */
reason: z.enum(['owner', 'team', 'none'])
/**
* `owner`, `team`, `fleet`, or `none`. Present so a refusal can explain
* itself rather than being an unexplained no.
*/
reason: z.enum(['owner', 'team', 'fleet', 'none'])
});
export type Entitlement = z.infer<typeof Entitlement>;
@@ -376,14 +390,20 @@ export namespace Machine {
/**
* Whether a user may use a box.
*
* The whole access model in one function: a solo box (`teamId` null) is the
* owner's alone, and a team-scoped box is open to that team. Multi-user
* access is the paid tier, so this is the line the paywall sits on — worth
* having exactly one implementation of.
* The whole access model in one function: a box someone brought is open to
* its owner and to the team it was registered under, and hardware an
* organisation owns outright is open to whoever has paid for a run on it.
*
* Membership is read live rather than cached in the machine row, so
* removing someone from a team takes their box access with it and nobody
* has to remember to revoke anything.
*
* **Fleet hardware refuses everyone for now, and that is deliberate.** What
* grants it is a plan, and nothing here can yet ask whether a user has one
* — so the honest answer is no rather than a yes that would hand out metered
* hardware for free. Failing closed on the expensive case is the cheap
* mistake to make; the branch is written out so there is one obvious place
* for the plan check to land. todo(d-0051)
*/
export const entitlement = fn(
z.object({ machineId: z.string(), userId: z.string() }),
@@ -392,7 +412,12 @@ export namespace Machine {
if (!machine) {
return { entitled: false, reason: 'none' };
}
if (machine.ownerUserId === input.userId) {
if (machine.organisationId) {
// Fleet hardware. Not the owner's and not a team's, so neither
// test below means anything here.
return { entitled: false, reason: 'fleet' };
}
if (machine.ownerUserId && machine.ownerUserId === input.userId) {
return { entitled: true, reason: 'owner' };
}
if (!machine.teamId) {
@@ -407,7 +432,21 @@ export namespace Machine {
}
);
export const listByOwner = fn(Info.shape.ownerUserId, async (ownerUserId) => {
/** Every host an organisation owns outright — its fleet. */
export const listByOrganisation = fn(z.string(), async (organisationId) => {
return Database.use(async (tx) => {
return tx
.select()
.from(MachineTable)
.where(
and(eq(MachineTable.organisationId, organisationId), isNull(MachineTable.timeDeleted))
)
.orderBy(MachineTable.timeCreated)
.then((rows) => rows.map(serialize));
});
});
export const listByOwner = fn(z.string(), async (ownerUserId) => {
return Database.use(async (tx) => {
return tx
.select()
@@ -432,6 +471,7 @@ export namespace Machine {
id: input.id,
ownerUserId: input.ownerUserId,
teamId: input.teamId,
organisationId: input.organisationId,
label: input.label,
slug: input.slug,
lastSeen: input.lastSeen?.toISOString() ?? null,

View File

@@ -1,33 +1,67 @@
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { sql } from 'drizzle-orm';
import { check, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid, utc } from '../db/types.js';
import { OrganisationTable } from '../organisation/organisation.sql.js';
import { TeamTable } from '../team/team.sql.js';
import { UserTable } from '../user/user.sql.js';
/**
* A registered nessh host — the *box* that runs downloads and serves SSH, not
* the laptop someone connects from. (`nessh-tui-redesign-guide.md` §7.2 uses
* "machine" for the other end of that connection; this table is the host end.)
* A registered host — the *box* that runs downloads and serves SSH, not the
* laptop someone connects from. Note the word is used the other way round in
* some client-facing writing, where "machine" is the end a person sits at;
* this table is the host end.
*
* A box does not assert who it is. It registers once against an owner's token
* and is handed an id and a secret, so ids are unique because the API assigns
* them rather than because a self-reported string happened not to collide.
*
* **Hardware is owned one of two ways, and never both.** Someone brings their
* own and reaches it through a team; or an organisation owns it outright, to
* serve workloads for people who have no hardware of their own. The check
* constraint below is what keeps that an either/or rather than a convention.
*/
export const MachineTable = pgTable(
'machine',
{
...id,
...timestamps,
ownerUserId: ulid('owner_user_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
// Every user gets a personal team at signup, so there is always one to
// point at and the single-operator case is a team of one rather than a
// special case in every query. This was nullable, which cost a
// `teamId ?? ownerUserId` branch at each call site instead. ref(d-0048)
teamId: ulid('team_id')
.notNull()
.references(() => TeamTable.id, { onDelete: 'restrict' }),
/**
* Who registered it, when a person did.
*
* Null for hardware an organisation owns, which is the whole point of
* the column being nullable: a company's card is not anybody's personal
* property, and parking it under whichever employee ran the command
* made it one — where `cascade` below meant deleting that account
* deleted the machine.
*
* `cascade` stays, and is right once null is available. It only ever
* fires for a host somebody brought, and a box dying with the account
* that owns it is the behaviour that account expects. Fleet hardware is
* never reached by it, because the column it would follow is null.
*/
ownerUserId: ulid('owner_user_id').references(() => UserTable.id, {
onDelete: 'cascade'
}),
// A team's own hardware, brought by one of its members. Every user gets
// a personal team at signup, so there is always one to point at and the
// single-operator case is a team of one rather than a special case in
// every query. ref(d-0048)
//
// Null exactly when `organisationId` is set; see the check below.
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'restrict' }),
/**
* The organisation that owns this host outright.
*
* Set for fleet hardware and null for everything else. Deliberately not
* reached through a team: a team's machines belong to that team, and
* putting the fleet in a team would make every query that asks "whose
* hardware is this?" answer with a team that does not pay for it and
* cannot be billed for it.
*/
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
onDelete: 'restrict'
}),
label: text('label').notNull(),
// The name this host is reached at: `amber-otter-4821.nestri.link`.
//
@@ -72,6 +106,12 @@ export const MachineTable = pgTable(
uniqueIndex('machine_slug_unique').on(t.slug),
uniqueIndex('machine_endpoint_id_unique').on(t.endpointId),
index('machine_owner_idx').on(t.ownerUserId),
index('machine_team_idx').on(t.teamId)
index('machine_team_idx').on(t.teamId),
index('machine_organisation_idx').on(t.organisationId),
// Exactly one owner, enforced here rather than in the code that writes
// rows. Both null is a host nobody owns and nothing can bill; both set
// is two answers to one question, and whichever one a given query
// happens to join through would decide who pays.
check('machine_one_owner', sql`(${t.teamId} is null) != (${t.organisationId} is null)`)
]
);

View File

@@ -72,7 +72,8 @@ describe('Machine registration', () => {
Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: owner.userId,
// @ts-expect-error — the point of the test is that this is refused
// Null is a real value now — it is how fleet hardware says it has
// no team — so this is refused for naming neither owner.
teamId: null,
label: 'teamless'
})

View File

@@ -0,0 +1,193 @@
import { and, eq, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { UserTable } from '../user/user.sql.js';
import { OrganisationTable } from './organisation.sql.js';
export namespace Organisation {
export const Info = z
.object({
id: z.string().meta({
description: 'Unique identifier for the organisation',
example: Examples.Organisation.id
}),
name: z.string().meta({
description: 'Display name of the organisation',
example: Examples.Organisation.name
}),
slug: z.string().meta({
description: 'URL-friendly unique slug for the organisation',
example: Examples.Organisation.slug
}),
domain: z.string().meta({
description:
'The email domain whose verified addresses belong to this organisation, without an @',
example: Examples.Organisation.domain
}),
domainVerified: z.boolean().meta({
description:
'Whether the domain has been shown to belong to them. Nothing is granted on an unverified claim',
example: Examples.Organisation.domainVerified
})
})
.meta({
ref: 'Organisation',
description:
'A company. It owns hardware outright, rather than through a team, and gathers the teams whose members sign in with its domain.',
example: Examples.Organisation
});
export type Info = z.infer<typeof Info>;
/**
* The domain part of an address, lower-cased.
*
* Returns null for anything that is not one address with one `@`, because
* every caller here is about to use the answer to decide membership and a
* best guess at a malformed address is the wrong kind of helpful.
*/
export function domainOf(email: string | null | undefined): string | null {
if (!email) {
return null;
}
const parts = email.trim().toLowerCase().split('@');
if (parts.length !== 2 || !parts[0] || !parts[1]) {
return null;
}
return parts[1]!;
}
export const create = fn(
Info.pick({ id: true, name: true, slug: true, domain: true }).extend({
domainVerified: Info.shape.domainVerified.optional()
}),
async (input) => {
await Database.use(async (tx) => {
await tx.insert(OrganisationTable).values({
id: input.id,
name: input.name,
slug: input.slug,
domain: input.domain.trim().toLowerCase(),
domainVerified: input.domainVerified ?? false
});
});
return input.id;
}
);
export const fromID = fn(Info.shape.id, async (id) => {
return Database.use(async (tx) => {
return tx
.select()
.from(OrganisationTable)
.where(and(eq(OrganisationTable.id, id), isNull(OrganisationTable.timeDeleted)))
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
export const fromSlug = fn(Info.shape.slug, async (slug) => {
return Database.use(async (tx) => {
return tx
.select()
.from(OrganisationTable)
.where(and(eq(OrganisationTable.slug, slug), isNull(OrganisationTable.timeDeleted)))
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
/**
* The organisation a domain belongs to, if one has proved it does.
*
* Only ever answers with a *verified* domain. An unverified row is a claim
* anybody could have typed, and answering with it would let whoever typed
* `gmail.com` reach every account on it.
*/
export const fromVerifiedDomain = fn(Info.shape.domain, async (domain) => {
const normalized = domain.trim().toLowerCase();
return Database.use(async (tx) => {
return tx
.select()
.from(OrganisationTable)
.where(
and(
eq(OrganisationTable.domain, normalized),
eq(OrganisationTable.domainVerified, true),
isNull(OrganisationTable.timeDeleted)
)
)
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
});
/**
* Which organisation a user belongs to, derived rather than stored.
*
* Membership is their verified address's domain matching a verified
* organisation domain, and there is no membership table on purpose: 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.
*
* Two consequences worth stating, because both are features here. Signing
* in with a personal address gets an ordinary personal account, which is
* what lets the same person hold a company account and use the consumer
* product. And a user belongs to at most one organisation — when someone
* needs to be in two, this is where a membership table goes, and until then
* it would be a table with one row per user saying what the address says.
*
* An unverified address is not membership. It is a string somebody typed.
*/
export const forUser = fn(z.string(), async (userId) => {
const user = await Database.use(async (tx) => {
return tx
.select({ email: UserTable.email, emailVerified: UserTable.emailVerified })
.from(UserTable)
.where(and(eq(UserTable.id, userId), isNull(UserTable.timeDeleted)))
.then((rows) => rows.at(0) ?? null);
});
if (!user?.emailVerified) {
return null;
}
const domain = domainOf(user.email);
if (!domain) {
return null;
}
return fromVerifiedDomain(domain);
});
/** Whether this user may act for this organisation. */
export async function isMember(userId: string, organisationId: string): Promise<boolean> {
const organisation = await forUser(userId);
return organisation?.id === organisationId;
}
export const remove = fn(Info.shape.id, async (id) => {
await Database.use(async (tx) => {
await tx
.update(OrganisationTable)
.set({ timeDeleted: sql`now()` })
.where(eq(OrganisationTable.id, id));
});
});
export function serialize(input: typeof OrganisationTable.$inferSelect): Info {
return {
id: input.id,
name: input.name,
slug: input.slug,
domain: input.domain,
domainVerified: input.domainVerified
};
}
}

View File

@@ -0,0 +1,60 @@
import { boolean, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
import { id, timestamps } from '../db/types.js';
/**
* A company, and the owner of hardware that belongs to nobody in particular.
*
* It exists because two kinds of machine were being modelled as one. A host
* somebody brings is theirs, reached through a team, and dies with their
* account — which is right. A host bought to serve other people's workloads is
* none of those things, and until now it had to be registered under some
* employee's personal team, where a deleted user row would take it with it.
*
* So an organisation owns fleet hardware *directly* rather than through a team
* inside it. Those are different relationships and collapsing them was the
* bug: a team's hardware is the team's, and the fleet is the company's.
*
* **Not a billing subject.** A team pays for what it uses whether it belongs
* to an organisation or not, so there are deliberately no plan or subscription
* columns here — this row says who owns the metal, not who owes money.
*/
export const OrganisationTable = pgTable(
'organisation',
{
...id,
...timestamps,
name: text('name').notNull(),
slug: text('slug').notNull(),
/**
* The email domain that makes someone a member, without an `@`.
*
* Membership is derived from this rather than stored: an address is
* already the root identity, and one verified domain answers "who
* belongs here?" without a table that can disagree with it. Someone
* signing in with a personal address gets their ordinary personal
* account, which is what makes it safe to dogfood the company account
* and the consumer product from the same machine.
*
* Lower-cased and unique, for the same reason a user's address is: two
* organisations claiming one domain would make membership ambiguous in
* exactly the case that matters. Nothing here enforces the case, so
* anything writing it has to normalize first.
*/
domain: text('domain').notNull(),
/**
* Whether the domain has been shown to belong to them.
*
* Separate from the domain itself because an unverified claim is a real
* state and must never grant anything: anyone can type `gmail.com`, and
* membership derived from an unchecked claim would hand them every
* account on it. Nothing verifies domains yet, so this is set by hand
* and read by everything that grants.
*/
domainVerified: boolean('domain_verified').notNull().default(false)
},
(t) => [
uniqueIndex('organisation_slug_unique').on(t.slug),
uniqueIndex('organisation_domain_unique').on(t.domain)
]
);

View File

@@ -0,0 +1,213 @@
import { afterAll, describe, expect, test } from 'bun:test';
import { Actor } from '../actor.js';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Machine } from '../machine/index.js';
import { User } from '../user/index.js';
import { Organisation } from './index.js';
const sql = testDb();
const createdUserIds: string[] = [];
const createdOrgIds: string[] = [];
/** A user with an address, verified or not, and nothing else attached. */
async function newUser(label: string, email: string | null, emailVerified: boolean) {
const userId = Identifier.ascending('user');
await User.create({ id: userId, name: label, email, emailVerified, image: null });
createdUserIds.push(userId);
return userId;
}
async function newOrg(label: string, domain: string, domainVerified: boolean) {
const id = Identifier.ascending('organisation');
await Organisation.create({
id,
name: label,
slug: `${label}-${id.slice(-6)}`,
domain,
domainVerified
});
createdOrgIds.push(id);
return id;
}
afterAll(async () => {
// Machines reference organisations with `restrict`, so the fleet has to go
// before the organisation that owns it.
if (createdOrgIds.length > 0) {
await sql`delete from "machine" where organisation_id in ${sql(createdOrgIds)}`;
}
if (createdUserIds.length > 0) {
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
createdUserIds.length = 0;
}
if (createdOrgIds.length > 0) {
await sql`delete from "organisation" where id in ${sql(createdOrgIds)}`;
createdOrgIds.length = 0;
}
});
describe('Organisation membership', () => {
test('a verified address on a verified domain is membership', async () => {
const orgId = await newOrg('org-member', 'member.example', true);
const userId = await newUser('member', 'someone@member.example', true);
const found = await Organisation.forUser(userId);
expect(found?.id).toBe(orgId);
});
test('an unverified domain grants nothing', async () => {
// Anyone can type a domain into a row. Until it is shown to be theirs,
// honouring it would hand them every account on it.
await newOrg('org-unverified', 'unverified.example', false);
const userId = await newUser('unverified', 'someone@unverified.example', true);
expect(await Organisation.forUser(userId)).toBeNull();
});
test('an unverified address is not membership either', async () => {
// The address is a string somebody typed until the code has been
// entered, and the domain half is no more trustworthy than the rest.
await newOrg('org-bothends', 'bothends.example', true);
const userId = await newUser('bothends', 'someone@bothends.example', false);
expect(await Organisation.forUser(userId)).toBeNull();
});
test('a personal address belongs to no organisation, and that is not an error', async () => {
await newOrg('org-personal', 'personal-co.example', true);
const userId = await newUser('personal', 'someone@gmail.example', true);
expect(await Organisation.forUser(userId)).toBeNull();
});
test('the domain match ignores case, because addresses do', async () => {
const orgId = await newOrg('org-case', 'case.example', true);
const userId = await newUser('case', 'Someone@CASE.example', true);
expect((await Organisation.forUser(userId))?.id).toBe(orgId);
});
});
describe('domainOf', () => {
test('takes the domain half, lower-cased', () => {
expect(Organisation.domainOf('Someone@Example.COM')).toBe('example.com');
});
test('refuses anything that is not one address', () => {
// Every caller uses the answer to decide membership, so a best guess at
// a malformed address is the wrong kind of helpful.
for (const bad of [
'',
null,
undefined,
'no-at-sign',
'two@at@signs',
'@nolocal',
'nodomain@'
]) {
expect(Organisation.domainOf(bad)).toBeNull();
}
});
});
describe('Fleet hardware', () => {
test('a host an organisation owns has no owner and no team', async () => {
const orgId = await newOrg('org-fleet', 'fleet.example', true);
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId: orgId,
label: 'fleet-card'
});
const machine = await Machine.fromID(registered.id);
expect(machine?.organisationId).toBe(orgId);
expect(machine?.ownerUserId).toBeNull();
expect(machine?.teamId).toBeNull();
});
test('hardware belongs to a team or an organisation, never both and never neither', async () => {
const orgId = await newOrg('org-either', 'either.example', true);
// `fn()` parses synchronously, so a bad argument never becomes a
// rejected promise.
expect(() =>
Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
label: 'ownerless'
})
).toThrow();
expect(() =>
Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: 'tem_whatever',
organisationId: orgId,
label: 'doubly-owned'
})
).toThrow();
});
test('the fleet lists separately from anybodys own hardware', async () => {
const orgId = await newOrg('org-list', 'list.example', true);
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId: orgId,
label: 'listed-card'
});
const fleet = await Machine.listByOrganisation(orgId);
expect(fleet.map((m) => m.id)).toContain(registered.id);
});
test('fleet hardware survives the account that registered it', async () => {
// The whole reason the column is nullable. It used to cascade, so
// deleting whoever ran the command deleted the machine.
const orgId = await newOrg('org-survives', 'survives.example', true);
const userId = await newUser('survives', 'admin@survives.example', true);
const registered = await Actor.with(
{ type: 'user', properties: { userID: userId, linkedAccountID: '' } },
async () =>
Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId: orgId,
label: 'outlives-me'
})
);
await sql`delete from "user" where id = ${userId}`;
createdUserIds.splice(createdUserIds.indexOf(userId), 1);
expect((await Machine.fromID(registered.id))?.organisationId).toBe(orgId);
});
});
describe('Entitlement on fleet hardware', () => {
test('nobody is entitled yet, and the reason says why', async () => {
// What grants a run on metered hardware is a plan, and there is nothing
// to ask yet — so this fails closed rather than giving it away.
const orgId = await newOrg('org-entitle', 'entitle.example', true);
const userId = await newUser('entitle', 'someone@entitle.example', true);
const registered = await Machine.register({
id: Identifier.ascending('machine'),
ownerUserId: null,
teamId: null,
organisationId: orgId,
label: 'metered'
});
const answer = await Machine.entitlement({ machineId: registered.id, userId });
expect(answer).toEqual({ entitled: false, reason: 'fleet' });
});
});

View File

@@ -1,6 +1,7 @@
import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Burn } from '../billing/burn.js';
import { BoxTable, BoxTier } from '../box/box.sql.js';
import { Box } from '../box/index.js';
import { Database } from '../db/index.js';
@@ -8,6 +9,7 @@ import { ErrorCodes, VisibleError } from '../error.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
import { GameTable } from '../game/game.sql.js';
import { Machine } from '../machine/index.js';
import { SessionState, SessionTable } from './session.sql.js';
/**
@@ -650,6 +652,37 @@ export namespace Session {
await Box.setState({ id: moved.boxId, ...box });
}
// Burn follows the run's state, in the same transaction that
// moved it. A session that went live without its meter starting
// is free hardware; one that ended without its meter stopping
// bills forever. Both are silent, so neither may be a second
// write that might not happen.
//
// `live` is where it starts rather than `starting`, because what
// is billed is an envelope actually held — a box that never
// finished coming up held nothing.
const machine = await Machine.fromID(input.machineId);
if (machine?.teamId) {
if (current.state !== 'live' && moved.state === 'live') {
// The rate is fixed here, from what this run actually is:
// the size it holds, and whose card it holds it on. Both
// are settled before the run starts, which is what lets a
// person be told the cost before committing to it.
const box = await Box.fromID(moved.boxId);
await Burn.start({
teamId: machine.teamId,
sessionId: moved.id,
tier: (box?.tier ?? 'sm') as Burn.Tier,
hostClass: machine.organisationId ? 'fleet' : 'byo'
});
} else if (
ACCRUING.includes(current.state as (typeof ACCRUING)[number]) &&
!ACCRUING.includes(moved.state as (typeof ACCRUING)[number])
) {
await Burn.stop({ teamId: machine.teamId, sessionId: moved.id });
}
}
return { outcome: 'moved', session: moved };
});
}
@@ -671,6 +704,16 @@ export namespace Session {
*/
const ADDRESSABLE = ['starting', 'live'] as const;
/**
* The states in which a run is spending.
*
* Only `live`. A run that is being brought up holds nothing yet, and the
* terminal states hold nothing any more — so this is a list of one, written
* as a list because the question it answers is "is this run costing
* anything?" and that will not always have one answer.
*/
const ACCRUING = ['live'] as const;
/**
* Publish a ticket for a run, on behalf of the host it is placed on.
*

View File

@@ -47,7 +47,14 @@ async function scene(label: string, steamAppId: number) {
afterAll(async () => {
if (createdUserIds.length > 0) {
// session cascades from box; box has to precede the machine, which
// cascades from the user.
// cascades from the user. `burn_segment` holds a session with
// `restrict` — deleting a run must not erase what it cost — so what the
// runs cost 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;
@@ -344,7 +351,13 @@ describe('Session claim', () => {
test('re-reporting the state you already reported changes nothing', async () => {
const { machineId, session } = await requested('ses-repeat', 5424);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
const again = await Session.transition({
claimToken: HOLDER,
id: session.id,
@@ -373,8 +386,20 @@ describe('Session claim', () => {
expect(skipped.outcome).toBe('illegal');
expect((await Session.fromID(session.id))?.state).toBe('requested');
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'failed', errorMessage: 'no' });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'failed',
errorMessage: 'no'
});
// Terminal is terminal: a dead session cannot be resurrected.
const raised = await Session.transition({
@@ -390,7 +415,13 @@ describe('Session claim', () => {
test('the timestamps survive a duplicate report, which is what billing rests on', async () => {
const { machineId, session } = await requested('ses-idempotent', 5426);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
const live = await Session.transition({
claimToken: HOLDER,
id: session.id,
@@ -414,7 +445,13 @@ describe('Session claim', () => {
const { machineId, session } = await requested('ses-ticket-scope', 5427);
const other = await scene('ses-ticket-other', 5428);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
const refused = await Session.publishTicket({
claimToken: HOLDER,
@@ -426,12 +463,22 @@ describe('Session claim', () => {
expect((await Session.fromID(session.id))?.ticket).toBeNull();
// A ticket may appear while the state is still `starting`.
const first = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'one' });
const first = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'one'
});
expect(first.outcome).toBe('published');
expect(first.session?.ticket).toBe('one');
expect(first.session?.state).toBe('starting');
const second = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'two' });
const second = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'two'
});
expect(second.session?.ticket).toBe('two');
});
@@ -691,11 +738,34 @@ describe('Session claim', () => {
test('a stopped session has no address to publish', async () => {
const { machineId, session } = await requested('ses-ticket-dead', 5429);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'live', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'ended', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'live',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'ended',
errorMessage: null
});
const result = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'late' });
const result = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'late'
});
expect(result.outcome).toBe('closed');
expect((await Session.fromID(session.id))?.ticket).toBeNull();
});
@@ -739,9 +809,27 @@ describe('Session one active run per box', () => {
});
const first = await mk();
await Session.transition({ claimToken: HOLDER, id: first.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: first.id, machineId, state: 'live', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: first.id, machineId, state: 'ended', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: first.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: first.id,
machineId,
state: 'live',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: first.id,
machineId,
state: 'ended',
errorMessage: null
});
// The index is partial for exactly this reason: a box is a durable
// thing and playing twice is the ordinary case, so a stopped run must
@@ -755,11 +843,29 @@ describe('Session one active run per box', () => {
describe('Session tickets and the end of a run', () => {
test('stopping a run takes its address away', async () => {
const { machineId, session } = await requestedRun('ses-ticket-cleared', 5452);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'live', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'live',
errorMessage: null
});
expect(
(await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'live-address' })).session
?.ticket
(
await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'live-address'
})
).session?.ticket
).toBe('live-address');
const ended = await Session.transition({
@@ -779,8 +885,19 @@ describe('Session tickets and the end of a run', () => {
test('a run that failed does not keep an address either', async () => {
const { machineId, session } = await requestedRun('ses-ticket-cleared-fail', 5453);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'starting-address' });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'starting-address'
});
const failed = await Session.transition({
claimToken: HOLDER,
@@ -814,12 +931,24 @@ describe('Session and the box underneath it', () => {
// `created` while a run on it was `live`.
expect((await Box.fromID(box.id))?.state).toBe('created');
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
// `starting` is deliberately not a box state: that transition is
// synchronous from the agent's side, so nothing would ever write it.
expect((await Box.fromID(box.id))?.state).toBe('created');
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'live', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'live',
errorMessage: null
});
const running = await Box.fromID(box.id);
expect(running?.state).toBe('running');
expect(running?.stopReason).toBeNull();
@@ -828,9 +957,27 @@ describe('Session and the box underneath it', () => {
test('a run that ends stops its box, cleanly', async () => {
const { machineId, box, session } = await requestedRun('ses-box-ended', 5461);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'live', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'ended', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'live',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'ended',
errorMessage: null
});
const stopped = await Box.fromID(box.id);
expect(stopped?.state).toBe('stopped');
@@ -840,7 +987,13 @@ describe('Session and the box underneath it', () => {
test('a run that fails stops its box in the words the agent used', async () => {
const { machineId, box, session } = await requestedRun('ses-box-failed', 5462);
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
@@ -891,24 +1044,68 @@ describe('Session tickets need a claim first', () => {
// A ticket is the address of something being brought up, so publishing
// one for a `requested` run means the agent skipped the claim — the
// step that is the only mutual exclusion in the design.
const early = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'too-soon' });
const early = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'too-soon'
});
expect(early.outcome).toBe('unclaimed');
expect((await Session.fromID(session.id))?.ticket).toBeNull();
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
const now = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'in-time' });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
const now = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'in-time'
});
expect(now.outcome).toBe('published');
expect(now.session?.ticket).toBe('in-time');
});
test('the two refusals are different answers, because they are different mistakes', async () => {
const { machineId, session } = await requestedRun('ses-ticket-refusals', 5466);
const unclaimed = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'a' });
const unclaimed = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'a'
});
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'starting', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'live', errorMessage: null });
await Session.transition({ claimToken: HOLDER, id: session.id, machineId, state: 'ended', errorMessage: null });
const closed = await Session.publishTicket({ claimToken: HOLDER, id: session.id, machineId, ticket: 'b' });
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'starting',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'live',
errorMessage: null
});
await Session.transition({
claimToken: HOLDER,
id: session.id,
machineId,
state: 'ended',
errorMessage: null
});
const closed = await Session.publishTicket({
claimToken: HOLDER,
id: session.id,
machineId,
ticket: 'b'
});
// One is an agent that has not claimed the work; the other is a run
// with nothing left to reach. Collapsing them would tell an agent

View File

@@ -148,6 +148,37 @@ export namespace Enrolment {
});
});
/**
* The enrolment a host holds for one user, or null.
*
* This is the question "may this host speak about this user's Steam
* library?", and it is answered from the record of sign-ins rather than
* from team membership: holding a refresh token for somebody is what makes
* a host able to enumerate their games in the first place. One host carries
* several people's sign-ins, so the pair is the unit and neither half of it
* is enough on its own.
*/
export const findByMachineAndUser = fn(
Info.pick({ machineId: true, userId: true }),
async (input) => {
return Database.use(async (tx) => {
return tx
.select()
.from(SteamEnrolmentTable)
.where(
and(
eq(SteamEnrolmentTable.machineId, input.machineId),
eq(SteamEnrolmentTable.userId, input.userId)
)
)
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
}
);
/**
* Every enrolment the control plane believes this host has.
*

View File

@@ -2,6 +2,7 @@ import { eq, and, isNull, sql } from 'drizzle-orm';
import z from 'zod';
import { Actor } from '../actor.js';
import { Polar } from '../billing/polar.js';
import { Database } from '../db/index.js';
import { Examples } from '../examples.js';
import { fn } from '../fn.js';
@@ -28,6 +29,11 @@ export namespace Team {
description: 'The user who owns/created this team',
example: Examples.Team.ownerId
}),
organisationId: z.string().nullable().optional().meta({
description:
'The organisation this team belongs to, or null for a personal team. It groups teams under a company; it does not move billing, which stays on the team',
example: Examples.Team.organisationId
}),
billingEmail: z.email().nullable().optional().meta({
description: 'Email address used for billing and invoices',
example: Examples.Team.billingEmail
@@ -70,6 +76,28 @@ export namespace Team {
role: 'owner'
});
});
// Register the team with the payment provider, *after* the rows are
// committed and without being able to affect them.
//
// Every team exists on their side, free ones included, so that an
// upgrade changes a subscription rather than inventing a customer and
// there is one question to ask about anybody rather than two.
//
// **Signing up is not allowed to depend on a third party.** So this
// cannot run inside the transaction, cannot fail the call, and does not
// retry: a team that misses it is free, which is what it would have been
// anyway, and the next call puts it right because the operation is
// idempotent.
Database.effect(async () => {
try {
await Polar.ensureFree({ teamId: input.id });
} catch (error) {
// eslint-disable-next-line no-console
console.error('could not register team with the payment provider:', error);
}
});
return input.id;
});
@@ -165,12 +193,44 @@ export namespace Team {
return create({ id, name: `${input.displayName}'s Team`, slug });
});
/**
* Record what the payment provider says a team is on.
*
* The only writer is the webhook, and it writes both fields together: a plan
* without the status it came from cannot say whether "paid" means paying,
* cancelled-but-paid-up, or behind on a card, and every one of those wants a
* different sentence in front of a person.
*
* Deliberately not reached from anywhere a user can call. A plan that could
* be set by a request is a plan somebody can set on themselves.
*/
export const setPlan = fn(
Info.pick({ id: true }).extend({
plan: z.string(),
subscriptionStatus: z.string()
}),
async (input) => {
return Database.use(async (tx) => {
return tx
.update(TeamTable)
.set({ plan: input.plan, subscriptionStatus: input.subscriptionStatus })
.where(and(eq(TeamTable.id, input.id), isNull(TeamTable.timeDeleted)))
.returning()
.then((rows) => {
const row = rows.at(0);
return row ? serialize(row) : null;
});
});
}
);
export function serialize(input: typeof TeamTable.$inferSelect): z.infer<typeof Info> {
return {
id: input.id,
name: input.name,
slug: input.slug,
ownerId: input.ownerId,
organisationId: input.organisationId,
billingEmail: input.billingEmail,
plan: input.plan,
subscriptionStatus: input.subscriptionStatus,

View File

@@ -1,18 +1,38 @@
import { jsonb, pgTable, text } from 'drizzle-orm/pg-core';
import { index, jsonb, pgTable, text } from 'drizzle-orm/pg-core';
import { id, timestamps, ulid } from '../db/types.js';
import { OrganisationTable } from '../organisation/organisation.sql.js';
import { UserTable } from '../user/user.sql.js';
export const TeamTable = pgTable('team', {
...id,
...timestamps,
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
ownerId: ulid('owner_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
billingEmail: text('billing_email'),
plan: text('plan').notNull().default('free'),
subscriptionStatus: text('subscription_status').notNull().default('active'),
metadata: jsonb('metadata').$type<{}>()
});
export const TeamTable = pgTable(
'team',
{
...id,
...timestamps,
name: text('name').notNull(),
slug: text('slug').notNull().unique(),
ownerId: ulid('owner_id')
.notNull()
.references(() => UserTable.id, { onDelete: 'cascade' }),
/**
* The organisation this team belongs to, if it belongs to one.
*
* Null for every personal team, which is most of them, and that is the
* ordinary case rather than a missing value. It groups teams under a
* company and decides which of them a verified domain reaches; it does not
* move billing, which stays on the team either way.
*
* `restrict`, so an organisation with teams cannot be deleted out from
* under them — where those teams should go is a decision, and there is no
* UI for it, so the database refuses rather than guessing.
*/
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
onDelete: 'restrict'
}),
billingEmail: text('billing_email'),
plan: text('plan').notNull().default('free'),
subscriptionStatus: text('subscription_status').notNull().default('active'),
metadata: jsonb('metadata').$type<{}>()
},
(t) => [index('team_organisation_idx').on(t.organisationId)]
);

View File

@@ -4,8 +4,8 @@ import { Actor } from '../actor.js';
import { Fixtures } from '../db/fixtures.js';
import { testDb } from '../db/test.js';
import { Identifier } from '../id.js';
import { Member } from './member.js';
import { Team } from './index.js';
import { Member } from './member.js';
const sql = testDb();
@@ -44,7 +44,10 @@ describe('Team.ensurePersonal', () => {
// the user is created — that is what backfills accounts made before the
// call existed. A second call must not mint a second team.
const again = await Actor.with(
{ type: 'user', properties: { userID: owner.userId, linkedAccountID: owner.linkedAccountId } },
{
type: 'user',
properties: { userID: owner.userId, linkedAccountID: owner.linkedAccountId }
},
() => Team.ensurePersonal({ displayName: 'team-idempotent' })
);

View File

@@ -6,9 +6,9 @@ import { ErrorCodes, VisibleError } from '../error.js';
import { fn } from '../fn.js';
import { Identifier } from '../id.js';
import { User } from './index.js';
import { UserTable } from './user.sql.js';
import { LinkedAccount } from './linked-account.js';
import { LinkedAccountTable } from './linked-account.sql.js';
import { UserTable } from './user.sql.js';
const STEAM_ID_RE = /^\d{17}$/;

View File

@@ -40,7 +40,9 @@ export namespace Waitlist {
return tx
.select()
.from(WaitlistEntryTable)
.where(and(eq(WaitlistEntryTable.email, input.email), isNull(WaitlistEntryTable.timeDeleted)))
.where(
and(eq(WaitlistEntryTable.email, input.email), isNull(WaitlistEntryTable.timeDeleted))
)
.then((rows) => rows.at(0) ?? null);
});
if (existing) {