mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
refactor(api)!: remove the shared operator secret, and let hosts sync their own
A single secret that turned any request into an operator was the only credential several routes accepted, and it had no caller left: the device pairing it existed for is on hold, and nothing in this tree or any client sent it. What remained was a key that bypassed authentication entirely, required to boot, and checked by nobody. Every route behind it had a better answer available: - Library and game sync move to host credentials. Both took a `userId` in the body, which meant one secret could write into anybody's library. A host now says which of its enrolled users a batch is for, and that claim is checked against the Steam sign-ins it actually holds — one box carries several people's accounts, so the pair is the unit. - Download-state reporting narrows to hosts alone, and the body that could name a different host is gone. Which host is reporting comes from its own credentials, and a body that still names one is refused rather than ignored. - Linking a Steam account is always for the caller. - Creating a game by hand is deleted; syncing already upserts the catalogue. - Reading the waitlist is deleted. Every address on it belongs to someone who has not agreed to anything, and answering it over HTTP made that list something a leaked key could drain. - The pairing-code routes are deleted with the flow they served. The domain module and its table stay, so returning to it is a route file rather than a migration. Nothing in the API now accepts a credential that stands for more than one caller: every request resolves to a specific user or a specific host, which is what lets a route say "the caller's own library" and mean it. BREAKING CHANGE: the `x-nestri-admin-token` header is no longer accepted and `ADMIN_SHARED_SECRET` is no longer read. `POST /games`, `GET /waitlist` and the `/pairing-code` routes are gone; `POST /games/sync` and `POST /library/sync` now require host credentials and take `userId` in the body; `POST /steam/link` no longer accepts `userId`; `POST /games/download-state` no longer accepts `hostId`.
This commit is contained in:
@@ -14,14 +14,14 @@ src/<parent>/
|
||||
|
||||
### Sub-modules nested under parents
|
||||
|
||||
| File | Namespace | Why |
|
||||
| ----------------------- | --------------- | ------------------------------------- |
|
||||
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
|
||||
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
|
||||
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
|
||||
| `user/library.*` | `Library` | User's owned games with playtime |
|
||||
| `team/member.*` | `Member` | Team membership with role |
|
||||
| `game/depot.*` | `Depot` | Platform-specific game content depots |
|
||||
| File | Namespace | Why |
|
||||
| ----------------------- | --------------- | --------------------------------------- |
|
||||
| `user/linked-account.*` | `LinkedAccount` | A user's OAuth/gaming identities |
|
||||
| `user/fingerprint.*` | `Fingerprint` | SSH key fingerprints |
|
||||
| `game/download.*` | `GameDownload` | Per-host game depot downloads |
|
||||
| `user/library.*` | `Library` | User's owned games with playtime |
|
||||
| `team/member.*` | `Member` | Team membership with role |
|
||||
| `game/depot.*` | `Depot` | Platform-specific game content depots |
|
||||
| `steam/enrolment.*` | `Enrolment` | Which host holds a Steam token for whom |
|
||||
|
||||
Existing top-level modules: `user/`, `team/`, `game/`, `pairing-code/`, `steam/`, `auth/`, `db/`.
|
||||
@@ -573,25 +573,19 @@ A Cloudflare Worker using `@nestri/auth` (OpenAuth). Entry point is the `success
|
||||
4. If no memberships, calls `Team.createPersonal({ displayName })`
|
||||
5. Issues JWT via `context.subject('user', { userID, linkedAccountID })`
|
||||
|
||||
### Admin Auth via Shared Secret
|
||||
|
||||
Server-to-server calls can authenticate as an `admin` actor by setting the `x-nestri-admin-token` header to the value of `ADMIN_SHARED_SECRET`. This bypasses JWT auth entirely and grants a system-level actor with no user scope — useful for operations like adding games to the DB, syncing data, or other admin tasks.
|
||||
|
||||
Configure `ADMIN_SHARED_SECRET` in `.env`. It has no default anywhere — a known value here is an authentication bypass, so nothing falls back to one.
|
||||
|
||||
### Auth Middleware (`apps/api/app/middleware/auth.ts`)
|
||||
|
||||
Hono middleware that runs on every API request:
|
||||
|
||||
1. Checks `x-nestri-admin-token` header — if it matches `Env.get().ADMIN_SHARED_SECRET`, sets actor to `admin` and proceeds immediately
|
||||
2. Otherwise, reads `Authorization: Bearer <token>` header
|
||||
1. Checks `x-nestri-machine-id` / `x-nestri-machine-secret` — a registered host authenticates as itself, and bad credentials fall through to `public` rather than erroring
|
||||
2. Otherwise, reads `Authorization: Bearer <token>` header — a personal access token is resolved from the database, anything else is verified as a JWT
|
||||
3. Verifies via `client.verify(subjects, token)` from `@nestri/auth/client`
|
||||
4. If valid `user` subject:
|
||||
- Checks `x-nestri-team` header for team-scoped access
|
||||
- If team header present, verifies membership via `Member.findByTeamAndUser`
|
||||
- Sets actor to `member` (with role) or `user` type
|
||||
5. If no token/invalid: sets actor to `public` type
|
||||
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. The `admin` actor passes this guard (it's not `public`), so admin routes can use `.use(notPublic)` like any other protected route.
|
||||
6. Exports `notPublic` guard middleware — throws `VisibleError('authentication', UNAUTHORIZED, …)` if actor is `public`. Caught by `onError` → 401 JSON response. It admits machines too; what stops a host acting as its owner is `Actor.userID`, which refuses a `machine` outright.
|
||||
|
||||
### OpenAuth Subjects (`src/auth/subjects.ts`)
|
||||
|
||||
|
||||
@@ -33,11 +33,6 @@ const System = z.object({
|
||||
})
|
||||
});
|
||||
|
||||
const Admin = z.object({
|
||||
type: z.literal('admin'),
|
||||
properties: z.object({})
|
||||
});
|
||||
|
||||
/**
|
||||
* A registered nessh host, authenticated by its own credentials.
|
||||
*
|
||||
@@ -58,7 +53,7 @@ const Machine = z.object({
|
||||
})
|
||||
});
|
||||
|
||||
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Admin, Machine]);
|
||||
const ActorInfo = z.discriminatedUnion('type', [Public, User, Member, System, Machine]);
|
||||
type ActorInfo = z.infer<typeof ActorInfo>;
|
||||
|
||||
const _context = Context.create<ActorInfo>();
|
||||
|
||||
@@ -27,10 +27,6 @@ export namespace Env {
|
||||
*/
|
||||
AUTH_INTERNAL_URL: z.string().optional(),
|
||||
|
||||
SSH_AUTH_KEY: z.string().optional(),
|
||||
|
||||
ADMIN_SHARED_SECRET: z.string().optional(),
|
||||
|
||||
DATABASE_URL: z.string().optional()
|
||||
});
|
||||
|
||||
|
||||
@@ -148,6 +148,37 @@ export namespace Enrolment {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The enrolment a host holds for one user, or null.
|
||||
*
|
||||
* This is the question "may this host speak about this user's Steam
|
||||
* library?", and it is answered from the record of sign-ins rather than
|
||||
* from team membership: holding a refresh token for somebody is what makes
|
||||
* a host able to enumerate their games in the first place. One host carries
|
||||
* several people's sign-ins, so the pair is the unit and neither half of it
|
||||
* is enough on its own.
|
||||
*/
|
||||
export const findByMachineAndUser = fn(
|
||||
Info.pick({ machineId: true, userId: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(SteamEnrolmentTable)
|
||||
.where(
|
||||
and(
|
||||
eq(SteamEnrolmentTable.machineId, input.machineId),
|
||||
eq(SteamEnrolmentTable.userId, input.userId)
|
||||
)
|
||||
)
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Every enrolment the control plane believes this host has.
|
||||
*
|
||||
|
||||
Reference in New Issue
Block a user