diff --git a/.agents/skills/kitcn/references/features/auth.md b/.agents/skills/kitcn/references/features/auth.md index 94440228b..3fba69452 100644 --- a/.agents/skills/kitcn/references/features/auth.md +++ b/.agents/skills/kitcn/references/features/auth.md @@ -406,11 +406,26 @@ All from `kitcn/react`: authClient={authClient} convexQueryClient={convexQueryClient} // when using TanStack Query initialToken={token} // from SSR (caller.getToken()) + optimisticAuth // opt in: open gates while Convex confirms a held JWT + onTokenIdentityChange={() => window.location.reload()} onMutationUnauthorized={() => router.push('/login')} onQueryUnauthorized={({ queryName }) => console.log(`Unauth: ${queryName}`)} > ``` +`optimisticAuth` only opens auth-bound query gates for a held, unexpired JWT; +expired, opaque, and refused tokens stay closed. 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. + +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. +`onTokenIdentityAdmitted(token)` observes admitted JWTs so the app can update +that shared baseline. All three identity options require +`onTokenIdentityChange`. + For `@convex-dev/auth` (React Native): ```tsx import { ConvexProviderWithAuth } from 'kitcn/react'; diff --git a/.agents/skills/kitcn/references/features/react.md b/.agents/skills/kitcn/references/features/react.md index 3220a4aed..a785fc054 100644 --- a/.agents/skills/kitcn/references/features/react.md +++ b/.agents/skills/kitcn/references/features/react.md @@ -298,6 +298,22 @@ const mutation = useMutation(crpc.user.update.mutationOptions({ Signature: `crpc.path.mutationOptions(options?)` — standard TanStack mutation options except `mutationFn`. +Pass `optimisticUpdate(localStore, args)` to use Convex's optimistic local +query store. This option is removed before the TanStack options are returned; +Convex replays it as query data changes and owns rollback when the mutation +completes. + +```ts +const mutation = useMutation(crpc.todos.rename.mutationOptions({ + optimisticUpdate: (store, args) => { + const todos = store.getQuery(api.todos.list, {}); + if (todos) store.setQuery(api.todos.list, {}, todos.map((todo) => + todo._id === args.id ? { ...todo, title: args.title } : todo + )); + }, +})); +``` + ### Mutation Keys ```ts diff --git a/.changeset/optimistic-auth-gate.md b/.changeset/optimistic-auth-gate.md new file mode 100644 index 000000000..039e263ca --- /dev/null +++ b/.changeset/optimistic-auth-gate.md @@ -0,0 +1,10 @@ +--- +"kitcn": patch +--- + +- Add opt-in optimistic auth and document identity admission to + `ConvexAuthProvider`, with the same guarded token source for Convex and cRPC + HTTP requests. +- Add Convex-native `optimisticUpdate` support to cRPC `mutationOptions`. +- Reuse the Convex client's logger for server HTTP clients so construction is + deterministic during prerendering. diff --git a/docs/plans/473-autoclosure.md b/docs/plans/473-autoclosure.md new file mode 100644 index 000000000..7ebea3bea --- /dev/null +++ b/docs/plans/473-autoclosure.md @@ -0,0 +1,398 @@ +# Autoclose PR 473 + +Objective: +Autoclose PR #473 without expanding its contract. Recover the missing exact-PR +task evidence, verify the package and auth behavior, close all live P1-or-higher +feedback, pass the repository gate, and merge only the verified head. + +Flow mode: +one-shot execution + +Goal plan: +docs/plans/473-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) + +Linked plans: +- [PR #473 task recovery](docs/plans/473-optimistic-auth-gate.md) - owns the + exact PR contract, package proof, task-style PR body, and task evidence. + +User requirements: +- Latest steering: `fix...` authorizes the fixture repair that blocked CI. + Resume task #473, normalize proven host-dependent Expo settings, refresh all + donor snapshots after Next drift is reproduced, run the full gate, update + the existing PR, and continue to its protected merge gates. +- Run the repaired `autoclosure` workflow on PR #473 now. +- Preserve and continue partially good work. Close only when no usable task + state exists. +- Merge only when the recovered PR is genuinely ready. + +Completion threshold: +- PR #473 has exactly one task-plan line. The linked task plan exists at the + live head and names PR #473. +- Every linked-plan and closure-matrix gate is complete or N/A with evidence. +- Focused package tests, the `packages/kitcn` build, and lint pass on the final + committed head. Every applicable `bun check` lane passes. The latest repair + authorization supersedes the earlier local fixture waiver. +- The final feedback inventory has zero actionable P1-or-higher items. Every + lower-priority item has an explicit user deferral or a concrete non-actionable + verdict. +- The terminal receipt binds its proof to the exact live head. A guarded merge + lands that head in `main`, and GitHub reports the PR as merged. +- No new product scope. Completion requires every applicable lane below to have + fresh evidence, review findings closed, + required GitHub `CI` passing, authorized delivery complete, and the goal + checker passing. A local fixture waiver cannot satisfy branch protection. + +Verification surface: +- Immutable-head and local `HEAD` equality checks for PR #473. +- Focused auth-provider, React context, query-options, and server-client tests. +- `bun --cwd packages/kitcn build`, package typecheck if available, + `bun lint:fix`, and `bun check` from the PR worktree. +- `resolve-pr-feedback` helper output plus raw top-level, review-body, and + GraphQL thread inventories. +- `gh pr view` body/check/head read-back, terminal receipt read-back, guarded + merge result, and fetched `main` history. + +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: +- Scope extension authorized by `fix...`: repair fixture normalization in + `tooling/fixtures.ts` with a red-green regression, then regenerate + `fixtures/**` canonically. No package API or manual fixture patching. +- intended delta: the optimistic auth gate, JWT identity guard, guarded HTTP + token source, Convex optimistic mutation passthrough, and deterministic + server-client construction described by PR #473 +- allowed repairs: the existing `packages/kitcn` code and tests, its changeset, + these two exact-PR plans, and the PR body or feedback receipts +- unrelated files: preserve; do not treat as blockers +- non-goals: new auth features, stack topology changes, base retargets, broad + migrations, deployment, or changes to the repaired autoclosure workflow + +Output budget strategy: +- Read exact files and bounded diffs. Count or save broad feedback and test + output before inspecting slices. Exclude `node_modules`, generated build + output, coverage, and `tmp` unless a named proof requires them. + +Blocked condition: +- Stop only if the contributor branch cannot accept the required evidence, + GitHub feedback cannot be fetched or read back, a required check needs an + external authorization, or repeated distinct repairs reproduce the same + environment failure. + +Start Gates: +| Gate | Applies | Evidence | +| --- | --- | --- | +| Immutable PR head fetched | yes | `refs/pr/473` = live `headRefOid` = `424a3bec59d8c9b1accf592ad534ef73ad90e7dc` | +| Task intake classified | yes | `recoverable`; detailed PR body, three coherent commits, and ten related package files provide a concrete contract, but the body has no task-plan line | +| Complete task evidence verified | yes | GitHub head, fetched `refs/pr/473`, and local `HEAD` all equal `29f558fbfd5ff37222d30d006a9fbee24744e9a1`; the plan exists at that head and the body has exactly one matching task-plan line | +| Recoverable task state adopted | yes | Preserved branch, exact-PR plan, recovery fix, task-format body, and immutable-head read-back are live | +| Active source/plan reconstructed | yes | PR #473 body, commits, changed paths, comments, and checks read from GitHub; immutable head fetched locally | +| Intended delta and exclusions recorded | yes | Boundaries above mirror the PR contract and forbid new product scope or topology changes | +| Closure matrix classified | yes | Package/API, generated fixtures, changeset, source behavior, feedback, review, repository check, and GitHub delivery apply; UI is N/A | +| Live PR feedback target resolved | yes | PR #473 at recovered head `29f558fbfd5ff37222d30d006a9fbee24744e9a1` | +| Feedback proof checkout bound to PR head | yes | Local `HEAD`, fetched PR ref, and live OID matched before feedback review | +| Unfiltered feedback inventory | yes | Helper, raw REST, and paginated GraphQL inventories were fetched twice; zero actionable items exist | +| GitHub delivery expectation recorded | yes | Recover exact-PR evidence, push to `EfficiencyCorp:feat/optimistic-auth-gate`, verify, then merge only with an exact-head guard | +| Active goal checked or created | yes | get_goal returns null after resume; existing exact-PR plans remain the acceptance record. No new native goal inferred. | +| Agent-native pack selected | yes | Required by the autoclosure goal contract | +| Agent-facing action surface identified | yes | Generated Expo guidance and fixture sync/check tooling; no new user action or workflow rule | +| Source rule versus generated mirror boundary identified | yes | Donor generates guidance; tooling/fixtures.ts owns snapshot normalization. Never manually edited fixture docs/settings. | +| Installed-skill lock versus local-rule owner identified | no | No installed skill or lock state belongs to PR #473 | +| `agent-native-reviewer` loaded or waiver recorded | yes | Source, route, discoverability, generation and proof parity audit passes for fixture maintenance | + +Closure matrix: +| Lane | Applies | Owner/proof | Status | +| --- | --- | --- | --- | +| task intake classification | yes | immutable-head `recoverable` evidence | complete | +| per-PR task ownership | yes | recovered exact PR + dedicated task plan | complete | +| recoverable task adoption | yes | preserved branch + exact-PR `task` + repaired evidence read-back | complete | +| absent-state close | no | N/A: PR is recoverable, so the corrected workflow preserves it | complete | +| source behavior | yes | 97 focused tests plus direct source review | complete | +| package/API/build | yes | package typecheck/build and public type coverage | complete | +| generated output | yes | published skill mirror regenerated from package skill source | complete | +| fixtures/scenarios | yes | 14 normalization tests, canonical all sync, all eight fixture checks and runtime scenarios pass | complete | +| docs/package skill | yes | `www` and package skill references synchronized | complete | +| changeset | yes | `.changeset/optimistic-auth-gate.md`; fixed package group audited | complete | +| agent workflow | no | N/A: no workflow action or rule changed | complete | +| live PR feedback | yes | helper + raw REST + paginated GraphQL; zero actionable P0-P3 | complete | +| cleanup/review | yes | no-comments, direct P1 audit, agent-native boundary audit, autoreview clean | complete | +| repository check | yes | bun check exits 0 after authorized fixture repair, including CI/verify/runtime lanes | complete | +| GitHub delivery | yes | material head pushed/read; exact-head receipt and merge must wait for required `CI` plus code-owner approval | blocked | + +Work Checklist: +- [x] **Declare the mode and resolve the forge before any poll.** Mode is + `drive`. `origin` CLI is unavailable, so this run uses GitHub CLI. +- [x] **Work the merge frontier and nothing above it.** PR #473 is the only + frontier. +- [x] **One babysitter per stack.** No other task or worktree is attached to + this PR in the current chat. +- [x] **Never mutate stack topology.** This run preserves `main` as the base + and `feat/optimistic-auth-gate` as the contributor head. +- [x] **Order is conflicts, then review threads, then CI.** Exact-PR evidence + reached the live head before review; CI follows the final plan push. +- [x] **Trust the active forge's verdict, not a green check list.** GitHub's + ruleset remains authoritative for final approval and CI. +- [x] **Classify CI before any retrigger.** The fork run was `action_required`, + not failed; normal workflow approval was issued without bypass. +- [x] **Bugbot is triaged skeptically, always.** No Bugbot item exists. +- [x] **Stop at the human's line.** The user authorized autoclosure and merge, + but no scope expansion or protection bypass. +- [x] **Resolve the forge and dependency chain.** GitHub is the forge. PR #473 + is a single PR from `EfficiencyCorp:feat/optimistic-auth-gate` to `main`. +- [x] **Verify each PR independently.** The current agent did not author the + contributor's code and will issue the verdict directly. A higher-priority + runtime rule forbids spawning the playbook's verifier subagent. +- [x] **Find the contiguous verified run.** The run contains only PR #473. +- [x] **Cancel pending merges before changing the chain.** No pending merge or + topology write. No topology write is planned. +- [x] **Prepare only the bottom PR.** PR #473 is the bottom and only PR. +- [x] **Reassess the evidence.** Review and tests bind to final material head + `ea5e442d...` and base OID `3250fb9c...`. +- [ ] **Merge with a service-enforced head condition.** Once required `CI` and + review pass, the external closeout + uses GitHub's + `--match-head-commit` guard after every gate passes. +- [x] **Arm future merging only with durable verification gates.** Skip future + arming. This run will use an immediate guarded merge. +- [x] **Watch the frontier and preserve its verdict.** GitHub CI was authorized, + watched, and classified as a real required-context failure caused only by + the unrelated fixture drift. +- [ ] **Confirm the landing before advancing.** The external closeout must read + `MERGED` and prove the merge commit is in fetched `main`. +- [x] **Stop at the ceiling.** The ceiling is PR #473. +- [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] Bounded intake classified immutable-head task state as `complete`, + `recoverable`, or `absent` from source-backed intent plus delta coherence; + incomplete evidence alone was not classified as absent. +- [x] Recoverable work was preserved and adopted through `task` for the exact + PR; its dedicated plan/body evidence was committed, pushed, and read back + at the new head before normal closeout continued. +- [x] Complete or recovered task evidence was verified from the PR body, + fetched head, and exact PR ownership. Absent state instead has the exact + missing-state comment and `CLOSED` read-back, and no full review, 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 complete or recovered PR; + every + actionable P1-or-higher finding was fixed, proved, replied to, and + resolved or received the required top-level reply receipt. +- [x] For a complete or recovered PR, local committed `HEAD`, fetched PR ref, + and live `headRefOid` matched before proof/reply/resolution and after + every push. + For an absent-state PR, this and all feedback gates are N/A with the exact + missing-state comment and `CLOSED` receipts. +- [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] Every P1-or-higher proof reran after the final material branch push, + regardless of file type, including resolved or outdated threads that + disappear from the helper's unresolved-thread output. +- [x] Feedback was re-fetched after the last push/reply/resolution and shows + zero unresolved actionable P1-or-higher findings. +- [ ] The terminal receipt is deliberately external rather than versioned: post + and read it after this final plan-only push, forbid later branch writes, + require receipt/live/fetched/local OID equality, and re-fetch feedback + before merging. The PR receipt is the authoritative result record. +- [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. +- [ ] PR body and check state must be updated/read back after the final blocker + evidence push. +- [x] Earlier fixture waiver is superseded by the authorized source repair; + GitHub owns protected CI/review gates. +- [x] Agent-native pack: N/A; no source-of-truth agent rule changed. +- [x] Agent-native pack: N/A; no agent action changed. +- [x] Agent-native pack: package skill source and generated mirror are synced; + `.agents/rules/**` did not change. +- [x] Agent-native pack: no installed skill or lock state changed; published + package skill content stayed source-owned. +- [x] Agent-native pack: routing, required receipts, placeholder failure, + completion representability, and forbidden behavior have eval/smoke rows. +- [x] Agent-native pack: direct audit found no accepted/actionable findings. + +Error attempts: +| Failure signature | Count | Next different move | Resolution | +| --- | ---: | --- | --- | +| `bun check` cannot resolve `kitcn/auth/*` from `convex` before package artifacts exist | 1 | Build the package because package exports and artifacts are in scope, then rerun the exact gate | Resolved. The package build passed and the next check reached the test suite. | +| Full Bun suite fails the two changed server-client tests after `auth-start/index.retry.test.ts` | 1 | Run the contaminator and victim together, then replace process-global module mocks with file-scoped spies | Resolved. The two-file repro changed from two failures to 15 passes. | +| `fixtures:check` regenerates Expo SDK 55 guidance that differs from committed fixture snapshots | 1 | Do not add unrelated scaffold drift to PR #473. Ask whether to waive the gate or authorize a separate fixture repair. | Resolved for this PR by the user's `go` at 2026-09-30T00:58:19+02:00. The waiver applies only to this unrelated fixture lane. | +| Required GitHub `CI` fails on the same waived Expo drift after normal fork-workflow approval | 1 | Do not bypass branch protection. Record the run and stop until a separately scoped fixture repair lands or GitHub no longer requires the failing context. | Blocked: https://github.com/udecode/kitcn/actions/runs/36644341283 | + +Completion Gates: +| Gate | Applies | Required action | Evidence | +| --- | --- | --- | --- | +| Task intake classification | yes | Classify immutable head | `recoverable`: coherent source, tests, and detailed PR contract existed despite missing task evidence. | +| Per-PR task ownership | yes | Bind exact PR and plan | PR #473 owns `docs/plans/473-optimistic-auth-gate.md`. | +| Recoverable task adoption | yes | Preserve and repair | Existing work was adopted, repaired, committed, pushed, and read back. | +| Absent-state disposition | no | N/A | The PR was recoverable; corrected autoclosure policy forbids closing partially good work. | +| Targeted behavior proof | yes | Run owning proof | Final focused set: 97 pass, 0 fail, 293 assertions. | +| Source/generated audit | yes | Prove owners and mirrors | Package skill source and generated mirror are byte-identical; no agent rule source changed. | +| Package/docs/scenario closure | yes | Close applicable contracts | Typecheck/build/docs/skill/changeset pass; UI/scenario N/A; unrelated fixture drift waived. | +| Feedback proof checkout | yes | Bind proof to exact head | Local/fetched/live matched at material head; repeat after the final plan-only push and external receipt. | +| Live PR feedback resolution | yes | Inventory every surface | Helper 0 threads/1 non-noise comment/0 reviews; raw REST 2 bot comments; GraphQL 0 threads; zero actionable items. | +| Feedback priority classification | yes | Persist verdicts | Both bot comments are informational P3 and ledgered with source-backed rationale. | +| Final P1 proof replay | yes | Replay after material push | 97 focused tests, package typecheck/build, lint, full test lanes, intent checks, and autoreview pass after `ea5e442d...`. | +| Final live feedback read-back | yes | Re-fetch every surface | Final material-head fetch shows zero actionable P0-P3 and no unresolved thread. | +| External terminal receipt | yes | Post/read after final versioned push | Blocked until required GitHub `CI` passes; do not issue a false ready receipt. | +| Deslop | yes | Run bounded cleanup | Removed 19 narrative/redundant comment lines, replaced a boolean flag with a typed callback, and renamed internal helpers. | +| Agent-native reviewer | yes | Audit parity | Fixture sync/check remain discoverable; source owner and generated output are correct; focused test and canonical sync pass; no shared skill/rule changes. | +| Final lint | yes | Run lint | `bun lint` passes across 975 files. | +| Repository check | yes | Run all applicable lanes | Latest bun check exits 0, no waiver; required GitHub CI needs fresh final-head proof. | +| GitHub delivery | yes | Push, read back, receipt, protected merge | Blocked: required `CI` is failure and review is required; no admin bypass is allowed. | +| Autoreview | yes | Resolve accepted findings | Branch autoreview is clean; direct P1 audit found no remaining actionable issue. | +| Goal plan complete | yes | Run goal checker | Blocked until required GitHub `CI`, code-owner approval, receipt, and merge complete. | +| Agent source / generated sync | no | N/A | No `.agents/rules/**` source changed; package skill mirror sync passes. | +| Installed lock audit | no | N/A | No installed skill or lock state changed. | +| Agent action discoverability | no | N/A | No agent action changed; package guidance remains discoverable from the kitcn root skill. | +| Helper and template smoke | no | N/A | No agent helper or plan template implementation changed. | +| Agent-native review | no | N/A | No agent workflow delta; direct boundary audit and `intent` checks are clean. | + +Phase / pass table: +| Phase | Status | Evidence | Next | +| --- | --- | --- | --- | +| Inventory | complete | immutable head, PR source, comments, checks, and recoverable classification recorded | task evidence repair | +| Repair | complete | recovery commit and task-format body are live; local/fetched/live heads match | feedback and source review | +| Review/checks | complete | focused/full proof, feedback inventory, no-comments, direct P1 review, and autoreview are clean | delivery | +| Delivery | pending | local fixture repair verified; final push, fresh GitHub CI and code-owner approval required | final-head protected gates | +| Closeout | blocked | no receipt or merge while the required context is red | resume after blocker clears | + +Verification evidence: +- Latest `bun check` exits 0 after owner repair and canonical all-fixture sync. + Source tests, type tests, CLI tests, Concave smoke, all eight fixture checks, + kitcn verify and runtime scenarios pass. No fixture waiver remains. Log + `/tmp/pr473-fixed-check.log`. +- Normalization red-green: 12 pass/2 fail with retained Expo settings before + the patch; 14 pass/0 fail and 57 assertions after it. Both scopes preserve + AGENTS, other Claude artifacts and non-Expo settings. +- Incremental autoreview and TruffleHog are clean; no-comments has zero new + comment/suppression findings. Deslop found no incremental tooling regression. +- Pending-merge inspection/cancellation read back `cancelled` at fcbd2f84, + base main@3250fb9c, autoMerge=false and queueEntryId=null before final push. +- Final external actions stay unchecked until actually performed. They do not + require a receipt-only branch commit; the PR is their authoritative record. +- `bun --cwd packages/kitcn build` from the PR worktree passed. +- `bun --cwd packages/kitcn typecheck` passed. +- `bun test packages/kitcn/src/auth-start/index.retry.test.ts packages/kitcn/src/react/client.test.ts` failed before the test-isolation fix with two missing-`consistentQuery` errors, then passed with 15 tests. +- The final focused set passed 97 tests with 293 assertions. +- The full Bun lane passed 1,475 tests with 4,448 assertions; Vitest passed + 1,053 tests with 14 skipped and no type errors. +- `bun lint`, `bun run intent:validate`, `bun run intent:stale`, direct review, + no-comments cleanup, and branch autoreview pass. +- `bun check` reaches only `fixtures:check`: Expo's live SDK 55 template changed + generated guidance outside the immutable PR diff. The user explicitly waived + only that unrelated lane. + +Feedback ledger: +| URL | Source | Priority | Claim | Verdict | Rationale / proof | Reply | Resolution | +| --- | --- | --- | --- | --- | --- | --- | --- | +| https://github.com/udecode/kitcn/pull/473#issuecomment-5857551022 | top-level bot comment | P3 | Fork contributor needs Vercel team authorization | non-actionable | Vercel is not the active ruleset's required `CI` context; no deploy bypass is in scope | N/A | informational | +| https://github.com/udecode/kitcn/pull/473#issuecomment-5857551032 | top-level bot comment | P3 | Changeset releases `kitcn` and `@kitcn/resend` | non-actionable | `.changeset/config.json` fixes `kitcn` and `@kitcn/*` together; the single PR changeset correctly targets `kitcn` | N/A | informational | + +Feedback inventory receipt: +- Helper: 0 unresolved review threads, 1 non-noise PR comment, 0 review bodies. +- Raw REST: 2 top-level comments, 0 reviews, 0 inline comments. +- Raw GraphQL: 0 review threads, including resolved and outdated. +- Actionable P0/P1/P2/P3: 0/0/0/0. Informational P3 bot items: 2. + +Timeline: +- 2026-09-29T22:35:42.520Z Autoclosure plan created. +- 2026-09-30T00:39:00+02:00 `bun check` passed lint and package typechecks but + stopped in `convex` because the fresh worktree had no `packages/kitcn` build + artifacts. Package export proof requires the build, so the next move is + `bun --cwd packages/kitcn build` followed by the same gate. +- 2026-09-30T00:45:45+02:00 The package build, package typecheck, focused + contaminator-victim test, and all 1,475 Bun tests pass. The repository gate + stops only on live Expo template drift outside PR #473. +- 2026-09-30T00:49:29+02:00 Revalidated the clean local recovery commit and + unchanged live PR head. The fixture runner delegates Expo creation to + `create-expo-app@latest` with the moving `default@sdk-55` tag. No open repo + issue or PR owns the resulting guidance drift. Fixing that generator contract + would be a separate CLI task, not an in-scope repair for PR #473. +- 2026-09-30T00:50:11+02:00 Read the active `main` ruleset. It requires the + `CI` status, one code-owner approval, approval after the last push, and an + extra approval for unattributed changes. The current account can bypass the + ruleset, but Shipping forbids `--admin`; a human approval remains a later + wait rather than a defect to code around. +- 2026-09-30T00:50:53+02:00 Third consecutive goal turn revalidated a clean + local recovery commit, unchanged live PR head, red `bun check` fixture lane, + and no explicit waiver. The blocked audit is satisfied. +- 2026-09-30T00:58:19+02:00 The user said `go`, explicitly waiving only the + unrelated Expo fixture lane. Recovery resumed. This does not authorize an + admin merge bypass or unrelated fixture changes. +- 2026-09-30T01:00:00+02:00 Recovery commit + `29f558fbfd5ff37222d30d006a9fbee24744e9a1` reached the contributor branch. + GitHub head, fetched `refs/pr/473`, and local `HEAD` matched; the live body + contained exactly one task-plan line and the plan existed at that head. +- 2026-09-30T01:20:00+02:00 Material head + `ea5e442d5774fc7f7c0aa1f1d6bae8c1c34b7747` reached the contributor branch. + Post-push proof passed 97 focused tests, package typecheck/build, lint, 1,475 + Bun tests, 1,053 Vitest tests, intent validation/stale checks, direct P1 + review, no-comments cleanup, and autoreview. Feedback remained zero + actionable items across helper, REST, and GraphQL inventories. +- 2026-09-30T01:25:00+02:00 GitHub classified the fork `CI` run as + `action_required`, not failed. Normal maintainer workflow approval was issued; + protected-branch CI and code-owner review remain mandatory. +- 2026-09-30T01:28:00+02:00 Required GitHub `CI` run 36644341283 passed + install, skill validation, package build, 1,475 Bun tests, 1,053 Vitest tests, + CLI tests, and Concave smoke, then failed only when the moving Expo SDK 55 + template changed generated `.claude/settings.json`, `AGENTS.md`, and + `CLAUDE.md`. The user's local waiver cannot turn a required GitHub context + green, and Shipping forbids an admin bypass. + +Reboot status: +| Question | Answer | +| --- | --- | +| Where am I? | Authorized fixture-owner repair and canonical regeneration complete; full repository gate green. | +| Where am I going? | Finish checks, commit/push repair, get independent verdict and required CI, bind external receipt, guarded merge only after code-owner approval. | +| What is the goal? | Merge only a fully recovered and verified PR #473. | +| What have I learned? | The PR was recoverable. It also needed callback-safe cleanup, admission-time getter reads, public type tightening, docs, and isolated retry mocks. | +| What have I done? | Repaired source/tests/docs/types, synchronized skills, passed owning proofs and reviews, classified all feedback, and approved the normal fork CI run. | + +Open risks: +- Full local bun check passes without a fixture waiver; required final-head + GitHub CI and independent Shipping verdict remain to be observed. +- GitHub reports Vercel failure because the contributor deployment needs + udecode team authorization. Vercel is not a required ruleset context. +- Required GitHub `CI` at the old head is red. The authorized repair must be + pushed and pass a fresh normal fork run. No rules change or bypass applies. +- The `main` ruleset requires a code-owner approval after the final push. This + run will not use the account's available bypass. + +Resolved blocker receipt: +- Attempted: installed dependencies, built `packages/kitcn`, fixed the + deterministic suite contamination, reran focused tests, package typecheck, + lint, and the full repository gate across three goal turns. +- Evidence: 97 focused tests and all 1,475 Bun tests pass. `bun check` stops + only when the moving Expo SDK 55 template changes generated guidance outside + PR #473. Final material head is + `ea5e442d5774fc7f7c0aa1f1d6bae8c1c34b7747`. +- Previous blocker: repo policy forbade updating the PR with a failing + `bun check`, and autoclosure forbade adding unrelated fixture or CLI scope. +- Resolution: the user's `go` at 2026-09-30T00:58:19+02:00 explicitly waives + only the unrelated Expo fixture lane for PR #473. A code-owner approval will + still be required after the final push. +- Live feedback review is complete: zero actionable items, with both bot + comments ledgered as informational P3. diff --git a/docs/plans/473-optimistic-auth-gate.md b/docs/plans/473-optimistic-auth-gate.md new file mode 100644 index 000000000..80ec9e535 --- /dev/null +++ b/docs/plans/473-optimistic-auth-gate.md @@ -0,0 +1,550 @@ +# Optimistic auth gate and token identity guard + +Objective: +Recover and verify PR #473. Done when its exact task evidence is live, every +source-listed auth and React case passes, package gates pass, and the PR body +accurately reports the final proof. + +Flow mode: +one-shot execution + +Goal plan: +docs/plans/473-optimistic-auth-gate.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) + +Linked plans: +- None. + +User requirements: +- Latest steering: `fix...` authorizes repairing the Expo fixture drift that + blocks required CI, then continuing PR #473 closeout. Normalize the proven + host-dependent Expo settings at the fixture boundary and refresh all donor + snapshots through `tooling/fixtures.ts`; do not hand-edit generated files. +- Adopt the existing PR instead of closing it because its work is substantive. +- Finish the exact PR through the task contract, then return control to + autoclosure for feedback, final checks, and merge. + +Task source: +- type: GitHub pull request recovery +- id / link: https://github.com/udecode/kitcn/pull/473 +- title: `feat(react): optimistic auth gate, token identity guard, optimisticUpdate passthrough, deterministic server construction` +- acceptance criteria: the five behavior groups and test cases in the PR body, + plus exact-PR task evidence, package build and checks, a matching changeset, + and the required task-style PR body + +Timed checkpoint: +- requested duration: N/A. The user requested immediate closeout, not a timed run. +- semantics: N/A. +- initial confidence score: N/A. Completion uses binary evidence gates. +- improvement loop: Fix only defects inside the existing PR contract. +- final score / loop closure: N/A. The root autoclosure plan owns final closure. + +Completion threshold: +- The live PR body contains exactly one + `🧭 Task plan: docs/plans/473-optimistic-auth-gate.md` line. The file exists + at that exact head and names PR #473. +- Every source-listed case has focused test or source-audit evidence on the + committed head. +- The package build, relevant typecheck, lint, changeset audit, and repository + check pass or the root plan records a real external blocker. +- The PR body uses the task-style contract and matches the final evidence. +- Task closure is legal only when the source-of-truth acceptance criteria are + satisfied or explicitly narrowed, required verification evidence is recorded, + code-review and release-artifact gates are closed when applicable, verified + code changes are committed and PR'd unless explicitly declined or blocked, + task-style PR body sync is complete or marked N/A with reason, + GitHub issue/PR sync is complete or marked N/A with reason, and + `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/473-optimistic-auth-gate.md` passes. + +Verification surface: +- Focused tests in the four changed test files under `packages/kitcn/src`. +- Source audit of the auth provider, HTTP token path, mutation options, server + client construction, exports, static import graph, and changeset. +- `bun --cwd packages/kitcn build`, the owning typecheck, `bun lint:fix`, and + `bun check` from the PR worktree. +- `gh pr view` read-back of the final body, head OID, checks, and merge state. + +Constraints: +- Preserve existing user-facing behavior outside the task scope. +- Prefer the durable ownership boundary over caller-by-caller patches. +- When a GitHub PR is in scope, this plan owns exactly one PR. A coordinating + batch plan must link a separate task plan for every PR an agent processes. +- Verified code changes must be committed and PR'd because the task skill + requires that path unless the user explicitly says not to, the work has no + local patch, or a real blocker is recorded. +- The absence of a separate "open a PR" sentence from the user is not a valid + N/A reason for verified code-changing task work. +- A PR created by this task must use the PR #270 emoji task-style PR body + contract below, not a generic summary/body from a git helper skill. +- 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. +- Do not add broad ceremony when the task is trivial or docs-only. + +Boundaries: +- Authorized CI repair: `tooling/fixtures.ts`, its regression test, and + generated `fixtures/**`. The full gate also reproduced Next donor dependency + drift. Preserve deterministic donor output; exclude only Expo's + host-dependent `.claude/settings.json`. No package API or release artifact + change. Browser proof is N/A for fixture normalization and agent guidance. +- Source of truth: PR #473 body, its immutable head, and the package owners in + `packages/kitcn`. +- Allowed edit scope: the existing changed package files and tests, its + changeset, this plan, and the PR body. Add docs only if the public guidance + audit proves a real gap. +- Browser surface: N/A. The PR changes library state and typed options, not a + rendered route. +- GitHub issue sync: N/A. No linked issue is named in the PR. +- Non-goals: new auth features, compatibility shims, scaffold changes, UI + changes, deployment, or stack/base rewrites. + +Output budget strategy: +- Read exact changed files and bounded diffs. Save or count broad test and + feedback output before inspecting only failing slices. Exclude generated + builds, `node_modules`, coverage, and `tmp` by default. + +Blocked condition: +- Stop if the contributor branch cannot be updated, a required public behavior + cannot be verified without unavailable external state, or distinct repair + attempts reproduce the same environment failure. + +Task state: +- task_type: feature recovery and package API verification +- task_complexity: non-trivial, but not a new architecture task because the PR + contract and implementation boundary are already concrete +- current_phase: verified task delivery +- current_phase_status: full repository gate and source review complete +- next_phase: final push/body read-back; root external receipt and protected merge +- goal_status: active until final push/body read-back; root owns external landing + +Current verdict: +- verdict: ready +- confidence: 95-100% in the package change; GitHub approval and CI remain + external root-autoclosure gates +- next owner: root autoclosure +- reason: source review repaired the identity callback ordering, getter + admission semantics, public types, documentation, and test isolation; every + package proof, fixture normalization regression, canonical generation and + complete bun check pass without a waiver; final push/body read-back pending + +Implementation readiness: +- verdict: ready +- exact owner: `packages/kitcn` auth-client and React package code in PR #473 +- contradiction status: source review found two identity-guard defects and one + test-isolation defect; all three were repaired at their owning boundaries +- source-listed cases complete: yes; 97 focused tests, package typecheck/build, + 1,475 Bun tests, Vitest, lint, docs/skill validation, and review are green + +Pre-solution issue challenge: +- reporter claim: Convex orders authentication before queued queries, an SSR + token can safely open the query gate before socket confirmation, and an + identity-changing refresh can otherwise send queued work as another identity +- suggested diagnosis or fix: optimistic auth plus a JWT identity guard at the + shared token-fetch boundary, guarded HTTP token access, Convex-owned + optimistic updates, and injected server logging +- repro ladder: +- tests / source-level repro: the contaminator-victim order reproduced two + failures; a callback-order test reproduced zero `close()` calls; a getter + test reproduced mount-time baseline freezing + - repo-owned automated browser or integration proof: N/A unless source review + shows the unit boundary cannot model an auth ordering claim + - Browser plugin: N/A because there is no rendered browser contract + - screenshot / visual proof: N/A because no visual state changes +- reproduction verdict: valid; the auth-ordering contract is testable at the + package boundary and the suite also exposed a process-global mock leak +- validity verdict: valid after narrowing the fix to token admission, Convex + local state, HTTP token reuse, and file-scoped test doubles +- best long-term fix boundary: the shared token fetch/admission boundary and + Convex's own local store, not individual query or mutation callers +- harsh honest feedback: the PR direction was good, but the first implementation + trusted a callback not to throw and read a live getter too early +- hard-stop decision: continued because the claims reproduced; no compatibility + shim or caller-by-caller workaround was added + +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/473-optimistic-auth-gate.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 was requested. | +| Walkthrough baseline for possible UI change | no | N/A. The PR has no rendered UI change. | +| Skill analysis before edits | yes | Loaded `autoclosure`, `task`, `autogoal`, Better Auth guidance, diagnosis, testing, TDD, changeset, poteto Babysit and Shipping, unslop, technical writing, and deslop | +| Active goal checked or created | yes | Root goal points to `docs/plans/473-autoclosure.md`; this exact task plan is linked from it | +| Source of truth read before edits | yes | PR #473 body, three commits, changed paths, checks, comments, and immutable head read first | +| Exact per-PR task ownership | yes | This plan owns only https://github.com/udecode/kitcn/pull/473 | +| GitHub comments and attachments read | yes | Two bot comments, no reviews, no inline comments, and no media attachments at intake | +| Video transcript evidence required | no | N/A. The PR has no video or screen recording. | +| Pre-solution issue challenge required | yes | Auth-ordering and identity-change claims are recorded above; validity remains a closeout proof item | +| Reproduction verdict before implementation | yes | Repository gate reproduced a deterministic cross-file test failure before its test-only fix | +| Repro escalation ladder selected | yes | Focused source tests own this non-visual package behavior. Browser and screenshot proof are N/A. | +| Suggested fix reviewed against durable boundary | yes | The shared token-fetch boundary and Convex local store are the proposed owners; full review follows evidence recovery | +| `docs/solutions` checked for non-trivial existing-code work | yes | No matching auth-identity, ConvexHttpClient, or mock-contamination solution exists | +| TDD decision before behavior change or bug fix | yes | Existing failing server-client tests formed the red signal; the contaminator-victim pair proved the fix | +| Branch decision for code-changing task | yes | Preserved PR head `424a3bec...` on local `codex/pr-473-autoclosure`; target remains `EfficiencyCorp:feat/optimistic-auth-gate` | +| Release artifact decision | yes | Reuse and audit `.changeset/optimistic-auth-gate.md` | +| Browser tool decision for browser surface | no | N/A. No rendered or native browser behavior changes. | +| Commit / PR expectation decision | yes | Commit and push to the existing PR are required after `bun check` passes or the user explicitly waives its unrelated fixture lane | +| Task-style PR body decision | yes | Replace the current prose body with the required PR #270 task format while preserving the auto-release block | +| Task-plan PR body evidence | yes | Live body has exactly one `🧭 Task plan: docs/plans/473-optimistic-auth-gate.md` line and the plan exists at the recovered head | +| GitHub issue sync expectation decision | no | N/A. No linked issue exists. | +| Output budget strategy recorded | yes | Recorded above before broad review or feedback inventory | +| Package/API pack selected | yes | `package-api` is materialized in this plan | +| Public surface or package boundary identified | yes | `ConvexAuthProvider` props, cRPC mutation options, HTTP token context, and server `ConvexQueryClient` behavior | +| Convex entry/import graph impact identified | yes | Client and server React entry graphs require an import audit after recovery | +| CLI/scaffold/generated impact identified | yes | Latest repair extends only fixture normalization and canonically regenerated donor snapshots; product scaffold source is unchanged | +| Release artifact path selected | yes | `.changeset/optimistic-auth-gate.md` | +| `changeset` skill loaded when `.changeset` is required | yes | Loaded and compared the draft with the current changelog style | +| Package build / fixture impact decision recorded | yes | Package build passes; all eight fixtures regenerated, validated, and checked against fresh output. | + +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. +- [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. +- [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 are N/A because this is a published package delta with a changeset. +- [x] Package/API pack: compatibility is additive; closed-alpha hard-cut policy needs no shim or migration. +- [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 are N/A because no CLI surface changed. +- [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. +- [x] Package/API pack: `packages/kitcn` build passed; all eight fixtures regenerated canonically after the authorized CI repair. + +Completion Gates: +| Gate | Applies | Required action | Evidence | +|------|---------|-----------------|----------| +| Named verification threshold | yes | Run the named proofs | 97 focused tests, package typecheck/build, lint, 1,475 Bun tests, 1,053 Vitest tests, and intent validation/stale checks pass. | +| Exact per-PR task ownership | yes | Bind one plan to one PR | This plan names and owns only PR #473. | +| Pre-solution issue challenge verdict | yes | Record the validity decision | Valid after narrowing to shared token admission and Convex local state. | +| Repro escalation ladder | yes | Use the smallest honest harness | Source-level package tests reproduced every defect; Browser and screenshots are N/A for non-visual library behavior. | +| Bug reproduced before fix | yes | Record failing proof | Two missing-`consistentQuery` failures, zero callback-path closes, and stale getter admission were reproduced before repair. | +| Targeted behavior verification | yes | Run focused proof | 97 focused tests pass with 293 assertions. | +| TypeScript or typed config changed | yes | Run relevant typecheck | `bun --cwd packages/kitcn typecheck` passes. | +| Package exports or file layout changed | yes | Build emitted package surfaces | `bun --cwd packages/kitcn build` passes. | +| Package manifests, lockfile, or install graph changed | yes | Regenerate and validate | Six generated web manifests reflect donor cn/lucide-react drift; root/package manifests and lockfile are unchanged. | +| Agent rules or skills changed | no | N/A | No agent workflow source changed; the package skill documentation mirror was regenerated from its owner. | +| Workspace authority proof | yes | Prove from the PR worktree | Every command ran in `/Users/zbeyens/.codex/worktrees/pr-473-autoclosure/better-convex`. | +| Browser surface changed | no | N/A | No rendered route or native browser behavior changed. | +| Browser final proof | no | N/A | Package tests own the behavior. | +| UI walkthrough | no | N/A | No UI or rendered output changed. | +| Scaffold or fixture output changed | yes | Canonical sync/check | All eight fixtures synchronized, validated, and checked. Only Expo guidance/settings and six web donor manifests changed. | +| Package behavior or public API changed | yes | Maintain release artifact | `.changeset/optimistic-auth-gate.md` describes the package delta. | +| Docs and kitcn skill sync changed | yes | Keep guidance synchronized | `www` auth/mutation docs and `packages/kitcn/skills/kitcn` references match; generated mirror is byte-identical. | +| Docs or content changed | yes | Verify current-state claims | Source-backed examples and links were reviewed; rendered proof is N/A for incidental API docs. | +| High-risk mini gate | yes | Prove identity failure modes | Tests cover expiry, refusal, refresh, remount, live getter admission, callback throws, HTTP reuse, optimistic rollback, and request isolation. | +| Agent-native review for agent/tooling changes | yes | Source and proof parity audit | Existing fixtures:sync/check routes own the action. Durable owner is tooling/fixtures.ts; donor guidance stays generated, no hidden local Claude dependency or manual snapshot edits. Regression and sync pass. | +| Local install corruption suspected | no | N/A | The initial failure was missing fresh-worktree build artifacts, resolved by the required package build. | +| Commit created | yes | Commit verified changes | Material repairs are committed as `ea5e442d5774fc7f7c0aa1f1d6bae8c1c34b7747`. | +| PR create or update | yes | Push and read back | Material head is live on PR #473; root autoclosure owns the final plan-only push and merge. | +| Task-style PR body verified | yes | Use and read back the required format | Body contract is prepared for the final plan head; root autoclosure will update and read it back before receipt. | +| PR task evidence verified | yes | Check body, plan, and exact ownership | Exactly one matching task-plan line exists and the plan names PR #473. | +| PR proof image hosting | no | N/A | No browser proof or image belongs in the PR. | +| GitHub issue sync-back | no | N/A | No linked issue exists. | +| Final handoff contract | yes | Fill exact fields | Filled below; root autoclosure owns only external receipt, approval, CI, and merge. | +| Final lint | yes | Run lint | `bun lint` passes across 975 files. | +| Output budget discipline | yes | Keep broad output bounded | Searches were scoped and broad test/review output was summarized before inspecting failures. | +| Timed checkpoint | no | N/A | No duration was requested. | +| Autoreview for non-trivial implementation changes | yes | Close accepted findings | Branch autoreview and direct P1 review report no accepted/actionable findings. | +| Goal plan complete | yes | Run the goal checker | This final plan-only commit is checked before the root receipt. | +| Public API / package boundary proof | yes | Audit public types and entries | Compile-time tests cover provider props and `optimisticUpdate`; source-level `any` was removed. | +| Convex bundle/import proof | yes | Keep entry graphs narrow | Changes stay in existing auth-client/react/server owners; no new monolithic import graph was added. | +| CLI/scaffold/generated proof | yes | Canonical regeneration | Product scaffold contract unchanged; fixture normalizer has 14 passing tests and all eight snapshots were generated and validated. | +| Release artifact classification | yes | Classify delta | Published `kitcn` package behavior, types, docs, and runtime changed. | +| Published package changeset | yes | Keep one package changeset | `.changeset/optimistic-auth-gate.md` is present; fixed package config intentionally includes `@kitcn/resend`. | +| No release artifact | no | N/A | A release artifact is required and present. | +| Package typecheck/build/test | yes | Run owning proofs | Typecheck/build pass; focused 97-test set and full repository test lanes pass. | +| Fixture/scaffold generation | yes | Canonical sync/check | fixtures:sync and all eight fixture checks pass; full bun check including verify/runtime exits 0. | +| Docs/package skill sync | yes | Synchronize guidance | Source and generated skill references match the `www` guidance. | + +Phase / pass table: +| Phase | Status | Evidence | Next | +|-------|--------|----------|------| +| Intake and source read | complete | source, exact PR, and recovery boundaries recorded | implementation | +| Implementation | complete | identity, type, docs, and isolation repairs committed | verification | +| Verification | complete | focused/full tests, typecheck, build, lint, intent, review | closeout | +| Commit / PR / GitHub sync | complete | material head `ea5e442d...` pushed and read back; final plan/body owned by root | root receipt | +| Closeout | complete | task contract satisfied; external merge gates delegated to root autoclosure | root merge | + +Findings: +- `auth-start/index.retry.test.ts` used process-global `mock.module` calls. Its + `convex/browser` replacement removed `consistentQuery` from the class later + imported by `react/client.test.ts`. +- Expo fixture checks run `create-expo-app@latest` with + `default@sdk-55`. That moving upstream template changed generated guidance + without any PR #473 scaffold or fixture edit. + +Decisions and tradeoffs: +- Read `tokenIdentityBaseline` getters at token admission, while fixed baselines + and SSR tokens still seed the guard. Mount-time reads made live identity stale. +- Close the Convex client before notifying consumers. Cleanup cannot depend on + callback success. +- Preserve the existing package boundaries and add no compatibility layer; the + repository is closed alpha and the public additions are source-compatible. + +Implementation notes: +- Fixture repair data shape: the existing `TemplateKey` registry identifies + donor families. The shared private artifact normalizer receives that key; + public snapshot/comparison signatures remain unchanged. +- Architect ground: sync normalizes the generated app before copying it; + checks normalize the fresh app and both comparison operands. The existing + shared artifact boundary owns all three paths, before the owned/full split. +- Architect phases: ground complete; two independent inherited-model sketches + complete; agree proceeded under existing authority; implement matches A; + scrap N/A because no contract deviation occurred. Model diversity is reduced + by the repository's inherit-parent role configuration. +- Arena phases: framed correctness, retention, interface depth, maintainability + and proportionality; compared A (private donor discriminator) and B (registry + omission field); cross-judge scored A 5/5/5/4/5, B 5/5/3/4/3; picked A; no + graft because B's configuration is unearned for one known artifact; red-green + verification passed. No new module, registry policy API, or scaffold change. +- Throughput checkpoint: root owns regression and plan evidence; repair agent + owns generator source and canonical fixture regeneration. Serialize sync, + fixture checks and package builds to avoid shared packing/build races. +- Test-first cadence: red was observed before the source patch. A separate + knowingly failing commit is skipped to preserve the user-owned task bundle; + the final commit includes the verified checkout. +- `WeakMap`-backed token state and file-scoped spies keep retry tests isolated. +- Public optimistic update args use Convex `Value`, with compile-time API tests. +- Current-state docs and published skill references describe the auth gate, + identity baseline, mismatch callback, and optimistic mutation option. + +Review fixes: +- Close the Convex client before invoking `onTokenIdentityChange`, so a + throwing consumer callback cannot leave queued work alive after the guard + trips. A focused red test observed zero `close()` calls before the reorder. +- Replace the new source-level `any` constraint with Convex `Value` and add + compile-time coverage for public `optimisticUpdate` and provider props. +- Document the public auth and optimistic-mutation options in `www` and the + compressed published kitcn skill mirrors. + +Error attempts: +| Error / failed attempt | Count | Next different move | Resolution | +|------------------------|-------|---------------------|------------| +| Fresh-worktree `bun check` cannot resolve built `kitcn/auth/*` exports from `convex` | 1 | Build `packages/kitcn`, then rerun the exact repository gate | Resolved. Build passed and package consumers typechecked. | +| `auth-start/index.retry.test.ts` poisons `convex/browser` for later tests | 1 | Replace process-global `mock.module` calls with file-scoped spies and rerun the minimal order | Resolved. Two failures became 15 passes and the full Bun suite passed. | +| `fixtures:check` detects upstream Expo guidance drift outside the PR diff | 1 | Keep unrelated fixture churn out of PR #473 and request either a waiver or a separately scoped repair | Resolved for this PR by the user's `go` at 2026-09-30T00:58:19+02:00. The waiver applies only to this unrelated fixture lane. | + +Verification evidence: +- CI repair red signal: `bun test ./tooling/fixtures.test.ts` reports 12 pass, + 2 fail. Both Expo keys retain host-dependent settings, receiving `true` where + absence (`false`) is required. The donor's `isClaudeCodeInstalled` condition + is visible in installed `create-expo@5.0.3` build 205, lines 1152-1179. +- Full gate after snapshot-only regeneration passes Expo locally but reproduces + Next dependency drift (`cn` and `lucide-react`). Canonical all-fixture sync + must retain those changes rather than hide them from comparison. +- CI repair baseline: final-head GitHub run 36645318114 passes source tests and + types, then fails on stale Expo-generated `.claude/settings.json`, `AGENTS.md`, + and `CLAUDE.md`. The original fixture check is the integration red harness; + the host-dependent normalization has its own narrow red-green regression. +- CI repair green signal: `bun test ./tooling/fixtures.test.ts` passes all 14 + tests and 57 assertions, including idempotent Expo snapshot removal, both + comparison scopes, and preservation of AGENTS, other Claude files, and + non-Expo settings. No-comments audit found zero new comments or suppressions. +- Red proof: `bun test packages/kitcn/src/auth-start/index.retry.test.ts packages/kitcn/src/react/client.test.ts` failed two server-mode tests because `consistentQuery` was absent after a process-global `convex/browser` mock. +- Green proof: the same command passes 15 tests after file-scoped spies replace the global mocks. +- `bun --cwd packages/kitcn typecheck` passes. +- `bun --cwd packages/kitcn build` passes and emits the React, auth-client, server, and other package artifacts. +- The final focused set passes 97 tests and 293 assertions. +- The full Bun lane passes 1,475 tests with 4,448 assertions. Vitest passes + 1,053 tests with 14 skipped and no type errors. +- `bun lint`, `bun run intent:validate`, and `bun run intent:stale` pass. +- Latest `bun check` exits 0 with no waiver. It passes 1,475 Bun tests, 1,053 + Vitest tests (14 skipped), 124 CLI tests, Concave smoke, all eight fixture + checks, `kitcn verify`, and every runtime scenario. Log + `/tmp/pr473-fixed-check.log`. Earlier red fixture runs are superseded. +- Incremental autoreview exits 0, TruffleHog clean, no accepted/actionable + findings. Direct source review confirms both existing callers use the same + donor-discriminated normalizer and no public scaffold behavior changed. +- Deslop delta adds no finding in the incremental tooling repair. Existing + retry-test setup warnings reflect deliberate isolated spies; no speculative + deduplication reintroduces the process-global leak. + +Source-listed case matrix: +| Case | Source claim | Harness | Before | Expected after | Evidence | Status | +| --- | --- | --- | --- | --- | --- | --- | +| optimistic auth | A held, unexpired JWT opens the optional or required query gate before Convex confirmation; expiry, refusal, and default-off states stay safe | `convex-auth-provider.test.tsx` focused cases | coherent implementation | every named state passes | final 97-test focused set | green | +| token identity guard | SSR, first-token, refresh, remount, getter, admitted callback, and tripped states never hand a mismatched identity to Convex or HTTP | `convex-auth-provider.test.tsx` focused cases plus source audit | callback cleanup and getter timing defects reproduced | every named identity transition passes | focused tests prove close-before-callback and live getter admission | green | +| guarded HTTP token | HTTP headers use the same guarded fetcher and omit a refused token | `context.test.tsx` focused cases | coherent implementation | guarded token or no header | final 97-test focused set | green | +| optimistic mutation | `optimisticUpdate` reaches Convex `withOptimisticUpdate` and not TanStack options | `use-query-options.test.tsx` focused cases and typecheck | public type constraint used `any` | Convex owns update and rollback | focused runtime and compile-time tests pass with `Value` | green | +| deterministic server client | Construction and querying avoid `Math.random`; interleaved requests preserve auth and snapshots | `client.test.ts` server-mode cases | full suite failed because another test replaced `convex/browser` process-wide | focused and full-suite cases pass | 15-test pair and 1,475-test Bun suite pass | green | +| test isolation | Auth-start retry test cannot alter the Convex client class seen by later files | contaminator-victim two-file order | two failures with missing `consistentQuery` | 15 passes with real class plus file-scoped spies | exact red and green commands recorded above | green | + +Final handoff contract: +- Commit line: `ea5e442d` contains the final material repair; the root plan adds + one evidence-only commit before receipt. +- PR line: PR #473 is updated on `EfficiencyCorp:feat/optimistic-auth-gate`. +- Issue line: N/A; no linked issue. +- Confidence line: 95-100% in the implementation; merge remains gated by GitHub. +- Flow table: + - Reproduced: three source/test defects; Browser N/A. + - Verified: 97 focused tests plus complete package/repository lanes; Browser N/A. +- Browser check: N/A; non-visual package behavior. +- Outcome: optimistic auth, guarded identity, HTTP token reuse, optimistic + mutations, and deterministic server construction are verified. +- Caveat: the authorized fixture repair supersedes the earlier local waiver. + Required GitHub CI and post-push code-owner approval remain mandatory. +- Design: + - Chosen boundary: shared token admission plus existing Convex client/store. + - Why not quick patch: per-query checks would split identity ownership. + - Why not broader change: no new auth topology or compatibility layer is needed. +- Verified: focused/full tests, typecheck, build, lint, intent checks, feedback, + no-comments review, direct source review, and autoreview. +- PR body verified: root autoclosure owns the final body read-back after this + plan-only commit. + +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: material head `ea5e442d5774fc7f7c0aa1f1d6bae8c1c34b7747`. +- PR: https://github.com/udecode/kitcn/pull/473 +- Issue: N/A; no linked issue. +- Browser proof: N/A; no rendered surface changed. +- Caveats: unrelated Expo fixture drift waived; protected-branch CI and review + remain external and cannot be bypassed. + +Timeline: +- 2026-09-29T22:35:42.653Z Task goal plan created. +- 2026-09-30T00:39:00+02:00 First `bun check` reached workspace typecheck and + failed only where `convex` imports package export artifacts that do not exist + in a fresh worktree. The package build is the next different move. +- 2026-09-30T00:45:45+02:00 The branch-owned contamination fix passes its + deterministic two-file repro, package typecheck, package build, lint, and the + full 1,475-test Bun lane. The repository gate stops on unrelated Expo fixture + drift before task evidence can be pushed. +- 2026-09-30T00:49:29+02:00 Revalidated live PR head + `424a3bec59d8c9b1accf592ad534ef73ad90e7dc`, local recovery commit + `c09c6d1b2603441eb3c68db19a6eb1903702a683`, and the fixture generator's + moving Expo inputs. A deterministic fixture-owner repair needs its own task. +- 2026-09-30T00:50:11+02:00 The active `main` ruleset requires `CI`, one + code-owner approval, last-push approval, and extra approval for unattributed + changes. The available admin bypass is deliberately out of bounds. +- 2026-09-30T00:50:53+02:00 The same fixture-gate blocker remained authoritative + for a third consecutive goal turn. No user waiver or external state change + arrived, so the task recovery is blocked without pushing partial evidence. +- 2026-09-30T00:58:19+02:00 The user said `go`, explicitly waiving only the + unrelated Expo fixture lane. Exact-PR recovery resumed without authorizing + fixture changes or an admin merge bypass. +- 2026-09-30T01:00:00+02:00 Recovery commit + `29f558fbfd5ff37222d30d006a9fbee24744e9a1` reached the contributor branch. + GitHub head, fetched `refs/pr/473`, and local `HEAD` matched; the live body + contained exactly one task-plan line and this plan existed at that head. + +Reboot status: +| Question | Answer | +|----------|--------| +| Where am I? | Exact-PR package recovery and authorized CI fixture repair pass the complete repository gate. | +| Where am I going? | Finish full gate, push verified repair, update body, then external receipt, required CI and protected merge. | +| What is the goal? | Recover and verify PR #473 without changing its product contract. | +| What have I learned? | Expo donor settings depend on the generating host; normalization belongs at the shared snapshot/comparison boundary. Web donor dependency drift must remain visible. | +| What have I done? | Repaired package defects, added red-green normalization coverage, regenerated all fixtures, and passed incremental autoreview with no findings. | + +Open risks: +- Local bun check is green; required GitHub CI still needs a fresh final-head + run. Local proof is not a server-enforced status or approval waiver. +- Independent final-head Shipping verdict and live feedback/receipt cycle + remain required before merge. +- GitHub approval is an external post-push wait. The merge must not use the + current account's ruleset bypass. + +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/fixtures/expo-auth/.claude/settings.json b/fixtures/expo-auth/.claude/settings.json deleted file mode 100644 index 176e6a5a7..000000000 --- a/fixtures/expo-auth/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "expo@claude-plugins-official": true - } -} diff --git a/fixtures/expo-auth/AGENTS.md b/fixtures/expo-auth/AGENTS.md index a62f7ed19..be05e778d 100644 --- a/fixtures/expo-auth/AGENTS.md +++ b/fixtures/expo-auth/AGENTS.md @@ -1,3 +1,41 @@ -# Expo HAS CHANGED +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. -Read the exact versioned docs at https://docs.expo.dev/versions/v55.0.0/ before writing any code. +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/fixtures/expo-auth/CLAUDE.md b/fixtures/expo-auth/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/fixtures/expo-auth/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/fixtures/expo/.claude/settings.json b/fixtures/expo/.claude/settings.json deleted file mode 100644 index 176e6a5a7..000000000 --- a/fixtures/expo/.claude/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "enabledPlugins": { - "expo@claude-plugins-official": true - } -} diff --git a/fixtures/expo/AGENTS.md b/fixtures/expo/AGENTS.md index a62f7ed19..be05e778d 100644 --- a/fixtures/expo/AGENTS.md +++ b/fixtures/expo/AGENTS.md @@ -1,3 +1,41 @@ -# Expo HAS CHANGED +This is an Expo/React Native mobile application. Prioritize mobile-first patterns, performance, and cross-platform compatibility. -Read the exact versioned docs at https://docs.expo.dev/versions/v55.0.0/ before writing any code. +## Expo has changed — do not trust your training data + +Expo ships breaking changes every SDK release. APIs you remember are likely renamed, moved, or removed. Before writing any code that touches an Expo, EAS, or React Native API: + +1. Read the major version of the `expo` package in `package.json`. +2. Fetch the matching versioned docs: `https://docs.expo.dev/versions/v.0.0/` +3. For anything else, fetch https://docs.expo.dev/llms.txt — an index of all Expo docs with corrections to common LLM misconceptions. Follow its links to the specific page you need; never answer from memory. + +## Commands + +Use `bunx` instead of `npx` if the project uses bun (`bun.lock` present). + +```bash +npx expo install # ALWAYS use instead of npm/yarn/pnpm/bun add — resolves SDK-compatible versions +npx expo start # start the dev server +npx expo lint # lint +npx tsc --noEmit # typecheck +npx expo-doctor # diagnose dependency and config issues +npx expo install --fix # fix incompatible package versions +``` + +Run lint and typecheck before declaring any task done. + +## Navigation & Routing + +- Use **Expo Router** for all navigation. Routes live in `src/app/` — every file there is a screen, `_layout.tsx` files define navigators. Keep non-route code (components, hooks, utils) outside `src/app/`. +- Import `Link`, `router`, and `useLocalSearchParams` from `expo-router`. +- Docs: https://docs.expo.dev/router/introduction.md + +## Building with EAS + +Use EAS to build, sign, and submit the app in the cloud (`eas build`, `eas submit`) and to ship over-the-air updates (`eas update`) — no local Xcode or Android Studio required. Run EAS CLI as `bunx eas-cli ` in Bun projects, or `npx eas-cli@latest ` otherwise; substitute that for bare `eas` in docs examples. +Docs: https://docs.expo.dev/eas/index.md + +## Rules + +- If `ios/` and `android/` directories do not exist, they are generated (Continuous Native Generation). Never create or edit them by hand — configure native behavior in `app.json` and config plugins. +- Expo Go only includes its bundled native modules. After adding a library with native code, the app needs a development build: `npx expo run:ios|android` locally, or `eas build --profile development`. +- Prefer recommended Expo modules over third-party libraries, and check your available skills before adding dependencies. Docs: https://docs.expo.dev/versions/latest/index.md diff --git a/fixtures/expo/CLAUDE.md b/fixtures/expo/CLAUDE.md deleted file mode 100644 index 43c994c2d..000000000 --- a/fixtures/expo/CLAUDE.md +++ /dev/null @@ -1 +0,0 @@ -@AGENTS.md diff --git a/fixtures/next-auth/package.json b/fixtures/next-auth/package.json index 65bf488b5..408b36570 100644 --- a/fixtures/next-auth/package.json +++ b/fixtures/next-auth/package.json @@ -5,11 +5,11 @@ "@tanstack/react-query": "5.95.2", "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "next": "16.3.4", "next-themes": "^0.4.6", "react": "19.2.8", diff --git a/fixtures/next/package.json b/fixtures/next/package.json index c6f18e638..281e24fe6 100644 --- a/fixtures/next/package.json +++ b/fixtures/next/package.json @@ -4,11 +4,11 @@ "@opentelemetry/api": "1.9.0", "@tanstack/react-query": "5.95.2", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "next": "16.3.4", "next-themes": "^0.4.6", "react": "19.2.8", diff --git a/fixtures/start-auth/package.json b/fixtures/start-auth/package.json index 8536979e9..89bf8acf3 100644 --- a/fixtures/start-auth/package.json +++ b/fixtures/start-auth/package.json @@ -13,11 +13,11 @@ "@tanstack/router-plugin": "latest", "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "latest", diff --git a/fixtures/start/package.json b/fixtures/start/package.json index 75def390e..68a7ecd52 100644 --- a/fixtures/start/package.json +++ b/fixtures/start/package.json @@ -12,11 +12,11 @@ "@tanstack/react-start": "latest", "@tanstack/router-plugin": "latest", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "latest", diff --git a/fixtures/vite-auth/package.json b/fixtures/vite-auth/package.json index 28153f9ee..b2ff73a00 100644 --- a/fixtures/vite-auth/package.json +++ b/fixtures/vite-auth/package.json @@ -7,11 +7,11 @@ "@tanstack/react-query": "5.95.2", "better-auth": "1.7.1", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "latest", diff --git a/fixtures/vite/package.json b/fixtures/vite/package.json index f592b3bd9..60bf61bb0 100644 --- a/fixtures/vite/package.json +++ b/fixtures/vite/package.json @@ -6,11 +6,11 @@ "@tailwindcss/vite": "^4", "@tanstack/react-query": "5.95.2", "class-variance-authority": "^0.7.1", - "cn": "^0.3.0", + "cn": "^0.4.0", "convex": "1.44.0", "hono": "4.12.9", "kitcn": "workspace:*", - "lucide-react": "^1.46.0", + "lucide-react": "^1.49.0", "react": "^19.2.8", "react-dom": "^19.2.8", "shadcn": "latest", diff --git a/packages/kitcn/skills/kitcn/references/features/auth.md b/packages/kitcn/skills/kitcn/references/features/auth.md index 94440228b..3fba69452 100644 --- a/packages/kitcn/skills/kitcn/references/features/auth.md +++ b/packages/kitcn/skills/kitcn/references/features/auth.md @@ -406,11 +406,26 @@ All from `kitcn/react`: authClient={authClient} convexQueryClient={convexQueryClient} // when using TanStack Query initialToken={token} // from SSR (caller.getToken()) + optimisticAuth // opt in: open gates while Convex confirms a held JWT + onTokenIdentityChange={() => window.location.reload()} onMutationUnauthorized={() => router.push('/login')} onQueryUnauthorized={({ queryName }) => console.log(`Unauth: ${queryName}`)} > ``` +`optimisticAuth` only opens auth-bound query gates for a held, unexpired JWT; +expired, opaque, and refused tokens stay closed. 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. + +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. +`onTokenIdentityAdmitted(token)` observes admitted JWTs so the app can update +that shared baseline. All three identity options require +`onTokenIdentityChange`. + For `@convex-dev/auth` (React Native): ```tsx import { ConvexProviderWithAuth } from 'kitcn/react'; diff --git a/packages/kitcn/skills/kitcn/references/features/react.md b/packages/kitcn/skills/kitcn/references/features/react.md index 3220a4aed..a785fc054 100644 --- a/packages/kitcn/skills/kitcn/references/features/react.md +++ b/packages/kitcn/skills/kitcn/references/features/react.md @@ -298,6 +298,22 @@ const mutation = useMutation(crpc.user.update.mutationOptions({ Signature: `crpc.path.mutationOptions(options?)` — standard TanStack mutation options except `mutationFn`. +Pass `optimisticUpdate(localStore, args)` to use Convex's optimistic local +query store. This option is removed before the TanStack options are returned; +Convex replays it as query data changes and owns rollback when the mutation +completes. + +```ts +const mutation = useMutation(crpc.todos.rename.mutationOptions({ + optimisticUpdate: (store, args) => { + const todos = store.getQuery(api.todos.list, {}); + if (todos) store.setQuery(api.todos.list, {}, todos.map((todo) => + todo._id === args.id ? { ...todo, title: args.title } : todo + )); + }, +})); +``` + ### Mutation Keys ```ts 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 3867fa63b..e32de0dd8 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx @@ -1312,6 +1312,629 @@ describe('ConvexAuthProvider', () => { expect(convexToken).toHaveBeenCalledTimes(0); }); + describe('onTokenIdentityChange', () => { + type FetchToken = (args: { + forceRefreshToken: boolean; + }) => Promise; + + const identityJwt = (sub: string, sessionId: string, expSeconds = 3600) => { + const payload = btoa( + JSON.stringify({ + exp: Math.floor(Date.now() / 1000) + expSeconds, + sessionId, + sub, + }) + ); + return `x.${payload}.z`; + }; + + const fetchWithAct = async ( + fetchToken: FetchToken, + forceRefreshToken: boolean + ) => { + let token: string | null = null; + await act(async () => { + token = await fetchToken({ forceRefreshToken }); + }); + return token; + }; + + const guardHarness = ({ + guard, + refreshed, + throws = false, + }: { + guard: boolean; + refreshed: string; + throws?: boolean; + }) => { + let fetchToken: + | ((args: { forceRefreshToken: boolean }) => Promise) + | null = null; + const close = mock(async () => {}); + const client = { + setAuth: (fetcher: typeof fetchToken) => { + fetchToken = fetcher; + }, + clearAuth: () => {}, + close, + }; + const authClient = { + useSession: () => ({ data: null, isPending: true }), + convex: { token: async () => ({ data: { token: refreshed } }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const onTokenIdentityChange = mock(() => { + if (throws) throw new Error('identity callback failed'); + }); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useAuth(), { wrapper }); + return { + close, + onTokenIdentityChange, + fetch: (forceRefreshToken: boolean) => { + if (!fetchToken) throw new Error('setAuth was not called'); + return fetchWithAct(fetchToken, forceRefreshToken); + }, + }; + }; + + const flush = () => + act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + test("the reviewer's sequence: SSR token A near expiry, the first fetch returns B", async () => { + let fetchToken: + | ((args: { forceRefreshToken: boolean }) => Promise) + | null = null; + const close = mock(async () => {}); + const client = { + setAuth: (fetcher: typeof fetchToken) => { + fetchToken = fetcher; + }, + clearAuth: () => {}, + close, + }; + const tokenForB = identityJwt('user_b', 'session_b'); + const authClient = { + useSession: () => ({ + data: { session: { id: 'session_a' }, user: { id: 'user_a' } }, + isPending: false, + }), + convex: { token: async () => ({ data: { token: tokenForB } }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const onTokenIdentityChange = mock(() => {}); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const { result } = renderHook(() => useAuthStore(), { wrapper }); + await flush(); + if (!fetchToken) throw new Error('setAuth was not called'); + + expect(await fetchWithAct(fetchToken, false)).toBeNull(); + expect(onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + expect(result.current.get('token')).not.toBe(tokenForB); + expect(await fetchWithAct(fetchToken, true)).toBeNull(); + }); + + test('no SSR token: the first token a sign-in obtains sets the identity', async () => { + let fetchToken: + | ((args: { forceRefreshToken: boolean }) => Promise) + | null = null; + const close = mock(async () => {}); + const client = { + setAuth: (fetcher: typeof fetchToken) => { + fetchToken = fetcher; + }, + clearAuth: () => {}, + close, + }; + const tokenForC = identityJwt('user_c', 'session_c'); + const authClient = { + useSession: () => ({ + data: { session: { id: 'session_c' }, user: { id: 'user_c' } }, + isPending: false, + }), + convex: { token: async () => ({ data: { token: tokenForC } }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const onTokenIdentityChange = mock(() => {}); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useAuth(), { wrapper }); + await flush(); + if (!fetchToken) throw new Error('setAuth was not called'); + + expect(await fetchWithAct(fetchToken, false)).toBe(tokenForC); + expect(onTokenIdentityChange).toHaveBeenCalledTimes(0); + expect(close).toHaveBeenCalledTimes(0); + }); + + test('never hands Convex a token for another user or session', async () => { + const harness = guardHarness({ + guard: true, + refreshed: identityJwt('user_b', 'session_b'), + }); + await flush(); + + expect(await harness.fetch(false)).not.toBeNull(); + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + test('closes the client even when the identity-change callback throws', async () => { + const harness = guardHarness({ + guard: true, + refreshed: identityJwt('user_b', 'session_b'), + throws: true, + }); + await flush(); + + expect(await harness.fetch(false)).not.toBeNull(); + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + }); + + test('passes a refreshed token for the same session through', async () => { + const refreshed = identityJwt('user_a', 'session_a', 7200); + const harness = guardHarness({ guard: true, refreshed }); + await flush(); + + await harness.fetch(false); + expect(await harness.fetch(true)).toBe(refreshed); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + expect(harness.close).toHaveBeenCalledTimes(0); + }); + + describe('tokenIdentityBaseline', () => { + const remount = ({ + baseline, + obtained, + }: { + baseline: string | null | undefined; + obtained: string; + }) => { + let fetchToken: + | ((args: { forceRefreshToken: boolean }) => Promise) + | null = null; + const close = mock(async () => {}); + const client = { + setAuth: (fetcher: typeof fetchToken) => { + fetchToken = fetcher; + }, + clearAuth: () => {}, + close, + }; + const authClient = { + useSession: () => ({ + data: { session: { id: 'session' }, user: { id: 'user' } }, + isPending: false, + }), + convex: { token: async () => ({ data: { token: obtained } }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { + oneTimeToken: { verify: async () => ({ data: {} }) }, + }, + }; + const onTokenIdentityChange = mock(() => {}); + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + renderHook(() => useAuth(), { wrapper }); + return { + close, + onTokenIdentityChange, + fetch: (forceRefreshToken: boolean) => { + if (!fetchToken) throw new Error('setAuth was not called'); + return fetchWithAct(fetchToken, forceRefreshToken); + }, + }; + }; + + test('a remount without a token refuses a first token of another identity', async () => { + const harness = remount({ + baseline: 'user_a|session_a', + obtained: identityJwt('user_b', 'session_b'), + }); + await flush(); + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + expect(await harness.fetch(true)).toBeNull(); + }); + + test('a remount without a token admits a token of the same identity', async () => { + const sameSession = identityJwt('user_a', 'session_a'); + const harness = remount({ + baseline: 'user_a|session_a', + obtained: sameSession, + }); + await flush(); + + expect(await harness.fetch(false)).toBe(sameSession); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + expect(harness.close).toHaveBeenCalledTimes(0); + }); + + test('without a baseline the first token obtained sets the identity, as before', async () => { + const first = identityJwt('user_b', 'session_b'); + for (const baseline of [undefined, null]) { + const harness = remount({ baseline, obtained: first }); + await flush(); + + expect(await harness.fetch(false)).toBe(first); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + expect(harness.close).toHaveBeenCalledTimes(0); + } + }); + }); + + describe('tokenIdentityBaseline getter and onTokenIdentityAdmitted', () => { + const documentHarness = ({ + getter = true, + guard = true, + identity, + obtained, + }: { + getter?: boolean; + guard?: boolean; + identity: string | null; + obtained: string[]; + }) => { + let fetchToken: + | ((args: { forceRefreshToken: boolean }) => Promise) + | null = null; + const setAuth = mock((fetcher: typeof fetchToken) => { + fetchToken = fetcher; + }); + const close = mock(async () => {}); + const client = { setAuth, clearAuth: () => {}, close }; + const queue = [...obtained]; + const convexToken = mock(async () => ({ + data: { token: queue.shift() ?? null }, + })); + const authClient = { + useSession: () => ({ + data: { session: { id: 'session' }, user: { id: 'user' } }, + isPending: false, + }), + convex: { token: convexToken }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { + oneTimeToken: { verify: async () => ({ data: {} }) }, + }, + }; + const document = { identity }; + const readBaseline = mock(() => document.identity); + const onTokenIdentityChange = mock(() => {}); + const onTokenIdentityAdmitted = mock((_token: string) => {}); + const Provider = ({ + children, + onAdmitted, + }: { + children: ReactNode; + onAdmitted: (token: string) => void; + }) => ( + + {children} + + ); + let onAdmitted: (token: string) => void = onTokenIdentityAdmitted; + const view = renderHook(() => useAuth(), { + wrapper: ({ children }: { children: ReactNode }) => ( + {children} + ), + }); + return { + close, + convexToken, + document, + onTokenIdentityAdmitted, + onTokenIdentityChange, + readBaseline, + setAuth, + fetch: (forceRefreshToken: boolean) => { + if (!fetchToken) throw new Error('setAuth was not called'); + return fetchWithAct(fetchToken, forceRefreshToken); + }, + replaceOnAdmitted: (next: (token: string) => void) => { + onAdmitted = next; + view.rerender(); + }, + }; + }; + + test('the getter is read at admission, not at mount: a remount refuses a token of another identity', async () => { + const harness = documentHarness({ + identity: null, + obtained: [identityJwt('user_b', 'session_b')], + }); + await flush(); + expect(harness.readBaseline).toHaveBeenCalledTimes(0); + harness.document.identity = 'user_a|session_a'; + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.readBaseline).toHaveBeenCalled(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityAdmitted).toHaveBeenCalledTimes(0); + expect(await harness.fetch(true)).toBeNull(); + }); + + test('the getter current identity owns the first admission', async () => { + const tokenForB = identityJwt('user_b', 'session_b'); + const harness = documentHarness({ + identity: 'user_a|session_a', + obtained: [tokenForB], + }); + await flush(); + expect(harness.readBaseline).toHaveBeenCalledTimes(0); + harness.document.identity = 'user_b|session_b'; + + expect(await harness.fetch(false)).toBe(tokenForB); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + expect(harness.close).toHaveBeenCalledTimes(0); + }); + + test('a cached token is refused once the document moved to another identity', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = documentHarness({ + identity: 'user_a|session_a', + obtained: [tokenForA], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + harness.document.identity = 'user_b|session_b'; + + expect(await harness.fetch(false)).toBeNull(); + expect(harness.convexToken).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.close).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityAdmitted).toHaveBeenCalledTimes(1); + }); + + test('a getter answering null does not constrain; the guard keeps the identity it admitted', async () => { + const tokenForA = identityJwt('user_a', 'session_a'); + const harness = documentHarness({ + identity: null, + obtained: [tokenForA, identityJwt('user_b', 'session_b')], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(tokenForA); + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityAdmitted.mock.calls).toEqual([ + [tokenForA], + ]); + }); + + test('onTokenIdentityAdmitted hears every admitted token once, cached ones included', async () => { + const first = identityJwt('user_a', 'session_a'); + const refreshed = identityJwt('user_a', 'session_a', 7200); + const harness = documentHarness({ + identity: 'user_a|session_a', + obtained: [first, refreshed], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(first); + expect(await harness.fetch(false)).toBe(first); + expect(await harness.fetch(true)).toBe(refreshed); + expect(harness.convexToken).toHaveBeenCalledTimes(2); + expect(harness.onTokenIdentityAdmitted.mock.calls).toEqual([ + [first], + [first], + [refreshed], + ]); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(0); + }); + + test('onTokenIdentityAdmitted is never called for a refused token, nor after the guard tripped', async () => { + const harness = documentHarness({ + getter: false, + identity: 'user_a|session_a', + obtained: [ + identityJwt('user_b', 'session_b'), + identityJwt('user_a', 'session_a'), + ], + }); + await flush(); + + expect(await harness.fetch(false)).toBeNull(); + expect(await harness.fetch(true)).toBeNull(); + expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(harness.onTokenIdentityAdmitted).toHaveBeenCalledTimes(0); + }); + + test('a new onTokenIdentityAdmitted is used without handing Convex a new fetcher', async () => { + const token = identityJwt('user_a', 'session_a'); + const harness = documentHarness({ + identity: 'user_a|session_a', + obtained: [token], + }); + await flush(); + const setAuthCalls = harness.setAuth.mock.calls.length; + const next = mock((_token: string) => {}); + harness.replaceOnAdmitted(next); + await flush(); + + expect(await harness.fetch(false)).toBe(token); + expect(harness.setAuth).toHaveBeenCalledTimes(setAuthCalls); + expect(next.mock.calls).toEqual([[token]]); + expect(harness.onTokenIdentityAdmitted).toHaveBeenCalledTimes(0); + }); + + test('onTokenIdentityAdmitted needs onTokenIdentityChange', async () => { + const token = identityJwt('user_b', 'session_b'); + const harness = documentHarness({ + guard: false, + identity: 'user_a|session_a', + obtained: [token], + }); + await flush(); + + expect(await harness.fetch(false)).toBe(token); + expect(harness.onTokenIdentityAdmitted).toHaveBeenCalledTimes(0); + expect(harness.close).toHaveBeenCalledTimes(0); + }); + }); + + test('changes nothing without the option', async () => { + const refreshed = identityJwt('user_b', 'session_b'); + const harness = guardHarness({ guard: false, refreshed }); + await flush(); + + await harness.fetch(false); + expect(await harness.fetch(true)).toBe(refreshed); + expect(harness.close).toHaveBeenCalledTimes(0); + }); + }); + + describe('optimisticAuth', () => { + const optimisticHarness = ( + initialToken: string, + optimisticAuth: boolean + ) => { + let reportAuth: ((isAuthenticated: boolean) => void) | null = null; + const client = { + setAuth: (_fetchToken: unknown, onChange: (value: boolean) => void) => { + reportAuth = onChange; + }, + clearAuth: () => {}, + }; + const authClient = { + useSession: () => ({ data: null, isPending: true }), + convex: { token: async () => ({ data: {} }) }, + getSession: async () => null, + updateSession: () => {}, + crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, + }; + const wrapper = ({ children }: { children: ReactNode }) => ( + + {children} + + ); + const hook = renderHook( + () => ({ auth: useAuth(), store: useAuthStore() }), + { wrapper } + ); + return { ...hook, report: (value: boolean) => reportAuth?.(value) }; + }; + + const flush = () => + act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); + + test('opens the gate on a held, unexpired JWT before Convex confirms it', async () => { + const { result } = optimisticHarness(makeJwt(3600), true); + await flush(); + + expect(result.current.auth.isLoading).toBe(false); + expect(result.current.auth.isAuthenticated).toBe(true); + }); + + test('waits for the confirmation without the option', async () => { + const { result } = optimisticHarness(makeJwt(3600), false); + await flush(); + + expect(result.current.auth.isLoading).toBe(true); + expect(result.current.auth.isAuthenticated).toBe(false); + }); + + test('keeps the gate closed for an expired JWT', async () => { + const { result } = optimisticHarness(makeJwt(-10), true); + await flush(); + + expect(result.current.auth.isLoading).toBe(true); + expect(result.current.auth.isAuthenticated).toBe(false); + }); + + test('a refused token closes the gate and never reopens it', async () => { + const { result, report } = optimisticHarness(makeJwt(3600), true); + await flush(); + expect(result.current.auth.isAuthenticated).toBe(true); + + await act(async () => report(false)); + + expect(result.current.auth.isAuthenticated).toBe(false); + expect(result.current.auth.isLoading).toBe(true); + + await act(async () => { + result.current.store.set('isLoading', true); + }); + await flush(); + expect(result.current.auth.isAuthenticated).toBe(false); + }); + + test('the confirmed state takes over once Convex confirms', async () => { + const { result, report } = optimisticHarness(makeJwt(3600), true); + await flush(); + + await act(async () => report(true)); + + expect(result.current.auth.isLoading).toBe(false); + expect(result.current.auth.isAuthenticated).toBe(true); + }); + }); + 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 ab45822f9..1edabdc07 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -87,6 +87,51 @@ export type ConvexAuthProviderProps = { onQueryUnauthorized?: (info: { queryName: string }) => void; /** Custom function to detect UNAUTHORIZED errors. Default checks code property. */ 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`. + */ + 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. + */ + onTokenIdentityChange?: () => void; + /** + * The identity (`sub|sessionId`, as the guard decodes it from a JWT) the + * document already speaks for when this provider mounts, for an app that + * mounts the provider more than once in one document (for example one per + * route group, over a shared Convex client). The guard starts from it + * instead of from `initialToken` or the first token obtained, so a remount + * 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`. + */ + 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 + * `onTokenIdentityChange`. + */ + onTokenIdentityAdmitted?: (token: string) => void; }; const defaultMutationHandler = () => { @@ -319,6 +364,10 @@ export function ConvexAuthProvider({ onMutationUnauthorized, onQueryUnauthorized, isUnauthorized, + optimisticAuth = false, + onTokenIdentityChange, + onTokenIdentityAdmitted, + tokenIdentityBaseline, }: ConvexAuthProviderProps) { // Handle cross-domain one-time token useOTTHandler(authClient); @@ -345,6 +394,10 @@ export function ConvexAuthProvider({ authClient={authClient} client={client} convexQueryClient={convexQueryClient} + onTokenIdentityAdmitted={onTokenIdentityAdmitted} + onTokenIdentityChange={onTokenIdentityChange} + optimisticAuth={optimisticAuth} + tokenIdentityBaseline={tokenIdentityBaseline} > {children} @@ -361,11 +414,19 @@ function ConvexAuthProviderInner({ client, authClient, convexQueryClient, + optimisticAuth, + onTokenIdentityChange, + onTokenIdentityAdmitted, + tokenIdentityBaseline, }: { children: ReactNode; client: ConvexReactClient; authClient: ConvexAuthProviderClient; convexQueryClient?: ConvexAuthProviderQueryClient; + optimisticAuth: boolean; + onTokenIdentityChange?: () => void; + onTokenIdentityAdmitted?: (token: string) => void; + tokenIdentityBaseline?: ConvexAuthProviderProps['tokenIdentityBaseline']; }) { const authStore = useAuthStore(); convexQueryClient?.updateAuthStore(authStore); @@ -504,6 +565,37 @@ 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( @@ -539,6 +631,7 @@ function ConvexAuthProviderInner({ .token({ fetchOptions }) .then((result: { data?: { token?: string | null } | null }) => { const jwt = result.data?.token || null; + if (jwt && !admitToken(jwt)) return null; if (jwt) { const exp = decodeJwtExp(jwt); @@ -667,7 +760,19 @@ function ConvexAuthProviderInner({ }, // Stable deps - authStore/authClient rarely change // session/isPending accessed via refs to prevent callback recreation - [authStore, authClient, getCachedJwt] + [authStore, authClient, getCachedJwt, admitToken] + ); + + const guardedFetchAccessToken = useCallback( + async (args: { forceRefreshToken?: boolean } = {}) => { + if (identityGuardRef.current?.tripped) return null; + const token = await fetchAccessToken(args); + if (!token || !admitToken(token, onTokenIdentityAdmittedRef.current)) { + return null; + } + return token; + }, + [fetchAccessToken, admitToken] ); // Create useAuth hook for ConvexProviderWithAuth @@ -682,19 +787,21 @@ function ConvexAuthProviderInner({ isLoading: isPendingRef.current && !token, // If Better Auth confirms no session, stale JWT should not keep auth=true. isAuthenticated: sessionMissing ? false : hasSession || token !== null, - fetchAccessToken, + fetchAccessToken: guardedFetchAccessToken, }; }, - [fetchAccessToken, authStore] + [guardedFetchAccessToken, authStore] ); return ( - + - {children} + + {children} + ); @@ -712,24 +819,162 @@ function ConvexAuthProviderInner({ * 5. Without defensive check, we'd sync { isLoading: false, isAuthenticated: false } * 6. Queries would throw UNAUTHORIZED before token is validated */ -function AuthStateSync({ children }: { children: ReactNode }) { +function AuthStateSync({ + children, + optimisticAuth = false, +}: { + children: ReactNode; + optimisticAuth?: boolean; +}) { const { isLoading: convexIsLoading, isAuthenticated } = useConvexAuth(); const authStore = useAuthStore(); const token = useAuthValue('token'); + const rejectedTokenRef = useRef(null); useEffect(() => { - // DEFENSIVE: If we have a token but Convex says not authenticated, - // stay in loading state to avoid UNAUTHORIZED errors during hydration - const hasTokenButNotAuth = !!token && !isAuthenticated; - const isLoading = convexIsLoading || hasTokenButNotAuth; + const gate = resolveAuthGate({ + convexIsLoading, + isAuthenticated, + optimisticAuth, + rejectedToken: rejectedTokenRef.current, + token, + }); + if ( + optimisticAuth && + isTokenRejectedByConvex({ convexIsLoading, isAuthenticated, token }) + ) { + rejectedTokenRef.current = token; + } - authStore.set('isLoading', isLoading); - authStore.set('isAuthenticated', isAuthenticated); - }, [convexIsLoading, isAuthenticated, token, authStore]); + authStore.set('isLoading', gate.isLoading); + authStore.set('isAuthenticated', gate.isAuthenticated); + }, [convexIsLoading, isAuthenticated, optimisticAuth, 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 = { + convexIsLoading: boolean; + isAuthenticated: boolean; + optimisticAuth: boolean; + rejectedToken: string | null; + 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`). + */ +function resolveAuthGate({ + convexIsLoading, + isAuthenticated, + optimisticAuth, + rejectedToken, + token, +}: AuthGateInput): { isAuthenticated: boolean; isLoading: boolean } { + if ( + optimisticAuth && + convexIsLoading && + isOptimisticToken(token, rejectedToken) + ) { + return { isAuthenticated: true, isLoading: false }; + } + // DEFENSIVE: If we have a token but Convex says not authenticated, + // stay in loading state to avoid UNAUTHORIZED errors during hydration + return { + isAuthenticated, + isLoading: convexIsLoading || (!!token && !isAuthenticated), + }; +} + +function isOptimisticToken(token: string | null, rejectedToken: string | null) { + if (!token || token === rejectedToken) return false; + const expiresAt = decodeJwtExp(token); + return expiresAt !== null && expiresAt > Date.now(); +} + /** * Handles cross-domain one-time token (OTT) verification. */ diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.types.test.ts b/packages/kitcn/src/auth-client/convex-auth-provider.types.test.ts index c3e0e77e0..c28cb4099 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.types.test.ts +++ b/packages/kitcn/src/auth-client/convex-auth-provider.types.test.ts @@ -136,6 +136,13 @@ ConvexAuthProvider({ authClient, children: "ok", client, + onTokenIdentityAdmitted: (token) => { + const admittedToken: string = token; + admittedToken; + }, + onTokenIdentityChange: () => {}, + optimisticAuth: true, + tokenIdentityBaseline: () => "user-id|session-id", }); ConvexAuthProvider({ diff --git a/packages/kitcn/src/auth-start/index.retry.test.ts b/packages/kitcn/src/auth-start/index.retry.test.ts index 70b89980b..913a83ce4 100644 --- a/packages/kitcn/src/auth-start/index.retry.test.ts +++ b/packages/kitcn/src/auth-start/index.retry.test.ts @@ -1,37 +1,50 @@ -import { afterEach, describe, expect, mock, test } from 'bun:test'; +import * as startServer from '@tanstack/react-start/server'; +import { ConvexHttpClient } from 'convex/browser'; +import * as convexNextjs from 'convex/nextjs'; import { makeFunctionReference } from 'convex/server'; +import * as tokenModule from '../auth/internal/token'; + +import { convexBetterAuthReactStart } from './server'; + +const activeSpies: Array<{ mockRestore: () => void }> = []; + +const trackSpy = void }>(spy: T): T => { + activeSpies.push(spy); + return spy; +}; describe('auth/start token refresh', () => { afterEach(() => { - mock.restore(); + for (const spy of activeSpies.splice(0)) { + spy.mockRestore(); + } }); test('retries with a fresh token when cached auth fails', async () => { - const query = mock(async function ( - this: { token?: string }, - _ref: unknown - ) { - if (this.token === 'stale-token') { + const tokens = new WeakMap(); + const query = mock(async (token?: string) => { + if (token === 'stale-token') { const error = new Error('unauthorized'); (error as Error & { code?: string }).code = 'UNAUTHORIZED'; throw error; } return 'ok'; }); - - mock.module('convex/browser', () => ({ - ConvexHttpClient: class { - token?: string; - constructor(_url: string) {} - query = query; - mutation = query; - action = query; - setAuth(token: string) { - this.token = token; - } - setFetchOptions(_options: RequestInit) {} - }, - })); + trackSpy( + spyOn(ConvexHttpClient.prototype, 'setAuth').mockImplementation(function ( + this: ConvexHttpClient, + token: string + ) { + tokens.set(this, token); + }) + ); + trackSpy( + spyOn(ConvexHttpClient.prototype, 'query').mockImplementation( + async function (this: ConvexHttpClient) { + return query(tokens.get(this)); + } as typeof ConvexHttpClient.prototype.query + ) + ); const getToken = mock(async (_siteUrl: string, _headers: Headers) => { if (getToken.mock.calls.length === 1) { @@ -40,15 +53,10 @@ describe('auth/start token refresh', () => { return { isFresh: true, token: 'fresh-token' }; }); - mock.module('../auth/internal/token', () => ({ - getToken, - })); + trackSpy(spyOn(tokenModule, 'getToken').mockImplementation(getToken)); const request = new Request('https://app.example.com/'); - mock.module('@tanstack/react-start/server', () => ({ - getRequest: () => request, - getRequestHeaders: () => request.headers, - })); + trackSpy(spyOn(startServer, 'getRequest').mockReturnValue(request)); const serverMutation = mock( async (_ref: unknown, _args: unknown, options?: { token?: string }) => { @@ -61,11 +69,21 @@ describe('auth/start token refresh', () => { } ); - mock.module('convex/nextjs', () => ({ - fetchAction: serverMutation, - fetchMutation: serverMutation, - fetchQuery: serverMutation, - })); + trackSpy( + spyOn(convexNextjs, 'fetchAction').mockImplementation( + serverMutation as typeof convexNextjs.fetchAction + ) + ); + trackSpy( + spyOn(convexNextjs, 'fetchMutation').mockImplementation( + serverMutation as typeof convexNextjs.fetchMutation + ) + ); + trackSpy( + spyOn(convexNextjs, 'fetchQuery').mockImplementation( + serverMutation as typeof convexNextjs.fetchQuery + ) + ); const mutationRef = makeFunctionReference<'mutation'>('todos:create'); const api = { @@ -77,8 +95,6 @@ describe('auth/start token refresh', () => { }, } as const; - const { convexBetterAuthReactStart } = await import('./server'); - const auth = convexBetterAuthReactStart({ api, auth: { diff --git a/packages/kitcn/src/integration/crpc-generated-api.e2e.types.ts b/packages/kitcn/src/integration/crpc-generated-api.e2e.types.ts index 99ca7c0d9..831d076c1 100644 --- a/packages/kitcn/src/integration/crpc-generated-api.e2e.types.ts +++ b/packages/kitcn/src/integration/crpc-generated-api.e2e.types.ts @@ -176,6 +176,14 @@ crpc.organization.list.queryOptions({ unexpected: 'x' }); const updateMutation = crpc.organization.update.mutationOptions(); updateMutation.mutationFn?.({ id: 'org_1', name: 'New Name' }, {} as any); +crpc.organization.update.mutationOptions({ + optimisticUpdate: (_store, args) => { + const id: string = args.id; + const name: string = args.name; + id; + name; + }, +}); // @ts-expect-error missing required field updateMutation.mutationFn?.({ id: 'org_1' }, {} as any); // @ts-expect-error wrong field type diff --git a/packages/kitcn/src/react/client.test.ts b/packages/kitcn/src/react/client.test.ts index 208f4fdc5..93306cc70 100644 --- a/packages/kitcn/src/react/client.test.ts +++ b/packages/kitcn/src/react/client.test.ts @@ -1,4 +1,6 @@ import type { QueryFunctionContext } from '@tanstack/react-query'; +import { ConvexReactClient } from 'convex/react'; +import { syncConvexAuthForStartLoader } from '../auth-start'; describe('ConvexQueryClient (server mode)', () => { const originalWindow = (globalThis as any).window; @@ -125,6 +127,86 @@ describe('ConvexQueryClient (server mode)', () => { expect(result).toEqual({ args: { prompt: 'hi' }, ok: true }); }); + describe('per-request server clients', () => { + const originalFetch = globalThis.fetch; + let seen: Array<{ auth: string | null; ts: string }> = []; + + beforeEach(() => { + let nextTs = 0; + seen = []; + globalThis.fetch = (async (url: string, init?: RequestInit) => { + if (String(url).endsWith('/api/query_ts')) { + nextTs += 1; + return Response.json({ ts: `snapshot-${nextTs}` }); + } + const body = JSON.parse(String(init?.body ?? '{}')); + seen.push({ + auth: new Headers(init?.headers).get('Authorization'), + ts: String(body.ts), + }); + return Response.json({ status: 'success', value: null, logLines: [] }); + }) as typeof fetch; + }); + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + const context = (name: string) => + ({ + queryKey: ['convexQuery', name, {}], + }) as unknown as QueryFunctionContext; + + const startConvexClient = () => { + const convex = new ConvexReactClient( + 'https://happy-otter-123.convex.cloud' + ); + return { + url: convex.url, + logger: convex.logger, + setAuth: () => {}, + clearAuth: () => {}, + }; + }; + + test('constructing and querying call no Math.random', async () => { + 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(); + }); + + test('interleaved Start requests keep their own auth and snapshot', async () => { + const ConvexQueryClient = await getServerConvexQueryClient('interleave'); + const convex = startConvexClient(); + + const a = new ConvexQueryClient(convex as any); + await syncConvexAuthForStartLoader({ + convex: a, + getToken: async () => 'token-a', + }); + await a.queryFn()(context('a:one')); + const b = new ConvexQueryClient(convex as any); + await syncConvexAuthForStartLoader({ + convex: b, + getToken: async () => 'token-b', + }); + await b.queryFn()(context('b:one')); + await a.queryFn()(context('a:two')); + + expect(seen).toEqual([ + { auth: 'Bearer token-a', ts: 'snapshot-1' }, + { auth: 'Bearer token-b', ts: 'snapshot-2' }, + { auth: 'Bearer token-a', ts: 'snapshot-1' }, + ]); + }); + }); + test('queryFn throws if a skipped query ever runs', async () => { const ConvexQueryClient = await getServerConvexQueryClient('skip'); const client = new ConvexQueryClient({ diff --git a/packages/kitcn/src/react/client.ts b/packages/kitcn/src/react/client.ts index 4ca60fd1a..fa9ffbe23 100644 --- a/packages/kitcn/src/react/client.ts +++ b/packages/kitcn/src/react/client.ts @@ -386,6 +386,10 @@ export class ConvexQueryClient { if (isServer) { this.serverHttpClient = new ConvexHttpClient(this.convexClient.url, { fetch: options.serverFetch, + // The Convex client's logger: Convex's default logger registers its + // listener under a Math.random() id, which a prerender (Next Cache + // Components) refuses, and a server client is built per request. + logger: this.convexClient.logger, }); } } diff --git a/packages/kitcn/src/react/context.test.tsx b/packages/kitcn/src/react/context.test.tsx index 67cedd797..069cda418 100644 --- a/packages/kitcn/src/react/context.test.tsx +++ b/packages/kitcn/src/react/context.test.tsx @@ -175,6 +175,52 @@ describe('createCRPCContext', () => { } }); + test('http headers take their token from the guarded fetcher, not the cache', async () => { + const createHttpProxySpy = spyOn( + httpProxyModule, + 'createHttpProxy' + ).mockReturnValue({} as any); + useAuthStoreSpy.mockImplementation( + () => + ({ + get: (key: string) => + key === 'token' + ? 'cached-token' + : key === 'expiresAt' + ? Date.now() + 3_600_000 + : null, + }) as any + ); + let guarded: string | null = 'token-a'; + const fetchAccessToken = mock(async () => guarded); + 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 headers = createHttpProxySpy.mock.calls[0]?.[0] + ?.headers as () => Promise>; + + expect(await headers()).toEqual({ Authorization: 'Bearer token-a' }); + guarded = null; + expect(await headers()).toEqual({}); + expect(fetchAccessToken).toHaveBeenCalledTimes(2); + } finally { + 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 b08be9e59..919d2f96b 100644 --- a/packages/kitcn/src/react/context.tsx +++ b/packages/kitcn/src/react/context.tsx @@ -238,6 +238,22 @@ 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'); @@ -255,20 +271,6 @@ export function createCRPCContext>( return { ...userHeaders, Authorization: `Bearer ${token}` }; } - // Use fetchAccessToken from context (available immediately, no race condition) - if (fetchAccessToken) { - const newToken = await fetchAccessToken({ - forceRefreshToken: !!expiresAt, - }); - if (newToken) { - const userHeaders = - typeof httpOptions.headers === 'function' - ? await httpOptions.headers() - : httpOptions.headers; - return { ...userHeaders, Authorization: `Bearer ${newToken}` }; - } - } - // No auth - return user headers only const userHeaders = typeof httpOptions.headers === 'function' diff --git a/packages/kitcn/src/react/crpc-types.ts b/packages/kitcn/src/react/crpc-types.ts index 5a79e3c28..e2043035c 100644 --- a/packages/kitcn/src/react/crpc-types.ts +++ b/packages/kitcn/src/react/crpc-types.ts @@ -11,12 +11,14 @@ import type { UseMutationOptions, UseQueryOptions, } from '@tanstack/react-query'; +import type { OptimisticUpdate } from 'convex/browser'; import type { Watch, WatchQueryOptions } from 'convex/react'; import type { FunctionArgs, FunctionReference, FunctionReturnType, } from 'convex/server'; +import type { Value } from 'convex/values'; import type { ConvexActionKey, ConvexInfiniteQueryMeta, @@ -233,6 +235,15 @@ export type DecorateInfiniteQuery> = { // Mutation & Action Decorators (React-specific) // ============================================================================ +/** + * Convex-native optimistic update for a cRPC mutation. It edits Convex's local + * query store, which the query client reads, so Convex rolls it back when the + * mutation fails. Args and query values are in their wire shape. + */ +export type ConvexOptimisticUpdateOption> = { + optimisticUpdate?: OptimisticUpdate; +}; + /** * Decorated mutation procedure with mutationOptions and mutationKey methods. */ @@ -245,7 +256,8 @@ export type DecorateMutation> = { MutationVariables >, ReservedMutationOptions - > + > & + ConvexOptimisticUpdateOption> ) => UseMutationOptions< FunctionReturnType, DefaultError, diff --git a/packages/kitcn/src/react/use-query-options.test.tsx b/packages/kitcn/src/react/use-query-options.test.tsx index a308703d0..d5c9620a6 100644 --- a/packages/kitcn/src/react/use-query-options.test.tsx +++ b/packages/kitcn/src/react/use-query-options.test.tsx @@ -435,6 +435,28 @@ describe('use-query-options', () => { ); }); + test('useConvexMutationOptions passes optimisticUpdate to the Convex mutation', async () => { + const fn = makeFunctionReference<'mutation'>('todos:rename'); + const withUpdate = mock(async () => null); + const withOptimisticUpdate = mock(() => withUpdate); + const convexMutation = Object.assign( + mock(async () => null), + { withOptimisticUpdate } + ); + useConvexMutationSpy.mockImplementation(() => convexMutation as any); + const optimisticUpdate = () => {}; + + const { result } = renderHook(() => + useConvexMutationOptions(fn, { optimisticUpdate } as any) + ); + await result.current.mutationFn?.({ title: 'x' } as any, mutationFnContext); + + expect(withOptimisticUpdate).toHaveBeenCalledWith(optimisticUpdate); + expect(withUpdate).toHaveBeenCalledTimes(1); + expect(convexMutation).toHaveBeenCalledTimes(0); + expect('optimisticUpdate' in result.current).toBe(false); + }); + test('useConvexActionOptions runs action when not guarded', async () => { const fn = makeFunctionReference<'action'>('ai:generate'); diff --git a/packages/kitcn/src/react/use-query-options.ts b/packages/kitcn/src/react/use-query-options.ts index 7116bc7ad..604079a54 100644 --- a/packages/kitcn/src/react/use-query-options.ts +++ b/packages/kitcn/src/react/use-query-options.ts @@ -49,6 +49,7 @@ import { useFnMeta, useMeta } from './context'; import type { ConvexActionOptions, ConvexInfiniteQueryOptions, + ConvexOptimisticUpdateOption, ConvexQueryOptions, InfiniteQueryOptsParam, } from './crpc-types'; @@ -384,7 +385,8 @@ export function useConvexMutationOptions< FunctionArgs >, ReservedMutationOptions - >, + > & + ConvexOptimisticUpdateOption>, transformer?: DataTransformerOptions ): UseMutationOptions< FunctionReturnType, @@ -396,11 +398,15 @@ export function useConvexMutationOptions< const name = getFunctionName(mutation); const [namespace, fnName] = name.split(':'); const authType = getMeta(namespace, fnName)?.auth as AuthType; - const convexMutation = useConvexMutationBase(mutation); + const { optimisticUpdate, ...mutationOptions } = options ?? {}; + const reactMutation = useConvexMutationBase(mutation); + const convexMutation = optimisticUpdate + ? reactMutation.withOptimisticUpdate(optimisticUpdate) + : reactMutation; const resolvedTransformer = getTransformer(transformer); return { - ...options, // Spread user options FIRST + ...mutationOptions, // Spread user options FIRST mutationFn: async (args) => { // Only guard if auth is required if (authType === 'required' && guard()) { diff --git a/tooling/fixtures.test.ts b/tooling/fixtures.test.ts index eb44ee03c..4be6bba45 100644 --- a/tooling/fixtures.test.ts +++ b/tooling/fixtures.test.ts @@ -224,6 +224,52 @@ describe('tooling/fixtures', () => { } }); + test.each([ + 'expo', + 'expo-auth', + ] as const)('%s snapshots exclude host-dependent Claude settings in every check scope', (templateKey) => { + const templateDir = mkdtempSync( + path.join(tmpdir(), 'kitcn-template-claude-') + ); + const settingsPath = path.join(templateDir, '.claude', 'settings.json'); + const commandPath = path.join( + templateDir, + '.claude', + 'commands', + 'test.md' + ); + const guidancePath = path.join(templateDir, 'AGENTS.md'); + + try { + mkdirSync(path.dirname(commandPath), { recursive: true }); + writeFileSync( + path.join(templateDir, 'package.json'), + JSON.stringify({ name: 'app', private: true }) + ); + writeFileSync(settingsPath, '{"enabledPlugins":{"expo":true}}'); + writeFileSync(commandPath, 'Run tests.\n'); + writeFileSync(guidancePath, 'Expo guidance.\n'); + + normalizeTemplateSnapshot(templateDir, templateKey); + normalizeTemplateSnapshot(templateDir, templateKey); + expect(existsSync(settingsPath)).toBe(false); + + for (const scope of ['owned', 'full'] as const) { + writeFileSync(settingsPath, '{"enabledPlugins":{"expo":true}}'); + stripFixtureComparisonArtifacts(templateDir, templateKey, scope); + expect(existsSync(settingsPath)).toBe(false); + expect(readFileSync(commandPath, 'utf8')).toBe('Run tests.\n'); + expect(readFileSync(guidancePath, 'utf8')).toBe('Expo guidance.\n'); + } + + writeFileSync(settingsPath, '{"permissions":{}}'); + stripFixtureComparisonArtifacts(templateDir, 'next', 'full'); + expect(readFileSync(settingsPath, 'utf8')).toBe('{"permissions":{}}'); + } finally { + rmSync(templateDir, { force: true, recursive: true }); + } + }); + test('stripFixtureComparisonArtifacts ignores shadcn-owned UI component output by default', () => { const templateDir = mkdtempSync( path.join(tmpdir(), 'kitcn-template-comparison-') diff --git a/tooling/fixtures.ts b/tooling/fixtures.ts index 1c667a000..bf18f1aa9 100644 --- a/tooling/fixtures.ts +++ b/tooling/fixtures.ts @@ -122,8 +122,15 @@ const normalizeTemplatePackageJson = ( version: packageJson.version, }); -const stripFixtureSnapshotArtifacts = (directory: string) => { +const stripFixtureSnapshotArtifacts = ( + directory: string, + templateKey: TemplateKey +) => { rmSync(path.join(directory, '.env.local'), { force: true }); + + if (TEMPLATE_DEFINITIONS[templateKey].initTemplate === 'expo') { + rmSync(path.join(directory, '.claude', 'settings.json'), { force: true }); + } }; export const stripFixtureComparisonArtifacts = ( @@ -131,7 +138,7 @@ export const stripFixtureComparisonArtifacts = ( templateKey: TemplateKey, scope: FixtureCheckScope = DEFAULT_FIXTURE_CHECK_SCOPE ) => { - stripFixtureSnapshotArtifacts(directory); + stripFixtureSnapshotArtifacts(directory, templateKey); if (scope === 'full' || !SHADCN_TEMPLATE_KEYS.has(templateKey)) { return; @@ -195,7 +202,7 @@ export const normalizeTemplateSnapshot = ( normalizeEnvLocal(directory); patchPreparedLocalDevPort(directory); patchFixtureTsconfigPaths(directory, getTemplateFixtureDir(templateKey)); - stripFixtureSnapshotArtifacts(directory); + stripFixtureSnapshotArtifacts(directory, templateKey); }; type TsconfigJson = { diff --git a/www/content/docs/auth/client.mdx b/www/content/docs/auth/client.mdx index 63a1fef84..7f34028db 100644 --- a/www/content/docs/auth/client.mdx +++ b/www/content/docs/auth/client.mdx @@ -460,6 +460,8 @@ function App() { client={convexClient} authClient={authClient} initialToken={serverToken} + optimisticAuth + onTokenIdentityChange={() => window.location.reload()} onMutationUnauthorized={() => { // Custom handler for unauthorized mutations openLoginModal(); @@ -483,9 +485,30 @@ function App() { | `authClient` | `AuthClient` | Better Auth client instance | | `convexQueryClient` | `ConvexQueryClient?` | Shared TanStack Query client bridge | | `initialToken` | `string?` | Initial session token (from SSR) | +| `optimisticAuth` | `boolean?` | Opens auth-bound query gates for a held, unexpired JWT while Convex confirms it. Defaults to `false`. | +| `onTokenIdentityChange` | `() => void` | Enables the document identity guard. Called after the client is closed when a token changes JWT `sub` or `sessionId`. Reload the document here. | +| `tokenIdentityBaseline` | `string \| null \| (() => string \| null)` | Optional `sub|sessionId` identity owned by the document. A getter is checked for every token admission. Requires `onTokenIdentityChange`. | +| `onTokenIdentityAdmitted` | `(token: string) => void` | Called for every decodable token admitted by the identity guard, including cached tokens. Requires `onTokenIdentityChange`. | | `onMutationUnauthorized` | `() => void` | Called when mutation is blocked | | `onQueryUnauthorized` | `({ queryName }) => void` | Called when query is blocked | +### Optimistic auth and document identity + +`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. + +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. + +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. + ## Auth Flow SSR → client hydration: `getToken()` reads cookie → prefetch queries with token → pass `initialToken` to client → `ConvexAuthProvider` mounts with token → Convex validates JWT → `AuthStateSync` updates `isAuthenticated` → `HydrationBoundary` hydrates prefetched data. diff --git a/www/content/docs/react/mutations.mdx b/www/content/docs/react/mutations.mdx index ef9493aa1..1e510b88a 100644 --- a/www/content/docs/react/mutations.mdx +++ b/www/content/docs/react/mutations.mdx @@ -51,6 +51,34 @@ const updateUser = useMutation( See [API Reference](#mutationoptions-1) for the full signature and options. +### Convex optimistic updates + +Pass `optimisticUpdate` to update Convex's local query store while a mutation +is pending. Convex replays the update when query data changes and rolls it back +when the mutation completes. + +```tsx +const renameTodo = useMutation( + crpc.todos.rename.mutationOptions({ + optimisticUpdate: (localStore, args) => { + const todos = localStore.getQuery(api.todos.list, {}); + if (!todos) return; + + localStore.setQuery( + api.todos.list, + {}, + todos.map((todo) => + todo._id === args.id ? { ...todo, title: args.title } : todo + ) + ); + }, + }) +); +``` + +`optimisticUpdate` is a Convex option, not a TanStack Query callback. Its +arguments and query values use Convex's wire shape. + ## Mutation Keys Get type-safe mutation keys for cache operations: