Skip to content

Server-backed highlights go live: auth hardening + delete-flash fix#294

Open
cameronapak wants to merge 19 commits into
mainfrom
claude/ype-1034-pr5-hardening
Open

Server-backed highlights go live: auth hardening + delete-flash fix#294
cameronapak wants to merge 19 commits into
mainfrom
claude/ype-1034-pr5-hardening

Conversation

@cameronapak

@cameronapak cameronapak commented Jul 20, 2026

Copy link
Copy Markdown
Collaborator

Summary

Server-backed BibleReader highlights go live: the dark-launch flag flips on after a final round of auth hardening, a fix for successful deletes being misread as failures, and Swift-parity UX corrections. Last PR of the YPE-1034 stack (#283#285#286 → this one); the consolidated changelog announces the launch and the removal of the accidentally shipped localStorage-only highlights.

Changes

  1. Highlights are enabled by default (HIGHLIGHTS_LIVE on) — signed-in readers' highlights persist to their YouVersion account; six stacked changesets are consolidated into one launch changelog.
  2. Removing a highlight no longer flashes back before disappearing — the core ApiClient treated empty-body 2xx JSON responses (the real DELETE shape) as failures, so the machine reverted the successful removal; regression tests now drive the real client through a stubbed fetch.
  3. A data-exchange grant only applies to the user who initiated it — initiator recorded at redirect, read-and-cleared at callback, any mismatch fails closed; signIn also clears stale permission stashes so an abandoned flow can't leak grants across users.
  4. A resumed pending write that fails 401/403 re-prompts for permission instead of silently reverting the reader's tap.
  5. Sign-in matches the Swift SDK: appName / signInPromptMessage props, Platform wordmark, comma-joined requested_permissions wire format, and a permission cache seeded from token scope so a highlight tap doesn't re-prompt right after OAuth.
  6. Dark mode matches the Swift SDK: highlight fills render at 0.3 opacity (1.0 in light mode) and verse numbers paint white over dark-mode fills; the active swatch shows a checkmark instead of an X.
  7. Hardening and cleanup: xstate pinned to exactly 5.32.4 (supply-chain cooldown), review findings across all three packages (color row gated on interactivity, stable serverColors/refetch identities, shared useApiClient), the logo's accessible label is a required localized prop, the pending-permissions stash stores string[], and personal workflow notes are stripped from AGENTS.md.

Start here: change 3 — auth-boundary logic; the fail-closed path is the part worth adversarial review.

Test plan

  • pnpm build && pnpm typecheck && pnpm lint && pnpm test: all green (972 tests — core 349 / hooks 288 / ui 335)
  • Vapor regression suite fails on the reverted fix, passes with it (fetch-stub end-to-end through the real client, machine, and adapter)
  • Storybook integration tests run in CI

Needs manual check: remove a highlight in the signed-in vite demo and confirm it vanishes with no flash — the fix is test-covered, but the live confirmation was blocked by a signed-out demo session.

Greptile Summary

This PR hardens the highlights feature for production (HIGHLIGHTS_LIVE flipped to true) with four targeted fixes on top of the restored stack from #288: cross-user grant isolation in the data-exchange callback (fail-closed via initiator binding), 401/403 re-prompt for resumed pending writes, several code-quality cleanups (reference-stable serverColors, shared useApiClient, stable refetch identity via fetchFnRef), and an xstate rollback from 5.32.5 → 5.32.4.

  • Change 1 + 6 (auth boundary): The data-exchange grant is now state-bound to the initiating user: saveDataExchangeInitiator() is called before the token mint, the callback reads-and-clears it atomically, and a user or initiator mismatch degrades to failure. signIn also clears any stale stash and initiator from a prior abandoned flow so cross-user leakage is prevented on both code paths.
  • Change 2 (resume re-prompt): The reprompt field was removed from WriteOp; now any apply op (user-initiated or resume-applied) re-stashes and re-prompts on 401/403, while removes only invalidate the cache — fixing the silent intent-drop that existed for pending resumes.
  • Change 3 (cleanups): serverColors is reference-stable via a useRef equality guard, useApiClient consolidates client construction across four hooks, and refetch identity is stabilized via a fetchFnRef pattern in useApiData (explicit deps array still drives re-fetches, so no behavior regression).

Confidence Score: 5/5

The auth-boundary hardening is well-implemented and fail-closed; no grant cross-user leakage paths remain.

The core change — binding the data-exchange grant to the initiating user via a read-and-cleared stash — is correctly implemented: the initiator is saved before the async token mint, consumed atomically at callback entry, and any mismatch (different user, missing initiator) degrades to failure rather than silently saving the grant. The stash is also state-bound so a pre-code hop from a different OAuth flow cannot contribute grants. The signIn path correctly purges any prior stash and initiator before starting a new flow. The resume-write re-prompt fix and the signedOut guard in the permission-dialog state are both straightforward and well-tested. The only non-blocking note is that the OIDC_SCOPES denylist used when seeding the permission cache from token scope is a hardcoded set that could grow stale if the auth server adds new standard scopes.

packages/core/src/Users.ts — the OIDC_SCOPES denylist approach for filtering token scope before storing in the permission cache.

Important Files Changed

Filename Overview
packages/core/src/Users.ts Adds cross-user grant isolation via state-bound stash and initiator binding; the fail-closed logic is solid. Minor: OIDC_SCOPES denylist could grow stale.
packages/core/src/data-exchange.ts Initiator is read-and-cleared atomically before the grant check; user-mismatch and missing-initiator both degrade correctly to failure. URL cleanup fires regardless of mismatch.
packages/ui/src/components/bible-reader-highlights-machine.ts Removes the reprompt field; all apply ops (user-initiated and resume) now correctly re-stash + re-prompt on 401/403. The new signedOut always-guard cleanly drops pending state when the user signs out while the permission dialog is open.
packages/hooks/src/useApiData.ts fetchFnRef pattern correctly stabilizes refetch identity without breaking re-fetches — the explicit deps array (not fetchFn identity) still drives the useEffect trigger.
packages/hooks/src/internal/useApiClient.ts New shared hook DRYs up ApiClient construction across four consumer hooks; optional: true variant for useHighlightAuthActions is cleanly typed with overloads.
packages/core/src/SignInWithYouVersionPKCE.ts Permissions wire format changed from repeated requested_permissions[] to single comma-joined requested_permissions (Swift/Kotlin parity). Tests updated to match.
packages/ui/src/components/use-bible-reader-highlights.ts serverColors now reference-stable via equality guard; highlightsInteractive exposed to prevent dead color-swatch rows in copy/share-only integrations.
packages/ui/src/lib/feature-flags.ts HIGHLIGHTS_LIVE flipped to true, enabling highlights for all integrators.
packages/ui/src/components/verse.tsx Theme-aware highlight fill opacity added (full strength light, 0.3 dark) with white verse-number labels in dark mode — mirrors Swift SDK UX baseline.

Sequence Diagram

sequenceDiagram
    participant User
    participant SDK as SDK
    participant Config as Config
    participant Server as Server

    Note over User,Server: Data-exchange grant flow with initiator binding
    User->>SDK: startDataExchange()
    SDK->>Config: saveDataExchangeInitiator(currentUserId)
    SDK->>Server: updateToken([highlights])
    Server-->>SDK: exchange token
    SDK->>User: redirect to dataExchangeUrl

    Note over User,Server: Page navigates — redirect returns
    Server->>SDK: callback URL with granted status
    SDK->>Config: read + clear dataExchangeInitiator
    SDK->>Config: read currentUserId

    alt initiator equals currentUserId
        SDK->>Config: saveGrantedPermissions
        SDK-->>User: status granted
    else mismatch or no initiator
        SDK-->>User: status failure - re-prompt
    end

    Note over User,Server: Sign-in path with early grant stash
    User->>SDK: signIn()
    SDK->>Config: clear stale stash and initiator
    SDK->>Server: PKCE redirect
    Server-->>SDK: pre-code callback with granted_permissions
    SDK->>Config: stashPendingGrantedPermissions(state, grants)
    SDK->>Server: obtainLocation - final callback
    Server-->>SDK: code and tokens
    SDK->>Config: union URL grants + stash + scope minus OIDC
    SDK->>Config: saveGrantedPermissions
Loading

Reviews (8): Last reviewed commit: "docs: consolidate highlights changesets ..." | Re-trigger Greptile

Context used:

  • File used - docs/review-guidelines.md (source)

@changeset-bot

changeset-bot Bot commented Jul 20, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 00b1ff4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 4 packages
Name Type
@youversion/platform-core Minor
@youversion/platform-react-hooks Minor
@youversion/platform-react-ui Minor
vite-react Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/ui/src/components/bible-reader-highlights-machine.ts
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr3-recent-colors branch from 2f47c5f to 8a6ae88 Compare July 21, 2026 19:46
cameronapak and others added 6 commits July 21, 2026 14:54
A pending highlight resumed after sign-in/data-exchange now routes write
failures through the same handling as a user-initiated apply: 401/403
invalidates the permission cache, re-stashes the pending highlight (in its
own scope), and re-opens the permission dialog instead of silently dropping
the user's tap. No unattended redirect loop: the dialog requires a click.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness:
- Gate the color row on auth-provider presence (highlightsInteractive) so
  copy/share-only integrations don't render inert swatches
- Exit permissionDialog to idle and clear pending when auth flips signed-out

Efficiency:
- Keep serverColors reference-stable across equal refetches (no chapter-wide
  verse re-sweep per write)
- Stabilize useApiData refetch identity by holding fetchFn in a ref

Reuse/simplification:
- Export getHttpStatus from core; machine consumes it instead of probing the
  ad-hoc error shape
- Extract resolveAuthToken (core) to de-dupe getAuthToken in highlights and
  data-exchange clients
- Add internal useApiClient hook; refactor five hooks onto it
- Drop derivable WriteOp.reprompt; share scopesEqual from the machine

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
xstate@5.32.5 was published 2026-07-14, inside the 3-day minimumReleaseAge
window, so every CI job failed at pnpm install. Pin to 5.32.4 (published
2026-07-02) and rebuild the lockfile from a fresh resolution, per the
policy's own guidance. Bump back once 5.32.5 ages out.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The callback saved granted_permissions for whoever was in localStorage
when the return page loaded — if user B signed in on another tab
mid-redirect, B inherited A's grant and hasPermission('highlights')
skipped B's consent (review finding).

The redirect leg now records the initiating user's id; the callback
reads-and-clears it and only saves the grant when it matches the current
user. Mismatch or missing initiator fails closed: grant discarded, result
downgraded to failure, URL still cleaned — the flow re-prompts instead of
proceeding as granted. Sign-out clears the initiator. The combined
sign-in+permissions path is unaffected: it returns through the sign-in
callback, which binds the grant to the user from the same ID token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cameronapak
cameronapak force-pushed the claude/ype-1034-pr5-hardening branch from 98f5693 to 905e592 Compare July 21, 2026 20:01
@cameronapak
cameronapak changed the base branch from claude/ype-1034-pr3-recent-colors to main July 21, 2026 20:01
…ing on 5xx

Save the data-exchange initiator before minting the token, and stop clearing the pending stash on apply network/5xx so a sibling permission-lost intent survives.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cameronapak
cameronapak changed the base branch from main to claude/ype-1034-pr3-recent-colors July 22, 2026 16:05
@cameronapak
cameronapak changed the base branch from claude/ype-1034-pr3-recent-colors to main July 22, 2026 16:05
cameronapak and others added 2 commits July 22, 2026 11:08
Keep controlled mode always interactive (YPE-3705) while self-contained
still requires HIGHLIGHTS_LIVE and an auth provider.

Co-authored-by: Cursor <cursoragent@cursor.com>
Take aria-hidden from main; keep this branch's checkmark-tap remove test.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cameronapak cameronapak changed the title Harden highlights: grant binding, 401/403 re-prompt, xstate pin (4/4) WIP (almost ready): Harden highlights: grant binding, 401/403 re-prompt, xstate pin (4/4) Jul 22, 2026
cameronapak and others added 7 commits July 22, 2026 12:31
Stash granted_permissions across the web OAuth hop and seed from token
scope; sync appName/signInPromptMessage onto the UI-bundled core so the
sign-in dialog pitch reaches consumers of the UI package.

Co-authored-by: Cursor <cursoragent@cursor.com>
An abandoned sign-in flow (consent granted, tab closed before the final
code round-trip) left the pre-code granted_permissions stash in
localStorage. A later sign-in by a different user would union that stale
stash into its own grant set and seed it into the optimistic permission
cache under the wrong user. Clear the stash when starting a new flow; it
is only ever produced later during the callback pre-code hop and never
needs to survive a signIn. Mirrors the data-exchange initiator binding.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prevent cross-flow permission cache seeding and leftover data-exchange
initiator binding after mint failure or a new sign-in.

Co-authored-by: Cursor <cursoragent@cursor.com>
The real DELETE /v1/highlights/{id} returns 200 application/json with an
empty body. ApiClient called response.json() on it, which throws, so
every successful delete surfaced as a failed write. The highlights
machine then (correctly, for a real failure) reverted its remove
overlay, repainting the stale highlight until the refetch caught up —
the "removed highlight flashes back" bug.

Read the body as text and treat empty as undefined. Regression coverage
stubs global.fetch and drives the real client + machine + adapter, the
only test shape that can catch response-parsing bugs (client-method
mocks cannot).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Flips the YPE-1034 dark-launch flag. The vapor delete-flash bug is fixed
and regression-tested; the optimistic-overlay staleness trade-off
(local overlay wins over server truth until a refetch reflects the
write) is signed off as acceptable for launch.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Highlight fills now render at 1.0 opacity in light mode and 0.3 in dark
mode (Swift BibleTextView+Rendering parity), replacing the flat 0.35.
Highlighted verse number labels paint white in dark mode so they stay
legible over the fill; both fill and label styles clear on removal.
ADR YPE-642 corrected to record the as-built values.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The logo component hardcoded an English aria-label default, which
bypassed localization. The label is now a required prop; the sign-in
dialog already passes the localized string.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cameronapak and others added 3 commits July 22, 2026 15:56
The stash payload is already JSON; comma-joining the permissions into a
string was a needless round-trip. Unknown shapes fail closed (the stash
only lives across a single in-flight OAuth redirect).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The learned-preferences blocks were one developer's session notes, not
repo guidance.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six stacked-PR changesets merge into highlights-server-backed.md: one
consumer-facing entry announcing server-backed highlights as live, with
an overt callout that earlier releases accidentally shipped a demo-only
localStorage implementation. Controlled-mode and aria-label entries stay
separate; the aria-label entry now covers the required-label change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@cameronapak cameronapak changed the title WIP (almost ready): Harden highlights: grant binding, 401/403 re-prompt, xstate pin (4/4) Server-backed highlights go live: auth hardening + delete-flash fix Jul 22, 2026
@cameronapak cameronapak self-assigned this Jul 22, 2026
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.

1 participant