mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 09:15:19 +03:00
feat(api): record which host holds a Steam token for whom (#327)
## What this is
A host that signs someone into Steam ends up holding a refresh token.
The
control plane needs to know that happened; it must not know the
credential.
This adds the table, the domain module and the three
machine-authenticated
routes that record the outcome, and a test that the surface refuses a
token.
| | |
|---|---|
| `steam_enrolment` | `(machine_id, user_id)` primary key, `steam_id`,
`state`, `enrolled_at`, `last_ok_at`, `revoked_at`. **No token column.**
|
| `Enrolment.record` | upsert to `enrolled` — keeps the original
`enrolled_at`, clears a previous refusal |
| `Enrolment.markStale` | machine-scoped update; `null` when there is
nothing to mark |
| `Enrolment.listByMachine` | what this host is believed to hold, oldest
first |
| `POST /machine/enrolment` | `{userId, steamId}` → the enrolment |
| `POST /machine/enrolment/stale` | `{userId}` → the enrolment, now
stale; `404` if absent |
| `GET /machine/enrolment` | the list |
All three take the host from its own credentials, never from a body, so
a box
can neither report onto nor read another box's hardware. `data` is the
object
itself — never `{"data": {"enrolment": …}}`.
## The test failing first
Both new files, against unmodified code:
```
✗ POST /machine/enrolment > the outcome is recorded and `data` is the enrolment itself
✗ POST /machine/enrolment > the machine is taken from the credentials, never the body
✗ POST /machine/enrolment > re-enrolling keeps the first `enrolledAt` and adopts the new Steam account
✗ POST /machine/enrolment > one Steam account on two hosts is two enrolments
✗ POST /machine/enrolment > a user nobody has heard of is refused rather than crashing
✗ POST /machine/enrolment > a Steam id has to look like one
✗ POST /machine/enrolment > machine credentials are required
✗ POST /machine/enrolment/stale > a refused token moves the enrolment to stale
✗ POST /machine/enrolment/stale > re-enrolling after a refusal returns the row to enrolled
✗ POST /machine/enrolment/stale > an enrolment this host does not have is a 404
✗ POST /machine/enrolment/stale > a host cannot mark another host’s enrolment stale
✗ POST /machine/enrolment/stale > machine credentials are required
✗ GET /machine/enrolment > a host with no enrolments gets an empty list, not a 404
✗ GET /machine/enrolment > every enrolment this host is expected to hold, and no other host’s
✗ GET /machine/enrolment > machine credentials are required
✗ The enrolment surface refuses a token > POST /machine/enrolment rejects every credential-shaped field
✗ The enrolment surface refuses a token > POST /machine/enrolment/stale rejects every credential-shaped field
✗ The enrolment surface refuses a token > the published surface has exactly three enrolment routes and no field for a credential
error: Cannot find module './enrolment.js' from 'packages/core/src/steam/enrolment.test.ts'
0 pass
19 fail
1 error
Ran 19 tests across 2 files.
```
And after, against a database migrated from zero:
```
363 pass
0 fail
1047 expect() calls
Ran 363 tests across 29 files. [8.39s]
```
`bunx oxlint` clean; `oxfmt --check` clean on every file in the diff.
`tsc
--noEmit` on `apps/api` and `packages/core` reports exactly the same 5
and 2
pre-existing errors as `dev` does — none in files this touches.
## How the token is kept out, in three places rather than one
1. **The column list.** A core test asserts `information_schema.columns`
for
the table is exactly the seven contract columns. Adding `refresh_token`,
or
an `encrypted_token`, or a `secret`, fails it.
2. **The request bodies are `.strict()`**, derived from the domain
schema with
`Info.pick(...)` so they cannot drift from it. `refreshToken`, `token`,
`accessToken`, `challengeUrl` and `clientId` are each rejected with a
`400`.
3. **The published surface.** A test walks `/doc`, collects every
request-body
property and parameter under `/machine/enrolment*`, and asserts the set
is
exactly `{userId, steamId}` — so any new field on this surface has to be
argued for in that test, not only a token-shaped one. It also asserts
the
route list is exactly the two paths.
Checked by hand against a live server: the rejected key's **name**
reaches the
log, its **value** does not (`grep -c eyJsecret` over the server log →
0).
## Driven over real HTTP, not only `app.request`
A real `bun run apps/api/app/server.ts`, a real registered machine,
curl:
```
GET /machine/enrolment → {"data":[]} [200]
POST /machine/enrolment → {"data":{…,"state":"enrolled","lastOkAt":null}} [200]
POST again, new steam account → same enrolledAt, new steamId [200]
POST + refreshToken → Unrecognized key: "refreshToken" [400]
POST + challengeUrl+clientId → Unrecognized keys: "challengeUrl", "clientId" [400]
POST /machine/enrolment/stale → {"data":{…,"state":"stale"}} [200]
GET /machine/enrolment → the one row, stale [200]
no credentials / wrong secret → Machine credentials required [403]
stale for an absent enrolment → This machine has no enrolment for that user [404]
POST naming another machine → Unrecognized key: "machineId" [400]
```
## Two places the contract was read rather than followed literally, both
worth a look
- **`state` is a Postgres enum, not `text`.** The three values are the
whole
state machine, so the database refuses a fourth rather than storing it.
If
you would rather have `text`, say so and I will change it — but a typo'd
state is otherwise a silent write.
- **The row has no `id` and no `time_deleted`**, which departs from the
every-table convention in `packages/core/CLAUDE.md`. The pair *is* the
identity, and `enrolled_at` would make `time_created` a second answer to
the
same question. The row's life is bounded by the machine's and the
user's, and
both foreign keys cascade. Flagging it because it is the sort of thing a
reviewer should agree to on purpose.
## Review rounds: three findings, all real, all fixed
- **An overlong `userId` returned a 500.** Ids live in a `char(30)`
column, so
an overlong one is refused by Postgres with `22001` — not the `23503`
the
handler catches — and fell through to the global error boundary.
Measured
before fixing: 44 characters → `500`, absent-but-well-formed → `404`.
`Identifier.schema` had no callers anywhere in the tree, so it now
asserts
the exact width and the separator as well as the prefix, and
`Enrolment.Info`
uses it for both foreign keys. The route picks up the constraint through
its
existing `Info.pick(...)`, so the answer is a `400` naming `userId`.
Verified
against a live server: zero 500s across the run.
- **`user_id` was unindexed.** The key begins with `machine_id`, so
neither the
cascade behind deleting a user nor "which hosts hold a token for me" can
use
it. `steam_enrolment_user_idx` added. The migration has not been
released, so
it is folded in and regenerated through `drizzle-kit` rather than
followed by
a corrective `0013`.
- **Every documented id was one character short.** `Examples.Id` emitted
25
payload characters where an id has 26, so the published examples were 29
characters — invalid against the width the previous fix started
enforcing.
Never broken at runtime, since an example is not parsed; wrong in the
documentation people copy from. The width now comes from
`Identifier.LENGTH` rather than being typed out, in both places that had
counted it by hand, and the third hand-written copy of the same literal
in
`apps/api/app/routes/steam.ts` now calls the generator instead.
Each is covered by a test, including one that walks four differently
misshapen
ids. The existing "a user nobody has heard of" test now uses a
well-formed
absent id, so it exercises the foreign-key path it was written for
rather than
passing for the wrong reason.
## Files outside this lane's ownership
Four, each the smallest possible diff:
- `apps/api/app/index.ts` — one import, one `.route('/machine', …)`
line.
- `packages/core/src/examples.ts` — one `Examples.SteamEnrolment` block.
- `packages/core/src/id.ts` — `Identifier.schema` gains the width and
separator
checks described above, and `LENGTH` is exported so the examples can
derive
from it. `schema` had **no callers in the tree** before this branch, so
nothing else can be affected by the tightening; this lane is its first.
- `packages/core/src/id.test.ts` — new. Pins a generated id, the schema
for
one, and the documented example together, for every prefix.
- `apps/api/app/routes/steam.ts` — one line: a hand-written `usr_XXX…`
example
literal, wrong by the same character, replaced with
`Examples.Id('user')`.
Owned by no lane this week. Flagging it because it is the only change
here
outside enrolment's own surface.
- `packages/core/CLAUDE.md` — one row in the sub-module table, and the
sentence
saying `steam/` owns no table is now false, so it is reworded.
`packages/core/src/steam/index.ts` is this lane's, and the change there
is one
word: `STEAM_ID_RE` is exported so the enrolment schema uses the same
rule
rather than a second copy of the same regex.
## What this does not verify
- **No host has ever called these routes.** The other half of this seam
was
written from the same document without either side reading the other's
code.
A disagreement, if there is one, surfaces on first contact — not here.
- **Nothing enforces that a token never arrives.** The three guards
above fail
when somebody adds a field *to this surface*. They say nothing about a
route
added elsewhere, and no test can.
- **`revoked` has no writer.** The value exists in the enum and nothing
sets
it. Revocation is not built here.
- **`last_ok_at` has no writer**, so it is null in every row this
creates. It
has never been exercised with a value.
- **Authorisation is not tested here and is not this lane's.** Whether a
person
may reach a given box is decided before a request arrives; these routes
authenticate a *machine*, which is a different question. Nothing here
would
catch a mistake in the other one.
- **The `404` for an unknown user comes from catching a foreign-key
violation**
(`23503`), not from a lookup. It is exercised for a user id that does
not
exist. It has not been exercised against a user deleted concurrently
with the
insert, which is the same code path but a race I did not reproduce.
- **The index is not measured.** It is added because two readers exist
that
cannot use the primary key, not because a plan was compared. On a table
this
size neither would be slow yet.
- **Nothing else is measured.** No timing, no throughput, no load. The
only
numbers above are test counts and HTTP status codes.
<!-- greptile_comment -->
<h3>Greptile Summary</h3>
This PR adds machine-authenticated Steam enrolment reporting without
transmitting or storing Steam credentials.
- Adds record, stale-state, and machine-scoped listing operations in the
core domain.
- Adds corresponding `/machine/enrolment` API routes with strict request
validation.
- Adds the enrolment table, state enum, foreign keys, user index, and
migration metadata.
- Aligns identifier validation and OpenAPI examples with the fixed
30-character identifier format.
- Adds domain, API, schema, authorization, and credential-exclusion
tests.
<h3>Confidence Score: 5/5</h3>
The PR appears safe to merge; the prior identifier-example issue is
fixed and no blocking correctness, security, or repository-rule
violations remain.
The current code fully addresses all previous findings: malformed
identifiers are rejected before database access, the user foreign key
has its own index, and shared examples now satisfy the identifier
schema. The changes since the previous review introduce no new
actionable failures.
<h3>Important Files Changed</h3>
| Filename | Overview |
|----------|----------|
| apps/api/app/routes/enrolment.ts | Adds three strictly validated,
machine-authenticated enrolment routes scoped through the authenticated
machine actor. |
| packages/core/src/steam/enrolment.ts | Adds validated enrolment
recording, stale-state updates, machine-scoped listing, and
serialization. |
| packages/core/src/steam/enrolment.sql.ts | Defines credential-free
enrolment persistence with a composite key, cascading foreign keys, and
a user lookup index. |
| packages/core/migrations/0012_steam_enrolment_without_a_token.sql |
Creates the enrolment enum, table, constraints, and user index
consistently with the Drizzle model. |
| packages/core/src/id.ts | Tightens identifier validation to the exact
prefixed 30-character storage format. |
| packages/core/src/examples.ts | Corrects shared identifier examples to
produce schema-valid 30-character values. |
| apps/api/test/enrolment.test.ts | Covers route shape, authentication,
machine isolation, validation, lifecycle behavior, and rejection of
credential fields. |
<h3>Sequence Diagram</h3>
```mermaid
sequenceDiagram
participant H as Authenticated host
participant A as API
participant C as Core Enrolment domain
participant D as PostgreSQL
H->>A: "POST /machine/enrolment {userId, steamId}"
A->>A: Derive machineId from actor credentials
A->>C: record(machineId, userId, steamId)
C->>D: Upsert enrolment metadata
D-->>C: Enrolment row
C-->>A: Serialized enrolment
A-->>H: "{data: enrolment}"
H->>A: "POST /machine/enrolment/stale {userId}"
A->>C: markStale(authenticated machineId, userId)
C->>D: Machine-and-user-scoped update
D-->>H: Updated enrolment or 404
H->>A: GET /machine/enrolment
A->>C: listByMachine(authenticated machineId)
C->>D: Select this machine's rows
D-->>H: "{data: enrolments[]}"
```
<sub>Reviews (3): Last reviewed commit: ["fix(core): document an id that
is
actual..."](fe5297acbd)
| [Re-trigger
Greptile](https://app.greptile.com/api/retrigger?id=60932045)</sub>
<details><summary><h4>Context used (3)</h4></summary>
- Knowledge Base — [API HTTP composition and
authorization](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-http-and-auth.md)
- Knowledge Base — [API catalog, machine, and account
endpoints](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/api-catalog-and-machine-endpoints.md)
- Knowledge Base — [Core domain and
persistence](https://app.greptile.com/nestri/-/custom-context/knowledge-base/nestrilabs/nestri/-/docs/core-domain-data.md)
</details>
<!-- /greptile_comment -->
This commit is contained in:
@@ -10,6 +10,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
|
||||
import { auth } from './middleware/auth.js';
|
||||
import { AccessTokenApi } from './routes/access-token.js';
|
||||
import { EnrolmentApi } from './routes/enrolment.js';
|
||||
import { GameApi } from './routes/game.js';
|
||||
import { IndexApi } from './routes/index.js';
|
||||
import { LibraryApi } from './routes/library.js';
|
||||
@@ -45,6 +46,7 @@ const routes = app
|
||||
.route('/pairing-code', PairingCodeApi.route)
|
||||
.route('/machine', MachineApi.route)
|
||||
.route('/machine', SessionApi.machineRoute)
|
||||
.route('/machine', EnrolmentApi.route)
|
||||
.route('/session', SessionApi.route)
|
||||
.route('/access-token', AccessTokenApi.route)
|
||||
.route('/waitlist', WaitlistApi.route)
|
||||
|
||||
127
apps/api/app/routes/enrolment.ts
Normal file
127
apps/api/app/routes/enrolment.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Enrolment } from '@nestri/core/steam/enrolment';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, machineOnly, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* What a host reports about the Steam sign-ins it holds.
|
||||
*
|
||||
* Mounted where a host looks for it — everything a box says about itself lives
|
||||
* under one prefix — and machine-authenticated throughout, so the host is
|
||||
* taken from its own credentials and never from a body. A box therefore cannot
|
||||
* report an enrolment onto somebody else's hardware.
|
||||
*
|
||||
* **Nothing here accepts a credential**, and that is the point of the shape
|
||||
* rather than a property of it. The refresh token, the challenge URL and the
|
||||
* client id all stay inside the host process; the bodies below are strict, so
|
||||
* a host that tried to send one is told it is wrong instead of being quietly
|
||||
* believed. ref(d-0004)
|
||||
*
|
||||
* Whether a *person* may reach a given box is decided before a request gets
|
||||
* here, at the edge, by comparing the team that owns the hardware against the
|
||||
* teams they belong to. It is a different question from the one these routes
|
||||
* ask, and none of them re-ask it.
|
||||
*/
|
||||
export namespace EnrolmentApi {
|
||||
// Picked from the domain schema rather than restated, so the shape a host
|
||||
// must send and the shape the record has cannot drift apart — including the
|
||||
// Steam id's format, which is checked here at the boundary and therefore
|
||||
// answers with a validation error rather than a server fault.
|
||||
//
|
||||
// `.strict()` on both is load-bearing. A body carrying a refresh token, a
|
||||
// challenge URL or a client id is a mistake worth refusing loudly:
|
||||
// accepting and ignoring it would mean the credential reached this process,
|
||||
// was written to the request log, and nobody found out.
|
||||
const Reported = Enrolment.Info.pick({ userId: true, steamId: true }).strict();
|
||||
const ForOneUser = Enrolment.Info.pick({ userId: true }).strict();
|
||||
|
||||
export const route = new Hono()
|
||||
.post(
|
||||
'/enrolment',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Enrolment'],
|
||||
summary: 'Say a Steam sign-in completed',
|
||||
description:
|
||||
'Records that the calling host now holds a Steam refresh token for this user. The host comes from its own credentials. Repeating it is the same fact restated — the Steam account is updated, a previous refusal is cleared, and the time the pairing began is left alone. The token itself is never sent: it belongs on the host that obtained it, and there is no field here that would carry one.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Enrolment.Info) } },
|
||||
description: 'The enrolment, as it now stands'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator('json', Reported),
|
||||
async (c) => {
|
||||
const body = c.req.valid('json');
|
||||
return c.json({
|
||||
data: await Enrolment.record({
|
||||
machineId: Actor.machineID,
|
||||
userId: body.userId,
|
||||
steamId: body.steamId
|
||||
})
|
||||
});
|
||||
}
|
||||
)
|
||||
.post(
|
||||
'/enrolment/stale',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Enrolment'],
|
||||
summary: 'Say Steam refused the token this host holds',
|
||||
description:
|
||||
'Marks the calling host’s enrolment for this user as stale. Scoped to the caller, so an enrolment belonging to another host is simply not found. An enrolment that was never recorded is a 404 rather than a new stale row — inventing one would make the record claim a sign-in that never happened.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Enrolment.Info) } },
|
||||
description: 'The enrolment, now stale'
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
validator('json', ForOneUser),
|
||||
async (c) => {
|
||||
const enrolment = await Enrolment.markStale({
|
||||
machineId: Actor.machineID,
|
||||
userId: c.req.valid('json').userId
|
||||
});
|
||||
if (!enrolment) {
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'This machine has no enrolment for that user'
|
||||
);
|
||||
}
|
||||
return c.json({ data: enrolment });
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/enrolment',
|
||||
machineOnly,
|
||||
describeRoute({
|
||||
tags: ['Enrolment'],
|
||||
summary: 'Ask what this host is expected to hold',
|
||||
description:
|
||||
'Every enrolment recorded against the calling host, oldest first. A host that lost its disk asks this to find out which sign-ins it is believed to have, and can then report the ones it does not. Nothing reconciles the answer yet; the shape is fixed now so it does not change once something depends on it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Enrolment.Info)) } },
|
||||
description: 'Enrolments this host is expected to hold'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
return c.json({ data: await Enrolment.listByMachine(Actor.machineID) });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -23,9 +23,7 @@ export namespace SteamApi {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z
|
||||
.union([LinkedAccount.Info, z.null()])
|
||||
.meta({
|
||||
z.union([LinkedAccount.Info, z.null()]).meta({
|
||||
description: 'The linked Steam account, or null',
|
||||
example: Examples.LinkedAccount
|
||||
})
|
||||
@@ -53,9 +51,7 @@ export namespace SteamApi {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.object({ unlinked: z.boolean() })
|
||||
)
|
||||
schema: Result(z.object({ unlinked: z.boolean() }))
|
||||
}
|
||||
},
|
||||
description: 'Steam account unlinked'
|
||||
@@ -117,9 +113,12 @@ export namespace SteamApi {
|
||||
description: 'Steam ID to link',
|
||||
example: '76561197960287930'
|
||||
}),
|
||||
userId: z.string().optional().meta({
|
||||
userId: z
|
||||
.string()
|
||||
.optional()
|
||||
.meta({
|
||||
description: 'User ID to link to (admin only; omitted when linking your own account)',
|
||||
example: 'usr_XXXXXXXXXXXXXXXXXXXXXXXXX'
|
||||
example: Examples.Id('user')
|
||||
}),
|
||||
profile: z
|
||||
.record(z.string(), z.unknown())
|
||||
|
||||
363
apps/api/test/enrolment.test.ts
Normal file
363
apps/api/test/enrolment.test.ts
Normal file
@@ -0,0 +1,363 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
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 Steam ID is 17 digits; these are distinct and obviously not real. */
|
||||
function steamId(n: number) {
|
||||
return `765611980000${String(n).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
async function registeredHost(label: string) {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
const registered = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: owner.teamId,
|
||||
label
|
||||
});
|
||||
return {
|
||||
id: registered.id,
|
||||
userId: owner.userId,
|
||||
headers: {
|
||||
'x-nestri-machine-id': registered.id,
|
||||
'x-nestri-machine-secret': registered.secret,
|
||||
'content-type': 'application/json'
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function enrol(host: { headers: Record<string, string> }, body: unknown) {
|
||||
return app.request('/machine/enrolment', {
|
||||
method: 'POST',
|
||||
headers: host.headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
function markStale(host: { headers: Record<string, string> }, body: unknown) {
|
||||
return app.request('/machine/enrolment/stale', {
|
||||
method: 'POST',
|
||||
headers: host.headers,
|
||||
body: JSON.stringify(body)
|
||||
});
|
||||
}
|
||||
|
||||
function list(host: { headers: Record<string, string> }) {
|
||||
return app.request('/machine/enrolment', { headers: host.headers });
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
describe('POST /machine/enrolment', () => {
|
||||
test('the outcome is recorded and `data` is the enrolment itself', async () => {
|
||||
const host = await registeredHost('enrol-shape');
|
||||
|
||||
const res = await enrol(host, { userId: host.userId, steamId: steamId(1) });
|
||||
expect(res.status).toBe(200);
|
||||
|
||||
const body = (await res.json()) as any;
|
||||
// `data` is the object, not `{"data": {"enrolment": …}}`. A host written
|
||||
// against the wrapped form parses nothing, and finds out on first
|
||||
// contact rather than in review.
|
||||
expect(body.data).toMatchObject({
|
||||
machineId: host.id,
|
||||
userId: host.userId,
|
||||
steamId: steamId(1),
|
||||
state: 'enrolled'
|
||||
});
|
||||
expect(Object.keys(body.data).sort()).toEqual(
|
||||
['enrolledAt', 'lastOkAt', 'machineId', 'revokedAt', 'state', 'steamId', 'userId'].sort()
|
||||
);
|
||||
expect(body.data.enrolment).toBeUndefined();
|
||||
// camelCase on the wire, always. The snake-to-camel seam is where a
|
||||
// host and a control plane silently stop understanding each other.
|
||||
for (const key of Object.keys(body.data)) {
|
||||
expect(key).not.toContain('_');
|
||||
}
|
||||
expect(typeof body.data.enrolledAt).toBe('string');
|
||||
expect(body.data.lastOkAt).toBeNull();
|
||||
expect(body.data.revokedAt).toBeNull();
|
||||
});
|
||||
|
||||
test('the machine is taken from the credentials, never the body', async () => {
|
||||
const host = await registeredHost('enrol-self');
|
||||
const other = await registeredHost('enrol-other');
|
||||
|
||||
// A host naming another host would be a host enrolling somebody else's
|
||||
// hardware. There is no field for it, so this is a validation error.
|
||||
const res = await enrol(host, {
|
||||
userId: host.userId,
|
||||
steamId: steamId(2),
|
||||
machineId: other.id
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
|
||||
const still = await list(other);
|
||||
expect(((await still.json()) as any).data).toEqual([]);
|
||||
});
|
||||
|
||||
test('re-enrolling keeps the first `enrolledAt` and adopts the new Steam account', async () => {
|
||||
const host = await registeredHost('enrol-again');
|
||||
|
||||
const first = (await (
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(3) })
|
||||
).json()) as any;
|
||||
const second = (await (
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(4) })
|
||||
).json()) as any;
|
||||
|
||||
expect(second.data.enrolledAt).toBe(first.data.enrolledAt);
|
||||
expect(second.data.steamId).toBe(steamId(4));
|
||||
expect(second.data.state).toBe('enrolled');
|
||||
});
|
||||
|
||||
test('one Steam account on two hosts is two enrolments', async () => {
|
||||
// Two hosts, two tokens, two rows — the whole reason the Steam id is
|
||||
// not unique across machines. A unique index there would read as
|
||||
// hygiene and would refuse the second host.
|
||||
const first = await registeredHost('enrol-two-a');
|
||||
const second = await registeredHost('enrol-two-b');
|
||||
const shared = steamId(5);
|
||||
|
||||
expect((await enrol(first, { userId: first.userId, steamId: shared })).status).toBe(200);
|
||||
expect((await enrol(second, { userId: second.userId, steamId: shared })).status).toBe(200);
|
||||
|
||||
const a = ((await (await list(first)).json()) as any).data;
|
||||
const b = ((await (await list(second)).json()) as any).data;
|
||||
expect(a).toHaveLength(1);
|
||||
expect(b).toHaveLength(1);
|
||||
expect(a[0].machineId).toBe(first.id);
|
||||
expect(b[0].machineId).toBe(second.id);
|
||||
});
|
||||
|
||||
test('a user nobody has heard of is refused rather than crashing', async () => {
|
||||
const host = await registeredHost('enrol-ghost');
|
||||
// Well-formed and simply absent, which is the case the foreign key
|
||||
// catches. A malformed one never reaches the database at all.
|
||||
const res = await enrol(host, {
|
||||
userId: Identifier.ascending('user'),
|
||||
steamId: steamId(6)
|
||||
});
|
||||
expect(res.status).toBe(404);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.type).toBe('not_found');
|
||||
});
|
||||
|
||||
test('a userId of the wrong shape is bad input, not a server fault', async () => {
|
||||
// Ids live in a fixed-width column, so an overlong one is refused by
|
||||
// the database rather than merely not found — and that refusal used to
|
||||
// reach the host as a 500, which tells it to retry something that can
|
||||
// never succeed. The width is checked where the input arrives.
|
||||
const host = await registeredHost('enrol-misshapen');
|
||||
const malformed = [`usr_${'a'.repeat(40)}`, 'usr_short', `mch_${'a'.repeat(26)}`, 'nonsense'];
|
||||
for (const userId of malformed) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await enrol(host, { userId, steamId: steamId(15) });
|
||||
expect(res.status).toBe(400);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
expect(((await res.json()) as any).type).toBe('validation');
|
||||
}
|
||||
});
|
||||
|
||||
test('a Steam id has to look like one', async () => {
|
||||
const host = await registeredHost('enrol-badsteam');
|
||||
const res = await enrol(host, { userId: host.userId, steamId: 'not-a-steam-id' });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
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' },
|
||||
body: JSON.stringify({ userId: Identifier.ascending('user'), steamId: steamId(7) })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
expect(((await res.json()) as any).message).toContain('Machine credentials');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /machine/enrolment/stale', () => {
|
||||
test('a refused token moves the enrolment to stale', async () => {
|
||||
const host = await registeredHost('stale-happy');
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(8) });
|
||||
|
||||
const res = await markStale(host, { userId: host.userId });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as any;
|
||||
expect(body.data.state).toBe('stale');
|
||||
expect(body.data.userId).toBe(host.userId);
|
||||
});
|
||||
|
||||
test('re-enrolling after a refusal returns the row to enrolled', async () => {
|
||||
const host = await registeredHost('stale-recover');
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(9) });
|
||||
await markStale(host, { userId: host.userId });
|
||||
|
||||
const again = (await (
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(9) })
|
||||
).json()) as any;
|
||||
expect(again.data.state).toBe('enrolled');
|
||||
});
|
||||
|
||||
test('an enrolment this host does not have is a 404', async () => {
|
||||
const host = await registeredHost('stale-missing');
|
||||
const res = await markStale(host, { userId: host.userId });
|
||||
expect(res.status).toBe(404);
|
||||
expect(((await res.json()) as any).type).toBe('not_found');
|
||||
});
|
||||
|
||||
test('a host cannot mark another host’s enrolment stale', async () => {
|
||||
const owner = await registeredHost('stale-owner');
|
||||
const stranger = await registeredHost('stale-stranger');
|
||||
await enrol(owner, { userId: owner.userId, steamId: steamId(10) });
|
||||
|
||||
// Scoped to the calling machine, so somebody else's row is simply not
|
||||
// there — a miss, not a permission check that could be forgotten.
|
||||
const res = await markStale(stranger, { userId: owner.userId });
|
||||
expect(res.status).toBe(404);
|
||||
|
||||
const untouched = ((await (await list(owner)).json()) as any).data;
|
||||
expect(untouched[0].state).toBe('enrolled');
|
||||
});
|
||||
|
||||
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' },
|
||||
body: JSON.stringify({ userId: Identifier.ascending('user') })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /machine/enrolment', () => {
|
||||
test('a host with no enrolments gets an empty list, not a 404', async () => {
|
||||
const host = await registeredHost('list-empty');
|
||||
const res = await list(host);
|
||||
expect(res.status).toBe(200);
|
||||
expect(((await res.json()) as any).data).toEqual([]);
|
||||
});
|
||||
|
||||
test('every enrolment this host is expected to hold, and no other host’s', async () => {
|
||||
const host = await registeredHost('list-mine');
|
||||
const other = await registeredHost('list-theirs');
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(11) });
|
||||
await enrol(other, { userId: other.userId, steamId: steamId(12) });
|
||||
|
||||
const res = await list(host);
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as any;
|
||||
// `data` is the list itself.
|
||||
expect(Array.isArray(body.data)).toBe(true);
|
||||
expect(body.data).toHaveLength(1);
|
||||
expect(body.data[0].machineId).toBe(host.id);
|
||||
});
|
||||
|
||||
test('machine credentials are required', async () => {
|
||||
const res = await app.request('/machine/enrolment', {
|
||||
headers: { 'x-nestri-admin-token': TEST_ADMIN_SECRET }
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('The enrolment surface refuses a token', () => {
|
||||
// The token lives on the host and nowhere else. There is no endpoint that
|
||||
// accepts a refresh token, a challenge URL or a client id, and the way that
|
||||
// stays true is a test that fails the moment somebody adds one.
|
||||
|
||||
const forbidden = [
|
||||
{ refreshToken: 'eyJ.not.a.real.one' },
|
||||
{ token: 'anything' },
|
||||
{ accessToken: 'anything' },
|
||||
{ challengeUrl: 'https://s.team/q/1/2' },
|
||||
{ clientId: '1234567890' }
|
||||
];
|
||||
|
||||
test('POST /machine/enrolment rejects every credential-shaped field', async () => {
|
||||
const host = await registeredHost('refuse-token-enrol');
|
||||
for (const extra of forbidden) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await enrol(host, {
|
||||
userId: host.userId,
|
||||
steamId: steamId(13),
|
||||
...extra
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
expect(((await res.json()) as any).type).toBe('validation');
|
||||
}
|
||||
});
|
||||
|
||||
test('POST /machine/enrolment/stale rejects every credential-shaped field', async () => {
|
||||
const host = await registeredHost('refuse-token-stale');
|
||||
await enrol(host, { userId: host.userId, steamId: steamId(14) });
|
||||
for (const extra of forbidden) {
|
||||
// eslint-disable-next-line no-await-in-loop
|
||||
const res = await markStale(host, { userId: host.userId, ...extra });
|
||||
expect(res.status).toBe(400);
|
||||
}
|
||||
});
|
||||
|
||||
test('the published surface has exactly three enrolment routes and no field for a credential', async () => {
|
||||
const res = await app.request('/doc');
|
||||
const doc = (await res.json()) as any;
|
||||
|
||||
function resolve(schema: any): any {
|
||||
if (schema?.$ref) {
|
||||
const name = String(schema.$ref).split('/').pop()!;
|
||||
return resolve(doc.components?.schemas?.[name]);
|
||||
}
|
||||
return schema;
|
||||
}
|
||||
|
||||
function propertyNames(schema: any): string[] {
|
||||
const s = resolve(schema);
|
||||
if (!s) return [];
|
||||
const own = Object.keys(s.properties ?? {});
|
||||
const composed = [...(s.allOf ?? []), ...(s.anyOf ?? []), ...(s.oneOf ?? [])].flatMap(
|
||||
propertyNames
|
||||
);
|
||||
return [...own, ...composed];
|
||||
}
|
||||
|
||||
const paths = Object.keys(doc.paths).filter((p) => p.startsWith('/machine/enrolment'));
|
||||
expect(paths.sort()).toEqual(['/machine/enrolment', '/machine/enrolment/stale']);
|
||||
|
||||
const accepted = new Set<string>();
|
||||
for (const path of paths) {
|
||||
for (const operation of Object.values<any>(doc.paths[path])) {
|
||||
for (const parameter of operation.parameters ?? []) {
|
||||
accepted.add(parameter.name);
|
||||
}
|
||||
const schema = operation.requestBody?.content?.['application/json']?.schema;
|
||||
if (schema) {
|
||||
for (const name of propertyNames(schema)) {
|
||||
accepted.add(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Not "contains no token" — an exact set. Anything new on this surface
|
||||
// has to be argued for here, which is the point.
|
||||
expect([...accepted].sort()).toEqual(['steamId', 'userId']);
|
||||
});
|
||||
});
|
||||
@@ -22,10 +22,11 @@ src/<parent>/
|
||||
| `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/`.
|
||||
|
||||
Modules that don't own their own table (like `steam/`) only need a single `index.ts` exposing reusable `fn()` functions — no `.sql.ts` file.
|
||||
A parent may own no table of its own and still have sub-modules that do: `steam/index.ts` is reusable `fn()` functions with no `.sql.ts` beside it, while `steam/enrolment.*` is a full pair.
|
||||
|
||||
## Pattern: `.sql.ts` (Drizzle Table)
|
||||
|
||||
@@ -377,7 +378,7 @@ The IDs are 30-char strings: `{prefix}_{26 base62 chars}`. They are monotonicall
|
||||
```ts
|
||||
export namespace Examples {
|
||||
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
|
||||
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
`${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`;
|
||||
|
||||
export const User = { id: Id('user'), name: '…', email: '…', … };
|
||||
export const LinkedAccount = { id: Id('linkedAccount'), provider: 'steam', … };
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
-- That a host holds a Steam refresh token for a user — and never the token.
|
||||
--
|
||||
-- The auth session begins on the machine that will use the credential, so the
|
||||
-- token is written on that host, encrypted, under that host's own account, and
|
||||
-- it never travels back. What travels back is the outcome, and this table is
|
||||
-- where the outcome is kept. ref(d-0004)
|
||||
--
|
||||
-- **There is no token column and there must never be one**, including a
|
||||
-- nullable "encrypted token" that looks harmless while empty. The protection
|
||||
-- here is not that the column is guarded; it is that the credential is never
|
||||
-- sent to this database at all, and a column able to hold one is the first step
|
||||
-- in undoing that. The same applies to the challenge URL and client id the
|
||||
-- sign-in flow uses: they live for about two minutes inside one process and
|
||||
-- nothing outside it needs them.
|
||||
--
|
||||
-- `steam_id` is not unique, on purpose. One Steam account signed in on two
|
||||
-- hosts is two rows and two tokens, because each token is bound to the address
|
||||
-- that asked for it — that binding is the anti-theft signal, and sharing one
|
||||
-- token between hosts is the thing it fires on. A unique index here would read
|
||||
-- as hygiene and would refuse a person their second box.
|
||||
--
|
||||
-- The key is the pair. An enrolment is a fact about this user on this host and
|
||||
-- there is exactly one such fact, so the row carries no surrogate id. It also
|
||||
-- carries no `time_deleted`: the three states are the lifecycle, and the row
|
||||
-- itself only goes away when the machine or the user does, which the foreign
|
||||
-- keys already do.
|
||||
--
|
||||
-- The key begins with the machine, so it answers "what does this host hold" and
|
||||
-- nothing else. `user_id` gets its own index because the two things that read
|
||||
-- by user cannot use the key: deleting a user cascades into this table by that
|
||||
-- column alone, and asking which hosts hold a token for one person is the
|
||||
-- obvious next reader.
|
||||
--
|
||||
-- `last_ok_at` has no writer yet. A successful logon happens inside the
|
||||
-- workload, which holds no control-plane credential, so the report has to come
|
||||
-- back out through the host and nothing carries it today. The column exists
|
||||
-- with the shape it will need and stays null rather than being filled with the
|
||||
-- nearest event that was easy to observe.
|
||||
|
||||
CREATE TYPE "public"."steam_enrolment_state" AS ENUM('enrolled', 'stale', 'revoked');--> statement-breakpoint
|
||||
CREATE TABLE "steam_enrolment" (
|
||||
"machine_id" char(30) NOT NULL,
|
||||
"user_id" char(30) NOT NULL,
|
||||
"steam_id" text NOT NULL,
|
||||
"state" "steam_enrolment_state" NOT NULL,
|
||||
"enrolled_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"last_ok_at" timestamp with time zone,
|
||||
"revoked_at" timestamp with time zone,
|
||||
CONSTRAINT "steam_enrolment_machine_id_user_id_pk" PRIMARY KEY("machine_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_machine_id_machine_id_fk" FOREIGN KEY ("machine_id") REFERENCES "public"."machine"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "steam_enrolment" ADD CONSTRAINT "steam_enrolment_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "steam_enrolment_user_idx" ON "steam_enrolment" USING btree ("user_id");
|
||||
2930
packages/core/migrations/meta/0012_snapshot.json
Normal file
2930
packages/core/migrations/meta/0012_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -85,6 +85,13 @@
|
||||
"when": 1788607804606,
|
||||
"tag": "0011_auth_state_in_postgres",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 12,
|
||||
"version": "7",
|
||||
"when": 1788691753961,
|
||||
"tag": "0012_steam_enrolment_without_a_token",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,8 +1,12 @@
|
||||
import { Identifier } from './id.js';
|
||||
|
||||
export namespace Examples {
|
||||
// The width is taken from the generator rather than typed out. Counting
|
||||
// twenty-six of anything by eye is a thing people get wrong once and then
|
||||
// never look at again — this was one short, which made every documented id
|
||||
// a value the schema that published it would reject.
|
||||
export const Id = (prefix: keyof typeof Identifier.prefixes) =>
|
||||
`${Identifier.prefixes[prefix]}_XXXXXXXXXXXXXXXXXXXXXXXXX`;
|
||||
`${Identifier.prefixes[prefix]}_${'X'.repeat(Identifier.LENGTH)}`;
|
||||
|
||||
export const User = {
|
||||
id: Id('user'),
|
||||
@@ -128,6 +132,16 @@ export namespace Examples {
|
||||
lastSeen: '2026-07-28T12:00:00.000Z'
|
||||
};
|
||||
|
||||
export const SteamEnrolment = {
|
||||
machineId: Id('machine'),
|
||||
userId: Id('user'),
|
||||
steamId: '76561197960287930',
|
||||
state: 'enrolled' as const,
|
||||
enrolledAt: '2026-07-28T12:00:00.000Z',
|
||||
lastOkAt: null,
|
||||
revokedAt: null
|
||||
};
|
||||
|
||||
export const Box = {
|
||||
id: Id('box'),
|
||||
userId: Id('user'),
|
||||
|
||||
51
packages/core/src/id.test.ts
Normal file
51
packages/core/src/id.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Examples } from './examples.js';
|
||||
import { Identifier } from './id.js';
|
||||
|
||||
const prefixes = Object.keys(Identifier.prefixes) as (keyof typeof Identifier.prefixes)[];
|
||||
|
||||
describe('an id, the rule for one, and the documented example agree', () => {
|
||||
// Three things have to say the same thing and only one of them is the
|
||||
// generator. They drifted once already: the example was twenty-nine
|
||||
// characters against a rule demanding thirty, so every id in the published
|
||||
// documentation was a value the schema beside it would reject. Nothing
|
||||
// noticed, because an example is never parsed.
|
||||
|
||||
test('every generated id satisfies its own schema', () => {
|
||||
for (const prefix of prefixes) {
|
||||
const parsed = Identifier.schema(prefix).safeParse(Identifier.ascending(prefix));
|
||||
expect(parsed.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('every documented example satisfies the schema that publishes it', () => {
|
||||
for (const prefix of prefixes) {
|
||||
const parsed = Identifier.schema(prefix).safeParse(Examples.Id(prefix));
|
||||
expect(parsed.success).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test('an id is the width the column holds', () => {
|
||||
// `ulid()` is `char(26 + 4)`, and a char column refuses an overlong
|
||||
// value rather than truncating — so a generator that drifted wider
|
||||
// would fail every insert, not merely look wrong.
|
||||
for (const prefix of prefixes) {
|
||||
expect(Identifier.ascending(prefix)).toHaveLength(30);
|
||||
expect(Examples.Id(prefix)).toHaveLength(30);
|
||||
}
|
||||
});
|
||||
|
||||
test('the schema refuses the near misses, not just the obvious ones', () => {
|
||||
const schema = Identifier.schema('user');
|
||||
const body = 'a'.repeat(Identifier.LENGTH);
|
||||
expect(schema.safeParse(`usr_${body}`).success).toBe(true);
|
||||
// One short, one long, right length with the wrong prefix, and the
|
||||
// prefix without its separator — which would otherwise read as a user
|
||||
// id because it starts with the same three letters.
|
||||
expect(schema.safeParse(`usr_${body.slice(1)}`).success).toBe(false);
|
||||
expect(schema.safeParse(`usr_${body}a`).success).toBe(false);
|
||||
expect(schema.safeParse(`mch_${body}`).success).toBe(false);
|
||||
expect(schema.safeParse(`usr${body}a`).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -27,11 +27,34 @@ export namespace Identifier {
|
||||
refreshToken: 'rft'
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* An id as this control plane issues them: the right prefix, and the exact
|
||||
* width the column has.
|
||||
*
|
||||
* The width is the half that matters at an API boundary. Ids are stored in
|
||||
* a fixed-width column, so an overlong string is *refused by the database*
|
||||
* rather than simply not matching anything — which surfaces to the caller
|
||||
* as a server fault instead of the validation error it actually is.
|
||||
* Checking it where the input arrives is what keeps the two apart.
|
||||
*
|
||||
* The separator is part of the prefix check for the same reason: without
|
||||
* it, `usrsomething` reads as a user id.
|
||||
*/
|
||||
export function schema(prefix: keyof typeof prefixes) {
|
||||
return z.string().startsWith(prefixes[prefix]);
|
||||
return z
|
||||
.string()
|
||||
.startsWith(`${prefixes[prefix]}_`)
|
||||
.length(prefixes[prefix].length + 1 + LENGTH);
|
||||
}
|
||||
|
||||
const LENGTH = 26;
|
||||
/**
|
||||
* How many characters follow the prefix and separator.
|
||||
*
|
||||
* Exported because three things have to agree on it and two of them are
|
||||
* not the generator: the column is fixed-width, {@link schema} refuses
|
||||
* anything else, and the documented examples have to be values that pass.
|
||||
*/
|
||||
export const LENGTH = 26;
|
||||
|
||||
let lastTimestamp = 0;
|
||||
let counter = 0;
|
||||
|
||||
72
packages/core/src/steam/enrolment.sql.ts
Normal file
72
packages/core/src/steam/enrolment.sql.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { index, pgEnum, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { ulid, utc } from '../db/types.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* Where an enrolment can be, and nowhere else.
|
||||
*
|
||||
* There is no `pending`. A challenge showing on a screen lives about two
|
||||
* minutes, rotates on a cadence the auth provider chooses, and nothing outside
|
||||
* the host process needs to know it exists — so it is not a fact about the
|
||||
* machine and recording it here would be a claim we cannot keep true.
|
||||
* ref(d-0004)
|
||||
*/
|
||||
export const SteamEnrolmentState = pgEnum('steam_enrolment_state', [
|
||||
'enrolled',
|
||||
'stale',
|
||||
'revoked'
|
||||
]);
|
||||
|
||||
/**
|
||||
* That a host holds a Steam refresh token for a user — and never the token.
|
||||
*
|
||||
* **This table has no token column and must not gain one.** The token is
|
||||
* written by the host, encrypted, under that host's own account, and it does
|
||||
* not cross back: not in a request body, not in a log, not in an error
|
||||
* message, not as a metric label. A nullable "encrypted token" column would be
|
||||
* an invitation rather than a safeguard, because the thing standing between a
|
||||
* database leak and somebody's game library is that the credential was never
|
||||
* sent here at all. ref(d-0004)
|
||||
*
|
||||
* `steam_id` is deliberately **not unique**. One Steam account signed in on two
|
||||
* hosts is two rows and two tokens, which is the entire point of doing the auth
|
||||
* session on the machine that will use it: each token is bound to the address
|
||||
* that asked for it, and a shared one would be the theft signal we are avoiding.
|
||||
* A unique index here would read as hygiene and would refuse a person their
|
||||
* second box.
|
||||
*
|
||||
* The key is the pair, because an enrolment is a fact about *this user on this
|
||||
* host* and there is only ever one such fact. That is also why the row carries
|
||||
* no surrogate id and no soft-delete: the states are the lifecycle, and the row
|
||||
* itself goes away only when the machine or the user does.
|
||||
*/
|
||||
export const SteamEnrolmentTable = pgTable(
|
||||
'steam_enrolment',
|
||||
{
|
||||
machineId: ulid('machine_id')
|
||||
.notNull()
|
||||
.references(() => MachineTable.id, { onDelete: 'cascade' }),
|
||||
userId: ulid('user_id')
|
||||
.notNull()
|
||||
.references(() => UserTable.id, { onDelete: 'cascade' }),
|
||||
steamId: text('steam_id').notNull(),
|
||||
state: SteamEnrolmentState('state').notNull(),
|
||||
enrolledAt: utc('enrolled_at').notNull().defaultNow(),
|
||||
// Written when a logon actually succeeds, which happens inside the
|
||||
// workload and reports back through the host. Nothing writes it yet,
|
||||
// and it stays null rather than being filled with the time of the
|
||||
// nearest event that was easy to observe.
|
||||
lastOkAt: utc('last_ok_at'),
|
||||
revokedAt: utc('revoked_at')
|
||||
},
|
||||
(t) => [
|
||||
primaryKey({ columns: [t.machineId, t.userId] }),
|
||||
// The key starts with the machine, which answers "what does this host
|
||||
// hold" and nothing else. Deleting a user cascades into this table by
|
||||
// `user_id` alone, and asking which hosts hold a token for one person
|
||||
// is the obvious next reader — neither can use the key.
|
||||
index('steam_enrolment_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
162
packages/core/src/steam/enrolment.test.ts
Normal file
162
packages/core/src/steam/enrolment.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { Fixtures } from '../db/fixtures.js';
|
||||
import { testDb } from '../db/test.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Enrolment } from './enrolment.js';
|
||||
|
||||
const sql = testDb();
|
||||
|
||||
const createdUserIds: string[] = [];
|
||||
|
||||
function steamId(n: number) {
|
||||
return `765611980001${String(n).padStart(5, '0')}`;
|
||||
}
|
||||
|
||||
async function host(label: string) {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
return { machineId: await Fixtures.machine(owner, label), userId: owner.userId };
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||||
createdUserIds.length = 0;
|
||||
}
|
||||
});
|
||||
|
||||
describe('the schema holds no token, and cannot be made to', () => {
|
||||
test('the columns are exactly the facts about an enrolment', async () => {
|
||||
// The refresh token lives on the host, encrypted, and nowhere else. A
|
||||
// nullable column that could hold one is an invitation, so the guard is
|
||||
// the column list itself rather than a promise in a comment: adding
|
||||
// `refresh_token`, or an `encrypted_token`, or a `secret`, fails here.
|
||||
const columns = await sql<{ column_name: string }[]>`
|
||||
select column_name from information_schema.columns
|
||||
where table_schema = 'public' and table_name = 'steam_enrolment'
|
||||
order by column_name
|
||||
`;
|
||||
expect(columns.map((c) => c.column_name)).toEqual([
|
||||
'enrolled_at',
|
||||
'last_ok_at',
|
||||
'machine_id',
|
||||
'revoked_at',
|
||||
'state',
|
||||
'steam_id',
|
||||
'user_id'
|
||||
]);
|
||||
});
|
||||
|
||||
test('the Steam id is not unique across machines', async () => {
|
||||
// One Steam account on two hosts is two rows and two tokens. A unique
|
||||
// index here would look like hygiene and would refuse the second host.
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
select indexdef from pg_indexes
|
||||
where schemaname = 'public' and tablename = 'steam_enrolment'
|
||||
`;
|
||||
const uniqueOnSteamId = indexes.filter(
|
||||
(i) => i.indexdef.includes('UNIQUE') && i.indexdef.includes('steam_id')
|
||||
);
|
||||
expect(uniqueOnSteamId).toEqual([]);
|
||||
});
|
||||
|
||||
test('an enrolment is one fact per machine and user', async () => {
|
||||
const primary = await sql<{ indexdef: string }[]>`
|
||||
select indexdef from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'steam_enrolment'
|
||||
and indexname = 'steam_enrolment_machine_id_user_id_pk'
|
||||
`;
|
||||
expect(primary).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.record', () => {
|
||||
test('a first report creates the row as enrolled', async () => {
|
||||
const h = await host('core-enrol-new');
|
||||
const row = await Enrolment.record({ ...h, steamId: steamId(1) });
|
||||
expect(row).toMatchObject({ ...h, steamId: steamId(1), state: 'enrolled' });
|
||||
expect(row.lastOkAt).toBeNull();
|
||||
expect(row.revokedAt).toBeNull();
|
||||
expect(() => new Date(row.enrolledAt).toISOString()).not.toThrow();
|
||||
});
|
||||
|
||||
test('a second report is an upsert, not a duplicate', async () => {
|
||||
const h = await host('core-enrol-upsert');
|
||||
const first = await Enrolment.record({ ...h, steamId: steamId(2) });
|
||||
const second = await Enrolment.record({ ...h, steamId: steamId(3) });
|
||||
|
||||
expect(second.enrolledAt).toBe(first.enrolledAt);
|
||||
expect(second.steamId).toBe(steamId(3));
|
||||
expect(await Enrolment.listByMachine(h.machineId)).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('re-enrolling clears the refusal', async () => {
|
||||
const h = await host('core-enrol-recover');
|
||||
await Enrolment.record({ ...h, steamId: steamId(4) });
|
||||
await Enrolment.markStale(h);
|
||||
const back = await Enrolment.record({ ...h, steamId: steamId(4) });
|
||||
expect(back.state).toBe('enrolled');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.markStale', () => {
|
||||
test('a refused token is recorded against that host alone', async () => {
|
||||
const mine = await host('core-stale-mine');
|
||||
const theirs = await host('core-stale-theirs');
|
||||
await Enrolment.record({ ...mine, steamId: steamId(5) });
|
||||
await Enrolment.record({ ...theirs, steamId: steamId(5) });
|
||||
|
||||
const marked = await Enrolment.markStale(mine);
|
||||
expect(marked?.state).toBe('stale');
|
||||
|
||||
const untouched = await Enrolment.listByMachine(theirs.machineId);
|
||||
expect(untouched[0]!.state).toBe('enrolled');
|
||||
});
|
||||
|
||||
test('nothing to mark is null rather than a write', async () => {
|
||||
const h = await host('core-stale-absent');
|
||||
expect(await Enrolment.markStale(h)).toBeNull();
|
||||
expect(await Enrolment.listByMachine(h.machineId)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the user foreign key has its own index', () => {
|
||||
test('deleting a user, and asking by user, do not scan the table', async () => {
|
||||
// The primary key starts with the machine, so neither of the two things
|
||||
// that read by user alone can use it: the cascade behind a user
|
||||
// deletion, and the question "which hosts hold a token for me".
|
||||
const indexes = await sql<{ indexdef: string }[]>`
|
||||
select indexdef from pg_indexes
|
||||
where schemaname = 'public'
|
||||
and tablename = 'steam_enrolment'
|
||||
and indexname = 'steam_enrolment_user_idx'
|
||||
`;
|
||||
expect(indexes).toHaveLength(1);
|
||||
expect(indexes[0]!.indexdef).toContain('user_id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrolment.listByMachine', () => {
|
||||
test('every enrolment for one host, oldest first', async () => {
|
||||
const h = await host('core-list');
|
||||
const second = await Fixtures.owner('core-list-second');
|
||||
createdUserIds.push(second.userId);
|
||||
|
||||
await Enrolment.record({ ...h, steamId: steamId(6) });
|
||||
await Enrolment.record({
|
||||
machineId: h.machineId,
|
||||
userId: second.userId,
|
||||
steamId: steamId(7)
|
||||
});
|
||||
|
||||
const rows = await Enrolment.listByMachine(h.machineId);
|
||||
expect(rows).toHaveLength(2);
|
||||
expect(rows.map((r) => r.userId)).toEqual([h.userId, second.userId]);
|
||||
});
|
||||
|
||||
test('an unknown host has no enrolments rather than an error', async () => {
|
||||
expect(await Enrolment.listByMachine(Identifier.ascending('machine'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
181
packages/core/src/steam/enrolment.ts
Normal file
181
packages/core/src/steam/enrolment.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
import { and, eq } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { SteamEnrolmentState, SteamEnrolmentTable } from './enrolment.sql.js';
|
||||
import { STEAM_ID_RE } from './index.js';
|
||||
|
||||
/** A foreign key that names a row nobody has. */
|
||||
function isForeignKeyViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
return e?.code === '23503' || e?.cause?.code === '23503';
|
||||
}
|
||||
|
||||
/**
|
||||
* What the control plane knows about a host's Steam sign-ins: that one
|
||||
* happened, for whom, and whether it is still working.
|
||||
*
|
||||
* It does not know the credential and is not able to. The auth session begins
|
||||
* on the machine that will use the token, so the token is written there and
|
||||
* stays there; what comes back here is the *outcome*. Everything in this
|
||||
* namespace is therefore a report being recorded rather than a secret being
|
||||
* stored, and the one place that is enforced is the table's column list.
|
||||
* ref(d-0004)
|
||||
*
|
||||
* This module is the sole writer of every state. A host says what happened; it
|
||||
* does not say what the record should become.
|
||||
*/
|
||||
export namespace Enrolment {
|
||||
export const Info = z
|
||||
.object({
|
||||
// Shaped, not merely non-empty. Both are foreign keys into
|
||||
// fixed-width columns, so a string of the wrong width is rejected
|
||||
// by the database itself — and a database refusal reaches a caller
|
||||
// as a server fault rather than as the bad input it is.
|
||||
machineId: Identifier.schema('machine').meta({
|
||||
description: 'The host that holds a token for this user',
|
||||
example: Examples.SteamEnrolment.machineId
|
||||
}),
|
||||
userId: Identifier.schema('user').meta({
|
||||
description: 'The person the host signed in as',
|
||||
example: Examples.SteamEnrolment.userId
|
||||
}),
|
||||
steamId: z.string().regex(STEAM_ID_RE, 'must be a 17-digit Steam ID').meta({
|
||||
description: 'The Steam account that was signed in',
|
||||
example: Examples.SteamEnrolment.steamId
|
||||
}),
|
||||
state: z.enum(SteamEnrolmentState.enumValues).meta({
|
||||
description:
|
||||
'`enrolled` — the host holds a working token. `stale` — Steam refused the one it holds. `revoked` — the enrolment was ended',
|
||||
example: Examples.SteamEnrolment.state
|
||||
}),
|
||||
enrolledAt: z.iso.datetime().meta({
|
||||
description: 'When this host first signed this user in. Unchanged by a re-enrolment',
|
||||
example: Examples.SteamEnrolment.enrolledAt
|
||||
}),
|
||||
lastOkAt: z.iso.datetime().nullable().meta({
|
||||
description: 'When a logon last succeeded. Nothing writes this yet, so it is null',
|
||||
example: Examples.SteamEnrolment.lastOkAt
|
||||
}),
|
||||
revokedAt: z.iso.datetime().nullable().meta({
|
||||
description: 'When the enrolment was ended',
|
||||
example: Examples.SteamEnrolment.revokedAt
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'SteamEnrolment',
|
||||
description: 'That a host holds a Steam token for a user — never the token itself',
|
||||
example: Examples.SteamEnrolment
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
/**
|
||||
* Record that a host completed a sign-in for a user.
|
||||
*
|
||||
* An upsert, because a host re-running the flow — a person signing in
|
||||
* again, a token replaced after a refusal — is the same fact restated, not
|
||||
* a second one. `enrolledAt` therefore survives: it says when this pairing
|
||||
* began, and a re-enrolment does not begin it again. `revokedAt` is cleared,
|
||||
* because a row that is `enrolled` and carries a revocation time is two
|
||||
* answers to one question.
|
||||
*/
|
||||
export const record = fn(
|
||||
Info.pick({ machineId: true, userId: true, steamId: true }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.insert(SteamEnrolmentTable)
|
||||
.values({
|
||||
machineId: input.machineId,
|
||||
userId: input.userId,
|
||||
steamId: input.steamId,
|
||||
state: 'enrolled'
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: [SteamEnrolmentTable.machineId, SteamEnrolmentTable.userId],
|
||||
set: { steamId: input.steamId, state: 'enrolled', revokedAt: null }
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => serialize(rows[0]!))
|
||||
.catch((err) => {
|
||||
if (isForeignKeyViolation(err)) {
|
||||
// A host naming a user or a machine that is not there.
|
||||
// Said plainly rather than surfacing as a server fault,
|
||||
// because the host can neither retry nor fix it.
|
||||
throw new VisibleError(
|
||||
'not_found',
|
||||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||||
'No such user or machine'
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Record that Steam refused the token this host holds.
|
||||
*
|
||||
* Scoped to the machine in the query itself, so another host's enrolment is
|
||||
* a miss rather than a permission check somebody could forget to write.
|
||||
* Returns null when there is nothing to mark — a host reporting a refusal
|
||||
* for an enrolment that was never recorded is telling us something, and
|
||||
* inventing a `stale` row to hold it would make the record say a sign-in
|
||||
* happened that never did.
|
||||
*/
|
||||
export const markStale = fn(Info.pick({ machineId: true, userId: true }), async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.update(SteamEnrolmentTable)
|
||||
.set({ state: 'stale' })
|
||||
.where(
|
||||
and(
|
||||
eq(SteamEnrolmentTable.machineId, input.machineId),
|
||||
eq(SteamEnrolmentTable.userId, input.userId)
|
||||
)
|
||||
)
|
||||
.returning()
|
||||
.then((rows) => {
|
||||
const row = rows.at(0);
|
||||
return row ? serialize(row) : null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Every enrolment the control plane believes this host has.
|
||||
*
|
||||
* A host that lost its disk asks this to find out what it is expected to
|
||||
* hold, and can then say it does not. Reconciling the answer is the
|
||||
* caller's business and nothing does it yet; the shape is fixed now so it
|
||||
* does not have to change once something depends on it.
|
||||
*/
|
||||
export const listByMachine = fn(Info.shape.machineId, async (machineId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select()
|
||||
.from(SteamEnrolmentTable)
|
||||
.where(eq(SteamEnrolmentTable.machineId, machineId))
|
||||
.orderBy(SteamEnrolmentTable.enrolledAt)
|
||||
.then((rows) => rows.map(serialize));
|
||||
});
|
||||
});
|
||||
|
||||
export function serialize(input: typeof SteamEnrolmentTable.$inferSelect): Info {
|
||||
return {
|
||||
machineId: input.machineId,
|
||||
userId: input.userId,
|
||||
steamId: input.steamId,
|
||||
state: input.state as Info['state'],
|
||||
enrolledAt: input.enrolledAt.toISOString(),
|
||||
lastOkAt: input.lastOkAt?.toISOString() ?? null,
|
||||
revokedAt: input.revokedAt?.toISOString() ?? null
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,8 @@ import { Identity } from '../user/identity.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
|
||||
const STEAM_ID_RE = /^\d{17}$/;
|
||||
/** An individual Steam account id: 17 digits, always. */
|
||||
export const STEAM_ID_RE = /^\d{17}$/;
|
||||
|
||||
function isUniqueViolation(err: unknown): boolean {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
|
||||
Reference in New Issue
Block a user