Repository navigation
feat(fobs): link a key fob in a window and resolve it for the Zählwerk - #326
Open
patrick-hermann-sva wants to merge 5 commits into
Open
patrick-hermann-sva wants to merge 5 commits into
patrick-hermann-sva wants to merge 5 commits into
Conversation
Two additive migrations for ADR-0024 and ADR-0025. checkins holds who stood at which half and how they proved it; nothing points at it yet, #320 does that. fob_link_window has a primary key that can only be true, so an upsert that replaces the row only once it has expired, or for the player who holds it, keeps at most one window open across replicas without a lock or Redis. identities gains a nullable last_used_at for the profile's fob list. Refs #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
POST /api/identities/rfid/resolve, behind the scoreboard token and only with SP_RFID_UID_KEY set, names the player behind a fob and records the check-in ADR-0024 needs as proof. A fob is stored as an HMAC of its UID, never the UID. An unknown fob outside a window is a 404 with no body, leaves no row, and neither the UID nor its hash reaches the log; a decode error is not echoed because it can quote the card number. The profile gets a card to link a fob in a 60 s window, which polls itself until the fob arrives, and to list and remove linked fobs. The resolve logic lives in the auth package beside the cookie (invariant 4), but it is not an Authenticator: a UID is not a secret. Refs #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A new SP_* variable lands in every deployment path in the same change, as the scoreboard token did. kcl renders it from rfid-uid-key in Vault behind fobsEnabled; terraform passes it as a Container Apps secret and refuses one shorter than 32 characters. backup-restore.md notes that fobs only survive a move together with their key. Refs #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
scripts/db_test.sh requires every table a migration creates to be counted, so a dump and its restore can be compared. Refs #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
revive refuses an exported constant without a comment. Refs #321 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
This builds #321, the Schmetterpause half of checking in at the table by fob. zaehlwerk-firmware#63 reads a fob and a button press. zaehlwerk#92 claims the half, but zaehlwerk cannot know who the fob belongs to. That lookup is here. Every successful resolve also records the check-in that ADR-0024 accepts as proof in place of an operator.
The design is ADR-0025, in #324. This PR is based on
mainrather than stacked on it, so either can merge first.Changes
Data (
feat(db)): two additive migrations.checkinsstores player, half (left/right, as zaehlwerk's ADR-0005 names them), method (phone/rfid) and time. Nothing points at it yet. Linking a result to its check-ins is Results from checked-in matches: no operator needed; pending by default, auto-accept as a setting #320.fob_link_windowholds at most one row, enforced by a primary key that can only betrue. Opening is a single upsert that replaces the row only if it has expired or belongs to the same player. Consuming isdelete … returning. So at most one window is open, even across replicas, without a lock or Redis.identities.last_used_atis added (nullable).Resolve and link (
feat(fobs)):POST /api/identities/rfid/resolvesits on the outer mux next to the other/apiroutes. It exists only with bothSP_SCOREBOARD_TOKENandSP_RFID_UID_KEYset. Without the key, startup logsfobs are off: SP_RFID_UID_KEY is not set.auth.Fobs, beside the cookie authenticator (invariant 4: handlers don't know how a fob is stored). The subject isHMAC-SHA256(key, "rfid:" || uid_bytes). The UID is normalised from hex with optional:/-/space and must be 4, 7 or 10 bytes.404with an empty body. No row is written, and the log says onlyunknown fob. Malformed bodies get a fixed message, because Go's decode error can quote the input, and the input is a card number./adminshowsSP_RFID_UID_KEYon/off next to the other switches.Deployment (
feat(deploy)):SP_RFID_UID_KEYis wired into every path in the same change, as the scoreboard token was:fobsEnabledplus the Vault propertyrfid-uid-key;rfid_uid_keyas a Container Apps secret, with a ≥32-character validation and its own test;docs/deployment.mdandkcl/README.md;docs/backup-restore.mdthat fobs only survive a move together with their key.Verified
go generate ./...produced no churn.gofmtis clean, andgo build ./...passes.go test -race ./...is green.internal/server/fob_test.gocover every acceptance point of feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk #321:internal/repository/postgres/fob_test.go: the window upsert rules, consume-once, close-only-your-own, the check-in constraint,Touch, and deleting a player together with their check-in and window) ran against a real Postgres 17: a throwaway container withSP_TEST_DATABASE_URLpointed at a_testdatabase. They passed and were not skipped.task kcl:checkpasses: all profiles render, and withfobsEnabled=truethe ExternalSecret carriesSP_RFID_UID_KEYfromrfid-uid-key.terraform testpasses 11 of 11, including the newshort_rfid_uid_key_is_refused.terraform fmt -checkis clean.scrollWidth == clientWidthholds at both widths in both states. Screenshots were taken locally and are not attached here.Not verified:
{"uid", "half"}withhalfasleft/right.Left open, deliberately
fobs.templ.POST /api/results(ADR-0024) is Results from checked-in matches: no operator needed; pending by default, auto-accept as a setting #320. Until then a check-in is only a record.checkins.methodalready allowsphone, but nothing creates such a row. That path still needs its own design (see docs(adr): make a fob an identity, linked only in a window #324).SP_RFID_UID_KEYdeviates from feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk #321'sRFID_UID_KEYbecause every variable this app reads carries theSP_prefix.Closes #321
Refs #323, #324, #320
🤖 Generated with Claude Code