diff --git a/src/app/actions/profile.ts b/src/app/actions/profile.ts index 7f80828..d928096 100644 --- a/src/app/actions/profile.ts +++ b/src/app/actions/profile.ts @@ -69,19 +69,31 @@ export async function bootstrapProfile(): Promise> { let auditQueued = false; if (!profile.audit_completed) { - const providerToken = (await sb.auth.getSession()).data.session?.provider_token; - if (providerToken) { - await inngest.send({ - name: 'audit/run', - data: { - userId: profile.id, - githubHandle: profile.github_handle, - githubId, - accessToken: providerToken, - }, - }); - auditQueued = true; - } + // Look up the user's active GitHub App installation. Passing the installation + // ID lets audit-run mint a short-lived install token at execution time rather + // than embedding a long-lived OAuth token in the Inngest event payload (which + // is persisted to third-party storage). If no installation exists yet the + // function performs the same DB lookup at execution time and may find one by + // then; if still nothing it exits cleanly with reason 'no_auth_source'. + const { data: installs } = await service + .from('github_installations') + .select('id') + .eq('user_id', profile.id) + .is('uninstalled_at', null) + .order('installed_at', { ascending: false }) + .limit(1); + const installationId = (installs?.[0]?.id as number | undefined) ?? undefined; + + await inngest.send({ + name: 'audit/run', + data: { + userId: profile.id, + githubHandle: profile.github_handle, + githubId, + installationId, + }, + }); + auditQueued = true; } // Fire-and-forget maintainer discovery so this user picks up admin diff --git a/src/app/actions/recommendations.ts b/src/app/actions/recommendations.ts index cd8253f..1136b72 100644 --- a/src/app/actions/recommendations.ts +++ b/src/app/actions/recommendations.ts @@ -112,29 +112,32 @@ export async function claimRecommendation(recId: number): Promise= 3) { - return err('claim_limit', 'you already have 3 active claims - merge or close them first'); - } + // Atomic claim: the count check and the write are merged into a single UPDATE + // inside claim_recommendation_atomic (see migration 0012). This eliminates the + // TOCTOU window that existed when they were separate round-trips -- concurrent + // requests can no longer both pass a count of 2 and both commit, because the + // subquery that evaluates the count and the row that gets written are handled + // atomically by the database engine. + // + // Zero rows returned means one of two things: the user already holds 3 active + // claims, or this specific rec is no longer open. Both outcomes are safe to + // surface with the same error because the UI re-fetches state after either. + const { data: rpcData, error: rpcErr } = await service.rpc('claim_recommendation_atomic', { + p_rec_id: recId, + p_user_id: user.id, + }); - // Atomic claim: UPDATE ... WHERE status='open' AND user_id=auth.uid() - // Zero rows affected = already claimed or doesn't exist. - const { data, error: updateErr } = await service - .from('recommendations') - .update({ status: 'claimed', claimed_at: new Date().toISOString() }) - .eq('id', recId) - .eq('user_id', user.id) - .eq('status', 'open') - .select('id') - .maybeSingle(); + if (rpcErr) return err('persist_failed', rpcErr.message); - if (updateErr) return err('persist_failed', updateErr.message); - if (!data) return err('already_claimed', 'this rec is no longer open'); + const rows = rpcData as Array<{ id: number }> | null; + if (!rows || rows.length === 0) { + return err( + 'claim_limit_or_not_open', + 'claim rejected: you may already have 3 active claims, or this rec is no longer open', + ); + } + + const claimedId = rows[0]!.id; // Invalidate cache so next dashboard load is fresh. await cacheDel(`recs:${user.id}`); @@ -146,7 +149,7 @@ export async function claimRecommendation(recId: number): Promise