diff --git a/.agents/skills/kitcn/references/features/auth.md b/.agents/skills/kitcn/references/features/auth.md index 3fba69452..712fbf6f4 100644 --- a/.agents/skills/kitcn/references/features/auth.md +++ b/.agents/skills/kitcn/references/features/auth.md @@ -363,7 +363,7 @@ All from `kitcn/react`: |------|---------|-------------| | `useAuth()` | `{ hasSession, isAuthenticated, isLoading }` | Full auth state | | `useMaybeAuth()` | `boolean` | Has token (optimistic, may not be verified) | -| `useIsAuth()` | `boolean` | Server-verified authentication | +| `useIsAuth()` | `boolean` | Server-verified authentication (with `optimisticAuth`, also during the optimistic window) | | `useAuthGuard()` | `() => boolean` | Guard mutations, returns true if blocked | | `useConvexAuthRecovery()` | `{ recover, status, error }` | Rebind Convex auth after a transient token failure | @@ -389,7 +389,7 @@ All from `kitcn/react`: | Component | Renders when | |-----------|-------------| | `MaybeAuthenticated` | Has session token (optimistic) | -| `Authenticated` | Server-verified authenticated | +| `Authenticated` | Server-verified authenticated (with `optimisticAuth`, also during the optimistic window) | | `MaybeUnauthenticated` | No session token (optimistic) | | `Unauthenticated` | Server-verified not authenticated | @@ -414,16 +414,34 @@ All from `kitcn/react`: ``` `optimisticAuth` only opens auth-bound query gates for a held, unexpired JWT; -expired, opaque, and refused tokens stay closed. Enable +expired, opaque, and refused tokens stay closed. The window ends at the Convex +client's first auth result; use one `optimisticAuth` setting per client; no +optimism over a client the Start loader authenticated. Enable `onTokenIdentityChange` to refuse a JWT whose `sub` or `sessionId` differs from -the document identity before Convex or HTTP sees it. The client closes before -the callback runs, so reload the document there. +the document identity (whatever its `exp`) before Convex, HTTP or the Start +loader sees it. On a trip, every mounted provider hands out no token and +publishes unauthenticated; each guarded one closes its client, then calls the +callback once, so reload the document there. Browser only, page-wide: later +providers start tripped, and a guarded one that mounts or shows again on a +tripped page also closes its client and calls the callback once. Enabling the +guard on a tripped page delivers that close and callback once too; sign-in +mutations fail with `TOKEN_IDENTITY_CHANGED`. For multiple provider mounts, pass `tokenIdentityBaseline` as -`sub|sessionId`, or a getter returning the document's current identity. The -getter is checked for every admission, cached tokens included. +`sub|sessionId`, or a getter returning the document's current identity. Every +admission (SSR, fresh and cached tokens, sign-in, HTTP, Start loader) binds a +token to the page identity, the provider's own baseline and every mounted +provider's current getter answer; an opaque session token is only exchanged, +never handed to Convex. Held tokens are reconciled when a provider joins the +page and at every admission. A page that never enables the guard is +unchanged; once a guarded provider establishes the page identity it persists +until reload, binding every provider and the Start loader, even after that +provider unmounts. Two kitcn versions or revisions on one page (dev HMR +across revisions included) are unsupported: no shared page identity until +reload. `onTokenIdentityAdmitted(token)` observes admitted JWTs so the app can update -that shared baseline. All three identity options require +that shared baseline. Admission rechecks the baseline after the callback before +publishing or handing out the token. All three identity options require `onTokenIdentityChange`. For `@convex-dev/auth` (React Native): diff --git a/.changeset/harden-auth-identity-guard.md b/.changeset/harden-auth-identity-guard.md new file mode 100644 index 000000000..7056763df --- /dev/null +++ b/.changeset/harden-auth-identity-guard.md @@ -0,0 +1,19 @@ +--- +"kitcn": patch +--- + +## Patches + +- Fix `onTokenIdentityChange` letting a token of another user or session into + the auth store, Convex, cRPC HTTP headers or the TanStack Start loader: the + SSR token, restored sessions, sign-in tokens, JWTs without `exp` and + concurrent first tokens are all held to the page's identity and every + mounted provider's current `tokenIdentityBaseline` getter, including changes + made by `onTokenIdentityAdmitted` before publication. +- Fix an identity guard trip staying local to one provider: every mounted + provider now hands out no token and publishes unauthenticated, each guarded + one closes its client and calls `onTokenIdentityChange` once, including a + guard enabled after the trip, and sign-in + mutations fail with `TOKEN_IDENTITY_CHANGED` until the page reloads. +- Fix the `optimisticAuth` window reopening after Convex refused a token: it + now ends at the Convex client's first auth result. diff --git a/docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md b/docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md new file mode 100644 index 000000000..c05f26281 --- /dev/null +++ b/docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md @@ -0,0 +1,851 @@ +# Harden the optimistic auth identity guard + +Objective: +Fix the token identity guard and optimistic gate defects still present at +#473's head (fcbd2f84): one token gate, page-wide trip, client-scoped +optimistic window; done when every listed test fails at fcbd2f84 and passes. + +Goal plan: +docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md + +Template: +docs/plans/templates/task.md + +Primary template: +docs/plans/templates/task.md + +Applied packs: +- package-api (docs/plans/templates/packs/package-api.md) +- docs (docs/plans/templates/packs/docs.md) +- agent-native (docs/plans/templates/packs/agent-native.md) + +Task source: +- type: single-PR bug-fix follow-up, one package +- id / link: follow-up to https://github.com/udecode/kitcn/pull/473 (merged + as 16e0bb46, released in 0.33.6). This PR: https://github.com/udecode/kitcn/pull/475 (#475). Branch + `feat/optimistic-auth-hardening-v2`, based on `main` 126f3e99. The work + was built on #473's head fcbd2f84 (branch `feat/optimistic-auth-hardening`, + last 67119913) and carried over as its net diff; the squash differs from + fcbd2f84 only in #473's own plan files. +- title: fix(auth): harden the optimistic auth identity guard +- acceptance criteria: every defect in the case matrix has a test that fails + at fcbd2f84 and passes here; nothing already fixed at fcbd2f84 is redone; + #473's own tests keep passing; props-off behaviour unchanged. +- caveat: #473 was adopted by its maintainer (commits 29f558fb, 22855d78, + ea5e442d, fcbd2f84). This PR carries the hardening from an independent + review of #473 (seven rounds, kept on `wip/473-hardening-rounds`, cea25963) + that his head does not have. +- likely files: `auth-client/convex-auth-provider.tsx`, + `auth-client/client-settlement.ts`, `react/token-gate.ts`, + `react/identity-guard-registry.ts`, `react/auth-mutations.ts`, + `react/context.tsx`, `auth-start/index.ts`, their tests; auth docs and the + kitcn skill mirror. +- browser surface: none rendered. +- root-cause layer: token cache writes and hand-outs that skipped identity + admission; trip state local to one provider; refusal memory per token. + +Timed checkpoint: +- requested duration: N/A; no duration requested. +- semantics: N/A. +- initial confidence score: N/A. +- improvement loop: N/A. +- final score / loop closure: N/A. + +Completion threshold: +- Every case-matrix test fails at fcbd2f84 (probe recorded below) and passes + here; #473's tests pass unchanged. +- `bun --cwd packages/kitcn typecheck` and build, `bun lint:fix` (no source + change), `bun check` (only the pre-existing `fixtures:check` drift) and + `test:verify` run with exact results. +- Closure is not claimed here: push, PR creation and live compliance + read-back are reserved by the requester, and `bun check` stops on a + pre-existing fixture drift; each is recorded as blocked or handed-off. +- `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md` passes. + +Verification surface: +- Probe: this branch's `convex-auth-provider.test.tsx` run against + fcbd2f84's source (a temporary stand-in for the new trip module), then the + same file here. +- Focused: provider, context, use-query-options, client, auth-start retry, + auth-mutations tests. +- `bun --cwd packages/kitcn typecheck`, build, `bun lint:fix`, `bun check`, + `bun run test:verify`, `bun run intent:validate`, `bun run intent:stale`, + MDX compile of `client.mdx`. +- `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md` + +Constraints: +- Keep #473's maintainer changes (ea5e442d) where they already fix a defect; + keep his plan files untouched. +- Props-off behaviour unchanged (cRPC HTTP headers already route through the + fetcher in #473). +- When a GitHub PR is in scope, this plan owns exactly one PR. +- A task-run PR body must include `๐Ÿงญ Task plan: docs/plans/.md`; the + plan must exist at the PR head and identify the exact PR before + autoclosure. + +Boundaries: +- Source of truth: the defects reproduced at fcbd2f84 (case matrix) and the + guarantees #473 documents. +- Allowed edit scope: the files under Task source; docs and skill mirror; one + changeset; this plan. +- Browser surface: none. +- GitHub issue sync: N/A; no issue. +- Non-goals: #473's plan files, fixture drift repair, the app-set + `Authorization` header, the opaque-token exchange path. + +Output budget strategy: +- Test and gate output goes to `/tmp/h-*.log`; only counts, failing test + names and exit codes are read back. + +Blocked condition: +- Stop if a listed test passes at fcbd2f84 (the defect is already fixed) or + if #473's own tests fail with this change. + +Task state: +- task_type: bug fix (one package) +- task_complexity: non-trivial +- current_phase: closeout +- current_phase_status: verified source; final delivery externally handed off +- next_phase: push, exact-head verification, protected approval +- goal_status: implementation and local proof complete; landing approval external + +Current verdict: +- verdict: source ready after two additional behavioral repairs +- confidence: 95% after focused, built, independent and full-gate proof +- next owner: autoclosure, then protected code-owner/last-push approver +- reason: both additional defects reproduced and repaired without public API + changes; final exact-head delivery receipts are required before landing. + +Implementation readiness: +- verdict: ready +- exact owner: one admission, page trip and identity + (`react/identity-guard-registry.ts`), store-level wrappers + (`react/token-gate.ts`), client settlement + (`auth-client/client-settlement.ts`), provider admission and gate +- contradiction status: reconciled with ea5e442d (see Decisions) +- source-listed cases complete: yes + +Pre-solution issue challenge: +- reporter claim: independent review of #473 found tokens of another + identity reaching the store or Convex, trips that do not hold, and refused + tokens reopening the optimistic gate. +- suggested diagnosis or fix: one gate for every token write and hand-out; a + page-wide trip; an optimistic window per Convex client. +- repro ladder: + - tests / source-level repro: this branch's provider tests run against + fcbd2f84's source: 38 fail (list under Findings); 52 pass. + - repo-owned automated browser or integration proof: N/A; provider and + loader harnesses own the behaviour. + - Browser plugin: N/A; no rendered UI. + - screenshot / visual proof: N/A. +- reproduction verdict: reproduced (38 failing tests at fcbd2f84). +- validity verdict: valid; partially already fixed upstream (close before + callback; admission-time getter reads; retry mock isolation; exp-less + refresh/HTTP refusal), dropped from scope. +- best long-term fix boundary: the token gate and page-level state. +- harsh honest feedback: the surface is wide; the tests are the contract. +- hard-stop decision: proceed. + +Completion rule: +- Do not call `update_goal(status: complete)` while any required checklist item + remains unchecked. If an item does not apply, check it and add `N/A: `. +- Do not call `update_goal(status: complete)` until every completion threshold + above is satisfied, final handoff evidence is recorded, and + `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md` passes. +- Do not create hook state for this goal. This file plus the active goal are the + durable state. + +Start Gates: +| Gate | Applies | Evidence | +|------|---------|----------| +| Timed checkpoint parsed | no | N/A: no duration requested | +| Walkthrough baseline for possible UI change | no | N/A: no rendered UI | +| Skill analysis before edits | yes | `task`, `autogoal` (task + package-api, docs, agent-native), `changeset`; not `major-task` (one package, bug fixes) | +| Active goal checked or created | yes | This plan; Codex goal tools unavailable (Claude Code); `check-complete.mjs` is the proof | +| Source of truth read before edits | yes | #473 head fcbd2f84 diff (ea5e442d) and review findings read | +| Exact per-PR task ownership | yes | Not-yet-created follow-up PR; PR number added when opened | +| GitHub comments and attachments read | yes | #473 adoption commits and plans read locally; no issue | +| Video transcript evidence required | no | N/A: none | +| Pre-solution issue challenge required | yes | Recorded above | +| Reproduction verdict before implementation | yes | 38 tests fail at fcbd2f84 | +| Repro escalation ladder selected | yes | Focused tests; higher rungs N/A | +| Suggested fix reviewed against durable boundary | yes | Single gate and page state owners | +| `docs/solutions` checked for non-trivial existing-code work | yes | No entry covers these owners | +| TDD decision before behavior change or bug fix | yes | Red at fcbd2f84 (probe), green here | +| Branch decision for code-changing task | yes | `feat/optimistic-auth-hardening` from fcbd2f84 | +| Release artifact decision | yes | New patch changeset `.changeset/harden-auth-identity-guard.md` (#473's draft released in 0.33.6; see Decisions) | +| Browser tool decision for browser surface | no | N/A: no browser surface | +| Commit / PR expectation decision | yes | For verified code-changing work, default is commit, push, and PR because `task` explicitly requires it; N/A only for explicit user decline, no local patch, analytical/blocked/inconclusive work, or recorded blocker. | +| Task-style PR body decision | yes | Draft in the #459 task style | +| Task-plan PR body evidence | yes | Draft has one `๐Ÿงญ Task plan:` line naming this file | +| GitHub issue sync expectation decision | no | N/A: no issue | +| Output budget strategy recorded | yes | Logs to `/tmp`, summaries only | +| Package/API pack selected | yes | Runtime behaviour of public props | +| Public surface or package boundary identified | yes | No new public export; new internal modules; `AuthMutationError` code `TOKEN_IDENTITY_CHANGED` | +| Convex entry/import graph impact identified | yes | Client and loader entries only; the loader imports only dependency-free `identity-guard-registry.ts` and `client-settlement.ts` | +| CLI/scaffold/generated impact identified | no | N/A: none | +| Release artifact path selected | yes | New `.changeset/harden-auth-identity-guard.md` (patch) | +| `changeset` skill loaded when `.changeset` is required | yes | Loaded; no unreleased changeset on the base, so a new file (see Decisions) | +| Package build / fixture impact decision recorded | yes | Build run; no scaffold change | +| Docs pack selected | yes | `www/content/docs/auth/client.mdx` changed | +| Docs guidance loaded | yes | Doc guidelines and `.agents/AGENTS.md` Docs rules | +| Docs lane selected | yes | Incidental reference docs | +| Target docs and nearest sibling docs read | yes | Auth client hooks, components and #473's optimistic auth section | +| Docs style doctrine read | yes | Latest-state voice | +| Documented source owner identified | yes | Provider JSDoc and tests | +| Agent-native pack selected | yes | kitcn skill reference changed | +| Agent-facing action surface identified | yes | Configuring the provider props from the skill | +| Source rule versus generated mirror boundary identified | yes | Source `packages/kitcn/skills/kitcn/**`, mirror via `bun tooling/sync-kitcn-skill.ts` | +| Installed-skill lock versus local-rule owner identified | yes | Repo-owned skill; no lock change | +| `agent-native-reviewer` loaded or waiver recorded | yes | Loaded; PASS, no findings (route unchanged, source edited, mirror synced) | + +Work Checklist: +- [x] If a duration was requested, it is recorded as minimum active work unless + explicitly marked hard stop; when no better metric exists, initial and + final confidence scores are recorded. N/A: no duration requested. +- [x] Objective includes outcome, completion threshold, verification surface, + constraints, boundaries, and blocked condition. +- [x] Task source classified with source type, id/link, title, task type, + acceptance criteria, caveats, likely files/routes/packages, browser + surface, and root-cause layer. +- [x] Every GitHub PR in scope has its own task plan. This plan owns one exact + PR, owns a not-yet-created PR slice, or records N/A because no PR is in + scope; a batch plan is not used as a substitute. +- [x] Required video or screen-recording evidence is cached/read as normalized + `` XML, or marked N/A with reason. N/A: none. +- [x] For public GitHub bug reports, behavior claims, technical diagnoses, or + suggested fixes, reporter claims are challenged before implementation + with a recorded verdict: `valid`, `not reproduced`, `invalid`, + `wont-fix`, `partially valid`, or `platform limitation`. Feature, docs, + support, or cleanup requests with no bug claim may mark reproduction + `N/A` with reason. +- [x] Repro escalation ladder followed for bug/behavior claims: focused + test/source-level repro first when applicable; existing repo-owned + automated browser or integration proof next when available and useful as + executable coverage; the repo-approved Browser tool next when tests or + automation cannot reproduce or cannot model the surface honestly; + screenshot or explicit visual-proof waiver when visual/native state + matters. +- [x] Hard-stop rule followed for bug/behavior claims: no code when the issue + is not reproduced, invalid, or won't-fix; partial validity pivots to the + best long-term fix and records what was wrong or incomplete in the + issue's proposed path. +- [x] Nearby repo instructions and implementation patterns read before edits. +- [x] Source-listed case matrix is complete and every contradiction has an + owner, harness, and verdict before mutation. +- [x] Readiness is classified `ready`, `repair-source`, `major`, `blocked`, or + `invalid` with evidence. +- [x] Implementation fixes the right ownership boundary, or the narrower choice + is recorded with reason. +- [x] Release artifact requirement recorded: active changeset, new changeset, or + N/A with reason. +- [x] Final handoff shape decided: bug/feature/testing/batch/review/GitHub + requirements, PR body sync, and issue sync when applicable. +- [x] Commit/PR handling recorded for code-changing work: commit and PR + completed, no local patch, user explicitly declined, or blocker recorded. + "User did not separately ask for a PR" is not a valid blocker. +- [x] PR body shape recorded: PR #270 emoji task-style body used, N/A reason + recorded, or blocker recorded. +- [x] PR task evidence recorded: body includes `๐Ÿงญ Task plan: ...`, the plan + exists at the PR head, and it identifies the exact PR before autoclosure. +- [x] Branch handling recorded for code-changing work: dedicated branch used, + new branch needed, or N/A with reason. +- [x] Local-env-rot retry policy recorded for any surprising repo-wide failure: + reinstall/rerun evidence or N/A with reason. +- [x] Workspace authority recorded: every proof command names the cwd/tool that + owns the changed behavior. +- [x] Output budget discipline recorded and followed: broad searches are + scoped, capped, counted, or artifacted instead of streamed into goal + context. +- [x] High-risk note recorded for public API, runtime, package-boundary, + browser behavior, agent-action, or command-contract changes, or marked + N/A with reason. +- [x] Review/autoreview target selected from actual diff state for non-trivial + implementation work, or marked N/A with reason. +- [x] Agent-native review decision recorded for `.agents/**`, `.claude/**`, + `.codex/**`, skills, hooks, commands, prompts, or user-action tooling. +- [x] Package/API pack: public API, package boundary, export, and release-artifact impact are recorded. +- [x] Package/API pack: release artifact matrix is applied: `.changeset` or explicit no-artifact reason. +- [x] Package/API pack: `.changeset` work loads `changeset` and follows its package/version/prose rules. +- [x] Package/API pack: no-artifact decisions state why the diff has no published package user-visible delta from `main`. N/A: a changeset is added. +- [x] Package/API pack: compatibility, migration, or hard-cut decision is explicit when public shape changes. +- [x] Package/API pack: affected Convex static import graphs stay narrow and + plugin/per-module boundaries are used where appropriate. +- [x] Package/API pack: CLI commands remain deterministic, `--json` capable, + and non-interactive with explicit confirmation bypass when relevant. N/A: no CLI. +- [x] Package/API pack: docs and `packages/kitcn/skills/kitcn/**` stay + current-state synchronized when public guidance changes. +- [x] Package/API pack: package-owned typecheck/build/test proof is recorded or marked N/A with reason. +- [x] Package/API pack: `packages/kitcn` build, fixture sync/check, or other owning package proof is recorded when required. +- [x] Docs pack: docs lane, target docs, nearest sibling docs, and source owner are recorded. +- [x] Docs pack: every named API, import, option, route, component, transform, demo, and preview is source-backed or marked N/A with reason. +- [x] Docs pack: docs use current-state reference voice, not changelog voice. +- [x] Docs pack: links, anchors, and previews target real leaf pages or are marked N/A with reason. +- [x] Agent-native pack: source-of-truth rule files are edited instead of generated skill mirrors. +- [x] Agent-native pack: the changed agent action is discoverable from the skill/rule text. +- [x] Agent-native pack: generated mirrors are synced when `.agents/rules/**` changed, or N/A reason is recorded. N/A: no `.agents/rules/**` change. +- [x] Agent-native pack: installed skills are changed only through + `npx skills add/update/remove`; local rules/templates/helpers stay source-owned. N/A: no installed skill changed. +- [x] Agent-native pack: routing, required receipts, placeholder failure, + completion representability, and forbidden behavior have eval/smoke rows. N/A: reference prose only. +- [x] Agent-native pack: accepted agent-native review findings are fixed or explicitly rejected with reason. + +Completion Gates: +| Gate | Applies | Required action | Evidence | +|------|---------|-----------------|----------| +| Named verification threshold | yes | Run the command, proof, source audit, or artifact check named in this plan | See Verification evidence | +| Exact per-PR task ownership | yes | Record the exact PR and dedicated plan, or the not-yet-created single-PR slice | Exact PR https://github.com/udecode/kitcn/pull/475; body names this plan and fetched head contains it | +| Pre-solution issue challenge verdict | yes | Record reporter claim, suggested fix, repro verdict, validity verdict, durable boundary, and hard-stop/pivot decision before implementation | Valid; reproduced | +| Repro escalation ladder | yes | For bug/behavior claims, record test/source-level, automated browser/integration, Browser, and screenshot/visual-proof outcomes or N/A/blocker reasons before `not reproduced` | Unit and integration tests; other rungs N/A | +| Bug reproduced before fix | yes | Record failing test/repro or N/A with reason | 38 fail at fcbd2f84 | +| Targeted behavior verification | yes | Run focused test/proof for changed behavior or record N/A | Focused suites pass (see evidence) | +| TypeScript or typed config changed | yes | Run relevant typecheck | `bun --cwd packages/kitcn typecheck` exit 0 | +| Package exports or file layout changed | yes | Run the relevant package build before final verification and keep generated updates | Build exit 0; three new internal modules, no export change | +| Package manifests, lockfile, or install graph changed | no | Run `bun install` and relevant package checks | N/A: none | +| Agent rules or skills changed | yes | Run `bun install` and verify generated skill sync | Skill source changed; mirror synced; intent validate/stale pass; no `.agents/rules/**` change | +| Workspace authority proof | yes | Run verification in the owning repo/package/app/route/tool and record cwd; do not count the wrong workspace as proof | All commands at the kitcn root on this branch | +| Browser surface changed | no | Capture Browser Use proof or record explicit waiver/blocker | N/A | +| Browser final proof | no | Attach screenshot or exact browser verification caveat when browser proof applies | N/A | +| UI walkthrough | no | If UI or rendered output changed, run `.agents/skills/walkthrough/SKILL.md` after final proof and show annotated images in the final handoff; otherwise record N/A | N/A: no rendered output | +| Scaffold or fixture output changed | no | Run `bun run fixtures:sync` and `bun run fixtures:check`, or record N/A | N/A for scaffold code: no scaffold change. Snapshots resynced in a separate commit because the upstream templates drifted after 0.33.6's CI; `fixtures:check` exit 0 | +| Package behavior or public API changed | yes | Add a changeset or record why no changeset applies | New patch changeset | +| Docs and kitcn skill sync changed | yes | Keep `www/**` and `packages/kitcn/skills/kitcn/**` in sync, or record N/A | `client.mdx` and skill `auth.md` updated and synced | +| Docs or content changed | yes | For docs-heavy work, use `--template docs`; for incidental docs, verify source-backed claims, links, examples, and rendered output or record N/A | Claims checked against source and tests; MDX compiles | +| High-risk mini gate | yes | For public API/runtime/package-boundary/browser/agent-action/command-contract changes, record realistic failure mode, proof plan, and why the chosen boundary is right; otherwise N/A | Failure mode: a legitimate token refused or a foreign one admitted; proof: the case matrix plus #473's tests; boundary: one gate every write goes through | +| Agent-native review for agent/tooling changes | yes | For `.agents/**`, `.claude/**`, `.codex/**`, skills, hooks, commands, prompts, or user-action tooling, load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted/actionable findings, or record N/A | PASS, no findings | +| Local install corruption suspected | no | Run `bun install` once, rerun the exact failing command, or record N/A | N/A: pinned Bun 1.3.9 used throughout | +| Commit created | yes | For verified code-changing work, stage the entire current checkout per repo policy and create a commit; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | Runtime, docs and plan commits on this branch | +| PR create or update | handed-off | For verified code-changing work, run `check`, push, create or update the PR, and sync PR body to the task-style final handoff; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | Exact PR #475 updated by autoclosure; no history rewrite; final receipts recorded externally after versioned evidence push | +| Task-style PR body verified | handed-off | Verify the PR body with `gh pr view --json body`; it must preserve auto-release blocks when applicable, must not include a current-PR self-link, and must use the PR #270 emoji format: `๐Ÿ› Fixes ...`, `๐ŸŸข 95-100% confidence`, `Phase / ๐Ÿงช Tests / ๐ŸŒ Browser` table, and bold emoji Outcome/Caveat/Design/Verified sections | Pending: draft body local; `gh pr view --json body` read-back after it is applied | +| PR task evidence verified | yes | Verify body plan line, plan at PR head, and exact PR ownership | Exact PR #475 verified COMPLETE at f9016674; body names this existing plan | +| PR proof image hosting | no | If PR body needs browser proof, replace local image paths with hosted GitHub URLs or record N/A | N/A: no images | +| GitHub issue sync-back | no | Post concise issue sync after PR exists, or record N/A/blocker | N/A: no issue | +| Final handoff contract | yes | Fill the final handoff fields below with exact PR/issue/confidence/tests/browser/outcome/caveats/design/verification content or N/A reason | Filled below | +| Final lint | yes | Run `bun lint:fix` or scoped equivalent | `bun lint:fix` exit 0, no source change | +| Output budget discipline | yes | Verify no unbounded high-volume command output was streamed, or record the accidental output and recovery | Summaries only | +| Timed checkpoint | no | If duration was requested, keep improving until elapsed, then finish the current loop cleanly; otherwise N/A | N/A | +| Autoreview for non-trivial implementation changes | yes | Load `.agents/skills/autoreview/SKILL.md`; use dirty local `--mode local`, branch/PR `--mode branch --base `, or committed slice `--mode commit --commit ` until no accepted/actionable findings, or record N/A for docs-only/trivial/no local patch | 2026-10-01 branch review at 90a1653e vs main126f3e99: TruffleHog clean, one full bundle, exit0, no accepted/actionable P0 findings | +| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md` | `[autogoal] complete` | +| Public API / package boundary proof | yes | Source-audit public API, exports, and package boundary impact | No export change; `TOKEN_IDENTITY_CHANGED` error code on sign-in mutations after a trip | +| Convex bundle/import proof | yes | Audit affected function-entry static graphs or record N/A | Loader entry imports two dependency-free modules | +| CLI/scaffold/generated proof | no | Prove command contract and regenerate owned output or record N/A | N/A | +| Release artifact classification | yes | Record whether the change is published package behavior/API/types/config/runtime or no published user-visible delta | Published runtime behaviour | +| Published package changeset | yes | If published package users see a delta, load `changeset` and add/update one `.changeset/*.md` per package | One `kitcn` patch changeset | +| No release artifact | no | If no artifact is needed, record the exact reason: internal-only, docs-only, agent-only, test-only, or no user-visible delta from `main` | N/A: changeset added | +| Package typecheck/build/test | yes | Run owning package checks or record N/A with reason | typecheck 0, build 0, focused pass | +| Fixture/scaffold generation | no | Run `bun run fixtures:sync` and `bun run fixtures:check` when scaffold output changed, otherwise N/A | N/A: no scaffold change | +| Docs/package skill sync | yes | Synchronize current-state public guidance or record N/A | Synced; intent validate and stale pass | +| Docs source-backed claim audit | yes | Verify docs claims against current source or record N/A | Claims match provider source and tests | +| Docs links / routes / previews | no | Verify leaf links, routes, anchors, and preview names or record N/A | N/A: no new links | +| Docs MDX/content parser | yes | Run the relevant `www` docs parser/build for MDX/content changes, or record N/A | `client.mdx` compiles with `@mdx-js/mdx` | +| Kitcn docs sync | yes | If `www/**` changed, update matching `packages/kitcn/skills/kitcn/**` content or record N/A | Skill `auth.md` mirrors the page | +| Agent source / generated sync | yes | Run `bun install` when `.agents/rules/**` changed and verify generated mirrors | Mirror synced by its owner command | +| Installed lock audit | no | Verify expected lock entries and removed skills through CLI-managed state | N/A: no installed skill changed | +| Agent action discoverability | yes | Source-audit the skill/rule path an agent will read | `SKILL.md` routes to `references/features/auth.md` | +| Helper and template smoke | no | Syntax-check helpers and prove incomplete failure/completed representation when applicable | N/A: none changed | +| Agent-native review | yes | Load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted findings, or record N/A | PASS, no findings | + +Phase / pass table: +| Phase | Status | Evidence | Next | +|-------|--------|----------|------| +| Intake and source read | done | #473 head and review history read | implementation | +| Implementation | done | Port of the hardening onto fcbd2f84, reconciled with ea5e442d | verification | +| Verification | done | Probe at fcbd2f84, focused suites, full gate | closeout | +| Commit / PR / GitHub sync | handed-off | Source commits plus final proof snapshot on exact #475 | post-push receipts | +| Closeout | blocked | Protected code-owner and last-push approval; no bypass | authorized reviewer | + +Findings: +- Probe at fcbd2f84 (this branch's provider tests, 44 in the `identity guard + admission` block, against #473's source; a temporary inert stand-in for + the new trip module): 38 fail, 6 pass. Failing, by defect: + - held SSR token not admitted: `a held SSR token of another identity never + opens the optimistic gate and trips the guard`, `a held SSR JWT of + another identity is withheld whatever its exp`; + - store token opens the gate without admission: `a token seeded into the + store opens the optimistic gate only if the guard would admit it`; + - restore not admitted: `an opaque persisted session token is not + restored while an identity is established`, `a persisted JWT of another + identity is not restored`; + - concurrent first tokens: `concurrent first tokens: the losing identity + is never cached`; + - identity-less JWT: `with an identity established, a JWT without one is + refused`, `an admitted identity refuses a later JWT without one`, + `before any identity, a JWT without one is handed out and announced + without setting it`; + - sign-in-returned token not admitted: `a JWT a sign-in returns for + another identity is refused and trips the document`, `a sign-in + returning a JWT of another identity fails whatever its exp`, `a sign-in + fails if the document moved identity before authenticated is published`; + - Start loader not held to the page: `the Start loader hands no token to a + fresh client after a trip`, `the Start loader refuses a token of another + identity than the document admitted`, `the Start loader refuses another + identity before the provider fetches, from its baseline or held token`, + `the Start loader refuses a JWT of another identity whatever its exp`; + - trip does not publish unauthenticated or hold: `a trip publishes a + terminal unauthenticated state that Convex cannot reopen`, `nothing + writes a token back after a trip, not even the hydration fallback`; + - trip is local to one provider: `two mounted providers sharing a client: + tripping one quarantines the other`, `a later provider over a tripped + client starts tripped`, `a remount with a fresh client cannot reopen the + document after a trip`, `an in-flight fetch in an unguarded provider + hands out nothing after a trip`, `a trip from the Start loader calls the + mounted guarded provider once`, `a trip calls every mounted guarded + provider once, even when one callback throws`; + - sign-in on a tripped page succeeds: `a sign-in on a tripped document + surfaces an error and publishes nothing`, `a trip during a sign-in, + sign-up or social sign-in fails it before anything is published`, + `waiting for auth after a sign-in fails at once when the document trips`; + - refused token reopens the optimistic gate: `a token Convex refused never + reopens the optimistic gate, even after another refusal`, `a refusal + hidden behind the SDK's transparent retry never reopens the gate`, `a + refused token after a prior confirmation never reopens the gate`, `no + number of refusals lets a refused token reopen the gate`, `a remount + over a client that already reported auth gets no optimism`; + - settlement: `a client's auth result reported before React commits still + ends its optimistic window`, `one provider's refusal ends the optimistic + window of another over the same client`, `a client the Start loader + authenticated gets no optimistic window; a fresh one does`; + - stale publication: `a trip in a descendant effect is not overwritten by + a stale optimistic publication`, `a settlement in a descendant effect is + not overwritten by a stale optimistic publication`; + - mechanism test (not a defect): `the settlement wrapper is installed only + with optimisticAuth, once`. + Tests that trigger the trip through the new module directly (`a trip + during a sign-in...`, `waiting for auth...`, `a trip in a descendant + effect...`) have public-API counterparts in the same list (`a sign-in on a + tripped document...`, the two-provider test). +- Passing at fcbd2f84, dropped from scope as already fixed or never broken: + `a throwing onTokenIdentityChange still closes the client` (ea5e442d closes + before the callback; our duplicate test removed, his kept), `a JWT of + another identity is refused whatever its exp: refresh and HTTP` (his + admission decodes identity independent of `exp`). Controls that pass + before and after: `a held SSR token of the baseline identity still opens + the optimistic gate`, `a persisted JWT of the established identity is + restored`, `losing the local session does not end a fresh client + optimistic window`, `a JWT without exp of the established identity is + handed out but never opens the optimistic gate`. +- Also already fixed at fcbd2f84 and kept as his: retry mock isolation + (29f558fb, kept over our `spyOn` version), admission-time getter reads (his + guard never reads the getter at mount; kept, and the Start loader reads a + getter baseline only at admission), the `DecorateMutation` JSDoc and + `ConvexOptimisticUpdateOption` `Value` typing. + +Decisions and tradeoffs: +- Classification: `task`, not `major-task` (one package, bug fixes). +- Reconciliation with ea5e442d: his close-before-callback kept in spirit; + ours is strictly stronger (a throwing callback is caught and logged, and + one throwing subscriber cannot stop another provider's). His getter + semantics kept (no getter read at mount; his test asserts it); the held + SSR check reads the getter only when there is a held token to admit. His + naming (`decodeTokenSubjectSessionIdentity`, + `resolveTokenIdentityBaseline`, `currentDocumentIdentity`) adopted. +- Changeset: #473's draft was released in 0.33.6 (126f3e99), so per the + `changeset` skill this PR adds a new patch changeset, + `.changeset/harden-auth-identity-guard.md` (`## Patches`, three `Fix` + bullets). Until the rebase the lines were appended to #473's unreleased + draft (round H1, H8). + +Implementation notes: +- Ported from `wip/473-hardening-rounds` (cea25963): `react/token-gate.ts`, + `react/identity-guard-trip.ts`, `auth-client/client-settlement.ts`, + provider, `react/context.tsx`, `react/auth-mutations.ts`, + `auth-start/index.ts`, and their tests (appended to #473's provider test + file as the `identity guard admission` block). + +Review fixes: +- Earlier review rounds that produced the ported code are recorded on + `wip/473-hardening-rounds` in `docs/plans/473-optimistic-auth-gate.md`. +- Round H1 (Codex lanes: standards, spec, adversarial on 66da0e78). Red + logs kept outside the repo (`kitcn-1596-bodies/h1-red.log`, `h2-red.log`). + - H1 fixed (309f876b), behavioural at the artifact level: `auth/start` is + a separate tsdown build group from `auth/client` and `react`, so each + bundle had its own trip, page identity and settlement state. All of it + (plus admissions and the settlement wrapper marker) now lives in one + `globalThis` registry under `Symbol.for('kitcn.identityGuard.v1')` + (`react/identity-guard-registry.ts`); browser-only semantics unchanged. + New `identity-guard.entrypoints.integration.test.tsx` imports + `kitcn/auth/client`, `kitcn/auth/start` and `kitcn/react` from the + build: loader settlement seen by the provider; provider page identity + holds the loader; a provider trip stops the loader (3 red, then green). + No other new module-level state remains. + - H2 fixed (6346b55d), behavioural: the Start loader requires the + recorded page identity and every getter's current answer (read at + admission). Test `the Start loader holds a token to both the recorded + page identity and the current getter`. + - H3 fixed (6346b55d), behavioural: provider admission and the held SSR + check honour the recorded page identity. Test `a sibling provider + without a baseline cannot admit another identity after the page + admitted one`. + - H5 fixed (6346b55d), behavioural: the page identity is recorded in a + layout effect (commit), before passive effects hand Convex a fetcher; + it is never unrecorded. Test `an identity recorded in an abandoned + render does not quarantine the page`. The render-time settlement + wrapper install and admission registration stay, with a comment on why + a discarded render is harmless. + - H4 fixed (fc43523e), behavioural: auth-state publication reads the + store's token at the write. Test `auth-state publication reads the held + token at the write`. + - H6 fixed (fc43523e), behavioural: `publishToken` rechecks the trip after + admission (which runs `onTokenIdentityAdmitted`). Test `a trip inside + onTokenIdentityAdmitted leaves no token in the store`. + - H7 fixed (fc43523e), behavioural: only a structural JWT with a future + `exp` opens the optimistic gate. Test `a token that is not a JWT never + opens the optimistic gate, whatever its payload`. + - H8 fixed (the round H1 plan commit): changeset folded (see Decisions). + - H9 fixed (body): getter reads happen at admission, including the + initial SSR admission. +- Round H2 (Codex lanes: adversarial and spec on 3cb36826). The remaining + P1s shared one cause: admission was split between per-provider + constraints and the page registry, and paths checked different subsets. + Red logs kept outside the repo (`kitcn-1596-bodies/j-red.log`: 7 fail; + `j-built-red.log`: 2 fail against the round H1 build). + - J1 fixed (7d3a9d6d), structural: one `admitToken` in + `react/identity-guard-registry.ts`, called by every path (SSR hydration, + fetcher fresh and cached and the final hand-out, restore, hydration + fallback, sign-in tokens and publication, `AuthStateSync`, HTTP + headers, the Start loader); callers keep no admission logic. Order: page + trip; opaque-token policy; identity against the page identity, the + provider's own baseline and admitted identity, and every mounted + getter's current answer (identity-less JWTs refused once any is bound); + `onTokenIdentityAdmitted`; the trip again. A refused JWT trips the page. + Mounted providers live in the registry (added in a layout effect, + removed at unmount); held tokens are reconciled against the page when + a guard joins it and at every admission. `identity-guard-trip.ts` is folded into the registry module. + Tests: `a later SSR token must match both its baseline and the page + identity`; `providers mounted together with SSR tokens of two + identities trip the page`; `a trip inside onTokenIdentityAdmitted at a + cached hand-out hands out nothing`; `a sibling without a getter is + bound by another guard's current getter`; built: `a sibling in + kitcn/auth/client is bound by another guard's current getter`, `an SSR + token in kitcn/auth/client is reconciled against the page identity`. + One existing test changed contract: a foreign token seeded into the + store now trips the page at the optimistic gate instead of only keeping + it closed (`a token seeded into the store opens the optimistic gate + only if the guard admits it`). + - J2 fixed (7d3a9d6d), behavioural: `pageRegistry()` returns null on the + server and never creates the registry; trip, identity, settlement and + getters are all read through it. Tests `a server-only loader creates, + reads and writes no page state`; `page state left by a torn-down DOM + does not reach a server call`. + - J3 fixed (7d3a9d6d), behavioural: the cached-token path classifies with + `isJwt`, so a two-segment opaque credential goes to the exchange as the + bearer, never to Convex. Test `a cached two-segment opaque credential + goes to the exchange, never to Convex`. + - Net non-test `src` delta: +386 / -441 (net -55); the provider lost 129 + lines. +- Round H3 (Codex lanes: adversarial and spec on eeda2dae), one commit + (eaefa418). Red log `kitcn-1596-bodies/k-red.log`: 8 source tests fail + before the fix. The built cases were not shown red against a build of + the prior source (the attempt was abandoned; see Error attempts); their + source twins were red. + - K1 fixed, behavioural: the HTTP token source admits after the app + headers load (both the fetcher and the store-token branch) and marks the + headers with a recheck that `executeHttpRequest` runs synchronously + right before dispatch, after per-call headers load; refused, the + `Authorization` is dropped. Tests `the store-token fallback admits after + the app headers load`; `no kitcn token is sent when the page trips while + per-call headers load`; built `kitcn/react HTTP sends no kitcn token once + the page trips while per-call headers load`. + - K2 fixed, behavioural: auth-state publication admits the held token + (`hold`) before the optimistic window and expiry checks. Tests `a + foreign token seeded after Convex confirmed trips and never publishes + authenticated`; `an expired foreign token seeded into the store trips + the page`. + - K3 fixed, behavioural: a fixed baseline is captured on the first + render; only a getter is read live. Test `a fixed baseline is read on + the first render only`. + - K4 fixed, wording: held tokens are reconciled when a guard joins the + page and at every admission (registry JSDoc, provider comment, docs, + skill, body, this plan). No per-commit reconciliation added. + - K5 fixed, behavioural: `identityGuardInPlay` (guarded provider, mounted + guarded sibling, or a bound identity) gates structural classification; + without it cached-token routing is by `exp` exactly as at fcbd2f84. + Tests `with no guarded provider, a cached opaque credential is handed + out as before`; `with a guard, a cached opaque credential is exchanged, + whatever the session state`; built `a cached opaque credential in + kitcn/auth/client: direct with no guard, exchanged with one`. + - K6 fixed, behavioural: key `kitcn.identityGuard.v2`, shape validated; + another shape under it is never used (a local stand-in, a console + warning, guarded admissions fail closed). Test `an incompatible + registry under the key fails guarded admissions closed`. + - K7 fixed, wording: JSDoc, `client.mdx`, skill and mirror, body state + the unguarded scope (narrowed in round H4, L6: a page that never + enables a guard is unchanged; an established page identity persists + until reload, even after the guard unmounts). + +- Round H4 (Codex lanes: adversarial and spec on 080f48b7), one commit + (7b9924d6). Red logs `kitcn-1596-bodies/l-red.log` (5 source tests fail) + and `l-built-red.log` (2 built cases fail against the round H3 build, the + 7 earlier built cases pass there); then rebuilt and green. + - L1 fixed, behavioural: guardedness is read at every commit; a guard + enabled after mount is seeded from its fixed baseline or held token, + joins the page (`joinPage`) and reconciles held tokens. A fixed + baseline value stays first-render-only. Test `a guard enabled after + mount binds the identity the provider holds`. + - L2 fixed, behavioural: on a page with a mounted guarded provider, the + first identity any admission admits claims the page, the Start loader + included. Tests `on a guarded page, the Start loader's first admitted + JWT claims the page identity`; built `kitcn/auth/start's first JWT + claims the identity of a page kitcn/auth/client guards`. + - L3 fixed, behavioural: `publishAuthState` admits the current held + token and reads the trip again after the loading write, right before + an authenticated write. Tests `a token swapped by a loading subscriber + is admitted before authenticated is published`; `a trip in a loading + subscriber is seen before authenticated is published`; built `kitcn/ + auth/client admits the held token again right before publishing + authenticated`. + - L4 fixed, behavioural: the Start loader reads the trip after `setAuth` + and refuses (clears both clients) if it tripped. Test `the Start loader + refuses when its setAuth trips the page`. + - L5 accepted and documented (registry key comment, JSDoc, `client.mdx`, + skill and mirror, body Caveat): two kitcn versions or revisions on one + page (dev HMR across revisions included) are unsupported; registries + under different keys, or next to an incompatible object under the key, + share no page identity until reload. The detecting entry keeps K6's + fail-closed behaviour. v3-adversarial findings 4 (v1 and v2 keys split + the identity) and 5 (per-copy stand-in) are accepted on this rationale. + - L6 fixed, wording: a page that never enables the guard is unchanged; + an established page identity persists until reload, binding every + provider and the loader, even after the guard unmounts. + - L7 fixed: the owner, import-graph and release-artifact rows name + `identity-guard-registry.ts` and the folded changeset. + +Error attempts: +| Error / failed attempt | Count | Next different move | Resolution | +|------------------------|-------|---------------------|------------| +| Built red for round H3: a chained shell command meant to stash the fix, build and restore failed on zsh word splitting; its fallback `git checkout` reverted the uncommitted registry edits and `git stash pop` applied an unrelated stash (feat/crpc-optimistic-update split-B) into this worktree | 1 | Re-applied the registry edits; no chained stash moves | Registry restored and re-verified; the popped stash's two untracked files remain in this worktree, byte-identical to dangling stash commit 7ae5df22 (restoring the stash entry and deleting the files is left to the requester) | + +Verification evidence: +- 2026-10-01 autoclosure in + `/Users/zbeyens/.codex/worktrees/pr475-autoclosure/better-convex` (Bun 1.3.12): + - Intake head f9016674, base main 126f3e99; COMPLETE task state, preserved. + - Late enablement regression: mounting unguarded, tripping, then enabling + `onTokenIdentityChange` failed with 0 closes, expected 1. The repaired + provider quarantines once independently of its one-time notification, + and checks the terminal trip when the callback becomes enabled. + - Post-callback baseline regression: `onTokenIdentityAdmitted` changed the + live getter from A to B. RED observed A in token subscribers before its + eventual refusal. Admission now re-enters without announcing after the + callback, checking all current identities before any token publication. + - Late-enable regression GREEN: 1 pass; source auth-client/react/auth-start: 294 pass, + 0 fail across 22 files; source-first package typecheck and build pass. + - Final repaired-source full `bun check` exit0: Bun1556/0, Vitest1053 pass + and14 skipped, CLI124/0, lint/typecheck, Concave, fixture freshness, + consumer verify and runtime scenarios. Final focused suites295/0 and + built-entrypoint integration10/0; both new regressions GREEN. + - Typed token endpoint call replaces an unnecessary `any`; nullable results + retain the existing no-token behavior. Internal narrative comments were + removed while protocol, SSR and Convex ordering constraints were retained. + - Published auth guidance updated through its source; generated mirror + synced. Intent validation/staleness, MDX compile and mirror parity pass. + - Independent verifier uses its own worktree; parent baseline 123/0, + original candidate focused 192/0 plus built-entrypoint 10/0. + - Autoreview exit0 and TruffleHog clean. Final-head replay, delivery and + terminal feedback receipts continue externally after the versioned plan + push, as specified in docs/plans/475-autoclosure.md; no approval bypass. +- The snapshots below are historical author evidence, not final-head proof. +- Probe (Bun 1.3.9, fcbd2f84 source, this branch's provider tests, inert + stand-in for the trip module): 52 pass, 38 fail (Findings); log kept + outside the repo. +- Current (Bun 1.3.9, HEAD 7b9924d6 plus this plan): + - `bun run build` (packages/kitcn): exit 0. + - Focused (provider 115, context 13, auth-mutations 11, use-query-options + 24, client 14, auth-start retry 1): 178 pass, 0 fail, 0 `act` warnings. + - Built-entrypoint integration (`identity-guard.entrypoints` 9, + `package-entrypoints` 1): 10 pass, 0 fail. + - `bun lint:fix`: 979 files, no fixes applied; `bun lint` exit 0. + - `bun check`: exit 1 at `fixtures:check` (the `expo` drift) after every + earlier lane passed: lint, typecheck, `test:bun` 1554 pass / 0 fail (155 + files), `test:vitest` 1053 pass / 14 skipped, no type errors, + `test:cli` 124 pass / 0 fail, Concave smoke. + - `test:verify`: exit 0. + - `client.mdx` compiles; skill mirror synced. + - `check-complete.mjs`: `[autogoal] complete` (gates resolved or recorded + as blocked or handed-off; not closure). +- Round H3 snapshot (Bun 1.3.9, HEAD eaefa418 plus this plan): + - `bun run build` (packages/kitcn): exit 0. + - Focused (provider 110, context 13, auth-mutations 11, use-query-options + 24, client 14, auth-start retry 1): 173 pass, 0 fail, 0 `act` warnings. + - Built-entrypoint integration (`identity-guard.entrypoints` 7, + `package-entrypoints` 1): 8 pass, 0 fail. + - `bun lint:fix`: 979 files, no fixes applied; `bun lint` exit 0. + - `bun check`: exit 1 at `fixtures:check` (the `expo` drift) after every + earlier lane passed: lint, typecheck, `test:bun` 1547 pass / 0 fail (155 + files), `test:vitest` 1053 pass / 14 skipped, no type errors, + `test:cli` 124 pass / 0 fail, Concave smoke. + - `test:verify`: exit 0. + - `client.mdx` compiles; skill mirror synced. + - `check-complete.mjs`: `[autogoal] complete` (gates resolved or recorded + as blocked or handed-off; not closure). +- Round H2 snapshot (Bun 1.3.9, HEAD 7d3a9d6d plus this plan): + - `bun run build` (packages/kitcn): exit 0. + - Focused (provider 104, context 11, auth-mutations 11, use-query-options + 24, client 14, auth-start retry 1): 165 pass, 0 fail, 0 `act` warnings. + - Built-entrypoint integration (`identity-guard.entrypoints` 5, + `package-entrypoints` 1): 6 pass, 0 fail. + - `bun lint:fix`: exit 0, 979 files, no fixes applied, source unchanged; + `bun lint` exit 0. + - `bun check`: exit 1 at `fixtures:check` (the `expo` drift) after every + earlier lane passed: lint, typecheck, `test:bun` 1537 pass / 0 fail (155 + files), `test:vitest` 1053 pass / 14 skipped, no type errors, + `test:cli` 124 pass / 0 fail, Concave smoke. + - `test:verify`: exit 0. + - `intent:validate` all passed, `intent:stale` up to date; `client.mdx` + compiles. + - `check-complete.mjs`: `[autogoal] complete` (gates resolved or recorded + as blocked or handed-off; not closure). +- Round H1 snapshot (Bun 1.3.9, HEAD fc43523e plus changeset and this plan): + - `bun --cwd packages/kitcn build`: exit 0; typecheck exit 0. + - Focused (provider 97, context 11, auth-mutations 11, use-query-options + 24, client 14, auth-start retry 1): 158 pass, 0 fail, 0 `act` warnings. + - Built-entrypoint integration (`identity-guard.entrypoints` 3, + `package-entrypoints` 1): 4 pass, 0 fail. + - `bun lint:fix`: exit 0, no fixes applied, source unchanged. + - `bun check`: exit 1 at `fixtures:check` (the `expo` drift) after every + earlier lane passed: lint (biome 980 files, eslint), typecheck 5/5, + `test:bun` 1528 pass / 0 fail (155 files), `test:vitest` 1053 pass / 14 + skipped, no type errors, `test:cli` 124 pass / 0 fail, Concave smoke. + - `test:verify`: exit 0. + - `check-complete.mjs`: `[autogoal] complete` (gates resolved or recorded + as blocked or handed-off; not closure). +- Historical snapshot (Bun 1.3.9, HEAD 7b8f3179 plus changeset and this plan): + - `bun --cwd packages/kitcn build`: exit 0; typecheck exit 0. + - Focused (provider 91, context 11, auth-mutations 11, use-query-options + 24, client 14, auth-start retry 1): 152 pass, 0 fail, 0 `act` warnings. + - `bun lint:fix`: exit 0, no fixes applied, source unchanged. + - `bun check`: exit 1 at `fixtures:check` (the `expo` drift, pre-existing + on `main`) after every earlier lane passed: lint, typecheck 5/5, + `test:bun` 1519 pass / 0 fail (154 files), `test:vitest` 1053 pass / 14 + skipped, no type errors, `test:cli` 124 pass / 0 fail, Concave smoke. + - `test:verify`: exit 0. + - `intent:validate` "all passed", `intent:stale` "All skills up-to-date"; + `client.mdx` compiles. + - `test:runtime`: not run locally (its `expo` scenario needs port 3210, + held by an unrelated local backend). + - `check-complete.mjs`: `[autogoal] complete` (gates resolved or recorded + as blocked or handed-off; not closure). + +Source-listed case matrix: +| Case | Source claim | Harness | Before (fcbd2f84) | Expected after | Evidence | Status | +| --- | --- | --- | --- | --- | --- | --- | +| 1 | Held SSR token of another identity admitted before publication | provider tests | published, gate open | withheld, trip | pass | done | +| 2 | Store token opens the gate only if admissible | provider test | opens | closed | pass | done | +| 3 | Restore admits a persisted credential | provider tests | restored | not restored | pass | done | +| 4 | Concurrent first tokens never publish the loser | provider test | published | never | pass | done | +| 5 | Identity-less JWT refused once an identity exists | provider tests | handed out | refused | pass | done | +| 6 | Sign-in-returned JWT admitted; authenticated re-admits | provider tests | published | refused, trip | pass | done | +| 7 | Start loader held to the page identity | provider tests | token handed out | refused | pass | done | +| 8 | Trip publishes unauthenticated; nothing writes back | provider tests | stays authenticated | unauthenticated | pass | done | +| 9 | Trip page-wide; each guarded provider closes and calls back once | provider tests | local only | page-wide | pass | done | +| 10 | Sign-in on a tripped page fails with `TOKEN_IDENTITY_CHANGED` | provider tests | success | error | pass | done | +| 11 | Refused token never reopens the optimistic gate | provider tests | reopens | closed | pass | done | +| 12 | Settlement recorded where Convex reports it; loader settles its client | provider tests | missed | settled | pass | done | +| 13 | Auth-state publication reads current trip and settlement | provider tests | stale `true` | never | pass | done | + +- Final-head snapshot (Bun 1.3.9, base `main` 126f3e99, commit 88fb6c4f; content identical to 67119913 in `packages/kitcn/src`, `www`, skills and `.agents`): + - `bun install` exit 0; `bun --cwd packages/kitcn build` exit 0. + - Focused suites (provider, context, auth-mutations, use-query-options, client, auth-start retry): exit 0. Built entrypoints: exit 0. + - `bun run check:ci`: lint (979 files, no fixes), typecheck 6/6 packages, `test:bun` 1554 pass / 0 fail (155 files), `test:vitest` 1053 pass / 14 skipped, `test:cli` 124 pass, Concave smoke passed; exit 1 only at `fixtures:check` on the `next` fixture (create-next-app `AGENTS.md` text changed upstream; no fixture or scaffold change in this PR). + - `bun run test:verify`: exit 0. + - `test:runtime`: not run locally (port 3210 held by an unrelated local backend). + +Final handoff contract: +- Commit line: runtime, docs and plan commits on `feat/optimistic-auth-hardening`. +- PR line: https://github.com/udecode/kitcn/pull/475 (#475), follow-up to #473. +- Issue line: `๐Ÿ› Fixes โž– N/A` +- Confidence line: `๐ŸŸข 95% confidence` +- Flow table: + - Reproduced: 38 tests fail at #473's head; browser N/A + - Verified: focused 295 pass, built-entrypoint 10 pass, full repository/runtime gate; browser N/A +- Browser check: N/A. +- Outcome: the identity guard and optimistic gate hold their documented guarantees. +- Caveat: protected approval external; one optimism setting per client; + mixed kitcn revisions unsupported until reload. +- Design: + - Chosen boundary: one admission function in the registry; page-level trip and identity; client window. + - Why not quick patch: each bypass was a separate write path. + - Why not broader change: no API change needed. +- Verified: see Verification evidence. +- PR body verified: existing exact plan line read back; final updated proof body + has a post-push read-back gate in docs/plans/475-autoclosure.md. + +Task-style PR body contract: +- Preserve any existing `` block. If a changeset is + part of the diff and repo policy expects auto release, include that block. +- Use the accepted PR #270 visual format. The body starts with an emoji + issue/fix line, for example `๐Ÿ› Fixes #123` or `๐Ÿ› Fixes โž– N/A`, then + `๐Ÿงญ Task plan: docs/plans/.md`, then an emoji confidence line like + `๐ŸŸข 95-100% confidence`. +- Use this exact table header: `| Phase | ๐Ÿงช Tests | ๐ŸŒ Browser |`. +- Use `Reproduced` and `Verified` rows. Mark passing proof with `๐ŸŸข`, repro or + failing proof with `๐Ÿ”ด`, and non-applicable cells with `โž– N/A`. +- Use bold emoji section headings: `**โœ… Outcome**`, `**โš ๏ธ Caveat**`, + `**๐Ÿ—๏ธ Design**`, and `**๐Ÿงช Verified**`. +- Never include a line that links to the current PR itself. The current PR URL + belongs in the final response, not in its own description. +- Do not replace this with a generic `Summary` / `Verification` PR body, an + adaptive prose body from a git helper skill, plain `## Outcome` sections, or + an unrelated generated badge footer unless the caller or repo template + explicitly asks for it. +- Proof is `gh pr view --json body` output or a concise source-backed summary + of that output. + +Final handoff / sync: +- Commit: 88fb6c4f (net content of `feat/optimistic-auth-hardening` 67119913 on `main` 126f3e99) plus this plan update, branch `feat/optimistic-auth-hardening-v2`. +- PR: https://github.com/udecode/kitcn/pull/475 (#475). +- Issue: N/A. +- Browser proof: N/A. +- Caveats: final protected approval belongs to another authorized reviewer. + +Timeline: +- 2026-09-30 Branch from #473's head fcbd2f84; hardening ported from + `wip/473-hardening-rounds` and reconciled with ea5e442d; probe at fcbd2f84 + (38 fail); gates run; plan closed as blocked on the pre-existing gate. +- 2026-09-30 Round H1 (309f876b, 6346b55d, fc43523e, 3cb36826). +- 2026-09-30 Round H2: one admission in the registry (7d3a9d6d). +- 2026-09-30 Round H3: admission at send and before publication, fixed + baselines, guarded-only opaque routing, registry key v2 (eaefa418). +- 2026-09-30 Round H4: live guardedness, loader claims on guarded pages, + admission between publication writes, loader trip after setAuth + (7b9924d6). +- 2026-09-30 CI on #475: every lane passed except `fixtures:check`, whose + scaffold snapshots drifted from the current upstream templates + (create-next-app 16.3.6, TanStack Start, Vite) after 0.33.6's CI ran. A + separate commit resyncs them with `bun run fixtures:sync` (28 files under + `fixtures/`, no kitcn source or scaffold change); `fixtures:check` exit 0 + locally afterwards. +- 2026-09-30 Round H5 (after #475 opened; found by porting this branch into + a consumer app whose provider remounts per route group): a trip that + happened while no guarded provider was mounted (a token refresh finishing + after its provider unmounted; the Start loader before any provider) never + reached `onTokenIdentityChange`, because a provider mounting on a tripped + page took `inheritedTrip` as already reported. Fixed, behavioural: every + guarded provider that mounts or shows again on a tripped page runs the + trip's close and callback once (`tripSettledRef` starts `false`). Test + `a later provider over a tripped client starts tripped and still reports + the trip once` (renamed from `a later provider over a tripped client + starts tripped`; red first: 0 callbacks, expected 1), including that a + rerender does not report it again. Provider suite 115 pass, 0 fail. Docs, + JSDoc and both skill references updated. + +Reboot status: +| Question | Answer | +|----------|--------| +| Where am I? | Repaired source verified; delivery receipts and protected approval | +| Where am I going? | Push exact #475, read back final head/body, await protected approval | +| What is the goal? | Fix the guard and gate defects still at #473's head | +| What have I learned? | See Findings | +| What have I done? | See Timeline | + +Open risks: +- Autoreview and TruffleHog pass; no unresolved accepted review finding. +- Exact-head final receipts and required CI are mandatory after the final + versioned push. Approval must satisfy code-owner and last-push rules. +- One `optimisticAuth` setting per Convex client is a documented rule. +- Accepted: two kitcn versions or revisions on one page share no page + identity until reload (L5). + +Hard closeout guard: +- A local-only final response for verified code-changing work is invalid unless + this plan records an explicit user decline, no local patch, analytical/ + blocked/inconclusive outcome, or a real commit/PR blocker. diff --git a/docs/plans/475-autoclosure.md b/docs/plans/475-autoclosure.md new file mode 100644 index 000000000..d09b097d7 --- /dev/null +++ b/docs/plans/475-autoclosure.md @@ -0,0 +1,265 @@ +# Autoclose PR 475 + +Objective: +Finish https://github.com/udecode/kitcn/pull/475 without widening auth scope. +Preserve the existing implementation and its dedicated task plan. Prove token +admission, runtime checks, built entrypoints, reviews, and exact-head feedback; +merge only with required checks and protected approval satisfied. + +Goal plan: +docs/plans/475-autoclosure.md + +Template: +docs/plans/templates/autoclosure.md + +Primary template: +docs/plans/templates/autoclosure.md + +Applied packs: +- agent-native (docs/plans/templates/packs/agent-native.md) + +Completion threshold: +- Dedicated task: docs/plans/2026-09-30-harden-optimistic-auth-identity-guard.md. +- All 13 source-backed identity/settlement case groups must pass, including + separately built auth/client, react, HTTP, and Start entrypoints. +- Required runtime and repository gates pass; exact-head independent review + and feedback have no accepted unresolved findings. +- No new product scope. Completion requires every applicable lane below to have + fresh evidence, `bun check` passing, review findings closed, authorized + GitHub delivery complete, and the goal checker passing. + +Verification surface: +- Focused Bun auth-client/react/auth-start suites; package build and built + entrypoint integration; fixture check; `bun check`; independent shipping + verifier; agent-native source/mirror audit; final autoreview; raw GitHub + comments, reviews, threads, required checks, protections and head read-back. + +Constraints: +- Finish the intended delta; do not invent the next feature. +- Preserve source/generated/package/docs ownership. +- Use a different diagnostic after repeated failure signatures. + +Boundaries: +- intended delta: page-wide guarded token admission and client auth settlement. +- allowed repairs: bugs in that contract, its existing tests/docs, stale task + evidence, generated ownership, and missing verification. +- unrelated files: preserve; do not treat as blockers +- non-goals: new auth features, unrelated PRs, bypassing protections, changing + Vercel team membership, or closing usable work for incomplete evidence. + +Output budget strategy: +- Audit changed files and their direct owners; save full command output under + /tmp/pr475-*.log and report bounded summaries. Delegate read-only grounding, + comment audit and independent verification with exclusive worktrees. + +Blocked condition: +- Another authorized reviewer must satisfy protected code-owner/last-push + approval. Runtime port conflicts are diagnosed without killing unrelated + processes. Never infer native-goal creation authority from this task. + +Start Gates: +| Gate | Applies | Evidence | +| --- | --- | --- | +| Dedicated task invocation and plan for exact PR | yes | Resume task for exact PR #475; existing dedicated plan | +| Task evidence verified at PR head | yes | Body names plan; fetched f9016674 contains plan naming #475: COMPLETE | +| Active source/plan reconstructed | yes | Existing 13 case groups; package registry/provider/HTTP/Start source | +| Intended delta and exclusions recorded | yes | Boundaries above | +| Closure matrix classified | yes | Applicable lanes below; absent-state close is N/A | +| Live PR feedback target resolved | yes | Full resolve-pr-feedback #475; no actionable GitHub findings | +| Feedback proof checkout bound to PR head | yes | Intake local HEAD = fetched refs/pr/475 = live f9016674; final equality is an external delivery gate | +| Unfiltered feedback inventory | yes | Raw 3 comments, 0 reviews, 0 threads; helper 2 comments, omitted Vercel item explicitly ledgered below | +| GitHub delivery expectation recorded | yes | Update same PR; guarded merge only when ready | +| Active goal checked or created | yes | Native get_goal returned null; creation not authorized; this plan tracks closeout | +| Agent-native pack selected | yes | Published auth guidance and generated mirror changed | +| Agent-facing action surface identified | yes | Auth identity guard setup and proof through public package entrypoints | +| Source rule versus generated mirror boundary identified | yes | packages/kitcn/skills/kitcn/references/features/auth.md owns .agents mirror | +| Installed-skill lock versus local-rule owner identified | yes | No installed workflow or lock changes; published package reference only | +| `agent-native-reviewer` loaded or waiver recorded | yes | Loaded; parity audit required before closeout | + +Closure matrix: +| Lane | Applies | Owner/proof | Status | +| --- | --- | --- | --- | +| per-PR task ownership | yes | Existing exact #475 plan and body line | done | +| noncompliant close | no | N/A: complete task state; preserve usable work | done | +| source behavior | yes | 295/0 focused; both new regressions RED then GREEN | done | +| package/API/build | yes | Source-first typecheck; fresh build; built entrypoints 10/0 | done | +| generated output | yes | Owner sync and byte-identical auth skill mirror | done | +| fixtures/scenarios | yes | Final repaired-source bun check exit0, including fixture and runtime lanes | done | +| docs/package skill | yes | Current-state matching references; intent validate/stale and MDX compile | done | +| changeset | yes | Existing kitcn patch updated; no duplicate changeset | done | +| agent workflow | no | N/A: published auth guidance only, no workflow/helper change | done | +| live PR feedback | yes | Inventory below; final replay and external receipt after versioned push | handed-off | +| cleanup/review | yes | Comment audit, bounded deslop, parity audit, autoreview clean | done | +| repository check | yes | Final repaired-source bun check exit0 | done | +| GitHub delivery | yes | Same contributor branch, no force push; approval cannot be bypassed | handed-off | + +Work Checklist: +- [x] Every PR has its own `task` invocation and dedicated task plan; a batch + plan or aggregate autoclosure is not used as a substitute. +- [x] Task evidence was verified from the PR body, fetched head, and exact PR + ownership; otherwise the required comment and `CLOSED` state were read + back and no source review, repair, merge, or release work continued. +- [x] Intended behavior and exclusions are reconstructed from real sources. +- [x] Each lane is proven or N/A with a concrete reason. +- [x] Generated output was changed through its owner and regenerated. +- [x] Package/docs/skill/fixture/scenario/changeset contracts are synchronized. +- [x] Full `resolve-pr-feedback` ran for the exact compliant PR; every + actionable P1-or-higher finding was fixed, proved, replied to, and + resolved or received the required top-level reply receipt. +- [x] Feedback checkout handoff is recorded: intake local/fetched/live OIDs + matched; final committed HEAD must match fetched and live head after + the versioned push, before any proof/reply/resolution receipt. +- [x] Unfiltered top-level PR comments and review bodies were fetched through + the GitHub API, compared by ID/URL with helper output, and every excluded + bot/author item was ledgered; identity alone never dismissed feedback. + Only the exact terminal receipt produced/read back by this run is exempt + from the versioned ledger. +- [x] All inline review threads were fetched with GraphQL cursor pagination + without filtering resolved/outdated items; every thread has priority, + rationale, relocation, and proof state in the ledger. +- [x] Every actionable feedback item has a persisted P0-P3 priority and + one-sentence rationale from the autoclosure rubric; ambiguous P1-versus- + lower items fail closed as P1. +- [x] Final replay handoff requires both P1 regressions and the focused suite + after the final material push, including any resolved/outdated threads + newly discovered by the unfiltered final inventory. +- [x] Final feedback handoff requires a fresh helper/raw inventory after all + pushes/replies/resolutions and zero unresolved actionable P1 findings. +- [x] External receipt handoff requires post/read-back of the final exact-head + P1 proof only after all versioned updates are pushed. No receipt-only + branch push; receipt/live/fetched/local OIDs must match, and the final + raw/helper inventory must contain no unclassified URL beyond that receipt. +- [x] Any remaining P2-or-lower item has its exact URL plus the user's explicit + priority deferral recorded; no feedback was silently ignored. +- [x] Accepted cleanup and review findings are closed. +- [x] PR body/check handoff requires publishing the prepared final proof body, + preserving auto-release, reading it back, and observing required CI at + the final head. No earlier check authorizes landing the new head. +- [x] Residual blocker/waiver has exact evidence and next owner. +- [x] Agent-native pack: source-of-truth rule files are edited instead of generated skill mirrors. +- [x] Agent-native pack: the changed agent action is discoverable from the skill/rule text. +- [x] Agent-native pack: generated mirrors are synced when `.agents/rules/**` changed, or N/A reason is recorded. +- [x] Agent-native pack: installed skills are changed only through + `npx skills add/update/remove`; local rules/templates/helpers stay source-owned. +- [x] Agent-native pack: routing, required receipts, placeholder failure, + completion representability, and forbidden behavior have eval/smoke rows. +- [x] Agent-native pack: accepted agent-native review findings are fixed or explicitly rejected with reason. + +Error attempts: +| Failure signature | Count | Next different move | Resolution | +| --- | ---: | --- | --- | +| Vitest found no focused .test files | 1 | Read owning test config | Correct lane is Bun; focused suites pass | +| Typed token endpoint result includes undefined | 1 | Use declared return type and optional data | Typecheck and focused types pass | +| Recursive admission return inference TS7023 | 1 | Explicit boolean return contract | Typecheck and focused types pass | +| MDX module not directly linked at root/www | 2 | Resolve installed MDX package path | MDX compiler passes; no install/code change | + +Completion Gates: +| Gate | Applies | Required action | Evidence | +| --- | --- | --- | --- | +| Per-PR task ownership | yes | Record exact PR and dedicated task-plan path | Exact #475 dedicated task plan verified at intake and retained | +| Noncompliant PR disposition | no | Verify task evidence or comment then close and read back | N/A: COMPLETE task state, no closure permitted | +| Targeted behavior proof | yes | Run smallest missing owning proof | 295/0 source suites, both P1 REDโ†’GREEN regressions, 10/0 fresh built entrypoints | +| Source/generated audit | yes | Prove correct source and regenerated mirrors | Published auth reference source synced; byte-identical generated mirror | +| Package/docs/scenario closure | yes | Run every applicable local contract | Package typecheck/build, MDX compile, intent validate/stale; final runtime gate recorded below | +| Feedback proof checkout | handed-off | Compliant PR only: require local committed `HEAD` = fetched PR ref = live `headRefOid` before proof/reply/resolution and at terminal verification | Intake equality f9016674; final live/fetched/local equality is required after versioned push | +| Live PR feedback resolution | yes | Compliant PR only: run full `resolve-pr-feedback` and close every actionable P1-or-higher finding; otherwise N/A with noncompliant stop receipts | Full helper and raw inventory: zero actionable items; all three URLs classified below | +| Feedback priority classification | yes | Compliant PR only: persist P0-P3 plus rationale for every actionable item; classify ambiguous P1-versus-lower as P1 | Two local P1 defects fixed; no actionable GitHub findings and no lower-priority deferrals | +| Final P1 proof replay | handed-off | Compliant PR only: after the final material branch push, rerun every P1-or-higher proof, including resolved/outdated items | After final versioned push, replay both named regressions and 295/0 focused suite; external receipt binds final OID | +| Final live feedback read-back | handed-off | Compliant PR only: re-fetch helper plus unfiltered top-level/all-thread inventories; require zero actionable P1-or-higher and explicit P2-or-lower deferrals | After push/verifier comment, refetch helper, raw paginated comments/reviews and all GraphQL threads | +| External terminal receipt | handed-off | Compliant PR only: post/read exact-head receipt; require receipt/live/fetched/local OID equality and no unrecorded helper/raw URL except that verified receipt | Post/read exact-head receipt only after final plan push; four-way OID equality; no receipt-only branch push | +| Deslop | yes | Run bounded cleanup or N/A | Bounded comment cleanup and typed token call; intentional scanner findings source-checked | +| Agent-native reviewer | yes | Run for workflow changes or N/A | PASS source/mirror/discoverability/proof audit; no accepted findings | +| Final lint | yes | Run `bun lint:fix` | bun lint:fix exit0, 979 files, no formatter changes | +| Repository check | yes | Run `bun check` | Final repaired-source bun check exit0; Bun1556/0, Vitest1053/14 skipped, CLI124/0, fixture/verify/runtime pass | +| GitHub delivery | handed-off | Commit/push/open or update PR and read back | Push same PR without force; body and exact head read back; protected approval belongs to another authorized reviewer | +| Autoreview | yes | Resolve every accepted actionable finding | Branch vs actual main base: TruffleHog clean, structured review exit0, no accepted/actionable findings | +| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/475-autoclosure.md` | Checker rejects original incomplete template; final resolved or explicitly external-blocked gate representation checked before handoff | +| Agent source / generated sync | yes | Run `bun install` when `.agents/rules/**` changed and verify generated mirrors | tooling/sync-kitcn-skill.ts run; cmp auth source/mirror exit0; rules unchanged | +| Installed lock audit | no | Verify expected lock entries and removed skills through CLI-managed state | N/A: no installed workflow skills or lock changes | +| Agent action discoverability | yes | Source-audit the skill/rule path an agent will read | Published SKILL.md routes to references/features/auth.md; matching docs claim audit | +| Helper and template smoke | no | Syntax-check helpers and prove incomplete failure/completed representation when applicable | N/A: no workflow/helper/template behavior changed; incomplete plan checker smoke fails as expected | +| Agent-native review | yes | Load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted findings, or record N/A | PASS: auth setup route, source owner, generated mirror and runnable tests remain available | + +Phase / pass table: +| Phase | Status | Evidence | Next | +| --- | --- | --- | --- | +| Inventory | done | Exact task/head/source and raw feedback | repair | +| Repair | done | Two source-backed P1 bugs, red-green proof | final checks | +| Review/checks | done | Final source fullcheck exit0, review clean, 295/0 focused, 10/0 built | delivery | +| Delivery | handed-off | Same-PR push/body/read-back protocol below | exact-head replay | +| Closeout | blocked | Protected code-owner/last-push approval required | authorized reviewer | + +Verification evidence: +- This is the versioned proof snapshot. Checked delivery-handoff rows record + the required protocol and owner, not a claim that future GitHub mutations + already happened. External exact-head receipts must prove those gates after + this snapshot is pushed; they are not stored by a receipt-only branch commit. +- Source candidate 90a1653eb2788324d6c1cf9e85bf6f588e7b3570; main base + 126f3e992110b6ded7d7c9a7d8ce178b6ede61f8. Subsequent versioned edits are + evidence-only; final head and stable patch-id are bound in external receipts. +- Both P1 regressions ran RED before source fixes, then GREEN. Full focused + auth-client/react/auth-start 295 pass, 0 fail; fresh built integration 10/0; + source-first package typecheck and both package builds exit 0. +- Final repaired-source `bun check` exit0: lint; workspace typecheck; Bun + 1556/0; Vitest1053 pass,14 skipped; CLI124/0; Concave smoke; fixture freshness; + consumer verify; all selected runtime scenarios. No unrelated process killed. +- Independent verifier, own worktree, original parent 123/0, original head + 192/0 plus built integration 10/0; repaired candidate 295/0 and built 10/0. + Its callback probe now refuses, trips, and announces exactly once. +- Autoreview branch against refs/remotes/kitcn/main: TruffleHog clean, + one full 264038-byte pass, exit 0, no accepted/actionable P0 findings. + Lower-priority coverage is the source review and independent verifier; + this is not a claim that the helper reviewed every priority. +- Agent-native review PASS: auth setup -> published auth feature reference -> + provider/registry owner -> focused and built-entrypoint proof -> same-PR + handoff. Source/mirror parity, intent validation/staleness and MDX compile + pass. No workflow/helper/installed lock changes; their smoke lanes are N/A. +- Bounded deslop accepted internal narration deletion and typed token call; + preserved external protocol and browser/SSR/Convex constraints. Slop scanner + catches for malformed JWT decoding and independent trip-listener delivery + are intentional boundary behavior, covered by tests, not error hiding. + Settlement installation is intentionally client-wide idempotent bookkeeping, + not a provider render-state publication; no unrelated lifecycle rewrite. + Pre-existing OTT casts are outside the changed auth-admission invariant. +- Final delivery protocol: commit all source/plan evidence, validate pending + merge cancellation and contributor ref, push without rewriting history, + read body/head back, replay both P1 proofs at that committed live head, + independent verifier posts exact-head/base/patch-id verdict, then post and + read the terminal feedback receipt. No receipt-only branch commit. +- Required main rules: one approving review, code-owner approval and approval + by someone other than last pusher; only CI is a required status check. + Vercel preview authorization is not a required check. No admin/auto merge. + +Feedback ledger: +| URL | Classification | Rationale / proof | +| --- | --- | --- | +| https://github.com/udecode/kitcn/pull/475#issuecomment-5916047277 | informational, not actionable | Changeset bot detects patch release metadata; existing changeset verified | +| https://github.com/udecode/kitcn/pull/475#issuecomment-5916047348 | informational, not actionable in this task | Contributor preview requires Vercel team authorization; live main rules require CI, not Vercel | +| https://github.com/udecode/kitcn/pull/475#issuecomment-5916047404 | informational, not actionable code feedback | Hosted review quota notice; independent source proof and successful local autoreview cover review | + +Local review findings: +| Finding | Priority | Rationale | Proof | +| --- | --- | --- | --- | +| Late guard after page trip never closes/notifies | P1 | Breaks terminal reload notification contract | New late-enable provider regression, RED then GREEN | +| Baseline changes in admission callback publish stale token | P1 | Token subscribers see a token current identity disallows | New post-callback publication regression, RED then GREEN | +No actionable GitHub P0-P3 findings; no implicit P2/P3 deferral. + +Timeline: +- 2026-10-01T08:50:02.238Z Autoclosure plan created. +- 2026-10-01 Both new behavioral defects reproduced and repaired; source + checks, independent proof, full runtime and autoreview pass. Final versioned + snapshot hands exact-head replay/body/receipt gates to external delivery. + +Reboot status: +| Question | Answer | +| --- | --- | +| Where am I? | Source repairs verified; final gate and delivery | +| Where am I going? | Exact-head delivery, external receipt, protected approval | +| What is the goal? | Verify and land PR #475 if protected readiness permits | +| What have I learned? | See closure matrix | +| What have I done? | See timeline | + +Open risks: +- Protected approval requires another authorized reviewer after our push. +- One optimisticAuth setting per client and unsupported mixed kitcn revisions + remain documented constraints; neither fix changes the public API. diff --git a/fixtures/next-auth/.gitignore b/fixtures/next-auth/.gitignore index 204342b48..d1bc0c8f3 100644 --- a/fixtures/next-auth/.gitignore +++ b/fixtures/next-auth/.gitignore @@ -31,6 +31,9 @@ pnpm-debug.log* # env files (can opt-in for committing if needed) .env* +# vercel +.vercel + # typescript *.tsbuildinfo next-env.d.ts diff --git a/fixtures/next-auth/AGENTS.md b/fixtures/next-auth/AGENTS.md index 8bd0e3908..643577dfa 100644 --- a/fixtures/next-auth/AGENTS.md +++ b/fixtures/next-auth/AGENTS.md @@ -1,5 +1,9 @@ + # This is NOT the Next.js you know -This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` โ€” verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/fixtures/next-auth/CLAUDE.md b/fixtures/next-auth/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/fixtures/next-auth/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/fixtures/next-auth/package.json b/fixtures/next-auth/package.json index 408b36570..ee8fd7055 100644 --- a/fixtures/next-auth/package.json +++ b/fixtures/next-auth/package.json @@ -10,7 +10,7 @@ "hono": "4.12.9", "kitcn": "workspace:*", "lucide-react": "^1.49.0", - "next": "16.3.4", + "next": "16.3.6", "next-themes": "^0.4.6", "react": "19.2.8", "react-dom": "19.2.8", @@ -25,8 +25,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "9.39.5", - "eslint-config-next": "16.3.4", - "prettier": "^3.9.6", + "eslint-config-next": "16.3.6", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/fixtures/next-auth/tsconfig.json b/fixtures/next-auth/tsconfig.json index f41b722b8..9e3169eea 100644 --- a/fixtures/next-auth/tsconfig.json +++ b/fixtures/next-auth/tsconfig.json @@ -94,12 +94,11 @@ }, "include": [ "next-env.d.ts", - "next.config.ts", "**/*.ts", "**/*.tsx", - "**/*.mts", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + "**/*.mts" ], "exclude": [ "node_modules" diff --git a/fixtures/next/.gitignore b/fixtures/next/.gitignore index 204342b48..d1bc0c8f3 100644 --- a/fixtures/next/.gitignore +++ b/fixtures/next/.gitignore @@ -31,6 +31,9 @@ pnpm-debug.log* # env files (can opt-in for committing if needed) .env* +# vercel +.vercel + # typescript *.tsbuildinfo next-env.d.ts diff --git a/fixtures/next/AGENTS.md b/fixtures/next/AGENTS.md index 8bd0e3908..643577dfa 100644 --- a/fixtures/next/AGENTS.md +++ b/fixtures/next/AGENTS.md @@ -1,5 +1,9 @@ + # This is NOT the Next.js you know -This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. +This version has breaking changes โ€” APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. + +This block is written and re-added by `next dev` โ€” verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. + diff --git a/fixtures/next/CLAUDE.md b/fixtures/next/CLAUDE.md new file mode 100644 index 000000000..43c994c2d --- /dev/null +++ b/fixtures/next/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/fixtures/next/package.json b/fixtures/next/package.json index 281e24fe6..96d44dfc4 100644 --- a/fixtures/next/package.json +++ b/fixtures/next/package.json @@ -9,7 +9,7 @@ "hono": "4.12.9", "kitcn": "workspace:*", "lucide-react": "^1.49.0", - "next": "16.3.4", + "next": "16.3.6", "next-themes": "^0.4.6", "react": "19.2.8", "react-dom": "19.2.8", @@ -24,8 +24,8 @@ "@types/react": "^19", "@types/react-dom": "^19", "eslint": "9.39.5", - "eslint-config-next": "16.3.4", - "prettier": "^3.9.6", + "eslint-config-next": "16.3.6", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", "tailwindcss": "^4", "typescript": "^5", diff --git a/fixtures/next/tsconfig.json b/fixtures/next/tsconfig.json index f41b722b8..9e3169eea 100644 --- a/fixtures/next/tsconfig.json +++ b/fixtures/next/tsconfig.json @@ -94,12 +94,11 @@ }, "include": [ "next-env.d.ts", - "next.config.ts", "**/*.ts", "**/*.tsx", - "**/*.mts", ".next/types/**/*.ts", - ".next/dev/types/**/*.ts" + ".next/dev/types/**/*.ts", + "**/*.mts" ], "exclude": [ "node_modules" diff --git a/fixtures/start-auth/.cta.json b/fixtures/start-auth/.cta.json index e7b7cbebf..806826c70 100644 --- a/fixtures/start-auth/.cta.json +++ b/fixtures/start-auth/.cta.json @@ -3,12 +3,13 @@ "mode": "file-router", "typescript": true, "packageManager": "pnpm", + "git": true, + "install": true, + "intent": false, "includeExamples": false, "tailwind": true, "addOnOptions": {}, - "envVarValues": {}, - "git": true, - "install": true, + "projectPreset": "default", "routerOnly": false, "version": 1, "framework": "react", diff --git a/fixtures/start-auth/.gitignore b/fixtures/start-auth/.gitignore index 4c1f7bdbd..1f78428a8 100644 --- a/fixtures/start-auth/.gitignore +++ b/fixtures/start-auth/.gitignore @@ -4,6 +4,7 @@ dist dist-ssr *.local .env* +.nitro .tanstack .wrangler .output diff --git a/fixtures/start-auth/eslint.config.js b/fixtures/start-auth/eslint.config.js index ebffd3365..d49b5cb8f 100644 --- a/fixtures/start-auth/eslint.config.js +++ b/fixtures/start-auth/eslint.config.js @@ -8,6 +8,7 @@ export default [ rules: { "import/no-cycle": "off", "import/order": "off", + "import/consistent-type-specifier-style": "off", "sort-imports": "off", "@typescript-eslint/array-type": "off", "@typescript-eslint/require-await": "off", diff --git a/fixtures/start-auth/package.json b/fixtures/start-auth/package.json index 89bf8acf3..8c7c72855 100644 --- a/fixtures/start-auth/package.json +++ b/fixtures/start-auth/package.json @@ -8,9 +8,7 @@ "@tanstack/react-query": "5.95.2", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", "@tanstack/react-start": "latest", - "@tanstack/router-plugin": "latest", "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", @@ -29,19 +27,16 @@ "devDependencies": { "@tanstack/devtools-vite": "latest", "@tanstack/eslint-config": "latest", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", - "@types/node": "^20", + "@tanstack/router-cli": "^1.167.39", + "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "eslint": "^9", - "jsdom": "^30", - "prettier": "^3.9.6", + "eslint": "^10", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", - "typescript": "^6", + "typescript": "^6.0.2", "vite": "^8", - "vitest": "^4", "@types/bun": "latest", "@concavejs/cli": "0.0.1-alpha.14" }, @@ -50,9 +45,9 @@ "private": true, "scripts": { "dev": "vite dev --port 3005", + "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", - "test": "vitest run", "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"", "check": "prettier --check \"**/*.{ts,tsx,js,jsx}\"", diff --git a/fixtures/start-auth/tsr.config.json b/fixtures/start-auth/tsr.config.json new file mode 100644 index 000000000..8b6b6eddb --- /dev/null +++ b/fixtures/start-auth/tsr.config.json @@ -0,0 +1,3 @@ +{ + "target": "react" +} diff --git a/fixtures/start/.cta.json b/fixtures/start/.cta.json index e7b7cbebf..806826c70 100644 --- a/fixtures/start/.cta.json +++ b/fixtures/start/.cta.json @@ -3,12 +3,13 @@ "mode": "file-router", "typescript": true, "packageManager": "pnpm", + "git": true, + "install": true, + "intent": false, "includeExamples": false, "tailwind": true, "addOnOptions": {}, - "envVarValues": {}, - "git": true, - "install": true, + "projectPreset": "default", "routerOnly": false, "version": 1, "framework": "react", diff --git a/fixtures/start/.gitignore b/fixtures/start/.gitignore index 4c1f7bdbd..1f78428a8 100644 --- a/fixtures/start/.gitignore +++ b/fixtures/start/.gitignore @@ -4,6 +4,7 @@ dist dist-ssr *.local .env* +.nitro .tanstack .wrangler .output diff --git a/fixtures/start/eslint.config.js b/fixtures/start/eslint.config.js index ebffd3365..d49b5cb8f 100644 --- a/fixtures/start/eslint.config.js +++ b/fixtures/start/eslint.config.js @@ -8,6 +8,7 @@ export default [ rules: { "import/no-cycle": "off", "import/order": "off", + "import/consistent-type-specifier-style": "off", "sort-imports": "off", "@typescript-eslint/array-type": "off", "@typescript-eslint/require-await": "off", diff --git a/fixtures/start/package.json b/fixtures/start/package.json index 68a7ecd52..a3b57e86e 100644 --- a/fixtures/start/package.json +++ b/fixtures/start/package.json @@ -8,9 +8,7 @@ "@tanstack/react-query": "5.95.2", "@tanstack/react-router": "latest", "@tanstack/react-router-devtools": "latest", - "@tanstack/react-router-ssr-query": "latest", "@tanstack/react-start": "latest", - "@tanstack/router-plugin": "latest", "class-variance-authority": "^0.7.1", "cn": "^0.4.0", "convex": "1.44.0", @@ -28,19 +26,16 @@ "devDependencies": { "@tanstack/devtools-vite": "latest", "@tanstack/eslint-config": "latest", - "@testing-library/dom": "^10.4.1", - "@testing-library/react": "^16.3.2", - "@types/node": "^20", + "@tanstack/router-cli": "^1.167.39", + "@types/node": "^22", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", - "eslint": "^9", - "jsdom": "^30", - "prettier": "^3.9.6", + "eslint": "^10", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", - "typescript": "^6", + "typescript": "^6.0.2", "vite": "^8", - "vitest": "^4", "@types/bun": "latest", "@concavejs/cli": "0.0.1-alpha.14" }, @@ -49,9 +44,9 @@ "private": true, "scripts": { "dev": "vite dev --port 3005", + "generate-routes": "tsr generate", "build": "vite build", "preview": "vite preview", - "test": "vitest run", "lint": "eslint", "format": "prettier --write \"**/*.{ts,tsx,js,jsx}\"", "check": "prettier --check \"**/*.{ts,tsx,js,jsx}\"", diff --git a/fixtures/start/tsr.config.json b/fixtures/start/tsr.config.json new file mode 100644 index 000000000..8b6b6eddb --- /dev/null +++ b/fixtures/start/tsr.config.json @@ -0,0 +1,3 @@ +{ + "target": "react" +} diff --git a/fixtures/vite-auth/eslint.config.js b/fixtures/vite-auth/eslint.config.js index ef614d25c..42ae251ea 100644 --- a/fixtures/vite-auth/eslint.config.js +++ b/fixtures/vite-auth/eslint.config.js @@ -18,5 +18,8 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + 'react-refresh/only-export-components': 'off', + }, }, ]) diff --git a/fixtures/vite-auth/package.json b/fixtures/vite-auth/package.json index b2ff73a00..b1a0a20d5 100644 --- a/fixtures/vite-auth/package.json +++ b/fixtures/vite-auth/package.json @@ -22,17 +22,17 @@ }, "devDependencies": { "@eslint/js": "^10", - "@types/node": "^20", + "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", "eslint": "^10", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17", - "prettier": "^3.9.6", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", - "typescript": "~6", + "typescript": "~6.0.2", "typescript-eslint": "^8", "vite": "^8", "@types/bun": "latest", diff --git a/fixtures/vite-auth/src/components/theme-provider.tsx b/fixtures/vite-auth/src/components/theme-provider.tsx index 1349a0ca1..d67939444 100644 --- a/fixtures/vite-auth/src/components/theme-provider.tsx +++ b/fixtures/vite-auth/src/components/theme-provider.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-refresh/only-export-components */ import * as React from "react" type Theme = "dark" | "light" | "system" diff --git a/fixtures/vite-auth/tsconfig.app.json b/fixtures/vite-auth/tsconfig.app.json index 10c100774..c43baaf57 100644 --- a/fixtures/vite-auth/tsconfig.app.json +++ b/fixtures/vite-auth/tsconfig.app.json @@ -10,6 +10,7 @@ "types": [ "vite/client" ], + "allowArbitraryExtensions": true, "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/fixtures/vite-auth/tsconfig.node.json b/fixtures/vite-auth/tsconfig.node.json index 3edd7fdbd..b404446d8 100644 --- a/fixtures/vite-auth/tsconfig.node.json +++ b/fixtures/vite-auth/tsconfig.node.json @@ -5,12 +5,11 @@ "lib": [ "ES2023" ], - "module": "esnext", + "module": "nodenext", "types": [ "node" ], "skipLibCheck": true, - "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", diff --git a/fixtures/vite-auth/vite.config.ts b/fixtures/vite-auth/vite.config.ts index cfb9144ed..d4b677e14 100644 --- a/fixtures/vite-auth/vite.config.ts +++ b/fixtures/vite-auth/vite.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; -import path from "path" +import { resolve } from "node:path" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" import { defineConfig } from "vite" @@ -11,7 +11,7 @@ export default defineConfig({ resolve: { alias: { '@convex': fileURLToPath(new URL('./convex/shared', import.meta.url)), - "@": path.resolve(__dirname, "./src"), + "@": resolve(import.meta.dirname, "./src"), }, }, }) diff --git a/fixtures/vite/eslint.config.js b/fixtures/vite/eslint.config.js index ef614d25c..42ae251ea 100644 --- a/fixtures/vite/eslint.config.js +++ b/fixtures/vite/eslint.config.js @@ -18,5 +18,8 @@ export default defineConfig([ languageOptions: { globals: globals.browser, }, + rules: { + 'react-refresh/only-export-components': 'off', + }, }, ]) diff --git a/fixtures/vite/package.json b/fixtures/vite/package.json index 60bf61bb0..41a1ec199 100644 --- a/fixtures/vite/package.json +++ b/fixtures/vite/package.json @@ -21,17 +21,17 @@ }, "devDependencies": { "@eslint/js": "^10", - "@types/node": "^20", + "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^6", "eslint": "^10", "eslint-plugin-react-hooks": "^7.1.1", - "eslint-plugin-react-refresh": "^0.5.2", + "eslint-plugin-react-refresh": "^0.5.7", "globals": "^17", - "prettier": "^3.9.6", + "prettier": "^3.9.9", "prettier-plugin-tailwindcss": "^0.8.1", - "typescript": "~6", + "typescript": "~6.0.2", "typescript-eslint": "^8", "vite": "^8", "@types/bun": "latest", diff --git a/fixtures/vite/src/components/theme-provider.tsx b/fixtures/vite/src/components/theme-provider.tsx index 1349a0ca1..d67939444 100644 --- a/fixtures/vite/src/components/theme-provider.tsx +++ b/fixtures/vite/src/components/theme-provider.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react-refresh/only-export-components */ import * as React from "react" type Theme = "dark" | "light" | "system" diff --git a/fixtures/vite/tsconfig.app.json b/fixtures/vite/tsconfig.app.json index 10c100774..c43baaf57 100644 --- a/fixtures/vite/tsconfig.app.json +++ b/fixtures/vite/tsconfig.app.json @@ -10,6 +10,7 @@ "types": [ "vite/client" ], + "allowArbitraryExtensions": true, "skipLibCheck": true, "moduleResolution": "bundler", "allowImportingTsExtensions": true, diff --git a/fixtures/vite/tsconfig.node.json b/fixtures/vite/tsconfig.node.json index 3edd7fdbd..b404446d8 100644 --- a/fixtures/vite/tsconfig.node.json +++ b/fixtures/vite/tsconfig.node.json @@ -5,12 +5,11 @@ "lib": [ "ES2023" ], - "module": "esnext", + "module": "nodenext", "types": [ "node" ], "skipLibCheck": true, - "moduleResolution": "bundler", "allowImportingTsExtensions": true, "verbatimModuleSyntax": true, "moduleDetection": "force", diff --git a/fixtures/vite/vite.config.ts b/fixtures/vite/vite.config.ts index cfb9144ed..d4b677e14 100644 --- a/fixtures/vite/vite.config.ts +++ b/fixtures/vite/vite.config.ts @@ -1,6 +1,6 @@ import { fileURLToPath } from 'node:url'; -import path from "path" +import { resolve } from "node:path" import tailwindcss from "@tailwindcss/vite" import react from "@vitejs/plugin-react" import { defineConfig } from "vite" @@ -11,7 +11,7 @@ export default defineConfig({ resolve: { alias: { '@convex': fileURLToPath(new URL('./convex/shared', import.meta.url)), - "@": path.resolve(__dirname, "./src"), + "@": resolve(import.meta.dirname, "./src"), }, }, }) diff --git a/packages/kitcn/skills/kitcn/references/features/auth.md b/packages/kitcn/skills/kitcn/references/features/auth.md index 3fba69452..712fbf6f4 100644 --- a/packages/kitcn/skills/kitcn/references/features/auth.md +++ b/packages/kitcn/skills/kitcn/references/features/auth.md @@ -363,7 +363,7 @@ All from `kitcn/react`: |------|---------|-------------| | `useAuth()` | `{ hasSession, isAuthenticated, isLoading }` | Full auth state | | `useMaybeAuth()` | `boolean` | Has token (optimistic, may not be verified) | -| `useIsAuth()` | `boolean` | Server-verified authentication | +| `useIsAuth()` | `boolean` | Server-verified authentication (with `optimisticAuth`, also during the optimistic window) | | `useAuthGuard()` | `() => boolean` | Guard mutations, returns true if blocked | | `useConvexAuthRecovery()` | `{ recover, status, error }` | Rebind Convex auth after a transient token failure | @@ -389,7 +389,7 @@ All from `kitcn/react`: | Component | Renders when | |-----------|-------------| | `MaybeAuthenticated` | Has session token (optimistic) | -| `Authenticated` | Server-verified authenticated | +| `Authenticated` | Server-verified authenticated (with `optimisticAuth`, also during the optimistic window) | | `MaybeUnauthenticated` | No session token (optimistic) | | `Unauthenticated` | Server-verified not authenticated | @@ -414,16 +414,34 @@ All from `kitcn/react`: ``` `optimisticAuth` only opens auth-bound query gates for a held, unexpired JWT; -expired, opaque, and refused tokens stay closed. Enable +expired, opaque, and refused tokens stay closed. The window ends at the Convex +client's first auth result; use one `optimisticAuth` setting per client; no +optimism over a client the Start loader authenticated. Enable `onTokenIdentityChange` to refuse a JWT whose `sub` or `sessionId` differs from -the document identity before Convex or HTTP sees it. The client closes before -the callback runs, so reload the document there. +the document identity (whatever its `exp`) before Convex, HTTP or the Start +loader sees it. On a trip, every mounted provider hands out no token and +publishes unauthenticated; each guarded one closes its client, then calls the +callback once, so reload the document there. Browser only, page-wide: later +providers start tripped, and a guarded one that mounts or shows again on a +tripped page also closes its client and calls the callback once. Enabling the +guard on a tripped page delivers that close and callback once too; sign-in +mutations fail with `TOKEN_IDENTITY_CHANGED`. For multiple provider mounts, pass `tokenIdentityBaseline` as -`sub|sessionId`, or a getter returning the document's current identity. The -getter is checked for every admission, cached tokens included. +`sub|sessionId`, or a getter returning the document's current identity. Every +admission (SSR, fresh and cached tokens, sign-in, HTTP, Start loader) binds a +token to the page identity, the provider's own baseline and every mounted +provider's current getter answer; an opaque session token is only exchanged, +never handed to Convex. Held tokens are reconciled when a provider joins the +page and at every admission. A page that never enables the guard is +unchanged; once a guarded provider establishes the page identity it persists +until reload, binding every provider and the Start loader, even after that +provider unmounts. Two kitcn versions or revisions on one page (dev HMR +across revisions included) are unsupported: no shared page identity until +reload. `onTokenIdentityAdmitted(token)` observes admitted JWTs so the app can update -that shared baseline. All three identity options require +that shared baseline. Admission rechecks the baseline after the callback before +publishing or handing out the token. All three identity options require `onTokenIdentityChange`. For `@convex-dev/auth` (React Native): diff --git a/packages/kitcn/src/auth-client/client-settlement.ts b/packages/kitcn/src/auth-client/client-settlement.ts new file mode 100644 index 000000000..0debeeb26 --- /dev/null +++ b/packages/kitcn/src/auth-client/client-settlement.ts @@ -0,0 +1,55 @@ +import type { ConvexReactClient } from 'convex/react'; +import { pageRegistry } from '../react/identity-guard-registry'; + +// A Convex client's optimistic window ends at its first auth result: the +// value Convex reports through the `onChange` it is given in `setAuth`. It is +// recorded there, not in a React effect, so a result reported just before an +// unmount still counts, every provider over the client hears it, and a local +// session change (which reports nothing) does not. + +export function watchClientSettlement(client: ConvexReactClient) { + const registry = pageRegistry(); + if (!registry || registry.watchedClients.has(client)) return; + registry.watchedClients.add(client); + const setAuth = client.setAuth.bind(client); + client.setAuth = (fetchToken, onChange, onRefreshChange) => + setAuth( + fetchToken, + (isAuthenticated) => { + settleClient(client); + onChange?.(isAuthenticated); + }, + onRefreshChange + ); +} + +export function settleClient(client: object) { + const registry = pageRegistry(); + if (!registry || registry.settledClients.has(client)) return; + registry.settledClients.add(client); + for (const listener of [ + ...(registry.settlementListeners.get(client) ?? []), + ]) { + listener(); + } +} + +export const isClientSettled = (client: object) => + pageRegistry()?.settledClients.has(client) ?? false; + +export const subscribeClientSettlement = ( + client: object, + listener: () => void +) => { + const registry = pageRegistry(); + if (!registry) return () => {}; + let clientListeners = registry.settlementListeners.get(client); + if (!clientListeners) { + clientListeners = new Set(); + registry.settlementListeners.set(client, clientListeners); + } + clientListeners.add(listener); + return () => { + clientListeners.delete(listener); + }; +}; diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx index e32de0dd8..9054d3009 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx @@ -1,13 +1,26 @@ import { act, render, renderHook, waitFor } from '@testing-library/react'; -import type { ReactNode } from 'react'; +import { type ReactNode, Suspense, useEffect } from 'react'; +import { flushSync } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { syncConvexAuthForStartLoader } from '../auth-start'; +import { AuthMutationError } from '../crpc/auth-error'; +import { createAuthMutations } from '../react/auth-mutations'; +import { writeAuthSessionFallbackToken } from '../react/auth-session-fallback'; import type { AuthStore } from '../react/auth-store'; import { decodeJwtExp, useAuth, useAuthStore, + useAuthValue, useConvexAuthRecovery, useFetchAccessToken, } from '../react/auth-store'; +import { + isDocumentTripped, + resetDocumentTripForTests, + tripDocument, +} from '../react/identity-guard-registry'; +import { isClientSettled } from './client-settlement'; import { ConvexAuthProvider } from './convex-auth-provider'; const makeJwt = (expSecondsFromNow: number) => { @@ -37,6 +50,7 @@ describe('ConvexAuthProvider', () => { }); afterEach(() => { + resetDocumentTripForTests(); window.sessionStorage.clear(); try { window.history.replaceState({}, '', originalHref); @@ -1935,6 +1949,2152 @@ describe('ConvexAuthProvider', () => { }); }); + describe('identity guard admission', () => { + const identityJwt = (sub: string, sessionId: string, expSeconds = 3600) => + `x.${btoa( + JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expSeconds, + sessionId, + sub, + }) + )}.z`; + const claim = (token: string) => { + const payload = JSON.parse(atob(token.split('.')[1]!)); + return `${payload.sub}|${payload.sessionId}`; + }; + + type Binding = { + fetchToken: (args: { + forceRefreshToken: boolean; + }) => Promise; + onChange: (isAuthenticated: boolean) => void; + }; + + /** + * A Convex client stub that records every binding (the fetcher and + * confirmation callback Convex receives), so a test drives the real + * confirmation transitions. Several providers may share one. + */ + const makeConvexClient = () => { + const bindings: Binding[] = []; + const close = mock(async () => {}); + const client = { + setAuth: ( + fetchToken: Binding['fetchToken'], + onChange: Binding['onChange'] + ) => { + bindings.push({ fetchToken, onChange }); + }, + clearAuth: () => {}, + close, + }; + return { bindings, client, close }; + }; + + /** + * A provider over `convex` (a fresh client stub by default). `tokens` are + * what the token endpoint answers, in order. + */ + const convexHarness = ({ + authClientExtras = {}, + baseline, + baselineHolder, + convex = makeConvexClient(), + extraHook = () => null, + guard = true, + guardHolder, + initialToken, + onTokenIdentityAdmitted, + onTokenIdentityChange = mock(() => {}), + optimisticAuth = false, + persistedSessionAnswer = { data: null, error: null }, + session = 'active', + sessionRef = { current: session }, + tokenEndpoint, + tokens = [], + }: { + authClientExtras?: Record; + baseline?: string | null | (() => string | null); + /** A baseline prop the test changes; rerender the harness after. */ + baselineHolder?: { current: string | null }; + convex?: Pick< + ReturnType, + 'bindings' | 'client' | 'close' + >; + extraHook?: () => unknown; + guard?: boolean; + /** Guardedness the test changes; rerender the harness after. */ + guardHolder?: { current: boolean }; + initialToken?: string; + onTokenIdentityAdmitted?: (token: string) => void; + onTokenIdentityChange?: () => void; + optimisticAuth?: boolean; + persistedSessionAnswer?: unknown; + session?: 'active' | 'none' | 'pending'; + /** Mutable session state; rerender the harness after changing it. */ + sessionRef?: { current: 'active' | 'none' | 'pending' }; + /** Replaces the `tokens` queue, e.g. to hold a request in flight. */ + tokenEndpoint?: () => Promise<{ data: { token: string | null } }>; + tokens?: Array; + }) => { + const { bindings, client, close } = convex; + const queue = [...tokens]; + const convexToken = mock( + tokenEndpoint ?? + (async () => ({ + data: { token: queue.shift() ?? null }, + })) + ); + const getSession = mock(async () => null); + const $fetch = mock( + async (..._args: unknown[]) => persistedSessionAnswer + ); + const sessionResults = { + active: { + data: { session: { id: 'session' }, user: { id: 'user' } }, + isPending: false, + }, + none: { data: null, isPending: false }, + pending: { data: null, isPending: true }, + }; + const authClient = { + useSession: () => sessionResults[sessionRef.current], + convex: { token: convexToken }, + getSession, + $fetch, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + ...authClientExtras, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + let recovery: ReturnType | undefined; + const view = renderHook( + () => { + recovery = useConvexAuthRecovery(); + return { auth: useAuth(), extra: extraHook(), store: useAuthStore() }; + }, + { wrapper } + ); + const latest = () => { + const binding = bindings.at(-1); + if (!binding) throw new Error('setAuth was not called'); + return binding; + }; + return { + $fetch, + bindings, + close, + convexToken, + getSession, + onTokenIdentityChange, + rerender: () => view.rerender(), + result: view.result, + unmount: view.unmount, + fetch: async (forceRefreshToken: boolean) => { + let token: string | null = null; + await act(async () => { + token = await latest().fetchToken({ forceRefreshToken }); + }); + return token; + }, + recover: async () => { + const before = bindings.length; + await act(async () => { + void recovery!.recover({ timeoutMs: 1000 }).catch(() => {}); + }); + await waitFor(() => { + expect(bindings.length).toBeGreaterThan(before); + }); + }, + report: (isAuthenticated: boolean) => + act(async () => { + latest().onChange(isAuthenticated); + }), + }; + }; + + const flush = () => + act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + test('a held SSR token of another identity never opens the optimistic gate and trips the guard', async () => { + const harness = convexHarness({ + baseline: 'user_a|session_a', + initialToken: identityJwt('user_b', 'session_b'), + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(harness.result.current.store.get('token')).toBeNull(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + // With the token withheld and the session pending, Convex is not even + // bound yet: it is never handed the refused token. + expect(harness.bindings).toHaveLength(0); + }); + + test('a held SSR token of the baseline identity still opens the optimistic gate', async () => { + const token = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ + baseline: 'user_a|session_a', + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(harness.result.current.store.get('token')).toBe(token); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + }); + + test('a token seeded into the store opens the optimistic gate only if the guard admits it', async () => { + const harness = convexHarness({ + baseline: 'user_a|session_a', + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + await act(async () => { + harness.result.current.store.set( + 'token', + identityJwt('user_a', 'session_a') + ); + }); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + + // The gate admits through the one admission: another identity is + // refused there and trips the page. + await act(async () => { + harness.result.current.store.set( + 'token', + identityJwt('user_b', 'session_b') + ); + }); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('an opaque persisted session token is not restored while an identity is established', async () => { + writeAuthSessionFallbackToken('opaque-session-token'); + const harness = convexHarness({ + baseline: 'user_a|session_a', + session: 'none', + }); + await flush(); + + expect(harness.getSession).toHaveBeenCalledTimes(0); + expect(harness.$fetch).toHaveBeenCalledTimes(0); + expect(harness.result.current.store.get('token')).toBeNull(); + }); + + test('a persisted JWT of the established identity is restored', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + writeAuthSessionFallbackToken(tokenForA); + const harness = convexHarness({ + baseline: 'user_a|session_a', + persistedSessionAnswer: { + data: { session: { id: 'session_a' }, user: { id: 'user_a' } }, + error: null, + }, + session: 'none', + }); + await flush(); + + expect(harness.$fetch).toHaveBeenCalledTimes(1); + expect(harness.$fetch.mock.calls[0]![1]).toMatchObject({ + headers: { Authorization: `Bearer ${tokenForA}` }, + }); + expect(harness.result.current.store.get('token')).toBe(tokenForA); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + }); + + test('a persisted JWT of another identity is not restored', async () => { + writeAuthSessionFallbackToken(identityJwt('user_b', 'session_b')); + const harness = convexHarness({ + baseline: 'user_a|session_a', + session: 'none', + }); + await flush(); + + expect(harness.$fetch).toHaveBeenCalledTimes(0); + expect(harness.result.current.store.get('token')).toBeNull(); + }); + + test('concurrent first tokens: the losing identity is never cached', async () => { + const document: { identity: string | null } = { identity: null }; + const claimDocument = (token: string) => { + document.identity ??= claim(token); + }; + const tokenForA = identityJwt('user_a', 'session_a'); + const tokenForB = identityJwt('user_b', 'session_b'); + const first = convexHarness({ + baseline: () => document.identity, + onTokenIdentityAdmitted: claimDocument, + tokens: [tokenForA], + }); + const second = convexHarness({ + baseline: () => document.identity, + onTokenIdentityAdmitted: claimDocument, + tokens: [tokenForB], + }); + await flush(); + const published: Array = []; + const unsubscribe = second.result.current.store.subscribe( + 'token', + (value: string | null) => { + published.push(value); + } + ); + + let results: Array = []; + await act(async () => { + results = await Promise.all([ + first.bindings.at(-1)!.fetchToken({ forceRefreshToken: false }), + second.bindings.at(-1)!.fetchToken({ forceRefreshToken: false }), + ]); + }); + unsubscribe(); + + // B is refused before it is ever cached. Its refusal trips the + // document, so the first provider's hand-out answers null too. + expect(results).toEqual([null, null]); + expect(published).not.toContain(tokenForB); + // The trip reaches every mounted guarded provider once. + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(first.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a trip publishes a terminal unauthenticated state that Convex cannot reopen', async () => { + const harness = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + expect(await harness.fetch(false)).not.toBeNull(); + await harness.report(true); + expect(harness.result.current.store.get('isAuthenticated')).toBe(true); + + expect(await harness.fetch(true)).toBeNull(); + + expect(harness.result.current.store.get('token')).toBeNull(); + expect(harness.result.current.store.get('isAuthenticated')).toBe(false); + expect(harness.result.current.store.get('isLoading')).toBe(false); + await harness.report(true); + expect(harness.result.current.store.get('isAuthenticated')).toBe(false); + }); + + test('a later provider over a tripped client starts tripped and still reports the trip once', async () => { + const convex = makeConvexClient(); + const first = convexHarness({ + convex, + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + await first.fetch(false); + expect(await first.fetch(true)).toBeNull(); + first.unmount(); + + // A remount in the same document, over the same (closed) client. + const second = convexHarness({ + baseline: 'user_a|session_a', + convex, + initialToken: identityJwt('user_a', 'session_a'), + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(second.result.current.store.get('token')).toBeNull(); + expect(second.result.current.auth.isAuthenticated).toBe(false); + // A guarded provider joining a page tripped earlier reports the trip + // too, so the app learns of a trip even one that happened while no + // guarded provider was mounted (once; a rerender does not report it + // again). + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(convex.close).toHaveBeenCalledTimes(2); + second.rerender(); + await flush(); + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a sign-in on a tripped document surfaces an error and publishes nothing', async () => { + const signedIn = identityJwt('user_a', 'session_a', 7200); + const authClientExtras = { + signIn: { email: async () => ({ data: { token: signedIn } }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const harness = convexHarness({ + authClientExtras, + extraHook: () => mutations.useSignInMutationOptions(), + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + await harness.fetch(false); + expect(await harness.fetch(true)).toBeNull(); + + const published: unknown[] = []; + const store = harness.result.current.store; + const unsubscribeAuth = store.subscribe( + 'isAuthenticated', + (value: boolean) => published.push(value) + ); + const unsubscribeToken = store.subscribe( + 'token', + (value: string | null) => published.push(value) + ); + const options = harness.result.current.extra as { + mutationFn: (args: unknown) => Promise; + }; + let failure: unknown; + await act(async () => { + failure = await options + .mutationFn({ email: 'a@example.invalid', password: 'x' }) + .then( + () => null, + (error: unknown) => error + ); + }); + unsubscribeAuth(); + expect(failure).toBeInstanceOf(AuthMutationError); + expect((failure as AuthMutationError).code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + unsubscribeToken(); + + expect(published).not.toContain(true); + expect(published).not.toContain(signedIn); + expect(store.get('isAuthenticated')).toBe(false); + expect(store.get('token')).toBeNull(); + }); + + test('nothing writes a token back after a trip, not even the hydration fallback', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ + initialToken: tokenForA, + session: 'pending', + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.result.current.store.get('token')).toBeNull(); + expect(harness.result.current.store.get('isAuthenticated')).toBe(false); + }); + + const deferred = () => { + let resolve!: (value: T) => void; + const promise = new Promise((r) => { + resolve = r; + }); + return { promise, resolve }; + }; + + test('an in-flight fetch in an unguarded provider hands out nothing after a trip', async () => { + const response = deferred<{ data: { token: string | null } }>(); + const unguarded = convexHarness({ + guard: false, + tokenEndpoint: () => response.promise, + }); + const guarded = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + let pending!: Promise; + act(() => { + pending = unguarded.bindings.at(-1)!.fetchToken({ + forceRefreshToken: true, + }); + }); + await guarded.fetch(false); + expect(await guarded.fetch(true)).toBeNull(); + + let handedOut: string | null = 'unset'; + await act(async () => { + response.resolve({ data: { token: makeJwt(3600) } }); + handedOut = await pending; + }); + expect(handedOut).toBeNull(); + expect(unguarded.result.current.store.get('token')).toBeNull(); + }); + + const signInCases = [ + ['sign-in', 'email'], + ['social sign-in', 'social'], + ['sign-up', 'signUp'], + ] as const; + + test('a trip during a sign-in, sign-up or social sign-in fails it before anything is published', async () => { + for (const [, method] of signInCases) { + resetDocumentTripForTests(); + const tokenForA = identityJwt('user_a', 'session_a', 7200); + const session = deferred(); + const authClientExtras = { + getSession: () => session.promise, + signIn: { + email: async () => ({ data: { token: tokenForA } }), + social: async () => ({ data: { token: tokenForA } }), + }, + signUp: { email: async () => ({ data: { token: tokenForA } }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const useSignInHooks = () => ({ + email: mutations.useSignInMutationOptions(), + signUp: mutations.useSignUpMutationOptions(), + social: mutations.useSignInSocialMutationOptions(), + }); + const harness = convexHarness({ + authClientExtras, + extraHook: useSignInHooks, + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + const store = harness.result.current.store; + const published: unknown[] = []; + const unsubscribe = store.subscribe( + 'isAuthenticated', + (value: boolean) => published.push(value) + ); + const hooks = harness.result.current.extra as Record< + string, + { mutationFn: (args: unknown) => Promise } + >; + let outcome!: Promise; + act(() => { + outcome = hooks[method]!.mutationFn({}).then( + () => null, + (error: unknown) => error + ); + }); + let failure: unknown; + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + tripDocument(); + session.resolve({ data: null }); + failure = await outcome; + }); + unsubscribe(); + + expect((failure as AuthMutationError)?.code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + expect(published).not.toContain(true); + harness.unmount(); + } + }); + + test('waiting for auth after a sign-in fails at once when the document trips', async () => { + const authClientExtras = { + signIn: { email: async () => ({ data: {} }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const harness = convexHarness({ + authClientExtras, + extraHook: () => mutations.useSignInMutationOptions(), + }); + await flush(); + const options = harness.result.current.extra as { + mutationFn: (args: unknown) => Promise; + }; + const started = Date.now(); + let outcome!: Promise; + act(() => { + outcome = options.mutationFn({}).then( + () => null, + (error: unknown) => error + ); + }); + let failure: unknown; + await act(async () => { + await new Promise((r) => setTimeout(r, 100)); + tripDocument(); + failure = await outcome; + }); + + expect((failure as AuthMutationError)?.code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + expect(Date.now() - started).toBeLessThan(2000); + }, 10_000); + + test('a JWT a sign-in returns for another identity is refused and trips the document', async () => { + const tokenForB = identityJwt('user_b', 'session_b', 7200); + const authClientExtras = { + signIn: { email: async () => ({ data: { token: tokenForB } }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const harness = convexHarness({ + authClientExtras, + extraHook: () => mutations.useSignInMutationOptions(), + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + const store = harness.result.current.store; + const published: unknown[] = []; + const unsubscribeToken = store.subscribe( + 'token', + (value: string | null) => published.push(value) + ); + const unsubscribeAuth = store.subscribe( + 'isAuthenticated', + (value: boolean) => published.push(value) + ); + const options = harness.result.current.extra as { + mutationFn: (args: unknown) => Promise; + }; + let failure: unknown; + await act(async () => { + failure = await options.mutationFn({}).then( + () => null, + (error: unknown) => error + ); + }); + unsubscribeToken(); + unsubscribeAuth(); + + expect((failure as AuthMutationError)?.code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + expect(published).not.toContain(tokenForB); + expect(published).not.toContain(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(store.get('token')).toBeNull(); + }); + + test("a client's auth result reported before React commits still ends its optimistic window", async () => { + for (const result of [false, true]) { + const token = makeJwt(3600); + const convex = makeConvexClient(); + const first = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + act(() => { + convex.bindings.at(-1)!.onChange(result); + first.unmount(); + }); + + const second = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(second.result.current.auth.isAuthenticated).toBe(false); + second.unmount(); + } + }); + + test("one provider's refusal ends the optimistic window of another over the same client", async () => { + const token = makeJwt(3600); + const convex = makeConvexClient(); + const first = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + const second = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(first.result.current.auth.isAuthenticated).toBe(true); + expect(second.result.current.auth.isAuthenticated).toBe(true); + + await act(async () => { + convex.bindings[0]!.onChange(false); + }); + await flush(); + + expect(second.result.current.auth.isAuthenticated).toBe(false); + }); + + test('losing the local session does not end a fresh client optimistic window', async () => { + const token = makeJwt(3600); + const convex = makeConvexClient(); + const sessionRef = { current: 'active' as 'active' | 'none' | 'pending' }; + const first = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + sessionRef, + }); + await flush(); + expect(first.result.current.auth.isAuthenticated).toBe(true); + sessionRef.current = 'none'; + await act(async () => first.rerender()); + await flush(); + first.unmount(); + + const second = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(second.result.current.auth.isAuthenticated).toBe(true); + }); + + test('the settlement wrapper is installed only with optimisticAuth, once', async () => { + class StubClient { + setAuth(_fetchToken: unknown, _onChange?: unknown) {} + clearAuth() {} + close = async () => {}; + } + const authClient = { + useSession: () => ({ data: null, isPending: true }), + convex: { token: async () => ({ data: {} }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const mount = (client: StubClient, optimisticAuth: boolean) => + renderHook(() => useAuth(), { + wrapper: ({ children }: { children: ReactNode }) => ( + + {children} + + ), + }); + + const plain = new StubClient(); + mount(plain, false); + await flush(); + expect(Object.hasOwn(plain, 'setAuth')).toBe(false); + expect(plain.setAuth).toBe(StubClient.prototype.setAuth); + + const optimistic = new StubClient(); + mount(optimistic, true); + await flush(); + const wrapped = optimistic.setAuth; + expect(Object.hasOwn(optimistic, 'setAuth')).toBe(true); + mount(optimistic, true); + await flush(); + expect(optimistic.setAuth).toBe(wrapped); + }); + + test('the Start loader hands no token to a fresh client after a trip', async () => { + const tokenForB = identityJwt('user_b', 'session_b'); + const harness = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + tokens: [tokenForB], + }); + await flush(); + await harness.fetch(false); + expect(await harness.fetch(true)).toBeNull(); + + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + const state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => tokenForB, + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(fresh.setAuth).toHaveBeenCalledTimes(0); + }); + + test('the Start loader refuses a token of another identity than the document admitted', async () => { + const harness = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + expect(await harness.fetch(false)).not.toBeNull(); + + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + let state: unknown; + // Refusing trips the page, which quarantines the mounted provider. + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => identityJwt('user_b', 'session_b'), + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(fresh.setAuth).toHaveBeenCalledTimes(0); + }); + + test('a trip in a descendant effect is not overwritten by a stale optimistic publication', async () => { + const published: boolean[] = []; + const useTripOnMount = () => { + const store = useAuthStore(); + useEffect(() => { + const unsubscribe = store.subscribe( + 'isAuthenticated', + (value: boolean) => published.push(value) + ); + tripDocument(); + return unsubscribe; + }, [store]); + }; + const harness = convexHarness({ + extraHook: useTripOnMount, + guard: false, + initialToken: makeJwt(3600), + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(published).not.toContain(true); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + }); + + test('a settlement in a descendant effect is not overwritten by a stale optimistic publication', async () => { + const first = makeJwt(3600); + const second = makeJwt(3500); + const convex = makeConvexClient(); + const published: boolean[] = []; + const useSettleOnSecondToken = () => { + const store = useAuthStore(); + const token = useAuthValue('token'); + useEffect(() => { + if (token !== second) return; + const unsubscribe = store.subscribe( + 'isAuthenticated', + (value: boolean) => published.push(value) + ); + convex.bindings.at(-1)!.onChange(false); + // Published before AuthStateSync's effect in the same commit runs, + // so a stale optimistic publication would show up as `true`. + store.set('isAuthenticated', false); + return unsubscribe; + }, [store, token]); + }; + const harness = convexHarness({ + convex, + extraHook: useSettleOnSecondToken, + guard: false, + initialToken: first, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + + await act(async () => { + harness.result.current.store.set('token', second); + }); + await flush(); + + expect(published).not.toContain(true); + }); + + test('a sign-in fails if the document moved identity before authenticated is published', async () => { + const document = { identity: 'user_a|session_a' }; + const tokenForA = identityJwt('user_a', 'session_a', 7200); + const session = deferred(); + const authClientExtras = { + getSession: () => session.promise, + signIn: { email: async () => ({ data: { token: tokenForA } }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const harness = convexHarness({ + authClientExtras, + baseline: () => document.identity, + extraHook: () => mutations.useSignInMutationOptions(), + }); + await flush(); + const store = harness.result.current.store; + const published: unknown[] = []; + const unsubscribe = store.subscribe('isAuthenticated', (value: boolean) => + published.push(value) + ); + const options = harness.result.current.extra as { + mutationFn: (args: unknown) => Promise; + }; + let outcome!: Promise; + act(() => { + outcome = options.mutationFn({}).then( + () => null, + (error: unknown) => error + ); + }); + let failure: unknown; + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + document.identity = 'user_b|session_b'; + session.resolve({ data: null }); + failure = await outcome; + }); + unsubscribe(); + + expect((failure as AuthMutationError)?.code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + expect(published).not.toContain(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('the Start loader refuses another identity before the provider fetches, from its baseline or held token', async () => { + for (const setup of [ + { baseline: 'user_a|session_a' }, + { initialToken: identityJwt('user_a', 'session_a') }, + ]) { + resetDocumentTripForTests(); + const harness = convexHarness({ ...setup, session: 'pending' }); + await flush(); + + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + let state: unknown; + // Refusing trips the page, which quarantines the mounted provider. + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => identityJwt('user_b', 'session_b'), + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(fresh.setAuth).toHaveBeenCalledTimes(0); + expect(isDocumentTripped()).toBe(true); + harness.unmount(); + } + }); + + test('a client the Start loader authenticated gets no optimistic window; a fresh one does', async () => { + const token = makeJwt(3600); + const loaded = makeConvexClient(); + await syncConvexAuthForStartLoader({ + convex: loaded.client as any, + getToken: async () => token, + }); + const overLoaded = convexHarness({ + convex: loaded, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(overLoaded.result.current.auth.isAuthenticated).toBe(false); + + const fresh = convexHarness({ + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(fresh.result.current.auth.isAuthenticated).toBe(true); + }); + + test('a trip from the Start loader calls the mounted guarded provider once', async () => { + const harness = convexHarness({ + baseline: 'user_a|session_a', + session: 'pending', + }); + await flush(); + + await act(async () => { + await syncConvexAuthForStartLoader({ + convex: { clearAuth: () => {}, setAuth: () => {} }, + getToken: async () => identityJwt('user_b', 'session_b'), + }); + }); + + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + test('a trip calls every mounted guarded provider once, even when one callback throws', async () => { + const consoleError = spyOn(console, 'error').mockImplementation(() => {}); + try { + const first = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + const throwing = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + onTokenIdentityChange: mock(() => { + throw new Error('callback failed'); + }), + }); + const third = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + await first.fetch(false); + + expect(await first.fetch(true)).toBeNull(); + + for (const harness of [first, throwing, third]) { + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + } + } finally { + consoleError.mockRestore(); + } + }); + + const claimsJwt = (claims: Record) => + `x.${btoa(JSON.stringify(claims))}.z`; + const foreignWithoutExpiry = [ + ['exp 0', claimsJwt({ exp: 0, sessionId: 'session_b', sub: 'user_b' })], + ['no exp', claimsJwt({ sessionId: 'session_b', sub: 'user_b' })], + ] as const; + + test('a JWT of another identity is refused whatever its exp: refresh and HTTP', async () => { + for (const [, tokenForB] of foreignWithoutExpiry) { + resetDocumentTripForTests(); + const harness = convexHarness({ + extraHook: useFetchAccessToken, + initialToken: identityJwt('user_a', 'session_a'), + tokens: [tokenForB], + }); + await flush(); + const published: Array = []; + const unsubscribe = harness.result.current.store.subscribe( + 'token', + (value: string | null) => published.push(value) + ); + await harness.fetch(false); + + expect(await harness.fetch(true)).toBeNull(); + const httpFetcher = harness.result.current.extra as () => Promise< + string | null + >; + let http: string | null = 'unset'; + await act(async () => { + http = await httpFetcher(); + }); + unsubscribe(); + + expect(http).toBeNull(); + expect(published).not.toContain(tokenForB); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + harness.unmount(); + } + }); + + test('a held SSR JWT of another identity is withheld whatever its exp', async () => { + for (const [, tokenForB] of foreignWithoutExpiry) { + resetDocumentTripForTests(); + const harness = convexHarness({ + baseline: 'user_a|session_a', + initialToken: tokenForB, + session: 'pending', + }); + await flush(); + + expect(harness.result.current.store.get('token')).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + harness.unmount(); + } + }); + + test('a sign-in returning a JWT of another identity fails whatever its exp', async () => { + for (const [, tokenForB] of foreignWithoutExpiry) { + resetDocumentTripForTests(); + const authClientExtras = { + signIn: { email: async () => ({ data: { token: tokenForB } }) }, + }; + const mutations = createAuthMutations(authClientExtras as any); + const harness = convexHarness({ + authClientExtras, + extraHook: () => mutations.useSignInMutationOptions(), + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + const options = harness.result.current.extra as { + mutationFn: (args: unknown) => Promise; + }; + let failure: unknown; + await act(async () => { + failure = await options.mutationFn({}).then( + () => null, + (error: unknown) => error + ); + }); + + expect((failure as AuthMutationError)?.code).toBe( + 'TOKEN_IDENTITY_CHANGED' + ); + expect(harness.result.current.store.get('token')).not.toBe(tokenForB); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + harness.unmount(); + } + }); + + test('the Start loader refuses a JWT of another identity whatever its exp', async () => { + for (const [, tokenForB] of foreignWithoutExpiry) { + resetDocumentTripForTests(); + const harness = convexHarness({ + baseline: 'user_a|session_a', + session: 'pending', + }); + await flush(); + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => tokenForB, + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(fresh.setAuth).toHaveBeenCalledTimes(0); + harness.unmount(); + } + }); + + test('a JWT without exp of the established identity is handed out but never opens the optimistic gate', async () => { + const tokenForA = claimsJwt({ sessionId: 'session_a', sub: 'user_a' }); + const handedOut = convexHarness({ + baseline: 'user_a|session_a', + tokens: [tokenForA], + }); + await flush(); + expect(await handedOut.fetch(false)).toBe(tokenForA); + expect(handedOut.onTokenIdentityChange).toHaveBeenCalledTimes(0); + + const held = convexHarness({ + baseline: 'user_a|session_a', + initialToken: tokenForA, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(held.result.current.auth.isAuthenticated).toBe(false); + }); + + test('with an identity established, a JWT without one is refused', async () => { + const onTokenIdentityAdmitted = mock((_token: string) => {}); + const harness = convexHarness({ + baseline: 'user_a|session_a', + onTokenIdentityAdmitted, + tokens: [makeJwt(3600)], + }); + await flush(); + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(onTokenIdentityAdmitted).toHaveBeenCalledTimes(0); + }); + + test('an admitted identity refuses a later JWT without one', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ tokens: [tokenForA, makeJwt(7200)] }); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('before any identity, a JWT without one is handed out and announced without setting it', async () => { + const identityless = makeJwt(3600); + const tokenForB = identityJwt('user_b', 'session_b', 7200); + const onTokenIdentityAdmitted = mock((_token: string) => {}); + const harness = convexHarness({ + onTokenIdentityAdmitted, + tokens: [identityless, tokenForB], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(identityless); + expect(await harness.fetch(true)).toBe(tokenForB); + expect(onTokenIdentityAdmitted.mock.calls).toEqual([ + [identityless], + [tokenForB], + ]); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + }); + + /** + * A client whose auth runs through Convex's own AuthenticationManager, so + * the SDK's transparent retry (refetch and re-authenticate after an auth + * error) happens between the provider's token hand-outs and the single + * refusal React finally sees. `refuse` is the server rejecting the token + * it was last sent. + */ + const makeManagedConvexClient = async () => { + const entry = import.meta.resolve('convex/browser'); + const { AuthenticationManager } = await import( + new URL('./sync/authentication_manager.js', entry).href + ); + let authVersion = 0; + let auth: string | null = null; + const clear = () => { + auth = null; + authVersion += 1; + }; + const syncState = { + clearAuth: clear, + getAuth: () => (auth ? { tokenType: 'User', value: auth } : undefined), + hasAuth: () => auth !== null, + isCurrentOrNewerAuthVersion: (version: number) => + version >= authVersion, + isNewAuth: (value: string) => auth !== value, + markAuthCompletion: () => {}, + }; + const quiet = () => {}; + const manager = new AuthenticationManager( + syncState, + { + authenticate: (token: string) => { + auth = token; + authVersion += 1; + }, + clearAuth: clear, + pauseSocket: quiet, + resumeSocket: quiet, + stopSocket: async () => {}, + tryRestartSocket: quiet, + }, + { + initialAuthTokenReuse: false, + logger: { error: quiet, log: quiet, logVerbose: quiet, warn: quiet }, + refreshTokenLeewaySeconds: 10, + } + ); + const bindings: Binding[] = []; + const close = mock(async () => {}); + const client = { + setAuth: ( + fetchToken: Binding['fetchToken'], + onChange: Binding['onChange'] + ) => { + bindings.push({ fetchToken, onChange }); + void manager.setConfig(fetchToken, onChange); + }, + clearAuth: () => manager.stop(), + close, + }; + const refuse = () => + act(async () => { + manager.onAuthError({ + authUpdateAttempted: true, + baseVersion: authVersion - 1, + error: 'invalid token', + type: 'AuthError', + }); + await new Promise((r) => setTimeout(r, 0)); + }); + // The server confirms the token it was last sent. + const confirm = () => + act(async () => { + manager.onTransition({ + clientClockSkew: 0, + endVersion: { identity: authVersion }, + startVersion: { identity: authVersion - 1 }, + }); + await new Promise((r) => setTimeout(r, 0)); + }); + return { bindings, client, close, confirm, refuse }; + }; + + test('a refused token after a prior confirmation never reopens the gate', async () => { + const tokenA = makeJwt(3600); + const tokenB = makeJwt(3500); + // Under a minute left: the recovery fetch goes back to the endpoint. + const tokenC = makeJwt(30); + const convex = await makeManagedConvexClient(); + const harness = convexHarness({ + convex, + guard: false, + initialToken: tokenA, + optimisticAuth: true, + tokens: [tokenB, tokenC, null, tokenB], + }); + await flush(); + // The server confirms A; Convex refreshes to B. + await convex.confirm(); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + + // The server refuses B; the SDK retries with C; the server refuses C. + await convex.refuse(); + await convex.refuse(); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + + // Recovery obtains B again while Convex confirms it. + await harness.recover(); + await flush(); + expect(harness.result.current.store.get('token')).toBe(tokenB); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + }); + + test('no number of refusals lets a refused token reopen the gate', async () => { + const tokens = Array.from({ length: 17 }, (_, index) => + makeJwt(3600 - index) + ); + const harness = convexHarness({ + guard: false, + initialToken: tokens[0], + optimisticAuth: true, + tokens: [...tokens.slice(1), tokens[0]!], + }); + await flush(); + expect(await harness.fetch(false)).toBe(tokens[0]); + await harness.report(false); + for (const token of tokens.slice(1)) { + await harness.recover(); + expect(await harness.fetch(true)).toBe(token); + await harness.report(false); + } + + await harness.recover(); + expect(await harness.fetch(true)).toBe(tokens[0]); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + }); + + test('a remount over a client that already reported auth gets no optimism', async () => { + const token = makeJwt(3600); + const convex = makeConvexClient(); + const first = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + }); + await flush(); + expect(await first.fetch(false)).toBe(token); + await first.report(true); + first.unmount(); + + const second = convexHarness({ + convex, + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(second.result.current.auth.isAuthenticated).toBe(false); + expect(second.result.current.auth.isLoading).toBe(true); + }); + + test('the Start loader holds a token to both the recorded page identity and the current getter', async () => { + const document = { identity: 'user_a|session_a' }; + const readBaseline = mock(() => document.identity); + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ + baseline: readBaseline, + tokens: [tokenForA], + }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenForA); + document.identity = 'user_b|session_b'; + readBaseline.mockClear(); + + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => tokenForA, + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(readBaseline).toHaveBeenCalled(); + }); + + test('a sibling provider without a baseline cannot admit another identity after the page admitted one', async () => { + const first = convexHarness({ + tokens: [identityJwt('user_a', 'session_a')], + }); + const second = convexHarness({ + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + expect(await first.fetch(false)).not.toBeNull(); + + expect(await second.fetch(false)).toBeNull(); + expect(second.result.current.store.get('token')).toBeNull(); + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('auth-state publication reads the held token at the write', async () => { + const first = makeJwt(3600); + const second = makeJwt(3500); + const published: boolean[] = []; + const useClearOnSecondToken = () => { + const store = useAuthStore(); + const token = useAuthValue('token'); + useEffect(() => { + if (token !== second) return; + store.set('token', null); + store.set('expiresAt', null); + store.set('isAuthenticated', false); + return store.subscribe('isAuthenticated', (value: boolean) => + published.push(value) + ); + }, [store, token]); + }; + const harness = convexHarness({ + extraHook: useClearOnSecondToken, + guard: false, + initialToken: first, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + await act(async () => { + harness.result.current.store.set('token', second); + }); + await flush(); + + expect(published).not.toContain(true); + }); + + test('an identity recorded in an abandoned render does not quarantine the page', async () => { + const never = new Promise(() => {}); + const Suspender = () => { + throw never; + }; + const authClient = { + useSession: () => ({ data: null, isPending: true }), + convex: { token: async () => ({ data: {} }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const view = render( + + {}} + tokenIdentityBaseline="user_a|session_a" + > + + + + ); + await flush(); + view.unmount(); + + const tokenForB = identityJwt('user_b', 'session_b'); + const fresh = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh, + getToken: async () => tokenForB, + }); + }); + + expect(state).toEqual({ isAuthenticated: true, token: tokenForB }); + expect(isDocumentTripped()).toBe(false); + }); + + test('a baseline changed inside onTokenIdentityAdmitted prevents token publication', async () => { + let baseline = 'user_a|session_a'; + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ + baseline: () => baseline, + onTokenIdentityAdmitted: () => { + baseline = 'user_b|session_b'; + }, + tokens: [tokenForA], + }); + await flush(); + const published: Array = []; + const unsubscribe = harness.result.current.store.subscribe( + 'token', + (token: string | null) => published.push(token) + ); + + expect(await harness.fetch(false)).toBeNull(); + unsubscribe(); + + expect(published).not.toContain(tokenForA); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a trip inside onTokenIdentityAdmitted leaves no token in the store', async () => { + const harness = convexHarness({ + onTokenIdentityAdmitted: () => tripDocument(), + tokens: [identityJwt('user_a', 'session_a')], + }); + await flush(); + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.result.current.store.get('token')).toBeNull(); + }); + + test('a token that is not a JWT never opens the optimistic gate, whatever its payload', async () => { + const payload = btoa( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + ); + for (const token of [`opaque.${payload}`, `a.${payload}.b.c`]) { + const harness = convexHarness({ + guard: false, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + harness.unmount(); + } + }); + + describe('one admission for every path', () => { + const sessionPending = { + useSession: () => ({ data: null, isPending: true }), + convex: { token: async () => ({ data: {} }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + + test('a later SSR token must match both its baseline and the page identity', async () => { + const first = convexHarness({ baseline: 'user_a|session_a' }); + await flush(); + + const second = convexHarness({ + baseline: 'user_b|session_b', + initialToken: identityJwt('user_b', 'session_b'), + }); + await flush(); + + expect(second.result.current.store.get('token')).toBeNull(); + expect(isDocumentTripped()).toBe(true); + expect(first.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('providers mounted together with SSR tokens of two identities trip the page', async () => { + for (const baselines of [false, true]) { + const stores = new Map<'a' | 'b', AuthStore>(); + const Probe = ({ name }: { name: 'a' | 'b' }) => { + const store = useAuthStore(); + useEffect(() => { + stores.set(name, store); + }, [name, store]); + return null; + }; + const changes = { a: mock(() => {}), b: mock(() => {}) }; + const provider = (name: 'a' | 'b') => ( + + + + ); + const view = render( + <> + {provider('a')} + {provider('b')} + + ); + await flush(); + + expect(isDocumentTripped()).toBe(true); + expect(stores.get('a')!.get('token')).toBeNull(); + expect(stores.get('b')!.get('token')).toBeNull(); + expect(changes.a).toHaveBeenCalledTimes(1); + expect(changes.b).toHaveBeenCalledTimes(1); + view.unmount(); + resetDocumentTripForTests(); + } + }); + + test('a trip inside onTokenIdentityAdmitted at a cached hand-out hands out nothing', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const tokenForB = identityJwt('user_b', 'session_b'); + const container = document.createElement('div'); + const root = createRoot(container); + let mountRefusing = false; + const harness = convexHarness({ + onTokenIdentityAdmitted: () => { + if (!mountRefusing) return; + mountRefusing = false; + // A provider mounted synchronously from the callback refuses B + // against the page identity A and trips the page. + flushSync(() => { + root.render( + {}} + > + {null} + + ); + }); + }, + tokens: [tokenForA], + }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenForA); + + mountRefusing = true; + expect(await harness.fetch(false)).toBeNull(); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + act(() => root.unmount()); + }); + + test("a sibling without a getter is bound by another guard's current getter", async () => { + const page = { identity: 'user_a|session_a' }; + const tokenForA = identityJwt('user_a', 'session_a'); + const first = convexHarness({ + baseline: () => page.identity, + tokens: [tokenForA], + }); + const second = convexHarness({ tokens: [tokenForA, tokenForA] }); + await flush(); + expect(await first.fetch(false)).toBe(tokenForA); + expect(await second.fetch(false)).toBe(tokenForA); + + page.identity = 'user_b|session_b'; + + expect(await second.fetch(false)).toBeNull(); + expect(isDocumentTripped()).toBe(true); + expect(second.result.current.store.get('token')).toBeNull(); + expect(first.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a cached two-segment opaque credential goes to the exchange, never to Convex', async () => { + const payload = btoa( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + ); + const opaque = `opaque.${payload}`; + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ + baseline: 'user_a|session_a', + initialToken: opaque, + session: 'pending', + tokens: [tokenForA], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + expect(harness.convexToken).toHaveBeenCalledTimes(1); + expect( + (harness.convexToken.mock.calls[0] as any)[0].fetchOptions.headers + ).toEqual({ Authorization: `Bearer ${opaque}` }); + }); + + describe('on the server', () => { + const registryKey = Symbol.for('kitcn.identityGuard.v1'); + const scope = globalThis as unknown as Record; + const withoutWindow = async (run: () => Promise) => { + const saved = Object.getOwnPropertyDescriptor(globalThis, 'window')!; + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: undefined, + writable: true, + }); + try { + await run(); + } finally { + Object.defineProperty(globalThis, 'window', saved); + } + }; + + test('a server-only loader creates, reads and writes no page state', async () => { + delete scope[registryKey]; + const tokenForB = identityJwt('user_b', 'session_b'); + await withoutWindow(async () => { + const client = { + clearAuth: mock(() => {}), + setAuth: mock(() => {}), + }; + const state = await syncConvexAuthForStartLoader({ + convex: client, + getToken: async () => tokenForB, + }); + expect(state).toEqual({ isAuthenticated: true, token: tokenForB }); + expect(scope[registryKey]).toBeUndefined(); + }); + }); + + test('page state left by a torn-down DOM does not reach a server call', async () => { + const view = convexHarness({ baseline: 'user_a|session_a' }); + await flush(); + view.unmount(); + const tokenForB = identityJwt('user_b', 'session_b'); + const client = { clearAuth: mock(() => {}), setAuth: mock(() => {}) }; + + await withoutWindow(async () => { + const state = await syncConvexAuthForStartLoader({ + convex: client, + getToken: async () => tokenForB, + }); + expect(state).toEqual({ isAuthenticated: true, token: tokenForB }); + }); + + expect(isClientSettled(client)).toBe(false); + expect(isDocumentTripped()).toBe(false); + }); + }); + }); + + describe('publication, fixed baselines, opaque routing, registry shape', () => { + const opaqueWithExp = () => + `opaque.${btoa( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + )}`; + + test('a foreign token seeded after Convex confirmed trips and never publishes authenticated', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = convexHarness({ tokens: [tokenForA] }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenForA); + await harness.report(true); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + + await act(async () => { + harness.result.current.store.set( + 'token', + identityJwt('user_b', 'session_b') + ); + }); + + expect(harness.result.current.auth.isAuthenticated).toBe(false); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('an expired foreign token seeded into the store trips the page', async () => { + const harness = convexHarness({ baseline: 'user_a|session_a' }); + await flush(); + + await act(async () => { + harness.result.current.store.set( + 'token', + identityJwt('user_b', 'session_b', -60) + ); + }); + + expect(harness.result.current.auth.isAuthenticated).toBe(false); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a fixed baseline is read on the first render only', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const baselineHolder = { current: 'user_a|session_a' as string | null }; + const harness = convexHarness({ baselineHolder, tokens: [tokenForA] }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenForA); + + baselineHolder.current = 'user_b|session_b'; + harness.rerender(); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + expect(isDocumentTripped()).toBe(false); + expect(harness.onTokenIdentityChange).not.toHaveBeenCalled(); + }); + + test('with no guarded provider, a cached opaque credential is handed out as before', async () => { + const opaque = opaqueWithExp(); + const harness = convexHarness({ + guard: false, + initialToken: opaque, + session: 'pending', + tokens: [identityJwt('user_a', 'session_a')], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(opaque); + expect(harness.convexToken).not.toHaveBeenCalled(); + }); + + test('with a guard, a cached opaque credential is exchanged, whatever the session state', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + for (const session of ['active', 'pending'] as const) { + const opaque = opaqueWithExp(); + const harness = convexHarness({ + initialToken: opaque, + session, + tokens: [tokenForA], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + expect(harness.convexToken).toHaveBeenCalledTimes(1); + expect( + (harness.convexToken.mock.calls[0] as any)[0].fetchOptions.headers + ).toEqual({ Authorization: `Bearer ${opaque}` }); + harness.unmount(); + resetDocumentTripForTests(); + } + }); + + test('an incompatible registry under the key fails guarded admissions closed', async () => { + const key = Symbol.for('kitcn.identityGuard.v2'); + const scope = globalThis as unknown as Record; + const saved = scope[key]; + scope[key] = { + admissions: new WeakMap(), + documentIdentity: null, + identitySources: new Set(), + settledClients: new WeakSet(), + settlementListeners: new WeakMap(), + tripListeners: new Set(), + tripped: false, + watchedClients: new WeakSet(), + }; + const warn = spyOn(console, 'warn').mockImplementation(() => {}); + try { + const harness = convexHarness({ + tokens: [identityJwt('user_a', 'session_a')], + }); + await flush(); + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.result.current.store.get('token')).toBeNull(); + expect(warn).toHaveBeenCalled(); + harness.unmount(); + } finally { + warn.mockRestore(); + if (saved === undefined) delete scope[key]; + else scope[key] = saved; + } + }); + }); + + describe('late guards, loader claims, publication between writes', () => { + type Published = { + auth: boolean; + token: string | null; + tripped: boolean; + }; + const publicationProbe = ( + onLoaded: (store: AuthStore) => void, + published: Published[] + ) => + function usePublicationProbe() { + const store = useAuthStore(); + useEffect(() => { + const offLoading = store.subscribe( + 'isLoading', + (loading: boolean) => { + if (!loading) onLoaded(store); + } + ); + const offAuth = store.subscribe( + 'isAuthenticated', + (auth: boolean) => { + published.push({ + auth, + token: store.get('token'), + tripped: isDocumentTripped(), + }); + } + ); + return () => { + offLoading(); + offAuth(); + }; + }, [store]); + }; + + test('a guard enabled after mount binds the identity the provider holds', async () => { + const guardHolder = { current: false }; + const harness = convexHarness({ + guardHolder, + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + + guardHolder.current = true; + harness.rerender(); + await flush(); + + expect(await harness.fetch(true)).toBeNull(); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.result.current.store.get('token')).toBeNull(); + }); + + test('a guard enabled after the page trips closes its client and reports once', async () => { + const guardHolder = { current: false }; + const harness = convexHarness({ + guardHolder, + initialToken: identityJwt('user_a', 'session_a'), + }); + await flush(); + act(() => tripDocument()); + expect(harness.close).not.toHaveBeenCalled(); + expect(harness.onTokenIdentityChange).not.toHaveBeenCalled(); + + guardHolder.current = true; + harness.rerender(); + await flush(); + + expect(harness.close).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.result.current.store.get('token')).toBeNull(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + harness.rerender(); + await flush(); + expect(harness.close).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test("on a guarded page, the Start loader's first admitted JWT claims the page identity", async () => { + const convex = makeConvexClient(); + const harness = convexHarness({ convex, session: 'pending' }); + await flush(); + const tokenForA = identityJwt('user_a', 'session_a'); + const tokenForB = identityJwt('user_b', 'session_b'); + + let first: unknown; + let second: unknown; + await act(async () => { + first = await syncConvexAuthForStartLoader({ + convex: convex.client as any, + getToken: async () => tokenForA, + }); + second = await syncConvexAuthForStartLoader({ + convex: convex.client as any, + getToken: async () => tokenForB, + }); + }); + + expect(first).toEqual({ isAuthenticated: true, token: tokenForA }); + expect(second).toEqual({ isAuthenticated: false, token: null }); + expect(isDocumentTripped()).toBe(true); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a token swapped by a loading subscriber is admitted before authenticated is published', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const tokenForB = identityJwt('user_b', 'session_b'); + const published: Published[] = []; + const harness = convexHarness({ + extraHook: publicationProbe((store) => { + if (store.get('token') === tokenForA) store.set('token', tokenForB); + }, published), + tokens: [tokenForA], + }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenForA); + await harness.report(true); + + expect( + published.some((entry) => entry.auth && entry.token === tokenForB) + ).toBe(false); + expect(isDocumentTripped()).toBe(true); + }); + + test('a trip in a loading subscriber is seen before authenticated is published', async () => { + const published: Published[] = []; + const harness = convexHarness({ + extraHook: publicationProbe(() => tripDocument(), published), + tokens: [identityJwt('user_a', 'session_a')], + }); + await flush(); + await harness.fetch(false); + await harness.report(true); + + expect(isDocumentTripped()).toBe(true); + expect(published.some((entry) => entry.auth && entry.tripped)).toBe( + false + ); + }); + + test('the Start loader refuses when its setAuth trips the page', async () => { + const convexClient = { + clearAuth: mock(() => {}), + setAuth: mock(() => tripDocument()), + }; + const serverHttpClient = { + clearAuth: mock(() => {}), + setAuth: mock((_token: string) => {}), + }; + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: { convexClient, serverHttpClient }, + getToken: async () => identityJwt('user_a', 'session_a'), + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + expect(serverHttpClient.setAuth).not.toHaveBeenCalled(); + }); + }); + + test('a remount with a fresh client cannot reopen the document after a trip', async () => { + const first = convexHarness({ + initialToken: identityJwt('user_a', 'session_a'), + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + await first.fetch(false); + expect(await first.fetch(true)).toBeNull(); + first.unmount(); + + const second = convexHarness({ + baseline: 'user_a|session_a', + initialToken: identityJwt('user_a', 'session_a'), + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(second.result.current.store.get('token')).toBeNull(); + expect(second.result.current.auth.isAuthenticated).toBe(false); + }); + + test('two mounted providers sharing a client: tripping one quarantines the other', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const convex = makeConvexClient(); + const first = convexHarness({ + convex, + initialToken: tokenForA, + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + const second = convexHarness({ + convex, + extraHook: useFetchAccessToken, + initialToken: tokenForA, + }); + await flush(); + const httpFetcher = second.result.current.extra as (args?: { + forceRefreshToken?: boolean; + }) => Promise; + let before: string | null = null; + await act(async () => { + before = await httpFetcher(); + }); + expect(before).toBe(tokenForA); + + let refused: string | null = 'unset'; + await act(async () => { + refused = await convex.bindings[0]!.fetchToken({ + forceRefreshToken: true, + }); + }); + expect(refused).toBeNull(); + + let after: string | null = 'unset'; + await act(async () => { + after = await httpFetcher(); + }); + expect(after).toBeNull(); + expect(second.result.current.store.get('token')).toBeNull(); + expect(second.result.current.store.get('isAuthenticated')).toBe(false); + }); + + test("a refusal hidden behind the SDK's transparent retry never reopens the gate", async () => { + const tokenA = makeJwt(3600); + // Under a minute left: the recovery fetch goes back to the endpoint. + const tokenB = makeJwt(30); + const convex = await makeManagedConvexClient(); + const harness = convexHarness({ + convex, + guard: false, + initialToken: tokenA, + optimisticAuth: true, + tokens: [tokenB, null, tokenA], + }); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(true); + + // The server refuses A; the SDK refetches B on its own and sends it; + // the server refuses B; only then does React hear one refusal. + await convex.refuse(); + await convex.refuse(); + await flush(); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + + // Recovery obtains A again: a token Convex refused never reopens the gate. + await harness.recover(); + await flush(); + expect(harness.result.current.store.get('token')).toBe(tokenA); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + }); + + test('a token Convex refused never reopens the optimistic gate, even after another refusal', async () => { + const tokenA = makeJwt(3600); + const tokenB = makeJwt(3500); + const harness = convexHarness({ + guard: false, + initialToken: tokenA, + optimisticAuth: true, + tokens: [tokenB, tokenA], + }); + await flush(); + expect(await harness.fetch(false)).toBe(tokenA); + await harness.report(false); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + + // Convex refuses B too, then the token endpoint answers A again. + await harness.recover(); + expect(await harness.fetch(true)).toBe(tokenB); + await harness.report(false); + await harness.recover(); + expect(await harness.fetch(true)).toBe(tokenA); + await flush(); + + expect(harness.result.current.store.get('token')).toBe(tokenA); + expect(harness.result.current.auth.isAuthenticated).toBe(false); + }); + }); + test('useAuth reports unauthenticated when session is confirmed missing, even with SSR token', async () => { const initialToken = makeJwt(3600); const client = { diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.tsx b/packages/kitcn/src/auth-client/convex-auth-provider.tsx index 1edabdc07..2aa52b716 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -8,7 +8,15 @@ import type { AuthTokenFetcher } from 'convex/browser'; import type { ConvexReactClient } from 'convex/react'; import { useConvexAuth } from 'convex/react'; import type { ReactNode } from 'react'; -import { useCallback, useEffect, useMemo, useRef } from 'react'; +import { + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useSyncExternalStore, +} from 'react'; import { CRPCClientError, defaultIsUnauthorized } from '../crpc/error'; import { @@ -28,6 +36,30 @@ import { useAuthStore, useAuthValue, } from '../react/auth-store'; +import { + admitToken, + attachStoreGuard, + decodeTokenSubjectSessionIdentity, + type IdentityGuard, + identityGuardInPlay, + isDocumentTripped, + isJwt, + isTokenAdmissible, + joinPage, + mountGuard, + subscribeDocumentTrip, + tripDocument, +} from '../react/identity-guard-registry'; +import { + admitStoreToken, + publishAuthState, + publishToken, +} from '../react/token-gate'; +import { + isClientSettled, + subscribeClientSettlement, + watchClientSettlement, +} from './client-settlement'; import type { ConvexAuthProviderClient } from './types'; type AuthClientFetch = ConvexAuthProviderClient & { @@ -89,21 +121,42 @@ export type ConvexAuthProviderProps = { isUnauthorized?: (error: unknown) => boolean; /** * Run auth-bound queries as soon as an unexpired JWT is held instead of - * after Convex confirms it. Convex sends them after Authenticate on the same - * socket and evaluates none of them if the token is refused; a refused token - * sets `isAuthenticated` back to false, which resets auth-bound queries, and - * never opens the gate again. Default `false`. + * after Convex confirms it, until the Convex client reports its first auth + * result (confirmed or refused); after that the gate follows Convex's + * confirmed state for the client's lifetime, remounts included. Convex + * sends them after Authenticate on the same socket and evaluates none of + * them if the token is refused; a refused token sets `isAuthenticated` back + * to false, which resets auth-bound queries. With the identity guard, only + * a token the guard admits opens it. No effect over a client the TanStack + * Start loader already authenticated. Every provider over one Convex client + * must use the same setting: mixing optimistic and non-optimistic providers + * over one client is unsupported, because results the client reports + * before an optimistic provider mounts are not seen. Default `false`. */ optimisticAuth?: boolean; /** * Fix the identity (JWT `sub` and `sessionId`) the document speaks for: the * one it holds when it mounts (`initialToken`), or, with none, the first - * token it obtains. A token for another user or session is refused before - * it is cached, so neither Convex, the optimistic gate nor HTTP headers ever - * see it: the Convex client is closed (dropping queued requests and - * optimistic updates), every later token request answers null, and this is - * called, typically to reload the page. Same-session refreshes pass - * through. Off when not set. + * token that carries one. Guarantees: (1) a token of another user or + * session (or a JWT without an identity once one is established) is never + * cached, published, or handed to Convex, cRPC HTTP or the Start loader; + * (2) when a trip happens (this or another provider, or the Start loader, + * refuses one), this provider stops handing out tokens (null), publishes + * unauthenticated, calls `client.close()` (Convex's close semantics govern + * its queued work) and then this, once, where the app should reload the + * page. A provider that mounts (or shows again) on a page that is already + * tripped starts tripped and does the same, once, so a trip that happened + * while no guarded provider was mounted still reaches the app. + * The trip is page-wide in the browser (never on the server); sign-in + * mutations fail with `AuthMutationError` code `TOKEN_IDENTITY_CHANGED` + * until the reload. It governs the token kitcn supplies, not an + * `Authorization` header the app sets itself. Same-session refreshes pass + * through. Off when not set: a page that never enables it is unchanged. + * Once a guarded provider establishes the page identity, it persists until + * the reload, binding every provider (with or without this) and the Start + * loader, even after that provider unmounts. Two kitcn versions or + * revisions on one page (dev HMR across revisions included) are + * unsupported: they share no page identity until the reload. */ onTokenIdentityChange?: () => void; /** @@ -115,20 +168,23 @@ export type ConvexAuthProviderProps = { * without a token still refuses another user's or session's token. * * A fixed value is read on the first render only. A getter is read at - * every admission, cached tokens included, and a token must match both the - * identity it returns and the one this guard already admitted; any - * mismatch trips the guard. So a provider kept mounted but hidden (React - * ``) cannot resume under an identity the document has since - * moved away from. A getter returning null does not constrain. Needs - * `onTokenIdentityChange`. + * every admission, cached tokens included, and while this provider is + * mounted it binds every provider's admissions. A token must match all of: + * the page identity (the first one a provider knew), this provider's + * baseline and admitted identity, and every mounted getter's current + * answer; any mismatch trips the page. So a provider kept mounted but + * hidden (React ``) cannot resume under an identity the document + * has since moved away from. A getter returning null does not constrain. + * Needs `onTokenIdentityChange`. */ tokenIdentityBaseline?: string | null | (() => string | null); /** - * Called synchronously with every token the identity guard admits as it is - * handed to Convex or HTTP headers, cached tokens included, so the document - * can claim the identity at that moment (for example the first token a - * document without one obtains). Never called for a refused token. Read - * from a ref, so passing a new function does not re-run effects. Needs + * Called synchronously with every token the identity guard admits, before + * Convex or HTTP headers receive it: a fresh token when it is admitted and + * cached, a cached token each time it is handed out. The document can claim + * the identity at that moment (for example the first token a document + * without one obtains). Never called for a refused token. Read from a ref, + * so passing a new function does not re-run effects. Needs * `onTokenIdentityChange`. */ onTokenIdentityAdmitted?: (token: string) => void; @@ -371,14 +427,71 @@ export function ConvexAuthProvider({ }: ConvexAuthProviderProps) { // Handle cross-domain one-time token useOTTHandler(authClient); + useMemo(() => { + if (optimisticAuth) watchClientSettlement(client); + }, [client, optimisticAuth]); + + const baselineRef = useRef(tokenIdentityBaseline); + baselineRef.current = tokenIdentityBaseline; + const onAdmittedRef = useRef(onTokenIdentityAdmitted); + onAdmittedRef.current = onTokenIdentityAdmitted; + const [{ guard, inheritedTrip, refusedInitialToken }] = useState(() => { + const guarded = onTokenIdentityChange !== undefined; + const getter = typeof tokenIdentityBaseline === 'function'; + const fixed = getter ? null : (tokenIdentityBaseline ?? null); + const created: IdentityGuard = { + baseline: () => { + if (!created.guarded) return null; + return created.hasGetter + ? resolveTokenIdentityBaseline(baselineRef.current) + : fixed; + }, + guarded, + hasGetter: guarded && getter, + heldToken: () => null, + identity: guarded ? fixed : null, + onAdmitted: (token) => onAdmittedRef.current?.(token), + tripped: isDocumentTripped(), + }; + const refused = + !created.tripped && + !!initialToken && + !isTokenAdmissible(initialToken, created, 'hold'); + if (refused) created.tripped = true; + else if (guarded) { + created.identity ??= decodeTokenSubjectSessionIdentity( + initialToken ?? null + ); + } + return { + guard: created, + inheritedTrip: isDocumentTripped(), + refusedInitialToken: refused, + }; + }); + + useLayoutEffect(() => { + const guarded = onTokenIdentityChange !== undefined; + guard.hasGetter = guarded && typeof tokenIdentityBaseline === 'function'; + if (guarded === guard.guarded) return; + guard.guarded = guarded; + if (!guarded) return; + guard.identity ??= + (guard.hasGetter ? null : guard.baseline()) ?? + decodeTokenSubjectSessionIdentity(guard.heldToken()); + joinPage(guard); + }); // Memoize decoded JWT to avoid re-parsing on every render const tokenValues = useMemo( - () => ({ - expiresAt: initialToken ? decodeJwtExp(initialToken) : null, - token: initialToken ?? null, - }), - [initialToken] + () => + refusedInitialToken || inheritedTrip + ? { expiresAt: null, token: null } + : { + expiresAt: initialToken ? decodeJwtExp(initialToken) : null, + token: initialToken ?? null, + }, + [initialToken, inheritedTrip, refusedInitialToken] ); // AuthProvider wraps inner so useAuthStore() is available inside @@ -394,10 +507,11 @@ export function ConvexAuthProvider({ authClient={authClient} client={client} convexQueryClient={convexQueryClient} - onTokenIdentityAdmitted={onTokenIdentityAdmitted} + guard={guard} + inheritedTrip={inheritedTrip} onTokenIdentityChange={onTokenIdentityChange} optimisticAuth={optimisticAuth} - tokenIdentityBaseline={tokenIdentityBaseline} + refusedInitialToken={refusedInitialToken} > {children} @@ -414,19 +528,21 @@ function ConvexAuthProviderInner({ client, authClient, convexQueryClient, + guard, + inheritedTrip, optimisticAuth, onTokenIdentityChange, - onTokenIdentityAdmitted, - tokenIdentityBaseline, + refusedInitialToken, }: { children: ReactNode; client: ConvexReactClient; authClient: ConvexAuthProviderClient; convexQueryClient?: ConvexAuthProviderQueryClient; + guard: IdentityGuard; + inheritedTrip: boolean; optimisticAuth: boolean; onTokenIdentityChange?: () => void; - onTokenIdentityAdmitted?: (token: string) => void; - tokenIdentityBaseline?: ConvexAuthProviderProps['tokenIdentityBaseline']; + refusedInitialToken: boolean; }) { const authStore = useAuthStore(); convexQueryClient?.updateAuthStore(authStore); @@ -443,10 +559,15 @@ function ConvexAuthProviderInner({ sessionRef.current = session; isPendingRef.current = isPending; + const isOpaqueUnderGuard = useCallback( + (token: string) => identityGuardInPlay(guard) && !isJwt(token), + [guard] + ); + const getCachedJwt = useCallback( (minTimeRemainingMs = 0) => { const cachedToken = authStore.get('token'); - if (!cachedToken) { + if (!cachedToken || isOpaqueUnderGuard(cachedToken)) { return null; } @@ -457,7 +578,7 @@ function ConvexAuthProviderInner({ return cachedToken; }, - [authStore] + [authStore, isOpaqueUnderGuard] ); // Clear token when session becomes null (logout) @@ -479,6 +600,63 @@ function ConvexAuthProviderInner({ } }, [session, isPending, authStore]); + const onTokenIdentityChangeRef = useRef(onTokenIdentityChange); + onTokenIdentityChangeRef.current = onTokenIdentityChange; + // Registered during render so store-level paths (auth mutations, HTTP + // headers) admit through this guard before any effect runs; a render React + // discards leaves an entry keyed by a store nothing else holds. + attachStoreGuard(authStore.store, guard); + const [guardTripped, setGuardTripped] = useState( + refusedInitialToken || inheritedTrip + ); + const quarantinedRef = useRef(false); + const tripNotifiedRef = useRef(false); + const announcedTokenRef = useRef(null); + + const quarantine = useCallback(() => { + guard.tripped = true; + authStore.set('token', null); + authStore.set('expiresAt', null); + authStore.set('sessionSyncGraceUntil', null); + authStore.set('isAuthenticated', false); + authStore.set('isLoading', false); + setGuardTripped(true); + }, [authStore, guard]); + + const tripGuard = useCallback(() => { + guard.tripped = true; + if (!quarantinedRef.current) { + quarantinedRef.current = true; + quarantine(); + } + tripDocument(); + if (tripNotifiedRef.current || !onTokenIdentityChangeRef.current) return; + tripNotifiedRef.current = true; + try { + void Promise.resolve(client.close()).catch(() => {}); + } catch {} + try { + onTokenIdentityChangeRef.current?.(); + } catch (error) { + console.error('[ConvexAuthProvider] onTokenIdentityChange threw', error); + } + }, [client, guard, quarantine]); + + useLayoutEffect(() => { + guard.heldToken = () => authStore.get('token'); + const unsubscribe = subscribeDocumentTrip(tripGuard); + const unmount = mountGuard(guard); + if (refusedInitialToken || isDocumentTripped()) tripGuard(); + return () => { + unmount(); + unsubscribe(); + }; + }, [authStore, guard, refusedInitialToken, tripGuard]); + + useLayoutEffect(() => { + if (onTokenIdentityChange && isDocumentTripped()) tripGuard(); + }, [onTokenIdentityChange, tripGuard]); + useEffect(() => { isMountedRef.current = true; @@ -491,7 +669,6 @@ function ConvexAuthProviderInner({ if (hasActiveSessionData(session) || isPending || authStore.get('token')) { return; } - const persistedToken = readAuthSessionFallbackToken(); if ( !persistedToken || @@ -510,9 +687,15 @@ function ConvexAuthProviderInner({ const persistedSessionData = readAuthSessionFallbackData(); const graceUntil = Date.now() + AUTH_SESSION_SYNC_GRACE_MS; - authStore.set('token', persistedToken); - authStore.set('expiresAt', decodeJwtExp(persistedToken)); - authStore.set('sessionSyncGraceUntil', graceUntil); + if ( + !publishToken(authStore, persistedToken, { + announce: true, + sessionSyncGraceUntil: graceUntil, + use: 'restore', + }) + ) { + return; + } if (persistedSessionData) { syncSessionAtom(authClient, persistedSessionData); } @@ -565,37 +748,6 @@ function ConvexAuthProviderInner({ .catch(() => {}); }, [session, isPending, authStore, authClient]); - const onTokenIdentityChangeRef = useRef(onTokenIdentityChange); - onTokenIdentityChangeRef.current = onTokenIdentityChange; - const onTokenIdentityAdmittedRef = useRef(onTokenIdentityAdmitted); - onTokenIdentityAdmittedRef.current = onTokenIdentityAdmitted; - const tokenIdentityBaselineRef = useRef(tokenIdentityBaseline); - tokenIdentityBaselineRef.current = tokenIdentityBaseline; - const identityGuardRef = useRef(null); - identityGuardRef.current ??= { - identity: - (typeof tokenIdentityBaseline === 'function' - ? null - : resolveTokenIdentityBaseline(tokenIdentityBaseline)) ?? - decodeTokenSubjectSessionIdentity(authStore.get('token')), - currentDocumentIdentity: - typeof tokenIdentityBaseline === 'function' - ? () => resolveTokenIdentityBaseline(tokenIdentityBaselineRef.current) - : null, - tripped: false, - }; - const admitToken = useCallback( - (token: string, onAdmitted?: (token: string) => void) => - guardTokenIdentity({ - client, - guard: identityGuardRef.current!, - onAdmitted, - onIdentityChange: onTokenIdentityChangeRef.current, - token, - }), - [client] - ); - // Stable fetchAccessToken - only recreated when authStore/authClient change (rare) // Reads session/isPending from refs to avoid dependency on changing objects const fetchAccessToken = useCallback( @@ -619,25 +771,31 @@ function ConvexAuthProviderInner({ } = { throw: false, }; - if (cachedToken && decodeJwtExp(cachedToken) === null) { + if ( + cachedToken && + (decodeJwtExp(cachedToken) === null || + isOpaqueUnderGuard(cachedToken)) + ) { fetchOptions.credentials = 'omit'; fetchOptions.headers = { Authorization: `Bearer ${cachedToken}`, }; } - // biome-ignore lint/suspicious/noExplicitAny: convex plugin type - pendingTokenRef.current = (authClient as any).convex + pendingTokenRef.current = authClient.convex .token({ fetchOptions }) - .then((result: { data?: { token?: string | null } | null }) => { - const jwt = result.data?.token || null; - if (jwt && !admitToken(jwt)) return null; - + .then((result) => { + const jwt = result?.data?.token || null; if (jwt) { - const exp = decodeJwtExp(jwt); - authStore.set('token', jwt); - authStore.set('expiresAt', exp); - authStore.set('sessionSyncGraceUntil', null); + if ( + !publishToken(authStore, jwt, { + announce: true, + sessionSyncGraceUntil: null, + }) + ) { + return null; + } + announcedTokenRef.current = jwt; return jwt; } @@ -719,9 +877,9 @@ function ConvexAuthProviderInner({ // Convex asked for a fresh token, but dropping auth to null here can // briefly flip to unauthenticated before Better Auth session settles. if (!freshToken && cachedJwt) { - authStore.set('token', cachedJwt); - authStore.set('expiresAt', decodeJwtExp(cachedJwt)); - return cachedJwt; + return publishToken(authStore, cachedJwt, { announce: false }) + ? cachedJwt + : null; } return freshToken; @@ -743,7 +901,8 @@ function ConvexAuthProviderInner({ !forceRefreshToken && cachedToken && expiresAt && - timeRemaining >= 60_000 + timeRemaining >= 60_000 && + !isOpaqueUnderGuard(cachedToken) ) { return cachedToken; } @@ -760,19 +919,21 @@ function ConvexAuthProviderInner({ }, // Stable deps - authStore/authClient rarely change // session/isPending accessed via refs to prevent callback recreation - [authStore, authClient, getCachedJwt, admitToken] + [authStore, authClient, getCachedJwt, isOpaqueUnderGuard] ); const guardedFetchAccessToken = useCallback( async (args: { forceRefreshToken?: boolean } = {}) => { - if (identityGuardRef.current?.tripped) return null; + if (isDocumentTripped()) return null; const token = await fetchAccessToken(args); - if (!token || !admitToken(token, onTokenIdentityAdmittedRef.current)) { - return null; - } - return token; + if (!token) return null; + const announced = announcedTokenRef.current === token; + if (announced) announcedTokenRef.current = null; + return admitToken(token, { announce: !announced, guard, use: 'handout' }) + ? token + : null; }, - [fetchAccessToken, admitToken] + [fetchAccessToken, guard] ); // Create useAuth hook for ConvexProviderWithAuth @@ -799,7 +960,11 @@ function ConvexAuthProviderInner({ client={client as IConvexReactClient} useAuth={useAuth} > - + {children} @@ -821,143 +986,96 @@ function ConvexAuthProviderInner({ */ function AuthStateSync({ children, + client, + guardTripped, optimisticAuth = false, }: { children: ReactNode; + client: ConvexReactClient; + guardTripped: boolean; optimisticAuth?: boolean; }) { const { isLoading: convexIsLoading, isAuthenticated } = useConvexAuth(); const authStore = useAuthStore(); const token = useAuthValue('token'); - const rejectedTokenRef = useRef(null); + const subscribe = useCallback( + (listener: () => void) => subscribeClientSettlement(client, listener), + [client] + ); + const getSettled = useCallback(() => isClientSettled(client), [client]); + const settled = useSyncExternalStore(subscribe, getSettled, getSettled); useEffect(() => { + // Read the trip, the settlement and the held token again at the write: a + // descendant effect earlier in this commit may have changed any of them. const gate = resolveAuthGate({ + admitHeldToken: (held) => + admitStoreToken(authStore, held, { use: 'hold' }), convexIsLoading, + guardTripped: guardTripped || isDocumentTripped(), isAuthenticated, - optimisticAuth, - rejectedToken: rejectedTokenRef.current, - token, + optimisticWindow: optimisticAuth && !settled && !isClientSettled(client), + token: authStore.get('token'), }); - if ( - optimisticAuth && - isTokenRejectedByConvex({ convexIsLoading, isAuthenticated, token }) - ) { - rejectedTokenRef.current = token; - } - authStore.set('isLoading', gate.isLoading); - authStore.set('isAuthenticated', gate.isAuthenticated); - }, [convexIsLoading, isAuthenticated, optimisticAuth, token, authStore]); + publishAuthState(authStore, gate); + }, [ + client, + convexIsLoading, + guardTripped, + isAuthenticated, + optimisticAuth, + settled, + token, + authStore, + ]); return children; } -function decodeTokenSubjectSessionIdentity( - token: string | null -): string | null { - if (!token) return null; - try { - const segment = token.split('.')[1]; - if (!segment) return null; - const payload: unknown = JSON.parse( - atob(segment.replaceAll('-', '+').replaceAll('_', '/')) - ); - if (typeof payload !== 'object' || payload === null) return null; - const sub = 'sub' in payload ? payload.sub : null; - const sessionId = 'sessionId' in payload ? payload.sessionId : null; - if (typeof sub !== 'string') return null; - return `${sub}|${typeof sessionId === 'string' ? sessionId : ''}`; - } catch { - return null; - } -} - -type IdentityGuard = { - identity: string | null; - currentDocumentIdentity: (() => string | null) | null; - tripped: boolean; -}; - function resolveTokenIdentityBaseline( baseline: string | null | (() => string | null) | undefined ): string | null { return typeof baseline === 'function' ? baseline() : (baseline ?? null); } -function guardTokenIdentity({ - client, - guard, - onAdmitted, - onIdentityChange, - token, -}: { - client: ConvexReactClient; - guard: IdentityGuard; - onAdmitted: ((token: string) => void) | undefined; - onIdentityChange: (() => void) | undefined; - token: string; -}): boolean { - if (!onIdentityChange) return true; - if (guard.tripped) return false; - const identity = decodeTokenSubjectSessionIdentity(token); - if (identity === null) return true; - const current = guard.currentDocumentIdentity?.() ?? null; - const matchesGuard = guard.identity === null || guard.identity === identity; - const matchesDocument = current === null || current === identity; - if (matchesGuard && matchesDocument) { - guard.identity = identity; - onAdmitted?.(token); - return true; - } - guard.tripped = true; - void client.close(); - onIdentityChange(); - return false; -} - type AuthGateInput = { + admitHeldToken: (token: string) => boolean; convexIsLoading: boolean; + guardTripped: boolean; isAuthenticated: boolean; - optimisticAuth: boolean; - rejectedToken: string | null; + optimisticWindow: boolean; token: string | null; }; -/** - * Convex has settled on "not authenticated" while a token is still held: the - * server refused it (or refused its refresh). - */ -function isTokenRejectedByConvex({ - convexIsLoading, - isAuthenticated, - token, -}: Pick) { - return !!token && !convexIsLoading && !isAuthenticated; -} - /** * The auth state published to the store, which is what every query gate reads. * * Without `optimisticAuth` this is the confirmed state: queries wait until - * Convex has confirmed the token. With it, a held, unexpired JWT that Convex - * has not refused yet counts as authenticated while Convex confirms it, so - * auth-bound queries subscribe at once; Convex sends them after Authenticate on - * the same socket, and a refused token closes the socket before any of them is - * evaluated. When Convex refuses the token, `isAuthenticated` falls back to - * false, which resets auth-bound queries (see `CRPCProviderInner`). + * Convex has confirmed the token. With it, until the Convex client reports its + * first auth result, a held, unexpired JWT counts as authenticated while + * Convex confirms it, so auth-bound queries subscribe at once; Convex sends + * them after Authenticate on the same socket, and a refused token closes the + * socket before any of them is evaluated. When Convex refuses the token, + * `isAuthenticated` falls back to false, which resets auth-bound queries (see + * `CRPCProviderInner`), and the window is over: no token reopens it. */ function resolveAuthGate({ + admitHeldToken, convexIsLoading, + guardTripped, isAuthenticated, - optimisticAuth, - rejectedToken, + optimisticWindow, token, }: AuthGateInput): { isAuthenticated: boolean; isLoading: boolean } { + if (guardTripped || (token !== null && !admitHeldToken(token))) { + return { isAuthenticated: false, isLoading: false }; + } if ( - optimisticAuth && + optimisticWindow && convexIsLoading && - isOptimisticToken(token, rejectedToken) + token !== null && + isOptimisticToken(token) ) { return { isAuthenticated: true, isLoading: false }; } @@ -969,8 +1087,8 @@ function resolveAuthGate({ }; } -function isOptimisticToken(token: string | null, rejectedToken: string | null) { - if (!token || token === rejectedToken) return false; +function isOptimisticToken(token: string) { + if (!isJwt(token)) return false; const expiresAt = decodeJwtExp(token); return expiresAt !== null && expiresAt > Date.now(); } diff --git a/packages/kitcn/src/auth-client/identity-guard.entrypoints.integration.test.tsx b/packages/kitcn/src/auth-client/identity-guard.entrypoints.integration.test.tsx new file mode 100644 index 000000000..fa5c3ef68 --- /dev/null +++ b/packages/kitcn/src/auth-client/identity-guard.entrypoints.integration.test.tsx @@ -0,0 +1,419 @@ +import { act, renderHook } from '@testing-library/react'; +import { ConvexAuthProvider } from 'kitcn/auth/client'; +import { syncConvexAuthForStartLoader } from 'kitcn/auth/start'; +import { createCRPCContext, useAuth, useAuthStore } from 'kitcn/react'; +import { type ReactNode, useEffect } from 'react'; +import { + isDocumentTripped, + resetDocumentTripForTests, + tripDocument, +} from '../react/identity-guard-registry'; + +// Built entries: `kitcn/auth/start` is bundled apart from `kitcn/auth/client` +// and `kitcn/react`, so the identity guard's page state must be shared +// across bundles, not per module copy. +const identityJwt = (sub: string, sessionId: string, expSeconds = 3600) => + `x.${btoa( + JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expSeconds, + sessionId, + sub, + }) + )}.z`; + +type FetchToken = (args: { + forceRefreshToken: boolean; +}) => Promise; + +const mountProvider = ({ + baseline, + client, + extraHook = () => null, + guard = true, + initialToken, + optimisticAuth = false, + session = 'active', + tokens = [], +}: { + baseline?: string | (() => string | null); + client: { setAuth: (fetchToken: FetchToken) => void }; + extraHook?: () => unknown; + guard?: boolean; + initialToken?: string; + optimisticAuth?: boolean; + session?: 'active' | 'pending'; + tokens?: string[]; +}) => { + const queue = [...tokens]; + const onTokenIdentityChange = mock(() => {}); + const exchange = mock(async (_options: unknown) => ({ + data: { token: queue.shift() ?? null }, + })); + const authClient = { + convex: { token: exchange }, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + getSession: async () => null, + updateSession: () => {}, + useSession: () => + session === 'active' + ? { + data: { session: { id: 's' }, user: { id: 'u' } }, + isPending: false, + } + : { data: null, isPending: true }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + return { + exchange, + onTokenIdentityChange, + ...renderHook( + () => { + extraHook(); + return useAuth(); + }, + { wrapper } + ), + }; +}; + +const makeClient = () => { + const fetchers: FetchToken[] = []; + const reports: Array<(isAuthenticated: boolean) => void> = []; + return { + fetchers, + reports, + client: { + clearAuth: () => {}, + close: mock(async () => {}), + setAuth: ( + fetchToken: FetchToken, + onChange?: (isAuthenticated: boolean) => void + ) => { + fetchers.push(fetchToken); + if (onChange) reports.push(onChange); + }, + }, + }; +}; + +const flush = () => + act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + +describe('identity guard across built entrypoints', () => { + afterEach(() => { + resetDocumentTripForTests(); + }); + + test('a client kitcn/auth/start authenticated gets no optimistic window in kitcn/auth/client', async () => { + const token = identityJwt('user_a', 'session_a'); + const { client } = makeClient(); + await syncConvexAuthForStartLoader({ + convex: client, + getToken: async () => token, + }); + + const { result } = mountProvider({ + client, + initialToken: token, + optimisticAuth: true, + session: 'pending', + }); + await flush(); + + expect(result.current.isAuthenticated).toBe(false); + }); + + test('the page identity from kitcn/auth/client holds kitcn/auth/start', async () => { + const { client } = makeClient(); + mountProvider({ baseline: 'user_a|session_a', client, session: 'pending' }); + await flush(); + + const fresh = makeClient(); + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh.client, + getToken: async () => identityJwt('user_b', 'session_b'), + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + }); + + test('a trip in kitcn/auth/client stops kitcn/auth/start', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const { client, fetchers } = makeClient(); + const provider = mountProvider({ + client, + initialToken: tokenForA, + tokens: [identityJwt('user_b', 'session_b')], + }); + await flush(); + await act(async () => { + await fetchers.at(-1)!({ forceRefreshToken: false }); + await fetchers.at(-1)!({ forceRefreshToken: true }); + }); + expect(provider.onTokenIdentityChange).toHaveBeenCalledTimes(1); + + const fresh = makeClient(); + let state: unknown; + await act(async () => { + state = await syncConvexAuthForStartLoader({ + convex: fresh.client, + getToken: async () => tokenForA, + }); + }); + + expect(state).toEqual({ isAuthenticated: false, token: null }); + }); + + test("a sibling in kitcn/auth/client is bound by another guard's current getter", async () => { + const page = { identity: 'user_a|session_a' }; + const tokenForA = identityJwt('user_a', 'session_a'); + const first = makeClient(); + mountProvider({ + baseline: () => page.identity, + client: first.client, + tokens: [tokenForA], + }); + const second = makeClient(); + const sibling = mountProvider({ + client: second.client, + tokens: [tokenForA], + }); + await flush(); + let handed: string | null = null; + await act(async () => { + handed = await second.fetchers.at(-1)!({ forceRefreshToken: false }); + }); + expect(handed).toBe(tokenForA); + + page.identity = 'user_b|session_b'; + await act(async () => { + handed = await second.fetchers.at(-1)!({ forceRefreshToken: false }); + }); + + expect(handed).toBeNull(); + expect(isDocumentTripped()).toBe(true); + expect(sibling.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('an SSR token in kitcn/auth/client is reconciled against the page identity', async () => { + const first = mountProvider({ + baseline: 'user_a|session_a', + client: makeClient().client, + session: 'pending', + }); + await flush(); + + const later = makeClient(); + const second = mountProvider({ + baseline: 'user_b|session_b', + client: later.client, + initialToken: identityJwt('user_b', 'session_b'), + session: 'pending', + }); + await flush(); + + // Refused and tripped on mount, before anything asks for a token. + expect(isDocumentTripped()).toBe(true); + expect(first.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(second.onTokenIdentityChange).toHaveBeenCalledTimes(1); + // Any fetcher Convex was handed hands out nothing. + for (const fetchToken of later.fetchers) { + let handed: string | null = 'unset'; + await act(async () => { + handed = await fetchToken({ forceRefreshToken: false }); + }); + expect(handed).toBeNull(); + } + }); + + test('kitcn/react HTTP sends no kitcn token once the page trips while per-call headers load', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const sent: Array> = []; + const { CRPCProvider, useCRPC } = createCRPCContext({ + api: { + _http: { 'todos.get': { method: 'GET', path: '/todos' } }, + } as never, + convexSiteUrl: 'https://example.convex.site', + fetch: (async (_url: string, init: RequestInit) => { + sent.push(init.headers as Record); + return new Response('{}', { status: 200 }); + }) as typeof fetch, + }); + const { client } = makeClient(); + const queue = [tokenForA]; + const authClient = { + convex: { + token: async () => ({ data: { token: queue.shift() ?? tokenForA } }), + }, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + getSession: async () => null, + updateSession: () => {}, + useSession: () => ({ + data: { session: { id: 's' }, user: { id: 'u' } }, + isPending: false, + }), + }; + const onTokenIdentityChange = mock(() => {}); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {} } as never} + > + {children} + + + ); + const { result } = renderHook(() => useCRPC() as any, { wrapper }); + await flush(); + + await act(async () => { + await result.current.http.todos.get.query({}); + }); + expect(sent.at(-1)?.Authorization).toBe(`Bearer ${tokenForA}`); + + let release!: (headers: Record) => void; + let request!: Promise; + await act(async () => { + request = result.current.http.todos.get.query({ + headers: () => + new Promise>((resolve) => { + release = resolve; + }), + }); + await new Promise((r) => setTimeout(r, 0)); + }); + await act(async () => { + tripDocument(); + release({ 'x-call': '1' }); + await request; + }); + + expect(sent.at(-1)).toEqual({ 'x-call': '1' }); + expect(onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('a cached opaque credential in kitcn/auth/client: direct with no guard, exchanged with one', async () => { + const opaque = `opaque.${btoa( + JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 3600 }) + )}`; + const tokenForA = identityJwt('user_a', 'session_a'); + + const plain = makeClient(); + const unguarded = mountProvider({ + client: plain.client, + guard: false, + initialToken: opaque, + session: 'pending', + tokens: [tokenForA], + }); + await flush(); + let handed: string | null = null; + await act(async () => { + handed = await plain.fetchers.at(-1)!({ forceRefreshToken: false }); + }); + expect(handed).toBe(opaque); + expect(unguarded.exchange).not.toHaveBeenCalled(); + unguarded.unmount(); + + const guardedClient = makeClient(); + const guarded = mountProvider({ + client: guardedClient.client, + initialToken: opaque, + tokens: [tokenForA], + }); + await flush(); + await act(async () => { + handed = await guardedClient.fetchers.at(-1)!({ + forceRefreshToken: false, + }); + }); + expect(handed).toBe(tokenForA); + expect(guarded.exchange).toHaveBeenCalledTimes(1); + }); + + test("kitcn/auth/start's first JWT claims the identity of a page kitcn/auth/client guards", async () => { + const { client } = makeClient(); + const provider = mountProvider({ client, session: 'pending' }); + await flush(); + const tokenForA = identityJwt('user_a', 'session_a'); + let first: unknown; + let second: unknown; + await act(async () => { + first = await syncConvexAuthForStartLoader({ + convex: client, + getToken: async () => tokenForA, + }); + second = await syncConvexAuthForStartLoader({ + convex: client, + getToken: async () => identityJwt('user_b', 'session_b'), + }); + }); + + expect(first).toEqual({ isAuthenticated: true, token: tokenForA }); + expect(second).toEqual({ isAuthenticated: false, token: null }); + expect(isDocumentTripped()).toBe(true); + expect(provider.onTokenIdentityChange).toHaveBeenCalledTimes(1); + }); + + test('kitcn/auth/client admits the held token again right before publishing authenticated', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const tokenForB = identityJwt('user_b', 'session_b'); + const published: Array<{ auth: boolean; token: string | null }> = []; + const useSwapWhenLoaded = () => { + const store = useAuthStore(); + useEffect(() => { + const offLoading = store.subscribe('isLoading', (loading: boolean) => { + if (!loading && store.get('token') === tokenForA) { + store.set('token', tokenForB); + } + }); + const offAuth = store.subscribe('isAuthenticated', (auth: boolean) => { + published.push({ auth, token: store.get('token') }); + }); + return () => { + offLoading(); + offAuth(); + }; + }, [store]); + }; + const { client, fetchers, reports } = makeClient(); + mountProvider({ + client, + extraHook: useSwapWhenLoaded, + tokens: [tokenForA], + }); + await flush(); + await act(async () => { + await fetchers.at(-1)!({ forceRefreshToken: false }); + }); + await act(async () => { + reports.at(-1)!(true); + }); + + expect( + published.some((entry) => entry.auth && entry.token === tokenForB) + ).toBe(false); + expect(isDocumentTripped()).toBe(true); + }); +}); diff --git a/packages/kitcn/src/auth-start/index.ts b/packages/kitcn/src/auth-start/index.ts index be412cd8b..7f5c4cf68 100644 --- a/packages/kitcn/src/auth-start/index.ts +++ b/packages/kitcn/src/auth-start/index.ts @@ -1,3 +1,9 @@ +import { settleClient } from '../auth-client/client-settlement'; +import { + admitToken, + isDocumentTripped, +} from '../react/identity-guard-registry'; + export type MaybePromise = Promise | T; export type StartLoaderAuthClient = { @@ -45,7 +51,11 @@ export const syncConvexAuthForStartLoader = async ({ const serverHttpClient = isStartLoaderConvexQueryClient(convex) ? convex.serverHttpClient : undefined; - const token = (await getToken()) ?? null; + const loaderToken = (await getToken()) ?? null; + const admit = (candidate: string) => + admitToken(candidate, { use: 'handout' }); + const token = + loaderToken !== null && !admit(loaderToken) ? null : loaderToken; const previousToken = startLoaderAuthTokens.get(convex); if (previousToken === token) { @@ -60,7 +70,14 @@ export const syncConvexAuthForStartLoader = async ({ return { isAuthenticated: false, token }; } - authClient.setAuth(async () => token); + settleClient(authClient); + authClient.setAuth(async () => (admit(token) ? token : null)); + if (isDocumentTripped()) { + startLoaderAuthTokens.set(convex, null); + authClient.clearAuth(); + serverHttpClient?.clearAuth?.(); + return { isAuthenticated: false, token: null }; + } serverHttpClient?.setAuth(token); return { isAuthenticated: true, token }; }; diff --git a/packages/kitcn/src/crpc/http-client.ts b/packages/kitcn/src/crpc/http-client.ts index 84a8d4473..d90b6b336 100644 --- a/packages/kitcn/src/crpc/http-client.ts +++ b/packages/kitcn/src/crpc/http-client.ts @@ -125,6 +125,18 @@ export function buildSearchParams( return params; } +export const RECHECK_AUTHORIZATION = Symbol.for( + 'kitcn.http.recheckAuthorization' +); + +export const withAuthorizationRecheck = < + T extends { [key: string]: string | undefined }, +>( + headers: T, + recheck: () => boolean +): T => + Object.defineProperty(headers, RECHECK_AUTHORIZATION, { value: recheck }); + /** * Hono-style HTTP request executor. * Processes args in the same way as Hono's ClientRequestImpl.fetch(). @@ -221,6 +233,22 @@ export async function executeHttpRequest(opts: { } Object.assign(finalHeaders, headerValues); + const recheck = ( + resolvedBaseHeaders as + | { [RECHECK_AUTHORIZATION]?: () => boolean } + | undefined + )?.[RECHECK_AUTHORIZATION]; + const sendHeaders = + recheck && + finalHeaders.Authorization === resolvedBaseHeaders?.Authorization && + !recheck() + ? Object.fromEntries( + Object.entries(finalHeaders).filter( + ([key]) => key !== 'Authorization' + ) + ) + : finalHeaders; + // Build URL with path params let url = opts.convexSiteUrl + path; if (args.params) { @@ -244,7 +272,7 @@ export async function executeHttpRequest(opts: { const response = await fetchFn(url, { body: setBody ? rBody : undefined, method: methodUpperCase, - headers: finalHeaders, + headers: sendHeaders, ...mergedClientOpts.init, }); diff --git a/packages/kitcn/src/react/auth-mutations.ts b/packages/kitcn/src/react/auth-mutations.ts index a950b5155..8bba5a21f 100644 --- a/packages/kitcn/src/react/auth-mutations.ts +++ b/packages/kitcn/src/react/auth-mutations.ts @@ -19,6 +19,8 @@ import { useAuthStore, } from './auth-store'; import { useConvexQueryClient } from './context'; +import { isDocumentTripped } from './identity-guard-registry'; +import { publishAuthenticated, publishToken } from './token-gate'; export { AuthMutationError, isAuthMutationError } from '../crpc/auth-error'; @@ -40,13 +42,25 @@ type SignInMutationOptionsHook = ( } ) => UseMutationOptions; -/** Poll until JWT token exists (auth complete) (max 5s) */ +const tokenIdentityChangedError = () => + new AuthMutationError({ + code: 'TOKEN_IDENTITY_CHANGED', + message: 'This page switched accounts. Reload it to sign in.', + status: 401, + statusText: 'UNAUTHORIZED', + }); + +const assertDocumentNotTripped = () => { + if (isDocumentTripped()) throw tokenIdentityChangedError(); +}; + const waitForAuth = async ( store: AuthStore, timeout = 5000 ): Promise => { const start = Date.now(); while (Date.now() - start < timeout) { + assertDocumentNotTripped(); if (store.get('token')) return true; await new Promise((r) => setTimeout(r, 50)); } @@ -93,9 +107,14 @@ const seedReturnedToken = (store: AuthStore, value: unknown) => { return; } - store.set('token', token); - store.set('expiresAt', decodeJwtExp(token)); - store.set('sessionSyncGraceUntil', Date.now() + AUTH_SESSION_SYNC_GRACE_MS); + if ( + !publishToken(store, token, { + announce: true, + sessionSyncGraceUntil: Date.now() + AUTH_SESSION_SYNC_GRACE_MS, + }) + ) { + throw tokenIdentityChangedError(); + } if (decodeJwtExp(token) === null) { writeAuthSessionFallbackToken(token); } @@ -307,6 +326,7 @@ export function createAuthMutations( if (typeof signInSocial !== 'function') { throw new Error('Auth client does not expose signIn.social'); } + assertDocumentNotTripped(); const res = (await callAuthMethod( signInSocial, withDisabledSessionSignal(args) @@ -317,7 +337,9 @@ export function createAuthMutations( seedReturnedToken(authStoreApi, res); await hydrateReturnedSession(authClient, res); await ensureAuth(authStoreApi); - authStoreApi.set('isAuthenticated', true); + if (!publishAuthenticated(authStoreApi)) { + throw tokenIdentityChangedError(); + } return res; }, }; @@ -334,6 +356,7 @@ export function createAuthMutations( if (typeof signIn !== 'function') { throw new Error(`Auth client does not expose signIn.${signInMethod}`); } + assertDocumentNotTripped(); const res = (await callAuthMethod( signIn, withDisabledSessionSignal(args) @@ -344,7 +367,9 @@ export function createAuthMutations( seedReturnedToken(authStoreApi, res); await hydrateReturnedSession(authClient, res); await ensureAuth(authStoreApi); - authStoreApi.set('isAuthenticated', true); + if (!publishAuthenticated(authStoreApi)) { + throw tokenIdentityChangedError(); + } return res; }, }; @@ -360,6 +385,7 @@ export function createAuthMutations( if (typeof signUpEmail !== 'function') { throw new Error('Auth client does not expose signUp.email'); } + assertDocumentNotTripped(); const res = (await callAuthMethod( signUpEmail, withDisabledSessionSignal(args) @@ -370,7 +396,9 @@ export function createAuthMutations( seedReturnedToken(authStoreApi, res); await hydrateReturnedSession(authClient, res); await ensureAuth(authStoreApi); - authStoreApi.set('isAuthenticated', true); + if (!publishAuthenticated(authStoreApi)) { + throw tokenIdentityChangedError(); + } return res; }, }; diff --git a/packages/kitcn/src/react/client.test.ts b/packages/kitcn/src/react/client.test.ts index 93306cc70..6a7c83545 100644 --- a/packages/kitcn/src/react/client.test.ts +++ b/packages/kitcn/src/react/client.test.ts @@ -173,12 +173,14 @@ describe('ConvexQueryClient (server mode)', () => { const ConvexQueryClient = await getServerConvexQueryClient('random'); const convex = startConvexClient(); const random = spyOn(Math, 'random'); - - const client = new ConvexQueryClient(convex as any); - await client.queryFn()(context('a:one')); - - expect(random).toHaveBeenCalledTimes(0); - random.mockRestore(); + try { + const client = new ConvexQueryClient(convex as any); + await client.queryFn()(context('a:one')); + + expect(random).toHaveBeenCalledTimes(0); + } finally { + random.mockRestore(); + } }); test('interleaved Start requests keep their own auth and snapshot', async () => { diff --git a/packages/kitcn/src/react/context.test.tsx b/packages/kitcn/src/react/context.test.tsx index 069cda418..df3e5c07b 100644 --- a/packages/kitcn/src/react/context.test.tsx +++ b/packages/kitcn/src/react/context.test.tsx @@ -1,6 +1,8 @@ import { renderHook } from '@testing-library/react'; import type { ReactNode } from 'react'; +import { executeHttpRequest } from '../crpc/http-client'; +import { getTransformer } from '../crpc/transformer'; import * as authStoreModule from './auth-store'; import { createCRPCContext, @@ -9,6 +11,10 @@ import { useMeta, } from './context'; import * as httpProxyModule from './http-proxy'; +import { + resetDocumentTripForTests, + tripDocument, +} from './identity-guard-registry'; import * as proxyModule from './proxy'; import * as vanillaClientModule from './vanilla-client'; @@ -221,6 +227,157 @@ describe('createCRPCContext', () => { } }); + test('http headers carry no kitcn token when the document trips while app headers load', async () => { + const createHttpProxySpy = spyOn( + httpProxyModule, + 'createHttpProxy' + ).mockReturnValue({} as any); + const fetchAccessToken = mock(async () => 'token-a'); + useFetchAccessTokenSpy.mockImplementation(() => fetchAccessToken as any); + let releaseHeaders!: (headers: Record) => void; + const appHeaders = () => + new Promise>((resolve) => { + releaseHeaders = resolve; + }); + const api = { + _http: { 'todos.get': { method: 'GET', path: '/todos/:id' } }, + } as any; + + try { + const { CRPCProvider, useCRPC } = createCRPCContext({ + api, + convexSiteUrl: 'https://example.convex.site', + headers: appHeaders, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useCRPC(), { wrapper }); + const headers = createHttpProxySpy.mock.calls[0]?.[0] + ?.headers as () => Promise>; + + const pending = headers(); + await new Promise((r) => setTimeout(r, 0)); + tripDocument(); + releaseHeaders({ 'x-app': '1' }); + + expect(await pending).toEqual({ 'x-app': '1' }); + } finally { + resetDocumentTripForTests(); + createHttpProxySpy.mockRestore(); + } + }); + + test('the store-token fallback admits after the app headers load', async () => { + const createHttpProxySpy = spyOn( + httpProxyModule, + 'createHttpProxy' + ).mockReturnValue({} as any); + useAuthStoreSpy.mockImplementation( + () => + ({ + get: (key: string) => + key === 'token' + ? 'token-a' + : key === 'expiresAt' + ? Date.now() + 3_600_000 + : null, + }) as any + ); + let releaseHeaders!: (headers: Record) => void; + const appHeaders = () => + new Promise>((resolve) => { + releaseHeaders = resolve; + }); + const api = { + _http: { 'todos.get': { method: 'GET', path: '/todos/:id' } }, + } as any; + + try { + const { CRPCProvider, useCRPC } = createCRPCContext({ + api, + convexSiteUrl: 'https://example.convex.site', + headers: appHeaders, + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useCRPC(), { wrapper }); + const headers = createHttpProxySpy.mock.calls[0]?.[0] + ?.headers as () => Promise>; + + const pending = headers(); + await new Promise((r) => setTimeout(r, 0)); + tripDocument(); + releaseHeaders({ 'x-app': '1' }); + + expect(await pending).toEqual({ 'x-app': '1' }); + } finally { + resetDocumentTripForTests(); + createHttpProxySpy.mockRestore(); + } + }); + + test('no kitcn token is sent when the page trips while per-call headers load', async () => { + const createHttpProxySpy = spyOn( + httpProxyModule, + 'createHttpProxy' + ).mockReturnValue({} as any); + const fetchAccessToken = mock(async () => 'token-a'); + useFetchAccessTokenSpy.mockImplementation(() => fetchAccessToken as any); + const api = { + _http: { 'todos.get': { method: 'GET', path: '/todos/:id' } }, + } as any; + + try { + const { CRPCProvider, useCRPC } = createCRPCContext({ + api, + convexSiteUrl: 'https://example.convex.site', + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useCRPC(), { wrapper }); + const baseHeaders = createHttpProxySpy.mock.calls[0]?.[0] + ?.headers as () => Promise>; + + let releaseCallHeaders!: (headers: Record) => void; + const sent: Array> = []; + const request = executeHttpRequest({ + args: { + headers: () => + new Promise>((resolve) => { + releaseCallHeaders = resolve; + }), + }, + baseFetch: (async (_url: string, init: RequestInit) => { + sent.push(init.headers as Record); + return new Response('{}', { status: 200 }); + }) as typeof fetch, + baseHeaders, + convexSiteUrl: 'https://example.convex.site', + procedureName: 'todos.get', + route: { method: 'GET', path: '/todos' }, + transformer: getTransformer(), + }); + await new Promise((r) => setTimeout(r, 0)); + tripDocument(); + releaseCallHeaders({ 'x-call': '1' }); + await request; + + expect(sent).toEqual([{ 'x-call': '1' }]); + } finally { + resetDocumentTripForTests(); + createHttpProxySpy.mockRestore(); + } + }); + test('forwards transformer option to CRPC proxies', () => { const createOptionsProxySpy = spyOn( proxyModule, diff --git a/packages/kitcn/src/react/context.tsx b/packages/kitcn/src/react/context.tsx index 919d2f96b..0da79056d 100644 --- a/packages/kitcn/src/react/context.tsx +++ b/packages/kitcn/src/react/context.tsx @@ -15,6 +15,7 @@ import { useMemo, useRef, } from 'react'; +import { withAuthorizationRecheck } from '../crpc/http-client'; import type { HttpClientError } from '../crpc/http-types'; import type { DataTransformerOptions } from '../crpc/transformer'; import type { FnMeta, Meta } from '../crpc/types'; @@ -35,6 +36,7 @@ import { type VanillaHttpCRPCClientFromRouter, } from './http-proxy'; import { createCRPCOptionsProxy } from './proxy'; +import { admitStoreToken } from './token-gate'; import { createVanillaCRPCProxy } from './vanilla-client'; // ============================================================================ @@ -238,45 +240,29 @@ export function createCRPCContext>( convexSiteUrl: httpOptions.convexSiteUrl, routes: meta._http, headers: async () => { - if (fetchAccessToken) { - const expiresAt = authStore.get('expiresAt'); - // eslint-disable-next-line react-hooks/purity -- called in async callback, not during render - const timeRemaining = expiresAt ? expiresAt - Date.now() : 0; - const guardedToken = await fetchAccessToken({ - forceRefreshToken: !!expiresAt && timeRemaining < 60_000, - }); - const userHeaders = - typeof httpOptions.headers === 'function' - ? await httpOptions.headers() - : httpOptions.headers; - return guardedToken - ? { ...userHeaders, Authorization: `Bearer ${guardedToken}` } - : { ...userHeaders }; - } - - // Use authStore.get() for non-reactive access - const token = authStore.get('token'); const expiresAt = authStore.get('expiresAt'); - - // Check cache (60s leeway) // eslint-disable-next-line react-hooks/purity -- called in async callback, not during render - const now = Date.now(); - const timeRemaining = expiresAt ? expiresAt - now : 0; - - if (token && expiresAt && timeRemaining >= 60_000) { - const userHeaders = - typeof httpOptions.headers === 'function' - ? await httpOptions.headers() - : httpOptions.headers; - return { ...userHeaders, Authorization: `Bearer ${token}` }; - } - - // No auth - return user headers only + const timeRemaining = expiresAt ? expiresAt - Date.now() : 0; + const heldToken = authStore.get('token'); + const token = fetchAccessToken + ? await fetchAccessToken({ + forceRefreshToken: !!expiresAt && timeRemaining < 60_000, + }) + : heldToken && expiresAt && timeRemaining >= 60_000 + ? heldToken + : null; const userHeaders = typeof httpOptions.headers === 'function' ? await httpOptions.headers() : httpOptions.headers; - return { ...userHeaders }; + const admit = () => + !!token && admitStoreToken(authStore, token, { use: 'handout' }); + return admit() + ? withAuthorizationRecheck( + { ...userHeaders, Authorization: `Bearer ${token}` }, + admit + ) + : { ...userHeaders }; }, fetch: httpOptions.fetch, onError: httpOptions.onError, diff --git a/packages/kitcn/src/react/identity-guard-registry.ts b/packages/kitcn/src/react/identity-guard-registry.ts new file mode 100644 index 000000000..2f3ea60ed --- /dev/null +++ b/packages/kitcn/src/react/identity-guard-registry.ts @@ -0,0 +1,252 @@ +// The page state is browser only and lives on `globalThis`, so every built +// entry (`kitcn/auth/start` is bundled apart from `kitcn/auth/client` and +// `kitcn/react`) shares one copy. On the server, where one module serves many +// requests, it is never created, read or written. No imports, so loaders can +// use it too. + +export type IdentityGuard = { + guarded: boolean; + baseline: () => string | null; + hasGetter: boolean; + identity: string | null; + tripped: boolean; + heldToken: () => string | null; + onAdmitted: (token: string) => void; +}; + +/** + * - `handout`: to Convex, HTTP headers or the loader. With an identity + * guard in play, an opaque session token never goes (it is only the + * exchange credential). + * - `hold`: cached or published in a store; an opaque token may be held as + * the exchange credential. + * - `restore`: a persisted token; an opaque one is not restored once an + * identity is established, because it proves none. + */ +export type TokenUse = 'handout' | 'hold' | 'restore'; + +type PageRegistry = { + incompatible?: true; + tripped: boolean; + tripListeners: Set<() => void>; + documentIdentity: string | null; + guards: Set; + storeGuards: WeakMap; + settledClients: WeakSet; + settlementListeners: WeakMap void>>; + watchedClients: WeakSet; +}; + +const REGISTRY_KEY = Symbol.for('kitcn.identityGuard.v2'); + +const createRegistry = (): PageRegistry => ({ + documentIdentity: null, + guards: new Set(), + settledClients: new WeakSet(), + settlementListeners: new WeakMap(), + storeGuards: new WeakMap(), + tripListeners: new Set(), + tripped: false, + watchedClients: new WeakSet(), +}); + +const isRegistry = (value: unknown): value is PageRegistry => { + const candidate = value as Partial | null; + return ( + typeof candidate === 'object' && + candidate !== null && + typeof candidate.tripped === 'boolean' && + (candidate.documentIdentity === null || + typeof candidate.documentIdentity === 'string') && + candidate.tripListeners instanceof Set && + candidate.guards instanceof Set && + candidate.storeGuards instanceof WeakMap && + candidate.settledClients instanceof WeakSet && + candidate.settlementListeners instanceof WeakMap && + candidate.watchedClients instanceof WeakSet + ); +}; + +let incompatibleStandIn: PageRegistry | undefined; + +export const pageRegistry = (): PageRegistry | null => { + if (typeof window === 'undefined') return null; + const scope = globalThis as unknown as Record; + scope[REGISTRY_KEY] ??= createRegistry(); + const found = scope[REGISTRY_KEY]; + if (isRegistry(found)) return found; + if (!incompatibleStandIn) { + console.warn( + '[kitcn] identity guard state of another kitcn revision is on this page; guarded providers hand out no token until the page reloads.' + ); + incompatibleStandIn = { ...createRegistry(), incompatible: true }; + } + return incompatibleStandIn; +}; + +export const isDocumentTripped = () => pageRegistry()?.tripped ?? false; + +export const tripDocument = () => { + const registry = pageRegistry(); + if (!registry || registry.tripped) return; + registry.tripped = true; + for (const listener of [...registry.tripListeners]) { + try { + listener(); + } catch (error) { + console.error('[kitcn] identity guard trip listener threw', error); + } + } +}; + +export const subscribeDocumentTrip = (listener: () => void) => { + const registry = pageRegistry(); + if (!registry) return () => {}; + registry.tripListeners.add(listener); + return () => { + registry.tripListeners.delete(listener); + }; +}; + +export const attachStoreGuard = ( + store: object | undefined, + guard: IdentityGuard +) => { + if (store) pageRegistry()?.storeGuards.set(store, guard); +}; + +export const storeGuard = (store: object | undefined) => + store ? pageRegistry()?.storeGuards.get(store) : undefined; + +export const mountGuard = (guard: IdentityGuard) => { + const registry = pageRegistry(); + if (!registry) return () => {}; + registry.guards.add(guard); + joinPage(guard); + return () => { + registry.guards.delete(guard); + }; +}; + +export const joinPage = (guard: IdentityGuard) => { + const registry = pageRegistry(); + if (!registry) return; + if (guard.guarded && guard.identity) { + registry.documentIdentity ??= guard.identity; + } + for (const mounted of [...registry.guards]) { + const held = mounted.heldToken(); + if (held && isJwt(held) && !isTokenAdmissible(held, mounted, 'hold')) { + tripDocument(); + break; + } + } +}; + +const guardedProviderMounted = () => + [...(pageRegistry()?.guards ?? [])].some((mounted) => mounted.guarded); + +const boundIdentities = (guard: IdentityGuard | undefined) => { + const registry = pageRegistry(); + const bound = [registry?.documentIdentity ?? null]; + if (guard?.guarded) bound.push(guard.identity, guard.baseline()); + for (const mounted of registry?.guards ?? []) { + if (mounted.hasGetter) bound.push(mounted.baseline()); + } + return bound.filter((identity): identity is string => identity !== null); +}; + +export const identityGuardInPlay = (guard: IdentityGuard | undefined) => + !!guard?.guarded || + boundIdentities(guard).length > 0 || + guardedProviderMounted(); + +export const isTokenAdmissible = ( + token: string, + guard: IdentityGuard | undefined, + use: TokenUse +) => { + if (isDocumentTripped() || guard?.tripped) return false; + if (guard?.guarded && pageRegistry()?.incompatible) return false; + const bound = boundIdentities(guard); + if (!isJwt(token)) { + if (use === 'hold') return true; + if (use === 'restore') return bound.length === 0; + return !identityGuardInPlay(guard); + } + const identity = decodeTokenSubjectSessionIdentity(token); + return identity === null + ? bound.length === 0 + : bound.every((known) => known === identity); +}; + +export const admitToken = ( + token: string, + { + announce = false, + guard, + use, + }: { announce?: boolean; guard?: IdentityGuard; use: TokenUse } +): boolean => { + if (!isTokenAdmissible(token, guard, use)) { + if (isJwt(token)) { + if (guard) guard.tripped = true; + tripDocument(); + } + return false; + } + const identity = isJwt(token) + ? decodeTokenSubjectSessionIdentity(token) + : null; + if (identity !== null && (guard?.guarded || guardedProviderMounted())) { + if (guard?.guarded) guard.identity = identity; + const registry = pageRegistry(); + if (registry) registry.documentIdentity ??= identity; + } + if (announce && guard?.guarded && isJwt(token)) { + guard.onAdmitted(token); + return admitToken(token, { guard, use }); + } + return !isDocumentTripped() && !guard?.tripped; +}; + +function decodeJwtClaims(token: string): Record | null { + const segments = token.split('.'); + if (segments.length !== 3 || !segments[1]) return null; + try { + const payload: unknown = JSON.parse( + atob(segments[1].replaceAll('-', '+').replaceAll('_', '/')) + ); + return typeof payload === 'object' && + payload !== null && + !Array.isArray(payload) + ? (payload as Record) + : null; + } catch { + return null; + } +} + +export const isJwt = (token: string) => decodeJwtClaims(token) !== null; + +/** + * The user and session a Better Auth Convex JWT speaks for (`sub` and + * `sessionId`), or null. Other claims (name, email, updatedAt) may change + * within one session and do not count. + */ +export function decodeTokenSubjectSessionIdentity( + token: string | null +): string | null { + const claims = token ? decodeJwtClaims(token) : null; + if (!claims || typeof claims.sub !== 'string') return null; + const sessionId = + typeof claims.sessionId === 'string' ? claims.sessionId : ''; + return `${claims.sub}|${sessionId}`; +} + +export const resetDocumentTripForTests = () => { + const registry = pageRegistry(); + if (!registry) return; + registry.tripped = false; + registry.documentIdentity = null; +}; diff --git a/packages/kitcn/src/react/token-gate.ts b/packages/kitcn/src/react/token-gate.ts new file mode 100644 index 000000000..75c646264 --- /dev/null +++ b/packages/kitcn/src/react/token-gate.ts @@ -0,0 +1,67 @@ +'use client'; + +import { type AuthStore, decodeJwtExp } from './auth-store'; +import { + admitToken, + isDocumentTripped, + storeGuard, + type TokenUse, +} from './identity-guard-registry'; + +export const admitStoreToken = ( + authStore: AuthStore, + token: string, + options: { announce?: boolean; use: TokenUse } +) => admitToken(token, { ...options, guard: storeGuard(authStore.store) }); + +export const publishToken = ( + authStore: AuthStore, + token: string, + { + announce, + sessionSyncGraceUntil, + use = 'hold', + }: { + announce: boolean; + sessionSyncGraceUntil?: number | null; + use?: TokenUse; + } +) => { + if (!admitStoreToken(authStore, token, { announce, use })) return false; + authStore.set('token', token); + authStore.set('expiresAt', decodeJwtExp(token)); + if (sessionSyncGraceUntil !== undefined) { + authStore.set('sessionSyncGraceUntil', sessionSyncGraceUntil); + } + return true; +}; + +export const publishAuthenticated = (authStore: AuthStore) => { + const token = authStore.get('token'); + if ( + isDocumentTripped() || + (token && !admitStoreToken(authStore, token, { use: 'hold' })) + ) { + return false; + } + authStore.set('isAuthenticated', true); + return true; +}; + +export const publishAuthState = ( + authStore: AuthStore, + state: { isAuthenticated: boolean; isLoading: boolean } +) => { + const tripped = isDocumentTripped(); + authStore.set('isLoading', tripped ? false : state.isLoading); + if (!state.isAuthenticated || tripped) { + authStore.set('isAuthenticated', false); + return; + } + const token = authStore.get('token'); + authStore.set( + 'isAuthenticated', + !isDocumentTripped() && + !(token && !admitStoreToken(authStore, token, { use: 'hold' })) + ); +}; diff --git a/www/content/docs/auth/client.mdx b/www/content/docs/auth/client.mdx index 7f34028db..ec37357c4 100644 --- a/www/content/docs/auth/client.mdx +++ b/www/content/docs/auth/client.mdx @@ -276,7 +276,7 @@ function AuthStatus() { | Property | Description | |----------|-------------| | `hasSession` | Has a session token (may not be verified) | -| `isAuthenticated` | Token exists AND Convex auth verified | +| `isAuthenticated` | Token exists AND Convex auth verified. With `optimisticAuth`, also `true` for a held, unexpired JWT before Convex confirms it; the server still enforces auth | | `isLoading` | Convex auth is still loading | ### useMaybeAuth @@ -294,7 +294,7 @@ function Component() { ### useIsAuth -Check if user is authenticated (server-verified): +Check if user is authenticated (server-verified; with `optimisticAuth`, also during the optimistic window, while the server still enforces auth): ```tsx showLineNumbers {1,4} import { useIsAuth } from 'kitcn/react'; @@ -396,7 +396,7 @@ function App() { ### Authenticated -Render children only when server-verified: +Render children only when server-verified (with `optimisticAuth`, also during the optimistic window): ```tsx showLineNumbers {1,5-7} import { Authenticated } from 'kitcn/react'; @@ -497,17 +497,56 @@ function App() { `optimisticAuth` removes the client-side confirmation wait for a held, unexpired JWT. Convex still processes authentication before queries on the same connection. An expired, opaque, or refused token never opens the gate. +The window lasts until the Convex client reports its first auth result; after +that, for the client's lifetime, the gate follows Convex's confirmed state. +Use one `optimisticAuth` setting for every provider over a Convex client +(results reported before an optimistic provider mounts are not seen), and +expect no optimism over a client the TanStack Start loader already +authenticated. Use `onTokenIdentityChange` when one document must never send queued work under a different user or session. The provider compares the JWT `sub` and `sessionId`, refuses a mismatch before caching or forwarding it, closes the Convex client, and calls the callback. HTTP cRPC requests use the same guarded -token source. +token source. What the guard guarantees: + +1. A JWT of another user or session (whatever its `exp`) is never cached, + published, or handed to Convex, cRPC HTTP or the TanStack Start loader. + Once an identity is established, a JWT without one is refused too, and an + opaque session token is only exchanged for a JWT, never handed to Convex. +2. Admission rechecks the current baselines after `onTokenIdentityAdmitted` + returns, before publishing or handing out the token. +3. When a trip happens (any provider or the Start loader refuses such a + token), every mounted provider hands out no token and publishes + `isAuthenticated: false`; each one with `onTokenIdentityChange` closes its + client and calls it once. The trip covers the page in the browser (never + the server): providers and clients mounted later start tripped, a guarded + provider that mounts or shows again on a tripped page also closes its + client and calls `onTokenIdentityChange` once (so a trip that happened + while none was mounted still reaches the app). Enabling the guard after a + trip also closes the client and calls back once. Sign-in mutations fail + with `TOKEN_IDENTITY_CHANGED` until the reload. + +The guard covers the token kitcn supplies, not an `Authorization` header the +app sets in `httpOptions.headers`. For multiple provider mounts in one document, pass the shared identity as -`tokenIdentityBaseline`. Its format is `sub|sessionId`. A getter is evaluated -for each admission, including cached tokens. Use `onTokenIdentityAdmitted` to -observe admitted tokens and update the shared document identity. +`tokenIdentityBaseline`. Its format is `sub|sessionId`. Every admission (the +SSR token, fresh and cached tokens, sign-in tokens, HTTP headers and the Start +loader) holds a token to the page identity (the first one a provider knew), +the provider's own baseline, and the current answer of every mounted +provider's getter. Held tokens are reconciled when a provider joins the page +and at every admission, so a provider that mounts with an SSR token of +another identity trips the page. A page that never enables +`onTokenIdentityChange` is unchanged. Once a guarded provider establishes the +page identity, it persists until the reload and binds every provider (with or +without the option) and the Start loader, even after that provider unmounts. + +Two kitcn versions or revisions on one page (including dev HMR across +revisions) are unsupported: their entries share no page identity until the +reload, and an entry that finds page state it cannot read refuses its guarded +tokens. Use `onTokenIdentityAdmitted` to observe admitted +tokens and update the shared document identity. ## Auth Flow