xrpc: Add sh.tangled.knot.listKeys + public_keys table
Knot-scoped endpoint listing the SSH public keys registered for
push authentication. Currently read-only — keys arrive into the
table via AT Proto firehose ingest of sh.tangled.publicKey records,
which isn't built yet. Operators can pre-seed manually; the SSH
push handler that depends on this comes next.
Schema (migration 0006):
- public_keys (did TEXT, key TEXT, created_at TEXT, PK (did, key))
- index on (created_at DESC, did, key) for the listing query
created_at is TEXT rather than TIMESTAMPTZ because we emit it on
the wire as the same RFC3339 string the record provided —
roundtripping through a typed timestamp would introduce
formatting drift between intake and emission. Lexical sort of
consistent RFC3339 strings is chronologically correct, and the
AT Proto record schema mandates RFC3339, so this is safe.
(did, key) primary key — a single DID can register multiple keys
(different machines, etc.); a specific (DID, key) pair should
land at most once.
Wire format follows the Go knotserver:
- cursor is a string-encoded integer offset (same lexicon
divergence as repo.log — the lexicon allows any cursor format,
every existing client speaks Go's offset convention)
- order is created_at DESC with (did, key) tiebreak
- next cursor returned only if there are more rows (over-fetch
by 1 to detect)
- output object omits 'name' (the AT Proto record has it but the
listKeys lexicon doesn't expose it)
- InternalServerError on DB failure; InvalidRequest on negative
cursor
Tests need a small unusual piece of infrastructure: a
serializing mutex (LIST_KEYS_LOCK). Unlike every other XRPC
fixture in this file, public_keys isn't repo-scoped, so parallel
tests would step on each other's seeded rows. Five tests
serialize through the mutex; the non-row-touching invalid-cursor
test doesn't. Performance-wise the cost is negligible against
the testcontainer startup tax.
5 integration tests cover: created_at DESC ordering, cursor
pagination across two pages, empty-table returning an empty
array (no cursor), negative-cursor rejection, and lexicon wire
shape (no 'name' field; createdAt key spelling is camelCase per
lexicon).