Skip to content

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
mainfrom
feat/rfid-fob-resolve
Open

patrick-hermann-sva wants to merge 5 commits into
mainfrom
feat/rfid-fob-resolve

Conversation

@patrick-hermann-sva

Copy link
Copy Markdown
Contributor

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 main rather than stacked on it, so either can merge first.

Changes

Data (feat(db)): two additive migrations.

  • checkins stores 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_window holds at most one row, enforced by a primary key that can only be true. Opening is a single upsert that replaces the row only if it has expired or belongs to the same player. Consuming is delete … returning. So at most one window is open, even across replicas, without a lock or Redis.
  • identities.last_used_at is added (nullable).

Resolve and link (feat(fobs)):

  • POST /api/identities/rfid/resolve sits on the outer mux next to the other /api routes. It exists only with both SP_SCOREBOARD_TOKEN and SP_RFID_UID_KEY set. Without the key, startup logs fobs are off: SP_RFID_UID_KEY is not set.
  • The logic lives in auth.Fobs, beside the cookie authenticator (invariant 4: handlers don't know how a fob is stored). The subject is HMAC-SHA256(key, "rfid:" || uid_bytes). The UID is normalised from hex with optional :/-/space and must be 4, 7 or 10 bytes.
  • A known fob resolves to its owner and never consumes somebody else's open window.
  • An unknown fob outside a window gets 404 with an empty body. No row is written, and the log says only unknown fob. Malformed bodies get a fixed message, because Go's decode error can quote the input, and the input is a card number.
  • The profile (own page only) gets a Schlüsselanhänger card. „Anhänger koppeln“ opens a 60 s window. The card then polls every 2 s and turns into „Anhänger gekoppelt“ once the fob arrives, or says the window ran out. If somebody else holds the window, the player is told how many seconds to wait. Each fob is listed with the first 6 hex characters of its hash, the link date and the last-used time, and can be removed. Removing checks ownership.
  • /admin shows SP_RFID_UID_KEY on/off next to the other switches.

Deployment (feat(deploy)): SP_RFID_UID_KEY is wired into every path in the same change, as the scoreboard token was:

  • kcl fobsEnabled plus the Vault property rfid-uid-key;
  • terraform rfid_uid_key as a Container Apps secret, with a ≥32-character validation and its own test;
  • compose, docs/deployment.md and kcl/README.md;
  • a note in docs/backup-restore.md that fobs only survive a move together with their key.

Verified

  • go generate ./... produced no churn. gofmt is clean, and go build ./... passes.
  • go test -race ./... is green.
  • New handler tests in internal/server/fob_test.go cover every acceptance point of feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk #321:
    • a fob linked in the window resolves afterwards;
    • every resolve returns a new check-in id;
    • an unknown fob produces no row and no UID in the log, checked by capturing the debug-level log;
    • two windows cannot be open at once;
    • a player can see and remove their fobs, and nobody can remove somebody else's;
    • without the key the route returns 404 and the log line is written.
    • Also covered: expiry, cancelling, the poll seeing the fob arrive, and malformed input not echoing the UID.
  • The Postgres repository tests (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 with SP_TEST_DATABASE_URL pointed at a _test database. They passed and were not skipped.
  • task kcl:check passes: all profiles render, and with fobsEnabled=true the ExternalSecret carries SP_RFID_UID_KEY from rfid-uid-key.
  • terraform test passes 11 of 11, including the new short_rfid_uid_key_is_refused. terraform fmt -check is clean.
  • Looked at, not just tested: the profile card at 390 px and 1000 px, served from the in-memory store with the seed fixture, in both states (linked fob, open window). scrollWidth == clientWidth holds at both widths in both states. Screenshots were taken locally and are not attached here.

Not verified:

  • No real fob and no real zaehlwerk. The resolve contract is tested only from this side. zaehlwerk#92 has to send {"uid", "half"} with half as left/right.
  • The window race between two replicas is proven only through the upsert's semantics and the single-connection repository test, not under real concurrent load.

Left open, deliberately

Closes #321
Refs #323, #324, #320

🤖 Generated with Claude Code

patrick-hermann-sva and others added 3 commits October 14, 2026 06:45
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

1 active deployment
linting — ed81a448 Deployed Oct 14, 2026 by patrick-hermann-sva via terraform-test / Terraform-Test #590
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: RFID fobs as an identity — link a fob in a short window, resolve it for zaehlwerk

1 participant