mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-23 19:18:18 +03:00
fix(api): a misshapen id is bad input, not a server fault
Ids are stored in a fixed-width column, so an overlong one is refused by Postgres rather than simply matching nothing. That refusal is not a foreign-key violation, so it fell through to the global error boundary and reached the caller as a 500 — telling a host to retry something that can never succeed. Measured: a 44-character user id returned 500, where an absent but well-formed one correctly returned 404. `Identifier.schema` is the natural place for the check and had no callers yet, so it now asserts the exact width an id has as well as its prefix — including the separator, without which `usrsomething` reads as a user id. The enrolment schema uses it for both foreign keys, so the refusal happens where the input arrives and names the field. Also index `steam_enrolment.user_id`. The primary key begins with the machine, which answers what one host holds and nothing else, so neither of the two things that read by user alone can use it: the cascade behind deleting a user, and asking which hosts hold a token for one person. The table's migration has not been released, so this is folded into it rather than following it with a correction.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { pgEnum, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
|
||||
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';
|
||||
@@ -61,5 +61,12 @@ export const SteamEnrolmentTable = pgTable(
|
||||
lastOkAt: utc('last_ok_at'),
|
||||
revokedAt: utc('revoked_at')
|
||||
},
|
||||
(t) => [primaryKey({ columns: [t.machineId, t.userId] })]
|
||||
(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)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ 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();
|
||||
@@ -121,6 +122,22 @@ describe('Enrolment.markStale', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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');
|
||||
@@ -140,6 +157,6 @@ describe('Enrolment.listByMachine', () => {
|
||||
});
|
||||
|
||||
test('an unknown host has no enrolments rather than an error', async () => {
|
||||
expect(await Enrolment.listByMachine('mch_nosuchmachine')).toEqual([]);
|
||||
expect(await Enrolment.listByMachine(Identifier.ascending('machine'))).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -31,11 +32,15 @@ function isForeignKeyViolation(err: unknown): boolean {
|
||||
export namespace Enrolment {
|
||||
export const Info = z
|
||||
.object({
|
||||
machineId: z.string().meta({
|
||||
// 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: z.string().meta({
|
||||
userId: Identifier.schema('user').meta({
|
||||
description: 'The person the host signed in as',
|
||||
example: Examples.SteamEnrolment.userId
|
||||
}),
|
||||
|
||||
Reference in New Issue
Block a user