Skip to content

feat: make accounts optional on web and iOS - #321

Merged
jlian merged 50 commits into
devfrom
spike/better-auth-1.7-passkey
Aug 13, 2026
Merged

feat: make accounts optional on web and iOS#321
jlian merged 50 commits into
devfrom
spike/better-auth-1.7-passkey

Conversation

@jlian

@jlian jlian commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Closes #271

jlian and others added 29 commits August 10, 2026 16:40
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.
Copilot AI balanced review requested due to automatic review settings August 12, 2026 08:09
Copilot AI review requested due to automatic review settings August 12, 2026 16:50
@github-actions
github-actions Bot temporarily deployed to preview (#321) August 12, 2026 16:54 Inactive

Copilot AI 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.

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

  • birdName now affects only the credential label. An anonymous upgrade preserves the independently server-assigned name, and sessionless resolveUser generates 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:111
  • parseEBirdCSV returns 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-139 exempts 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.

@jlian

jlian commented Aug 12, 2026

Copy link
Copy Markdown
Owner Author

Bug bash focus

The best use of bug-bash time is signed-device and platform integration rather than repeating CRUD or ordinary browser navigation.

Highest priority

  • Signed-device Share Extension: Share photos into a cold, sessionless WingDex. Retry offline, close, relaunch, then retry online. CI uses an app-sandbox substitute because unsigned builds cannot access App Groups.
  • Shortcuts on a physical device: Run Open WingDex, View Outings, Upload Photos, data queries, and export. Take Photo requires a real camera. Simulator rejects App Intents because the unsigned app lacks a Team ID.
  • Native authentication ceremonies: Exercise passkey signup, cancellation, existing-account login, and adding a second passkey to a registered/social account. Confirm name/avatar remain unchanged. Repeat collision warning/export/no-merge with Apple, Google, and GitHub.
  • Real Cloudflare import behavior: Import the attached legacy WingDex CSV twice; import two overlapping exports concurrently; import a large file with hundreds of checklists; export/import into another account, then import the original eBird file. Confirm original eBird IDs and WingDex outing boundaries dedupe correctly.

Medium priority

  • Anonymous identity transitions: Save, dismiss the prompt, log into another account, then create a fresh anonymous identity. Confirm the new identity receives its own first-save prompt. Open account access through every entry point and confirm “Keep your sightings” appears whenever data exists.
  • Offline and rejected-session behavior: Cold-launch offline with a valid cached account. Delete the server session, relaunch, and verify there is no stale-data flash. Lose connectivity during save, import, logout, and account switching.
  • Native import presentation: Verify first-import and duplicate-only banners visually, confetti and status do not overlap, and Files/iCloud security-scoped access works on device.
  • Release build inspection: Verify App Group entitlement and Share Extension, App Intent indexing, absence of demo assets/controls, light/dark mode, and large Dynamic Type.

High-confidence automated coverage

  • Anonymous web lifecycle: badge, prompt, upgrade, cancellation, and account collision.
  • Web first-write bootstrap and stale-response protection.
  • Bearer lifecycle, expiry, logout revocation, and 401 clearing.
  • Account/cache isolation.
  • Import parsing, exact retries, submission dedupe, concurrent overlap, large atomic writes, and round-trip IDs.
  • Registered-only import/ordinary exports and anonymous warning export.
  • CoreML identification and model-backed native save journey.
  • Native share queue storage, retention, retry, explicit close, and consumer logic.
  • Accessibility audits across populated and empty native screens.
  • Exact-SHA CI: web/Functions tests, E2E, production build, and hosted iOS build/tests.

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.
Copilot AI review requested due to automatic review settings August 12, 2026 18:53
@github-actions
github-actions Bot temporarily deployed to preview (#321) August 12, 2026 18:56 Inactive
@github-actions
github-actions Bot temporarily deployed to preview (#321) August 12, 2026 19:01 Inactive

Copilot AI 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.

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 is bird-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 Succeeded durable-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 required response for import, outing export, and dex export, but openapi.yaml still 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']

Copilot AI review requested due to automatic review settings August 12, 2026 19:01

Copilot AI 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.

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 useSession publishes the anonymous session. ensureAnonymousSession() deliberately returns as soon as sign-in succeeds and sets user directly while hasSession still 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 than hasSession as 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 Succeeded upgrade event from inside afterVerification, 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.

@jlian
jlian changed the base branch from main to dev August 12, 2026 22:31
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.
Copilot AI review requested due to automatic review settings August 13, 2026 01:35
@github-actions
github-actions Bot temporarily deployed to preview (#321) August 13, 2026 01:38 Inactive

Copilot AI 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.

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.

Comment thread functions/lib/auth.ts Outdated
jlian added 2 commits August 12, 2026 18:51
…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.

Copilot AI 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.

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 outings response. The previous client rejected this case as No 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 required for 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.

@jlian
jlian merged commit 50631d4 into dev Aug 13, 2026
6 checks passed
@jlian
jlian deleted the spike/better-auth-1.7-passkey branch August 13, 2026 03:40
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.

Account-optional WingDex: use the app without signing up, and one atomic passkey ceremony when you do

2 participants