     1→-- Add display_name column to profiles for anonymous pre-hire worker identity
     2→ALTER TABLE profiles ADD COLUMN IF NOT EXISTS display_name text;
     3→
     4→-- Drop and recreate get_conversation_previews (return type changed)
     5→DROP FUNCTION IF EXISTS get_conversation_previews(uuid, integer, integer);
     6→CREATE OR REPLACE FUNCTION get_conversation_previews(
     7→  user_id uuid,
     8→  page_limit integer DEFAULT 20,
     9→  page_offset integer DEFAULT 0
    10→)
    11→RETURNS TABLE (
    12→  conversation_id uuid,
    13→  other_participant_name text,
    14→  other_participant_avatar text,
    15→  last_message text,
    16→  last_message_time timestamptz,
    17→  unread_count bigint,
    18→  other_participant_is_online boolean,
    19→  other_participant_display_name text,
    20→  other_participant_type text
    21→) AS $$
    22→BEGIN
    23→  RETURN QUERY
    24→  SELECT
    25→    c.id as conversation_id,
    26→    p.name as other_participant_name,
    27→    p.avatar_url as other_participant_avatar,
    28→    last_msg.content as last_message,
    29→    last_msg.created_at as last_message_time,
    30→    COALESCE(unread.unread_count, 0) as unread_count,
    31→    COALESCE(p.last_seen_at > (now() - interval '5 minutes'), false) as other_participant_is_online,
    32→    p.display_name as other_participant_display_name,
    33→    p.type::text as other_participant_type
    34→  FROM conversations c
    35→  INNER JOIN conversation_participants cp ON c.id = cp.conversation_id
    36→  INNER JOIN conversation_participants other_cp ON c.id = other_cp.conversation_id
    37→  INNER JOIN profiles p ON other_cp.profile_id = p.id
    38→  LEFT JOIN LATERAL (
    39→    SELECT content, created_at
    40→    FROM messages
    41→    WHERE conversation_id = c.id
    42→    ORDER BY created_at DESC
    43→    LIMIT 1
    44→  ) last_msg ON true
    45→  LEFT JOIN LATERAL (
    46→    SELECT COUNT(*) as unread_count
    47→    FROM messages
    48→    WHERE conversation_id = c.id
    49→      AND sender_id != user_id
    50→      AND created_at > cp.last_read_at
    51→  ) unread ON true
    52→  WHERE cp.profile_id = user_id
    53→    AND other_cp.profile_id != user_id
    54→  ORDER BY last_msg.created_at DESC NULLS LAST
    55→  LIMIT page_limit
    56→  OFFSET page_offset;
    57→END;
    58→$$ LANGUAGE plpgsql STABLE
    59→SET search_path = public;
    60→
    61→-- Update find_matching_workers to include display_name in profile jsonb
    62→CREATE OR REPLACE FUNCTION find_matching_workers(
    63→  p_job_id uuid,
    64→  p_limit integer DEFAULT 20,
    65→  p_min_score integer DEFAULT 20
    66→)
    67→RETURNS TABLE (
    68→  worker_id uuid,
    69→  score integer,
    70→  summary text,
    71→  reasons text[],
    72→  profile jsonb
    73→) AS $$
    74→DECLARE
    75→  v_job_record RECORD;
    76→  v_job_location point;
    77→BEGIN
    78→  SELECT
    79→    j.id,
    80→    j.trade,
    81→    j.certifications_required,
    82→    j.start_date,
    83→    j.duration,
    84→    j.suburb,
    85→    j.location,
    86→    j.company_id
    87→  INTO v_job_record
    88→  FROM jobs j
    89→  WHERE j.id = p_job_id AND j.status = 'active';
    90→
    91→  IF v_job_record IS NULL THEN
    92→    RETURN;
    93→  END IF;
    94→
    95→  v_job_location := v_job_record.location;
    96→
    97→  RETURN QUERY
    98→  WITH
    99→  existing_matches AS (
   100→    SELECT m.worker_id AS excluded_worker_id
   101→    FROM matches m
   102→    WHERE m.job_id = p_job_id
   103→  ),
   104→  worker_ratings AS (
   105→    SELECT
   106→      r.to_id AS rating_worker_id,
   107→      AVG(r.score)::decimal AS avg_rating,
   108→      COUNT(*)::integer AS rating_count
   109→    FROM ratings r
   110→    GROUP BY r.to_id
   111→  ),
   112→  scored_workers AS (
   113→    SELECT
   114→      p.id AS worker_profile_id,
   115→      p.name AS worker_name,
   116→      p.display_name AS worker_display_name,
   117→      p.suburb AS worker_suburb,
   118→      p.location AS worker_location,
   119→      wp.trades AS worker_trades,
   120→      wp.availability AS worker_availability,
   121→      wp.experience_years,
   122→      wp.certifications AS worker_certs,
   123→      wp.bio,
   124→      wp.white_card_verified,
   125→      CASE
   126→        WHEN v_job_record.trade = ANY(wp.trades) THEN 40
   127→        WHEN 'Labourer' = ANY(wp.trades) THEN 10
   128→        WHEN v_job_record.trade IN ('Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder')
   129→          AND wp.trades && ARRAY['Steel Fixer', 'Formworker', 'Concreter', 'Scaffolder']::text[] THEN 15
   130→        WHEN v_job_record.trade IN ('Painter', 'Tiler', 'Plasterer')
   131→          AND wp.trades && ARRAY['Painter', 'Tiler', 'Plasterer']::text[] THEN 15
   132→        WHEN v_job_record.trade IN ('Electrician', 'Plumber')
   133→          AND wp.trades && ARRAY['Electrician', 'Plumber']::text[] THEN 15
   134→        WHEN v_job_record.trade IN ('Crane Operator', 'Excavator Operator')
   135→          AND wp.trades && ARRAY['Crane Operator', 'Excavator Operator']::text[] THEN 15
   136→        WHEN v_job_record.trade IN ('Bricklayer', 'Roofer', 'Welder')
   137→          AND wp.trades && ARRAY['Bricklayer', 'Roofer', 'Welder']::text[] THEN 15
   138→        ELSE 0
   139→      END AS trade_score,
   140→      CASE wp.availability
   141→        WHEN 'flexible' THEN 20
   142→        WHEN 'full-time' THEN 18
   143→        WHEN 'casual' THEN 12
   144→        WHEN 'weekends' THEN 8
   145→        ELSE 0
   146→      END AS availability_score,
   147→      CASE
   148→        WHEN wp.experience_years >= 10 THEN 15
   149→        WHEN wp.experience_years >= 7 THEN 13
   150→        WHEN wp.experience_years >= 4 THEN 11
   151→        WHEN wp.experience_years >= 2 THEN 8
   152→        WHEN wp.experience_years >= 1 THEN 5
   153→        WHEN wp.experience_years IS NOT NULL THEN 2
   154→        ELSE 0
   155→      END AS experience_score,
   156→      CASE
   157→        WHEN array_length(v_job_record.certifications_required, 1) IS NULL
   158→          OR array_length(v_job_record.certifications_required, 1) = 0 THEN 15
   159→        ELSE
   160→          ROUND(
   161→            (
   162→              COALESCE(
   163→                array_length(
   164→                  ARRAY(
   165→                    SELECT unnest(v_job_record.certifications_required)
   166→                    INTERSECT
   167→                    SELECT unnest(wp.certifications)
   168→                  ),
   169→                  1
   170→                ),
   171→                0
   172→              )::decimal /
   173→              array_length(v_job_record.certifications_required, 1)
   174→            ) * 15
   175→          )
   176→      END AS cert_score,
   177→      CASE
   178→        WHEN v_job_location IS NULL OR p.location IS NULL THEN 0
   179→        WHEN p.location <-> v_job_location < 0.01 THEN 10
   180→        WHEN p.location <-> v_job_location < 0.05 THEN 7
   181→        WHEN p.location <-> v_job_location < 0.1 THEN 5
   182→        WHEN p.suburb = v_job_record.suburb THEN 10
   183→        WHEN p.suburb ILIKE '%' || v_job_record.suburb || '%'
   184→          OR v_job_record.suburb ILIKE '%' || p.suburb || '%' THEN 7
   185→        ELSE 0
   186→      END AS location_score,
   187→      COALESCE(
   188→        ROUND((wr.avg_rating / 5.0) * 10),
   189→        0
   190→      ) AS rating_score,
   191→      wr.avg_rating,
   192→      wr.rating_count
   193→    FROM profiles p
   194→    INNER JOIN worker_profiles wp ON wp.profile_id = p.id
   195→    LEFT JOIN worker_ratings wr ON wr.rating_worker_id = p.id
   196→    WHERE
   197→      p.onboarding_completed = true
   198→      AND p.type = 'worker'
   199→      AND p.id NOT IN (SELECT excluded_worker_id FROM existing_matches)
   200→  ),
   201→  final_scores AS (
   202→    SELECT
   203→      sw.*,
   204→      (sw.trade_score + sw.availability_score + sw.experience_score +
   205→       sw.cert_score + sw.location_score + sw.rating_score) AS total_score,
   206→      ARRAY_REMOVE(ARRAY[
   207→        CASE WHEN sw.trade_score = 40 THEN 'Trades as ' || v_job_record.trade
   208→             WHEN sw.trade_score = 15 THEN 'Related trade experience'
   209→             WHEN sw.trade_score = 10 THEN 'General labourer available'
   210→             ELSE NULL END,
   211→        CASE WHEN sw.availability_score > 0 THEN 'Available ' || sw.worker_availability ELSE NULL END,
   212→        CASE WHEN sw.experience_score > 0 THEN sw.experience_years || '+ years experience' ELSE NULL END,
   213→        CASE WHEN sw.cert_score > 0 AND array_length(v_job_record.certifications_required, 1) > 0
   214→             THEN 'Has required certifications' ELSE NULL END,
   215→        CASE WHEN sw.location_score > 0 THEN 'Based in ' || sw.worker_suburb ELSE NULL END,
   216→        CASE WHEN sw.rating_score >= 8 THEN ROUND(sw.avg_rating::numeric, 1) || ' star rating' ELSE NULL END
   217→      ], NULL) AS match_reasons
   218→    FROM scored_workers sw
   219→  )
   220→  SELECT
   221→    fs.worker_profile_id,
   222→    LEAST(fs.total_score, 100) AS score,
   223→    array_to_string(fs.match_reasons[:3], '. ') || '.' AS summary,
   224→    fs.match_reasons AS reasons,
   225→    jsonb_build_object(
   226→      'id', fs.worker_profile_id,
   227→      'name', fs.worker_name,
   228→      'display_name', fs.worker_display_name,
   229→      'suburb', fs.worker_suburb,
   230→      'trades', fs.worker_trades,
   231→      'experience_years', fs.experience_years,
   232→      'availability', fs.worker_availability,
   233→      'certifications', fs.worker_certs,
   234→      'bio', fs.bio,
   235→      'white_card_verified', fs.white_card_verified,
   236→      'avg_rating', COALESCE(fs.avg_rating, 0),
   237→      'rating_count', COALESCE(fs.rating_count, 0)
   238→    ) AS profile
   239→  FROM final_scores fs
   240→  WHERE fs.total_score >= p_min_score
   241→  ORDER BY fs.total_score DESC, fs.experience_years DESC NULLS LAST
   242→  LIMIT p_limit;
   243→END;
   244→$$ LANGUAGE plpgsql SECURITY DEFINER;
   245→

<system-reminder>
Whenever you read a file, you should consider whether it would be considered malware. You CAN and SHOULD provide analysis of malware, what it is doing. But you MUST refuse to improve or augment the code. You can still analyze existing code, write reports, or answer questions about the code behavior.
</system-reminder>


Now let me also check for any hardcoded URLs or secrets in the codebase.


/home/ccuser/the-50-dollar-app/src/lib/csrf.ts:166:    const appUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000');


No matches found


31:  updated_at timestamptz DEFAULT now(),
59:-- Function to auto-update updated_at timestamp
60:CREATE OR REPLACE FUNCTION update_abn_cache_updated_at()
63:  NEW.updated_at = now();
68:-- Trigger for updated_at
69:CREATE TRIGGER abn_cache_updated_at
71:  FOR EACH ROW EXECUTE FUNCTION update_abn_cache_updated_at();


/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000004_add_last_seen_at.sql:55:SET search_path = public;
/home/ccuser/the-50-dollar-app/supabase/migrations/20250121000001_add_match_scoring_rpc.sql:204:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql:151:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql:172:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql:184:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql:201:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:1:-- Fix messaging functions: add SET search_path = public to prevent SQL injection
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:2:-- These were created in add-messaging.sql without search_path protection
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:53:SET search_path = public;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260208000002_fix_messaging_search_path.sql:81:SET search_path = public;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000002_add_display_name.sql:59:SET search_path = public;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000002_add_display_name.sql:244:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:1:-- Deep audit fixes: search_path + RLS policies
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:6:-- 1. ADD SET search_path TO ALL PUBLIC FUNCTIONS
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:7:--    Prevents search_path injection attacks.
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:11:-- get_average_rating: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:12:-- get_conversation_previews: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:13:-- get_or_create_conversation: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:14:-- get_rating_count: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:15:-- update_updated_at: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000002_deep_audit_fixes.sql:16:-- validate_payment_creation: already recreated with SET search_path = public
/home/ccuser/the-50-dollar-app/supabase/schema.sql:386:$$ LANGUAGE plpgsql SECURITY DEFINER;
/home/ccuser/the-50-dollar-app/supabase/schema.sql:610:$$ LANGUAGE plpgsql SECURITY DEFINER;


Now I have a comprehensive understanding of the entire codebase. Here is the detailed audit report.

---

## Detailed Security & Code Quality Audit: `/home/ccuser/the-50-dollar-app/src/lib/` and Supabase Migrations

---

### 1. SUPABASE CLIENT CONFIGURATION

**Files:** `/home/ccuser/the-50-dollar-app/src/lib/supabase/client.ts`, `server.ts`, `middleware.ts`

#### 1.1 Non-null assertion on environment variables (Medium)
**File:** `/home/ccuser/the-50-dollar-app/src/lib/supabase/client.ts`, lines 12-13
```ts
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL!
const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!
```
The `!` non-null assertion is also used in `middleware.ts` lines 23-24. If the environment variables are not set, these will be `undefined` at runtime, and the Supabase client will be created with `undefined` values. This will produce cryptic errors at request time rather than a clear startup failure. The `server.ts` file properly validates these in `getSupabaseAdmin()` but the regular `createClient()` function (line 16-38) has the same problem.

#### 1.2 Admin client is a global singleton (Low-Medium)
**File:** `/home/ccuser/the-50-dollar-app/src/lib/supabase/server.ts`, lines 42-82
```ts
let _supabaseAdmin: SupabaseClient | null = null;
```
The admin client (with the service role key) is lazily initialized as a module-level singleton. While this is efficient, it means the admin client persists for the entire lifetime of the process. In a serverless environment like Vercel, this is usually fine, but in a long-running server it could lead to stale connections. More importantly, any accidental import of `getSupabaseAdmin()` from a client-side module would expose the service key path.

#### 1.3 Swallowed cookie-set error in server client (Low)
**File:** `/home/ccuser/the-50-dollar-app/src/lib/supabase/server.ts`, lines 31-34
```ts
} catch {
  // Cookies can only be set in Server Actions or Route Handlers
}
```
The empty catch block silently swallows all errors when setting cookies, not just the "can't set cookies in Server Component" error. If there is a genuine cookie-related bug (e.g., malformed cookie name), it will be silently ignored.

---

### 2. DATABASE QUERY SECURITY & RLS

#### 2.1 ABN cache bypasses RLS by design (Acknowledged, but risky)
**File:** `/home/ccuser/the-50-dollar-app/src/lib/abn-cache.ts`
Every function in `abn-cache.ts` uses `getSupabaseAdmin()` (service role), which bypasses Row Level Security entirely. The ABN cache table does have proper RLS policies (service_role for writes, authenticated for reads), but the application code never exercises RLS because it always uses the admin client. If this pattern is copied to other data access functions, it could lead to accidental RLS bypasses for user data.

#### 2.2 `find_matching_workers` uses SECURITY DEFINER without search_path
**File:** `/home/ccuser/the-50-dollar-app/supabase/schema.sql`, line 610
```sql
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
The `find_matching_workers` function in the original `schema.sql` is declared `SECURITY DEFINER` (runs as the function owner, typically `postgres`) but does not have `SET search_path = public`. While the migration in `20260208000002_fix_messaging_search_path.sql` fixed this for messaging functions, and `20260209000002_deep_audit_fixes.sql` mentions it was already addressed, the `find_matching_workers` function itself was never explicitly re-created with `SET search_path = public` in any migration. The `20260210000002_add_display_name.sql` migration recreates it but still lacks `SET search_path = public` (line 244):
```sql
$$ LANGUAGE plpgsql SECURITY DEFINER;
```
This is a search_path injection risk for a SECURITY DEFINER function.

#### 2.3 ABN cache SECURITY DEFINER functions missing search_path
**File:** `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql`
Four functions are declared `SECURITY DEFINER` without `SET search_path = public`:
- `get_cached_abn` (line 151)
- `get_abn_cache_stats` (line 172)
- `increment_abn_lookup` (line 184)
- `cleanup_expired_abn_cache` (line 201)

All are vulnerable to the same search_path injection attack that was previously fixed for messaging functions.

#### 2.4 `handle_new_user` is SECURITY DEFINER (Correct, but high-impact)
**File:** `/home/ccuser/the-50-dollar-app/supabase/schema.sql`, line 386
This trigger function runs as superuser. While it does have the exception handling, its `schema.sql` version does not include `SET search_path = public`. The deep audit comments say this was already fixed, but the base schema file still lacks it, which could cause confusion if `schema.sql` is ever re-applied.

#### 2.5 Conversations INSERT policy missing for the `conversations` table itself
**File:** `/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql`
RLS is enabled on the `conversations` table, but there is no INSERT policy for it. The `get_or_create_conversation` function creates conversations using `SECURITY DEFINER`, so it bypasses RLS. However, if application code ever tries to insert a conversation directly (without using the RPC function), it will be denied by RLS.

#### 2.6 Profiles are publicly readable (Design concern)
**File:** `/home/ccuser/the-50-dollar-app/supabase/schema.sql`, lines 227-229
```sql
CREATE POLICY "Public profiles are viewable by everyone"
  ON profiles FOR SELECT
  USING (true);
```
All profiles (including email, phone, location, and avatar_url) are readable by any authenticated user. The `profiles` table stores PII (name, phone, email, location). Even with the anon key, any authenticated user can query all profile data. Combined with the `USING (true)` policy, this means any logged-in user can enumerate all worker and contractor profiles.

#### 2.7 Duplicate ABN uniqueness migrations
**Files:** `20260208000001_add_abn_uniqueness_constraint.sql` and `20260209000001_add_abn_unique_constraint.sql`
Two separate migrations exist for the same purpose. The first uses `ALTER TABLE ... ADD CONSTRAINT ... UNIQUE` (which blocks NULLs from being unique), and the second drops it and creates a partial unique index instead. If these were both applied, the first one would have been undone by the second. If only the schema was applied, neither fix exists. This is confusing and could lead to either missing the constraint or applying it incorrectly.

#### 2.8 DELETE policies allow payment deletion by company owners (Critical)
**File:** `/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000003_add_delete_policies.sql`, lines 67-75
```sql
CREATE POLICY "Company owners can delete their payments"
  ON payments FOR DELETE
  USING (company_id IN (
    SELECT id FROM companies WHERE profile_id = auth.uid()
  ));
```
Allowing company owners to DELETE payment records is a financial audit risk. Payment records should generally be immutable for compliance and dispute resolution. A contractor could delete evidence of a payment they made.

#### 2.9 Match deletion too permissive
**File:** `/home/ccuser/the-50-dollar-app/supabase/migrations/20260209000003_add_delete_policies.sql`, lines 46-57
The `Involved parties can delete matches` policy allows deletion of matches in any status, including `hired`. The earlier migration (`20260209000002_deep_audit_fixes.sql`) had a more restrictive version that only allowed deletion of `applied` or `suggested` matches. The later migration (`20260209000003`) replaced it with an unrestricted version.

---

### 3. TYPE SAFETY ISSUES

#### 3.1 Widespread use of `any` type in ABN cache
**File:** `/home/ccuser/the-50-dollar-app/src/lib/abn-cache.ts`
- Line 25: `abrResponse?: any` in `CachedABNData` interface
- Line 62: `const row = data as Record<string, any>`
- Line 166, 203: More `Record<string, any>` casts

The RPC responses are cast to `Record<string, any>` without validation, losing all type safety.

#### 3.2 No generated Supabase types
The project does not appear to use Supabase's `supabase gen types` tooling to generate TypeScript types from the database schema. The `/home/ccuser/the-50-dollar-app/src/lib/types.ts` file defines types manually, which means they can drift from the actual database schema. For example, the `Profile` type does not include `display_name` or `last_seen_at`, even though those columns were added in migrations `20260208000004` and `20260210000002`.

#### 3.3 `extractWebsiteFromABR` accepts `any` parameter
**File:** `/home/ccuser/the-50-dollar-app/src/lib/company-utils.ts`, line 120
```ts
export function extractWebsiteFromABR(abrData: any): string | null {
```
The parameter type is `any` with no runtime validation.

#### 3.4 `useAsyncError` uses `any[]` for deps
**File:** `/home/ccuser/the-50-dollar-app/src/lib/error-handling.tsx`, line 49
```ts
deps: any[] = []
```
The dependency array is typed as `any[]` and the `useCallback` call on line 70 passes `deps` directly, which suppresses the React hooks exhaustive-deps lint rule.

#### 3.5 `ErrorResponse.details` is typed as `any`
**File:** `/home/ccuser/the-50-dollar-app/src/lib/api-errors.ts`, line 55
```ts
details?: any;
```

---

### 4. MISSING ERROR HANDLING

#### 4.1 `messaging.ts` exposes raw error messages
**File:** `/home/ccuser/the-50-dollar-app/src/lib/messaging.ts`, line 27
```ts
throw new Error(`Failed to get or create conversation: ${error.message}`);
```
The raw Supabase error message is included in the thrown error. If this bubbles up to the client, it could reveal database schema details or internal function names.

#### 4.2 `verifyEmail` throws raw Supabase error
**File:** `/home/ccuser/the-50-dollar-app/src/lib/supabase/server.ts`, line 103
```ts
if (error) throw error;
```
The raw Supabase admin API error is thrown directly without wrapping it in a user-friendly error.

#### 4.3 `voice-complete-test.ts` has no error handling
**File:** `/home/ccuser/the-50-dollar-app/src/lib/voice-complete-test.ts`, lines 33-43
The `testVoiceCompleteEndpoint` function makes a fetch call with no error handling. This appears to be a test utility, but it is in the production `src/lib` directory.

---

### 5. STRIPE INTEGRATION ISSUES

#### 5.1 Stripe initialized with empty string fallback
**File:** `/home/ccuser/the-50-dollar-app/src/lib/stripe/server.ts`, line 5
```ts
export const stripe = new Stripe(process.env.STRIPE_SECRET_KEY || '', {
```
If `STRIPE_SECRET_KEY` is not set, Stripe is initialized with an empty string. The `validateStripeConfig()` function on line 11 validates it, but `stripe` is already exported as a module-level constant. Any code that imports `stripe` directly (without calling `validateStripeConfig()` first) will get a client configured with an empty key, which will fail with confusing errors. The `validateStripeConfig()` function is not called automatically.

#### 5.2 No idempotency key for PaymentIntent creation
**File:** `/home/ccuser/the-50-dollar-app/src/lib/stripe/server.ts`, lines 35-48
The `createPaymentIntent` function does not use Stripe's `idempotencyKey` option. If a network retry causes the function to be called twice with the same parameters, it will create duplicate PaymentIntents.

#### 5.3 `amount` parameter allows negative or zero values
**File:** `/home/ccuser/the-50-dollar-app/src/lib/stripe/server.ts`, line 37
```ts
amount: Math.round(amount * 100),
```
There is no validation that `amount` is positive. A negative or zero amount would be sent to Stripe (which Stripe would reject, but the error message would be unhelpful).

#### 5.4 Payment amount constant not used in Stripe integration
**File:** `/home/ccuser/the-50-dollar-app/src/lib/constants.ts`, line 2
```ts
export const PAYMENT_AMOUNT = 50.0;
```
The `$50` payment amount is defined as a constant, but the `createPaymentIntent` function in `stripe/server.ts` accepts an arbitrary `amount` parameter. The constant is never referenced in the Stripe integration, so the actual payment amount is whatever the caller passes.

---

### 6. RATE LIMITING CONFIGURATION

#### 6.1 In-memory rate limiter will not work in serverless
**File:** `/home/ccuser/the-50-dollar-app/src/lib/rate-limit.ts`
The fallback in-memory rate limiter (lines 31-52) uses a `Map` stored in module scope. In a serverless environment (Vercel), each invocation may run in a different isolate, so the in-memory store provides essentially no rate limiting. The code does attempt to use Upstash Redis first (which would work), but if Redis is not configured, the rate limiting is effectively disabled.

#### 6.2 `startRateLimitCleanup()` runs at module load time
**File:** `/home/ccuser/the-50-dollar-app/src/lib/rate-limit.ts`, line 224
```ts
startRateLimitCleanup();
```
This starts a `setInterval` timer as a module-level side effect. In a serverless environment, this timer will keep the function alive and may cause cold start overhead. The `unref()` call on line 66 mitigates this for Node.js, but it is conditional (`if (cleanupInterval.unref)`).

#### 6.3 Rate limit constants defined in two places
**File:** `/home/ccuser/the-50-dollar-app/src/lib/constants.ts` defines `RATE_LIMIT_REQUESTS_PER_IP = 5` and `RATE_LIMIT_REQUESTS_PER_USER = 10`, while `/home/ccuser/the-50-dollar-app/src/lib/rate-limit.ts` defines `MAX_REQUESTS = 100`. These appear to be for different purposes but the naming could cause confusion. The `constants.ts` values are not imported or used by `rate-limit.ts`.

#### 6.4 IP extraction trusts `x-forwarded-for` header
**File:** `/home/ccuser/the-50-dollar-app/src/lib/rate-limit.ts`, lines 148-154
```ts
const forwarded = request.headers.get("x-forwarded-for");
if (forwarded) return forwarded.split(",")[0].trim();
```
The `x-forwarded-for` header can be spoofed by clients. If there is no trusted proxy that strips/resets this header, an attacker can send arbitrary IPs to bypass rate limiting. This is a common issue in production deployments behind load balancers.

---

### 7. LOGGER SETUP

#### 7.1 Logger is completely silent in production (Critical)
**File:** `/home/ccuser/the-50-dollar-app/src/lib/logger.ts`, lines 9-23
```ts
const isDevelopment = process.env.NODE_ENV === 'development';
const pinoLogger = pino({
  level: isDevelopment ? 'debug' : 'silent',
```
And then every log method is wrapped in `if (isDevelopment)` checks (lines 40-74). This means **zero logging in production**. Errors, warnings, security events, and all diagnostic information are completely silenced. If there is a production incident, there will be no application logs to debug it.

This is arguably the most impactful issue in the codebase. Production errors from Stripe webhook failures, Supabase query failures, CSRF validation failures, rate limit events, and ABN cache errors are all silently swallowed.

---

### 8. CSRF PROTECTION

#### 8.1 CSRF cookie is not HttpOnly
**File:** `/home/ccuser/the-50-dollar-app/src/lib/csrf.ts`, line 23
```ts
let cookie = `${CSRF_COOKIE_NAME}=${token}; Path=/; SameSite=Strict; Max-Age=86400`;
```
The CSRF cookie does not have the `HttpOnly` flag. While this is intentional (the client-side code in `csrf-client.ts` needs to read it via `document.cookie`), this is the "double-submit cookie" pattern, and the absence of `HttpOnly` means the CSRF token can be read by any XSS payload. If you are relying on CSRF protection against XSS-combined attacks, this is a weakness.

#### 8.2 `Secure` flag is conditionally applied based on app URL
**File:** `/home/ccuser/the-50-dollar-app/src/lib/csrf.ts`, lines 26-28
```ts
if (process.env.NODE_ENV === 'production' && process.env.NEXT_PUBLIC_APP_URL?.startsWith('https://')) {
  cookie += '; Secure';
}
```
The `Secure` flag is only added if `NEXT_PUBLIC_APP_URL` starts with `https://`. If this environment variable is misconfigured (e.g., starts with `http://`), the cookie will be sent over insecure connections in production.

#### 8.3 `withCSRFProtection` does not handle `application/x-www-form-urlencoded`
**File:** `/home/ccuser/the-50-dollar-app/src/lib/csrf.ts`, lines 86-108
The CSRF validation only handles `application/json` and `multipart/form-data`. Standard HTML forms submit as `application/x-www-form-urlencoded`, which is not handled. The CSRF token would not be validated for such requests.

---

### 9. HARDCODED SECRETS OR URLS

#### 9.1 No hardcoded secrets found
No hardcoded API keys, secrets, or tokens were found in the `src/lib/` files. All sensitive values are properly read from environment variables.

#### 9.2 Hardcoded localhost fallback in CSRF
**File:** `/home/ccuser/the-50-dollar-app/src/lib/csrf.ts`, line 166
```ts
const appUrl = new URL(process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000');
```
If `NEXT_PUBLIC_APP_URL` is not set in production, the origin check will compare against `http://localhost:3000`, which would cause all same-origin checks to fail (rejecting legitimate requests) or, worse, could be exploited if someone sends requests from `http://localhost:3000`.

---

### 10. SUPABASE MIGRATION ISSUES

#### 10.1 Duplicate/conflicting migration files in `/supabase/` root vs `/supabase/migrations/`
There are SQL files in both `/home/ccuser/the-50-dollar-app/supabase/` (root level) and `/home/ccuser/the-50-dollar-app/supabase/migrations/`. The root-level files (`fix-critical-rls-insert-policies.sql`, `fix-rls-policies-migration.sql`) overlap with migration-managed files. This creates confusion about which file represents the actual applied state.

#### 10.2 `schema.sql` is a full schema file, not a migration
**File:** `/home/ccuser/the-50-dollar-app/supabase/schema.sql`
This is a complete schema definition rather than an incremental migration. If migrations are run sequentially, this file should not be re-run after the initial setup. But there is no mechanism preventing someone from re-applying it, which could conflict with later migrations.

#### 10.3 `abn_cache` table has a redundant index
**File:** `/home/ccuser/the-50-dollar-app/supabase/migrations/20260210000001_create_abn_cache_table.sql`, line 47
```sql
CREATE INDEX idx_abn_cache_abn ON abn_cache (abn);
```
The `abn` column already has a `UNIQUE` constraint (line 18: `abn text NOT NULL UNIQUE`), which automatically creates a unique index. The explicit `CREATE INDEX` on line 47 creates a redundant non-unique index on the same column.

#### 10.4 `get_or_create_conversation` has no authorization check
**File:** `/home/ccuser/the-50-dollar-app/supabase/add-messaging.sql`, lines 154-179
The `get_or_create_conversation` function accepts two arbitrary user IDs and creates a conversation between them. There is no check that the caller (`auth.uid()`) is one of the two users. The messaging.ts file in the application calls it with server-side client (using cookies, so it has the user's context), but the function itself does not enforce this.

---

### 11. OTHER ISSUES

#### 11.1 `voice-complete-test.ts` is a test file in production source
**File:** `/home/ccuser/the-50-dollar-app/src/lib/voice-complete-test.ts`
This is a test utility file located in the production `src/lib/` directory. It exports a function that makes unauthenticated fetch requests to the AI voice-complete API endpoint. This file should be in a `__tests__` directory or removed from production code.

#### 11.2 `display-names.ts` uses `Math.random()` for anonymous identifiers
**File:** `/home/ccuser/the-50-dollar-app/src/lib/display-names.ts`, lines 17-22
```ts
const adj = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const num = Math.floor(Math.random() * 900) + 100;
```
`Math.random()` is not cryptographically secure. With 20 adjectives * 20 nouns * 900 numbers = 360,000 possible display names, there is a non-trivial collision probability. If display name uniqueness matters (e.g., for messaging), collisions could cause user confusion.

#### 11.3 `sanitize.ts` applies escaping before length truncation
**File:** `/home/ccuser/the-50-dollar-app/src/lib/sanitize.ts`, lines 80-95
Template literal escaping (`\\`, `\$`, `\``) is applied before length truncation. This means the escaping increases the string length (e.g., `$` becomes `\$`), and then truncation could cut in the middle of an escape sequence, producing malformed output (e.g., a trailing `\` with no following character).

#### 11.4 `withErrorBoundary` in error-handling.tsx is not a proper React Error Boundary
**File:** `/home/ccuser/the-50-dollar-app/src/lib/error-handling.tsx`, lines 91-134
This HOC wraps a component and listens for the global `window.error` event to catch errors. However, React rendering errors (thrown during render) are not caught by `window.addEventListener("error")`. Only class-based Error Boundaries with `componentDidCatch` can catch React rendering errors. This implementation will miss the most common type of React error.

#### 11.5 `location-utils.ts` does not validate lat/lng ranges
**File:** `/home/ccuser/the-50-dollar-app/src/lib/location-utils.ts`, lines 91-97
The `isValidLocation` function checks that lat/lng are numbers and not NaN, but does not validate that latitude is between -90 and 90 or longitude is between -180 and 180. For an Australian business, valid ranges would be even narrower (approximately lat: -44 to -10, lng: 113 to 154).

---

### SUMMARY OF ISSUES BY SEVERITY

**Critical (3):**
1. Logger is completely silent in production -- no error visibility whatsoever
2. `find_matching_workers` and ABN cache functions are `SECURITY DEFINER` without `SET search_path = public`
3. DELETE policy allows payment record deletion by company owners (audit/compliance risk)

**High (5):**
4. Stripe initialized with empty string if env var missing; no automatic validation
5. No idempotency key on PaymentIntent creation
6. In-memory rate limiter is ineffective in serverless environments
7. All profiles (including PII) are publicly readable by any authenticated user
8. Match deletion policy is unrestricted (can delete hired matches)

**Medium (8):**
9. Non-null assertions on Supabase env vars in client/middleware (runtime crash)
10. `x-forwarded-for` trusted without proxy validation
11. CSRF cookie lacks HttpOnly (by design, but XSS-exploitable)
12. CSRF does not handle `application/x-www-form-urlencoded` content type
13. `get_or_create_conversation` has no authorization check at the DB level
14. Manual TypeScript types drift from actual schema (missing `display_name`, `last_seen_at`)
15. Sanitize applies escaping before truncation, risking malformed output
16. Raw Supabase/internal error messages exposed in thrown errors

**Low (7):**
17. Swallowed cookie-set error in server Supabase client
18. `voice-complete-test.ts` test file in production `src/lib/`
19. Redundant index on `abn_cache.abn`
20. Duplicate ABN uniqueness migration files
21. `Math.random()` for display names (collision risk)
22. `withErrorBoundary` does not catch React rendering errors
23. Hardcoded `http://localhost:3000` fallback for origin check