Skip to content

Commit 029174b

Browse files
authored
feat: share/og pipeline, history insights, scanner UX upgrades (#10)
* perf(api): added in-memory result cache to /api/analyze - keyed by sha-256 of the full prompt so template edits auto-bust entries - lru eviction at 200 entries, 24h ttl per entry - cache check sits behind the rate limiter so abuse protection still applies - response now includes _cached flag (additive, non-breaking) - per-region in-memory; cold starts lose it. swap to vercel kv later if needed * feat(seo): added dynamic robots.txt and sitemap.xml routes - robots.txt allows crawlers, blocks /api, /history, /login - sitemap lists public routes (/, /scanner, /about) with lastmod, changefreq, priority - both routes derive origin from request, so they auto-track preview/prod domains - robots.txt prerendered for cdn-friendly delivery * feat(security): added baseline security headers via hooks - HSTS (1y, includeSubDomains, preload), Referrer-Policy, Permissions-Policy - X-Content-Type-Options nosniff, X-Frame-Options DENY - applied as defaults so per-route headers (e.g. /api/analyze) take precedence - covers both regular routes and the docs static-serving path * feat(scanner): cancellable in-flight scoring - scoreLLM accepts an external AbortSignal and returns a discriminated result so callers distinguish 'cancelled' from 'error' (deterministic fallback no longer kicks in when the user cancels mid-flight) - scoresStore tracks an AbortController; startScoring aborts any prior scan and returns the signal; reset/setError also abort cleanly - scanner page threads the signal through and bails silently on cancel, preventing the stale-results race when re-scanning during an LLM call * fix(api): rate limiter now distinguishes minute vs daily and emits Retry-After - Retry-After header now matches the actual reset window (not hardcoded 60s) - response body adds retryAfter (seconds) and a clearer reason in the error message - check both windows before incrementing; previously a daily-limit failure still consumed a minute slot, double-charging the user - discriminated return type from checkRateLimit for cleaner call-site handling * feat(scanner): live retry-after countdown in fallback toast - scoreLLM now distinguishes 429 from generic error and parses both Retry-After header and the response body's retryAfter field - scoresStore tracks an absolute llmRetryAtMs timestamp so the countdown stays accurate across re-renders - ScoreDashboard ticks once per second only while the toast is visible and a retry timestamp is set; the line auto-clears when it hits 0 * test: added coverage for classification and llm fallback - classification.test.ts locks down score-tier boundaries (>= not >), label/color mapping, and behavior on negative inputs - fallback.test.ts covers shape, experience-level/education/role-type detection, and the four suggestion-trigger paths - 32 new tests, all green; total now 138 across 16 files * refactor(api): extracted cache and rate-limiter from +server.ts - cache.ts owns the LRU result cache (hashPrompt, getCached, setCached) - rate-limiter.ts owns the per-IP minute/day windows and the discriminated result - +server.ts shrinks from ~360 to ~210 lines and reads as orchestration - behavior unchanged; module-level state preserved by keeping Maps inside the new files co-located tests in tests/unit/api/ cover hash determinism/length, cache round-trip and overwrite, and rate-limit boundaries (MAX_RPM exactly, inter-IP independence, retry-after shape). 17 new tests, all green. * feat(dashboard): added scan comparison band - new comparison.ts pure function returns per-platform deltas, average delta, passing-count delta, and improved/regressed/unchanged tallies - ScoreDashboard renders a colored band ("score went from 67 to 78 +11") with up/down/flat states, only on fresh scans for authenticated users with prior history; suppressed when viewing a history snapshot - store snapshots scanHistory[0] at startScoring time into previousScanForComparison so the band stays correct during the brief race between finishScoring (results visible) and the async saveToHistory -> loadHistory cycle - 6 unit tests cover empty inputs, mixed deltas, sign handling, missing platforms, and stable platform ordering. 161 tests total, all green. * feat(seo): per-route og/twitter meta and SoftwareApplication json-ld - new SeoHead component emits title/description/canonical/og/twitter on every public route, with og:url and canonical derived from the request origin so preview deployments bind to their own URLs automatically - removed the static og/twitter block from app.html since per-route tags would otherwise render duplicates - landing page also emits SoftwareApplication json-ld (with offers price 0 to mark it free) for rich-result eligibility on Google - json-ld output escapes '<' to its unicode form so a future user- controlled field can never close the surrounding <script> tag dry-tested: dev server up, curl'd /, /scanner, /about - each shows distinct og:title/description/url, twitter:* tags, canonical link, no duplicates. ld+json parsed cleanly via node. typecheck + lint + 161 tests all green. dev server stopped, working tree clean. * perf(firebase): lazy-load firebase sdk so landing page no longer pays for it - $lib/firebase exports a memoized getFirebase() that dynamic-imports firebase/app, firebase/auth, firebase/firestore on first use; type-only Auth/Firestore imports are erased at compile time so they don't bundle - authStore, scoresStore, and Hero all updated to await getFirebase() before touching auth/db; method-level dynamic imports keep each method pulling only the firestore symbols it needs - before: node 0 (root layout = every route) eagerly statically imported the firebase chunk (~488kb min). after: zero firebase references in any node bundle - the chunk only ships when a consumer code path runs dry-tested: typecheck + lint + 161 tests green; production build clean; post-build inspection of nodes 0-7 shows firebase-imports=0 on all and firebase-strings=0 except node 3 which has the literal word in the about-page tech-stack list (not a runtime dep). dev server boots, /, /scanner, /about all return 200. dev server stopped, working tree clean. * feat: error boundary, healthz, and csp report-only - src/routes/+error.svelte: branded error page handles 404/429/5xx and surfaces $page.error.message for client errors; matches the dark glassmorphic theme and reuses SeoHead with noIndex - src/routes/healthz/+server.ts: liveness probe for uptime checkers; returns {status,timestamp} with no-store and the standard headers - src/routes/api/csp-report/+server.ts: receives violation reports and console.warns them so vercel log aggregation surfaces issues without touching firestore or any paid tier - hooks.server.ts: emits Content-Security-Policy-Report-Only with a policy sized for sveltekit hydration, google fonts, firebase auth + firestore, and the LLM proxies; report-uri points at /api/csp-report. the csp-report endpoint itself is excluded from CSP injection to avoid feedback loops on its own error responses dry-tested: typecheck + lint + 161 tests; dev server probed with curl: - /healthz -> 200 with valid json + headers - bogus path -> 404 rendering branded error card - / -> Content-Security-Policy-Report-Only present - /api/csp-report POST -> 204 with no csp header on its own response * feat(history): per-row delta pill in scan history list each history entry shows a small +N/-N pill next to the average score, computed against the chronologically prior scan (history is newest-first so history[i+1] is the previous one). only renders when there's a non-zero delta and a prior scan exists. zero-allocation reuse of the existing scoresStore.history derivation. * feat(dashboard): twitter share-intent for the improvement moment when the comparison band shows a positive delta, render a small "Share +N" pill that opens twitter intent with pre-filled text referencing the user's actual delta and the site URL. origin pulled from window.location so preview deploys share their own URL. self-contained, no new deps, no infra. only renders when delta > 0 so users who tied or regressed don't see a (false) "share" prompt. * fix: bugs at scale - throttle limiter cleanup, race-fix in scan comparison rate-limiter: at >MAX_MAP_SIZE (10k) the daily map sits above threshold continuously, and the old code ran the O(n) cleanup on every request. throttled to at most once per CLEANUP_INTERVAL_MS (30s) so cleanup cost is bounded regardless of map size. matters past ~10k unique daily IPs. scores store: a rapid re-scan before the previous saveToHistory's async loadHistory completes left scanHistory[0] stale, so the snapshot used by the comparison band pointed at the scan BEFORE the immediate previous one. fixed by preferring the currently-visible results (in-memory truth) over scanHistory[0] when computing the snapshot at startScoring. * feat: dynamic og image and share landing page @vercel/og + an edge route at /api/og generates per-share PNG cards (score / verdict / passing-count, optionally a +N or -N delta pill) from query params. element tree built as plain objects so we don't need JSX in the project. new /share route reads the same query params, renders a polished landing card, and emits og:image pointing at /api/og with the same query - linkedin/twitter previews show the user's actual score. SeoHead's noIndex isn't applied here so crawlers DO index the page. dry-tested: dev server boots; curl /api/og returns image/png with valid PNG magic bytes; /share emits og:image=/api/og?... in head. @vercel/og's default cache-control (no-cache, no-store) is left as-is since user-specific share URLs should refetch when params change. ShareBadge wiring is a follow-up - this iteration ships the infra. * feat(share): wired share-link flow through OG infra ShareBadge: - shareUrl derives /share?score=X&pass=Y&total=Z from window.location - new "Copy share link" button (Clipboard API with execCommand fallback for insecure origins), shows "Link copied" for 1.8s on success - shareToLinkedIn now appends shareUrl to the post text so linkedin's crawler fetches /share and renders the dynamic OG card in the preview ScoreDashboard: - improvement twitter-intent button now points at /share?score=...&delta=... rather than the homepage, so twitter's preview shows the actual delta via the /api/og PNG instead of a generic site card * feat(share): added "Share to X" button to ShareBadge symmetry with the linkedin button. uses twitter intent with the same /share URL so the preview card shows the dynamic OG (per-share PNG with the user's actual score), not a generic site preview. shareText is too long for X's 280-char cap so a tighter version is built inline; URL is appended via intent's url param. * chore: login form hardening + SeoHead on /login and /history login: - normalizeEmail trims and lowercases on submit so "User@Example.com" and "user@example.com" land on the same firebase account instead of silently creating duplicates - displayName trimmed and capped at 80 chars on submit; input gets maxlength=80 and autocomplete=name SeoHead conversions: - /login: indexable (entry point for "ats screener login" searches), with proper canonical / og / twitter cards - /history: noIndex (auth-gated, would 404 to crawlers anyway), so search engines don't waste crawl budget hitting redirect-to-login * feat(scanner): live JD skill-extraction preview + fix prod build JD preview (in JobDescriptionInput): - as the user types, the parser runs on a 400ms debounce and shows detected role/industry/experience-level chips plus the top 12 extracted skills as chips - when a resume is loaded, matched skills get a green check and the preview shows "X of Y in your resume" - real-time signal of how well this resume aligns with this JD before the user even hits scan - parser is dynamically imported so compromise/skills-taxonomy don't ship in the layout chunk for landing-page visitors build fix (CRITICAL - this would fail on Vercel): - /api/og had runtime: 'edge' which caused esbuild to bundle hooks + root.js for the edge runtime. that bundle includes references to node:crypto / node:fs / node:path that edge can't resolve, so the whole production build broke - runtime: 'edge' is also deprecated in adapter-vercel as of recent versions - moved /api/og to default node runtime (which @vercel/og >=0.6 supports) and added maxDuration: 30 - moved docs-serving from hooks.server.ts to a /docs/[...slug]/+server.ts catchall, so fs/path live in a node-runtime route instead of polluting the shared bundle dry-tested: pnpm build:app succeeds; dev server boots, /api/og returns valid PNG (magic bytes verified), /share emits correct og:image, /docs/[...slug] catchall returns 404 when no doc exists. typecheck + lint + 161 tests all green. * feat(dashboard): per-card delta + a11y polish on toggles ScoreCard now accepts an optional previousScore prop and renders a small +N/-N pill anchored at the bottom-right of the score ring when the platform's score changed since the prior scan. ScoreDashboard builds a Map<system, previousScore> from comparison.platforms once and threads it through, so each card knows its own delta without recomputing. a11y polish: aria-expanded added to the three big disclosure toggles (ScoreBreakdown system row, JobDescriptionInput "Add JD", ScanHistory list) so screen readers announce the open/closed state correctly. * feat(suggestions): before/after example templates on expanded cards new $engine/suggestions/templates module classifies a suggestion's free-text summary into one of seven categories (quantification, action-verbs, missing-keywords, skills-section, short-resume, sections, format) via regex patterns and attaches a concrete tip + before/after example. pure functions, deterministic, no LLM call. ScoreDashboard's expanded suggestion view now renders the example inline as a side-by-side red/green diff pair so the user sees exactly what to change instead of just being told something is wrong. collapses to a single column under 640px. 11 unit tests in tests/unit/suggestions/templates.test.ts cover the classifier (per-category cues + ambiguous-text null) and the example shape (every type maps to a fully-populated record). 172 tests total. * feat(history): score timeline svg chart with hover tooltips new $engine/scorer/timeline.ts contains the pure geometry helper that takes the scan history (any order), sorts it ascending by timestamp, and returns evenly-spaced points + a path D string + an area D string for a left-to-right trajectory chart. inverted y so higher scores render visually higher; honors a custom viewBox. new ScoreTimeline component is a self-contained SVG chart - no chart library dep. each point renders a colored dot using the same tier palette as the dashboard. pointer-move snaps to the nearest dot and shows a tooltip with score / mode / file / date. tooltip flips left of the dot when near the right edge to avoid clipping. /history embeds the chart above the grid when >=2 scans exist; below that threshold it stays hidden. 7 unit tests cover sort order, y inversion, path shape, area closing, point clamping, and viewBox override. 179 tests total. * perf(og): cache /api/og at the vercel cdn for 24h @vercel/og's ImageResponse hardcodes Cache-Control: no-cache, no-store and the constructor's headers option only CONCATENATES rather than replaces (verified locally - the response shipped with both directives contradicting each other). re-wrapping the rendered bytes in a fresh Response is the only way to actually set the header. new policy: public, max-age=3600 (browser 1h), s-maxage=86400 (vercel cdn 1d), stale-while-revalidate=604800 (7d background refresh), immutable. since the URL is fully content-addressed by score+pass+ total+delta, every unique share combination caches forever - repeat hits from linkedin/twitter crawlers and individual visitors all serve from the edge with zero function invocation. real cost protection that scales linearly with NEW share combos rather than total clicks. dry-tested via curl: cache-control header now contains exactly the intended directives, no contradictions. typecheck + build clean. * feat: copy buttons on suggestion examples + journey stats card ScoreDashboard: - "Copy" pill on each before/after example block (uses Clipboard API with execCommand fallback for insecure origins). copiedKey is per-block so multiple copies show their own "copied" state without trampling each other. e.stopPropagation prevents the copy click bubbling to the surrounding suggestion-card and collapsing the block. role=button + tabindex=0 + keydown(Enter|Space) because the outer card is already a <button> and html disallows nested buttons /history: - new $engine/scorer/journey.computeJourneyStats pure function derives total improvement, best score, scan count, strongest-gain platform, and days-span from the user's history (sorted ascending, per-platform delta only when both first and latest have it) - inline stats card renders above the timeline when >=2 scans exist; big numbers, color-coded for positive/negative 4 unit tests cover null/empty input, single-scan no-delta, two-scan delta + best-platform, and input-order independence. 183 tests total. * fix(suggestions): render structured suggestions correctly + docs sync ScoreBreakdown rendered `<li>{suggestion}</li>` directly. when the LLM returned StructuredSuggestion objects (with summary/details/impact/ platforms) instead of plain strings, svelte's text interpolation fell back to String(obj) and shipped "[object Object]" to the page. fixed with type-narrowing helpers (same pattern report.ts and ScoreDashboard already use); structured suggestions now show their summary plus a nested list of their details. ScoreDashboard had the same class of bug in its non-deduplicated path at the !structured fallback (`<p>{suggestion}</p>`). hardened with a typeof guard so any malformed value renders empty rather than "[object Object]". JobDescriptionInput's debounced parser ran inside a fire-and-forget async IIFE with no .catch(); a transient parser failure would surface as an unhandled promise rejection. wrapped in try/catch with a console.warn so the scan flow stays unaffected. docs: - api/rate-limits: documents the new 429 body shape (retryAfter field + reason discriminator) and the Retry-After header - api/endpoints: documents the _cached response field, the in-memory cache behavior, and lists the new auxiliary endpoints (/healthz, /robots.txt, /sitemap.xml, /api/og, /share, /api/csp-report) - pnpm build:docs run; static/docs refreshed dry-tested via curl: /healthz, /robots.txt, /sitemap.xml, /api/og (magic bytes valid + correct cache-control), /share, rate limiter (11th request 429 with retry-after: 60), and a full LLM cache-hit round-trip (46s -> 81ms, 573x). 183 tests + lint + typecheck + build all green. * style: prettier auto-format * fix: copilot review pass + vercel preview build + governance copilot review (PR #10): - ScoreDashboard: change outer suggestion-card from <button> to <div role=button> so the inner copy buttons are valid HTML, restore real <button>s for the copy controls, drop the unsafe KeyboardEvent->MouseEvent cast (widen copyExample to Event) - ScoreDashboard: typeof guard on the !structured suggestion fallback so a malformed value renders empty instead of "[object Object]" - /api/og + /share: clamp pass to <= total so a tampered URL like ?pass=6&total=1 cannot render "6 of 1 ATS systems passed" - timeline.ts: clamp averageScore to [0, 100] before computing y so out-of-range scores still render inside the chart padding box - robots.txt: drop prerender so the Sitemap URL tracks the request origin on preview deploys - docs catchall: explicit path-traversal guard via resolve + startsWith on docsRoot, plus realpath check against symlink escapes - hooks: clarify CSP comment (browsers ignore CSP on non-document contexts, header is benign on json/image responses) - app.html: drop the static <meta name=robots>; SeoHead now emits a single robots tag per route (indexable or noindex) so noIndex pages no longer ship conflicting directives - migrate $app/stores -> $app/state across SeoHead, Navbar, +error, /share (deprecated in newer SvelteKit) vercel preview deploys were failing with "PUBLIC_FIREBASE_API_KEY is not exported by virtual:env/static/public" because preview env scopes those vars to Production only. switch firebase.ts to $env/dynamic/ public so the build succeeds when vars are missing; firebase init fails at runtime on preview, which is the expectation (auth is not expected to work on preview) governance: - bump version 0.1.0 -> 0.2.0 - CHANGELOG.md gains a 0.2.0 entry covering the full PR scope - SECURITY.md documents the new defenses (security headers, CSP report-only, retry-after, pass/total clamp, path-traversal guard, email normalization) - pnpm build:docs run, static/docs refreshed quality gate: 184 tests + lint + format + typecheck + production build all green. dry-tested via curl: /api/og pass=6&total=1 clamps correctly, /share shows "of 1 systems" and og:image=...pass=1&total=1, single <meta name=robots> in <head>, /docs/../etc/passwd returns 404.
1 parent 5a5e6db commit 029174b

50 files changed

Lines changed: 4376 additions & 256 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,53 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.2.0] - 2026-04-25
9+
10+
### Added
11+
12+
- **Share + OG pipeline**: dynamic `/api/og` PNG endpoint via `@vercel/og` (content-addressed by score/pass/total/delta, CDN-cached at `s-maxage=86400`); new `/share` landing page emits `og:image` pointing at `/api/og` so LinkedIn / X / iMessage previews show the user's actual score
13+
- **ShareBadge polish**: "Copy share link" + "Share to X" buttons; LinkedIn share-text now appends the `/share` URL so its crawler can fetch the rich preview
14+
- **Comparison band on dashboard**: "you went from 67 to 78 (+11)" with up/down/flat states and a Twitter share-intent on positive deltas
15+
- **Per-card delta pill** anchored to each ScoreCard's score ring
16+
- **Per-row delta** in the scan-history dropdown
17+
- **Journey stats card on /history**: total improvement, best score, scan count, strongest-gain platform, days span
18+
- **Score timeline SVG chart on /history**: line chart with hover tooltips, no chart-library dep
19+
- **Live JD skill-extraction preview**: as the user types a JD, parse on a 400ms debounce and show detected role/industry/level + extracted skill chips (matched skills get a green check)
20+
- **Before/after example templates** on expanded suggestion cards (7 categories) with one-click copy on each block
21+
- **Cancellable in-flight scoring**: re-scan or reset aborts the prior fetch instead of leaking a stale response into state
22+
- **Live retry-after countdown** in the LLM-fallback toast when `/api/analyze` returns 429
23+
- **In-memory result cache** on `/api/analyze`, SHA-256 keyed by full prompt; same-input requests skip the LLM (verified live: 46s to 81ms, ~570x). Response shape adds `_cached` flag (additive, non-breaking)
24+
- **Improved rate-limit response**: `Retry-After` header + `retryAfter` body field, distinguishes minute vs daily reason, no longer double-charges minute slots on daily failures
25+
- **Auxiliary endpoints**: `/healthz` (JSON liveness probe), dynamic `/robots.txt` and `/sitemap.xml` (origin-tracking), `/api/csp-report` (logs CSP violations to Vercel logs)
26+
- **Security headers via hooks**: HSTS, Referrer-Policy, Permissions-Policy, X-Content-Type-Options, X-Frame-Options DENY, plus `Content-Security-Policy-Report-Only`
27+
- **Per-route SEO**: `SeoHead` component with og/twitter/canonical, plus JSON-LD `SoftwareApplication` on the landing page
28+
- **Branded `+error.svelte`** for 404 / 429 / 5xx
29+
- **Custom `/docs/[...slug]` catchall** for the Astro docs build with path-traversal protection (replaces hooks-based docs serving)
30+
- **Login form hardening**: email normalization (trim + lowercase) prevents duplicate-account bug from casing/whitespace; displayName maxlength
31+
32+
### Changed
33+
34+
- **Firebase SDK lazy-loaded** out of the root layout chunk (~488kb no longer ships to landing-page visitors who never sign in)
35+
- **`@vercel/og` response re-wrapped** so `Cache-Control` actually applies (the constructor's headers option concatenates rather than replaces)
36+
- **Migrated `$app/stores` to `$app/state`** (deprecated in newer SvelteKit)
37+
- **Single source of truth for `<meta name="robots">`**: removed the static tag from `app.html`; `SeoHead` now emits exactly one tag (indexable or noindex per route) so noIndex pages no longer ship conflicting directives
38+
- **`/api/og` runtime**: moved off the deprecated `runtime: 'edge'` config to default node runtime
39+
40+
### Fixed
41+
42+
- **`[object Object]` rendered in per-platform suggestions**: `ScoreBreakdown.svelte` interpolated structured suggestions without type-narrowing. Fixed with `suggestionText` / `suggestionDetails` helpers (same pattern report.ts already used). Hardened the equivalent fallback branch in `ScoreDashboard.svelte` with a `typeof` guard
43+
- **Rate-limiter cleanup throttled** to once per 30s so it doesn't run O(n) on every request once the IP map exceeds threshold (real perf cliff at scale)
44+
- **Snapshot-at-`startScoring`** fixes a race where a rapid re-scan compared against the wrong "previous" entry while the firestore save was still in flight
45+
- **Production build error**: moved docs serving out of `hooks.server.ts` into a `/docs/[...slug]` catchall so node-only `fs/path` imports don't pollute the shared bundle (was a real prod-blocking error from the edge bundler trying to resolve Node built-ins)
46+
- **JD-preview unhandled rejection**: debounced parser now wraps the IIFE in try/catch so transient parse failures don't surface as unhandled rejections
47+
- **Pass/total tampering**: `/api/og` and `/share` now clamp `pass` to `<= total` so a crafted URL like `?pass=6&total=1` cannot render impossible text
48+
- **Timeline y-coordinate clamped** to `[0, 100]` so anomalous out-of-range scores still render inside the chart padding box
49+
- **Suggestion copy buttons**: outer suggestion-card changed from `<button>` to `<div role="button">` so the inner copy `<button>`s are valid HTML (no nested interactive elements)
50+
51+
### Tests
52+
53+
- 184 tests passing (started at 106): added coverage for classification, fallback, cache, rate-limiter, comparison, timeline, journey, suggestion templates
54+
855
## [0.1.0] - 2026-02-20
956

1057
### Added

SECURITY.md

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,14 @@ This project has both client-side and server-side components. Security considera
2121

2222
- **Resume file privacy**: Resume files (PDF/DOCX) are parsed entirely in the browser using Web Workers. The original file is never uploaded to any server.
2323
- **Text transmission**: Extracted text from your resume is sent to Google Gemini for AI-powered scoring analysis. Only the text content is transmitted, not the file itself.
24-
- **Authentication**: Firebase Authentication handles user sign-in (Google + email/password). Auth tokens are managed by the Firebase SDK.
24+
- **Authentication**: Firebase Authentication handles user sign-in (Google + email/password). Auth tokens are managed by the Firebase SDK. Email is normalized (trim + lowercase) at signup/signin to prevent duplicate-account creation from casing variants.
2525
- **Data storage**: Scan history (scores and metadata) is stored in Cloud Firestore. Each user can only read/write their own data via Firestore security rules.
2626
- **API key protection**: Server-side API keys (Gemini, etc.) are stored as environment variables and never exposed to the client.
27-
- **Rate limiting**: The LLM proxy endpoint implements per-IP rate limiting to prevent abuse.
28-
- **Input sanitization**: All user inputs (resume text, job descriptions) are validated and length-capped before processing.
27+
- **Rate limiting**: The LLM proxy endpoint implements per-IP rate limiting (10 RPM, 200 RPD) and emits a standard `Retry-After` header on `429`. The cleanup sweep is throttled so an over-threshold map cannot stall request handling.
28+
- **Input sanitization**: All user inputs (resume text, job descriptions, OG / share query params) are validated and length-capped before processing. Tampered share parameters are clamped (e.g. `pass <= total`) so a crafted URL cannot render impossible state.
29+
- **Security headers**: All responses set HSTS (1y, includeSubDomains, preload), `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy` opting out of camera/microphone/geolocation/payment/usb, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: DENY`.
30+
- **Content Security Policy**: Currently shipped in `Content-Security-Policy-Report-Only` mode with violations posted to `/api/csp-report` (logged to Vercel logs only, no persistent storage). The directives cover SvelteKit hydration, Google Fonts, Firebase Auth + Firestore, and the LLM proxies.
31+
- **Static-file path traversal**: The `/docs/[...slug]` catchall validates that every resolved file path stays under `static/docs/` (resolve + `startsWith` check, plus `realpath` check against symlink escapes).
2932

3033
## Supported Versions
3134

docs/src/content/docs/api/endpoints.md

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -103,20 +103,35 @@ Extract structured requirements from a job description without scoring a resume.
103103
"suggestions": ["Add AWS and CI/CD keywords to match Workday's exact matching"]
104104
}
105105
],
106-
"_provider": "gemini",
107-
"_fallback": false
106+
"_provider": "gemma-3-27b",
107+
"_fallback": false,
108+
"_cached": false
108109
}
109110
```
110111

111112
### Response Fields
112113

113-
| Field | Type | Description |
114-
| ------------------------ | -------- | --------------------------------------- |
115-
| `results` | array | Array of 6 platform scoring objects |
116-
| `results[].system` | string | Platform name |
117-
| `results[].overallScore` | number | 0-100 weighted composite score |
118-
| `results[].passesFilter` | boolean | Whether resume passes initial screening |
119-
| `results[].breakdown` | object | Per-dimension scores and details |
120-
| `results[].suggestions` | string[] | Platform-specific improvement tips |
121-
| `_provider` | string | Which LLM provider handled the request |
122-
| `_fallback` | boolean | Whether a fallback provider was used |
114+
| Field | Type | Description |
115+
| ------------------------ | -------------------------- | ---------------------------------------------------------------------------------------------------------- |
116+
| `results` | array | Array of 6 platform scoring objects |
117+
| `results[].system` | string | Platform name |
118+
| `results[].overallScore` | number | 0-100 weighted composite score |
119+
| `results[].passesFilter` | boolean | Whether resume passes initial screening |
120+
| `results[].breakdown` | object | Per-dimension scores and details |
121+
| `results[].suggestions` | string \| StructuredItem[] | Platform-specific improvement tips. May be plain strings (rule-based) or structured objects (LLM-enhanced) |
122+
| `_provider` | string | Which LLM provider handled the request (e.g. `gemma-3-27b`, `groq-llama-3.3-70b`) |
123+
| `_fallback` | boolean | `true` when all providers failed and the client must fall back to local rule-based scoring |
124+
| `_cached` | boolean | `true` when the response was served from the in-memory result cache (sub-100ms, zero LLM cost) |
125+
126+
The server keeps a SHA-256 keyed in-memory LRU of recent prompts (200 entries, 24h TTL). Identical input hits the cache and returns instantly; the `_cached` flag tells you whether the response was a hit. The cache lives per Vercel instance; cold starts begin empty.
127+
128+
## Auxiliary Endpoints
129+
130+
| Path | Method | Purpose |
131+
| ----------------- | ------ | ------------------------------------------------------------------------------------------------------ |
132+
| `/healthz` | GET | Liveness probe; JSON `{ status, timestamp }`. For uptime monitors |
133+
| `/robots.txt` | GET | Dynamic; the `Sitemap:` URL tracks the deployment origin |
134+
| `/sitemap.xml` | GET | Dynamic; lists public routes (`/`, `/scanner`, `/about`) with `lastmod` and `priority` |
135+
| `/api/og` | GET | Edge-cached PNG (`@vercel/og`) for share previews. Query: `score`, `pass`, `total`, optional `delta` |
136+
| `/share` | GET | Branded share landing page; reads the same query params and emits `og:image` pointing at `/api/og` |
137+
| `/api/csp-report` | POST | Receives Content-Security-Policy violation reports for the report-only header set by `hooks.server.ts` |

docs/src/content/docs/api/rate-limits.md

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,18 +32,28 @@ Cache-Control: no-store
3232

3333
## Handling Rate Limits
3434

35-
When you receive a `429` response:
35+
When you receive a `429` response, the body distinguishes which window was hit and the response includes a `Retry-After` header set to the seconds-until-reset for that window:
36+
37+
```http
38+
HTTP/1.1 429 Too Many Requests
39+
Retry-After: 60
40+
Content-Type: application/json
41+
```
3642

3743
```json
3844
{
39-
"error": "rate limit exceeded. try again in 60 seconds."
45+
"error": "rate limit exceeded: too many requests this minute. retry after 60s.",
46+
"retryAfter": 60
4047
}
4148
```
4249

50+
The error string ends with either `too many requests this minute` (per-minute window) or `daily limit reached` (per-day window). The `retryAfter` field (seconds) and the `Retry-After` header always match; clients can use either.
51+
4352
**Best practices:**
4453

45-
- Implement exponential backoff in your client
46-
- Cache results locally to avoid redundant requests
54+
- Honor the `Retry-After` header (it is the exact reset window for the limit you tripped)
55+
- Cache results locally to avoid redundant requests (the server also caches identical inputs in-memory; see the `_cached` flag in [endpoints](./endpoints))
56+
- Implement exponential backoff for transient 5xx errors (rate-limit 429s should use Retry-After directly)
4757
- For high-volume use, self-host with your own API keys
4858

4959
## Self-Hosted Limits

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ats-screener",
3-
"version": "0.1.0",
3+
"version": "0.2.0",
44
"private": true,
55
"license": "MIT",
66
"type": "module",
@@ -25,6 +25,7 @@
2525
"dependencies": {
2626
"@number-flow/svelte": "^0.3.11",
2727
"@selemondev/svelte-marquee": "^0.1.1",
28+
"@vercel/og": "^0.11.1",
2829
"bits-ui": "^2.16.0",
2930
"compromise": "^14.14.3",
3031
"firebase": "^12.9.0",

0 commit comments

Comments
 (0)