The postgres importer inserts records sequentially with FK enforcement live,
so it depends on records arriving in dependency order. That requirement was
implicit in each backend's export table list; make it explicit and enforced.
- Add EXPORT_KIND_ORDER (user, abode, resident, apikey, note) as a documented
single source of truth, with the FK dependency chain spelled out on its doc
comment, and note the ordering guarantee on the ExportEnvelope wire-format
doc. Derive the isExportKind set from it.
- Both backends' export() now iterate EXPORT_KIND_ORDER via a loader map
(postgres omits note by leaving it out of the map), so emission order is
tied to the constant and can't drift.
- Tests: a change-detector on EXPORT_KIND_ORDER, plus assertions that both the
sqlite and postgres (fake) exports emit record kinds grouped in FK-safe
order (kind rank non-decreasing down the stream, after the leading meta).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The export/import plan predated the postgres backend. Bring it up to parity:
- PostgresInterface implements Exportable + Importable, mirroring the sqlite
backend. Export is a signal-checked async generator (one query per table);
import drives a transaction via `WrappedPool.multi`, which rolls back on any
error/abort and commits only after the whole stream is consumed cleanly.
- The `note` kind is skipped on postgres (its note CRUD is still unimplemented,
so a pg database holds none) — a full dump from sqlite imports its
user/abode/resident/apikey records and drops notes.
- Unlike sqlite (PRAGMA foreign_keys=off), postgres keeps FK enforcement; the
FK-safe insertion order keeps a full dump valid, and truly-dangling partial
dumps will (correctly) fail.
- abode-import now resolves the backend via getDbInterface instead of
constructing SqliteInterface directly; isImportable keeps it from ever
running over the remote (api) interface, which has no import.
- Add isImportable(); make postgres selectClientApikeys' where optional.
- Tests: exercise the real PostgresInterface export/import paths against an
in-memory fake WrappedPgClient (no pg service in CI) — NDJSON shape,
meta.source, filtering, note-skip, insert dispatch, and error/abort rollback.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A non-admin's forced export filter includes co-resident *user* records so
abode/resident data isn't left with dangling references, but the same `users`
allowlist was also governing `apikey` records — leaking co-residents' apikey
metadata (name/permissions/expiry, though not the secret token).
Add a dedicated `apikeys` uid-allowlist to ExportFilter that scopes apikey
records specifically, falling back to `users` when absent (so existing
unfiltered/voluntary-narrowing behaviour and the round-trip are unchanged).
computeForcedExportFilter now sets it to the caller alone (intersected with an
apikey credential's restrict_users), so a non-admin can only ever export their
own keys. Global admins (forced filter null) are unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add backend-agnostic data export/import over an NDJSON wire format, plus an
inspect utility, exposed via three new CLIs and an HTTP export endpoint.
- ExportImport types + filter helpers (kind/record scoping, hard-intersection
of filters) in src/db/{types/ExportImport,export/filter}.ts
- SqliteInterface implements Exportable + Importable: signal-checked async
generator export (one query per table, per-record yield, trailing error
sentinel on mid-stream failure) and a manually-driven import transaction
that rolls back on any error/abort and never commits partial data
- ApiInterface implements Exportable via its own fetch({signal})
- computeForcedExportFilter enforces non-global-admin scope (resided-in abodes
+ co-resident users, intersected with apikey restrict_*); GET /export
intersects it with the caller's filter and wires an AbortController to the
response socket
- inspectExportStream reports kinds/counts from any stream without a db
- abode-export / abode-import / abode-inspect CLIs (import is sqlite-only)
- Secrets are not exported: imported users default to '#unset' passwords and
apikeys are re-minted a token (ClientApikey view round-trips exactly)
- test/tools/export-import.test.ts: round-trip, filter narrowing, forced-scope,
export/import cancellation, in-process Koa endpoint, inspect
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
/auth/logout previously only cleared the client's cookie, leaving the
session token valid in the sessions table — a stolen cookie captured
before logout would still work afterwards. Add BackendDbInterface#deleteSession
(implemented in SqliteInterface) and call it from the logout route using
the session token from the cookie. Caught by the new auth-http.test.ts
integration test, updated to assert the session is actually invalidated.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
- Add tsconfig.test.json + typecheck:test script so test/ is type-checked;
fixes real type errors it surfaced (hashedPw typing, PartialUser|ClientUser
narrowing for .email).
- Add HTTP-level auth-http.test.ts for the api backend covering
login/session-cookie/logout/clear-sessions/bearer-apikey flows, since
ApiInterface doesn't implement the session/login methods needed to run the
shared session/auth suites directly.
- Make the readonly-db test in shared/users.ts actually construct a readonly
db instance (previously a no-op that never ran) via a new getReadonlyDb
parameter, wired up for both sqlite and api backends.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds 215 tests across three tiers using node:test + node:assert/strict
(no new test framework dependencies):
- test/tools/ — middleware and utility tests (token, hash, authenticate,
jsonBody, convertError, validators)
- test/shared/ — DbInterface/BackendDbInterface contract suites reusable
across backends (users, abodes, residents, apikeys, sessions, auth)
- test/backends/sqlite/ — SQLite-private tests (sql builder, WrappedDb,
migrator) + shared suites via SqliteInterface
- test/backends/api/ — ApiInterface unit tests + shared suites via a
live Koa server backed by SQLite
Also fixes four bugs uncovered by the tests:
- ApiInterface: path params leaked into query string (slice(1) fix)
- ApiInterface: calling res.json() on 204 No Content responses
- SqliteInterface.updateResident: missing comma in SET clause
- WrappedBetterSqlite3Db: readonly:undefined rejected by better-sqlite3
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>