diff --git a/.env.example b/.env.example index cf1028b4..a34e64db 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/.github/workflows/release-prod.yml b/.github/workflows/release-prod.yml index 2be50e64..58ded398 100644 --- a/.github/workflows/release-prod.yml +++ b/.github/workflows/release-prod.yml @@ -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 || { diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 8f3dc936..19e861fc 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 diff --git a/apps/api/README.md b/apps/api/README.md index adcf9b9c..c9cc4f5e 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -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 ` 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). \ No newline at end of file +[`docs/deploy.md`](../../docs/deploy.md). diff --git a/apps/api/app/index.ts b/apps/api/app/index.ts index 065c2a44..6c12cd59 100644 --- a/apps/api/app/index.ts +++ b/apps/api/app/index.ts @@ -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 { diff --git a/apps/api/app/middleware/auth.ts b/apps/api/app/middleware/auth.ts index aab6062f..51097076 100644 --- a/apps/api/app/middleware/auth.ts +++ b/apps/api/app/middleware/auth.ts @@ -85,11 +85,6 @@ function getClient(env: Record) { } 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(); -}; diff --git a/apps/api/app/routes/game.ts b/apps/api/app/routes/game.ts index 7da2548c..cfac9cd9 100644 --- a/apps/api/app/routes/game.ts +++ b/apps/api/app/routes/game.ts @@ -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); - } ); } diff --git a/apps/api/app/routes/library.ts b/apps/api/app/routes/library.ts index 0ac1c9c7..d1c5fc4b 100644 --- a/apps/api/app/routes/library.ts +++ b/apps/api/app/routes/library.ts @@ -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])); diff --git a/apps/api/app/routes/pairing-code.ts b/apps/api/app/routes/pairing-code.ts deleted file mode 100644 index 128c0d28..00000000 --- a/apps/api/app/routes/pairing-code.ts +++ /dev/null @@ -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 } }); - } - ); -} diff --git a/apps/api/app/routes/steam.ts b/apps/api/app/routes/steam.ts index 2dbb7626..d037da3e 100644 --- a/apps/api/app/routes/steam.ts +++ b/apps/api/app/routes/steam.ts @@ -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 } diff --git a/apps/api/app/routes/waitlist.ts b/apps/api/app/routes/waitlist.ts index b4b170ce..f13d6e28 100644 --- a/apps/api/app/routes/waitlist.ts +++ b/apps/api/app/routes/waitlist.ts @@ -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); + } + ); } diff --git a/apps/api/app/utils/auth.ts b/apps/api/app/utils/auth.ts index 5bc3152d..3660e84a 100644 --- a/apps/api/app/utils/auth.ts +++ b/apps/api/app/utils/auth.ts @@ -1 +1 @@ -export { auth, notPublic, adminOnly, machineOnly, machineOrAdmin } from '../middleware/auth.js'; +export { auth, notPublic, machineOnly } from '../middleware/auth.js'; diff --git a/apps/api/app/utils/enrolment.ts b/apps/api/app/utils/enrolment.ts new file mode 100644 index 00000000..8162813f --- /dev/null +++ b/apps/api/app/utils/enrolment.ts @@ -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 { + 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; +} diff --git a/apps/api/app/utils/index.ts b/apps/api/app/utils/index.ts index 8d4af21c..9b05dfbe 100644 --- a/apps/api/app/utils/index.ts +++ b/apps/api/app/utils/index.ts @@ -1,4 +1,5 @@ export * from './auth'; +export * from './enrolment'; export * from './error'; export * from './result'; export * from './validator'; diff --git a/apps/api/test/enrolment.test.ts b/apps/api/test/enrolment.test.ts index a201f68b..4e228aa7 100644 --- a/apps/api/test/enrolment.test.ts +++ b/apps/api/test/enrolment.test.ts @@ -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> { + 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); }); diff --git a/apps/api/test/heartbeat.test.ts b/apps/api/test/heartbeat.test.ts index 0c85e1b0..396a2f6e 100644 --- a/apps/api/test/heartbeat.test.ts +++ b/apps/api/test/heartbeat.test.ts @@ -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> { + 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); }); diff --git a/apps/api/test/routes.test.ts b/apps/api/test/routes.test.ts index faa43831..e23cb819 100644 --- a/apps/api/test/routes.test.ts +++ b/apps/api/test/routes.test.ts @@ -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 { - 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; host: Record }> | 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> { + return (await credentials()).user; +} + +async function hostHeaders(): Promise> { + 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); - }); }); diff --git a/apps/api/test/setup.ts b/apps/api/test/setup.ts index d2499cc0..aa0e24f6 100644 --- a/apps/api/test/setup.ts +++ b/apps/api/test/setup.ts @@ -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 }; diff --git a/docker-compose.yml b/docker-compose.yml index bec8c96f..df81c1be 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/docs/deploy.md b/docs/deploy.md index 6d772c6b..f2556fc5 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -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 diff --git a/packages/core/CLAUDE.md b/packages/core/CLAUDE.md index a7ab9612..eab2357e 100644 --- a/packages/core/CLAUDE.md +++ b/packages/core/CLAUDE.md @@ -14,14 +14,14 @@ src// ### 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 ` 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 ` 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`) diff --git a/packages/core/src/actor.ts b/packages/core/src/actor.ts index 5cba9e5d..22c28504 100644 --- a/packages/core/src/actor.ts +++ b/packages/core/src/actor.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; const _context = Context.create(); diff --git a/packages/core/src/env.ts b/packages/core/src/env.ts index 28a31da9..57e4495a 100644 --- a/packages/core/src/env.ts +++ b/packages/core/src/env.ts @@ -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() }); diff --git a/packages/core/src/steam/enrolment.ts b/packages/core/src/steam/enrolment.ts index f46b224e..6a8f8e8a 100644 --- a/packages/core/src/steam/enrolment.ts +++ b/packages/core/src/steam/enrolment.ts @@ -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. *