Commit 029174b
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
File tree
- docs/src/content/docs/api
- src
- lib
- components
- landing
- scoring
- seo
- ui
- upload
- engine
- llm
- scorer
- suggestions
- stores
- routes
- about
- api
- analyze
- csp-report
- og
- docs/[...slug]
- healthz
- history
- login
- robots.txt
- scanner
- sitemap.xml
- tests/unit
- api
- llm
- scorer
- suggestions
Some content is hidden
Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
8 | 55 | | |
9 | 56 | | |
10 | 57 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
21 | 21 | | |
22 | 22 | | |
23 | 23 | | |
24 | | - | |
| 24 | + | |
25 | 25 | | |
26 | 26 | | |
27 | | - | |
28 | | - | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
29 | 32 | | |
30 | 33 | | |
31 | 34 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
103 | 103 | | |
104 | 104 | | |
105 | 105 | | |
106 | | - | |
107 | | - | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
108 | 109 | | |
109 | 110 | | |
110 | 111 | | |
111 | 112 | | |
112 | 113 | | |
113 | | - | |
114 | | - | |
115 | | - | |
116 | | - | |
117 | | - | |
118 | | - | |
119 | | - | |
120 | | - | |
121 | | - | |
122 | | - | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
32 | 32 | | |
33 | 33 | | |
34 | 34 | | |
35 | | - | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
36 | 42 | | |
37 | 43 | | |
38 | 44 | | |
39 | | - | |
| 45 | + | |
| 46 | + | |
40 | 47 | | |
41 | 48 | | |
42 | 49 | | |
| 50 | + | |
| 51 | + | |
43 | 52 | | |
44 | 53 | | |
45 | | - | |
46 | | - | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
47 | 57 | | |
48 | 58 | | |
49 | 59 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1 | 1 | | |
2 | 2 | | |
3 | | - | |
| 3 | + | |
4 | 4 | | |
5 | 5 | | |
6 | 6 | | |
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
28 | 29 | | |
29 | 30 | | |
30 | 31 | | |
| |||
0 commit comments