Skip to content

feat(passkeys): WebAuthn coordinate storage API (WA-2113..WA-2119)#3078

Draft
compojoom wants to merge 14 commits into
mainfrom
feat/passkey-coordinate-storage
Draft

feat(passkeys): WebAuthn coordinate storage API (WA-2113..WA-2119)#3078
compojoom wants to merge 14 commits into
mainfrom
feat/passkey-coordinate-storage

Conversation

@compojoom

Copy link
Copy Markdown
Contributor

Summary

Implements the Passkey Coordinate Storage API — a CGW-hosted endpoint that maps a WebAuthn credentialId to its P-256 public-key coordinates (x, y) plus the P256.Verifiers packed uint176 config, so a synced passkey on a second device can be used as a Safe signer.

Two endpoints, one Postgres table, no auth coupling. Off by default behind FF_PASSKEYS.

Resolves: WA-2113, WA-2114, WA-2115, WA-2116, WA-2117, WA-2118, WA-2119

Changes

One commit per Linear ticket:

Commit Ticket Scope
feat(passkeys): add passkey_coordinates migration WA-2113 Reversible migration; CHECK constraints on every fixed-width bytea; FILLFACTOR=100; destructive-rollback warning
feat(passkeys): scaffold PasskeysModule + FF_PASSKEYS WA-2114 Module skeleton, conditional registration, fail-closed bootstrap if any allowlist is empty
feat(passkeys): POST /v1/passkeys + attestation verification WA-2115 @simplewebauthn/server@13.3.0 attestation verify, deterministic stateless challenge, 24 KiB route body cap, 500 ms verification timeout, idempotent first-write-wins via ON CONFLICT DO NOTHING
feat(passkeys): GET /v1/passkeys/{credentialId} WA-2116 Public lookup with Cache-Control: public, max-age=86400, s-maxage=2592000, immutable + ETag; no-store on miss
feat(passkeys): per-IP rate limits WA-2117 Two RateLimitGuard subclasses; 429 carries Retry-After and Cache-Control: no-store
feat(passkeys): OpenAPI/Swagger error envelope WA-2118 PasskeyErrorResponse DTO with full errorId enum; documented cache headers
test(passkeys): HTTP-layer integration spec WA-2119 12 tests through the full Express pipeline with attestation + repository mocked

Notable cross-cutting changes

  • Global CacheControlInterceptor now respects an existing Cache-Control header set by inner interceptors / @Header decorators, instead of unconditionally overwriting to no-cache. Without this, the immutable lookup policy was silently downgraded.
  • New dependency: @simplewebauthn/server@13.3.0. Pinned at v13 (current latest); v11 was the API-stability boundary, v12/v13 are functionally compatible.

Key invariants

  • DTO has no x / y field — coordinates are NEVER trusted from the client; they come from the verified attestation.
  • expectedChallenge is a deterministic constant-time-comparing function, never () => true.
  • verifiers is uint176 (22 bytes) — packed P256.Verifiers, NOT an Ethereum address. No EIP-55 checksum.
  • Rows are immutable; PK conflict → 409 with distinct PASSKEY_CONFLICT / PASSKEY_CROSS_RP_CONFLICT codes.
  • Module bootstrap fails closed if any of rpId / origin / verifiers allowlists is empty when FF_PASSKEYS=true.

How to test it

# Unit + integration suite (38 tests, all pass)
yarn test src/modules/passkeys/

# TypeScript
yarn build  # or: tsc --noEmit

# Manual smoke (against a local CGW + Postgres):
export FF_PASSKEYS=true
export PASSKEYS_VERIFIERS_ALLOWLIST=0x000000000000000000000000000000000000000000  # 22 bytes hex
# POST a real WebAuthn attestation to /v1/passkeys
# GET /v1/passkeys/{credentialId} returns the canonical record

Out of scope (per RFC author)

  • signer_address / CREATE2 derivation — dropped from v1; an additive migration path is documented in the plan.
  • Real attestation-fixture e2e — requires a live registration ceremony on iOS / Android / hardware key. The integration spec uses mocked attestation; recording fixtures is a follow-up.

Checklist

  • I've tested the branch on mobile
  • I've documented how it affects the analytics (if at all)
  • I've written a unit/e2e test for it (38 tests added)

Safe Engineering Generated with Claude Code

compojoom added 7 commits May 5, 2026 21:46
Creates the passkey_coordinates table with strict CHECK constraints on every
fixed-width bytea column, FILLFACTOR=100 for the insert-only/immutable
workload, and a destructive-rollback warning on the down migration.
…A-2114)

Adds the empty PasskeysModule (controller / service / repository skeletons,
TypeORM entity for passkey_coordinates), conditional registration in
AppModule behind FF_PASSKEYS, and configuration keys for RP-ID / origin /
verifier allowlists, registration & lookup rate limits, and the
attestation verification timeout.

The module's onModuleInit fails closed if any of the three allowlists is
empty when the flag is on, preventing a silent fail-open regression.

Repository carries the idempotent first-write-wins create() with
ON CONFLICT DO NOTHING + post-conflict re-select to distinguish
identical / conflict / cross-RP-conflict outcomes; controller/service
remain empty until Phase 3/4 attach endpoints.
Adds a NestJS route that verifies a WebAuthn attestation via
@simplewebauthn/server, derives the (x, y) P-256 coordinates from the
COSE_Key in the attestation, and persists them idempotently against the
unforgeable credentialId.

Key invariants:
- DTO has no x/y field — coordinates are NEVER trusted from the client.
- expectedChallenge is a deterministic stateless function bound to
  (rpId, origin, verifiers); compared in constant time.
- 24 KiB per-route body cap (route-scoped express.json) plus per-field
  DTO maxLengths defend against CBOR-bomb DoS.
- 500ms timeout around verifyRegistrationResponse protects the worker
  thread from cert-chain stalls (tpm / android-safetynet).
- ON CONFLICT DO NOTHING + post-conflict re-select distinguishes
  201 / 200 / 409 (PASSKEY_CONFLICT vs PASSKEY_CROSS_RP_CONFLICT).
- Error responses are an opaque { code: errorId } envelope; library
  internals never leak.
- verifiers (uint176, 22 bytes) is allowlist-checked at 403 before
  attestation verification runs.
…A-2116)

Public, no-auth lookup of stored coordinates by credentialId. Validates
the path parameter is well-formed base64url that decodes to 1..1023 bytes
(WebAuthn L3 credentialId limits) before hitting the database.

Cache strategy via a route-scoped interceptor:
- 200 → public, max-age=86400, s-maxage=2592000, immutable + ETag
  (rows are immutable, key is unforgeable; CDN absorbs ~90% of read load)
- 4xx → no-store
  (avoids stale-negative cache that would lock first-launch flows
  immediately after a brand-new POST lands at the origin)

The interceptor replaces the global no-cache CacheControlInterceptor on
this route only; ETag is the first 16 bytes of the credentialId, which
the client already supplied on the URL.
Two guards subclass the existing RateLimitGuard:
- PasskeysRegistrationRateLimitGuard (default 20 / 600s)
- PasskeysLookupRateLimitGuard (default 120 / 600s; reads >> writes)

Both are wired via @UseGuards on the relevant controller methods. A small
canActivateWithRateLimitHeaders helper sets Retry-After and
Cache-Control: no-store on the response before the 429 propagates — the
guard layer runs before interceptors, so the lookup interceptor's
catchError cannot reach those exceptions.

Asymmetric budgets are deliberate per RFC §4.6: reads are first-launch
on a new device, writes are already gated by attestation verification.
Adds a PasskeyErrorResponse DTO that exports the full errorId enum from
the Phase 3 status-mapping table, and wires it into every error response
on both endpoints so generated clients receive a typed union for the
'code' field.

Also documents the GET 200 response headers (Cache-Control, ETag) so
edge clients can plan around the long s-maxage / immutable contract.
…WA-2119)

Spins up a NestJS test app with mocked PasskeyAttestationService and
IPasskeysRepository, then exercises the full Express pipeline:
- 201 / 200 / 403 / 422 / 503 / 409 / 400 / oversize-body status paths
- ETag and Cache-Control: public, max-age=86400, s-maxage=2592000,
  immutable on GET hits
- Cache-Control: no-store on GET 404 (prevents stale-negative cache)

Real attestation-fixture e2e (recorded once from iOS / Android / hardware
key, replayed in CI) is a follow-up — committing fixtures requires a
live registration ceremony outside this PR.

Two non-test changes were needed to make this work end-to-end:

1. The global CacheControlInterceptor now respects an existing
   Cache-Control set by an inner interceptor or @Header decorator. It
   used to unconditionally overwrite to 'no-cache' because its tap runs
   last in the chain — that silently downgraded the immutable lookup
   policy.

2. The route-scoped 24 KiB body cap is now bound via forRoutes(
   PasskeysController) instead of a path string, so it survives any
   future URI-versioning change.
@compojoom compojoom requested a review from a team as a code owner May 5, 2026 20:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 3ad0053eba

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/modules/passkeys/domain/passkeys.repository.ts Outdated
compojoom added 3 commits May 5, 2026 22:40
Five fixes from the multi-agent review of the WebAuthn coordinate
storage API:

1. **mapLibraryError** now logs unexpected errors before remapping. It
   only catches SyntaxError/RangeError (legitimate CBOR/base64 decode
   failures) → PASSKEY_MALFORMED_ATTESTATION. Anything else is logged
   via LoggingService.error and re-thrown so a real bug in
   @simplewebauthn/server isn't silently reported as "user sent bad
   input".

2. **runWithTimeout** no longer leaks the underlying verification
   promise on the timeout path. The work promise gets a .catch() that
   logs late rejections so they aren't unhandled. A note documents that
   setTimeout-based timeout cannot interrupt synchronous CPU work —
   cancellation is best-effort.

3. **mapAttestationError** 500 fallthrough now logs the original error
   (type, message, stack) and attaches it as `cause` on the
   HttpException so post-deploy debugging has something to grep for.

4. **Error envelope** is now a superset of CGW's standard shape:
   `{ statusCode, code, message }`. Stable machine-readable codes are
   preserved while message+statusCode keep generated typed clients
   compatible with the rest of the API. Every throw site updated;
   PasskeyErrorResponse DTO documents the superset relationship.

5. **e2e body-cap test** is no longer relaxed to "any 4xx". It now
   asserts [413, 400] AND that attestation.verify was never called, so
   it fails if some unrelated handler-side check (verifiers allowlist,
   Zod) accidentally satisfies the test. Added a complementary
   boundary test that sends a payload just under the cap and asserts
   the handler did run — proves the cap isn't accidentally tighter
   than 24 KiB.

Tests: 39/39 passing.
Local-only planning docs shouldn't ship in PRs. Untrack the existing
passkey plan and add /docs/plans/ to .gitignore so future plans stay
out of git by default.
- Drop redundant `async` on rate-limit guard canActivate (returns the
  helper's promise directly).
- Type `verified` as VerifiedPasskey instead of implicit any.
- Convert fakeCache.increment from async to Promise.resolve to satisfy
  useAwait.
- Auto-formatter passes (line breaks, simplified logical expressions in
  cache-control interceptor and attestation service).
@compojoom

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented May 5, 2026

Copy link
Copy Markdown

Claude finished @compojoom's task —— View job


Review of feat(passkeys): WebAuthn coordinate storage API

Overall this is well-structured with good security posture (fail-closed bootstrap, timing-safe challenge comparison, deterministic stateless challenges, per-field byte caps). Here are the items to address:


Bug — insertResult.raw[0] returns snake_case Postgres columns

src/modules/passkeys/domain/passkeys.repository.ts:38 — The Codex review is correct. insertResult.raw[0] from RETURNING * returns raw Postgres column names (credential_id, created_at, rp_id), not TypeORM entity camelCase names. toRecord() reads row.credentialId and row.createdAt, which will be undefined. This means createdAt.toISOString() in serialize() will throw on the first successful POST.

Fix: either use insertResult.generatedMaps[0] (which TypeORM maps to entity shape), or manually map from snake_case in toRecord, or re-query with repo.findOneBy({ credentialId: record.credentialId }) on the created path (same as the conflict path already does).


Security — origin field has no URL format validation

src/modules/passkeys/routes/entities/register-passkey.dto.entity.ts:30origin is validated only as z.string().min(1).max(512) with no format constraint. While the origin allowlist in the service layer gates it, the Zod schema accepts anything (e.g. strings with control characters, newlines). Consider adding .url() or at least a regex to match https://... at the schema level for defense-in-depth.


Logic — cross_rp_conflict semantics may be inverted

src/modules/passkeys/domain/passkeys.repository.ts:50-62 — The conflict detection checks: if coords match but rpId differs → cross_rp_conflict. But if coords don't match (regardless of rpId) → generic conflict. This means a same-credentialId with different coords AND different rpId is reported as PASSKEY_CONFLICT rather than PASSKEY_CROSS_RP_CONFLICT. Is this intentional? A cross-RP re-registration with different verifiers would be indistinguishable from a same-RP coordinate mismatch. Consider whether this is the desired UX for the client.


Missing test — repository layer has zero unit tests

The repository (passkeys.repository.ts) has no dedicated spec file. The toRecord() bug above would have been caught by a unit test asserting the shape of insertResult.raw[0]. The existing integration tests mock the repository entirely, so this path is never exercised through the real TypeORM flow.


Nit — createdAt is set both client-side and server-side

src/modules/passkeys/routes/passkeys.service.ts:97 sets createdAt: new Date(), but the DB entity has default: () => 'now()' and the migration has DEFAULT now(). On the created path (where raw[0] is used), the DB-generated timestamp is returned. On the identical path, the DB row's createdAt is returned. The client-side new Date() in the service is effectively dead code on both paths.


Nit — Rate limit windowSeconds is hardcoded

src/config/entities/configuration.ts:769,776windowSeconds: 600 is hardcoded for both registration and lookup rate limits while max is configurable via env vars. Consider making windowSeconds configurable too, or document why it's intentionally fixed.


Summary

The PR is well-designed with strong invariants. The insertResult.raw[0] bug is the only blocking issue — it will crash on the first successful registration. The rest are non-blocking improvements.

Codex P1 on PR #3078: `INSERT ... RETURNING *` exposes driver-level rows
(snake_case columns), not entity-mapped fields. The success path was
reading `raw[0].credentialId` / `raw[0].createdAt` (camelCase) and
getting `undefined`, which would have crashed `serialize` on the very
first successful POST.

Fix: re-select by credentialId via the entity repository on the insert
path. The extra ~1 ms round-trip is negligible — the cold path is
already gated behind 500 ms attestation verification.

Resolves: #3078 (comment)
@compojoom compojoom marked this pull request as draft May 6, 2026 06:23
Five follow-ups from claude.ai's review of PR #3078:

- DTO validates `origin` as a URL at the framework boundary as
  defense-in-depth (the allowlist is still the load-bearing check).
- Conflict detection now reports cross_rp_conflict whenever the RP
  differs, even if coords also differ — gives clients a clearer signal
  ("different domain" vs "different keys on this domain").
- Repository now has a dedicated unit spec covering the snake_case bug
  (would have caught it pre-merge), the new rpId-first conflict
  semantics, and the row-vanished defensive branch.
- Removed dead `createdAt: new Date()` in the service — DB defaults
  to `now()` and we re-select after insert, so the placeholder was
  never read. Introduced PasskeyRecordInput (Omit<...,'createdAt'>)
  for the write path so the type system enforces the distinction.
- Documented the hardcoded windowSeconds in configuration.ts (rate
  limit `max` is the only env-tunable knob; per-window seconds is fixed
  by the use case).

Tests: 45/45 passing (up from 39 with the new repo spec).
@compojoom

Copy link
Copy Markdown
Contributor Author

@claude review

@claude

claude Bot commented May 6, 2026

Copy link
Copy Markdown

Claude finished @compojoom's task —— View job


Review of feat(passkeys): WebAuthn coordinate storage API

  • Read key source files
  • Read test files
  • Analyze and post review

Well-structured PR with strong security posture. The previous review's blocking bug (insertResult.raw[0] snake_case) has been fixed via findOneByOrFail re-select. The origin field now has .url() validation. Repository unit tests have been added. Here are the remaining items:


1. Stateless challenge is replayable across time

deriveStatelessChallenge in passkeys.service.ts:263-265 hashes only (rpId, origin, verifiers) — all static per-device values. There is no time-bound or nonce component. An attacker who intercepts one valid attestation can replay it indefinitely to re-register the same credential. The WebAuthn spec expects challenges to be single-use or at least time-bound. Consider mixing in a timestamp bucket (e.g. Math.floor(Date.now() / 300_000)) or a server-held secret so the challenge expires.

2. PASSKEY_CHALLENGE_INVALID error ID is unreachable

PasskeyAttestationErrorId includes 'PASSKEY_CHALLENGE_INVALID' and mapAttestationError handles it (passkeys.service.ts:183), but nothing in PasskeyAttestationService ever throws with this error ID. The expectedChallenge callback returns a boolean to the library — a mismatch causes the library to throw its own error, which gets mapped to PASSKEY_MALFORMED_ATTESTATION or falls through to the generic path. Dead code path — either remove it or wire it up.

3. registrationInfo may be undefined

passkey-attestation.service.ts:136-138 — After verification.verified passes, verification.registrationInfo is typed as VerifiedRegistrationResponse['registrationInfo'] which is RegistrationInfo | undefined. The code accesses info.credential.publicKey without a null check. If the library ever returns verified: true with registrationInfo: undefined (defensive edge), this will throw an unhandled NPE that maps to a 500 rather than a descriptive error.

4. No index on rp_id for potential future queries

The migration creates the table with only the PK index on credential_id. If you ever need to query or paginate by rp_id (e.g. admin dashboard, audit), you'll need a migration. Not blocking, but worth noting as a conscious decision.

5. @Res({ passthrough: true }) disables interceptors for status-code changes

In passkeys.controller.ts:101,149 — using @Res() with passthrough: true works, but NestJS documentation warns that once you inject @Res(), some interceptor behaviors (like ClassSerializerInterceptor) may be affected. Currently this is fine since you use plain DTOs, but it's a subtle footgun if serialization interceptors are added later.

6. ETag is not cryptographically opaque

passkeys.service.ts:134etag is the first 16 bytes of credentialId in hex. Since credentialId is the URL path parameter the client already knows, the ETag provides zero additional cache-validation entropy. It will always match for the same URL. This makes If-None-Match validation trivially passable by any client that can read the URL. Not a security issue (rows are public), but the ETag is effectively decorative.

7. Rate limit test coverage gap

The e2e spec doesn't test the rate limit guards actually firing (exhausting the budget and asserting 429 + Retry-After header). The fakeCache in the e2e has working increment, so adding one test that sends N+1 requests and asserts 429 would close this gap.


Summary

The previous blocking issues (snake_case raw row, origin validation, repo test coverage) are all addressed. The remaining items are non-blocking. The challenge replayability (item 1) is the most significant — worth a design decision on whether time-bounding is needed given the threat model.


|

The allowlist was meant to prevent unbounded write-spam with
attacker-chosen verifiers, but the four other defenses already cover
it:

- WebAuthn attestation requires the credentialId's private key.
- The stateless challenge binds (rpId, origin, verifiers); the server
  recomputes and constant-time-compares — verifiers cannot be swapped
  on someone else's attestation.
- Per-IP registration rate limit caps write volume.
- Rows are immutable; first-write-wins.

What the allowlist actually added was operational cost (must update
env vars before each contract deployment, fail-closed bootstrap on
empty config) for marginal data-quality gain. Drop it.

`verifiers` is still validated as 22-byte well-formed hex by the DTO
regex; clients are still trusted to provide a Safe-deployment-aligned
value, and any garbage they submit is bounded by their rate budget.
@safe-global safe-global deleted a comment from popcorn-review Bot May 8, 2026

@PooyaRaki PooyaRaki left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed the PR except for the tests, since I expect there will be quite a few changes based on the remarks. I’ll review the tests together with the next iteration.

`DO $$
DECLARE row_count bigint;
BEGIN
SELECT count(*) INTO row_count FROM passkey_coordinates;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove this query or at least change it to count(credential_id)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Resolved in a926edf — removed the diagnostic RAISE NOTICE count query entirely. It was purely informational ahead of the DROP TABLE, and the destructive nature is already documented in the file header.

verifiers bytea NOT NULL,
rp_id text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT PK_passkey_coordinates PRIMARY KEY (credential_id),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we create an integer id as the PK and then make this one unique?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — added a SERIAL surrogate id as the PK and a UNIQUE constraint on credential_id, matching the repo convention (create_users, create_wallets, create-counterfactual-safes). The entity was updated to @PrimaryGeneratedColumn + @Unique, and lookups still go through credential_id.

rateLimit: {
registration: {
max: faker.number.int({ min: 10, max: 100 }),
windowSeconds: 600,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

windowSeconds s should also use faker

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — both windowSeconds entries now use faker.number.int({ min: 60, max: 3600 }), consistent with the surrounding faker-generated fields.

.map((s) => s.trim())
.filter(Boolean),
originAllowlist: (
process.env.PASSKEYS_ORIGIN_ALLOWLIST ?? 'https://app.safe.global'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All these env vars should be validated:
src/config/entities/schemas/configuration.schema.ts

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — all PASSKEYS_* env vars are now validated in configuration.schema.ts (allowlists as CSV piped to arrays, rate-limit maxima and timeout as coerced positive ints). The lookup-cache TTL vars added in this round are validated too.

export type PasskeyRecordInput = Omit<PasskeyRecord, 'createdAt'>;

export type WriteOutcome =
| { status: 'created'; record: PasskeyRecord }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — extracted a WriteOutcomeStatus enum mirroring the MemberStatus pattern, and migrated all call sites (repository, service, specs).

// The origin allowlist in the service is the load-bearing check, but URL
// parsing here strips control characters / whitespace / non-URL strings at
// the framework boundary as defense-in-depth.
origin: z.string().min(1).max(512).url(),

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is deprecated we should use z.url() instead.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edforigin now uses z.url().min(1).max(512) instead of the deprecated z.string().url().

return next.handle().pipe(
tap(() => {
if (!response.headersSent) {
response.setHeader('Cache-Control', HIT_CACHE_CONTROL);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

QQ: Why do we need to override the global values here? I’m concerned this could create friction in the future if we want to change the cache duration.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The override is intentional and not avoidable: the global CacheControlInterceptor sets no-cache for every endpoint (sensible default), but passkey rows are immutable (credentialId is the PK and the row is never mutated), so the lookup can be cached aggressively. The global interceptor explicitly defers to inner interceptors via a getHeader(Cache-Control) guard, so this is the route-specific policy the framework expects rather than a fight against a configurable global. On the friction concern (fair point): in a926edf the TTLs are moved out of constants into passkeys.lookupCache.hitMaxAgeSeconds / hitSharedMaxAgeSeconds, tunable via PASSKEYS_LOOKUP_CACHE_HIT_MAX_AGE_SECONDS and PASSKEYS_LOOKUP_CACHE_HIT_S_MAX_AGE_SECONDS. Defaults preserve current behaviour, so future tuning is a config change. The no-store miss policy stays hardcoded since it is a correctness property (prevents stale-negative caching).

@HttpCode(HttpStatus.CREATED)
public async register(
@Body(new ValidationPipe(RegisterPasskeySchema, HttpStatus.BAD_REQUEST))
dto: import('@/modules/passkeys/routes/entities/register-passkey.dto.entity').RegisterPasskeyDto,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need to import this inline? We could import the type at the top and have:

dto: RegisterPasskeyDto

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — added a top-level import type { RegisterPasskeyDto } and the parameter now reads dto: RegisterPasskeyDto. It is a pure type alias so there is no runtime import or circular-dependency risk.

return createHash('sha256').update(material).digest('base64url');
}

function serialize(record: PasskeyRecord): PasskeyRecordResponse {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we used TypeOrm transformers we could remove this function from here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partially — the databaseBufferTransformer (a926edf) removed the manual Buffer conversions, but a serialization step is still needed to shape the PasskeyRecordResponse returned to clients (hex/base64url string encoding for the API contract), which is separate from DB storage. So rather than delete it, it is now a private serialize() method on the class (see below).

* them without regenerating the attestation, and
* - the comparison is constant-time (in PasskeyAttestationService).
*/
function deriveStatelessChallenge(dto: RegisterPasskeyDto): string {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this a function rather than a class method?
Ideally, each file should contain a single type of object (e.g. class, interface, etc.).
Since this method is only used here, it could be implemented as a class method. If we intended to reuse it elsewhere, we should move it into a shared helper within the passkey module.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in a926edf — the standalone helpers in this file (decodeCredentialId, deriveStatelessChallenge, serialize, stripHex) are now private methods on PasskeysService, so the file holds a single class per the convention. Call sites updated to this.….

@PooyaRaki PooyaRaki marked this pull request as ready for review May 20, 2026 15:45
- migration: drop diagnostic notice query; introduce SERIAL id PK with
  UNIQUE constraint on credential_id (follows repo convention)
- entity: add @PrimaryGeneratedColumn id, switch bytea columns to use a
  new shared databaseBufferTransformer so reads land as Buffer
- repository: replace post-INSERT SELECT with insertResult.generatedMaps,
  split create() into tryInsert/resolveConflict, fold Buffer helpers into
  the class via the transformer
- attestation service: extract PasskeyAttestationError, error-id union,
  VerifyAttestationInput, VerifiedPasskey, and parse/compare helpers to
  sibling files; split the 79-line verify() into four sub-methods; replace
  the parsed-JSON cast with an isClientDataJSON type guard; TsDoc all
  public methods
- passkey-record: extract WriteOutcomeStatus enum (mirrors MemberStatus)
- routes: turn standalone helpers in passkeys.service.ts into private
  class methods; remove inline import in controller in favour of a
  top-level `import type`
- DTOs: switch to zod v4 z.base64url() / z.url() built-ins
- config: validate all PASSKEYS_* env vars in configuration.schema.ts;
  use faker for windowSeconds in test config; move lookup cache TTLs
  into config (PASSKEYS_LOOKUP_CACHE_HIT_*_MAX_AGE_SECONDS)
- cache interceptor: read TTLs from configuration service; keep override
  (global interceptor sets no-cache and explicitly defers to inner ones)
@compojoom compojoom marked this pull request as draft July 8, 2026 09:25
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.

2 participants