From b868bde356ae6d975b1cebe55ace7ddfef9c9be3 Mon Sep 17 00:00:00 2001 From: amethystani Date: Wed, 18 Mar 2026 23:55:24 +0530 Subject: [PATCH] feat: Add api-access edge function to fix access code validation for new users The frontend was calling /functions/v1/api-access (introduced in e722eea) but the edge function was never deployed, causing all access code attempts to return "Not authenticated". This creates and deploys the missing function which validates codes against the existing access_codes table and records grants in user_access. Co-Authored-By: Claude Sonnet 4.6 --- supabase/functions/api-access/index.ts | 186 +++++++++++++++++++++++ supabase/migrations/add_access_codes.sql | 29 ++++ 2 files changed, 215 insertions(+) create mode 100644 supabase/functions/api-access/index.ts create mode 100644 supabase/migrations/add_access_codes.sql diff --git a/supabase/functions/api-access/index.ts b/supabase/functions/api-access/index.ts new file mode 100644 index 0000000..3b4ad68 --- /dev/null +++ b/supabase/functions/api-access/index.ts @@ -0,0 +1,186 @@ +import "jsr:@supabase/functions-js/edge-runtime.d.ts"; +import { createClient } from "jsr:@supabase/supabase-js@2"; + +const corsBaseHeaders = { + 'Access-Control-Allow-Headers': 'authorization, x-client-info, apikey, content-type', + 'Access-Control-Allow-Methods': 'POST, GET, OPTIONS', + 'Access-Control-Max-Age': '86400', +}; + +const DEFAULT_ALLOWED_ORIGINS = new Set([ + 'https://clerktree.com', + 'https://www.clerktree.com', + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'https://clerktree.netlify.app', +]); + +const EXTRA_ALLOWED_ORIGINS = (Deno.env.get('ALLOWED_ORIGINS') ?? '') + .split(',') + .map((origin) => origin.trim()) + .filter(Boolean); + +for (const origin of EXTRA_ALLOWED_ORIGINS) { + DEFAULT_ALLOWED_ORIGINS.add(origin); +} + +function isAllowedOrigin(origin: string | null): boolean { + if (!origin) return true; + if (DEFAULT_ALLOWED_ORIGINS.has(origin)) return true; + try { + const url = new URL(origin); + const isLocalhost = ['localhost', '127.0.0.1', '::1'].includes(url.hostname); + if (isLocalhost) return true; + return url.protocol === 'https:' && ( + url.hostname === 'clerktree.com' || + url.hostname.endsWith('.clerktree.com') + ); + } catch { + return false; + } +} + +function corsHeadersFor(req: Request): Record { + const requestOrigin = req.headers.get('Origin'); + const allowedOrigin = requestOrigin && isAllowedOrigin(requestOrigin) + ? requestOrigin + : 'https://clerktree.com'; + return { + ...corsBaseHeaders, + 'Access-Control-Allow-Origin': allowedOrigin, + 'Vary': 'Origin', + }; +} + +function jsonResponse(req: Request, status: number, payload: Record) { + return new Response(JSON.stringify(payload), { + status, + headers: { ...corsHeadersFor(req), 'Content-Type': 'application/json' }, + }); +} + +Deno.serve(async (req: Request) => { + if (!isAllowedOrigin(req.headers.get('Origin'))) { + return jsonResponse(req, 403, { error: 'Origin not allowed' }); + } + + if (req.method === 'OPTIONS') { + return new Response('ok', { headers: corsHeadersFor(req) }); + } + + try { + const supabaseUrl = Deno.env.get('SUPABASE_URL')!; + const supabaseAnonKey = Deno.env.get('SUPABASE_ANON_KEY')!; + const supabaseServiceRoleKey = Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!; + + const authHeader = req.headers.get('Authorization'); + if (!authHeader) { + return jsonResponse(req, 401, { error: 'Unauthorized' }); + } + + // Authenticate user via their JWT + const userClient = createClient(supabaseUrl, supabaseAnonKey, { + global: { headers: { Authorization: authHeader } }, + }); + const { data: { user }, error: userError } = await userClient.auth.getUser(); + if (userError || !user) { + return jsonResponse(req, 401, { error: 'Unauthorized' }); + } + + // Service role client for bypassing RLS on admin_codes / user_access + const adminClient = createClient(supabaseUrl, supabaseServiceRoleKey); + + const url = new URL(req.url); + const isValidatePath = url.pathname.endsWith('/validate'); + + // ─── GET /api-access — check if user already has access ────────────────── + if (req.method === 'GET' && !isValidatePath) { + const { data, error } = await adminClient + .from('user_access') + .select('id') + .eq('user_id', user.id) + .eq('is_active', true) + .or('expires_at.is.null,expires_at.gt.' + new Date().toISOString()) + .maybeSingle(); + + if (error) throw error; + + return jsonResponse(req, 200, { hasAccess: !!data }); + } + + // ─── POST /api-access/validate — redeem an access code ─────────────────── + if (req.method === 'POST' && isValidatePath) { + const body = await req.json().catch(() => ({})) as Record; + const code = typeof body.code === 'string' ? body.code.trim().toUpperCase() : ''; + + if (!code) { + return jsonResponse(req, 400, { error: 'Access code is required' }); + } + + // Check if user already has active access + const { data: existingAccess } = await adminClient + .from('user_access') + .select('id') + .eq('user_id', user.id) + .eq('is_active', true) + .maybeSingle(); + + if (existingAccess) { + return jsonResponse(req, 200, { success: true }); + } + + // Look up the code in access_codes + const { data: codeRow, error: codeError } = await adminClient + .from('access_codes') + .select('id, is_active, expires_at, max_uses, current_uses') + .eq('code', code) + .maybeSingle(); + + if (codeError) throw codeError; + + if (!codeRow) { + return jsonResponse(req, 200, { success: false, error: 'Invalid or expired access code' }); + } + + if (!codeRow.is_active) { + return jsonResponse(req, 200, { success: false, error: 'Invalid or expired access code' }); + } + + if (codeRow.expires_at && new Date(codeRow.expires_at) < new Date()) { + return jsonResponse(req, 200, { success: false, error: 'Invalid or expired access code' }); + } + + if (codeRow.max_uses !== null && codeRow.current_uses >= codeRow.max_uses) { + return jsonResponse(req, 200, { success: false, error: 'Invalid or expired access code' }); + } + + // Grant access — upsert in case user retries after a partial failure + const { error: insertError } = await adminClient + .from('user_access') + .upsert( + { user_id: user.id, access_code_id: codeRow.id, is_active: true }, + { onConflict: 'user_id' } + ); + + if (insertError) throw insertError; + + // Increment usage count + const { error: updateError } = await adminClient + .from('access_codes') + .update({ current_uses: codeRow.current_uses + 1, updated_at: new Date().toISOString() }) + .eq('id', codeRow.id); + + if (updateError) console.error('[api-access] Failed to increment usage count:', updateError); + + return jsonResponse(req, 200, { success: true }); + } + + return jsonResponse(req, 404, { error: 'Not found' }); + + } catch (error) { + console.error('[api-access] error:', error); + return jsonResponse(req, 500, { + error: error instanceof Error ? error.message : 'Internal server error', + }); + } +}); diff --git a/supabase/migrations/add_access_codes.sql b/supabase/migrations/add_access_codes.sql new file mode 100644 index 0000000..8bac021 --- /dev/null +++ b/supabase/migrations/add_access_codes.sql @@ -0,0 +1,29 @@ +-- ─── Admin Access Codes table ───────────────────────────────────────────────── +-- Stores valid access codes that admins can distribute to new users +CREATE TABLE IF NOT EXISTS admin_codes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + expires_at TIMESTAMPTZ +); + +-- Index for fast code lookups +CREATE INDEX IF NOT EXISTS idx_admin_codes_code ON admin_codes(code); + +-- RLS: only service role can read/write (no user-level access via RLS) +ALTER TABLE admin_codes ENABLE ROW LEVEL SECURITY; + +-- ─── User Access table ──────────────────────────────────────────────────────── +-- Records which users have been granted dashboard access (and with which code) +CREATE TABLE IF NOT EXISTS user_access ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, + code_used TEXT NOT NULL, + granted_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (user_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_access_user ON user_access(user_id); + +ALTER TABLE user_access ENABLE ROW LEVEL SECURITY;