mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only credential several routes accepted, and it had no caller left: the device pairing it existed for is on hold, and nothing in this tree or any client sent it. What remained was a key that bypassed authentication entirely, required to boot, and checked by nobody. Every route behind it had a better answer available: - Library and game sync move to host credentials. Both took a `userId` in the body, which meant one secret could write into anybody's library. A host now says which of its enrolled users a batch is for, and that claim is checked against the Steam sign-ins it actually holds — one box carries several people's accounts, so the pair is the unit. - Download-state reporting narrows to hosts alone, and the body that could name a different host is gone. Which host is reporting comes from its own credentials, and a body that still names one is refused rather than ignored. - Linking a Steam account is always for the caller. - Creating a game by hand is deleted; syncing already upserts the catalogue. - Reading the waitlist is deleted. 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. - The pairing-code routes are deleted with the flow they served. The domain module and its table stay, so returning to it is a route file rather than a migration. Nothing in the API now accepts a credential that stands for more than one caller: every request resolves to a specific user or a specific host, which is what lets a route say "the caller's own library" and mean it. BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and `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 require host credentials and take `userId` in the body; `POST /steam/link` no longer accepts `userId`; `POST /games/download-state` no longer accepts `hostId`.
This commit is contained in:
@@ -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.
|
||||
|
||||
3
.github/workflows/release-prod.yml
vendored
3
.github/workflows/release-prod.yml
vendored
@@ -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 || {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -11,17 +11,17 @@ and returns `{ data: ... }`. All business logic lives in the core package.
|
||||
|
||||
Routes:
|
||||
|
||||
| Prefix | Purpose |
|
||||
| ----------------- | ------------------------------------------------------------- |
|
||||
| `/` | Health check |
|
||||
| `/user` | Current user profile, fingerprints, linked accounts |
|
||||
| `/steam` | Link / sync / unlink a Steam account |
|
||||
| `/library` | Owned games with playtime |
|
||||
| `/games` | Game catalog |
|
||||
| `/pairing-code` | Device pairing codes |
|
||||
| `/machine` | Host machines |
|
||||
| `/access-token` | Short-lived access tokens |
|
||||
| `/doc` | Generated OpenAPI spec |
|
||||
| Prefix | Purpose |
|
||||
| --------------- | --------------------------------------------------- |
|
||||
| `/` | Health check |
|
||||
| `/user` | Current user profile, fingerprints, linked accounts |
|
||||
| `/steam` | Link / sync / unlink a Steam account |
|
||||
| `/library` | Owned games with playtime |
|
||||
| `/games` | Game catalog |
|
||||
| `/pairing-code` | Device pairing codes |
|
||||
| `/machine` | Host machines |
|
||||
| `/access-token` | Short-lived access tokens |
|
||||
| `/doc` | Generated OpenAPI spec |
|
||||
|
||||
## Structure
|
||||
|
||||
@@ -29,7 +29,7 @@ Routes:
|
||||
app/
|
||||
index.ts # The handler: middleware, routes, error handler, /doc
|
||||
server.ts # The same handler behind a listening socket
|
||||
middleware/auth.ts # Bearer JWT + admin shared-secret auth → Actor
|
||||
middleware/auth.ts # Bearer JWT, access token or host credentials → Actor
|
||||
routes/*.ts # Thin route namespaces (UserApi, SteamApi, ...)
|
||||
utils/ # ErrorResponses, Result(), validator wrapping
|
||||
wrangler.jsonc # Worker configuration, one environment per stage
|
||||
@@ -39,12 +39,11 @@ test/ # Route tests
|
||||
|
||||
## Key details
|
||||
|
||||
- Auth: `Authorization: Bearer <JWT>` verified against `@nestri/auth`; or the `x-nestri-admin-token` header
|
||||
carrying `ADMIN_SHARED_SECRET`, which bypasses JWT verification entirely and is required — it has no
|
||||
default anywhere. It is what authenticates the callers that have no user identity to present:
|
||||
`POST /pairing-code/claim` (a device being paired has no identity yet, which is the whole point),
|
||||
`POST /games`, `POST /games/sync`, `POST /library/sync`, `GET /waitlist`, `POST /steam/link` on behalf
|
||||
of another user, and `POST /games/download-state` when an operator is repairing state a box reported.
|
||||
- Auth: `Authorization: Bearer …`, carrying either a session token verified against `@nestri/auth`
|
||||
or a personal access token resolved from the database; or a registered host's own
|
||||
`x-nestri-machine-id` and `x-nestri-machine-secret`. There is no shared secret and no credential
|
||||
that stands for more than one caller, so every route resolves to a specific user or a specific
|
||||
host — which is what lets a route say "the caller's own library" and mean it.
|
||||
- Errors: centralized `VisibleError` → typed JSON responses.
|
||||
- Settings arrive as bindings or as environment variables, and two of them have one spelling of
|
||||
each: Postgres is `HYPERDRIVE` or `DATABASE_URL`, and the route to the issuer is an `AUTH`
|
||||
@@ -60,4 +59,4 @@ bun run serve # as a plain process, on $PORT (default 3000)
|
||||
```
|
||||
|
||||
Needs a Postgres database and a reachable issuer. Full list and deployment steps:
|
||||
[`docs/deploy.md`](../../docs/deploy.md).
|
||||
[`docs/deploy.md`](../../docs/deploy.md).
|
||||
|
||||
@@ -15,7 +15,6 @@ import { GameApi } from './routes/game.js';
|
||||
import { IndexApi } from './routes/index.js';
|
||||
import { LibraryApi } from './routes/library.js';
|
||||
import { MachineApi } from './routes/machine.js';
|
||||
import { PairingCodeApi } from './routes/pairing-code.js';
|
||||
import { SessionApi } from './routes/session.js';
|
||||
import { SteamApi } from './routes/steam.js';
|
||||
import { UserApi } from './routes/user.js';
|
||||
@@ -43,7 +42,6 @@ const routes = app
|
||||
.route('/steam', SteamApi.route)
|
||||
.route('/library', LibraryApi.route)
|
||||
.route('/games', GameApi.route)
|
||||
.route('/pairing-code', PairingCodeApi.route)
|
||||
.route('/machine', MachineApi.route)
|
||||
.route('/machine', SessionApi.machineRoute)
|
||||
.route('/machine', EnrolmentApi.route)
|
||||
@@ -125,7 +123,6 @@ export type ApiEnv = {
|
||||
AUTH_INTERNAL_URL?: string;
|
||||
HYPERDRIVE?: Hyperdrive;
|
||||
DATABASE_URL?: string;
|
||||
ADMIN_SHARED_SECRET?: string;
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -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.
|
||||
@@ -263,34 +258,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();
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js';
|
||||
export { auth, notPublic, machineOnly } from '../middleware/auth.js';
|
||||
|
||||
32
apps/api/app/utils/enrolment.ts
Normal file
32
apps/api/app/utils/enrolment.ts
Normal 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;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export * from './auth';
|
||||
export * from './enrolment';
|
||||
export * from './error';
|
||||
export * from './result';
|
||||
export * from './validator';
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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 else’s 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
@@ -139,7 +135,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
|
||||
|
||||
@@ -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/`.
|
||||
@@ -573,25 +573,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`)
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -58,7 +53,7 @@ const Machine = z.object({
|
||||
})
|
||||
});
|
||||
|
||||
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>();
|
||||
|
||||
@@ -27,10 +27,6 @@ export namespace Env {
|
||||
*/
|
||||
AUTH_INTERNAL_URL: z.string().optional(),
|
||||
|
||||
SSH_AUTH_KEY: z.string().optional(),
|
||||
|
||||
ADMIN_SHARED_SECRET: z.string().optional(),
|
||||
|
||||
DATABASE_URL: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user