feat: make accounts optional on web and iOS - #321
Conversation
Record the Better Auth 1.7 release-candidate behavior used by the implementation.
Record the migration dependencies discovered while evaluating passkey upgrades.
Replaces the anonymous-then-promote dance on the web client with a single
addPasskey({ createSession: true }). The server now creates the user, the passkey
and the session together, and sets the session cookie on the same response, so
there is no anonymous account to mint first and flip afterwards.
The client no longer calls /api/auth/finalize-passkey. That endpoint and the
ownership polling stay on disk for now, because iOS still uses them.
Adds a guard for a registration response with no session. The plugin rolls user,
passkey and session back together, so this should be unreachable, but a silently
missing session would otherwise show up much later as a confusing signed-out
state rather than an error at the point of failure.
The existing use-auth-gate test asserted the old flow, including the finalize
call and its body. Inverted it: the assertion is now that nothing hits the
promotion endpoint, plus that createSession is actually requested.
610 web tests and 84 functions tests pass, tsc and eslint clean.
The destructured `context` argument is not the auth context: it is the opaque caller-supplied string from ?context=, round-tripped through the stored challenge. Calling internalAdapter on it threw inside the plugin's try block, which surfaced to every UI test as the generic 'Failed to verify registration'. Compiles either way, since these options are a plain bag with no inference, so only a real request catches it.
Verified convergence for #271: demo-first and one atomic ceremony are not in conflict, and no upstream change is needed. resolveRegistrationUser returns the SESSION user whenever one exists, even with requireSession false, and only falls back to resolveUser when there is no session. WingDex bootstraps an anonymous user on load, so registration already resolves to that user. afterVerification now names it and clears isAnonymous instead of creating a second user, and returns the same id. Consequences: The id never changes, so demo data and any real observations created on top of it stay attached with no row migration and no cascading delete. The earlier concern about moving five user-scoped tables does not arise on this path. It is atomic. The update runs inside the plugin's runWithTransaction alongside passkey persistence and session creation, which is what the old promotion could not do: finalize-passkey patched the user in a second round trip, and waitForPasskeyOwnership existed only to wait out that race. The sessionless branch is kept for a genuine no-session signup (cleared cookies, or a client that never bootstrapped), where there is nothing to upgrade. Also fixes an api-smoke assertion that encoded the old behavior. It expected 401 from verify-registration behind freshSessionMiddleware; registration is sessionless now, so the request reaches payload decoding. Asserting not-401 keeps the regression meaning without pinning the exact downstream status. Adds e2e/passkey-upgrade.spec.ts, which asserts the identity is preserved across signup. The other UI specs promote an anonymous user as a precondition and pass either way, since they only check the session ends up non-anonymous. 46 e2e, 84 functions, 610 web tests pass; tsc and eslint clean.
Parsed since the eBird importer was written, but never persisted: it was used only to group rows into outings within one file, then discarded. Storing it gives provenance, a key for skipping checklists already imported, and a way to identify demo checklists without a separate flag. Existing dedupe is species-level (does this species exist, does the date extend the range). Checklist-level dedupe across imports does not exist yet; this is the column it needs. Demo checklists now carry WINGDEX-DEMO- ids instead of eBird-shaped S ids. Real eBird ids are always S followed by digits, so the prefix cannot collide with a real record. Both the demo CSV and the e2e fixture are changed together, since ebird-fixture-parity asserts they stay byte-identical so e2e exercises the data users actually see. ebird-import-variant.csv keeps real-shaped ids, as it stands in for a genuine export. The column is probed rather than assumed, matching the existing handling of the other optional eBird columns, so a database predating 0008 still imports.
Re-importing an eBird export that overlaps one already imported created a second copy of every outing. The existing conflict check is species-level (is this species in the dex, does the date extend the range) and never suppressed a duplicate outing, so the preview correctly said 'duplicate' while confirming it inserted everything again. Now confirm looks up the submission ids it is about to insert, drops the ones this user already has, and drops their observations with them so nothing references an outing that is never inserted. Lookups are chunked at 100 to stay inside SQLite's bound-parameter limit on a large export. Only outings that HAVE a submission id are skipped. Rows without one fall back to date+location grouping and cannot be identified reliably, so they are still imported rather than guessed at. Also fixes the response, which reported outings.length and observations.length, the counts BEFORE filtering. It now reports what was actually persisted, plus a skipped count so a client can tell 'nothing new to import' from a failed import. Both looked identical from a zero count before. The existing e2e re-import test stopped at the preview and never re-confirmed, which is why this went unnoticed. Extended it to confirm twice and assert the second import is a no-op and the outing count is unchanged. Verified it fails without the server change.
Turning demo data off called an unscoped clear: DELETE FROM outing WHERE userId = ?, which removes everything the account owns. That was harmless only because anonymous visitors could not create real data. The moment bird ID is ungated for them, loading the demo, adding a real outing and toggling the demo back off would take the real outing with it. /api/data/clear now accepts ?scope=demo, which deletes only checklists whose submissionId carries the reserved WINGDEX-DEMO- prefix. Observations and photos cascade from outing, so no per-table sweep is needed, and dex_meta is left alone because it is derived and recomputed from what remains. The unscoped default is unchanged, so account reset and sign-out cleanup behave exactly as before. The response reports rowsAffected rather than an outing count: D1's meta.changes includes rows removed by the cascade (101 for 10 demo checklists), so naming it outings would have implied a number it does not carry. Adds e2e/demo-data-clear.spec.ts: imports the demo CSV, creates a real outing with no submission id, clears scope=demo, and asserts the real outing is the only thing left.
Remove the destructive clear from sample loading so existing sightings survive.
Removes BootShell. Every visitor waited on a session check before seeing anything, which existed because passkey registration used to require a session. It no longer does, so a visitor is resolved as a guest right away and a real session upgrades that in place when the check returns. Three follow-on fixes the e2e suite caught: useWingDexData now takes hasSession and skips /api/data/all without one. A guest has no session, so that request could only 401, which failed the smoke test that asserts no console errors on load. Add Photos opens the dialog before awaiting the anonymous sign-in rather than after. The first step is photo selection, which needs no identity, and on a cold worker the round trip was slow enough to look like a dead click. On failure the dialog closes and an error is surfaced. An expired session now falls back to the guest view rather than a null user. The toast still explains what happened, and the app stays usable while signing in again. Tests updated rather than deleted: the two auth-guard cases now assert the app renders for a visitor with no session and while the check is in flight, which is the behavior that replaced the splash. passkey-upgrade creates its anonymous session explicitly, since a fresh visitor no longer has one and that test is specifically about the upgrade path. 610 web unit tests pass.
Deferring the anonymous bootstrap made this a normal path: a visitor who signs up before touching anything that needs an account never has a session to upgrade. It exercises the other branch of afterVerification, where the durable user is created rather than promoted, and nothing covered it. Asserts the precondition too (no session before signup), since without that the test would silently become a second copy of the upgrade case. Verified the assertion actually runs by inverting it and watching it fail, rather than trusting a green result from a test that might have returned early.
Return 401 consistently across local and hosted runtimes. Callers that need an identity now bootstrap one explicitly.
Identification runs on-device, so an anonymous visitor costs nothing to serve, and the gate asked for account commitment before showing any value. Add Photos now creates the anonymous account in the background instead of opening the sign-up modal. Signing up changes meaning rather than disappearing: an anonymous account lives in one browser and is lost when cookies are cleared or the session expires, so a passkey is what makes results durable and portable. Two access rules had to follow, because an anonymous user can now own real data: Settings no longer redirects anonymous users home. It already renders a 'Create account or sign in' card for them, and bouncing them meant a visitor who had just recorded sightings could not reach it. Import & Export is no longer hidden from anonymous users. Hiding export from the only users who cannot recover their data by signing in elsewhere would trap it. requireAuth is left on useAuthGate but no longer used, since nothing gates on having an account. Kept for a future gate rather than deleted and re-added. 610 web unit and 49 e2e tests pass.
Identification runs on-device and is no longer gated, so the callback-after- sign-up path had no callers. Removing it also retires the pendingCallback ref, which only ever held null once requireAuth was gone. openSignIn stays: the header button and Settings both use it, and it now opens the modal regardless of who is asking, since there is no gate to bypass. Tests updated to match rather than deleted. The harness drives openSignIn, and the 'runs callback immediately when not anonymous' case now asserts the modal opens for a signed-in user, which is the behavior that replaced it. Recoverable from git if a future gate wants it back.
Reverts the two access changes from 9aaf307 while keeping the identification ungating that commit was actually about. Identification is free because it runs on-device and costs nothing to serve. Keeping and moving data is different: import, export, passkeys and profile are what an account is for, so Settings goes back behind sign-up and Import & Export returns to the !isAnonymous branch. Both files are now byte-identical to their pre-9aaf307 state for these gates, verified by diff, so this is a restore rather than a reimplementation. Sign-out was already hidden from anonymous users and stays that way: with no credential there is no way back in, so signing out would be indistinguishable from deleting the account. 610 web unit and 49 e2e tests pass.
Assign anonymous bird names server-side, preserve them through upgrade, and use a rolling 365-day session lifetime.
Remove demo data now that the account-optional app provides the real experience. Badge anonymous sightings so their device-local durability remains visible.
Import is the heaviest write path an account can reach, and it had no limiter while geocoding did. The key helper moves to its own module rather than being imported from geocoding-gateway, which is where it happened to live. Anonymous callers share a budget per IP rather than per account, because anonymous accounts can be minted without limit and a per-account key would hand out a fresh allowance each time. Nothing uses the binding yet.
…servation Re-importing an export created duplicate outings. Four imports of one file produced four copies of the same day and location. Two causes compounded. The submission id lived on the outing, but an outing merges several checklists from the same place and day, so it could record only one of their ids. One eBird row is exactly one (checklist, species) pair, which is the observation, so the id moves there and dedupe becomes exact. Migration 0008 is amended in place rather than superseded, since it has not shipped. Dedupe now runs on the rows before grouping, so the result no longer depends on which subset of them the caller sends. That subset was the second cause: the client dropped previews the species-level check marked duplicate, which changed which row landed first in each group and therefore which id the outing recorded. The two-phase import goes with it. Nothing ever rendered a preview, so the round trip only echoed the server's own parse back to it, and a preview id was the base64 of a whole row rather than a handle to server state. Confirm wrote whatever it was handed, and the 10MB body limit allowed roughly 17,000 fabricated rows in a single request. One POST now parses, dedupes, writes and returns a summary, which removes the forgery entirely rather than mitigating it, and halves the requests each import spends against the limiter. iOS still calls the old two-phase endpoints and will need updating.
The Settings gate was UI-only. An anonymous session could call the endpoints behind it directly, and import is the heaviest write path an account can reach: real exports run about 200 bytes per row, so the 10MB limit allows roughly 52,000 rows, against a free-tier budget of 100,000 D1 row writes a day. Only import is gated. Export is deliberately left open, because an anonymous user is offered their sightings as a CSV before signing in to a different account, which is the one case where leaving with your data matters most. Everything else under /api/data is the app itself, which anonymous visitors are meant to use. Two tests were reaching for import as a convenient way to get data into an account. The badge spec now creates an outing and an observation the way an anonymous visitor actually does, and the two api-smoke import tests get a real account through the browser rather than an anonymous session, since the passkey ceremony needs one.
…irst The suite mostly ran signed in, which is the opposite of what this work changed. Of 37 `loadApp` call sites only 13 skipped the passkey ceremony, so upload, outings and WingDex were only ever exercised by a registered user. Eight smoke tests that never needed an account now run without one, including the photo upload flows, which is the journey the whole issue is about. Smoke dropped from 53 to 24 seconds as a side effect of not minting an account per test. Tab navigation keeps its account, because it opens Settings; a new anonymous counterpart covers browsing and pins the gates, including that import is refused at the API rather than only hidden in the UI. Three journeys were missing entirely and are now covered: - the convert: signing up keeps the id, the bird name and the data, clears the badge and opens Settings and import - the loss event: clearing cookies degrades to a working fresh visitor rather than a broken one, which is exactly the risk the badge names - the session cookie is HttpOnly and expires about a year out, which pins the expiresIn change at the only layer that matters Also the cancelled ceremony. Requesting registration options must write nothing, since the resolveUser stub is not persisted and afterVerification never runs, and dismissing the sheet must stay quiet rather than reporting an error. It asserts the ceremony reached the options request and stopped before verification, so it cannot pass by the button doing nothing.
…sion journeys The remaining two gaps in section 4. The model-backed upload journey now runs without an account, along with location search and multi-photo clustering. It already paid the 61.66 MiB model download, and the account added nothing to what it proved. Flipping it surfaced a real interaction that only unit tests had seen: closing the flow after a first save is where the single sign-up prompt fires, and until now that was only exercised against a mocked AddPhotosFlow. Both upload tests now assert the prompt appears and dismiss it, so a real photo upload proves the prompt end to end. The collision journey is new. Two accounts are needed to test it, so it manages its own virtual authenticator rather than using the shared helper, which removes the authenticator when it is done and would strand the credential. It signs up as one account, adds a sighting, clears cookies, accumulates anonymous sightings, then signs back in with the original passkey. Signing in lands in the account that owns the passkey, and the anonymous sightings are neither merged into it nor dropped without the warning first. Merging is rejected on purpose, and a merge bug would only ever be visible here.
D1 allows 100 bound parameters per query. The checklist-skip query binds the user id plus one id per checklist, and chunked the ids at 100, so any export with 100 or more checklists overflowed on the very first chunk and failed the whole import with a 500. A real 148-checklist export hit it immediately. Both fixtures carry ten checklists, so nothing caught it. The regression test builds a 120-checklist CSV in memory rather than adding a large fixture, and it has to be an e2e test: the functions suite mocks D1, so it would not enforce the cap and would pass either way. Verified by restoring the old chunk size and watching it fail with the same 500. The bare catch made this much harder to find than it should have been. A 500 reported only the stage name, which cannot separate a bad CSV from a broken query. The underlying message now rides along in the result description, which the middleware strips before the response leaves, so it stays server-side.
Two findings from the manual test drive, both the shell disagreeing with the session. Logging out left the previous account's sightings on screen. The data hook returned early when the session went away and never touched its state, so outings, observations and the dex stayed exactly as they were until a reload. Home, Outings and WingDex all stayed browsable. That is a cross-identity leak rather than a cosmetic staleness: the next person at a shared machine kept seeing the account that had just signed out. The hook now clears instead of returning. A returning user saw the logged-out button for the length of the session check. Dropping the splash screen was deliberate, and the body painting immediately is the point, but the header was guessing at the identity before it knew one. It now holds an empty slot of the same size until the first check resolves, which keeps the layout still and never claims the wrong thing. Only the first check counts, so later refetches do not blank it again. Both tests were verified by reverting each fix and watching its own test fail. The sign-out test asserts the empty state rather than the absence of the outing, because a bare toBeHidden passes before the list has rendered at all. The header test uses a registered account: an anonymous session's avatar button is also labelled "Log in", so an anonymous user cannot tell the two states apart.
Model native identity as no session, anonymous, or registered, and keep the main tab interface available before an account exists. Anonymous bootstrap is coalesced at the first write and shared by Add Photos, incoming shares, debug photo injection, and read-only App Intents. Replace the anonymous-bootstrap/finalize signup dance with Better Auth's atomic one-ceremony registration. Native passkey responses now install the canonical user, raw bearer, signed token, and server expiry together; stale ceremony results cannot resurrect a session after logout or an account switch. Remove invented seven-day expiries, refresh canonical session metadata from get-session, and revoke the server bearer during logout while clearing local state immediately. Keychain restoration accepts only a complete current-format record because the app has not shipped and needs no migration fallback. Match the web durability UX with the anonymous bird avatar, data badge, first-save prompt, account-switch export warning, registered-only account controls, and anonymous Delete All Data access. Account-scoped caches clear and reload whenever identity changes, and ordinary CSV export stays account-only. The model-backed UI journey now launches sessionless, identifies and saves a real photo, shows the one-time prompt, and leaves the badge after dismissal.
Move the native client to the server's parse-dedupe-write response and delete the preview/confirm UI and protocol. The file importer still balances its security-scoped URL access, preserves timezone conversion, guards account changes, reloads the active store, and reports new species for celebration. Remove native demo mode rather than carrying its destructive clear-before-import behavior forward. Delete the bundled CSV, debug controls, launch arguments, loader, race stubs, generated resource references, and stale setup docs. UI tests now start from an empty account instead of replacing account data. Pin the new response shape with a native decoder test. The generated project builds without the demo asset and the focused DataStore tests pass.
Native and web clients now use Better Auth's transactional registration hook, so remove finalize-passkey, passkey ownership polling, the promotion SQL, route, OpenAPI surface, tests, and current-flow documentation. Keep the durable account-upgrade event by emitting it from afterVerification, the code that now performs the in-place upgrade. The event is committed in the same registration transaction as the passkey and session instead of being owned by a second endpoint. Also delete the unused anonymous data migration helper and tests. Upgrade in place preserves the user id, so no rows move between accounts.
Add functional UI coverage for cold sessionless launch, anonymous account gates, model-backed identify-and-save, first-save prompt, persistent badge, and the pre-login export warning. Give the passkey login command a stable identifier so the app-owned collision preflight is testable without automating the AuthenticationServices sheet. Dismiss registered-only Settings as soon as logout or session rejection changes identity. Manual simulator verification caught the stale sheet even though local state and the server bearer were already cleared. Finish the deferred accessibility pass. Empty-state typography now uses semantic Dynamic Type styles, decorative symbols stay out of the accessibility tree, and outing rows expose one human-readable summary with vertically flexible content. All 13 BirdIdFlow UI tests now pass together.
The account-optional header intentionally holds an empty avatar slot until the initial session check resolves. promoteAnonymousUser checked for the Log in button as soon as the header existed, sometimes saw zero buttons, and returned without registering a passkey. Import and seeded specs then waited forever for Settings. Wait for either resolved identity state, Log in or Settings, before deciding whether signup is needed. All 68 tracked E2E specs pass after the fix.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 88 out of 90 changed files in this pull request and generated no new comments.
Suppressed comments (6)
src/hooks/use-auth-gate.tsx:103
birdNamenow affects only the credential label. An anonymous upgrade preserves the independently server-assigned name, and sessionlessresolveUsergenerates another name, so the passkey label will almost never match the account's canonical bird name. Use the canonical registration user name for both values, or thread one generated name through the registration contract.
functions/api/import/ebird-csv.ts:111parseEBirdCSVreturns an empty array for an empty file or a CSV without valid species/date rows. This path currently records a receipt and returns 200 with zero imports, even though the API contract describes invalid CSV as 400. Reject it before committing an import receipt.
const parsedRows = parseEBirdCSV(csvContent, normalizedProfileTimezone)
parsedRowCount = parsedRows.length
wrangler.toml:32
- These comments describe the removed preview/confirm protocol and signed preview IDs. Import is now a single request with receipt/checklist dedupe, so this stale guidance will mislead future limiter changes.
functions/api/import/ebird-csv.ts:52 - This serializes every row into one bound string. D1 limits a string/BLOB value to 2 MB, while this endpoint accepts CSVs up to 10 MB, so a valid large import can fail with 500 before the batch runs. Chunk the JSON payloads below D1's limit and include all chunk statements in the same batch.
return db
.prepare(`INSERT INTO ${table} (${columns.join(', ')}) SELECT ${selections} FROM json_each(?)`)
.bind(JSON.stringify(rows))
ios/WingDex/Views/SignInView.swift:261
- The new Sign up style is already known to fail the XCTest contrast audit:
BirdIdFlowUITests.swift:137-139exempts exactly this label. Adjust the style to pass contrast in Light and Dark modes, then remove the exemption rather than shipping the failing control.
ios/WingDexUITests/BirdIdFlowUITests.swift:143 - This predicate accepts every Dynamic Type audit issue on Home, so both the current unidentified failure and future regressions pass silently. Narrow it to the exact known element/description, as the other handlers do, or fix the underlying failure.
Bug bash focusThe best use of bug-bash time is signed-device and platform integration rather than repeating CRUD or ordinary browser navigation. Highest priority
Medium priority
High-confidence automated coverage
|
Document eBird record formatting and local wall-clock preservation, the iOS 26 minimum, account-optional shell/cache ordering, Add tab navigation, test data reset semantics, registered-only import/export, preview commit diagnostics, and the motion/contrast tradeoffs in native sign-in. These are lines whose history repeatedly returned to an earlier value; keeping the rationale near the owning block should prevent the same decisions from being rediscovered.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 88 out of 90 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/hooks/use-auth-gate.tsx:106
- The passkey label is built from a new random bird name, but the server independently preserves the anonymous user's existing name or generates a different name for sessionless signup. The resulting credential is therefore labeled
Device (bird-A)while the account/avatar isbird-B, contrary to the canonical<device> (bird-name)UX. Please build the label from the same server-resolved account name used during registration.
functions/api/import/ebird-csv.ts:111 - An empty, headers-only, or otherwise unparseable CSV currently reaches
commitRows, records a file receipt, and returns 200 with zero imports. This contradicts the documented 400 response and makes the UI report a successful zero-outing import. Reject the upload when parsing produces no valid observation rows.
const parsedRows = parseEBirdCSV(csvContent, normalizedProfileTimezone)
parsedRowCount = parsedRows.length
functions/lib/auth.ts:388
- This emits a
Succeededdurable-upgrade event from inside the registration transaction, before the plugin has inserted the passkey/session and committed. If a later transaction step fails, the database rolls back but observability still claims the account upgrade succeeded. Emit this success only after the full registration transaction commits.
await ctx.context.internalAdapter.updateUser(sessionUserId, {
name,
image: emojiAvatarDataUrl(emojiForBirdName(name)),
isAnonymous: false,
})
logPasskeyAccountUpgrade(options.log)
wrangler.toml:32
- This comment still describes the deleted preview/confirm protocol and says an import consumes two requests. The route now performs one request and protects retries with import receipts, so this deployment documentation is misleading.
functions/_middleware.ts:15 - These prefixes introduce a new 403
Account requiredresponse for import, outing export, and dex export, butopenapi.yamlstill documents only 401 for those account-only paths. Please add the 403 response to each affected operation so generated clients and API consumers can distinguish a valid anonymous session from missing authentication.
/** Path prefixes that require a registered account rather than any session. */
const ACCOUNT_ONLY_PREFIXES = ['/api/import/', '/api/export/outing/', '/api/export/dex']
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 88 out of 90 changed files in this pull request and generated no new comments.
Suppressed comments (7)
src/App.tsx:432
- A successful save can happen before
useSessionpublishes the anonymous session.ensureAnonymousSession()deliberately returns as soon as sign-in succeeds and setsuserdirectly whilehasSessionstill comes from the later session refetch. In that window this guard drops the first-save signal permanently, so the promised one-time durability prompt never appears. Since this callback only fires after persistence succeeds, use the anonymous user identity rather thanhasSessionas the prerequisite.
src/hooks/use-auth-gate.tsx:104 - This generates a new random bird name only for the passkey label. The server independently preserves the existing anonymous name or generates another name for a sessionless user, so the stored label will almost always show a different bird name than the account. Pass the canonical anonymous name through registration, or use one shared registration context to choose both the user name and passkey label.
functions/lib/auth.ts:388 - This emits a
Succeededupgrade event from insideafterVerification, before the plugin's surrounding registration transaction has committed. If the later passkey or session write fails, the transaction rolls back the user update but observability still records a durable account upgrade. Emit the success only after the complete registration transaction succeeds, or classify this callback event as a non-terminal stage.
logPasskeyAccountUpgrade(options.log)
ios/WingDexUITests/BirdIdFlowUITests.swift:139
- Returning true here suppresses a known contrast failure for the newly styled Sign up control, so this test reports success while the control still fails the accessibility audit. Please correct the control's foreground/background treatment and remove the exception rather than accepting the failure.
ios/WingDexUITests/BirdIdFlowUITests.swift:143 - This treats every Dynamic Type issue anywhere on Home as handled. Unlike the other filters above, it is not scoped to a documented element, so future clipped or non-scaling controls will silently pass the audit. Restrict the exception to the specific known element and failure, or remove it once the layout is fixed.
wrangler.toml:32 - These comments still describe the removed preview/confirm protocol and forged preview IDs, but imports now parse and persist in one request. This makes the limiter configuration misleading when it is tuned later.
openapi.yaml:576 - The middleware now returns 403 for anonymous imports, but the updated API contract documents only 401 before jumping to 413. Add the account-required response so generated clients and API consumers can distinguish a missing session from an anonymous session that must be upgraded.
Start local shared-photo ingestion immediately while anonymous session and initial account data become ready in parallel. Anonymous readiness now ends when the bearer and user are published; bounded metadata enrichment continues in a token-scoped background task. Coalesce only initial account hydration, preserving explicit refresh semantics, and retain visible retry behavior when readiness or share cleanup fails. Simplify durability copy to Keep your WingDex across web and iOS, remove the redundant native subtitle, and clear current Xcode source warnings.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 89 out of 91 changed files in this pull request and generated 1 comment.
Suppressed comments (4)
ios/WingDexUITests/BirdIdFlowUITests.swift:143
- This blanket handler suppresses every Dynamic Type finding in the Home audit, including unrelated regressions introduced later. Other handlers in this file scope exceptions to a specific accessibility identifier; please either fix the current finding or restrict this exception to the known element and failure.
openapi.yaml:576 - The import endpoint is now registered-account-only in middleware, so anonymous callers receive 403, but the updated OpenAPI response list omits that outcome. Add the 403 response so generated clients and API consumers can distinguish an expired session (401) from an account upgrade requirement (403).
wrangler.toml:32 - This limiter comment describes the deleted preview/confirm protocol and claims signed preview IDs provide the real protection, but imports are now a single parsed-and-written request with no preview IDs. Please update it to describe the actual one-request limiter so future security work does not rely on a nonexistent control.
functions/lib/auth.ts:332 - This comment still says the app bootstraps an anonymous user on load and attaches demo data, which is the behavior this PR removes. Registration may now be genuinely sessionless, while anonymous creation is deferred until persistence is needed; please rewrite this explanation to match those two paths.
// Demo-first, one ceremony. requireSession false lets registration
// start without a session, but WingDex normally has one: the app
// bootstraps an anonymous user on load so a visitor can use the app
// before signing up.
…mits afterVerification runs inside the plugin's registration transaction, so a passkey or session write can still fail and roll the user update back. Signal the upgrade from there instead of logging it, and emit auth/account/upgrade alongside auth/passkey/create once the route returns success.
stage(fileURLs:) is nonisolated async, so it posts off the main thread even when called from a MainActor task. Observers deliver on the posting thread, so the staged-share handler mutated navigation state from a background thread.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 90 out of 92 changed files in this pull request and generated no new comments.
Suppressed comments (5)
src/hooks/use-auth-gate.tsx:101
- This passkey label uses a fresh client-generated bird name, but the server independently generates the durable name for sessionless signup and preserves the existing name during anonymous upgrade. The credential can therefore be saved as
Device (unrelated-name), contrary to the canonical<device> (bird-name)behavior. Please derive the label from the same canonical name used by registration.
functions/api/import/ebird-csv.ts:111 - An empty, header-only, or wholly invalid CSV parses to zero rows, but this path still records a file receipt and returns a successful
Imported ... 0 outingsresponse. The previous client rejected this case asNo valid data found; return a 400 before creating the receipt so users are not told an invalid import succeeded.
const parsedRows = parseEBirdCSV(csvContent, normalizedProfileTimezone)
parsedRowCount = parsedRows.length
openapi.yaml:576
- The middleware now returns 403
Account requiredfor an authenticated anonymous caller, but the updated import contract documents only 401. Add the 403 response so generated clients and API consumers can distinguish a missing session from an account-upgrade requirement.
ios/WingDexUITests/BirdIdFlowUITests.swift:143 - This handler marks every Dynamic Type failure on the populated Home screen as known, so the audit will pass even for newly clipped or inaccessible elements. The other handlers identify a specific element/description; narrow this exemption to the actual known issue instead of suppressing the entire audit category.
wrangler.toml:38 - This 40-request budget was documented as 20 imports when preview and confirm each consumed a request. After collapsing import to one request, it now permits 40 of the heaviest write operation per minute per account. Either reduce both production and preview limits to preserve the intended import budget or explicitly document why the doubled throughput is safe.
Closes #271