fix(server): plan 3c close-out — non-finite ids bound as NULL, trip id parsed once at every places/days/ws gate

Platform: NulSafeSqlitePlatform now renders a bound NaN as NULL and
+/-Infinity as 9e999/-9e999, matching better-sqlite3's own legacy binding
(rule 22 extension) — the program-wide fail-safe for any caller that still
passes an unguarded Number(untrusted) into a converted statement.

PlacesService.verifyTripAccess and RealtimeGateway.handleJoin stop doing
that: verifyTripAccess parses once with toRowId and threads the parsed id
through create/import/bulk/rating/export.gpx (deleting the seven
toRowId(tripId) ?? -1 sites), and handleJoin gets a Number.isFinite guard.
DaysService gets the same treatment across list/create/getDay/reorder/
insert/resyncAccommodationDays, closing the gap where a hex-spelled trip id
could renumber a real trip's days while the raw-bind inverted-accommodation
guard silently matched nothing.

DatabaseService.getTripTitle is deleted; PlacesService injects
TripsRepository directly and calls getTitle itself.

Template/cheap-item cleanup: DayAssignmentsRepository.listForTimeSort uses
$castTo instead of an unknown bridge; the duplicated TagRow interface in
Places.repository.ts is now imported from Tags.repository.ts;
AssignmentWithPlaceRow extends the guarded DayAssignmentRow instead of
restating its columns; PlaceShadowPicksRepository.totals drops its
unreachable ??-fallback branch; Places.repository.ts's updatePlace types
the NOT NULL name column as string; trip-members.service.ts's
transferOwnership threads one parsed trip id through all three writes and
corrects two stale "raw-bind seam" docstrings that were actually Number()
seams; a dead canAccessTrip mock is removed from reservations.e2e.test.ts.

Tests: the 23 new no-explicit-any warnings in TRIP-SVC-069 are typed
(lint back to 2416); HTTP and WS regression coverage for the non-finite
and non-canonical id shapes across places/days routes, mutation-proved;
TRIP-JOIN-005 asserts the exact concurrent-join settlement instead of
>= 1; a new tests/unit/db/transaction-yield.test.ts pins rule 24's
measured transaction-yield/dirty-read/nested-BEGIN behaviour.
This commit is contained in:
jubnl
2026-09-23 12:22:36 +02:00
parent 46a3246e82
commit 175eb3b11b
25 changed files with 996 additions and 205 deletions
+30
View File
@@ -238,6 +238,36 @@ describe('WS rooms', () => {
}
});
/**
* Plan 3c Task 9 fix wave (A-H1, live regression): before the
* `Number.isFinite` guard on `realtime.gateway.ts::handleJoin`, a
* non-numeric `tripId` became `NaN`, which used to reach
* `TripsRepository.findAccessible`'s raw bind as the unquoted bareword
* `NaN` and throw `SqliteError: no such column: NaN` — the promise this
* handler returns never resolved, so the client got NO reply at all
* (confirmed on the compiled dual boot: 4 `[ERROR]` lines, HEAD only).
* The platform's own `NaN` -> `NULL` rendering (`NulSafeSqlitePlatform`)
* fixes the crash even without this guard; this test pins the WS-level
* contract (the SAME `Access denied` frame WS-005 gets for a real trip
* with no access), not just "does not throw".
*/
it('WS-005b — join with a non-numeric tripId receives the same error frame as no access (rule 22 / A-H1)', async () => {
const { user } = createUser(testDb);
const token = createEphemeralToken(user.id, 'ws')!;
const client = await connectWs(token);
try {
await client.next(); // welcome
client.send({ type: 'join', tripId: 'abc' });
const msg = await client.next();
expect(msg.type).toBe('error');
expect(msg.message).toMatch(/access denied/i);
} finally {
client.close();
}
});
/**
* Plan 3c Task 0b (R9): `handleJoin`'s `this.db.canAccessTrip(...)` call
* (`realtime.gateway.ts:173`) is now `TripsRepository.findAccessible`