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

@@ -14,14 +14,14 @@ src/<parent>/
### Sub-modules nested under parents
| File | Namespace | Why |
| ----------------------- | --------------- | ------------------------------------- |
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
| `user/library.*` | `Library` | User's owned games with playtime |
| `team/member.*` | `Member` | Team membership with role |
| `game/depot.*` | `Depot` | Platform-specific game content depots |
| File | Namespace | Why |
| ----------------------- | --------------- | --------------------------------------- |
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
| `user/library.*` | `Library` | User's owned games with playtime |
| `team/member.*` | `Member` | Team membership with role |
| `game/depot.*` | `Depot` | Platform-specific game content depots |
| `steam/enrolment.*` | `Enrolment` | Which host holds a Steam token for whom |
Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`.
@@ -573,25 +573,19 @@ A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success
4. If no memberships, calls `Team.createPersonal({ displayName })`
5. Issues JWT via `context.subject('user', { userID, linkedAccountID })`
### Admin Auth via Shared Secret
Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks.
Configure `ADMIN_SHARED_SECRET` in `.env`. It has no default anywhere — a known value here is an authentication bypass, so nothing falls back to one.
### Auth Middleware (`apps/api/app/middleware/auth.ts`)
Hono middleware that runs on every API request:
1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately
2. Otherwise, reads `Authorization: Bearer <token>` header
1. Checks `x-nestri-machine-id` / `x-nestri-machine-secret` — a registered host authenticates as itself, and bad credentials fall through to `public` rather than erroring
2. Otherwise, reads `Authorization: Bearer <token>` header — a personal access token is resolved from the database, anything else is verified as a JWT
3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client`
4. If valid `user` subject:
- Checks `x-nestri-team` header for team-scoped access
- If team header present, verifies membership via `Member.findByTeamAndUser`
- Sets actor to `member` (with role) or `user` type
5. If no token/invalid: sets actor to `public` type
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route.
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. It admits machines too; what stops a host acting as its owner is `Actor.userID`, which refuses a `machine` outright.
### OpenAuth Subjects (`src/auth/subjects.ts`)

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