From 07b5927fd69a3c2a9ffb5668316209bbfce86095 Mon Sep 17 00:00:00 2001 From: EfficiencyCorp <169809483+EfficiencyCorp@users.noreply.github.com> Date: Sun, 27 Sep 2026 18:11:45 +0200 Subject: [PATCH 1/8] feat(react): optimistic auth gate, token identity guard, optimisticUpdate passthrough, deterministic server construction --- .changeset/optimistic-auth-gate.md | 14 + .../auth-client/convex-auth-provider.test.tsx | 283 ++++++++++++++++++ .../src/auth-client/convex-auth-provider.tsx | 227 +++++++++++++- packages/kitcn/src/react/client.test.ts | 84 ++++++ packages/kitcn/src/react/client.ts | 4 + packages/kitcn/src/react/context.test.tsx | 48 +++ packages/kitcn/src/react/context.tsx | 32 +- packages/kitcn/src/react/crpc-types.ts | 13 +- .../src/react/use-query-options.test.tsx | 23 ++ packages/kitcn/src/react/use-query-options.ts | 14 +- 10 files changed, 711 insertions(+), 31 deletions(-) create mode 100644 .changeset/optimistic-auth-gate.md diff --git a/.changeset/optimistic-auth-gate.md b/.changeset/optimistic-auth-gate.md new file mode 100644 index 000000000..9e678886c --- /dev/null +++ b/.changeset/optimistic-auth-gate.md @@ -0,0 +1,14 @@ +--- +"kitcn": patch +--- + +## Features + +- Add `optimisticAuth` to `ConvexAuthProvider`: auth-bound queries run as soon as an unexpired JWT is held instead of after Convex confirms it, and a refused token resets auth-bound queries and never reopens the gate. +- Add `onTokenIdentityChange` to `ConvexAuthProvider`: a document keeps the identity it mounted with (the SSR token) or, with none, the first one it obtains; a token for another user or session is refused before it is cached, so neither Convex nor HTTP requests ever use it, the client is closed (dropping queued requests and optimistic updates) and the callback runs, typically to reload the page. +- Improve cRPC HTTP headers to take their token from the same fetcher Convex uses, so its guards apply to HTTP requests too. +- Support `optimisticUpdate` in cRPC `mutationOptions`, passed to Convex's own optimistic updates so a refused write rolls back without flicker. + +## Patches + +- Fix server `ConvexQueryClient` construction calling `Math.random()` (Convex's default logger), which prerendering (Cache Components) refuses: the server HTTP client now reuses the Convex client's logger. 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..0243e610c 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,289 @@ describe('ConvexAuthProvider', () => { expect(convexToken).toHaveBeenCalledTimes(0); }); + describe('onTokenIdentityChange', () => { + 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 guardHarness = ({ + guard, + refreshed, + }: { + guard: boolean; + refreshed: 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: null, isPending: true }), + convex: { token: async () => ({ data: { token: refreshed } }) }, + 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 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 = { + // Better Auth already has a session: kitcn fetches instead of reusing A. + 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 fetchToken({ forceRefreshToken: false })).toBeNull(); + expect(onTokenIdentityChange).toHaveBeenCalledTimes(1); + expect(close).toHaveBeenCalledTimes(1); + // B never reached the cache, and nothing is handed out any more. + expect(result.current.get('token')).not.toBe(tokenForB); + expect(await fetchToken({ forceRefreshToken: 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 fetchToken({ forceRefreshToken: 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('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); + }); + + 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 = { + // Holds the confirmation until the test decides what the server says. + 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); + + // The same token held again never counts as authenticated again. + 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..4af7af7a7 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -87,6 +87,25 @@ 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; }; const defaultMutationHandler = () => { @@ -319,6 +338,8 @@ export function ConvexAuthProvider({ onMutationUnauthorized, onQueryUnauthorized, isUnauthorized, + optimisticAuth = false, + onTokenIdentityChange, }: ConvexAuthProviderProps) { // Handle cross-domain one-time token useOTTHandler(authClient); @@ -345,6 +366,8 @@ export function ConvexAuthProvider({ authClient={authClient} client={client} convexQueryClient={convexQueryClient} + onTokenIdentityChange={onTokenIdentityChange} + optimisticAuth={optimisticAuth} > {children} @@ -361,11 +384,15 @@ function ConvexAuthProviderInner({ client, authClient, convexQueryClient, + optimisticAuth, + onTokenIdentityChange, }: { children: ReactNode; client: ConvexReactClient; authClient: ConvexAuthProviderClient; convexQueryClient?: ConvexAuthProviderQueryClient; + optimisticAuth: boolean; + onTokenIdentityChange?: () => void; }) { const authStore = useAuthStore(); convexQueryClient?.updateAuthStore(authStore); @@ -504,6 +531,26 @@ function ConvexAuthProviderInner({ .catch(() => {}); }, [session, isPending, authStore, authClient]); + // The identity guard (`onTokenIdentityChange`): seeded from the token the + // document already holds when it mounts (the SSR token). + const onTokenIdentityChangeRef = useRef(onTokenIdentityChange); + onTokenIdentityChangeRef.current = onTokenIdentityChange; + const identityGuardRef = useRef(null); + identityGuardRef.current ??= { + identity: decodeTokenIdentity(authStore.get('token')), + tripped: false, + }; + const admitToken = useCallback( + (token: string) => + admitTokenIdentity({ + client, + guard: identityGuardRef.current!, + 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 +586,8 @@ function ConvexAuthProviderInner({ .token({ fetchOptions }) .then((result: { data?: { token?: string | null } | null }) => { const jwt = result.data?.token || null; + // A token for another identity is refused before it is cached. + if (jwt && !admitToken(jwt)) return null; if (jwt) { const exp = decodeJwtExp(jwt); @@ -667,7 +716,20 @@ function ConvexAuthProviderInner({ }, // Stable deps - authStore/authClient rarely change // session/isPending accessed via refs to prevent callback recreation - [authStore, authClient, getCachedJwt] + [authStore, authClient, getCachedJwt, admitToken] + ); + + // Every token consumer (Convex, HTTP headers) goes through this: once the + // guard has tripped it answers null, and it never hands out a token the + // guard refused. + const guardedFetchAccessToken = useCallback( + async (args: { forceRefreshToken?: boolean } = {}) => { + if (identityGuardRef.current?.tripped) return null; + const token = await fetchAccessToken(args); + if (!token || !admitToken(token)) return null; + return token; + }, + [fetchAccessToken, admitToken] ); // Create useAuth hook for ConvexProviderWithAuth @@ -682,19 +744,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 +776,161 @@ 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'); + // The token Convex refused last: it never opens the optimistic gate again. + 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; } +/** + * The user and session a Better Auth Convex JWT speaks for (`sub` and + * `sessionId`), or null. Other claims (name, email, updatedAt) may change + * within one session and do not count. + */ +function decodeTokenIdentity(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; tripped: boolean }; + +/** + * With `onTokenIdentityChange`, a document speaks for one identity (JWT `sub` + * and `sessionId`) for its lifetime: the one it already holds when it mounts + * (the SSR token), or, for a document with none, the first token it obtains. + * A token for another user or session is refused before it is cached, so no + * consumer (Convex, the optimistic gate, HTTP headers) ever sees it. Refusing + * trips the guard for good: the callback runs (reload the page there), the + * Convex client is closed, which drops the requests it had queued and their + * optimistic updates, and every later token request answers null. + */ +function admitTokenIdentity({ + client, + guard, + onIdentityChange, + token, +}: { + client: ConvexReactClient; + guard: IdentityGuard; + onIdentityChange: (() => void) | undefined; + token: string; +}): boolean { + if (!onIdentityChange) return true; + if (guard.tripped) return false; + const identity = decodeTokenIdentity(token); + if (identity === null) return true; + if (guard.identity === null) { + guard.identity = identity; + return true; + } + if (guard.identity === identity) return true; + guard.tripped = true; + onIdentityChange(); + void client.close(); + 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/react/client.test.ts b/packages/kitcn/src/react/client.test.ts index 208f4fdc5..7f089dfa0 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,88 @@ 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; + + // What a Start app hands kitcn: a real client's logger; setAuth and + // clearAuth stubbed (the real ones open a socket). + 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..68378dcbb 100644 --- a/packages/kitcn/src/react/context.test.tsx +++ b/packages/kitcn/src/react/context.test.tsx @@ -175,6 +175,54 @@ describe('createCRPCContext', () => { } }); + test('http headers take their token from the guarded fetcher, not the cache', async () => { + const createHttpProxySpy = spyOn( + httpProxyModule, + 'createHttpProxy' + ).mockReturnValue({} as any); + // The cache still holds a long-lived token the fetcher's guard refuses. + 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' }); + // The guard tripped: nothing, not even the cached token, goes out. + 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..083344b1b 100644 --- a/packages/kitcn/src/react/context.tsx +++ b/packages/kitcn/src/react/context.tsx @@ -238,6 +238,24 @@ export function createCRPCContext>( convexSiteUrl: httpOptions.convexSiteUrl, routes: meta._http, headers: async () => { + // Through the same token fetcher Convex uses, so its guards (the + // identity guard) apply to HTTP requests too. + 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 +273,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..039731724 100644 --- a/packages/kitcn/src/react/crpc-types.ts +++ b/packages/kitcn/src/react/crpc-types.ts @@ -11,6 +11,7 @@ import type { UseMutationOptions, UseQueryOptions, } from '@tanstack/react-query'; +import type { OptimisticUpdate } from 'convex/browser'; import type { Watch, WatchQueryOptions } from 'convex/react'; import type { FunctionArgs, @@ -236,6 +237,15 @@ export type DecorateInfiniteQuery> = { /** * Decorated mutation procedure with mutationOptions and mutationKey methods. */ +/** + * 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; +}; + export type DecorateMutation> = { mutationOptions: ( opts?: DistributiveOmit< @@ -245,7 +255,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..e236b0f77 100644 --- a/packages/kitcn/src/react/use-query-options.test.tsx +++ b/packages/kitcn/src/react/use-query-options.test.tsx @@ -435,6 +435,29 @@ 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); + // Not a TanStack option: it never reaches useMutation's options. + 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..cf6924494 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,17 @@ 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); + // Convex-native optimistic update: it edits Convex's local query store, + // which ConvexQueryClient reads, so Convex's own rollback applies. + 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()) { From 8f756193aff1ff23871c8576cce606fe633d7167 Mon Sep 17 00:00:00 2001 From: EfficiencyCorp <169809483+EfficiencyCorp@users.noreply.github.com> Date: Sun, 27 Sep 2026 22:47:22 +0200 Subject: [PATCH 2/8] feat(auth): tokenIdentityBaseline for providers that remount in one document ConvexAuthProvider gets tokenIdentityBaseline?: string | null. The token identity guard starts from it instead of from initialToken or the first token obtained, so an app that mounts the provider more than once per document (for example per route group over a shared Convex client) keeps refusing another user's or session's token on a remount without a token. Without it, behaviour is unchanged. --- .changeset/optimistic-auth-gate.md | 1 + .../auth-client/convex-auth-provider.test.tsx | 93 +++++++++++++++++++ .../src/auth-client/convex-auth-provider.tsx | 28 +++++- 3 files changed, 117 insertions(+), 5 deletions(-) diff --git a/.changeset/optimistic-auth-gate.md b/.changeset/optimistic-auth-gate.md index 9e678886c..3594b2a8a 100644 --- a/.changeset/optimistic-auth-gate.md +++ b/.changeset/optimistic-auth-gate.md @@ -6,6 +6,7 @@ - Add `optimisticAuth` to `ConvexAuthProvider`: auth-bound queries run as soon as an unexpired JWT is held instead of after Convex confirms it, and a refused token resets auth-bound queries and never reopens the gate. - Add `onTokenIdentityChange` to `ConvexAuthProvider`: a document keeps the identity it mounted with (the SSR token) or, with none, the first one it obtains; a token for another user or session is refused before it is cached, so neither Convex nor HTTP requests ever use it, the client is closed (dropping queued requests and optimistic updates) and the callback runs, typically to reload the page. +- Add `tokenIdentityBaseline` to `ConvexAuthProvider`: for an app that mounts the provider more than once per document (for example per route group over a shared Convex client), the identity guard starts from the identity the document already speaks for, so a remount without a token still refuses another user's or session's token. - Improve cRPC HTTP headers to take their token from the same fetcher Convex uses, so its guards apply to HTTP requests too. - Support `optimisticUpdate` in cRPC `mutationOptions`, passed to Convex's own optimistic updates so a refused write rolls back without flicker. 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 0243e610c..20fa9d893 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx @@ -1489,6 +1489,99 @@ describe('ConvexAuthProvider', () => { expect(harness.close).toHaveBeenCalledTimes(0); }); + describe('tokenIdentityBaseline', () => { + /** A remount without a token, whose first fetch returns `obtained`. */ + 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 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); + } + }); + }); + test('changes nothing without the option', async () => { const refreshed = identityJwt('user_b', 'session_b'); const harness = guardHarness({ guard: false, refreshed }); diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.tsx b/packages/kitcn/src/auth-client/convex-auth-provider.tsx index 4af7af7a7..873803749 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -106,6 +106,16 @@ export type ConvexAuthProviderProps = { * 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. Read on + * the first render only. Needs `onTokenIdentityChange`. + */ + tokenIdentityBaseline?: string | null; }; const defaultMutationHandler = () => { @@ -340,6 +350,7 @@ export function ConvexAuthProvider({ isUnauthorized, optimisticAuth = false, onTokenIdentityChange, + tokenIdentityBaseline, }: ConvexAuthProviderProps) { // Handle cross-domain one-time token useOTTHandler(authClient); @@ -368,6 +379,7 @@ export function ConvexAuthProvider({ convexQueryClient={convexQueryClient} onTokenIdentityChange={onTokenIdentityChange} optimisticAuth={optimisticAuth} + tokenIdentityBaseline={tokenIdentityBaseline} > {children} @@ -386,6 +398,7 @@ function ConvexAuthProviderInner({ convexQueryClient, optimisticAuth, onTokenIdentityChange, + tokenIdentityBaseline, }: { children: ReactNode; client: ConvexReactClient; @@ -393,6 +406,7 @@ function ConvexAuthProviderInner({ convexQueryClient?: ConvexAuthProviderQueryClient; optimisticAuth: boolean; onTokenIdentityChange?: () => void; + tokenIdentityBaseline?: string | null; }) { const authStore = useAuthStore(); convexQueryClient?.updateAuthStore(authStore); @@ -531,13 +545,15 @@ function ConvexAuthProviderInner({ .catch(() => {}); }, [session, isPending, authStore, authClient]); - // The identity guard (`onTokenIdentityChange`): seeded from the token the - // document already holds when it mounts (the SSR token). + // The identity guard (`onTokenIdentityChange`): seeded from the baseline + // the app hands a remount, else from the token the document already holds + // when it mounts (the SSR token). const onTokenIdentityChangeRef = useRef(onTokenIdentityChange); onTokenIdentityChangeRef.current = onTokenIdentityChange; const identityGuardRef = useRef(null); identityGuardRef.current ??= { - identity: decodeTokenIdentity(authStore.get('token')), + identity: + tokenIdentityBaseline ?? decodeTokenIdentity(authStore.get('token')), tripped: false, }; const admitToken = useCallback( @@ -838,8 +854,10 @@ type IdentityGuard = { identity: string | null; tripped: boolean }; /** * With `onTokenIdentityChange`, a document speaks for one identity (JWT `sub` - * and `sessionId`) for its lifetime: the one it already holds when it mounts - * (the SSR token), or, for a document with none, the first token it obtains. + * and `sessionId`) for its lifetime: `tokenIdentityBaseline` when given (a + * provider that remounts within one document), else the one it already holds + * when it mounts (the SSR token), or, for a document with none, the first + * token it obtains. * A token for another user or session is refused before it is cached, so no * consumer (Convex, the optimistic gate, HTTP headers) ever sees it. Refusing * trips the guard for good: the callback runs (reload the page there), the From 424a3bec59d8c9b1accf592ad534ef73ad90e7dc Mon Sep 17 00:00:00 2001 From: EfficiencyCorp <169809483+EfficiencyCorp@users.noreply.github.com> Date: Mon, 28 Sep 2026 10:41:17 +0200 Subject: [PATCH 3/8] feat(auth): tokenIdentityBaseline getter and onTokenIdentityAdmitted tokenIdentityBaseline also accepts a getter, () => string | null, read at every admission (cached tokens included): a token must match both the document's current identity and the one the guard already admitted, so a provider kept mounted but hidden (React ) cannot resume under an identity the document has moved away from. onTokenIdentityAdmitted?: (token) => void is called with every token the guard admits as it is handed out, cached ones included and refused ones never, so the document can claim the identity at that moment. Both the callback and the getter are read from refs, so new props do not re-run effects or hand Convex a new fetcher. --- .changeset/optimistic-auth-gate.md | 3 +- .../auth-client/convex-auth-provider.test.tsx | 212 ++++++++++++++++++ .../src/auth-client/convex-auth-provider.tsx | 85 +++++-- 3 files changed, 286 insertions(+), 14 deletions(-) diff --git a/.changeset/optimistic-auth-gate.md b/.changeset/optimistic-auth-gate.md index 3594b2a8a..dbfae2060 100644 --- a/.changeset/optimistic-auth-gate.md +++ b/.changeset/optimistic-auth-gate.md @@ -6,7 +6,8 @@ - Add `optimisticAuth` to `ConvexAuthProvider`: auth-bound queries run as soon as an unexpired JWT is held instead of after Convex confirms it, and a refused token resets auth-bound queries and never reopens the gate. - Add `onTokenIdentityChange` to `ConvexAuthProvider`: a document keeps the identity it mounted with (the SSR token) or, with none, the first one it obtains; a token for another user or session is refused before it is cached, so neither Convex nor HTTP requests ever use it, the client is closed (dropping queued requests and optimistic updates) and the callback runs, typically to reload the page. -- Add `tokenIdentityBaseline` to `ConvexAuthProvider`: for an app that mounts the provider more than once per document (for example per route group over a shared Convex client), the identity guard starts from the identity the document already speaks for, so a remount without a token still refuses another user's or session's token. +- Add `tokenIdentityBaseline` to `ConvexAuthProvider`: for an app that mounts the provider more than once per document (for example per route group over a shared Convex client), the identity guard starts from the identity the document already speaks for, so a remount without a token still refuses another user's or session's token. It also accepts a getter, read at every admission (cached tokens included), so a provider kept mounted but hidden (React ``) refuses a token once the document has moved to another identity. +- Add `onTokenIdentityAdmitted` to `ConvexAuthProvider`: called with every token the identity guard admits as it is handed out, cached tokens included and refused ones never, so the document can claim the identity at that moment. - Improve cRPC HTTP headers to take their token from the same fetcher Convex uses, so its guards apply to HTTP requests too. - Support `optimisticUpdate` in cRPC `mutationOptions`, passed to Convex's own optimistic updates so a refused write rolls back without flicker. 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 20fa9d893..e4fdfbc6e 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx @@ -1582,6 +1582,218 @@ describe('ConvexAuthProvider', () => { }); }); + describe('tokenIdentityBaseline getter and onTokenIdentityAdmitted', () => { + /** + * A mount with a Better Auth session and no token of its own; each + * fresh fetch returns the next of `obtained`. `document.identity` is + * what the baseline getter answers, read whenever the guard asks. + */ + 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 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 () => { + // The document speaks for no one when this provider mounts, then + // another mount establishes A before this one's first token arrives. + const harness = documentHarness({ + identity: null, + obtained: [identityJwt('user_b', 'session_b')], + }); + await flush(); + 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('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); + // Kept mounted but hidden while the document moved on to B. + harness.document.identity = 'user_b|session_b'; + + expect(await harness.fetch(false)).toBeNull(); + // The refused token was the cached one: no second fetch happened. + 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 }); diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.tsx b/packages/kitcn/src/auth-client/convex-auth-provider.tsx index 873803749..17671e4d2 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -112,10 +112,26 @@ export type ConvexAuthProviderProps = { * 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. Read on - * the first render only. Needs `onTokenIdentityChange`. + * 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; + 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 = () => { @@ -350,6 +366,7 @@ export function ConvexAuthProvider({ isUnauthorized, optimisticAuth = false, onTokenIdentityChange, + onTokenIdentityAdmitted, tokenIdentityBaseline, }: ConvexAuthProviderProps) { // Handle cross-domain one-time token @@ -377,6 +394,7 @@ export function ConvexAuthProvider({ authClient={authClient} client={client} convexQueryClient={convexQueryClient} + onTokenIdentityAdmitted={onTokenIdentityAdmitted} onTokenIdentityChange={onTokenIdentityChange} optimisticAuth={optimisticAuth} tokenIdentityBaseline={tokenIdentityBaseline} @@ -398,6 +416,7 @@ function ConvexAuthProviderInner({ convexQueryClient, optimisticAuth, onTokenIdentityChange, + onTokenIdentityAdmitted, tokenIdentityBaseline, }: { children: ReactNode; @@ -406,7 +425,8 @@ function ConvexAuthProviderInner({ convexQueryClient?: ConvexAuthProviderQueryClient; optimisticAuth: boolean; onTokenIdentityChange?: () => void; - tokenIdentityBaseline?: string | null; + onTokenIdentityAdmitted?: (token: string) => void; + tokenIdentityBaseline?: ConvexAuthProviderProps['tokenIdentityBaseline']; }) { const authStore = useAuthStore(); convexQueryClient?.updateAuthStore(authStore); @@ -547,20 +567,34 @@ function ConvexAuthProviderInner({ // The identity guard (`onTokenIdentityChange`): seeded from the baseline // the app hands a remount, else from the token the document already holds - // when it mounts (the SSR token). + // when it mounts (the SSR token). A getter baseline is re-read at every + // admission through a ref, so it always sees the latest prop. 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: - tokenIdentityBaseline ?? decodeTokenIdentity(authStore.get('token')), + resolveIdentityBaseline(tokenIdentityBaseline) ?? + decodeTokenIdentity(authStore.get('token')), + baseline: + typeof tokenIdentityBaseline === 'function' + ? () => resolveIdentityBaseline(tokenIdentityBaselineRef.current) + : null, tripped: false, }; + // `announce`: false for the check a fresh token passes before it is cached; + // the hand-out (guardedFetchAccessToken) checks it again and announces it, + // so `onTokenIdentityAdmitted` hears each token handed out exactly once. const admitToken = useCallback( - (token: string) => + (token: string, announce: boolean) => admitTokenIdentity({ client, guard: identityGuardRef.current!, + onAdmitted: announce ? onTokenIdentityAdmittedRef.current : undefined, onIdentityChange: onTokenIdentityChangeRef.current, token, }), @@ -603,7 +637,7 @@ function ConvexAuthProviderInner({ .then((result: { data?: { token?: string | null } | null }) => { const jwt = result.data?.token || null; // A token for another identity is refused before it is cached. - if (jwt && !admitToken(jwt)) return null; + if (jwt && !admitToken(jwt, false)) return null; if (jwt) { const exp = decodeJwtExp(jwt); @@ -742,7 +776,7 @@ function ConvexAuthProviderInner({ async (args: { forceRefreshToken?: boolean } = {}) => { if (identityGuardRef.current?.tripped) return null; const token = await fetchAccessToken(args); - if (!token || !admitToken(token)) return null; + if (!token || !admitToken(token, true)) return null; return token; }, [fetchAccessToken, admitToken] @@ -850,14 +884,34 @@ function decodeTokenIdentity(token: string | null): string | null { } } -type IdentityGuard = { identity: string | null; tripped: boolean }; +type IdentityGuard = { + identity: string | null; + /** The document's current identity, when the baseline is a getter. */ + baseline: (() => string | null) | null; + tripped: boolean; +}; + +/** + * `tokenIdentityBaseline` as given: a fixed identity, or a getter for the + * document's current one. + */ +function resolveIdentityBaseline( + baseline: string | null | (() => string | null) | undefined +): string | null { + return typeof baseline === 'function' ? baseline() : (baseline ?? null); +} /** * With `onTokenIdentityChange`, a document speaks for one identity (JWT `sub` * and `sessionId`) for its lifetime: `tokenIdentityBaseline` when given (a * provider that remounts within one document), else the one it already holds * when it mounts (the SSR token), or, for a document with none, the first - * token it obtains. + * token it obtains. Given as a getter, the document's current identity is + * read at every admission, cached tokens included: a token must match both + * it and the identity this guard already admitted, so a provider kept + * mounted but hidden (React ``) cannot resume under an identity the + * document has since moved away from. `onAdmitted` hears an admitted token, + * so the document can claim the identity at that moment. * A token for another user or session is refused before it is cached, so no * consumer (Convex, the optimistic gate, HTTP headers) ever sees it. Refusing * trips the guard for good: the callback runs (reload the page there), the @@ -867,11 +921,13 @@ type IdentityGuard = { identity: string | null; tripped: boolean }; function admitTokenIdentity({ client, guard, + onAdmitted, onIdentityChange, token, }: { client: ConvexReactClient; guard: IdentityGuard; + onAdmitted: ((token: string) => void) | undefined; onIdentityChange: (() => void) | undefined; token: string; }): boolean { @@ -879,11 +935,14 @@ function admitTokenIdentity({ if (guard.tripped) return false; const identity = decodeTokenIdentity(token); if (identity === null) return true; - if (guard.identity === null) { + const current = guard.baseline ? guard.baseline() : 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; } - if (guard.identity === identity) return true; guard.tripped = true; onIdentityChange(); void client.close(); From 29f558fbfd5ff37222d30d006a9fbee24744e9a1 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 30 Sep 2026 00:47:13 +0200 Subject: [PATCH 4/8] test(auth): isolate retry mocks for PR recovery --- docs/plans/473-autoclosure.md | 331 +++++++++++++ docs/plans/473-optimistic-auth-gate.md | 463 ++++++++++++++++++ .../kitcn/src/auth-start/index.retry.test.ts | 86 ++-- 3 files changed, 845 insertions(+), 35 deletions(-) create mode 100644 docs/plans/473-autoclosure.md create mode 100644 docs/plans/473-optimistic-auth-gate.md diff --git a/docs/plans/473-autoclosure.md b/docs/plans/473-autoclosure.md new file mode 100644 index 000000000..df4916977 --- /dev/null +++ b/docs/plans/473-autoclosure.md @@ -0,0 +1,331 @@ +# 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: +- 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, `bun lint:fix`, and + `bun check` pass on the final committed head. +- 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, `bun check` passing, review findings closed, authorized + GitHub delivery complete, and the goal checker passing. + +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: +- 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 | no | Missing at intake. Recovery owns this gate before normal review. | +| Recoverable task state adopted | yes | Exact-PR task plan `docs/plans/473-optimistic-auth-gate.md` created on the preserved head; body repair and read-back remain pending | +| 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, changeset, source behavior, feedback, review, repository check, and GitHub delivery apply; fixtures, UI, and agent workflow are currently N/A | +| Live PR feedback target resolved | conditional | Pending until task evidence reaches the live PR head | +| Feedback proof checkout bound to PR head | conditional | Pending until task evidence reaches the live PR head | +| Unfiltered feedback inventory | conditional | Pending until task evidence reaches the live PR head; intake read found two bot comments, no reviews, and no inline comments | +| 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 | Active goal points to this plan and names the task-evidence, feedback, proof, check, merge, and receipt threshold | +| Agent-native pack selected | yes | Required by the autoclosure goal contract | +| Agent-facing action surface identified | no | The PR does not change rules, skills, prompts, commands, or agent actions; the plan only records this run | +| Source rule versus generated mirror boundary identified | no | No agent source or generated mirror belongs to PR #473 | +| 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 | no | N/A because the PR does not change agent or tooling behavior | + +Closure matrix: +| Lane | Applies | Owner/proof | Status | +| --- | --- | --- | --- | +| task intake classification | pending | immutable-head `complete` / `recoverable` / `absent` evidence | pending | +| per-PR task ownership | conditional | complete evidence or recovered exact PR + dedicated task plan | pending | +| recoverable task adoption | conditional | preserve branch + exact-PR `task` + repaired evidence read-back | pending | +| absent-state close | conditional | exact missing-state comment + `CLOSED` read-back | pending | +| source behavior | pending | pending | pending | +| package/API/build | pending | pending | pending | +| generated output | pending | pending | pending | +| fixtures/scenarios | pending | pending | pending | +| docs/package skill | pending | pending | pending | +| changeset | pending | pending | pending | +| agent workflow | pending | pending | pending | +| live PR feedback | conditional | complete/recovered: `resolve-pr-feedback` + final P1 read-back; absent: N/A with comment/CLOSED receipts | pending | +| cleanup/review | pending | pending | pending | +| repository check | yes | `bun check` | pending | +| GitHub delivery | pending | pending | pending | + +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. +- [ ] **Order is conflicts, then review threads, then CI.** Run after exact-PR + evidence reaches the live head. +- [ ] **Trust the active forge's verdict, not a green check list.** Use the + GitHub watcher after recovery. +- [ ] **Classify CI before any retrigger.** No retry has been requested. +- [ ] **Bugbot is triaged skeptically, always.** No Bugbot item is known yet. +- [ ] **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`. +- [ ] **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. +- [ ] **Find the contiguous verified run.** The run contains only PR #473. +- [ ] **Cancel pending merges before changing the chain.** Inspect before any + topology write. No topology write is planned. +- [ ] **Prepare only the bottom PR.** PR #473 is the bottom and only PR. +- [ ] **Reassess the evidence.** Bind review and tests to the final head and + base OID. +- [ ] **Merge with a service-enforced head condition.** Use GitHub's + `--match-head-commit` guard after every gate passes. +- [ ] **Arm future merging only with durable verification gates.** Skip future + arming. This run will use an immediate guarded merge. +- [ ] **Watch the frontier and preserve its verdict.** Rearm after each push. +- [ ] **Confirm the landing before advancing.** Read the merged state and + confirm the merge commit is in fetched `main`. +- [ ] **Stop at the ceiling.** The ceiling is PR #473. +- [ ] Every PR has its own `task` invocation and dedicated task plan; a batch + plan or aggregate autoclosure is not used as a substitute. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Intended behavior and exclusions are reconstructed from real sources. +- [ ] Each lane is proven or N/A with a concrete reason. +- [ ] Generated output was changed through its owner and regenerated. +- [ ] Package/docs/skill/fixture/scenario/changeset contracts are synchronized. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Feedback was re-fetched after the last push/reply/resolution and shows + zero unresolved actionable P1-or-higher findings. +- [ ] After all versioned plan/source updates were pushed, the exact-head P1 + proof/read-back receipt was posted to the PR and read back; no terminal + receipt-only branch push was created. A post-comment `headRefOid` fetch + matches the OID recorded in that receipt, and a post-comment helper/raw + feedback fetch still shows zero actionable P1-or-higher items and no new + URL lacking a verdict or explicit deferral, except the verified receipt. +- [ ] Any remaining P2-or-lower item has its exact URL plus the user's explicit + priority deferral recorded; no feedback was silently ignored. +- [ ] Accepted cleanup and review findings are closed. +- [ ] PR body and check state match the final evidence. +- [ ] Residual blocker/waiver has exact evidence and next owner. +- [ ] Agent-native pack: source-of-truth rule files are edited instead of generated skill mirrors. +- [ ] Agent-native pack: the changed agent action is discoverable from the skill/rule text. +- [ ] Agent-native pack: generated mirrors are synced when `.agents/rules/**` changed, or N/A reason is recorded. +- [ ] Agent-native pack: installed skills are changed only through + `npx skills add/update/remove`; local rules/templates/helpers stay source-owned. +- [ ] Agent-native pack: routing, required receipts, placeholder failure, + completion representability, and forbidden behavior have eval/smoke rows. +- [ ] Agent-native pack: accepted agent-native review findings are fixed or explicitly rejected with reason. + +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. | + +Completion Gates: +| Gate | Applies | Required action | Evidence | +| --- | --- | --- | --- | +| Task intake classification | pending | Record immutable-head `complete`, `recoverable`, or `absent` sources and rationale | pending | +| Per-PR task ownership | pending | Record exact PR and complete or recovered task-plan path | pending | +| Recoverable task adoption | conditional | Preserve current work, run exact-PR `task`, repair plan/body evidence, and read back at new head | pending | +| Absent-state disposition | conditional | Name the missing usable state, comment, close, and read back | pending | +| Targeted behavior proof | pending | Run smallest missing owning proof | pending | +| Source/generated audit | pending | Prove correct source and regenerated mirrors | pending | +| Package/docs/scenario closure | pending | Run every applicable local contract | pending | +| Feedback proof checkout | conditional | Complete or recovered PR only: require local committed `HEAD` = fetched PR ref = live `headRefOid` before proof/reply/resolution and at terminal verification | pending | +| Live PR feedback resolution | conditional | Complete or recovered PR only: run full `resolve-pr-feedback` and close every actionable P1-or-higher finding; otherwise N/A with absent-state stop receipts | pending | +| Feedback priority classification | conditional | Complete or recovered PR only: persist P0-P3 plus rationale for every actionable item; classify ambiguous P1-versus-lower as P1 | pending | +| Final P1 proof replay | conditional | Complete or recovered PR only: after the final material branch push, rerun every P1-or-higher proof, including resolved/outdated items | pending | +| Final live feedback read-back | conditional | Complete or recovered PR only: re-fetch helper plus unfiltered top-level/all-thread inventories; require zero actionable P1-or-higher and explicit P2-or-lower deferrals | pending | +| External terminal receipt | conditional | Complete or recovered PR only: post/read exact-head receipt; require receipt/live/fetched/local OID equality and no unrecorded helper/raw URL except that verified receipt | pending | +| Deslop | pending | Run bounded cleanup or N/A | pending | +| Agent-native reviewer | pending | Run for workflow changes or N/A | pending | +| Final lint | yes | Run `bun lint:fix` | pending | +| Repository check | yes | Run `bun check` | pending | +| GitHub delivery | pending | Commit/push/open or update PR and read back | pending | +| Autoreview | yes | Resolve every accepted actionable finding | pending | +| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/473-autoclosure.md` | pending | +| Agent source / generated sync | pending | Run `bun install` when `.agents/rules/**` changed and verify generated mirrors | pending | +| Installed lock audit | pending | Verify expected lock entries and removed skills through CLI-managed state | pending | +| Agent action discoverability | pending | Source-audit the skill/rule path an agent will read | pending | +| Helper and template smoke | pending | Syntax-check helpers and prove incomplete failure/completed representation when applicable | pending | +| Agent-native review | pending | Load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted findings, or record N/A | pending | + +Phase / pass table: +| Phase | Status | Evidence | Next | +| --- | --- | --- | --- | +| Inventory | complete | immutable head, PR source, comments, checks, and recoverable classification recorded | task evidence repair | +| Repair | in_progress | plan and suite-isolation fix pass focused tests and typecheck; the user explicitly waived only the unrelated Expo fixture lane | amend and push exact-PR evidence | +| Review/checks | pending | | delivery | +| Delivery | pending | | final audit | +| Closeout | pending | | final | + +Verification evidence: +- `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 full Bun suite inside `bun check` passed with 1,473 tests after the fix. +- `bun check` remains red only in `fixtures:check`. Expo's live SDK 55 template changed generated `AGENTS.md` and removed `CLAUDE.md`. The immutable PR diff contains no fixture, CLI registry, or tooling change. + +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,473 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. + +Reboot status: +| Question | Answer | +| --- | --- | +| Where am I? | Recovery is active; exact-PR evidence is ready to publish. | +| Where am I going? | Push exact task evidence, then review, feedback, final checks, guarded merge, and final audit. | +| What is the goal? | Merge only a fully recovered and verified PR #473. | +| What have I learned? | The PR is recoverable. Its test suite had one process-global mock leak. The remaining check failure is unrelated Expo fixture drift. | +| What have I done? | Created exact-PR plans, fixed the test leak, passed focused tests, package build, package typecheck, lint, and the full Bun suite. | + +Open risks: +- `bun check` is not green because the external Expo template no longer matches + committed fixture guidance. The user explicitly waived only that unrelated + lane for PR #473; all other gates remain mandatory. +- GitHub still reports Vercel failure because the contributor deployment needs + udecode team authorization. Vercel is not the required ruleset context; `CI` + is required and should rerun after a permitted push. +- 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: 15 focused tests and all 1,473 Bun tests pass. `bun check` stops + only when the moving Expo SDK 55 template changes generated guidance outside + PR #473. Live PR head remains `424a3bec59d8c9b1accf592ad534ef73ad90e7dc`. +- 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 PR feedback review has not begun because the recovery evidence has not + reached the PR head. diff --git a/docs/plans/473-optimistic-auth-gate.md b/docs/plans/473-optimistic-auth-gate.md new file mode 100644 index 000000000..851022b18 --- /dev/null +++ b/docs/plans/473-optimistic-auth-gate.md @@ -0,0 +1,463 @@ +# 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: +- 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: +- 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: task evidence recovery +- current_phase_status: active after explicit unrelated-fixture waiver +- next_phase: push evidence, then implementation review +- goal_status: active + +Current verdict: +- verdict: repair-source +- confidence: high that the PR is recoverable; merge readiness remains unproven +- next owner: task +- reason: the immutable head has a coherent, substantive delta and detailed + acceptance cases, but it lacks the exact task-plan evidence required before + autoclosure review + +Implementation readiness: +- verdict: repair-source +- exact owner: `packages/kitcn` auth-client and React package code in PR #473 +- contradiction status: no contradiction found during bounded intake; full + source and test review begins only after the evidence repair reaches the head +- source-listed cases complete: extracted below; proof is pending + +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: existing focused tests are present; review and + execution are pending after evidence repair + - 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: pending +- validity verdict: pending +- 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: pending +- hard-stop decision: continue only after exact-PR task evidence is committed, + pushed, and read back at the live head + +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 | Required path is `docs/plans/473-optimistic-auth-gate.md`; live body/head proof is pending the recovery push | +| 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 | no | Immutable PR diff changes no CLI, scaffold, fixture, generated, or tooling owner | +| 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 applies and passes. Fixture generation is N/A to the PR; the user explicitly waived only the unrelated live Expo template drift lane. | + +Work Checklist: +- [ ] 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. +- [ ] Objective includes outcome, completion threshold, verification surface, + constraints, boundaries, and blocked condition. +- [ ] Task source classified with source type, id/link, title, task type, + acceptance criteria, caveats, likely files/routes/packages, browser + surface, and root-cause layer. +- [ ] 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. +- [ ] Required video or screen-recording evidence is cached/read as normalized + `` XML, or marked N/A with reason. +- [ ] 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. +- [ ] 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. +- [ ] 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. +- [ ] Nearby repo instructions and implementation patterns read before edits. +- [ ] Source-listed case matrix is complete and every contradiction has an + owner, harness, and verdict before mutation. +- [ ] Readiness is classified `ready`, `repair-source`, `major`, `blocked`, or + `invalid` with evidence. +- [ ] Implementation fixes the right ownership boundary, or the narrower choice + is recorded with reason. +- [ ] Release artifact requirement recorded: active changeset, new changeset, or + N/A with reason. +- [ ] Final handoff shape decided: bug/feature/testing/batch/review/GitHub + requirements, PR body sync, and issue sync when applicable. +- [ ] 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. +- [ ] PR body shape recorded: PR #270 emoji task-style body used, N/A reason + recorded, or blocker recorded. +- [ ] PR task evidence recorded: body includes `๐Ÿงญ Task plan: ...`, the plan + exists at the PR head, and it identifies the exact PR before autoclosure. +- [ ] Branch handling recorded for code-changing work: dedicated branch used, + new branch needed, or N/A with reason. +- [ ] Local-env-rot retry policy recorded for any surprising repo-wide failure: + reinstall/rerun evidence or N/A with reason. +- [ ] Workspace authority recorded: every proof command names the cwd/tool that + owns the changed behavior. +- [ ] Output budget discipline recorded and followed: broad searches are + scoped, capped, counted, or artifacted instead of streamed into goal + context. +- [ ] High-risk note recorded for public API, runtime, package-boundary, + browser behavior, agent-action, or command-contract changes, or marked + N/A with reason. +- [ ] Review/autoreview target selected from actual diff state for non-trivial + implementation work, or marked N/A with reason. +- [ ] Agent-native review decision recorded for `.agents/**`, `.claude/**`, + `.codex/**`, skills, hooks, commands, prompts, or user-action tooling. +- [ ] Package/API pack: public API, package boundary, export, and release-artifact impact are recorded. +- [ ] Package/API pack: release artifact matrix is applied: `.changeset` or explicit no-artifact reason. +- [ ] Package/API pack: `.changeset` work loads `changeset` and follows its package/version/prose rules. +- [ ] Package/API pack: no-artifact decisions state why the diff has no published package user-visible delta from `main`. +- [ ] Package/API pack: compatibility, migration, or hard-cut decision is explicit when public shape changes. +- [ ] Package/API pack: affected Convex static import graphs stay narrow and + plugin/per-module boundaries are used where appropriate. +- [ ] Package/API pack: CLI commands remain deterministic, `--json` capable, + and non-interactive with explicit confirmation bypass when relevant. +- [ ] Package/API pack: docs and `packages/kitcn/skills/kitcn/**` stay + current-state synchronized when public guidance changes. +- [ ] Package/API pack: package-owned typecheck/build/test proof is recorded or marked N/A with reason. +- [ ] Package/API pack: `packages/kitcn` build, fixture sync/check, or other owning package proof is recorded when required. + +Completion Gates: +| Gate | Applies | Required action | Evidence | +|------|---------|-----------------|----------| +| Named verification threshold | pending | Run the command, proof, source audit, or artifact check named in this plan | pending | +| Exact per-PR task ownership | pending | Record the exact PR and dedicated plan, or the not-yet-created single-PR slice | pending | +| Pre-solution issue challenge verdict | pending | Record reporter claim, suggested fix, repro verdict, validity verdict, durable boundary, and hard-stop/pivot decision before implementation | pending | +| Repro escalation ladder | pending | For bug/behavior claims, record test/source-level, automated browser/integration, Browser, and screenshot/visual-proof outcomes or N/A/blocker reasons before `not reproduced` | pending | +| Bug reproduced before fix | pending | Record failing test/repro or N/A with reason | pending | +| Targeted behavior verification | pending | Run focused test/proof for changed behavior or record N/A | pending | +| TypeScript or typed config changed | pending | Run relevant typecheck | pending | +| Package exports or file layout changed | pending | Run the relevant package build before final verification and keep generated updates | pending | +| Package manifests, lockfile, or install graph changed | pending | Run `bun install` and relevant package checks | pending | +| Agent rules or skills changed | pending | Run `bun install` and verify generated skill sync | pending | +| Workspace authority proof | pending | Run verification in the owning repo/package/app/route/tool and record cwd; do not count the wrong workspace as proof | pending | +| Browser surface changed | pending | Capture Browser Use proof or record explicit waiver/blocker | pending | +| Browser final proof | pending | Attach screenshot or exact browser verification caveat when browser proof applies | pending | +| UI walkthrough | pending | If UI or rendered output changed, run `.agents/skills/walkthrough/SKILL.md` after final proof and show annotated images in the final handoff; otherwise record N/A | pending | +| Scaffold or fixture output changed | pending | Run `bun run fixtures:sync` and `bun run fixtures:check`, or record N/A | pending | +| Package behavior or public API changed | pending | Add a changeset or record why no changeset applies | pending | +| Docs and kitcn skill sync changed | pending | Keep `www/**` and `packages/kitcn/skills/kitcn/**` in sync, or record N/A | pending | +| Docs or content changed | pending | For docs-heavy work, use `--template docs`; for incidental docs, verify source-backed claims, links, examples, and rendered output or record N/A | pending | +| High-risk mini gate | pending | For public API/runtime/package-boundary/browser/agent-action/command-contract changes, record realistic failure mode, proof plan, and why the chosen boundary is right; otherwise N/A | pending | +| Agent-native review for agent/tooling changes | pending | For `.agents/**`, `.claude/**`, `.codex/**`, skills, hooks, commands, prompts, or user-action tooling, load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted/actionable findings, or record N/A | pending | +| Local install corruption suspected | pending | Run `bun install` once, rerun the exact failing command, or record N/A | pending | +| Commit created | pending | For verified code-changing work, stage the entire current checkout per repo policy and create a commit; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | pending | +| PR create or update | pending | For verified code-changing work, run `check`, push, create or update the PR, and sync PR body to the task-style final handoff; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | pending | +| Task-style PR body verified | pending | Verify the PR body with `gh pr view --json body`; it must preserve auto-release blocks when applicable, must not include a current-PR self-link, and must use the PR #270 emoji format: `๐Ÿ› Fixes ...`, `๐ŸŸข 95-100% confidence`, `Phase / ๐Ÿงช Tests / ๐ŸŒ Browser` table, and bold emoji Outcome/Caveat/Design/Verified sections | pending | +| PR task evidence verified | pending | Verify body plan line, plan at PR head, and exact PR ownership | pending | +| PR proof image hosting | pending | If PR body needs browser proof, replace local image paths with hosted GitHub URLs or record N/A | pending | +| GitHub issue sync-back | pending | Post concise issue sync after PR exists, or record N/A/blocker | pending | +| Final handoff contract | pending | Fill the final handoff fields below with exact PR/issue/confidence/tests/browser/outcome/caveats/design/verification content or N/A reason | pending | +| Final lint | pending | Run `bun lint:fix` or scoped equivalent | pending | +| Output budget discipline | pending | Verify no unbounded high-volume command output was streamed, or record the accidental output and recovery | pending | +| Timed checkpoint | pending | If duration was requested, keep improving until elapsed, then finish the current loop cleanly; otherwise N/A | pending | +| Autoreview for non-trivial implementation changes | pending | Load `.agents/skills/autoreview/SKILL.md`; use dirty local `--mode local`, branch/PR `--mode branch --base `, or committed slice `--mode commit --commit ` until no accepted/actionable findings, or record N/A for docs-only/trivial/no local patch | pending | +| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/473-optimistic-auth-gate.md` | pending | +| Public API / package boundary proof | pending | Source-audit public API, exports, and package boundary impact | pending | +| Convex bundle/import proof | pending | Audit affected function-entry static graphs or record N/A | pending | +| CLI/scaffold/generated proof | pending | Prove command contract and regenerate owned output or record N/A | pending | +| Release artifact classification | pending | Record whether the change is published package behavior/API/types/config/runtime or no published user-visible delta | pending | +| Published package changeset | pending | If published package users see a delta, load `changeset` and add/update one `.changeset/*.md` per package | pending | +| No release artifact | pending | If no artifact is needed, record the exact reason: internal-only, docs-only, agent-only, test-only, or no user-visible delta from `main` | pending | +| Package typecheck/build/test | pending | Run owning package checks or record N/A with reason | pending | +| Fixture/scaffold generation | pending | Run `bun run fixtures:sync` and `bun run fixtures:check` when scaffold output changed, otherwise N/A | pending | +| Docs/package skill sync | pending | Synchronize current-state public guidance or record N/A | pending | + +Phase / pass table: +| Phase | Status | Evidence | Next | +|-------|--------|----------|------| +| Intake and source read | complete | source, exact PR, and recovery boundaries recorded | implementation | +| Implementation | pending | | verification | +| Verification | pending | | closeout | +| Commit / PR / GitHub sync | pending | | final response | +| Closeout | pending | | final response | + +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: +- None yet. + +Implementation notes: +- None yet. + +Review fixes: +- None yet. + +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: +- 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 full Bun test lane inside `bun check` passes 1,473 tests. The final fixture lane fails only on upstream Expo SDK 55 guidance drift outside the PR diff. + +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 | implemented at intake; independent review pending | every named state passes | pending after evidence recovery | pending | +| 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 | implemented at intake; independent review pending | every named identity transition passes | pending after evidence recovery | pending | +| guarded HTTP token | HTTP headers use the same guarded fetcher and omit a refused token | `context.test.tsx` focused cases | implemented at intake; independent review pending | guarded token or no header | pending after evidence recovery | pending | +| optimistic mutation | `optimisticUpdate` reaches Convex `withOptimisticUpdate` and not TanStack options | `use-query-options.test.tsx` focused cases and typecheck | implemented at intake; independent review pending | Convex owns update and rollback | pending after evidence recovery | pending | +| 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,473-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: pending +- PR line: pending +- Issue line: pending +- Confidence line: pending +- Flow table: + - Reproduced: tests pending, browser pending + - Verified: tests pending, browser pending +- Browser check: pending +- Outcome: pending +- Caveat: pending +- Design: + - Chosen boundary: pending + - Why not quick patch: pending + - Why not broader change: pending +- Verified: pending +- PR body verified: pending + +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: pending +- PR: pending +- Issue: pending +- Browser proof: pending +- Caveats: pending + +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,473-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. + +Reboot status: +| Question | Answer | +|----------|--------| +| Where am I? | Exact-PR evidence recovery is active and ready to publish. | +| Where am I going? | Push the plan and test-isolation fix, update the PR body, then return to autoclosure review. | +| What is the goal? | Recover and verify PR #473 without changing its product contract. | +| What have I learned? | The implementation is substantive. One unrelated process-global test mock broke the branch's server-client tests. Expo fixture drift remains outside the PR diff. | +| What have I done? | Created the dedicated task plan, fixed the test leak, and passed the focused tests, package typecheck, package build, lint, and full Bun suite. | + +Open risks: +- `bun check` is red only because the current external Expo template differs + from committed fixture guidance. The user explicitly waived only that + unrelated lane for PR #473; every other proof and GitHub gate remains active. +- The public auth and React API diff still needs independent source review, + full live feedback triage, and final exact-head proof after recovery. +- 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/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: { From 22855d789fa15960af03ee0fe13f4decc6a6ca26 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 30 Sep 2026 01:01:44 +0200 Subject: [PATCH 5/8] docs(task): record PR recovery evidence --- docs/plans/473-autoclosure.md | 26 +++++++++++++++----------- docs/plans/473-optimistic-auth-gate.md | 16 ++++++++++------ 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/docs/plans/473-autoclosure.md b/docs/plans/473-autoclosure.md index df4916977..d791e7858 100644 --- a/docs/plans/473-autoclosure.md +++ b/docs/plans/473-autoclosure.md @@ -86,14 +86,14 @@ Start Gates: | --- | --- | --- | | 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 | no | Missing at intake. Recovery owns this gate before normal review. | -| Recoverable task state adopted | yes | Exact-PR task plan `docs/plans/473-optimistic-auth-gate.md` created on the preserved head; body repair and read-back remain pending | +| 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, changeset, source behavior, feedback, review, repository check, and GitHub delivery apply; fixtures, UI, and agent workflow are currently N/A | -| Live PR feedback target resolved | conditional | Pending until task evidence reaches the live PR head | -| Feedback proof checkout bound to PR head | conditional | Pending until task evidence reaches the live PR head | -| Unfiltered feedback inventory | conditional | Pending until task evidence reaches the live PR head; intake read found two bot comments, no reviews, and no inline comments | +| 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 | conditional | Recovery is complete; refresh every feedback surface next | | 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 | Active goal points to this plan and names the task-evidence, feedback, proof, check, merge, and receipt threshold | | Agent-native pack selected | yes | Required by the autoclosure goal contract | @@ -105,9 +105,9 @@ Start Gates: Closure matrix: | Lane | Applies | Owner/proof | Status | | --- | --- | --- | --- | -| task intake classification | pending | immutable-head `complete` / `recoverable` / `absent` evidence | pending | -| per-PR task ownership | conditional | complete evidence or recovered exact PR + dedicated task plan | pending | -| recoverable task adoption | conditional | preserve branch + exact-PR `task` + repaired evidence read-back | pending | +| 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 | conditional | exact missing-state comment + `CLOSED` read-back | pending | | source behavior | pending | pending | pending | | package/API/build | pending | pending | pending | @@ -258,8 +258,8 @@ Phase / pass table: | Phase | Status | Evidence | Next | | --- | --- | --- | --- | | Inventory | complete | immutable head, PR source, comments, checks, and recoverable classification recorded | task evidence repair | -| Repair | in_progress | plan and suite-isolation fix pass focused tests and typecheck; the user explicitly waived only the unrelated Expo fixture lane | amend and push exact-PR evidence | -| Review/checks | pending | | delivery | +| Repair | complete | recovery commit and task-format body are live; local/fetched/live heads match | feedback and source review | +| Review/checks | in_progress | | delivery | | Delivery | pending | | final audit | | Closeout | pending | | final | @@ -295,11 +295,15 @@ Timeline: - 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. Reboot status: | Question | Answer | | --- | --- | -| Where am I? | Recovery is active; exact-PR evidence is ready to publish. | +| Where am I? | Recovery is complete; live feedback and source review are active. | | Where am I going? | Push exact task evidence, then review, feedback, final checks, guarded merge, and final audit. | | What is the goal? | Merge only a fully recovered and verified PR #473. | | What have I learned? | The PR is recoverable. Its test suite had one process-global mock leak. The remaining check failure is unrelated Expo fixture drift. | diff --git a/docs/plans/473-optimistic-auth-gate.md b/docs/plans/473-optimistic-auth-gate.md index 851022b18..10e27aaba 100644 --- a/docs/plans/473-optimistic-auth-gate.md +++ b/docs/plans/473-optimistic-auth-gate.md @@ -111,9 +111,9 @@ 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: task evidence recovery -- current_phase_status: active after explicit unrelated-fixture waiver -- next_phase: push evidence, then implementation review +- current_phase: implementation review +- current_phase_status: active after exact-PR recovery +- next_phase: verification - goal_status: active Current verdict: @@ -184,7 +184,7 @@ Start Gates: | 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 | Required path is `docs/plans/473-optimistic-auth-gate.md`; live body/head proof is pending the recovery push | +| 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 | @@ -324,7 +324,7 @@ Phase / pass table: | Phase | Status | Evidence | Next | |-------|--------|----------|------| | Intake and source read | complete | source, exact PR, and recovery boundaries recorded | implementation | -| Implementation | pending | | verification | +| Implementation | in_progress | recovery fix is live; source review remains | verification | | Verification | pending | | closeout | | Commit / PR / GitHub sync | pending | | final response | | Closeout | pending | | final response | @@ -438,11 +438,15 @@ Timeline: - 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 evidence recovery is active and ready to publish. | +| Where am I? | Exact-PR recovery is complete; implementation review is active. | | Where am I going? | Push the plan and test-isolation fix, update the PR body, then return to autoclosure review. | | What is the goal? | Recover and verify PR #473 without changing its product contract. | | What have I learned? | The implementation is substantive. One unrelated process-global test mock broke the branch's server-client tests. Expo fixture drift remains outside the PR diff. | From ea5e442d5774fc7f7c0aa1f1d6bae8c1c34b7747 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 30 Sep 2026 01:14:40 +0200 Subject: [PATCH 6/8] fix(auth): harden optimistic identity admission --- .../skills/kitcn/references/features/auth.md | 15 ++++ .../skills/kitcn/references/features/react.md | 16 ++++ .changeset/optimistic-auth-gate.md | 18 ++--- docs/plans/473-autoclosure.md | 12 +++ docs/plans/473-optimistic-auth-gate.md | 8 +- .../skills/kitcn/references/features/auth.md | 15 ++++ .../skills/kitcn/references/features/react.md | 16 ++++ .../auth-client/convex-auth-provider.test.tsx | 77 ++++++++++++++----- .../src/auth-client/convex-auth-provider.tsx | 77 ++++++------------- .../convex-auth-provider.types.test.ts | 7 ++ .../crpc-generated-api.e2e.types.ts | 8 ++ packages/kitcn/src/react/client.test.ts | 2 - packages/kitcn/src/react/context.test.tsx | 2 - packages/kitcn/src/react/context.tsx | 2 - packages/kitcn/src/react/crpc-types.ts | 9 ++- .../src/react/use-query-options.test.tsx | 1 - packages/kitcn/src/react/use-query-options.ts | 2 - www/content/docs/auth/client.mdx | 23 ++++++ www/content/docs/react/mutations.mdx | 28 +++++++ 19 files changed, 236 insertions(+), 102 deletions(-) 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 index dbfae2060..039e263ca 100644 --- a/.changeset/optimistic-auth-gate.md +++ b/.changeset/optimistic-auth-gate.md @@ -2,15 +2,9 @@ "kitcn": patch --- -## Features - -- Add `optimisticAuth` to `ConvexAuthProvider`: auth-bound queries run as soon as an unexpired JWT is held instead of after Convex confirms it, and a refused token resets auth-bound queries and never reopens the gate. -- Add `onTokenIdentityChange` to `ConvexAuthProvider`: a document keeps the identity it mounted with (the SSR token) or, with none, the first one it obtains; a token for another user or session is refused before it is cached, so neither Convex nor HTTP requests ever use it, the client is closed (dropping queued requests and optimistic updates) and the callback runs, typically to reload the page. -- Add `tokenIdentityBaseline` to `ConvexAuthProvider`: for an app that mounts the provider more than once per document (for example per route group over a shared Convex client), the identity guard starts from the identity the document already speaks for, so a remount without a token still refuses another user's or session's token. It also accepts a getter, read at every admission (cached tokens included), so a provider kept mounted but hidden (React ``) refuses a token once the document has moved to another identity. -- Add `onTokenIdentityAdmitted` to `ConvexAuthProvider`: called with every token the identity guard admits as it is handed out, cached tokens included and refused ones never, so the document can claim the identity at that moment. -- Improve cRPC HTTP headers to take their token from the same fetcher Convex uses, so its guards apply to HTTP requests too. -- Support `optimisticUpdate` in cRPC `mutationOptions`, passed to Convex's own optimistic updates so a refused write rolls back without flicker. - -## Patches - -- Fix server `ConvexQueryClient` construction calling `Math.random()` (Convex's default logger), which prerendering (Cache Components) refuses: the server HTTP client now reuses the Convex client's logger. +- 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 index d791e7858..31a79836e 100644 --- a/docs/plans/473-autoclosure.md +++ b/docs/plans/473-autoclosure.md @@ -270,6 +270,18 @@ Verification evidence: - The full Bun suite inside `bun check` passed with 1,473 tests after the fix. - `bun check` remains red only in `fixtures:check`. Expo's live SDK 55 template changed generated `AGENTS.md` and removed `CLAUDE.md`. The immutable PR diff contains no fixture, CLI registry, or tooling change. +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 diff --git a/docs/plans/473-optimistic-auth-gate.md b/docs/plans/473-optimistic-auth-gate.md index 10e27aaba..1fe423619 100644 --- a/docs/plans/473-optimistic-auth-gate.md +++ b/docs/plans/473-optimistic-auth-gate.md @@ -344,7 +344,13 @@ Implementation notes: - None yet. Review fixes: -- None yet. +- 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 | 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 e4fdfbc6e..e32de0dd8 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.test.tsx @@ -1313,6 +1313,10 @@ describe('ConvexAuthProvider', () => { }); describe('onTokenIdentityChange', () => { + type FetchToken = (args: { + forceRefreshToken: boolean; + }) => Promise; + const identityJwt = (sub: string, sessionId: string, expSeconds = 3600) => { const payload = btoa( JSON.stringify({ @@ -1324,12 +1328,25 @@ describe('ConvexAuthProvider', () => { 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) @@ -1349,7 +1366,9 @@ describe('ConvexAuthProvider', () => { updateSession: () => {}, crossDomain: { oneTimeToken: { verify: async () => ({ data: {} }) } }, }; - const onTokenIdentityChange = mock(() => {}); + const onTokenIdentityChange = mock(() => { + if (throws) throw new Error('identity callback failed'); + }); const wrapper = ({ children }: { children: ReactNode }) => ( { onTokenIdentityChange, fetch: (forceRefreshToken: boolean) => { if (!fetchToken) throw new Error('setAuth was not called'); - return fetchToken({ forceRefreshToken }); + return fetchWithAct(fetchToken, forceRefreshToken); }, }; }; @@ -1390,7 +1409,6 @@ describe('ConvexAuthProvider', () => { }; const tokenForB = identityJwt('user_b', 'session_b'); const authClient = { - // Better Auth already has a session: kitcn fetches instead of reusing A. useSession: () => ({ data: { session: { id: 'session_a' }, user: { id: 'user_a' } }, isPending: false, @@ -1415,12 +1433,11 @@ describe('ConvexAuthProvider', () => { await flush(); if (!fetchToken) throw new Error('setAuth was not called'); - expect(await fetchToken({ forceRefreshToken: false })).toBeNull(); + expect(await fetchWithAct(fetchToken, false)).toBeNull(); expect(onTokenIdentityChange).toHaveBeenCalledTimes(1); expect(close).toHaveBeenCalledTimes(1); - // B never reached the cache, and nothing is handed out any more. expect(result.current.get('token')).not.toBe(tokenForB); - expect(await fetchToken({ forceRefreshToken: true })).toBeNull(); + expect(await fetchWithAct(fetchToken, true)).toBeNull(); }); test('no SSR token: the first token a sign-in obtains sets the identity', async () => { @@ -1460,7 +1477,7 @@ describe('ConvexAuthProvider', () => { await flush(); if (!fetchToken) throw new Error('setAuth was not called'); - expect(await fetchToken({ forceRefreshToken: false })).toBe(tokenForC); + expect(await fetchWithAct(fetchToken, false)).toBe(tokenForC); expect(onTokenIdentityChange).toHaveBeenCalledTimes(0); expect(close).toHaveBeenCalledTimes(0); }); @@ -1478,6 +1495,20 @@ describe('ConvexAuthProvider', () => { 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 }); @@ -1490,7 +1521,6 @@ describe('ConvexAuthProvider', () => { }); describe('tokenIdentityBaseline', () => { - /** A remount without a token, whose first fetch returns `obtained`. */ const remount = ({ baseline, obtained, @@ -1538,7 +1568,7 @@ describe('ConvexAuthProvider', () => { onTokenIdentityChange, fetch: (forceRefreshToken: boolean) => { if (!fetchToken) throw new Error('setAuth was not called'); - return fetchToken({ forceRefreshToken }); + return fetchWithAct(fetchToken, forceRefreshToken); }, }; }; @@ -1583,11 +1613,6 @@ describe('ConvexAuthProvider', () => { }); describe('tokenIdentityBaseline getter and onTokenIdentityAdmitted', () => { - /** - * A mount with a Better Auth session and no token of its own; each - * fresh fetch returns the next of `obtained`. `document.identity` is - * what the baseline getter answers, read whenever the guard asks. - */ const documentHarness = ({ getter = true, guard = true, @@ -1660,7 +1685,7 @@ describe('ConvexAuthProvider', () => { setAuth, fetch: (forceRefreshToken: boolean) => { if (!fetchToken) throw new Error('setAuth was not called'); - return fetchToken({ forceRefreshToken }); + return fetchWithAct(fetchToken, forceRefreshToken); }, replaceOnAdmitted: (next: (token: string) => void) => { onAdmitted = next; @@ -1670,13 +1695,12 @@ describe('ConvexAuthProvider', () => { }; test('the getter is read at admission, not at mount: a remount refuses a token of another identity', async () => { - // The document speaks for no one when this provider mounts, then - // another mount establishes A before this one's first token arrives. 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(); @@ -1687,6 +1711,21 @@ describe('ConvexAuthProvider', () => { 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({ @@ -1696,11 +1735,9 @@ describe('ConvexAuthProvider', () => { await flush(); expect(await harness.fetch(false)).toBe(tokenForA); - // Kept mounted but hidden while the document moved on to B. harness.document.identity = 'user_b|session_b'; expect(await harness.fetch(false)).toBeNull(); - // The refused token was the cached one: no second fetch happened. expect(harness.convexToken).toHaveBeenCalledTimes(1); expect(harness.onTokenIdentityChange).toHaveBeenCalledTimes(1); expect(harness.close).toHaveBeenCalledTimes(1); @@ -1812,7 +1849,6 @@ describe('ConvexAuthProvider', () => { ) => { let reportAuth: ((isAuthenticated: boolean) => void) | null = null; const client = { - // Holds the confirmation until the test decides what the server says. setAuth: (_fetchToken: unknown, onChange: (value: boolean) => void) => { reportAuth = onChange; }, @@ -1881,7 +1917,6 @@ describe('ConvexAuthProvider', () => { expect(result.current.auth.isAuthenticated).toBe(false); expect(result.current.auth.isLoading).toBe(true); - // The same token held again never counts as authenticated again. await act(async () => { result.current.store.set('isLoading', true); }); diff --git a/packages/kitcn/src/auth-client/convex-auth-provider.tsx b/packages/kitcn/src/auth-client/convex-auth-provider.tsx index 17671e4d2..1edabdc07 100644 --- a/packages/kitcn/src/auth-client/convex-auth-provider.tsx +++ b/packages/kitcn/src/auth-client/convex-auth-provider.tsx @@ -565,10 +565,6 @@ function ConvexAuthProviderInner({ .catch(() => {}); }, [session, isPending, authStore, authClient]); - // The identity guard (`onTokenIdentityChange`): seeded from the baseline - // the app hands a remount, else from the token the document already holds - // when it mounts (the SSR token). A getter baseline is re-read at every - // admission through a ref, so it always sees the latest prop. const onTokenIdentityChangeRef = useRef(onTokenIdentityChange); onTokenIdentityChangeRef.current = onTokenIdentityChange; const onTokenIdentityAdmittedRef = useRef(onTokenIdentityAdmitted); @@ -578,23 +574,22 @@ function ConvexAuthProviderInner({ const identityGuardRef = useRef(null); identityGuardRef.current ??= { identity: - resolveIdentityBaseline(tokenIdentityBaseline) ?? - decodeTokenIdentity(authStore.get('token')), - baseline: + (typeof tokenIdentityBaseline === 'function' + ? null + : resolveTokenIdentityBaseline(tokenIdentityBaseline)) ?? + decodeTokenSubjectSessionIdentity(authStore.get('token')), + currentDocumentIdentity: typeof tokenIdentityBaseline === 'function' - ? () => resolveIdentityBaseline(tokenIdentityBaselineRef.current) + ? () => resolveTokenIdentityBaseline(tokenIdentityBaselineRef.current) : null, tripped: false, }; - // `announce`: false for the check a fresh token passes before it is cached; - // the hand-out (guardedFetchAccessToken) checks it again and announces it, - // so `onTokenIdentityAdmitted` hears each token handed out exactly once. const admitToken = useCallback( - (token: string, announce: boolean) => - admitTokenIdentity({ + (token: string, onAdmitted?: (token: string) => void) => + guardTokenIdentity({ client, guard: identityGuardRef.current!, - onAdmitted: announce ? onTokenIdentityAdmittedRef.current : undefined, + onAdmitted, onIdentityChange: onTokenIdentityChangeRef.current, token, }), @@ -636,8 +631,7 @@ function ConvexAuthProviderInner({ .token({ fetchOptions }) .then((result: { data?: { token?: string | null } | null }) => { const jwt = result.data?.token || null; - // A token for another identity is refused before it is cached. - if (jwt && !admitToken(jwt, false)) return null; + if (jwt && !admitToken(jwt)) return null; if (jwt) { const exp = decodeJwtExp(jwt); @@ -769,14 +763,13 @@ function ConvexAuthProviderInner({ [authStore, authClient, getCachedJwt, admitToken] ); - // Every token consumer (Convex, HTTP headers) goes through this: once the - // guard has tripped it answers null, and it never hands out a token the - // guard refused. const guardedFetchAccessToken = useCallback( async (args: { forceRefreshToken?: boolean } = {}) => { if (identityGuardRef.current?.tripped) return null; const token = await fetchAccessToken(args); - if (!token || !admitToken(token, true)) return null; + if (!token || !admitToken(token, onTokenIdentityAdmittedRef.current)) { + return null; + } return token; }, [fetchAccessToken, admitToken] @@ -836,7 +829,6 @@ function AuthStateSync({ const { isLoading: convexIsLoading, isAuthenticated } = useConvexAuth(); const authStore = useAuthStore(); const token = useAuthValue('token'); - // The token Convex refused last: it never opens the optimistic gate again. const rejectedTokenRef = useRef(null); useEffect(() => { @@ -861,12 +853,9 @@ function AuthStateSync({ return children; } -/** - * The user and session a Better Auth Convex JWT speaks for (`sub` and - * `sessionId`), or null. Other claims (name, email, updatedAt) may change - * within one session and do not count. - */ -function decodeTokenIdentity(token: string | null): string | null { +function decodeTokenSubjectSessionIdentity( + token: string | null +): string | null { if (!token) return null; try { const segment = token.split('.')[1]; @@ -886,39 +875,17 @@ function decodeTokenIdentity(token: string | null): string | null { type IdentityGuard = { identity: string | null; - /** The document's current identity, when the baseline is a getter. */ - baseline: (() => string | null) | null; + currentDocumentIdentity: (() => string | null) | null; tripped: boolean; }; -/** - * `tokenIdentityBaseline` as given: a fixed identity, or a getter for the - * document's current one. - */ -function resolveIdentityBaseline( +function resolveTokenIdentityBaseline( baseline: string | null | (() => string | null) | undefined ): string | null { return typeof baseline === 'function' ? baseline() : (baseline ?? null); } -/** - * With `onTokenIdentityChange`, a document speaks for one identity (JWT `sub` - * and `sessionId`) for its lifetime: `tokenIdentityBaseline` when given (a - * provider that remounts within one document), else the one it already holds - * when it mounts (the SSR token), or, for a document with none, the first - * token it obtains. Given as a getter, the document's current identity is - * read at every admission, cached tokens included: a token must match both - * it and the identity this guard already admitted, so a provider kept - * mounted but hidden (React ``) cannot resume under an identity the - * document has since moved away from. `onAdmitted` hears an admitted token, - * so the document can claim the identity at that moment. - * A token for another user or session is refused before it is cached, so no - * consumer (Convex, the optimistic gate, HTTP headers) ever sees it. Refusing - * trips the guard for good: the callback runs (reload the page there), the - * Convex client is closed, which drops the requests it had queued and their - * optimistic updates, and every later token request answers null. - */ -function admitTokenIdentity({ +function guardTokenIdentity({ client, guard, onAdmitted, @@ -933,9 +900,9 @@ function admitTokenIdentity({ }): boolean { if (!onIdentityChange) return true; if (guard.tripped) return false; - const identity = decodeTokenIdentity(token); + const identity = decodeTokenSubjectSessionIdentity(token); if (identity === null) return true; - const current = guard.baseline ? guard.baseline() : null; + const current = guard.currentDocumentIdentity?.() ?? null; const matchesGuard = guard.identity === null || guard.identity === identity; const matchesDocument = current === null || current === identity; if (matchesGuard && matchesDocument) { @@ -944,8 +911,8 @@ function admitTokenIdentity({ return true; } guard.tripped = true; - onIdentityChange(); void client.close(); + onIdentityChange(); return false; } 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/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 7f089dfa0..93306cc70 100644 --- a/packages/kitcn/src/react/client.test.ts +++ b/packages/kitcn/src/react/client.test.ts @@ -157,8 +157,6 @@ describe('ConvexQueryClient (server mode)', () => { queryKey: ['convexQuery', name, {}], }) as unknown as QueryFunctionContext; - // What a Start app hands kitcn: a real client's logger; setAuth and - // clearAuth stubbed (the real ones open a socket). const startConvexClient = () => { const convex = new ConvexReactClient( 'https://happy-otter-123.convex.cloud' diff --git a/packages/kitcn/src/react/context.test.tsx b/packages/kitcn/src/react/context.test.tsx index 68378dcbb..069cda418 100644 --- a/packages/kitcn/src/react/context.test.tsx +++ b/packages/kitcn/src/react/context.test.tsx @@ -180,7 +180,6 @@ describe('createCRPCContext', () => { httpProxyModule, 'createHttpProxy' ).mockReturnValue({} as any); - // The cache still holds a long-lived token the fetcher's guard refuses. useAuthStoreSpy.mockImplementation( () => ({ @@ -214,7 +213,6 @@ describe('createCRPCContext', () => { ?.headers as () => Promise>; expect(await headers()).toEqual({ Authorization: 'Bearer token-a' }); - // The guard tripped: nothing, not even the cached token, goes out. guarded = null; expect(await headers()).toEqual({}); expect(fetchAccessToken).toHaveBeenCalledTimes(2); diff --git a/packages/kitcn/src/react/context.tsx b/packages/kitcn/src/react/context.tsx index 083344b1b..919d2f96b 100644 --- a/packages/kitcn/src/react/context.tsx +++ b/packages/kitcn/src/react/context.tsx @@ -238,8 +238,6 @@ export function createCRPCContext>( convexSiteUrl: httpOptions.convexSiteUrl, routes: meta._http, headers: async () => { - // Through the same token fetcher Convex uses, so its guards (the - // identity guard) apply to HTTP requests too. if (fetchAccessToken) { const expiresAt = authStore.get('expiresAt'); // eslint-disable-next-line react-hooks/purity -- called in async callback, not during render diff --git a/packages/kitcn/src/react/crpc-types.ts b/packages/kitcn/src/react/crpc-types.ts index 039731724..e2043035c 100644 --- a/packages/kitcn/src/react/crpc-types.ts +++ b/packages/kitcn/src/react/crpc-types.ts @@ -18,6 +18,7 @@ import type { FunctionReference, FunctionReturnType, } from 'convex/server'; +import type { Value } from 'convex/values'; import type { ConvexActionKey, ConvexInfiniteQueryMeta, @@ -234,18 +235,18 @@ export type DecorateInfiniteQuery> = { // Mutation & Action Decorators (React-specific) // ============================================================================ -/** - * Decorated mutation procedure with mutationOptions and mutationKey methods. - */ /** * 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> = { +export type ConvexOptimisticUpdateOption> = { optimisticUpdate?: OptimisticUpdate; }; +/** + * Decorated mutation procedure with mutationOptions and mutationKey methods. + */ export type DecorateMutation> = { mutationOptions: ( opts?: DistributiveOmit< diff --git a/packages/kitcn/src/react/use-query-options.test.tsx b/packages/kitcn/src/react/use-query-options.test.tsx index e236b0f77..d5c9620a6 100644 --- a/packages/kitcn/src/react/use-query-options.test.tsx +++ b/packages/kitcn/src/react/use-query-options.test.tsx @@ -454,7 +454,6 @@ describe('use-query-options', () => { expect(withOptimisticUpdate).toHaveBeenCalledWith(optimisticUpdate); expect(withUpdate).toHaveBeenCalledTimes(1); expect(convexMutation).toHaveBeenCalledTimes(0); - // Not a TanStack option: it never reaches useMutation's options. expect('optimisticUpdate' in result.current).toBe(false); }); diff --git a/packages/kitcn/src/react/use-query-options.ts b/packages/kitcn/src/react/use-query-options.ts index cf6924494..604079a54 100644 --- a/packages/kitcn/src/react/use-query-options.ts +++ b/packages/kitcn/src/react/use-query-options.ts @@ -400,8 +400,6 @@ export function useConvexMutationOptions< const authType = getMeta(namespace, fnName)?.auth as AuthType; const { optimisticUpdate, ...mutationOptions } = options ?? {}; const reactMutation = useConvexMutationBase(mutation); - // Convex-native optimistic update: it edits Convex's local query store, - // which ConvexQueryClient reads, so Convex's own rollback applies. const convexMutation = optimisticUpdate ? reactMutation.withOptimisticUpdate(optimisticUpdate) : reactMutation; 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: From fcbd2f84cb73c6052de45fc16bbbed83255f6e70 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 30 Sep 2026 01:27:31 +0200 Subject: [PATCH 7/8] docs(task): record PR 473 closure proof and blocker --- docs/plans/473-autoclosure.md | 254 ++++++++++++---------- docs/plans/473-optimistic-auth-gate.md | 285 ++++++++++++++----------- 2 files changed, 299 insertions(+), 240 deletions(-) diff --git a/docs/plans/473-autoclosure.md b/docs/plans/473-autoclosure.md index 31a79836e..54a2c5260 100644 --- a/docs/plans/473-autoclosure.md +++ b/docs/plans/473-autoclosure.md @@ -34,16 +34,18 @@ 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, `bun lint:fix`, and - `bun check` pass on the final committed head. +- Focused package tests, the `packages/kitcn` build, and lint pass on the final + committed head. Every applicable `bun check` lane passes; the unrelated + moving Expo fixture lane has the user's exact waiver recorded below. - 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, `bun check` passing, review findings closed, authorized - GitHub delivery complete, and the goal checker passing. + fresh evidence, the exact unrelated fixture waiver, 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. @@ -93,7 +95,7 @@ Start Gates: | Closure matrix classified | yes | Package/API, changeset, source behavior, feedback, review, repository check, and GitHub delivery apply; fixtures, UI, and agent workflow are currently 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 | conditional | Recovery is complete; refresh every feedback surface next | +| 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 | Active goal points to this plan and names the task-evidence, feedback, proof, check, merge, and receipt threshold | | Agent-native pack selected | yes | Required by the autoclosure goal contract | @@ -108,18 +110,18 @@ Closure matrix: | 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 | conditional | exact missing-state comment + `CLOSED` read-back | pending | -| source behavior | pending | pending | pending | -| package/API/build | pending | pending | pending | -| generated output | pending | pending | pending | -| fixtures/scenarios | pending | pending | pending | -| docs/package skill | pending | pending | pending | -| changeset | pending | pending | pending | -| agent workflow | pending | pending | pending | -| live PR feedback | conditional | complete/recovered: `resolve-pr-feedback` + final P1 read-back; absent: N/A with comment/CLOSED receipts | pending | -| cleanup/review | pending | pending | pending | -| repository check | yes | `bun check` | pending | -| GitHub delivery | pending | pending | pending | +| 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 | no | N/A: no scaffold source changed; unrelated Expo drift explicitly waived | 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 | all owning lanes pass; required GitHub `CI` still fails on the unrelated Expo SDK 55 fixture drift | blocked | +| 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 @@ -130,93 +132,99 @@ Work Checklist: 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. -- [ ] **Order is conflicts, then review threads, then CI.** Run after exact-PR - evidence reaches the live head. -- [ ] **Trust the active forge's verdict, not a green check list.** Use the - GitHub watcher after recovery. -- [ ] **Classify CI before any retrigger.** No retry has been requested. -- [ ] **Bugbot is triaged skeptically, always.** No Bugbot item is known yet. -- [ ] **Stop at the human's line.** The user authorized autoclosure and merge, +- [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`. -- [ ] **Verify each PR independently.** The current agent did not author the +- [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. -- [ ] **Find the contiguous verified run.** The run contains only PR #473. -- [ ] **Cancel pending merges before changing the chain.** Inspect before any +- [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. -- [ ] **Prepare only the bottom PR.** PR #473 is the bottom and only PR. -- [ ] **Reassess the evidence.** Bind review and tests to the final head and - base OID. -- [ ] **Merge with a service-enforced head condition.** Use GitHub's +- [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. -- [ ] **Arm future merging only with durable verification gates.** Skip future +- [x] **Arm future merging only with durable verification gates.** Skip future arming. This run will use an immediate guarded merge. -- [ ] **Watch the frontier and preserve its verdict.** Rearm after each push. -- [ ] **Confirm the landing before advancing.** Read the merged state and - confirm the merge commit is in fetched `main`. -- [ ] **Stop at the ceiling.** The ceiling is PR #473. -- [ ] Every PR has its own `task` invocation and dedicated task plan; a batch +- [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. -- [ ] Bounded intake classified immutable-head task state as `complete`, +- [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. -- [ ] Recoverable work was preserved and adopted through `task` for the exact +- [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. -- [ ] Complete or recovered task evidence was verified from the PR body, +- [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. -- [ ] Intended behavior and exclusions are reconstructed from real sources. -- [ ] Each lane is proven or N/A with a concrete reason. -- [ ] Generated output was changed through its owner and regenerated. -- [ ] Package/docs/skill/fixture/scenario/changeset contracts are synchronized. -- [ ] Full `resolve-pr-feedback` ran for the exact complete or recovered PR; +- [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. -- [ ] For a complete or recovered PR, local committed `HEAD`, fetched PR ref, +- [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. -- [ ] Unfiltered top-level PR comments and review bodies were fetched through +- [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. -- [ ] All inline review threads were fetched with GraphQL cursor pagination +- [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. -- [ ] Every actionable feedback item has a persisted P0-P3 priority and +- [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. -- [ ] Every P1-or-higher proof reran after the final material branch push, +- [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. -- [ ] Feedback was re-fetched after the last push/reply/resolution and shows +- [x] Feedback was re-fetched after the last push/reply/resolution and shows zero unresolved actionable P1-or-higher findings. -- [ ] After all versioned plan/source updates were pushed, the exact-head P1 - proof/read-back receipt was posted to the PR and read back; no terminal - receipt-only branch push was created. A post-comment `headRefOid` fetch - matches the OID recorded in that receipt, and a post-comment helper/raw - feedback fetch still shows zero actionable P1-or-higher items and no new - URL lacking a verdict or explicit deferral, except the verified receipt. -- [ ] Any remaining P2-or-lower item has its exact URL plus the user's explicit +- [ ] 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. -- [ ] Accepted cleanup and review findings are closed. -- [ ] PR body and check state match the final evidence. -- [ ] Residual blocker/waiver has exact evidence and next owner. -- [ ] Agent-native pack: source-of-truth rule files are edited instead of generated skill mirrors. -- [ ] Agent-native pack: the changed agent action is discoverable from the skill/rule text. -- [ ] Agent-native pack: generated mirrors are synced when `.agents/rules/**` changed, or N/A reason is recorded. -- [ ] Agent-native pack: installed skills are changed only through - `npx skills add/update/remove`; local rules/templates/helpers stay source-owned. -- [ ] Agent-native pack: routing, required receipts, placeholder failure, +- [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] Residual waiver has exact evidence and owner: unrelated Expo drift is + user-waived; 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. -- [ ] Agent-native pack: accepted agent-native review findings are fixed or explicitly rejected with reason. +- [x] Agent-native pack: direct audit found no accepted/actionable findings. Error attempts: | Failure signature | Count | Next different move | Resolution | @@ -224,51 +232,58 @@ Error attempts: | `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 | pending | Record immutable-head `complete`, `recoverable`, or `absent` sources and rationale | pending | -| Per-PR task ownership | pending | Record exact PR and complete or recovered task-plan path | pending | -| Recoverable task adoption | conditional | Preserve current work, run exact-PR `task`, repair plan/body evidence, and read back at new head | pending | -| Absent-state disposition | conditional | Name the missing usable state, comment, close, and read back | pending | -| Targeted behavior proof | pending | Run smallest missing owning proof | pending | -| Source/generated audit | pending | Prove correct source and regenerated mirrors | pending | -| Package/docs/scenario closure | pending | Run every applicable local contract | pending | -| Feedback proof checkout | conditional | Complete or recovered PR only: require local committed `HEAD` = fetched PR ref = live `headRefOid` before proof/reply/resolution and at terminal verification | pending | -| Live PR feedback resolution | conditional | Complete or recovered PR only: run full `resolve-pr-feedback` and close every actionable P1-or-higher finding; otherwise N/A with absent-state stop receipts | pending | -| Feedback priority classification | conditional | Complete or recovered PR only: persist P0-P3 plus rationale for every actionable item; classify ambiguous P1-versus-lower as P1 | pending | -| Final P1 proof replay | conditional | Complete or recovered PR only: after the final material branch push, rerun every P1-or-higher proof, including resolved/outdated items | pending | -| Final live feedback read-back | conditional | Complete or recovered PR only: re-fetch helper plus unfiltered top-level/all-thread inventories; require zero actionable P1-or-higher and explicit P2-or-lower deferrals | pending | -| External terminal receipt | conditional | Complete or recovered PR only: post/read exact-head receipt; require receipt/live/fetched/local OID equality and no unrecorded helper/raw URL except that verified receipt | pending | -| Deslop | pending | Run bounded cleanup or N/A | pending | -| Agent-native reviewer | pending | Run for workflow changes or N/A | pending | -| Final lint | yes | Run `bun lint:fix` | pending | -| Repository check | yes | Run `bun check` | pending | -| GitHub delivery | pending | Commit/push/open or update PR and read back | pending | -| Autoreview | yes | Resolve every accepted actionable finding | pending | -| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/473-autoclosure.md` | pending | -| Agent source / generated sync | pending | Run `bun install` when `.agents/rules/**` changed and verify generated mirrors | pending | -| Installed lock audit | pending | Verify expected lock entries and removed skills through CLI-managed state | pending | -| Agent action discoverability | pending | Source-audit the skill/rule path an agent will read | pending | -| Helper and template smoke | pending | Syntax-check helpers and prove incomplete failure/completed representation when applicable | pending | -| Agent-native review | pending | Load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted findings, or record N/A | pending | +| 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 | no | N/A | No agent workflow changed; direct ownership/mirror/discoverability audit is clean. | +| Final lint | yes | Run lint | `bun lint` passes across 975 files. | +| Repository check | yes | Run all applicable lanes | Local owning lanes pass, but required GitHub `CI` fails on waived moving Expo SDK 55 fixture drift: run 36644341283. | +| 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 | in_progress | | delivery | -| Delivery | pending | | final audit | -| Closeout | pending | | final | +| Review/checks | complete | focused/full proof, feedback inventory, no-comments, direct P1 review, and autoreview are clean | delivery | +| Delivery | blocked | required GitHub `CI` fails on unrelated Expo fixture drift; code-owner approval is also required | separate fixture repair or external rules change | +| Closeout | blocked | no receipt or merge while the required context is red | resume after blocker clears | Verification evidence: - `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 full Bun suite inside `bun check` passed with 1,473 tests after the fix. -- `bun check` remains red only in `fixtures:check`. Expo's live SDK 55 template changed generated `AGENTS.md` and removed `CLAUDE.md`. The immutable PR diff contains no fixture, CLI registry, or tooling change. +- 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 | @@ -289,7 +304,7 @@ Timeline: 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,473 Bun tests pass. The repository gate + 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 @@ -311,23 +326,41 @@ Timeline: `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? | Recovery is complete; live feedback and source review are active. | -| Where am I going? | Push exact task evidence, then review, feedback, final checks, guarded merge, and final audit. | +| Where am I? | Local/source closeout is complete; protected GitHub delivery is blocked by the unrelated fixture failure. | +| Where am I going? | Resume only after a separately scoped fixture repair clears required `CI`, then obtain code-owner approval, bind the receipt, guarded-merge, and confirm landing. | | What is the goal? | Merge only a fully recovered and verified PR #473. | -| What have I learned? | The PR is recoverable. Its test suite had one process-global mock leak. The remaining check failure is unrelated Expo fixture drift. | -| What have I done? | Created exact-PR plans, fixed the test leak, passed focused tests, package build, package typecheck, lint, and the full Bun suite. | +| 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: - `bun check` is not green because the external Expo template no longer matches committed fixture guidance. The user explicitly waived only that unrelated lane for PR #473; all other gates remain mandatory. -- GitHub still reports Vercel failure because the contributor deployment needs - udecode team authorization. Vercel is not the required ruleset context; `CI` - is required and should rerun after a permitted push. +- GitHub reports Vercel failure because the contributor deployment needs + udecode team authorization. Vercel is not a required ruleset context. +- Required GitHub `CI` is red at run 36644341283 on the same unrelated Expo + fixture drift. The user's waiver closes the local scope decision, not the + server-enforced status requirement. Clearing it needs a separate fixture + repair or a repository-owner rules change; this run will not bypass it. - The `main` ruleset requires a code-owner approval after the final push. This run will not use the account's available bypass. @@ -335,13 +368,14 @@ 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: 15 focused tests and all 1,473 Bun tests pass. `bun check` stops +- 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. Live PR head remains `424a3bec59d8c9b1accf592ad534ef73ad90e7dc`. + 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 PR feedback review has not begun because the recovery evidence has not - reached the PR head. +- 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 index 1fe423619..59ca11de3 100644 --- a/docs/plans/473-optimistic-auth-gate.md +++ b/docs/plans/473-optimistic-auth-gate.md @@ -111,25 +111,28 @@ 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: implementation review -- current_phase_status: active after exact-PR recovery -- next_phase: verification -- goal_status: active +- current_phase: closeout +- current_phase_status: complete at the final material head +- next_phase: root autoclosure receipt and merge +- goal_status: complete; root autoclosure owns external landing Current verdict: -- verdict: repair-source -- confidence: high that the PR is recoverable; merge readiness remains unproven -- next owner: task -- reason: the immutable head has a coherent, substantive delta and detailed - acceptance cases, but it lacks the exact task-plan evidence required before - autoclosure review +- 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 + owning local proof passes apart from the explicitly waived unrelated Expo + fixture drift Implementation readiness: -- verdict: repair-source +- verdict: ready - exact owner: `packages/kitcn` auth-client and React package code in PR #473 -- contradiction status: no contradiction found during bounded intake; full - source and test review begins only after the evidence repair reaches the head -- source-listed cases complete: extracted below; proof is pending +- 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 @@ -139,19 +142,23 @@ Pre-solution issue challenge: shared token-fetch boundary, guarded HTTP token access, Convex-owned optimistic updates, and injected server logging - repro ladder: - - tests / source-level repro: existing focused tests are present; review and - execution are pending after evidence repair +- 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: pending -- validity verdict: pending +- 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: pending -- hard-stop decision: continue only after exact-PR task evidence is committed, - pushed, and read back at the live head +- 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 @@ -196,138 +203,137 @@ Start Gates: | Package build / fixture impact decision recorded | yes | Package build applies and passes. Fixture generation is N/A to the PR; the user explicitly waived only the unrelated live Expo template drift lane. | Work Checklist: -- [ ] If a duration was requested, it is recorded as minimum active work unless +- [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. -- [ ] Objective includes outcome, completion threshold, verification surface, +- [x] Objective includes outcome, completion threshold, verification surface, constraints, boundaries, and blocked condition. -- [ ] Task source classified with source type, id/link, title, task type, +- [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. -- [ ] Every GitHub PR in scope has its own task plan. This plan owns one exact +- [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. -- [ ] Required video or screen-recording evidence is cached/read as normalized +- [x] Required video or screen-recording evidence is cached/read as normalized `` XML, or marked N/A with reason. -- [ ] For public GitHub bug reports, behavior claims, technical diagnoses, or +- [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. -- [ ] Repro escalation ladder followed for bug/behavior claims: focused +- [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. -- [ ] Hard-stop rule followed for bug/behavior claims: no code when the issue +- [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. -- [ ] Nearby repo instructions and implementation patterns read before edits. -- [ ] Source-listed case matrix is complete and every contradiction has an +- [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. -- [ ] Readiness is classified `ready`, `repair-source`, `major`, `blocked`, or +- [x] Readiness is classified `ready`, `repair-source`, `major`, `blocked`, or `invalid` with evidence. -- [ ] Implementation fixes the right ownership boundary, or the narrower choice +- [x] Implementation fixes the right ownership boundary, or the narrower choice is recorded with reason. -- [ ] Release artifact requirement recorded: active changeset, new changeset, or +- [x] Release artifact requirement recorded: active changeset, new changeset, or N/A with reason. -- [ ] Final handoff shape decided: bug/feature/testing/batch/review/GitHub +- [x] Final handoff shape decided: bug/feature/testing/batch/review/GitHub requirements, PR body sync, and issue sync when applicable. -- [ ] Commit/PR handling recorded for code-changing work: commit and PR +- [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. -- [ ] PR body shape recorded: PR #270 emoji task-style body used, N/A reason +- [x] PR body shape recorded: PR #270 emoji task-style body used, N/A reason recorded, or blocker recorded. -- [ ] PR task evidence recorded: body includes `๐Ÿงญ Task plan: ...`, the plan +- [x] PR task evidence recorded: body includes `๐Ÿงญ Task plan: ...`, the plan exists at the PR head, and it identifies the exact PR before autoclosure. -- [ ] Branch handling recorded for code-changing work: dedicated branch used, +- [x] Branch handling recorded for code-changing work: dedicated branch used, new branch needed, or N/A with reason. -- [ ] Local-env-rot retry policy recorded for any surprising repo-wide failure: +- [x] Local-env-rot retry policy recorded for any surprising repo-wide failure: reinstall/rerun evidence or N/A with reason. -- [ ] Workspace authority recorded: every proof command names the cwd/tool that +- [x] Workspace authority recorded: every proof command names the cwd/tool that owns the changed behavior. -- [ ] Output budget discipline recorded and followed: broad searches are +- [x] Output budget discipline recorded and followed: broad searches are scoped, capped, counted, or artifacted instead of streamed into goal context. -- [ ] High-risk note recorded for public API, runtime, package-boundary, +- [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. -- [ ] Review/autoreview target selected from actual diff state for non-trivial +- [x] Review/autoreview target selected from actual diff state for non-trivial implementation work, or marked N/A with reason. -- [ ] Agent-native review decision recorded for `.agents/**`, `.claude/**`, +- [x] Agent-native review decision recorded for `.agents/**`, `.claude/**`, `.codex/**`, skills, hooks, commands, prompts, or user-action tooling. -- [ ] Package/API pack: public API, package boundary, export, and release-artifact impact are recorded. -- [ ] Package/API pack: release artifact matrix is applied: `.changeset` or explicit no-artifact reason. -- [ ] Package/API pack: `.changeset` work loads `changeset` and follows its package/version/prose rules. -- [ ] Package/API pack: no-artifact decisions state why the diff has no published package user-visible delta from `main`. -- [ ] Package/API pack: compatibility, migration, or hard-cut decision is explicit when public shape changes. -- [ ] Package/API pack: affected Convex static import graphs stay narrow and +- [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. -- [ ] Package/API pack: CLI commands remain deterministic, `--json` capable, - and non-interactive with explicit confirmation bypass when relevant. -- [ ] Package/API pack: docs and `packages/kitcn/skills/kitcn/**` stay +- [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. -- [ ] Package/API pack: package-owned typecheck/build/test proof is recorded or marked N/A with reason. -- [ ] Package/API pack: `packages/kitcn` build, fixture sync/check, or other owning package proof is recorded when required. +- [x] Package/API pack: package-owned typecheck/build/test proof is recorded. +- [x] Package/API pack: `packages/kitcn` build passed; fixture generation is N/A to this diff and the unrelated Expo drift is explicitly waived. Completion Gates: | Gate | Applies | Required action | Evidence | |------|---------|-----------------|----------| -| Named verification threshold | pending | Run the command, proof, source audit, or artifact check named in this plan | pending | -| Exact per-PR task ownership | pending | Record the exact PR and dedicated plan, or the not-yet-created single-PR slice | pending | -| Pre-solution issue challenge verdict | pending | Record reporter claim, suggested fix, repro verdict, validity verdict, durable boundary, and hard-stop/pivot decision before implementation | pending | -| Repro escalation ladder | pending | For bug/behavior claims, record test/source-level, automated browser/integration, Browser, and screenshot/visual-proof outcomes or N/A/blocker reasons before `not reproduced` | pending | -| Bug reproduced before fix | pending | Record failing test/repro or N/A with reason | pending | -| Targeted behavior verification | pending | Run focused test/proof for changed behavior or record N/A | pending | -| TypeScript or typed config changed | pending | Run relevant typecheck | pending | -| Package exports or file layout changed | pending | Run the relevant package build before final verification and keep generated updates | pending | -| Package manifests, lockfile, or install graph changed | pending | Run `bun install` and relevant package checks | pending | -| Agent rules or skills changed | pending | Run `bun install` and verify generated skill sync | pending | -| Workspace authority proof | pending | Run verification in the owning repo/package/app/route/tool and record cwd; do not count the wrong workspace as proof | pending | -| Browser surface changed | pending | Capture Browser Use proof or record explicit waiver/blocker | pending | -| Browser final proof | pending | Attach screenshot or exact browser verification caveat when browser proof applies | pending | -| UI walkthrough | pending | If UI or rendered output changed, run `.agents/skills/walkthrough/SKILL.md` after final proof and show annotated images in the final handoff; otherwise record N/A | pending | -| Scaffold or fixture output changed | pending | Run `bun run fixtures:sync` and `bun run fixtures:check`, or record N/A | pending | -| Package behavior or public API changed | pending | Add a changeset or record why no changeset applies | pending | -| Docs and kitcn skill sync changed | pending | Keep `www/**` and `packages/kitcn/skills/kitcn/**` in sync, or record N/A | pending | -| Docs or content changed | pending | For docs-heavy work, use `--template docs`; for incidental docs, verify source-backed claims, links, examples, and rendered output or record N/A | pending | -| High-risk mini gate | pending | For public API/runtime/package-boundary/browser/agent-action/command-contract changes, record realistic failure mode, proof plan, and why the chosen boundary is right; otherwise N/A | pending | -| Agent-native review for agent/tooling changes | pending | For `.agents/**`, `.claude/**`, `.codex/**`, skills, hooks, commands, prompts, or user-action tooling, load `.agents/skills/agent-native-reviewer/SKILL.md` and close accepted/actionable findings, or record N/A | pending | -| Local install corruption suspected | pending | Run `bun install` once, rerun the exact failing command, or record N/A | pending | -| Commit created | pending | For verified code-changing work, stage the entire current checkout per repo policy and create a commit; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | pending | -| PR create or update | pending | For verified code-changing work, run `check`, push, create or update the PR, and sync PR body to the task-style final handoff; N/A only for no local patch, explicit user decline, analytical/blocked/inconclusive work, or recorded external blocker | pending | -| Task-style PR body verified | pending | Verify the PR body with `gh pr view --json body`; it must preserve auto-release blocks when applicable, must not include a current-PR self-link, and must use the PR #270 emoji format: `๐Ÿ› Fixes ...`, `๐ŸŸข 95-100% confidence`, `Phase / ๐Ÿงช Tests / ๐ŸŒ Browser` table, and bold emoji Outcome/Caveat/Design/Verified sections | pending | -| PR task evidence verified | pending | Verify body plan line, plan at PR head, and exact PR ownership | pending | -| PR proof image hosting | pending | If PR body needs browser proof, replace local image paths with hosted GitHub URLs or record N/A | pending | -| GitHub issue sync-back | pending | Post concise issue sync after PR exists, or record N/A/blocker | pending | -| Final handoff contract | pending | Fill the final handoff fields below with exact PR/issue/confidence/tests/browser/outcome/caveats/design/verification content or N/A reason | pending | -| Final lint | pending | Run `bun lint:fix` or scoped equivalent | pending | -| Output budget discipline | pending | Verify no unbounded high-volume command output was streamed, or record the accidental output and recovery | pending | -| Timed checkpoint | pending | If duration was requested, keep improving until elapsed, then finish the current loop cleanly; otherwise N/A | pending | -| Autoreview for non-trivial implementation changes | pending | Load `.agents/skills/autoreview/SKILL.md`; use dirty local `--mode local`, branch/PR `--mode branch --base `, or committed slice `--mode commit --commit ` until no accepted/actionable findings, or record N/A for docs-only/trivial/no local patch | pending | -| Goal plan complete | yes | Run `node .agents/skills/autogoal/scripts/check-complete.mjs docs/plans/473-optimistic-auth-gate.md` | pending | -| Public API / package boundary proof | pending | Source-audit public API, exports, and package boundary impact | pending | -| Convex bundle/import proof | pending | Audit affected function-entry static graphs or record N/A | pending | -| CLI/scaffold/generated proof | pending | Prove command contract and regenerate owned output or record N/A | pending | -| Release artifact classification | pending | Record whether the change is published package behavior/API/types/config/runtime or no published user-visible delta | pending | -| Published package changeset | pending | If published package users see a delta, load `changeset` and add/update one `.changeset/*.md` per package | pending | -| No release artifact | pending | If no artifact is needed, record the exact reason: internal-only, docs-only, agent-only, test-only, or no user-visible delta from `main` | pending | -| Package typecheck/build/test | pending | Run owning package checks or record N/A with reason | pending | -| Fixture/scaffold generation | pending | Run `bun run fixtures:sync` and `bun run fixtures:check` when scaffold output changed, otherwise N/A | pending | -| Docs/package skill sync | pending | Synchronize current-state public guidance or record N/A | pending | +| 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 | no | N/A | No manifest, lockfile, or install-graph delta. | +| 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 | no | N/A | No scaffold/fixture source changed; user waived only unrelated moving Expo SDK 55 drift. | +| 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 | no | N/A | No agent action or workflow changed. | +| 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 | no | N/A | No CLI or scaffold contract changed; package skill mirror regenerated from source. | +| 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 | no | N/A | No fixture/scaffold delta; unrelated Expo drift was explicitly waived. | +| 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 | in_progress | recovery fix is live; source review remains | verification | -| Verification | pending | | closeout | -| Commit / PR / GitHub sync | pending | | final response | -| Closeout | pending | | final response | +| 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 @@ -338,10 +344,18 @@ Findings: without any PR #473 scaffold or fixture edit. Decisions and tradeoffs: -- None yet. +- 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: -- None yet. +- `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 @@ -364,35 +378,45 @@ Verification evidence: - 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 full Bun test lane inside `bun check` passes 1,473 tests. The final fixture lane fails only on upstream Expo SDK 55 guidance drift outside the PR diff. +- 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. +- `bun check` reaches only the explicitly waived upstream Expo SDK 55 guidance + drift outside the PR diff after every owning package and test gate passes. 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 | implemented at intake; independent review pending | every named state passes | pending after evidence recovery | pending | -| 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 | implemented at intake; independent review pending | every named identity transition passes | pending after evidence recovery | pending | -| guarded HTTP token | HTTP headers use the same guarded fetcher and omit a refused token | `context.test.tsx` focused cases | implemented at intake; independent review pending | guarded token or no header | pending after evidence recovery | pending | -| optimistic mutation | `optimisticUpdate` reaches Convex `withOptimisticUpdate` and not TanStack options | `use-query-options.test.tsx` focused cases and typecheck | implemented at intake; independent review pending | Convex owns update and rollback | pending after evidence recovery | pending | -| 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,473-test Bun suite pass | green | +| 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: pending -- PR line: pending -- Issue line: pending -- Confidence line: pending +- 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: tests pending, browser pending - - Verified: tests pending, browser pending -- Browser check: pending -- Outcome: pending -- Caveat: pending + - 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: unrelated moving Expo SDK 55 fixture drift is user-waived. GitHub CI + and a post-push code-owner approval remain root-autoclosure gates. - Design: - - Chosen boundary: pending - - Why not quick patch: pending - - Why not broader change: pending -- Verified: pending -- PR body verified: pending + - 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 @@ -416,11 +440,12 @@ Task-style PR body contract: of that output. Final handoff / sync: -- Commit: pending -- PR: pending -- Issue: pending -- Browser proof: pending -- Caveats: pending +- 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. @@ -429,7 +454,7 @@ Timeline: 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,473-test Bun lane. The repository gate stops on unrelated Expo fixture + 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 From a2a007d5f789f262dd73d92225d9052f33b92823 Mon Sep 17 00:00:00 2001 From: zbeyens Date: Wed, 30 Sep 2026 13:38:03 +0200 Subject: [PATCH 8/8] fix(fixtures): normalize Expo snapshots and refresh donor output --- docs/plans/473-autoclosure.md | 65 +++++++++----- docs/plans/473-optimistic-auth-gate.md | 106 +++++++++++++++++------ fixtures/expo-auth/.claude/settings.json | 5 -- fixtures/expo-auth/AGENTS.md | 42 ++++++++- fixtures/expo-auth/CLAUDE.md | 1 - fixtures/expo/.claude/settings.json | 5 -- fixtures/expo/AGENTS.md | 42 ++++++++- fixtures/expo/CLAUDE.md | 1 - fixtures/next-auth/package.json | 4 +- fixtures/next/package.json | 4 +- fixtures/start-auth/package.json | 4 +- fixtures/start/package.json | 4 +- fixtures/vite-auth/package.json | 4 +- fixtures/vite/package.json | 4 +- tooling/fixtures.test.ts | 46 ++++++++++ tooling/fixtures.ts | 13 ++- 16 files changed, 268 insertions(+), 82 deletions(-) delete mode 100644 fixtures/expo-auth/.claude/settings.json delete mode 100644 fixtures/expo-auth/CLAUDE.md delete mode 100644 fixtures/expo/.claude/settings.json delete mode 100644 fixtures/expo/CLAUDE.md diff --git a/docs/plans/473-autoclosure.md b/docs/plans/473-autoclosure.md index 54a2c5260..7ebea3bea 100644 --- a/docs/plans/473-autoclosure.md +++ b/docs/plans/473-autoclosure.md @@ -25,6 +25,10 @@ Linked plans: 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. @@ -35,15 +39,15 @@ Completion threshold: 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 unrelated - moving Expo fixture lane has the user's exact waiver recorded below. + 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, the exact unrelated fixture waiver, review findings closed, + 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. @@ -63,6 +67,9 @@ Constraints: - 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 @@ -92,17 +99,17 @@ Start Gates: | 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, changeset, source behavior, feedback, review, repository check, and GitHub delivery apply; fixtures, UI, and agent workflow are currently N/A | +| 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 | Active goal points to this plan and names the task-evidence, feedback, proof, check, merge, and receipt threshold | +| 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 | no | The PR does not change rules, skills, prompts, commands, or agent actions; the plan only records this run | -| Source rule versus generated mirror boundary identified | no | No agent source or generated mirror belongs to PR #473 | +| 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 | no | N/A because the PR does not change agent or tooling behavior | +| `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 | @@ -114,13 +121,13 @@ Closure matrix: | 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 | no | N/A: no scaffold source changed; unrelated Expo drift explicitly waived | 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 | all owning lanes pass; required GitHub `CI` still fails on the unrelated Expo SDK 55 fixture drift | blocked | +| 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: @@ -214,8 +221,8 @@ Work Checklist: - [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] Residual waiver has exact evidence and owner: unrelated Expo drift is - user-waived; GitHub owns protected CI/review gates. +- [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; @@ -251,9 +258,9 @@ Completion Gates: | 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 | no | N/A | No agent workflow changed; direct ownership/mirror/discoverability audit is clean. | +| 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 | Local owning lanes pass, but required GitHub `CI` fails on waived moving Expo SDK 55 fixture drift: run 36644341283. | +| 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. | @@ -269,10 +276,23 @@ Phase / pass table: | 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 | blocked | required GitHub `CI` fails on unrelated Expo fixture drift; code-owner approval is also required | separate fixture repair or external rules change | +| 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. @@ -345,22 +365,19 @@ Timeline: Reboot status: | Question | Answer | | --- | --- | -| Where am I? | Local/source closeout is complete; protected GitHub delivery is blocked by the unrelated fixture failure. | -| Where am I going? | Resume only after a separately scoped fixture repair clears required `CI`, then obtain code-owner approval, bind the receipt, guarded-merge, and confirm landing. | +| 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: -- `bun check` is not green because the external Expo template no longer matches - committed fixture guidance. The user explicitly waived only that unrelated - lane for PR #473; all other gates remain mandatory. +- 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` is red at run 36644341283 on the same unrelated Expo - fixture drift. The user's waiver closes the local scope decision, not the - server-enforced status requirement. Clearing it needs a separate fixture - repair or a repository-owner rules change; this run will not bypass it. +- 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. diff --git a/docs/plans/473-optimistic-auth-gate.md b/docs/plans/473-optimistic-auth-gate.md index 59ca11de3..80ec9e535 100644 --- a/docs/plans/473-optimistic-auth-gate.md +++ b/docs/plans/473-optimistic-auth-gate.md @@ -24,6 +24,10 @@ 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. @@ -86,6 +90,11 @@ Constraints: - 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 @@ -111,10 +120,10 @@ 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: closeout -- current_phase_status: complete at the final material head -- next_phase: root autoclosure receipt and merge -- goal_status: complete; root autoclosure owns external landing +- 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 @@ -123,8 +132,8 @@ Current verdict: - next owner: root autoclosure - reason: source review repaired the identity callback ordering, getter admission semantics, public types, documentation, and test isolation; every - owning local proof passes apart from the explicitly waived unrelated Expo - fixture drift + 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 @@ -197,10 +206,10 @@ Start Gates: | 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 | no | Immutable PR diff changes no CLI, scaffold, fixture, generated, or tooling owner | +| 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 applies and passes. Fixture generation is N/A to the PR; the user explicitly waived only the unrelated live Expo template drift lane. | +| 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 @@ -278,7 +287,7 @@ Work Checklist: - [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; fixture generation is N/A to this diff and the unrelated Expo drift is explicitly waived. +- [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 | @@ -291,18 +300,18 @@ Completion Gates: | 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 | no | N/A | No manifest, lockfile, or install-graph delta. | +| 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 | no | N/A | No scaffold/fixture source changed; user waived only unrelated moving Expo SDK 55 drift. | +| 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 | no | N/A | No agent action or workflow changed. | +| 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. | @@ -318,12 +327,12 @@ Completion Gates: | 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 | no | N/A | No CLI or scaffold contract changed; package skill mirror regenerated from source. | +| 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 | no | N/A | No fixture/scaffold delta; unrelated Expo drift was explicitly waived. | +| 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: @@ -352,6 +361,27 @@ Decisions and tradeoffs: 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, @@ -374,6 +404,21 @@ Error attempts: | `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. @@ -382,8 +427,16 @@ Verification evidence: - 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. -- `bun check` reaches only the explicitly waived upstream Expo SDK 55 guidance - drift outside the PR diff after every owning package and test gate passes. +- 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 | @@ -407,8 +460,8 @@ Final handoff contract: - 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: unrelated moving Expo SDK 55 fixture drift is user-waived. GitHub CI - and a post-push code-owner approval remain root-autoclosure gates. +- 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. @@ -477,18 +530,17 @@ Timeline: Reboot status: | Question | Answer | |----------|--------| -| Where am I? | Exact-PR recovery is complete; implementation review is active. | -| Where am I going? | Push the plan and test-isolation fix, update the PR body, then return to autoclosure review. | +| 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? | The implementation is substantive. One unrelated process-global test mock broke the branch's server-client tests. Expo fixture drift remains outside the PR diff. | -| What have I done? | Created the dedicated task plan, fixed the test leak, and passed the focused tests, package typecheck, package build, lint, and full Bun suite. | +| 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: -- `bun check` is red only because the current external Expo template differs - from committed fixture guidance. The user explicitly waived only that - unrelated lane for PR #473; every other proof and GitHub gate remains active. -- The public auth and React API diff still needs independent source review, - full live feedback triage, and final exact-head proof after recovery. +- 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. 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/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 = {