Security hardening, export fixes, cleanup - #2
Open
stevenfokoua wants to merge 234 commits into
Open
stevenfokoua wants to merge 234 commits into
stevenfokoua wants to merge 234 commits into
Conversation
- 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
# Conflicts: # package-lock.json
- 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.
|
@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.
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Hand-back — September 2026
Everything on
chore/security-ux-refactorthat was not yet onmain, finished and made safe to merge, then merged withupstream/mainso 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-webhookhad no authentication at all. Sixteen lines that took any POST body and wrote toemail_suppressionswith 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
svixpackage: HMAC-SHA256 over${svix-id}.${svix-timestamp}.${rawBody}, keyed on the base64 payload ofRESEND_WEBHOOK_SECRET, compared in constant time against eachv1,<sig>entry in thesvix-signatureheader. 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.tsso it can be tested on its own, andsvix.test.tsruns 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 theupserthad noonConflict, so the second bounce for an address would error against theemailprimary key.This needs
RESEND_WEBHOOK_SECRETin 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_suppressionshad no row-level security. Supabase grantsanonandauthenticatedfull privileges onpublicby 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 hasENABLE ROW LEVEL SECURITY, a restrictive deny-all policy foranonandauthenticated, and an explicitREVOKE, matching the pattern already used forsearch_quotaandactivity_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.sqlredefined it to look uppublic.usersbyauth.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 toauth_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.jsonpromotes the policy fromContent-Security-Policy-Report-Onlyto enforcing. Thereport-uri /csp-reportdirective is dropped, because no such endpoint exists in this repo and the reports went nowhere. The newclient/vercel.jsonfrom upstream carried no CSP at all, so if Vercel ever builds withclient/as the root directory the header would silently vanish; both files now carry the identical policy.Smaller ones.
exportthrew on a failed profile lookup, which escapesDeno.serveand 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 thesenderargument, so any page reaching the listener could drive it — not exploitable today withoutexternally_connectable, but the guard is one line.Deploy order — this part is not optional
supabase/migrations/20260429_*.sqlfiles in the Supabase SQL editor, plus Jude's20260706_soft_delete_stale_jobs.sql.supabase functions deploy. All six, not the three the docs used to list.main.werkstudent-searchcallsconsume_search_quotaand 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, soconsume_search_quotais 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 apg_policieslookup — so applying them before the functions ship breaks nothing.Bug fixes
Export dates were a day early for every user west of UTC.
formatDateran DATE-only column values throughnew Date(). Per ECMA-262 a date-only ISO string parses as UTC midnight, andtoLocaleDateStringthen renders it in the browser's timezone, soapplied_date,follow_up_date,interview_dateanddeadlineall 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 touchDate. Real timestamps still resolve to the viewer's local calendar day.CSV formula injection.
escapeCsvquoted 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.
downloadBlobrevoked the object URL synchronously afterclick(), 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, becausejobsService.exportCsvhad 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 byclient/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.jsupserteduser_jobswith literalchecklistandhistoryvalues ononConflict '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 anapplied_datethat 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'sno-emptycount from 17 to 5.Cleanup
client/src/pages/EnglishJobsPage.tsx(302 lines imported by nothing;App.tsxredirects/english-jobsto/jobs?language=en) andextension/Archive.zip. The emptyserver/directory was already gone.extension/popup.bundle.jstracked.popup.htmlloads it directly andbuild.jssays outright that it is committed so the extension loads unpacked without annpm install, so gitignoring it would break that. It is rebuilt in this branch. The rebuild moved@supabase/supabase-jsfrom 2.103.3 to 2.115.0 because the extension had no lockfile, so the lockfile is committed now and the bundle is reproducible.client/.env.examplethat does not exist (the only template is at the repo root), and listed three of the six Edge Functions.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.node-version: 20whilepackage.json,.nvmrcand.node-versionall 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 atypecheckscript toclientandtypecheck/testpassthroughs at the root so what CI runs has a name and cannot silently drift.@capacitor/climoved todevDependencies(it is iOS build tooling, not a runtime dependency of the web app), and@types/dompurifyremoved since dompurify 3.3.3 ships its own types and the stub shadowed them.Merge with upstream
upstream/mainhad 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.tsandfetch-jobs/index.tsare his files verbatim. His rework supersedes ours: the source fetchers return{jobs, complete}and no longer throw, so hisPromise.allis safe where ours neededallSettled, and he added per-source time budgets, request timeouts, pair rotation and stale-job cleanup we did not have. Hisfetch-jobsalso keeps the fail-closedCRON_SECRETcheck 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
getSearchConfigaggregatesuser_settingsdirectly instead of calling theget_search_configRPC our branch introduced, so20260429_get_search_config.sqlnow 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.tsxandjobs.service.tsconflicts were pure additions on his side, taken as is. The manual fetch card neededIconRefreshandisDemo, which his DashboardPage has and ours did not, so both were added.HotpicksPage.tsxkept both sides — his restyle plus ourtoastStoreimport for the swipe error toasts.The branch is now 0 behind
upstream/main.Verified locally
npm run typecheck(tsc -b --noEmit)npm test(vitest)npm run build(vite production)deno check(all six functions plus_shared)deno test _shared/svix.test.tsnpx eslint .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, 5no-empty(the Capacitor splash-screen and theme calls, where failing silently is correct), 4no-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
resend-webhookis already deployed. Ifsupabase functions deploywas 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.current_app_user_id()the production database currently has. Given filename ordering it is most likely still the April email-based one. Applying20260429_fix_current_app_user_id.sqlsettles it either way.user_jobsrow.onloadtrigger 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 DEFINERRPC appending withhistory = history || $1::jsonb, which would also fix the same race inclient/src/services/jobs.service.tswhere two tabs or two fast status changes can drop an entry. That is marked inpopup.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.