feat(passkeys): schema, dependency and per-environment Relying Party (#37) #330

Merged
bermudalamb merged 2 commits from feature/37-passkeys-groundwork into main 2026-09-09 14:08:29 -05:00
Owner

Groundwork only. Nothing reads any of this yet and no behaviour changes.

Validity check first, since the issue is three weeks old

Two things had moved since it was written, and one matters a great deal.

#313 changes the domain, and every passkey registered before that cutover stops working at it. A credential is bound to its Relying Party ID permanently — there is no migration and no re-signing. The code needs no change, because it follows PUBLIC_URL, but the credentials do not survive.

That is free today — production is not live and nobody holds one — and it stops being free the day the shop opens. Which is an argument for doing passkeys now or after the domain move, but not across it. The issue's RP table also names bermudalamb.synology.me hostnames that #313 will retire; deriving from PUBLIC_URL is what makes that a non-event.

The issue says PUBLIC_URL is set by "every environment". It is not. envValidation requires it only when SMTP is configured, so a local setup that cannot send mail legitimately has none. That is handled rather than assumed.

Package versions were checked too: @simplewebauthn/server@14.0.1 requires Node ≥20, and CI runs 20 with engines: >=20.9.0. Compatible.

The Relying Party

Derived from PUBLIC_URL, never written down — it is the one value here that cannot be corrected afterwards, and a wrong one surfaces only as a customer unable to sign in with a passkey that matches nothing. hostname rather than host, so a port cannot reach an ID that must not contain one.

Local falls back to localhost with two origins: Vite on 5173 during development, Express on 3000 once built. Same Relying Party — only the port differs, and the port is not part of the ID.

Nine unit tests, most of them about what must not end up in the ID: ports stripped, paths stripped, subdomains kept (QA and production are deliberately different RPs), and a malformed PUBLIC_URL refused rather than guessed at.

Two of the issue's open decisions are deferred, deliberately

Both belong to the ceremony that enforces them, not to the schema:

  • Signature counter policy → #39. Many synced passkeys report 0 forever, so "a non-increase means cloning" is wrong for them and right for a hardware key. This only has to hold the value.
  • Disabled accounts → #39. The schema takes the position that disabling should not cost a customer their devices: credentials survive it and are refused at the ceremony, so re-enabling does not mean re-registering everything.

Deletion is settled here — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists.

The third decision, answered by the schema

Challenges get their own table, not customer_tokens with a new kind — and the reason is structural. customer_tokens.customer_id is NOT NULL, and an authentication challenge is issued before anyone is identified: a discoverable-credential sign-in has no customer to attach to when the challenge is created. Reusing that table would mean making the column nullable for every other token kind.

Two things #56 taught me to do

Both tables are in resetDb's TRUNCATE list and REQUIRED_TABLES, and the Kysely mirror is updated by hand — including sort position, so a real db:types regenerate produces no diff. signature_counter is BIGINT (the spec allows a 32-bit unsigned value, which overflows a signed INTEGER), which makes it the first bigint column here, so the mirror gains the Int8 alias with it. schemaLoss's count moves 18 → 20.

Skipping either of those is exactly how #56 turned a green local run into a red main.

Verification

tsc clean for src and tests · lint 0 errors, no new warnings · 494 unit tests across 34 suites (9 new) · frontend build green · migration parses.

Not verified: the migration has not been run against a database, and the integration suite needs one this machine cannot provide. @simplewebauthn/browser is installed and unused — that is the groundwork the issue asked for, not an oversight.

Closes #37

🤖 Generated with Claude Code

Groundwork only. Nothing reads any of this yet and no behaviour changes. ## Validity check first, since the issue is three weeks old Two things had moved since it was written, and one matters a great deal. **#313 changes the domain, and every passkey registered before that cutover stops working at it.** A credential is bound to its Relying Party ID permanently — there is no migration and no re-signing. The *code* needs no change, because it follows `PUBLIC_URL`, but the credentials do not survive. That is **free today** — production is not live and nobody holds one — and it stops being free the day the shop opens. Which is an argument for doing passkeys now or after the domain move, but not across it. The issue's RP table also names `bermudalamb.synology.me` hostnames that #313 will retire; deriving from `PUBLIC_URL` is what makes that a non-event. **The issue says `PUBLIC_URL` is set by "every environment". It is not.** `envValidation` requires it only when SMTP is configured, so a local setup that cannot send mail legitimately has none. That is handled rather than assumed. Package versions were checked too: `@simplewebauthn/server@14.0.1` requires Node ≥20, and CI runs 20 with `engines: >=20.9.0`. Compatible. ## The Relying Party Derived from `PUBLIC_URL`, never written down — it is the one value here that cannot be corrected afterwards, and a wrong one surfaces only as a customer unable to sign in with a passkey that matches nothing. `hostname` rather than `host`, so a port cannot reach an ID that must not contain one. Local falls back to `localhost` with **two** origins: Vite on 5173 during development, Express on 3000 once built. Same Relying Party — only the port differs, and the port is not part of the ID. Nine unit tests, most of them about what must *not* end up in the ID: ports stripped, paths stripped, subdomains kept (QA and production are deliberately different RPs), and a malformed `PUBLIC_URL` refused rather than guessed at. ## Two of the issue's open decisions are deferred, deliberately Both belong to the ceremony that enforces them, not to the schema: - **Signature counter policy → #39.** Many synced passkeys report `0` forever, so "a non-increase means cloning" is wrong for them and right for a hardware key. This only has to hold the value. - **Disabled accounts → #39.** The schema takes the position that disabling should not cost a customer their devices: credentials survive it and are refused at the ceremony, so re-enabling does not mean re-registering everything. **Deletion is settled here** — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists. ## The third decision, answered by the schema **Challenges get their own table, not `customer_tokens` with a new `kind`** — and the reason is structural. `customer_tokens.customer_id` is `NOT NULL`, and an *authentication* challenge is issued before anyone is identified: a discoverable-credential sign-in has no customer to attach to when the challenge is created. Reusing that table would mean making the column nullable for every other token kind. ## Two things #56 taught me to do Both tables are in `resetDb`'s `TRUNCATE` list and `REQUIRED_TABLES`, and the Kysely mirror is updated by hand — **including sort position, so a real `db:types` regenerate produces no diff.** `signature_counter` is `BIGINT` (the spec allows a 32-bit *unsigned* value, which overflows a signed `INTEGER`), which makes it the first bigint column here, so the mirror gains the `Int8` alias with it. `schemaLoss`'s count moves 18 → 20. Skipping either of those is exactly how #56 turned a green local run into a red `main`. ## Verification `tsc` clean for src and tests · lint 0 errors, no new warnings · **494 unit tests across 34 suites** (9 new) · frontend build green · migration parses. **Not verified:** the migration has not been run against a database, and the integration suite needs one this machine cannot provide. `@simplewebauthn/browser` is installed and unused — that is the groundwork the issue asked for, not an oversight. Closes #37 🤖 Generated with [Claude Code](https://claude.com/claude-code)
bermudalamb added 1 commit 2026-09-09 13:48:33 -05:00
feat(passkeys): schema, dependency and per-environment Relying Party (#37)
SonarQube Analysis / sonarqube (pull_request) Failing after 27m2s
Linting / lint (pull_request) Successful in 3m9s
6742b15489
Groundwork only. Nothing reads any of this yet, and no behaviour changes.

The Relying Party ID is derived from PUBLIC_URL rather than written down, because it is the one value in this feature that cannot be corrected afterwards: a credential is bound to it permanently, and a wrong one surfaces only as a customer unable to sign in with a passkey that no longer matches anything. PUBLIC_URL is what every customer-facing link is already built from, so the ID is correct wherever those links are, and wrong only where they were already wrong. hostname rather than host, so a port cannot reach an ID that must not contain one.

Local development is the exception the issue's table did not cover. envValidation requires PUBLIC_URL only when SMTP is configured, so a local setup that cannot send mail legitimately has none and falls back to localhost, which browsers treat as a secure context. Two origins there rather than one: the app is served by Vite on 5173 during development and by Express on 3000 once built, and those differ only by port, which is not part of the RP ID.

The challenge table is separate from customer_tokens, and the reason is structural rather than preference. customer_tokens.customer_id is NOT NULL, and an authentication challenge is issued before anyone is identified — a discoverable-credential sign-in has no customer to attach to at the moment the challenge exists. Storing it there would mean making that column nullable for every other kind of token.

Two of the issue's open decisions are deliberately not made here, because they belong to the ceremony that enforces them rather than to the schema. What to do when the signature counter fails to increase is #39's: many synced passkeys report zero forever, so treating a non-increase as cloning is wrong for them and right for a hardware key, and this only has to hold the value. Whether a disabled account can authenticate is also #39's, and the schema takes the position that it should not cost the customer their devices: credentials survive disabling and are refused at the ceremony, so re-enabling does not mean re-registering everything. Deletion is different and is settled here — credentials cascade with the customer, since one outliving its owner could authenticate as an account that no longer exists.

signature_counter is BIGINT because the spec allows a 32-bit unsigned value, which overflows a signed INTEGER at half its range. That is the first bigint column in this schema, so the generated mirror gains the Int8 alias with it.

Both tables are added to resetDb's TRUNCATE list and to REQUIRED_TABLES, and the schema mirror is updated by hand to match what kysely-codegen emits — placement and all, so a real regenerate produces no diff. Skipping either is how #56 turned a green local run into a red main; the mirror drift guard exists precisely to catch it, and schemaLoss's count moves from 18 to 20 with them.

Verified: tsc clean for src and tests, lint 0 errors with no new warnings, 494 unit tests across 34 suites including nine new ones for the RP derivation, frontend build green, and the migration parses. Not verified: the migration has not been run against a database, and the integration suite needs one this machine cannot provide.

Worth knowing before this goes further: #313 changes the domain, and every passkey registered before that cutover stops working at it. This code needs no change — it follows PUBLIC_URL — but the credentials do not survive. That is free while production is not live and nobody holds one, and it stops being free the day the shop opens.

Closes #37

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
bermudalamb added 1 commit 2026-09-09 14:07:55 -05:00
Merge branch 'main' into feature/37-passkeys-groundwork
SonarQube Analysis / sonarqube (pull_request) Canceled after 0s
Linting / lint (pull_request) Canceled after 0s
4f6c70f381
bermudalamb merged commit c6f6ddc6dd into main 2026-09-09 14:08:29 -05:00
bermudalamb deleted branch feature/37-passkeys-groundwork 2026-09-09 14:08:30 -05:00
Sign in to join this conversation.
No Reviewers
No labels
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bermudalamb/redefined-designs#330