Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 25 additions & 13 deletions src/app/actions/profile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,19 +69,31 @@ export async function bootstrapProfile(): Promise<Result<BootstrapOutput>> {

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
Expand Down
47 changes: 25 additions & 22 deletions src/app/actions/recommendations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,29 +112,32 @@ export async function claimRecommendation(recId: number): Promise<Result<{ id: n
});
if (!rateRes.ok) return err('rate_limited', 'slow down', true);

// Enforce 3-claim limit: count currently claimed recs before allowing a new one.
const { count: claimedCount } = await service
.from('recommendations')
.select('id', { count: 'exact', head: true })
.eq('user_id', user.id)
.eq('status', 'claimed');
if ((claimedCount ?? 0) >= 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}`);
Expand All @@ -146,7 +149,7 @@ export async function claimRecommendation(recId: number): Promise<Result<{ id: n
detail: { recId } as never,
});

return ok({ id: data.id });
return ok({ id: claimedId });
}

const PR_URL_RE = /^https:\/\/github\.com\/[^/]+\/[^/]+\/pull\/\d+$/;
Expand Down
16 changes: 10 additions & 6 deletions src/inngest/functions/audit-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,15 +11,18 @@ const AUDIT_MAX_LEVEL = 2;

/**
* Audit pipeline. Fires once per user, ideally right after their GitHub App
* install lands. Idempotent duplicate runs silently no-op because xp_events
* install lands. Idempotent -- duplicate runs silently no-op because xp_events
* has UNIQUE(user_id, source, ref_id) keyed off github_id.
*
* Auth precedence:
* 1. event.data.accessToken (user OAuth token, freshly minted at sign-in)
* — used when audit is fired during the bootstrap flow.
* 2. install token minted from event.data.installationId
* — used when audit is fired from the install webhook handler. Install
* tokens are minted on demand from the App JWT and don't expire on us.
* 1. installationId provided in the event -- an install token is minted on
* demand from the App JWT. These are short-lived and never persist in
* event storage, so they are preferred.
* 2. No installationId -- the function queries github_installations at
* execution time. This covers the common case where the audit is queued
* at bootstrap before the install webhook arrives.
* 3. accessToken -- retained for backward compatibility with events already
* in the queue. New events no longer include this field.
*
* The function does everything in a single step.run so retries replay the
* whole pipeline. Combined with the xp_events UNIQUE constraint, no zombie
Expand All @@ -31,6 +34,7 @@ type AuditEvent = {
userId: string;
githubHandle: string;
githubId: string;
/** @deprecated No longer sent by bootstrap flow. Retained for events already in the queue. */
accessToken?: string;
installationId?: number;
};
Expand Down
48 changes: 48 additions & 0 deletions supabase/migrations/0012_claim_recommendation_atomic.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
-- Migration: atomic claim function to close TOCTOU race condition (issue #205)
--
-- The previous TypeScript implementation enforced the 3-claim cap with a COUNT
-- query followed by a separate UPDATE. Because these are two independent database
-- round-trips, concurrent requests for the same user can both read count < 3,
-- both pass the guard, and both commit, allowing a user to exceed 3 active claims.
--
-- This function moves both operations into a single UPDATE statement. The
-- subquery that checks the count and the row that gets written are evaluated
-- atomically by the database engine, so concurrent calls are serialised at
-- the row level. If the count is already 3 when the UPDATE runs, zero rows
-- are returned and the claim is rejected without a separate round-trip.

CREATE OR REPLACE FUNCTION public.claim_recommendation_atomic(
p_rec_id bigint,
p_user_id uuid
)
RETURNS TABLE(id bigint)
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = public
AS $$
BEGIN
RETURN QUERY
UPDATE recommendations AS r
SET
status = 'claimed',
claimed_at = now()
WHERE
r.id = p_rec_id
AND r.user_id = p_user_id
AND r.status = 'open'
AND (
SELECT COUNT(*)
FROM recommendations r2
WHERE r2.user_id = p_user_id
AND r2.status = 'claimed'
) < 3
RETURNING r.id;
END;
$$;

-- Grant execute to authenticated users (server actions run under their session)
-- and to the service role (used by Inngest functions and webhook handlers).
GRANT EXECUTE ON FUNCTION public.claim_recommendation_atomic(bigint, uuid)
TO authenticated;
GRANT EXECUTE ON FUNCTION public.claim_recommendation_atomic(bigint, uuid)
TO service_role;
Loading