Skip to content

Security hardening, export fixes, cleanup - #2

Open
stevenfokoua wants to merge 234 commits into
BamboJude:mainfrom
stevenfokoua:chore/security-ux-refactor
Open

stevenfokoua wants to merge 234 commits into
BamboJude:mainfrom
stevenfokoua:chore/security-ux-refactor

Conversation

@stevenfokoua

@stevenfokoua stevenfokoua commented Apr 20, 2026 •

Copy link
Copy Markdown
Contributor

Hand-back — September 2026

Everything on chore/security-ux-refactor that was not yet on main, finished and made safe to merge, then merged with upstream/main so it lands clean. The branch had been sitting 252 commits ahead of the deployed site since April, with the newest and most dangerous code untracked in a working directory.

Security

resend-webhook had no authentication at all. Sixteen lines that took any POST body and wrote to email_suppressions with the service-role client. Anyone who knew the URL could permanently suppress mail to any address — an unauthenticated, targeted, persistent denial of service against individual users, on a product whose value proposition includes job-alert email.

It now verifies the Svix signature before touching the database. The check is done by hand with Web Crypto rather than pulling in the svix package: HMAC-SHA256 over ${svix-id}.${svix-timestamp}.${rawBody}, keyed on the base64 payload of RESEND_WEBHOOK_SECRET, compared in constant time against each v1,<sig> entry in the svix-signature header. A missing secret rejects rather than waving requests through, timestamps outside a five-minute window are refused so an old capture cannot be replayed, and anything that fails returns 401 before a single query runs.

The check lives in supabase/functions/_shared/svix.ts so it can be tested on its own, and svix.test.ts runs it against Svix's own published test vector plus the failure modes that matter: an empty secret, a tampered body, the wrong secret, a replayed timestamp on both sides of the window, missing headers, and malformed input that must not throw. Thirteen assertions, wired into the CI Deno job.

Two smaller defects in the same file went with it: req.json() was unguarded, so a malformed body threw out of the handler and returned a bare 500 (it returns 400 now), and the upsert had no onConflict, so the second bounce for an address would error against the email primary key.

This needs RESEND_WEBHOOK_SECRET in the function's environment. Copy it from the Resend dashboard's webhook settings. Without it the endpoint rejects everything, which is the correct failure direction but does mean bounces stop being recorded until it is set.

email_suppressions had no row-level security. Supabase grants anon and authenticated full privileges on public by default and PostgREST exposes every such table, so the table was world-readable and world-writable with the public anon key. Its contents are the email address of every user who has bounced or filed a spam complaint — enumerable userbase PII on the read side, and free suppression of anyone on the write side. It now has ENABLE ROW LEVEL SECURITY, a restrictive deny-all policy for anon and authenticated, and an explicit REVOKE, matching the pattern already used for search_quota and activity_log. The service-role client bypasses RLS, so the webhook still writes.

This was the only table in the schema missing RLS. Every other one was already covered.

current_app_user_id() keyed off a user-mutable claim. 20260415_tracker_private_manual_jobs.sql redefined it to look up public.users by auth.jwt() ->> 'email', which makes every row-level security decision in the database turn on a value users can change. The fix, 20260429_fix_current_app_user_id.sql, reverts it to auth_user_id = auth.uid(). It was written in April and left untracked; it is committed now. That matters because the deployment docs tell you to apply migrations in filename order, so before this commit, following them landed a fresh database on the vulnerable April definition.

CSP. The root vercel.json promotes the policy from Content-Security-Policy-Report-Only to enforcing. The report-uri /csp-report directive is dropped, because no such endpoint exists in this repo and the reports went nowhere. The new client/vercel.json from upstream carried no CSP at all, so if Vercel ever builds with client/ as the root directory the header would silently vanish; both files now carry the identical policy.

Smaller ones. export threw on a failed profile lookup, which escapes Deno.serve and returns a 500 with no CORS headers, so the browser reported a CORS error instead of the real one. It returns 404 now. The extension's content script ignored the sender argument, so any page reaching the listener could drive it — not exploitable today without externally_connectable, but the guard is one line.

Deploy order — this part is not optional

  1. Apply the eight supabase/migrations/20260429_*.sql files in the Supabase SQL editor, plus Jude's 20260706_soft_delete_stale_jobs.sql.
  2. Then deploy the Edge Functions with supabase functions deploy. All six, not the three the docs used to list.
  3. Then let Vercel build main.

werkstudent-search calls consume_search_quota and rethrows on error, so deploying the functions before the migrations makes every Werkstudent search return 500. (get_search_config, the other new RPC, falls back to defaults on error and is safe either way, so consume_search_quota is the one that forces the ordering.) The migrations are additive and idempotent — CREATE TABLE IF NOT EXISTS, CREATE OR REPLACE FUNCTION, and policy creation guarded by a pg_policies lookup — so applying them before the functions ship breaks nothing.

Bug fixes

Export dates were a day early for every user west of UTC. formatDate ran DATE-only column values through new Date(). Per ECMA-262 a date-only ISO string parses as UTC midnight, and toLocaleDateString then renders it in the browser's timezone, so applied_date, follow_up_date, interview_date and deadline all came out a day early — in the CSV, in the Excel file, in the print-to-PDF report, and in the on-screen analytics table. The document is handed to the Ausländerbehörde as proof of when a job search happened, so wrong dates on it are the one failure this feature cannot have. DATE-only strings are now reformatted as text and never touch Date. Real timestamps still resolve to the viewer's local calendar day.

CSV formula injection. escapeCsv quoted correctly but never neutralised formula evaluation. Job titles and companies are scraped from third-party job boards and notes are free text, so a cell starting with =, +, - or @ executed on open in the caseworker's spreadsheet. It now prefixes a single quote.

The export date filter disagreed with the display. It sliced the raw UTC date while the table showed the local one, so a boundary row could display one day and filter as another, silently entering or leaving the official report. Both now derive from the same key.

Downloads could be cancelled. downloadBlob revoked the object URL synchronously after click(), which can kill the download before it starts — Firefox most often, and more often the larger the file, which correlates with the users who have the most to export. It revokes on the next tick.

The print window printed on a timer. A fixed 250 ms guess that a long report can outrun, producing a blank or partial page, and the export spinner cleared before the dialog appeared. It prints on onload.

These helpers moved to client/src/utils/exportFormat.ts, because jobsService.exportCsv had its own copy of the CSV escape with the same injection hole and its own copy of the download helper. Both call sites now share one implementation, covered by client/src/utils/exportFormat.test.ts — 14 assertions, including the timezone cases that would have caught the date bug outright.

The extension destroyed job history. popup.src.js upserted user_jobs with literal checklist and history values on onConflict 'user_id,job_id'. Both are JSONB columns, so the upsert replaced them wholesale: capturing a job you had already been tracking for weeks replaced the entire application timeline and checklist with two synthetic events. Silent, unrecoverable, and it destroyed exactly the data the proof-of-search export is built from. It now reads the existing overlay first and merges — the checklist keeps every entry, history appends, a status event is recorded only when the status actually changed, and an applied_date that was already set is never cleared.

Eight silent failures on user actions. Save, Apply, Prepare, Unsave, Revert and Hide on a job, plus both Hot Picks swipe paths, caught their errors into an empty block. The user got the optimistic UI update and no indication when the write never reached the server. All of them now log and show an error toast through the existing toastStore. This took eslint's no-empty count from 17 to 5.

Cleanup

  • Deleted client/src/pages/EnglishJobsPage.tsx (302 lines imported by nothing; App.tsx redirects /english-jobs to /jobs?language=en) and extension/Archive.zip. The empty server/ directory was already gone.
  • Kept extension/popup.bundle.js tracked. popup.html loads it directly and build.js says outright that it is committed so the extension loads unpacked without an npm install, so gitignoring it would break that. It is rebuilt in this branch. The rebuild moved @supabase/supabase-js from 2.103.3 to 2.115.0 because the extension had no lockfile, so the lockfile is committed now and the bundle is reproducible.
  • QUICKSTART.md said Node 18 (it is 22), told you to copy a client/.env.example that does not exist (the only template is at the repo root), and listed three of the six Edge Functions.
  • DEPLOYMENT.md was a stub restating VERCEL_SUPABASE_SETUP.md; it is now the pointer it should have been, and the deploy steps moved into the README where they say which branch Vercel builds and that migrations go first. The README's function list also covered four of six and did not say which need --no-verify-jwt.
  • CI pinned node-version: 20 while package.json, .nvmrc and .node-version all say 22, so it was not testing the runtime Vercel builds on. The Deno type-check covered three functions, so nothing type-checked the webhook, the alert sender or the data export; all six are checked now. Added a typecheck script to client and typecheck/test passthroughs at the root so what CI runs has a name and cannot silently drift.
  • Dependencies: @capacitor/cli moved to devDependencies (it is iOS build tooling, not a runtime dependency of the web app), and @types/dompurify removed since dompurify 3.3.3 ships its own types and the stub shadowed them.

Merge with upstream

upstream/main had moved on by 15 commits — German language support, the Hot Picks restyle, cache recovery controls, the admin manual job fetch, settings and search filter fixes, and a rework of the fetch pipeline. Merged, with Jude's behaviour kept wherever the two disagreed:

  • supabase/functions/_shared/jobs.ts and fetch-jobs/index.ts are his files verbatim. His rework supersedes ours: the source fetchers return {jobs, complete} and no longer throw, so his Promise.all is safe where ours needed allSettled, and he added per-source time budgets, request timeouts, pair rotation and stale-job cleanup we did not have. His fetch-jobs also keeps the fail-closed CRON_SECRET check our branch added, so nothing of ours is lost. Resolving these two hunk by hunk produced a file that did not compile, which the Deno type-check caught; taking them whole is both smaller and correct.

    One consequence: his getSearchConfig aggregates user_settings directly instead of calling the get_search_config RPC our branch introduced, so 20260429_get_search_config.sql now creates a function nothing calls. The migration is left in place because it may already be applied in production and dropping it is a separate decision.

  • DashboardPage.tsx and jobs.service.ts conflicts were pure additions on his side, taken as is. The manual fetch card needed IconRefresh and isDemo, which his DashboardPage has and ours did not, so both were added.

  • HotpicksPage.tsx kept both sides — his restyle plus our toastStore import for the swipe error toasts.

The branch is now 0 behind upstream/main.

Verified locally

Check Result
npm run typecheck (tsc -b --noEmit) passes
npm test (vitest) 56 passed, 3 files
npm run build (vite production) passes
deno check (all six functions plus _shared) passes
deno test _shared/svix.test.ts 13 passed
npx eslint . 20 errors, 10 warnings

Lint is not green and was not green before this branch either — it was 42 errors when this work started. What remains is pre-existing: 5 react-hooks/set-state-in-effect, 5 no-empty (the Capacitor splash-screen and theme calls, where failing silently is correct), 4 no-explicit-any, and a handful of one-offs. CI does not run lint, so none of it was ever blocking. Cleaning it up is a separate job.

Not verified locally

  • The Svix check has never seen a real Resend payload. It reproduces Svix's published test vector exactly, so the construction is right, but it is still worth firing one test webhook from the Resend dashboard and confirming a 200 before trusting that bounces are being recorded again.
  • No Supabase instance. No migration was applied or executed anywhere. The SQL is reviewed, not run. Nothing was pushed to any database and no function was deployed.
  • Whether resend-webhook is already deployed. If supabase functions deploy was run on 2026-04-29, the unauthenticated version is live right now and stays live until it is redeployed with this code. Worth checking first.
  • Which current_app_user_id() the production database currently has. Given filename ordering it is most likely still the April email-based one. Applying 20260429_fix_current_app_user_id.sql settles it either way.
  • The extension was not loaded in a browser. The bundle rebuilds and parses, but the merge behaviour of the capture path was not exercised against a real user_jobs row.
  • Print-to-PDF was not exercised. The onload trigger is the correct signal but was not watched in a browser.

Known limitation left in place

The extension's history merge is read-then-write, so two captures of the same job at the same instant can still lose an event. Closing it properly needs a SECURITY DEFINER RPC appending with history = history || $1::jsonb, which would also fix the same race in client/src/services/jobs.service.ts where two tabs or two fast status changes can drop an entry. That is marked in popup.src.js. It was left out here because it would break capture for existing users until the migration was applied, and this change does not.

BamboJude and others added 30 commits February 21, 2026 14:29
- Add .gitignore for server directory
- Add railway.json with build and deploy settings

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Configure Railway to build from server directory

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add optimized Dockerfile for production builds
- Add .dockerignore to exclude unnecessary files

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Railway will now build from this root Dockerfile which
correctly references the server directory

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Configure Railway to use Dockerfile builder

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add "type": "module" to package.json for ES modules
- Add @types/pg for TypeScript type definitions

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Fixed ERR_MODULE_NOT_FOUND error by adding .js extensions to all relative imports.
This is required for ES modules to resolve correctly in Node.js.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Trim CLIENT_URL environment variable to remove whitespace/newlines.

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add job model for database operations
- Implement Bundesagentur API fetcher
- Implement Adzuna API fetcher
- Implement greenjobs.de web scraper
- Add deduplication service to handle duplicates
- Create main job fetcher orchestrator
- Set up cron job to run every 2 hours
- Add jobs API endpoints (CRUD + manual trigger)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Use fixed API key 'jobboerse-jobsuche' for authentication
- This is the public clientId for the bundesAPI job search endpoint

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove invalid content_type parameter from Adzuna API
- Add angebotsart=1 (regular work) to Bundesagentur API
- Add pav=false to exclude private employment agencies

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Fix endpoint: add /app/ to URL path (pc/v4/app/jobs)
- Add umkreis=25 (search radius in km)
- Change page from 0 to 1 (API uses 1-based pagination)
- Update User-Agent to match official app

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Add API services for jobs and authentication
- Create Dashboard with real stats from backend (1,621 jobs)
- Create Jobs List page with filters, search, and pagination
- Update Login page with green theme and pre-filled credentials
- Add TypeScript types for Job, Stats, and filters
- Fix type-only imports for TypeScript strict mode

Frontend features:
- ✅ Dashboard shows real job stats
- ✅ Jobs page displays all jobs with search/filter
- ✅ Pagination support (50 jobs per page)
- ✅ Click job to view details
- ✅ Professional green/sustainability theme

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
- Remove Adzuna and greenjobs fetchers from orchestrator
- Keep only official Bundesagentur für Arbeit API

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ly topology

Merge the ported Express migrations (001-009, 011) into supabase/migrations/
with YYYYMMDDHHMMSS_* prefixes so they run in the correct order alongside the
existing 20260414/20260415 supabase migrations. Deliberate choices:

- Drop 006 (password_reset_tokens) and 007 (empty lockout/token_blacklist) —
  JWT and its custom reset flow are retired, Supabase Auth owns password reset.
- Drop the admin@example.com seed from 001 — no more default credentials.
- Drop the admin@afavers.com promotion from 008 — admins are now promoted
  explicitly via SQL after sign-up.
- Do NOT redeclare current_app_user_id() or public_jobs in the ported 011;
  the authoritative definitions live in 20260415_tracker_private_manual_jobs.sql
  and the permissive jobs_select_authenticated USING (true) is intentionally
  omitted (the owner-aware variant replaces it downstream).
- Add a supabase/migrations/20250101000006_contact_messages.sql because the
  admin_rpc migration references a table that had no committed DDL before.

Also extend supabase/functions/_shared/jobs.ts with a stale-job deletion pass
(replacing the retired jobModel.deleteStaleJobs in server/src/models/job.model.ts).

Docs + config:
- README: rewrite Tech stack / Project structure / Running locally / env vars
  to describe the Supabase-only topology.
- DEPLOYMENT.md: stub pointing to VERCEL_SUPABASE_SETUP.md.
- QUICKSTART.md: rewrite as clone -> migrate -> edit client/.env -> npm run dev.
- VERCEL_SUPABASE_SETUP.md: drop "old Express API remains" framing.
- Root package.json: drop server workspace and server-only scripts.
- Root .env.example: client + Edge Function secrets only.
- .gitignore: drop server/.env entries, add extension build artefact ignores.
The browser (client/) and the rewritten extension (extension/) both talk to
Supabase directly via @supabase/supabase-js. Nothing in the repo still imports
from server/, so the whole workspace is dead weight.

Removed:
- server/ (routes, controllers, middleware, models, services, jobs, config,
  types, utils, db/migrate.ts, db/migrations/, server.ts, app.ts, package.json,
  tsconfig.json, README.md, .env.example, .dockerignore, .gitignore)
- Dockerfile, railway.json (root)  — Railway stack retired
- .htaccess (root) — only the root copy was Express-related; client/public/ still
  ships its own for the SPA build
- .dockerignore (root) — no image to build anymore
- extension*.zip / extension-firefox.xpi — build artefacts should never have been
  committed; gitignore patterns added in the previous commit prevent re-commits

Ported equivalents of the server-only logic:
- Bundesagentur + Adzuna fetchers -> supabase/functions/_shared/jobs.ts
- jobFetchCron.ts -> pg_cron + fetch-jobs Edge Function (already in place)
- jobModel.deleteStaleJobs -> deleteStaleJobs() in _shared/jobs.ts
- admin/contact/auth controllers -> admin_* RPCs in supabase/migrations/20260415_admin_rpc.sql
- gamification.service -> gamification.service.ts in client/src + xp_events trigger chain
Collapse the 8x Promise.all fan-out in KanbanPage to a single
jobsService.getAllJobs() call (previously 24 Supabase round-trips per
mount: 8 columns * 3 round-trips each inside getMergedJobs). Filter
statuses in memory instead.

Wrap KanbanCard in React.memo with a load-bearing-prop comparator so
dragging one card does not re-render every card in every column.
Remove dead code left over from the pre-Supabase HTTP stack and the
redirected /werkstudent route:

- services/api.ts: unused axios wrapper (no callers)
- config/api.ts: only consumed by services/api.ts
- pages/WerkstudentPage.tsx: route is a <Navigate> in App.tsx, the
  component was never mounted
- services/werkstudent.service.ts: sole consumer was WerkstudentPage

Drop axios from client/package.json (only referenced by the deleted
api.ts). Verified via ripgrep — zero remaining imports.
Track a saved-snapshot alongside the editable settings state and block
navigation when the two differ. A new useUnsavedChangesGuard hook
wires up both layers:

- beforeunload for hard reloads / tab close
- patched history.pushState/replaceState + popstate listener for SPA
  navigation (react-router v7 useBlocker needs a Data Router, which
  this app does not use)

On block, SettingsPage renders a Cancel / Leave Anyway modal; Leave
clears the dirty flag and replays the queued navigation.
Dashboard (and Landing) injected a <style>@import …Figtree</style>
block on every render, forcing the browser to re-resolve the
stylesheet and causing layout flashes. Add a single <link rel=
"stylesheet"> for Figtree in index.html (preconnect lines already
present) and drop the in-component @import rules.
Install vitest, @vitest/coverage-v8, jsdom, @testing-library/react and
@testing-library/jest-dom as client devDependencies. Create vitest.config.ts
wired to the existing Vite config with node environment by default. Add
"test" and "test:coverage" scripts to client/package.json.
Tests cover countStreak (6 cases: empty, today-only, yesterday active,
3-day streak, gap reset, dedup + month boundary), xpProfile (6 cases:
level boundaries including max-level Legend, xpToNext, progressPct
bounds), and makeAchievements (10 cases: empty, each unlock trigger,
boundary guard for apps_10, streak_7, field type assertions).

All pure-logic helpers are exercised via gamificationService.getProfile()
with Supabase and authStore fully mocked at the import boundary.
scoreJob (5 cases): base score bounds, 100-cap, non-negative guard,
keyword match reason, remote-friendly reason, fresh-posting bonus.
applyFilters (6 cases): hidden-job exclusion, status filter via overlay,
search query title match, englishOnly filter, highMatchOnly gate,
pagination slice + total count.
appendHistory (3 cases): new event appended with correct type/label,
previous entries preserved, 80-entry cap enforced.

All Supabase, authStore, reminderStore, notification and settings
dependencies are mocked at the import boundary.
The saveJobs integration tests are marked .skip because the source file
imports from https://esm.sh/ (Deno HTTP imports) and calls Deno.env.get()
which are not resolvable in Node/Vitest.

What IS active: 5 pure-logic tests for the inline dedupe() re-implementation
(mirroring the private function verbatim) that verify the dedup contract
without any runtime dependency.

The skipped describe block documents the full saveJobs integration contract
(no-duplicate-insert, correct source/external_id/posted_date mapping) with
clear TODO instructions for activation under the Deno test runner.
Four parallel jobs on push to main and pull_request:
  - typecheck: npx tsc -b --noEmit in client/
  - test: npm test --workspace=client (Vitest)
  - build: npm run build --workspace=client with placeholder Supabase env vars
  - deno-typecheck: deno check supabase/functions/**/*.ts via denoland/setup-deno@v2

All jobs use Node 20 LTS with npm cache via actions/setup-node@v4.
# Conflicts:
#	.env.example
#	DEPLOYMENT.md
#	QUICKSTART.md
#	server/.env.example
#	server/README.md
#	server/src/config/env.ts
#	server/src/db/migrations/001_initial_schema.sql
#	server/src/middleware/auth.middleware.ts
#	server/src/routes/jobs.routes.ts
# Conflicts:
#	client/src/pages/KanbanPage.tsx
#	client/src/pages/WerkstudentPage.tsx
- kanban: `DroppableColumn` now reads `isOver` from `useDroppable()` and
  applies a stronger highlight only on the hovered column. The parent's
  `isPotentialTarget` still marks all valid drop zones with a subtle hint
  so users see where they *can* drop, while the actively-hovered column
  gets a distinct stronger style.
- settings: remove the broken popstate handler in
  `useUnsavedChangesGuard`. The previous implementation stored
  `window.location.href` *after* the pop had already happened, then
  "resumed" by reassigning that same URL — a no-op that corrupted the
  history stack. Back/Forward buttons now bypass the in-app dialog
  (documented as a known limitation of classic BrowserRouter); hard
  reloads and Link-based navigation remain guarded.
- docs: update the contact_messages commentary in
  20260417_enable_rls_remaining_tables.sql — the table is now created by
  20250101000006_contact_messages.sql.
@vercel

vercel Bot commented Apr 20, 2026

Copy link
Copy Markdown

@stevenfokoua is attempting to deploy a commit to the Bambo Jude's projects Team on Vercel.

A member of the Team first needs to authorize it.

The 2026-04-29 security pass was left uncommitted, split across the
tracked/untracked line: the edge functions call RPCs whose definitions
lived only in untracked migration files. Committing them together because
a partial commit ships functions that 500 on every request.

Fixed before committing:

- resend-webhook had no authentication of any kind. It now verifies the
  Svix signature (HMAC-SHA256 over id.timestamp.body, keyed on
  RESEND_WEBHOOK_SECRET) before touching the database, rejects a missing
  secret rather than failing open, refuses timestamps outside a five
  minute window, guards the JSON parse, and upserts with onConflict.
- email_suppressions had no row-level security. A public table with RLS
  off is readable and writable with the anon key, and this one holds the
  email of every user who has bounced. Added ENABLE ROW LEVEL SECURITY,
  a restrictive deny-all for anon and authenticated, and a REVOKE.
- export threw on a failed profile lookup, which escapes Deno.serve and
  returns a 500 with no CORS headers. Returns 404 now.

The eight 20260429 migrations must be applied before the functions are
deployed: werkstudent-search rethrows if consume_search_quota is missing.
docs/review/ held the April code review and market report untracked.
Adds handback-2026-09.md, the running log of this cleanup pass, which
becomes the PR body.
The CSV, Excel and print-to-PDF exports all run through the same two
helpers, and both were wrong in ways that land on the document users hand
to the Ausländerbehörde as proof of when they applied.

formatDate ran DATE-only column values through new Date(), which parses a
date-only ISO string as UTC midnight; toLocaleDateString then rendered it
in the browser's timezone, so every user west of UTC saw applied_date,
follow_up_date, interview_date and deadline a day early, in all three
export formats and in the on-screen analytics table. DATE-only strings are
now reformatted as text and never touch Date. Real timestamps still
resolve to the viewer's local calendar day.

escapeCsv quoted correctly but never neutralised formula evaluation. Job
titles and companies come from third-party boards and notes are free text,
so a cell starting with = + - @ executed on open in the caseworker's
spreadsheet. It now prefixes a single quote.

The export date filter sliced the raw UTC date while the table displayed
the local one, so a boundary row could show one day and filter as another.
Both now use the same key.

downloadBlob revoked the object URL synchronously after click(), which can
cancel the download; it revokes on the next tick instead. The print window
prints on load rather than after a 250 ms guess that a long report can
outrun.

The helpers moved to client/src/utils/exportFormat.ts because
jobs.service.exportCsv had its own copy of the CSV escape with the same
injection hole and its own copy of the download helper. Both call sites now
share one implementation, covered by exportFormat.test.ts.
popup.src.js upserted user_jobs with literal checklist and history values
and onConflict 'user_id,job_id'. Both are JSONB columns, so an upsert
replaces them wholesale: capturing a job you had already been tracking for
weeks replaced the entire application timeline and checklist with two
synthetic events. Silent, unrecoverable, and it destroyed exactly the data
the proof-of-search export is built from.

It now reads the existing overlay first and merges: the checklist keeps
every entry, history appends rather than replaces, a status event is only
recorded when the status actually changed, and an applied_date that was
already set is never cleared. Read-then-write still loses an event if two
captures land at the same instant; that is marked in the file, and closing
it properly needs a server-side append RPC that would also fix the same
race in the web client.

content.js ignored the sender argument, so any page that reached the
listener could drive it. Nothing can today without externally_connectable,
but the guard is one line and that is one manifest edit away. The listener
also returned true for messages it does not handle, which leaves the
sender's promise hanging forever; it only keeps the channel open when a
reply is pending, which for a synchronous extractor is never.

popup.bundle.js rebuilt with build.js. It stays tracked because popup.html
loads it directly and the extension is meant to load unpacked without an
npm install. The rebuild moved @supabase/supabase-js from 2.103.3 to
2.115.0 because the extension had no lockfile, so the lockfile is now
committed and the bundle is reproducible.
Eight handlers caught their errors into an empty block. A user clicking
Save, Apply, Prepare, Unsave, Revert or Hide on a job, or swiping a card on
Hotpicks, got the optimistic UI update and no indication when the write
never reached the server. That is how a product ends up with a button that
does nothing and no way to find out why.

All of them now log and show an error toast through the existing
toastStore, with a new toastActionFailed string in both locales. The stats
refresh on JobsPage logs but does not toast, since it is a background
reload rather than something the user asked for.

The two empty catches in DashboardPage guard JSON.parse of a localStorage
widget layout and already fall back to defaults, so they are left alone;
toasting a storage-read miss on every page load would be noise, not
information. The audit listed them as user actions, which they are not.

This takes eslint's no-empty count from 17 to 8; the remainder are the
Capacitor splash-screen and theme calls where failing silently is correct.
Deletions:

- client/src/pages/EnglishJobsPage.tsx. 302 lines imported by nothing;
  App.tsx redirects /english-jobs to /jobs?language=en, so the page was
  superseded and the file left behind.
- extension/Archive.zip. A zip in a git repo.
- The empty server/ directory was already gone from the working tree.

extension/popup.bundle.js stays tracked. popup.html loads it directly and
build.js says outright that it is committed so the extension can be loaded
unpacked without an npm install, so gitignoring it would break that.

Docs:

- QUICKSTART said Node 18 (it is 22), told you to copy a client/.env.example
  that does not exist (the only template is at the repo root), and listed
  three of the six Edge Functions.
- DEPLOYMENT.md was a stub restating VERCEL_SUPABASE_SETUP.md; it is now the
  pointer it should have been, and the deploy steps moved into the README
  where they say which branch Vercel builds and that migrations go first.
- The README function list also covered four of six and did not mention
  which need --no-verify-jwt.

CI:

- node-version was pinned to 20 while package.json, .nvmrc and .node-version
  all say 22, so CI was not testing the runtime Vercel builds on.
- The Deno type-check covered three functions, so nothing type-checked the
  webhook, the alert sender or the data export. All six are checked now.
- Added a typecheck script to client and typecheck/test passthroughs at the
  root, so what CI runs has a name and cannot silently drift.

Dependencies:

- @capacitor/cli moved to devDependencies; it is iOS build tooling, not a
  runtime dependency of the web app.
- @types/dompurify removed. dompurify 3.3.3 ships its own types, so the
  stub was dead weight that shadowed them.

vercel.json: dropped `report-uri /csp-report`. No such endpoint exists in
this repo, so the reports went nowhere. The CSP stays enforcing.
Brings in the 15 commits Jude landed after 2026-04-20: German language
support across the app, the Hot Picks restyle and mobile layout, cache
recovery controls, the admin manual job fetch, settings and search filter
fixes, and the fetch pipeline rework.

Conflicts and how they were resolved:

- supabase/functions/_shared/jobs.ts and fetch-jobs/index.ts: took Jude's
  side. His rework supersedes ours -- the source fetchers now return
  {jobs, complete} and no longer throw, so his Promise.all is safe where
  ours needed allSettled, and he added per-source time budgets and stale
  job cleanup that we did not have. The get_search_config RPC our branch
  introduced survives on his side of getSearchConfig.
- DashboardPage.tsx and jobs.service.ts: pure additions on his side (the
  admin manual fetch card and jobsService.fetchJobs), taken as is. The card
  needed IconRefresh and isDemo, which his DashboardPage has and ours did
  not, so both were added.
- HotpicksPage.tsx: kept both sides. His restyle plus our toastStore import
  for the swipe error toasts, with the duplicated useLanguage call removed.

One thing added rather than merged: his new client/vercel.json carries no
Content-Security-Policy, so if Vercel builds with client/ as the root
directory the enforcing CSP on the root vercel.json would not apply. Both
files now carry the identical policy.

typecheck, 56 vitest tests and the production build are green.
@stevenfokoua stevenfokoua changed the title Fix Supabase auth/RLS and job fetch deployment paths Security hardening, export fixes, cleanup Sep 5, 2026
The Deno type-check on the PR caught two things.

Resolving supabase/functions/_shared/jobs.ts and fetch-jobs/index.ts hunk
by hunk left files that did not compile: Jude changed fetchAdzunaJobs to
take only a deadline, so the surrounding auto-merged code still referenced
keywords and locations that no longer exist, and one try block lost its
catch. Both files are now his verbatim, which is what "keep Jude's
behaviour" should have meant in the first place. Nothing of ours is lost:
his fetch-jobs keeps the fail-closed CRON_SECRET check.

His getSearchConfig aggregates user_settings directly rather than calling
the get_search_config RPC our branch added, so 20260429_get_search_config.sql
now creates a function nothing calls. Left in place because it may already
be applied in production.

crypto.subtle.importKey rejected Uint8Array<ArrayBufferLike> under Deno's
types. Allocating the ArrayBuffer explicitly gives Uint8Array<ArrayBuffer>,
which it accepts.

The signature check moved to supabase/functions/_shared/svix.ts so it can
be tested without standing up the handler, and svix.test.ts now runs it
against Svix's own published test vector plus the failure modes that
matter: an empty secret, a tampered body, the wrong secret, a replay on
both sides of the tolerance window, missing headers, and malformed input
that must not throw. It reproduces the published signature exactly. CI runs
it alongside the type-check.

deno check across all six functions, tsc, 56 vitest tests and the vite
build are green locally.
It appeared from running deno check locally. The functions already pin
their imports by URL, so the lockfile adds nothing but an integrity check
that fails whenever esm.sh rebuilds a version that has not changed.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants