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:
Wanjohi
2026-09-18 22:58:52 +03:00
parent cfb8ec26a0
commit 40b4270161
24 changed files with 348 additions and 712 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.

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

@@ -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

@@ -12,7 +12,7 @@ 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 |
@@ -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

@@ -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 {

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.
@@ -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();
};

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,12 +441,8 @@ 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
}),
z
.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
@@ -464,36 +464,19 @@ export namespace GameApi {
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

@@ -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

@@ -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

@@ -4,18 +4,19 @@ 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(
export const route = new Hono().post(
'/',
describeRoute({
tags: ['Waitlist'],
@@ -56,31 +57,5 @@ export namespace WaitlistApi {
const entry = await Waitlist.join({ email, source });
return c.json({ data: entry }, 201);
}
)
.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() })
);
}

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

@@ -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

@@ -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

@@ -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 —
@@ -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

View File

@@ -15,7 +15,7 @@ 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 |
@@ -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`)

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.
*
@@ -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>();

View File

@@ -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()
});

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.
*