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

@@ -10,7 +10,7 @@ import { Hono } from 'hono';
import { describeRoute } from 'hono-openapi';
import { z } from 'zod';
import { ErrorResponses, adminOnly, machineOrAdmin, notPublic, Result, validator } from '../utils';
import { enrolledUser, ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
const SyncGameSchema = z.object({
steamAppId: z.number().int(),
@@ -150,12 +150,12 @@ export namespace GameApi {
.post(
'/sync',
notPublic,
adminOnly,
machineOnly,
describeRoute({
tags: ['Games'],
summary: 'Batch sync games, library entries, and depots',
description:
'Bulk upsert games, library entries, and depot info from Steam sync. Admin only.',
'Bulk upsert games, library entries and depot info from a Steam sync. Entries land in the caller\u2019s own library; there is no field for naming another user.',
responses: {
200: {
content: {
@@ -180,13 +180,17 @@ export namespace GameApi {
validator(
'json',
z.object({
userId: z.string(),
userId: z.string().meta({
description: 'Which of the host\u2019s enrolled users this sync is for',
example: Examples.User.id
}),
games: z.array(SyncGameSchema).default([]),
library: z.array(SyncLibrarySchema).default([])
})
),
async (c) => {
const { userId, games, library } = c.req.valid('json');
const { games, library } = c.req.valid('json');
const userId = await enrolledUser(c.req.valid('json').userId);
const existingGames = await Game.listByAppIDs(games.map((g) => g.steamAppId));
const existingByAppId = new Map(existingGames.map((g) => [g.steamAppId, g]));
@@ -347,7 +351,7 @@ export namespace GameApi {
tags: ['Games'],
summary: 'Get download states for a game',
description:
'Returns the per-host download states for a game. Optionally filter by hostId. Protected read route for initial/fallback data; SSH-connected clients use the live SSH snapshot.',
'Returns the per-host download states for a game, optionally filtered to one host. This is the recorded state, written by hosts as they report progress; a client holding a live connection to a host has a fresher answer from the host itself.',
responses: {
200: {
content: {
@@ -409,12 +413,12 @@ export namespace GameApi {
.post(
'/download-state',
notPublic,
machineOrAdmin,
machineOnly,
describeRoute({
tags: ['Games'],
summary: 'Report a download state change',
description:
'Update the shared per-host download state for a game. Called by nessh on terminal events (start/verifying/complete/fail). A registered host reports as itself and cannot name another; admin must supply the hostId explicitly.',
'Update the shared per-host download state for a game, on terminal events (start/verifying/complete/fail). A host reports as itself \u2014 which host it is comes from its own credentials, and there is no field that could name another.',
responses: {
200: {
content: {
@@ -437,63 +441,42 @@ export namespace GameApi {
}),
validator(
'json',
z.object({
hostId: z.string().optional().meta({
description:
'The nessh host reporting the download. Required for admin callers; ignored for machines, which report as themselves.',
example: Examples.GameDownload.hostId
}),
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
status: z.enum(GameDownload.Status).meta({
description: 'New download status',
example: Examples.GameDownload.status
}),
progressBytes: z.number().int().optional().meta({
description: 'Bytes downloaded so far',
example: Examples.GameDownload.progressBytes
}),
totalBytes: z.number().int().optional().meta({
description: 'Total bytes to download',
example: Examples.GameDownload.totalBytes
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: null
z
.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
status: z.enum(GameDownload.Status).meta({
description: 'New download status',
example: Examples.GameDownload.status
}),
progressBytes: z.number().int().optional().meta({
description: 'Bytes downloaded so far',
example: Examples.GameDownload.progressBytes
}),
totalBytes: z.number().int().optional().meta({
description: 'Total bytes to download',
example: Examples.GameDownload.totalBytes
}),
errorMessage: z.string().nullable().optional().meta({
description: 'Error message if status is failed',
example: null
})
})
})
// A body naming a host is refused rather than ignored. It used
// to carry one, so a caller that still sends it is saying
// something this route no longer honours, and accepting it
// quietly would look like it had been.
.strict()
),
async (c) => {
const { hostId, steamAppId, status, progressBytes, totalBytes, errorMessage } =
c.req.valid('json');
const { steamAppId, status, progressBytes, totalBytes, errorMessage } = c.req.valid('json');
// A machine reports as itself. Taking the id from the body would
// mean any holder of a shared secret could write download state
// under any box's id, which is the whole reason boxes register.
const actor = Actor.use();
let reportingHostId: string;
if (actor.type === 'machine') {
if (hostId && hostId !== actor.properties.machineID) {
throw new VisibleError(
'forbidden',
ErrorCodes.Permission.FORBIDDEN,
'A machine may only report its own download state'
);
}
reportingHostId = actor.properties.machineID;
} else {
if (!hostId) {
throw new VisibleError(
'validation',
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
'hostId is required when reporting on behalf of a host',
'hostId'
);
}
reportingHostId = hostId;
}
// A host reports as itself, and `machineOnly` is what makes that
// the only possibility: with the id read from its credentials
// there is no body field to disagree with them.
const reportingHostId = Actor.machineID;
const game = await Game.fromSteamAppID(steamAppId);
if (!game) {
@@ -517,102 +500,5 @@ export namespace GameApi {
data: { downloadId: row.id, download: GameDownload.serialize(row) }
});
}
)
.post(
'/',
notPublic,
adminOnly,
describeRoute({
tags: ['Games'],
summary: 'Create or update a game',
description: 'Upsert a game by Steam app ID. Admin only.',
responses: {
201: {
content: {
'application/json': {
schema: Result(
Game.Info.meta({
description: 'The created or updated game',
example: Examples.Game
})
)
}
},
description: 'Game created or updated'
},
400: ErrorResponses[400],
401: ErrorResponses[401],
403: ErrorResponses[403]
}
}),
validator(
'json',
z.object({
steamAppId: z.number().int().meta({
description: 'Steam application ID',
example: Examples.Game.steamAppId
}),
name: z.string().meta({
description: 'Game title',
example: Examples.Game.name
}),
slug: z.string().optional().meta({
description: 'URL-friendly slug',
example: Examples.Game.slug
}),
type: z.string().nullable().optional().meta({
description: 'Content type',
example: Examples.Game.type
}),
clientIcon: z.string().nullable().optional().meta({
description: 'Steam client icon hash (256×256 square)',
example: Examples.Game.clientIcon
}),
icon: z.string().nullable().optional().meta({
description: 'Steam icon hash (32×32)',
example: Examples.Game.icon
}),
shortDescription: z.string().nullable().optional().meta({
description: 'Short description',
example: Examples.Game.shortDescription
}),
description: z.string().nullable().optional().meta({
description: 'Full description',
example: Examples.Game.description
}),
developers: z.array(z.string()).nullable().optional().meta({
description: 'Game developers',
example: Examples.Game.developers
}),
publishers: z.array(z.string()).nullable().optional().meta({
description: 'Game publishers',
example: Examples.Game.publishers
}),
genres: z.array(z.string()).nullable().optional().meta({
description: 'Game genres',
example: Examples.Game.genres
}),
oslist: z.array(z.string()).nullable().optional().meta({
description: 'Supported OS list',
example: Examples.Game.oslist
}),
releaseDate: z.string().nullable().optional().meta({
description: 'Release date ISO string',
example: Examples.Game.releaseDate
})
})
),
async (c) => {
const body = c.req.valid('json');
const id = Identifier.ascending('game');
const slug =
body.slug ??
body.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
const game = await Game.upsert({ ...body, id, slug });
return c.json({ data: game[0] }, 201);
}
);
}

View File

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

View File

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