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

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