summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorFuwn <[email protected]>2026-02-09 23:41:01 -0800
committerFuwn <[email protected]>2026-02-09 23:41:01 -0800
commit56244758d94c14349540bd0951339fa939156204 (patch)
tree3fba880cda09c0e8d913dc30884182df5e6a73ee
parentfix: use online networkMode for offline mutations instead of offlineFirst (diff)
downloadasa.news-56244758d94c14349540bd0951339fa939156204.tar.xz
asa.news-56244758d94c14349540bd0951339fa939156204.zip
fix: P0 correctness and security fixes
- Add missing 'developer' case to check_custom_feed_limit trigger (was falling through to else 1) - Scope user_entry_states join to authenticated user in /api/v1/entries (admin client bypasses RLS) - Replace in-memory rate limiting with Supabase-backed solution (UNLOGGED table + check_rate_limit RPC + pg_cron cleanup)
-rw-r--r--FIXES_AND_RECOMMENDATIONS.md100
-rw-r--r--apps/web/app/api/account/data/route.ts2
-rw-r--r--apps/web/app/api/account/route.ts2
-rw-r--r--apps/web/app/api/billing/create-checkout-session/route.ts2
-rw-r--r--apps/web/app/api/billing/create-portal-session/route.ts2
-rw-r--r--apps/web/app/api/billing/webhook/route.ts2
-rw-r--r--apps/web/app/api/export/route.ts2
-rw-r--r--apps/web/app/api/share/route.ts2
-rw-r--r--apps/web/app/api/v1/entries/route.ts1
-rw-r--r--apps/web/app/api/v1/keys/route.ts2
-rw-r--r--apps/web/app/api/webhook-config/route.ts2
-rw-r--r--apps/web/app/api/webhook-config/test/route.ts2
-rw-r--r--apps/web/lib/api-auth.ts2
-rw-r--r--apps/web/lib/rate-limit.ts36
-rw-r--r--supabase/schema.sql46
15 files changed, 175 insertions, 30 deletions
diff --git a/FIXES_AND_RECOMMENDATIONS.md b/FIXES_AND_RECOMMENDATIONS.md
new file mode 100644
index 0000000..6afded5
--- /dev/null
+++ b/FIXES_AND_RECOMMENDATIONS.md
@@ -0,0 +1,100 @@
+# asa.news Fixes and Recommended Changes
+
+Date: 2026-02-10
+
+This note captures all fixes and changes identified during the codebase analysis.
+
+## P0: Fix Immediately (Correctness / Security)
+
+- [x] Fix plan-limit mismatch for developer custom feeds.
+ - Problem: app constants allow 1000 custom feeds for `developer`, but DB trigger fallback effectively allows 1.
+ - Files:
+ - `packages/shared/source/index.ts`
+ - `supabase/schema.sql` (`check_custom_feed_limit`)
+ - Action: update SQL case to explicitly include `when 'developer' then 1000`.
+
+- [x] Scope `user_entry_states` join in API v1 entries to the authenticated user.
+ - Problem: `user_entry_states!left(read, saved)` join is not user-filtered while using a service-role client.
+ - File: `apps/web/app/api/v1/entries/route.ts`
+ - Action: join/filter by `user_id = authResult.user.userIdentifier` (or fetch states in a second query keyed by entry IDs and user ID).
+
+- [x] Replace in-memory rate limiting with shared/distributed rate limiting.
+ - Problem: current `Map`-based limiter is per-process only and unreliable across instances/serverless.
+ - Files:
+ - `apps/web/lib/rate-limit.ts`
+ - all API routes using `rateLimit(...)`
+ - Action: move to Redis/Upstash/Postgres-backed limiter with per-route policies and standard headers.
+
+## P1: Must Fix for Green Lint / Stable Main
+
+- [ ] Resolve current ESLint errors (not warnings).
+ - Errors observed in:
+ - `apps/web/app/reader/_components/entry-detail-panel.tsx` (`set-state-in-effect`)
+ - `apps/web/app/reader/_components/reader-layout-shell.tsx` (`react-hooks/refs`, 2 errors)
+ - `apps/web/app/reader/_components/reader-shell.tsx` (`react-hooks/refs`)
+ - `apps/web/app/reader/_components/search-overlay.tsx` (`set-state-in-effect`)
+ - `apps/web/app/reader/settings/_components/api-settings.tsx` (`set-state-in-effect`)
+ - `apps/web/app/shared/[token]/page.tsx` (`no-html-link-for-pages`)
+ - `apps/web/lib/hooks/use-is-mobile.ts` (`set-state-in-effect`)
+ - `apps/web/public/sw.js` (`no-this-alias` in generated file)
+ - Action: fix hooks/ref patterns, replace root `<a href="/">` with `<Link href="/">`, and stop linting generated `public/sw.js` (or regenerate with lint-safe output and ignore generated bundles).
+
+## P2: Security Hardening
+
+- [ ] Reduce service-role usage in user-facing API handlers where not required.
+ - Problem: several authenticated routes use `createSupabaseAdminClient()` when user-scoped RLS would be safer.
+ - Files (examples):
+ - `apps/web/app/api/v1/feeds/route.ts`
+ - `apps/web/app/api/v1/entries/route.ts`
+ - `apps/web/app/api/v1/entries/[entryIdentifier]/route.ts`
+ - `apps/web/app/api/v1/profile/route.ts`
+ - `apps/web/app/api/v1/folders/route.ts`
+ - Action: prefer `createSupabaseServerClient()` + RLS for read paths; keep service role only for admin-only operations.
+
+- [ ] Harden webhook URL validation in web app paths to match worker-grade SSRF protections.
+ - Problem: `webhook-config` checks are hostname-string based; test webhook route performs outbound fetches.
+ - Files:
+ - `apps/web/app/api/webhook-config/route.ts`
+ - `apps/web/app/api/webhook-config/test/route.ts`
+ - Action: use DNS resolution + private/reserved IP block checks (including IPv6/link-local) before outbound requests.
+
+## P2: Data/Business Logic Consistency
+
+- [ ] Keep tier limits in one source of truth and verify parity between TS constants and SQL triggers/functions.
+ - Files:
+ - `packages/shared/source/index.ts`
+ - `supabase/schema.sql`
+ - Action: add a parity check script or migration checklist so limits never drift again.
+
+## P3: Quality and Maintainability
+
+- [ ] Reduce current lint warning load (120 warnings observed).
+ - Categories to address:
+ - missing hook dependencies (`react-hooks/exhaustive-deps`)
+ - unused variables
+ - `<img>` usage warnings (`@next/next/no-img-element`)
+ - generated file noise from `public/sw.js`
+ - Action: handle in batches and enforce warning budget in CI.
+
+- [ ] Add automated tests (currently no meaningful test coverage).
+ - Go worker: `go test ./...` passes but all packages report `[no test files]`.
+ - Web app: no unit/integration/e2e suite present.
+ - Action:
+ - worker: add unit tests for fetcher/parser/writer logic and error classification
+ - web: add API route tests + critical hook/component tests + at least one e2e happy path
+
+- [ ] Add CI gates for `lint`, `typecheck`, and tests.
+ - Problem: no `.github/workflows` found.
+ - Action: create CI to block regressions before merge.
+
+- [ ] Refresh stale app documentation.
+ - Problem: web README is still default create-next-app boilerplate.
+ - File: `apps/web/README.md`
+ - Action: document real setup, env vars, worker dependency, Supabase + Stripe requirements, local dev flow.
+
+## Execution Order Recommendation
+
+1. P0 fixes (correctness/security).
+2. P1 lint errors to restore green checks.
+3. P2 hardening (service-role reduction + webhook SSRF parity).
+4. P3 testing + CI + docs.
diff --git a/apps/web/app/api/account/data/route.ts b/apps/web/app/api/account/data/route.ts
index f3a61ec..20a7b8c 100644
--- a/apps/web/app/api/account/data/route.ts
+++ b/apps/web/app/api/account/data/route.ts
@@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`gdpr-export:${user.id}`, 3, 86_400_000)
+ const rateLimitResult = await rateLimit(`gdpr-export:${user.id}`, 3, 86_400_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/account/route.ts b/apps/web/app/api/account/route.ts
index 83502c2..abf2ca7 100644
--- a/apps/web/app/api/account/route.ts
+++ b/apps/web/app/api/account/route.ts
@@ -19,7 +19,7 @@ export async function DELETE() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`account-delete:${user.id}`, 3, 60_000)
+ const rateLimitResult = await rateLimit(`account-delete:${user.id}`, 3, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/billing/create-checkout-session/route.ts b/apps/web/app/api/billing/create-checkout-session/route.ts
index fae66b8..b3a5bf7 100644
--- a/apps/web/app/api/billing/create-checkout-session/route.ts
+++ b/apps/web/app/api/billing/create-checkout-session/route.ts
@@ -20,7 +20,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`checkout:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`checkout:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/billing/create-portal-session/route.ts b/apps/web/app/api/billing/create-portal-session/route.ts
index de67b40..7ad7219 100644
--- a/apps/web/app/api/billing/create-portal-session/route.ts
+++ b/apps/web/app/api/billing/create-portal-session/route.ts
@@ -20,7 +20,7 @@ export async function POST() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`portal:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`portal:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/billing/webhook/route.ts b/apps/web/app/api/billing/webhook/route.ts
index 285afdc..552c27b 100644
--- a/apps/web/app/api/billing/webhook/route.ts
+++ b/apps/web/app/api/billing/webhook/route.ts
@@ -136,7 +136,7 @@ async function handleInvoicePaid(invoice: Stripe.Invoice) {
export async function POST(request: Request) {
const clientIp = request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"
- const rateLimitResult = rateLimit(`webhook:${clientIp}`, 60, 60_000)
+ const rateLimitResult = await rateLimit(`webhook:${clientIp}`, 60, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/export/route.ts b/apps/web/app/api/export/route.ts
index 195d444..dff7582 100644
--- a/apps/web/app/api/export/route.ts
+++ b/apps/web/app/api/export/route.ts
@@ -12,7 +12,7 @@ export async function GET() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`export:${user.id}`, 5, 3_600_000)
+ const rateLimitResult = await rateLimit(`export:${user.id}`, 5, 3_600_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/share/route.ts b/apps/web/app/api/share/route.ts
index 5f67bc6..ca8f8e2 100644
--- a/apps/web/app/api/share/route.ts
+++ b/apps/web/app/api/share/route.ts
@@ -28,7 +28,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`share:${user.id}`, 30, 60_000)
+ const rateLimitResult = await rateLimit(`share:${user.id}`, 30, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/v1/entries/route.ts b/apps/web/app/api/v1/entries/route.ts
index 8a2de62..47789f1 100644
--- a/apps/web/app/api/v1/entries/route.ts
+++ b/apps/web/app/api/v1/entries/route.ts
@@ -43,6 +43,7 @@ export async function GET(request: Request) {
)
.in("feed_id", subscribedFeedIdentifiers)
.is("owner_id", null)
+ .eq("user_entry_states.user_id", authResult.user.userIdentifier)
.order("published_at", { ascending: false })
.limit(limit + 1)
diff --git a/apps/web/app/api/v1/keys/route.ts b/apps/web/app/api/v1/keys/route.ts
index de63a46..67bad66 100644
--- a/apps/web/app/api/v1/keys/route.ts
+++ b/apps/web/app/api/v1/keys/route.ts
@@ -54,7 +54,7 @@ export async function POST(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`api-keys:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`api-keys:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/webhook-config/route.ts b/apps/web/app/api/webhook-config/route.ts
index eefa9f2..aa63d0d 100644
--- a/apps/web/app/api/webhook-config/route.ts
+++ b/apps/web/app/api/webhook-config/route.ts
@@ -59,7 +59,7 @@ export async function PUT(request: Request) {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`webhook-config:${user.id}`, 10, 60_000)
+ const rateLimitResult = await rateLimit(`webhook-config:${user.id}`, 10, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/app/api/webhook-config/test/route.ts b/apps/web/app/api/webhook-config/test/route.ts
index 5e58c9c..ae17c5b 100644
--- a/apps/web/app/api/webhook-config/test/route.ts
+++ b/apps/web/app/api/webhook-config/test/route.ts
@@ -21,7 +21,7 @@ export async function POST() {
return NextResponse.json({ error: "not authenticated" }, { status: 401 })
}
- const rateLimitResult = rateLimit(`webhook-test:${user.id}`, 5, 60_000)
+ const rateLimitResult = await rateLimit(`webhook-test:${user.id}`, 5, 60_000)
if (!rateLimitResult.success) {
return NextResponse.json({ error: "too many requests" }, { status: 429 })
}
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index e491c11..7c1d009 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -57,7 +57,7 @@ export async function authenticateApiRequest(
}
}
- const rateLimitResult = rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
+ const rateLimitResult = await rateLimit(`api:${keyRow.user_id}`, 100, 60_000)
if (!rateLimitResult.success) {
return {
diff --git a/apps/web/lib/rate-limit.ts b/apps/web/lib/rate-limit.ts
index 506511d..c68f02c 100644
--- a/apps/web/lib/rate-limit.ts
+++ b/apps/web/lib/rate-limit.ts
@@ -1,26 +1,26 @@
-const requestTimestamps = new Map<string, number[]>()
+import { createSupabaseAdminClient } from "@/lib/supabase/admin"
-export function rateLimit(
+export async function rateLimit(
identifier: string,
limit: number,
windowMilliseconds: number
-): { success: boolean; remaining: number } {
- const now = Date.now()
- const timestamps = requestTimestamps.get(identifier) ?? []
- const windowStart = now - windowMilliseconds
- const recentTimestamps = timestamps.filter(
- (timestamp) => timestamp > windowStart
- )
+): Promise<{ success: boolean; remaining: number }> {
+ const windowSeconds = Math.max(Math.floor(windowMilliseconds / 1000), 1)
+ const adminClient = createSupabaseAdminClient()
- if (recentTimestamps.length === 0) {
- requestTimestamps.delete(identifier)
- } else if (recentTimestamps.length >= limit) {
- requestTimestamps.set(identifier, recentTimestamps)
- return { success: false, remaining: 0 }
- }
+ const { data, error } = await adminClient.rpc("check_rate_limit", {
+ p_identifier: identifier,
+ p_limit: limit,
+ p_window_seconds: windowSeconds,
+ })
- recentTimestamps.push(now)
- requestTimestamps.set(identifier, recentTimestamps)
+ if (error) {
+ console.error("rate limit check failed:", error)
+ return { success: true, remaining: limit }
+ }
- return { success: true, remaining: limit - recentTimestamps.length }
+ return {
+ success: data.success as boolean,
+ remaining: data.remaining as number,
+ }
}
diff --git a/supabase/schema.sql b/supabase/schema.sql
index 6603c36..fb5f3ab 100644
--- a/supabase/schema.sql
+++ b/supabase/schema.sql
@@ -946,7 +946,7 @@ begin
select tier, custom_feed_count into current_tier, current_count
from public.user_profiles where id = new.user_id;
- maximum_allowed := case current_tier when 'free' then 1 when 'pro' then 1000 else 1 end;
+ maximum_allowed := case current_tier when 'free' then 1 when 'pro' then 1000 when 'developer' then 1000 else 1 end;
if current_count >= maximum_allowed then
raise exception 'You have reached the maximum number of custom feeds for your plan (% on % tier)', maximum_allowed, current_tier;
@@ -1200,3 +1200,47 @@ SELECT cron.schedule('purge-orphaned-feeds', '30 4 * * *', $$
and subscriber_count = 0
and updated_at < now() - interval '7 days';
$$);
+
+-- every 5 minutes: cleanup expired rate limit windows
+SELECT cron.schedule('cleanup-rate-limits', '*/5 * * * *', $$DELETE FROM public.rate_limits WHERE window_start < now() - interval '10 minutes'$$);
+
+-- =============================================================================
+-- rate limiting
+-- =============================================================================
+
+CREATE UNLOGGED TABLE public.rate_limits (
+ identifier text NOT NULL,
+ window_start timestamptz NOT NULL,
+ request_count integer NOT NULL DEFAULT 1,
+ PRIMARY KEY (identifier, window_start)
+);
+
+CREATE OR REPLACE FUNCTION public.check_rate_limit(
+ p_identifier text,
+ p_limit integer,
+ p_window_seconds integer
+) RETURNS jsonb
+LANGUAGE plpgsql
+SECURITY DEFINER
+SET search_path TO 'public'
+AS $function$
+DECLARE
+ v_window_start timestamptz;
+ v_current_count integer;
+BEGIN
+ v_window_start := to_timestamp(
+ floor(extract(epoch from now()) / p_window_seconds) * p_window_seconds
+ );
+
+ INSERT INTO public.rate_limits (identifier, window_start, request_count)
+ VALUES (p_identifier, v_window_start, 1)
+ ON CONFLICT (identifier, window_start)
+ DO UPDATE SET request_count = rate_limits.request_count + 1
+ RETURNING request_count INTO v_current_count;
+
+ RETURN jsonb_build_object(
+ 'success', v_current_count <= p_limit,
+ 'remaining', greatest(p_limit - v_current_count, 0)
+ );
+END;
+$function$;