mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-03 02:04:31 +10:00
Compare commits
245
Commits
v5.3.2
...
release/v6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b13ab05777 | ||
|
|
0f3901324d | ||
|
|
e3072a6f3c | ||
|
|
00b8e3ebf5 | ||
|
|
c2137aa845 | ||
|
|
59167aae52 | ||
|
|
48d024cf3c | ||
|
|
419c7309af | ||
|
|
99ff19e4e8 | ||
|
|
581dafdfd9 | ||
|
|
0a0970160b | ||
|
|
78f4f81831 | ||
|
|
57171c6fcb | ||
|
|
8320f9ea27 | ||
|
|
36f2477c2a | ||
|
|
b63170c5f1 | ||
|
|
87b5d695a4 | ||
|
|
8ab54c8ad0 | ||
|
|
da65fbef12 | ||
|
|
0ac6f81bf2 | ||
|
|
1459259dd0 | ||
|
|
d9c121daaa | ||
|
|
bc92b8cf7f | ||
|
|
8105889b0c | ||
|
|
5d540f75d6 | ||
|
|
3e14ecaab8 | ||
|
|
fcfb18fe5a | ||
|
|
3e7849e51f | ||
|
|
0bdf8066cd | ||
|
|
bec152b7e9 | ||
|
|
ec0932c5d2 | ||
|
|
51faba9e9d | ||
|
|
e67b1c7ae9 | ||
|
|
b87b31f7e9 | ||
|
|
179861e39e | ||
|
|
50af92e2fc | ||
|
|
9a803305c8 | ||
|
|
d6f03a3e67 | ||
|
|
d46b4b5815 | ||
|
|
883045b14c | ||
|
|
31c58baed4 | ||
|
|
36ba314e2a | ||
|
|
49fcc630d1 | ||
|
|
1e8b638a6c | ||
|
|
d3ed651d65 | ||
|
|
6d4ceefbdf | ||
|
|
725be158c0 | ||
|
|
d4265e76e0 | ||
|
|
f8981502f1 | ||
|
|
8ded9de46e | ||
|
|
f82e9f33f3 | ||
|
|
5b84bf800a | ||
|
|
01e87a6f1b | ||
|
|
48b27802fd | ||
|
|
6cfcdea327 | ||
|
|
b399e289d6 | ||
|
|
708a956174 | ||
|
|
8a2fa26451 | ||
|
|
c0c7984712 | ||
|
|
bc1aaedf1f | ||
|
|
2622eeff12 | ||
|
|
03c3a88841 | ||
|
|
461d3c4e64 | ||
|
|
acdb9d88d3 | ||
|
|
54a9bfc307 | ||
|
|
23ea18241b | ||
|
|
c6c51e5b23 | ||
|
|
eaccd1d5c0 | ||
|
|
61f481055a | ||
|
|
e74403e12f | ||
|
|
a9c4b84d38 | ||
|
|
bdfe6fe421 | ||
|
|
16008a95d6 | ||
|
|
394e59e1af | ||
|
|
dc7e1e0431 | ||
|
|
a6ef340c09 | ||
|
|
39f30ac9a1 | ||
|
|
7304c38303 | ||
|
|
59337fcd51 | ||
|
|
18597a6de0 | ||
|
|
c0e9d3fc62 | ||
|
|
38447bd041 | ||
|
|
861feb22eb | ||
|
|
73a3dfc423 | ||
|
|
92459122c5 | ||
|
|
677ff17c1f | ||
|
|
c8aead4ff0 | ||
|
|
a325232a09 | ||
|
|
db97381b3d | ||
|
|
cfb272229b | ||
|
|
9acf289b11 | ||
|
|
25131b3a82 | ||
|
|
31fb576099 | ||
|
|
a6676b3268 | ||
|
|
0a5323520f | ||
|
|
3d65aea58c | ||
|
|
09134035a8 | ||
|
|
e3d72ab0e7 | ||
|
|
8a8de96a96 | ||
|
|
8e0a76bcb1 | ||
|
|
c652a288ba | ||
|
|
28eea458d9 | ||
|
|
607c561792 | ||
|
|
7827ff11bf | ||
|
|
d1aabaecb3 | ||
|
|
30819e2fc7 | ||
|
|
2a71d74e7f | ||
|
|
08c513ca26 | ||
|
|
0bc128ff3a | ||
|
|
d9979bbc8d | ||
|
|
500cabaf4a | ||
|
|
982e688e65 | ||
|
|
741b080296 | ||
|
|
3c71b4e7c3 | ||
|
|
ab2e263a2c | ||
|
|
7c33ebae11 | ||
|
|
218aad51a8 | ||
|
|
eae7de3fb0 | ||
|
|
bf3ca81c49 | ||
|
|
6e412c2f6b | ||
|
|
29647ec634 | ||
|
|
22dbbdc547 | ||
|
|
538fd316a1 | ||
|
|
36163a52b4 | ||
|
|
cb3c594655 | ||
|
|
c302faa70b | ||
|
|
e368e5955d | ||
|
|
0f6e08a922 | ||
|
|
a42cacc057 | ||
|
|
8951f45a3a | ||
|
|
2506509538 | ||
|
|
de3ffeacca | ||
|
|
48793e04be | ||
|
|
b775cbc15c | ||
|
|
bfb19ce56e | ||
|
|
cbc76b03b1 | ||
|
|
29ab0326b2 | ||
|
|
3405200cf4 | ||
|
|
ce2f1857f9 | ||
|
|
5dc67c3bd4 | ||
|
|
49422e98f2 | ||
|
|
17d25c5ffa | ||
|
|
6c2bc74f92 | ||
|
|
098df60230 | ||
|
|
11fe2b7a28 | ||
|
|
5f7ed15a5b | ||
|
|
722f5fa14c | ||
|
|
c0bf1aebaf | ||
|
|
bc28441b66 | ||
|
|
195fbaaaf4 | ||
|
|
82e2c92a51 | ||
|
|
9062114379 | ||
|
|
92dc11bd2f | ||
|
|
62572a20ca | ||
|
|
dcddc5639a | ||
|
|
55a3284360 | ||
|
|
f8767c32f1 | ||
|
|
3a69dfc4d5 | ||
|
|
f2eb230f69 | ||
|
|
d2ef001be9 | ||
|
|
3604d6feb2 | ||
|
|
991d7e0c32 | ||
|
|
2ca57fab80 | ||
|
|
d9ed63f720 | ||
|
|
6282bf77db | ||
|
|
cc28f78537 | ||
|
|
a043b28867 | ||
|
|
abca49120c | ||
|
|
9f809890e4 | ||
|
|
55db11aea8 | ||
|
|
cbd47ec1fb | ||
|
|
4acc5f19d4 | ||
|
|
3ef8eaeb26 | ||
|
|
e1d5b9ba9b | ||
|
|
26044b5346 | ||
|
|
6463a9e9c1 | ||
|
|
e86179187e | ||
|
|
12c7869ea7 | ||
|
|
2bf88584e8 | ||
|
|
82fa327900 | ||
|
|
aa9a5113c0 | ||
|
|
fdad39c632 | ||
|
|
c061367a27 | ||
|
|
2a41d45efc | ||
|
|
8dc45ee7e7 | ||
|
|
462db6011a | ||
|
|
ce0b4c606a | ||
|
|
f5a3fa35eb | ||
|
|
25306630e7 | ||
|
|
4fbc0d7b1d | ||
|
|
1253ef08a6 | ||
|
|
3397c77917 | ||
|
|
565424631a | ||
|
|
1608d56903 | ||
|
|
e26ce08fc5 | ||
|
|
0e36729b80 | ||
|
|
7f162bf230 | ||
|
|
7831cc04a7 | ||
|
|
443897dcb0 | ||
|
|
daa7d331f3 | ||
|
|
d4406c729b | ||
|
|
a568f64b43 | ||
|
|
dc6de786f7 | ||
|
|
13787333d4 | ||
|
|
6540c2aee9 | ||
|
|
c8df749258 | ||
|
|
060750a868 | ||
|
|
31e8dc39d4 | ||
|
|
c18911aaca | ||
|
|
448db84e50 | ||
|
|
4da00ddce1 | ||
|
|
49464bb7ff | ||
|
|
9dd38433d6 | ||
|
|
1ef7c9fa10 | ||
|
|
85352ee11b | ||
|
|
7244485d6e | ||
|
|
1b079dd5bd | ||
|
|
de85121f5e | ||
|
|
e2e5c15580 | ||
|
|
a82741f42a | ||
|
|
bda01febd5 | ||
|
|
fa19b891db | ||
|
|
91f1aba2e5 | ||
|
|
ffa16f2efa | ||
|
|
f2a76b2f69 | ||
|
|
639abf12b6 | ||
|
|
f73238ba3b | ||
|
|
a887a72d77 | ||
|
|
90d7d0a19b | ||
|
|
9e041e140b | ||
|
|
0f934bd849 | ||
|
|
4485825dfe | ||
|
|
def4f72169 | ||
|
|
b0aecdfb5a | ||
|
|
a7f1829484 | ||
|
|
328bf73cee | ||
|
|
1b78e546e2 | ||
|
|
fb756026fa | ||
|
|
73e7a3cb6d | ||
|
|
685fcab605 | ||
|
|
6db26e9b5c | ||
|
|
2b31d70a8a | ||
|
|
d0d20ce0fd | ||
|
|
b48a9c2142 | ||
|
|
0cb83602f5 |
@@ -1,3 +0,0 @@
|
|||||||
---
|
|
||||||
exclude_paths:
|
|
||||||
- "migrations/**"
|
|
||||||
+25
-4
@@ -4,6 +4,9 @@ PORT="3000"
|
|||||||
|
|
||||||
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
||||||
SERVER_PORT="3001"
|
SERVER_PORT="3001"
|
||||||
|
# Optional comma-separated proxy IPs/CIDRs. Only these socket peers may supply X-Forwarded-For.
|
||||||
|
# Configure your reverse proxy to append the actual client address; never trust a public client network.
|
||||||
|
TRUSTED_PROXIES=""
|
||||||
|
|
||||||
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
||||||
# OpenGraph metadata, and absolute upload URLs.
|
# OpenGraph metadata, and absolute upload URLs.
|
||||||
@@ -78,8 +81,10 @@ SMTP_SECURE="false"
|
|||||||
|
|
||||||
# --- Storage (optional) ---
|
# --- Storage (optional) ---
|
||||||
# Backend defaults to S3 when all credentials are present, otherwise local.
|
# Backend defaults to S3 when all credentials are present, otherwise local.
|
||||||
# Vercel defaults to private Blob. Explicit selection: local, s3, blob.
|
# Vercel defaults to private Blob; Workers uses native R2. Explicit selection: local, s3, blob, r2.
|
||||||
# STORAGE_BACKEND="local"
|
# STORAGE_BACKEND="local"
|
||||||
|
# Cloudflare Workers uses CLOUDFLARE=1 and native STORAGE_BACKEND="r2" bindings.
|
||||||
|
# Configure it through wrangler.jsonc and Wrangler secrets, not this Docker environment template.
|
||||||
# BLOB_READ_WRITE_TOKEN=""
|
# BLOB_READ_WRITE_TOKEN=""
|
||||||
# BLOB_STORE_ID=""
|
# BLOB_STORE_ID=""
|
||||||
# DEPLOYMENT_NAMESPACE="default"
|
# DEPLOYMENT_NAMESPACE="default"
|
||||||
@@ -100,12 +105,28 @@ S3_BUCKET="reactive-resume"
|
|||||||
S3_FORCE_PATH_STYLE="true"
|
S3_FORCE_PATH_STYLE="true"
|
||||||
|
|
||||||
# --- AI Agent Workspace (optional) ---
|
# --- AI Agent Workspace (optional) ---
|
||||||
# Required for the authenticated /agent workspace and saved AI providers.
|
# ENCRYPTION_SECRET is required for saved AI providers and the assistant.
|
||||||
# Redis also shares rate limits, resume events, cancellation and view deduplication.
|
# Redis is optional on a single server. Providers and conversations persist in PostgreSQL.
|
||||||
|
# Redis shares rate limits, resume events, cancellation and view deduplication, and resumes reply streams.
|
||||||
# Vercel Upstash KV_URL is accepted as an alias for REDIS_URL.
|
# Vercel Upstash KV_URL is accepted as an alias for REDIS_URL.
|
||||||
REDIS_URL="redis://redis:6379"
|
REDIS_URL="redis://redis:6379"
|
||||||
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||||
|
|
||||||
|
# --- Web access (optional) ---
|
||||||
|
# One shared connection supplies search and enhanced reading: firecrawl, tavily or exa.
|
||||||
|
# Leave unset to use the built-in reader and let users connect a personal key (requires ENCRYPTION_SECRET).
|
||||||
|
# WEB_ACCESS_PROVIDER="tavily"
|
||||||
|
# WEB_ACCESS_API_KEY=""
|
||||||
|
# Optional custom Firecrawl service, without /v2; may be keyless. Other providers use fixed cloud endpoints.
|
||||||
|
# WEB_ACCESS_PROVIDER="firecrawl"
|
||||||
|
# WEB_ACCESS_API_URL="http://localhost:3102"
|
||||||
|
|
||||||
|
# Optional shared AI provider. When set, personal AI providers are disabled.
|
||||||
|
# AI_PROVIDER="openai"
|
||||||
|
# AI_MODEL="gpt-5-mini"
|
||||||
|
# AI_API_KEY=""
|
||||||
|
# AI_BASE_URL=""
|
||||||
|
|
||||||
# --- Feature Flags ---
|
# --- Feature Flags ---
|
||||||
# This flag disables new signups, both on the web app and the server.
|
# This flag disables new signups, both on the web app and the server.
|
||||||
FLAG_DISABLE_SIGNUPS="false"
|
FLAG_DISABLE_SIGNUPS="false"
|
||||||
@@ -118,7 +139,7 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
|||||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||||
|
|
||||||
# This flag disables API rate limiting for authentication endpoints.
|
# This flag disables API and authentication rate limiting, including PDF export and AI requests.
|
||||||
# Rate limiting is enabled by default in production to prevent abuse.
|
# Rate limiting is enabled by default in production to prevent abuse.
|
||||||
FLAG_DISABLE_API_RATE_LIMIT="false"
|
FLAG_DISABLE_API_RATE_LIMIT="false"
|
||||||
|
|
||||||
|
|||||||
@@ -114,8 +114,10 @@ body:
|
|||||||
- Meowth
|
- Meowth
|
||||||
- Onyx
|
- Onyx
|
||||||
- Pikachu
|
- Pikachu
|
||||||
|
- Porygon
|
||||||
- Rhyhorn
|
- Rhyhorn
|
||||||
- Scizor
|
- Scizor
|
||||||
|
- Smeargle
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: logs
|
id: logs
|
||||||
|
|||||||
@@ -1,4 +0,0 @@
|
|||||||
self-hosted-runner:
|
|
||||||
labels:
|
|
||||||
- blacksmith-32vcpu-ubuntu-2404
|
|
||||||
- blacksmith-32vcpu-ubuntu-2404-arm
|
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
<!-- caveman-begin -->
|
<!-- caveman-begin -->
|
||||||
|
|
||||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||||
- Pattern: [thing] [action] [reason]. [next step].
|
- Pattern: [thing] [action] [reason]. [next step].
|
||||||
|
|||||||
@@ -13,12 +13,17 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
autofix:
|
autofix:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
|
||||||
- name: Check for merge conflict markers
|
- name: Check for merge conflict markers
|
||||||
run: |
|
run: |
|
||||||
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
|
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
name: Cloudflare Compatibility
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
env:
|
||||||
|
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
worker:
|
||||||
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
timeout-minutes: 20
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgres:17-alpine
|
||||||
|
env:
|
||||||
|
POSTGRES_PASSWORD: postgres
|
||||||
|
ports: [5432:5432]
|
||||||
|
options: >-
|
||||||
|
--health-cmd "pg_isready -U postgres"
|
||||||
|
--health-interval 5s --health-timeout 5s --health-retries 10
|
||||||
|
steps:
|
||||||
|
- if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- uses: pnpm/action-setup@v6
|
||||||
|
- uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version-file: .nvmrc
|
||||||
|
cache: pnpm
|
||||||
|
- run: pnpm install --frozen-lockfile
|
||||||
|
# No cloud account or deployment credentials: forks exercise the real local Workers runtime.
|
||||||
|
- run: pnpm check:cloudflare
|
||||||
|
- run: pnpm test:cloudflare
|
||||||
@@ -10,7 +10,7 @@ concurrency:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
crowdin-sync:
|
crowdin-sync:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -18,12 +18,22 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
# The Crowdin action runs in a container that cannot reach the git mirror mount, so copy its objects.
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
with:
|
||||||
|
dissociate: true
|
||||||
|
|
||||||
- name: Sync Translations from Crowdin
|
- name: Sync Translations from Crowdin
|
||||||
uses: crowdin/github-action@v2
|
uses: crowdin/github-action@v2
|
||||||
with:
|
with:
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
export_only_approved: true
|
||||||
|
skip_untranslated_strings: true
|
||||||
localization_branch_name: "l10n"
|
localization_branch_name: "l10n"
|
||||||
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
||||||
pull_request_title: "Sync Translations from Crowdin"
|
pull_request_title: "Sync Translations from Crowdin"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
mode:
|
mode:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
outputs:
|
outputs:
|
||||||
nightly: ${{ steps.mode.outputs.nightly }}
|
nightly: ${{ steps.mode.outputs.nightly }}
|
||||||
@@ -61,10 +61,10 @@ jobs:
|
|||||||
matrix:
|
matrix:
|
||||||
include:
|
include:
|
||||||
- platform: linux/amd64
|
- platform: linux/amd64
|
||||||
runner: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
arch: amd64
|
arch: amd64
|
||||||
- platform: linux/arm64
|
- platform: linux/arm64
|
||||||
runner: ${{ vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm' }}
|
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}
|
||||||
arch: arm64
|
arch: arm64
|
||||||
|
|
||||||
runs-on: ${{ matrix.runner }}
|
runs-on: ${{ matrix.runner }}
|
||||||
@@ -78,11 +78,24 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
|
||||||
- name: Setup Docker Buildx
|
- name: Setup Docker Buildx
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: docker/setup-buildx-action@v4
|
uses: docker/setup-buildx-action@v4
|
||||||
|
|
||||||
|
# Persists BuildKit layers and the Dockerfile's pnpm cache mounts between runs, one cache per architecture.
|
||||||
|
- name: Setup Docker Builder (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/setup-docker-builder@v2
|
||||||
|
with:
|
||||||
|
cache-key: Dockerfile-${{ matrix.arch }}
|
||||||
|
|
||||||
- ®istries
|
- ®istries
|
||||||
name: Determine registries
|
name: Determine registries
|
||||||
id: registries
|
id: registries
|
||||||
@@ -133,17 +146,23 @@ jobs:
|
|||||||
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
|
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
|
||||||
|
|
||||||
- name: Cache-only smoke build
|
- name: Cache-only smoke build
|
||||||
if: ${{ needs.mode.outputs.canary == 'true' }}
|
if: ${{ needs.mode.outputs.canary == 'true' && vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with: &cache-only-build
|
||||||
context: .
|
context: .
|
||||||
platforms: ${{ matrix.platform }}
|
platforms: ${{ matrix.platform }}
|
||||||
outputs: type=cacheonly
|
outputs: type=cacheonly
|
||||||
|
|
||||||
|
- name: Cache-only smoke build (Blacksmith)
|
||||||
|
if: ${{ needs.mode.outputs.canary == 'true' && vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/build-push-action@v2
|
||||||
|
with: *cache-only-build
|
||||||
|
|
||||||
- name: Build and Push by Digest
|
- name: Build and Push by Digest
|
||||||
id: build
|
id: build
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: docker/build-push-action@v7
|
uses: docker/build-push-action@v7
|
||||||
with:
|
with: &build-push
|
||||||
context: .
|
context: .
|
||||||
sbom: true
|
sbom: true
|
||||||
push: true
|
push: true
|
||||||
@@ -153,10 +172,16 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
annotations: ${{ steps.meta.outputs.annotations }}
|
annotations: ${{ steps.meta.outputs.annotations }}
|
||||||
|
|
||||||
|
- name: Build and Push by Digest (Blacksmith)
|
||||||
|
id: build-blacksmith
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/build-push-action@v2
|
||||||
|
with: *build-push
|
||||||
|
|
||||||
- name: Export digest
|
- name: Export digest
|
||||||
run: |
|
run: |
|
||||||
mkdir -p /tmp/digests
|
mkdir -p /tmp/digests
|
||||||
digest="${{ steps.build.outputs.digest }}"
|
digest="${{ steps.build.outputs.digest || steps.build-blacksmith.outputs.digest }}"
|
||||||
touch "/tmp/digests/${digest#sha256:}"
|
touch "/tmp/digests/${digest#sha256:}"
|
||||||
|
|
||||||
- name: Upload digest
|
- name: Upload digest
|
||||||
@@ -172,7 +197,7 @@ jobs:
|
|||||||
- mode
|
- mode
|
||||||
- build
|
- build
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
env:
|
env:
|
||||||
DEPLOY: ${{ secrets.SSH_KEY != '' && secrets.SSH_HOST != '' && secrets.SSH_USER != '' }}
|
DEPLOY: ${{ secrets.SSH_KEY != '' && secrets.SSH_HOST != '' && secrets.SSH_USER != '' }}
|
||||||
@@ -186,11 +211,17 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with: &checkout-package-json
|
||||||
sparse-checkout: package.json
|
sparse-checkout: package.json
|
||||||
sparse-checkout-cone-mode: false
|
sparse-checkout-cone-mode: false
|
||||||
|
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
with: *checkout-package-json
|
||||||
|
|
||||||
- name: Get version from package.json
|
- name: Get version from package.json
|
||||||
id: version
|
id: version
|
||||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||||
|
|||||||
@@ -17,10 +17,17 @@ env:
|
|||||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||||
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
||||||
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
|
LOCAL_STORAGE_PATH: /tmp/reactive-resume-e2e-storage
|
||||||
|
# The assistant spec talks to a scripted provider on 127.0.0.1.
|
||||||
|
FLAG_ALLOW_UNSAFE_AI_BASE_URL: "true"
|
||||||
|
# Real-database unit suites. The cover-letter suite works in its own schema; the OAuth flow suite writes
|
||||||
|
# signing keys under its own secret, so it gets a database the e2e server never reads.
|
||||||
|
COVER_LETTER_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
|
INTEGRATIONS_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
|
OAUTH_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/oauth_test
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
e2e:
|
e2e:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
timeout-minutes: 30
|
timeout-minutes: 30
|
||||||
|
|
||||||
services:
|
services:
|
||||||
@@ -40,9 +47,18 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@v6
|
uses: pnpm/action-setup@v6
|
||||||
@@ -53,9 +69,23 @@ jobs:
|
|||||||
node-version-file: ".nvmrc"
|
node-version-file: ".nvmrc"
|
||||||
cache: "pnpm"
|
cache: "pnpm"
|
||||||
|
|
||||||
|
- name: Cache Turbo artifacts
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: .turbo/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-
|
||||||
|
|
||||||
- name: Install Dependencies
|
- name: Install Dependencies
|
||||||
run: pnpm install --frozen-lockfile
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Check Package Boundaries
|
||||||
|
run: pnpm exec turbo boundaries
|
||||||
|
|
||||||
|
- name: Typecheck Affected Packages
|
||||||
|
run: pnpm exec turbo run typecheck --affected
|
||||||
|
|
||||||
- name: Install Playwright Browser
|
- name: Install Playwright Browser
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
run: pnpm exec playwright install --with-deps chromium
|
run: pnpm exec playwright install --with-deps chromium
|
||||||
@@ -71,6 +101,11 @@ jobs:
|
|||||||
- name: Run Database Migrations
|
- name: Run Database Migrations
|
||||||
run: pnpm db:migrate
|
run: pnpm db:migrate
|
||||||
|
|
||||||
|
- name: Prepare OAuth Test Database
|
||||||
|
run: |
|
||||||
|
psql "$DATABASE_URL" -c "CREATE DATABASE oauth_test"
|
||||||
|
DATABASE_URL="$OAUTH_TEST_DATABASE_URL" pnpm db:migrate
|
||||||
|
|
||||||
# Runs every workspace package, not a hand-maintained filter list, so a package
|
# Runs every workspace package, not a hand-maintained filter list, so a package
|
||||||
# cannot silently lose coverage by being left out. Serial execution: the PDF
|
# cannot silently lose coverage by being left out. Serial execution: the PDF
|
||||||
# rasterization and API rate-limit suites time out when several packages' Vitest
|
# rasterization and API rate-limit suites time out when several packages' Vitest
|
||||||
|
|||||||
@@ -10,14 +10,21 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
label:
|
label:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout Repository
|
- name: Checkout Repository
|
||||||
|
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
|
|
||||||
|
- name: Checkout Repository (Blacksmith)
|
||||||
|
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
|
||||||
- name: Apply Form Labels
|
- name: Apply Form Labels
|
||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ permissions:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
stale:
|
stale:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Close Inactive Issues Awaiting Information
|
- name: Close Inactive Issues Awaiting Information
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
name: Vercel compatibility
|
name: Vercel Compatibility
|
||||||
|
|
||||||
on:
|
on:
|
||||||
pull_request:
|
pull_request:
|
||||||
@@ -14,7 +14,7 @@ env:
|
|||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
artifact:
|
artifact:
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
timeout-minutes: 20
|
timeout-minutes: 20
|
||||||
services:
|
services:
|
||||||
postgres:
|
postgres:
|
||||||
@@ -36,7 +36,12 @@ jobs:
|
|||||||
VERCEL: "1"
|
VERCEL: "1"
|
||||||
VERCEL_ENV: production
|
VERCEL_ENV: production
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: pnpm/action-setup@v6
|
- uses: pnpm/action-setup@v6
|
||||||
@@ -44,48 +49,61 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
node-version-file: .nvmrc
|
node-version-file: .nvmrc
|
||||||
cache: pnpm
|
cache: pnpm
|
||||||
|
- name: Cache Turbo artifacts
|
||||||
|
uses: actions/cache@v5
|
||||||
|
with:
|
||||||
|
path: .turbo/cache
|
||||||
|
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-${{ github.sha }}
|
||||||
|
restore-keys: |
|
||||||
|
${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-
|
||||||
|
|
||||||
- run: pnpm install --frozen-lockfile
|
- run: pnpm install --frozen-lockfile
|
||||||
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
|
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
|
||||||
- name: Build Vercel artifact against isolated PostgreSQL
|
- name: Build Vercel artifact against isolated PostgreSQL
|
||||||
run: |
|
run: |
|
||||||
mkdir -p .vercel
|
mkdir -p .vercel
|
||||||
node --input-type=module - <<'JS'
|
cat > .vercel/project.json <<'JSON'
|
||||||
import { writeFileSync } from 'node:fs';
|
{
|
||||||
writeFileSync('.vercel/project.json', JSON.stringify({
|
"projectId": "prj_ci", "orgId": "team_ci", "projectName": "reactive-resume-ci",
|
||||||
projectId: 'prj_ci', orgId: 'team_ci', projectName: 'reactive-resume-ci',
|
"settings": { "framework": "services", "nodeVersion": "24.x", "createdAt": 0 }
|
||||||
settings: { framework: null, nodeVersion: '24.x', createdAt: 0 }
|
}
|
||||||
}));
|
JSON
|
||||||
JS
|
pnpm dlx --allow-build=esbuild vercel@61.0.0 build --prod --standalone --yes --global-config "$RUNNER_TEMP/vercel-offline"
|
||||||
pnpm dlx --allow-build=esbuild vercel@60.0.1 build --prod --yes --global-config "$RUNNER_TEMP/vercel-offline"
|
# Runs the backend Function from a copy outside the checkout, so a dependency the build left out fails here.
|
||||||
- name: Check Lambda module loading and function budget
|
- name: Check backend Function loading and budget
|
||||||
run: |
|
run: |
|
||||||
node --no-experimental-require-module --input-type=module - <<'JS'
|
node --no-experimental-require-module --input-type=module - <<'JS'
|
||||||
import assert from 'node:assert/strict';
|
import assert from 'node:assert/strict';
|
||||||
import { readFileSync, readdirSync } from 'node:fs';
|
import { cpSync, readFileSync } from 'node:fs';
|
||||||
const config = JSON.parse(readFileSync('.vercel/output/functions/api/index.func/.vc-config.json'));
|
import { join } from 'node:path';
|
||||||
|
const func = '.vercel/output/services/backend/functions/index.func';
|
||||||
|
const config = JSON.parse(readFileSync(`${func}/.vc-config.json`));
|
||||||
assert.equal(config.runtime, 'nodejs24.x');
|
assert.equal(config.runtime, 'nodejs24.x');
|
||||||
assert.equal(config.maxDuration, 300);
|
assert.equal(config.maxDuration, 300);
|
||||||
const tracedFiles = Object.keys(config.filePathMap ?? {});
|
assert.equal(config.handler, 'apps/server/vercel.mjs');
|
||||||
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/standard-fonts/Helvetica.cjs')));
|
const root = join(process.env.RUNNER_TEMP, 'backend-function');
|
||||||
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/data/Helvetica.afm')));
|
cpSync(func, root, { recursive: true, verbatimSymlinks: true });
|
||||||
for (const name of readdirSync('apps/server/dist')) {
|
const { default: app } = await import(join(root, config.handler));
|
||||||
if (name.endsWith('.mjs') && !['index.mjs', 'prepare-deployment.mjs'].includes(name)) {
|
const stage = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
|
||||||
await import(`./apps/server/dist/${name}`);
|
assert.equal(stage.status, 401);
|
||||||
}
|
const home = await app.fetch(new Request('http://localhost:3000/'));
|
||||||
}
|
assert.equal(home.status, 200);
|
||||||
const { default: app } = await import('./apps/server/dist/vercel.mjs');
|
assert.match(await home.text(), /application\/ld\+json/);
|
||||||
const response = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
|
|
||||||
assert.equal(response.status, 401);
|
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
JS
|
JS
|
||||||
|
|
||||||
live-smoke:
|
live-smoke:
|
||||||
if: github.event_name == 'workflow_dispatch'
|
if: github.event_name == 'workflow_dispatch'
|
||||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||||
environment: vercel-smoke
|
environment: vercel-smoke
|
||||||
timeout-minutes: 10
|
timeout-minutes: 10
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
with:
|
||||||
|
persist-credentials: false
|
||||||
|
- if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||||
|
uses: useblacksmith/checkout@v1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- uses: actions/setup-node@v6
|
- uses: actions/setup-node@v6
|
||||||
|
|||||||
+7
-2
@@ -4,12 +4,15 @@ node_modules
|
|||||||
|
|
||||||
# Build Outputs
|
# Build Outputs
|
||||||
dist
|
dist
|
||||||
|
dist-prerender
|
||||||
|
dist-cloudflare
|
||||||
.vercel
|
.vercel
|
||||||
.wrangler
|
.wrangler
|
||||||
|
|
||||||
# Environment Variables
|
# Environment Variables
|
||||||
.env*
|
.env*
|
||||||
!.env.example
|
!.env.example
|
||||||
|
.dev.vars*
|
||||||
|
|
||||||
# IDEs and Editors
|
# IDEs and Editors
|
||||||
*~
|
*~
|
||||||
@@ -54,12 +57,14 @@ temp
|
|||||||
.superpowers
|
.superpowers
|
||||||
.worktrees
|
.worktrees
|
||||||
.migration
|
.migration
|
||||||
graphify-out
|
/plans
|
||||||
|
|
||||||
# Local Storage Data
|
# Local Storage Data
|
||||||
/data
|
/data
|
||||||
/apps/web/data
|
/apps/web/data
|
||||||
|
|
||||||
|
# Redesign handoff (design references, not source)
|
||||||
|
/design_handoff_reactive_resume_redesign
|
||||||
|
|
||||||
# Git Hooks
|
# Git Hooks
|
||||||
.vite-hooks
|
.vite-hooks
|
||||||
|
|
||||||
|
|||||||
@@ -1,31 +0,0 @@
|
|||||||
config:
|
|
||||||
default: true
|
|
||||||
MD007: false
|
|
||||||
MD009: false
|
|
||||||
MD010: false
|
|
||||||
MD012: false
|
|
||||||
MD013: false
|
|
||||||
MD001: false
|
|
||||||
MD022: false
|
|
||||||
MD024: false
|
|
||||||
MD025: false
|
|
||||||
MD028: false
|
|
||||||
MD031: false
|
|
||||||
MD032: false
|
|
||||||
MD033: false
|
|
||||||
MD034: false
|
|
||||||
MD036: false
|
|
||||||
MD040: false
|
|
||||||
MD041: false
|
|
||||||
MD046: false
|
|
||||||
MD060: false
|
|
||||||
|
|
||||||
frontMatter: "^---[\\s\\S]*?---"
|
|
||||||
gitignore: true
|
|
||||||
globs:
|
|
||||||
- "**/*.{md,mdx}"
|
|
||||||
ignores:
|
|
||||||
- ".design-sync/**"
|
|
||||||
- "node_modules/**"
|
|
||||||
- ".turbo/**"
|
|
||||||
- "dist/**"
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
# Imported-table raster CI fix
|
|
||||||
|
|
||||||
## Root cause
|
|
||||||
|
|
||||||
Hosted runs `34007560930` (PR #3471) and `34007788443` (PR #3472) failed only in
|
|
||||||
`tests/e2e/specs/imported-table.spec.ts` with `horizontal: 18` instead of Plan 16's
|
|
||||||
`horizontal: 17`; text and vertical checks passed.
|
|
||||||
|
|
||||||
The PDF operator dump from the failed hosted artifact showed 29 table path records matching the Plan 16 contract
|
|
||||||
(17 horizontal, 12 vertical), followed by an unrelated `constructPath` `endPath` bbox:
|
|
||||||
`[0, 19.65, 358.93, 20.65]`. Its stroke color was reported as `#cc00cc` only because the helper retained the
|
|
||||||
last table stroke color. It was a later red section-divider fill/no-paint path, not an extra table border. The old
|
|
||||||
helper classified every thin bbox after the last matching color state, so it counted this false positive.
|
|
||||||
|
|
||||||
The table's explicit width is stable at 300pt, while row height legitimately changes from 30pt to 31pt after the
|
|
||||||
`Beta!` edit. The helper therefore scopes candidate paths by the fixture's 300pt horizontal grid envelope, not by a
|
|
||||||
row-height tolerance. Missing or duplicated paths inside that envelope still change the exact 17/12 contract.
|
|
||||||
|
|
||||||
## Change
|
|
||||||
|
|
||||||
- Added `tests/e2e/fixtures/pdf-borders.ts` with deterministic `countTableBorderGeometry` filtering.
|
|
||||||
- Updated browser/server PDF inspection in `tests/e2e/specs/imported-table.spec.ts` to use the helper.
|
|
||||||
- Added `tests/e2e/fixtures/pdf-borders.test.ts`; regression proves old stale-color counting returns 2 horizontal
|
|
||||||
paths while topology-scoped counting returns 1.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- Intent skill inventory: 7 packages, 26 skills; no matching local skill for this E2E/PDF helper.
|
|
||||||
- Focused helper regression: 1 file, 1 passed.
|
|
||||||
- Dedicated imported-table E2E: 2 consecutive runs, each 1 passed; both exercise initial, unrelated-edit, and table-edit
|
|
||||||
stages plus browser and server PDF exports.
|
|
||||||
- Production build: 3/3 tasks successful.
|
|
||||||
- Web typecheck via `rtk proxy pnpm --filter web typecheck`: passed (`tsgo --noEmit`).
|
|
||||||
- Turbo boundaries: 1,443 files across 20 packages, no issues.
|
|
||||||
- Targeted Biome: 3 files, no issues.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
|
|
||||||
The root `pnpm typecheck` wrapper was also tried but invokes an incompatible `tsc` path and reports TS5096 for
|
|
||||||
`allowImportingTsExtensions`; the package's documented `tsgo --noEmit` typecheck passes.
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
# Plan 21 implementation evidence
|
|
||||||
|
|
||||||
## Revision and scope
|
|
||||||
|
|
||||||
- Worktree: `issue-3060-section-heading-visibility`
|
|
||||||
- Base: current `origin/main` at dispatch, `2a4a1583b` (`fix(pdf): restore Gengar skill rating order (#3473)`)
|
|
||||||
- Product decisions applied: Q1 explicit Show heading toggle; Q2 Move-to continuations default visible; Q3 visual omission in preview/PDF/DOCX with accessible outline labels retained.
|
|
||||||
- No `.codegraph/` directory exists in this worktree, so CodeGraph was skipped after the required presence check.
|
|
||||||
- Intent discovery ran before edits; no matching local skill was available for this schema/PDF/DOCX/web change.
|
|
||||||
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
- Added backward-compatible `showHeading` section data for summary, built-ins, and custom sections. `parseResumeData` normalizes absent legacy values to `true`; explicit `false` survives round trips.
|
|
||||||
- Added heading toggles to built-in/summary and custom section menus. Toggle mutations use `useUpdateResumeData`, preserving undo/autosave/save/reload behavior; legacy absent values are treated as visible. Existing lock fieldset remains authoritative.
|
|
||||||
- Move-to-created custom sections explicitly set `showHeading: true`, independent of source heading state or copied title.
|
|
||||||
- `SectionShell` omits complete heading/icon/decoration output when disabled in both icon and no-icon branches. Empty titles still resolve localized defaults.
|
|
||||||
- DOCX section renderers omit visible heading paragraphs for summary, built-in, and custom sections while retaining content. Screen-reader mirror continues to expose section labels regardless of visual setting.
|
|
||||||
- Added characterization for Semantic CSS `section[id="..."] section-heading { display: none; }`; body remains while heading is omitted.
|
|
||||||
- Updated default/sample fixtures, generated schema references, recovery hashes, and compatibility tests; existing Gengar renderer/order changes remain untouched.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @reactive-resume/schema test`: 9 files, 132 tests passed.
|
|
||||||
- `pnpm --filter @reactive-resume/pdf test`: 81 files, 1059 tests passed.
|
|
||||||
- `pnpm --filter @reactive-resume/docx test`: 9 files, 76 tests passed.
|
|
||||||
- `pnpm --filter web test`: 135 files, 942 tests passed.
|
|
||||||
- `pnpm test`: full Turborepo suite passed (19 successful tasks; 10 cache hits).
|
|
||||||
- Affected typechecks passed: schema, PDF, DOCX, web.
|
|
||||||
- Focused menu, Move-to, accessible-outline, schema, PDF semantic, and DOCX renderer tests passed.
|
|
||||||
- `pnpm exec turbo boundaries`: passed (1108 files, 20 packages).
|
|
||||||
- Read-only `pnpm exec biome check` on 22 changed source/test files: passed; no write-capable `pnpm check` run.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- Final diff review completed; local commit follows.
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
# Plan 23 item-pagination execution evidence
|
|
||||||
|
|
||||||
## Scope
|
|
||||||
|
|
||||||
Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed.
|
|
||||||
|
|
||||||
## Durable diagnostic matrix
|
|
||||||
|
|
||||||
`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for:
|
|
||||||
|
|
||||||
- an item that fits remaining space;
|
|
||||||
- an item that fits a full page but not the remaining space;
|
|
||||||
- an oversized item taller than one page;
|
|
||||||
- a two-line paragraph at a boundary;
|
|
||||||
- nested bullets; and
|
|
||||||
- built-in plus custom items in an Azurill sidebar/main-column overflow fixture.
|
|
||||||
|
|
||||||
The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages.
|
|
||||||
|
|
||||||
The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once.
|
|
||||||
|
|
||||||
## Concrete blocker
|
|
||||||
|
|
||||||
React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added.
|
|
||||||
|
|
||||||
Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed.
|
|
||||||
- No production source or schema changes made after the unsafe fallback was reproduced.
|
|
||||||
- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists.
|
|
||||||
@@ -1,157 +0,0 @@
|
|||||||
# Plan 27 Phase A diagnostic evidence
|
|
||||||
|
|
||||||
Date: 2026-09-06
|
|
||||||
Issue: [#3377](https://github.com/reactive-resume/reactive-resume/issues/3377)<br>
|
|
||||||
Revision: `2a4a1583b` (`origin/main` at run start)
|
|
||||||
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
|
|
||||||
|
|
||||||
## Drift and authority
|
|
||||||
|
|
||||||
- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty.
|
|
||||||
- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof.
|
|
||||||
- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL.
|
|
||||||
- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`.
|
|
||||||
- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches.
|
|
||||||
- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
|
|
||||||
|
|
||||||
## Deterministic fixture
|
|
||||||
|
|
||||||
`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
|
|
||||||
|
|
||||||
The exact markers are versioned as `offline-font-scripts-v1`:
|
|
||||||
|
|
||||||
| Marker | Script or coverage |
|
|
||||||
| --- | --- |
|
|
||||||
| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency |
|
|
||||||
| `简体中文` | Han / Simplified Chinese |
|
|
||||||
| `العربية` | Arabic |
|
|
||||||
| `עברית` | Hebrew |
|
|
||||||
| `ไทย` | Thai |
|
|
||||||
| `Emoji 🚀` | Emoji |
|
|
||||||
|
|
||||||
`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
|
|
||||||
|
|
||||||
The four surfaces are separate tests:
|
|
||||||
|
|
||||||
1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads.
|
|
||||||
2. Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
|
|
||||||
3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
|
|
||||||
4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
|
|
||||||
|
|
||||||
Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
|
|
||||||
|
|
||||||
## Run protocol and cold-cache boundary
|
|
||||||
|
|
||||||
Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
|
|
||||||
|
|
||||||
```text
|
|
||||||
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
|
|
||||||
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
|
|
||||||
```
|
|
||||||
|
|
||||||
Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
|
|
||||||
|
|
||||||
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
|
|
||||||
|
|
||||||
## Administrator-hosted manifest proposal
|
|
||||||
|
|
||||||
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"schemaVersion": "offline-fonts-v1",
|
|
||||||
"mode": "local-only",
|
|
||||||
"assetRoot": "/fonts/offline/v1",
|
|
||||||
"families": {
|
|
||||||
"IBM Plex Serif": {
|
|
||||||
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
|
|
||||||
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
|
|
||||||
"preview": "ibm-plex-serif/preview.ttf"
|
|
||||||
},
|
|
||||||
"IBM Plex Sans": {
|
|
||||||
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
|
|
||||||
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
|
|
||||||
"preview": "ibm-plex-sans/preview.ttf"
|
|
||||||
},
|
|
||||||
"Noto Serif": {
|
|
||||||
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
|
|
||||||
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
|
|
||||||
"preview": "noto-serif/preview.ttf"
|
|
||||||
},
|
|
||||||
"Noto Sans": {
|
|
||||||
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
|
|
||||||
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
|
|
||||||
"preview": "noto-sans/preview.ttf"
|
|
||||||
},
|
|
||||||
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
|
|
||||||
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
|
|
||||||
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
|
|
||||||
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
|
|
||||||
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
|
|
||||||
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
|
|
||||||
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
|
|
||||||
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
|
|
||||||
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
|
|
||||||
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
|
|
||||||
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
|
|
||||||
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
|
|
||||||
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Candidate source, license, script, and size evidence
|
|
||||||
|
|
||||||
Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE).
|
|
||||||
|
|
||||||
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
|
|
||||||
| --- | --- | --- | --- | --- | ---: | --- |
|
|
||||||
| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
|
|
||||||
| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
|
|
||||||
| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration |
|
|
||||||
| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration |
|
|
||||||
| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback |
|
|
||||||
| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback |
|
|
||||||
| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback |
|
|
||||||
| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback |
|
|
||||||
| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback |
|
|
||||||
| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback |
|
|
||||||
|
|
||||||
Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.44 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
|
|
||||||
|
|
||||||
### Source and license obligations
|
|
||||||
|
|
||||||
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources.
|
|
||||||
- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements.
|
|
||||||
- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
|
|
||||||
- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest.
|
|
||||||
|
|
||||||
### Missing-family and missing-asset behavior
|
|
||||||
|
|
||||||
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
|
|
||||||
|
|
||||||
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
|
|
||||||
|
|
||||||
## Verification record
|
|
||||||
|
|
||||||
Completed read-only checks before handoff:
|
|
||||||
|
|
||||||
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
|
|
||||||
- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work.
|
|
||||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed.
|
|
||||||
- `git diff --check`: passed.
|
|
||||||
- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests).
|
|
||||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests).
|
|
||||||
- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
|
|
||||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected).
|
|
||||||
- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
|
|
||||||
- `pnpm exec turbo boundaries`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
|
|
||||||
|
|
||||||
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
# Plan 27A remediation round 2
|
|
||||||
|
|
||||||
Date: 2026-09-06
|
|
||||||
Base: `ae8e2f76f`
|
|
||||||
|
|
||||||
## Focused fixes
|
|
||||||
|
|
||||||
- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph.
|
|
||||||
- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop.
|
|
||||||
- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass.
|
|
||||||
- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error.
|
|
||||||
- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases.
|
|
||||||
- Removed trailing spaces from `plan-27a-remediation.md`.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed.
|
|
||||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed.
|
|
||||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
|
|
||||||
- `git diff --check` — passed after remediation-document whitespace cleanup.
|
|
||||||
|
|
||||||
Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made.
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
# Plan 27A remediation
|
|
||||||
|
|
||||||
Date: 2026-09-06
|
|
||||||
Base: `61b58ae9a`
|
|
||||||
Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only.
|
|
||||||
|
|
||||||
## Remediated findings
|
|
||||||
|
|
||||||
- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used.
|
|
||||||
- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines.
|
|
||||||
- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added.
|
|
||||||
- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic.
|
|
||||||
- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed.
|
|
||||||
- `git diff --check` — passed.
|
|
||||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
|
|
||||||
- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed.
|
|
||||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed.
|
|
||||||
- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
|
|
||||||
|
|
||||||
Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete.
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
# Issue 3350 remediation evidence
|
|
||||||
|
|
||||||
## Findings addressed
|
|
||||||
|
|
||||||
- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate.
|
|
||||||
- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count.
|
|
||||||
- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added.
|
|
||||||
|
|
||||||
## Verification
|
|
||||||
|
|
||||||
- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed.
|
|
||||||
- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed.
|
|
||||||
- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed.
|
|
||||||
- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked.
|
|
||||||
- `rtk git diff --check origin/main...HEAD` — passed.
|
|
||||||
|
|
||||||
Only PDF test coverage and this evidence file changed; production behavior remains untouched.
|
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"useTabs": true,
|
||||||
|
"printWidth": 120,
|
||||||
|
"sortPackageJson": false,
|
||||||
|
"sortImports": {
|
||||||
|
"newlinesBetween": false,
|
||||||
|
"customGroups": [
|
||||||
|
{
|
||||||
|
"groupName": "types",
|
||||||
|
"selector": "type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "tests",
|
||||||
|
"elementNamePattern": ["vitest", "vitest/**", "@testing-library/**"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"groupName": "workspace",
|
||||||
|
"elementNamePattern": ["@reactive-resume/**"]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"groups": [
|
||||||
|
"types",
|
||||||
|
"value-builtin",
|
||||||
|
"tests",
|
||||||
|
"value-external",
|
||||||
|
"workspace",
|
||||||
|
["value-internal", "value-parent", "value-sibling", "value-index"],
|
||||||
|
"unknown"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"sortTailwindcss": {
|
||||||
|
"stylesheet": "./packages/ui/src/styles/globals.css",
|
||||||
|
"functions": ["clsx", "cva", "cn"]
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"**/.turbo/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/dist-cloudflare/**",
|
||||||
|
"**/dist-prerender/**",
|
||||||
|
"**/.vercel/**",
|
||||||
|
"**/.wrangler/**",
|
||||||
|
"**/coverage/**",
|
||||||
|
"**/reports/**",
|
||||||
|
"**/routeTree.gen.ts",
|
||||||
|
"packages/pdf/src/semantic/__fixtures__/**/*.css",
|
||||||
|
"pnpm-lock.yaml",
|
||||||
|
"migrations/**",
|
||||||
|
"docs/spec.json",
|
||||||
|
"docs/guides/json-resume-schema.mdx",
|
||||||
|
"skills/resume-builder/references/schema.md"
|
||||||
|
],
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.md", "**/*.mdx"],
|
||||||
|
"options": {
|
||||||
|
"proseWrap": "preserve",
|
||||||
|
"useTabs": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y"],
|
||||||
|
"jsPlugins": ["@shadcn/lint"],
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
},
|
||||||
|
"ignorePatterns": [
|
||||||
|
"**/.turbo/**",
|
||||||
|
"**/.output/**",
|
||||||
|
"**/dist/**",
|
||||||
|
"**/dist-cloudflare/**",
|
||||||
|
"**/dist-prerender/**",
|
||||||
|
"**/.vercel/**",
|
||||||
|
"**/.wrangler/**",
|
||||||
|
"**/coverage/**",
|
||||||
|
"**/reports/**",
|
||||||
|
"**/routeTree.gen.ts"
|
||||||
|
],
|
||||||
|
"rules": {
|
||||||
|
"typescript/no-explicit-any": "error",
|
||||||
|
"require-await": "error",
|
||||||
|
"typescript/consistent-type-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"prefer": "type-imports",
|
||||||
|
"fixStyle": "separate-type-imports",
|
||||||
|
"disallowTypeAnnotations": false
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/no-non-null-assertion": "error",
|
||||||
|
"no-restricted-imports": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"patterns": [
|
||||||
|
{
|
||||||
|
"regex": "@reactive-resume/[^/]+/src(?:/|$)|(?:^|/)(?:apps|packages)/[^/]+/src(?:/|$)",
|
||||||
|
"message": "Use the workspace package export map instead of private src paths, including re-exports and dynamic imports."
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"group": ["apps/**", "packages/**"],
|
||||||
|
"message": "Do not import another workspace by repository path; use an explicit package export."
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"no-param-reassign": "error",
|
||||||
|
"typescript/prefer-as-const": "error",
|
||||||
|
"default-param-last": "error",
|
||||||
|
"typescript/prefer-enum-initializers": "error",
|
||||||
|
"react/self-closing-comp": "error",
|
||||||
|
"one-var": ["error", "never"],
|
||||||
|
"unicorn/prefer-number-properties": "error",
|
||||||
|
"typescript/no-inferrable-types": "error",
|
||||||
|
"no-else-return": "error",
|
||||||
|
"react/no-array-index-key": "off",
|
||||||
|
"react/exhaustive-deps": "warn",
|
||||||
|
"react/unsupported-syntax": "error",
|
||||||
|
"no-unused-vars": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"argsIgnorePattern": "^_",
|
||||||
|
"varsIgnorePattern": "^_",
|
||||||
|
"caughtErrorsIgnorePattern": "^_"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"typescript/triple-slash-reference": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"path": "always",
|
||||||
|
"types": "prefer-import",
|
||||||
|
"lib": "always"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"jsx-a11y/prefer-tag-over-role": "off",
|
||||||
|
"jsx-a11y/control-has-associated-label": "off",
|
||||||
|
"jsx-a11y/no-noninteractive-element-interactions": "off",
|
||||||
|
"jsx-a11y/no-autofocus": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"ignoreNonDOM": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unicorn/no-new-array": "off",
|
||||||
|
"no-irregular-whitespace": [
|
||||||
|
"error",
|
||||||
|
{
|
||||||
|
"skipComments": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"react/no-danger": "error",
|
||||||
|
"react/rules-of-hooks": "error"
|
||||||
|
},
|
||||||
|
"overrides": [
|
||||||
|
{
|
||||||
|
"files": ["**/*.test.{ts,tsx,mts,mjs}", "**/*.spec.{ts,tsx,mts,mjs}"],
|
||||||
|
"rules": {
|
||||||
|
"require-await": "off"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"files": ["tests/e2e/fixtures/test.ts"],
|
||||||
|
"rules": {
|
||||||
|
"react/rules-of-hooks": "off"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
**/reports
|
**/reports
|
||||||
data
|
data
|
||||||
apps/web/data
|
apps/web/data
|
||||||
screenshots
|
/screenshots
|
||||||
.vercel
|
.vercel
|
||||||
.wrangler
|
.wrangler
|
||||||
.tanstack
|
.tanstack
|
||||||
|
|||||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
|||||||
{
|
{
|
||||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
"recommendations": ["oxc.oxc-vscode", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+6
-7
@@ -1,10 +1,8 @@
|
|||||||
{
|
{
|
||||||
"biome.enabled": true,
|
|
||||||
"editor.codeActionsOnSave": {
|
"editor.codeActionsOnSave": {
|
||||||
"source.fixAll.biome": "explicit",
|
"source.fixAll.oxc": "explicit"
|
||||||
"source.organizeImports.biome": "explicit"
|
|
||||||
},
|
},
|
||||||
"editor.defaultFormatter": "biomejs.biome",
|
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||||
"files.readonlyInclude": {
|
"files.readonlyInclude": {
|
||||||
"**/locales/**.po": true,
|
"**/locales/**.po": true,
|
||||||
"**/routeTree.gen.ts": true,
|
"**/routeTree.gen.ts": true,
|
||||||
@@ -26,9 +24,10 @@
|
|||||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||||
],
|
],
|
||||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
"tailwindCSS.experimental.configFile": "packages/ui/src/styles/globals.css",
|
||||||
"typescript.experimental.useTsgo": true,
|
"typescript.experimental.useTsgo": true,
|
||||||
"[json]": {
|
"[json]": {
|
||||||
"editor.defaultFormatter": "biomejs.biome"
|
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||||
}
|
},
|
||||||
|
"editor.formatOnSave": true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,13 @@
|
|||||||
<!-- intent-skills:start -->
|
# Reactive Resume: agent instructions
|
||||||
## Skill Loading
|
|
||||||
|
|
||||||
Before editing files for a substantial task:
|
This file applies across the repository. Follow a closer `AGENTS.md` when one exists. Keep this guide focused on agent workflows; user-facing documentation lives in `README.md` and `docs/`. Format guidance: [agents.md](https://agents.md/).
|
||||||
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
|
|
||||||
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
|
|
||||||
- Use the loaded `SKILL.md` guidance while making the change.
|
|
||||||
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
|
|
||||||
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
|
|
||||||
<!-- intent-skills:end -->
|
|
||||||
|
|
||||||
<!-- caveman-begin -->
|
<!-- caveman-begin -->
|
||||||
|
|
||||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
|
|
||||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||||
- Pattern: [thing] [action] [reason]. [next step].
|
- Pattern: [thing] [action] [reason]. [next step].
|
||||||
@@ -27,46 +22,102 @@ Auto-Clarity: drop caveman for security warnings, irreversible actions, user con
|
|||||||
Boundaries: code/commits/PRs written normal.
|
Boundaries: code/commits/PRs written normal.
|
||||||
<!-- caveman-end -->
|
<!-- caveman-end -->
|
||||||
|
|
||||||
|
<!-- BEGIN:turborepo-agent-rules -->
|
||||||
|
|
||||||
|
# This is NOT the Turborepo you know
|
||||||
|
|
||||||
|
Turborepo configuration, task behavior, and CLI commands can vary between installed versions and may differ from your training data. Resolve the `turbo` package from this file's directory or relevant workspace; in monorepos, it may not be visible from the repository root. For example, run `node -p "require.resolve('turbo/package.json')"` from a workspace that depends on `turbo`.
|
||||||
|
|
||||||
|
Read `docs/README.md` inside that installed package first, then read the relevant pages from its `docs/` directory before changing Turborepo configuration or commands. Heed deprecation notices. These bundled docs match the installed package version and are available without network access.
|
||||||
|
|
||||||
|
This block is written and re-added by `turbo` before repository-scoped commands when an AI agent is detected. In the Turborepo source repository, its template is defined in `crates/turborepo-cli/src/cli/agent_guidance.rs`. Removing the managed block while updates are enabled means a later qualifying invocation will add it again. Set `"agentGuidance": false` in the root `turbo.json` or `turbo.jsonc` to opt out; this does not remove an existing block. Keep the block committed with your work to avoid an uncommitted change on the next agent invocation.
|
||||||
|
<!-- END:turborepo-agent-rules -->
|
||||||
|
|
||||||
## Agent skills
|
## Agent skills
|
||||||
|
|
||||||
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
||||||
|
- Check `git status --short` before editing. Preserve unrelated changes, including existing edits in this file.
|
||||||
|
- Use scripts and configuration as the source of truth when documentation disagrees with them.
|
||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (React 19 SPA with TanStack Router and Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app.
|
Reactive Resume is a free, open-source resume builder for creating, importing, exporting, and sharing resumes, cover letters, and job applications. It is a TypeScript pnpm monorepo managed by Turborepo, with two apps: `apps/web` (React 19 SPA with TanStack Router, TanStack Query, Tailwind CSS, and Vite) and `apps/server` (Hono / Node.js). oRPC connects browser workflows to server business logic; Better Auth handles authentication; Drizzle accesses PostgreSQL. Forme renders PDFs in the browser and on the server.
|
||||||
|
|
||||||
|
The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app. On Vercel, the `frontend` service serves static assets through its CDN and the `backend` service runs the same Hono application in a Node.js Function.
|
||||||
|
|
||||||
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
||||||
|
|
||||||
Prerequisites: **Node.js 24** (pinned in `.nvmrc`; matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 12.3.4** (pinned by `packageManager` in the root `package.json`; pnpm self-manages to it, so any recent pnpm can bootstrap — the Dockerfile's `ARG PNPM_VERSION` only picks the base image) ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
|
## Setup
|
||||||
|
|
||||||
|
Prerequisites: **Node.js 24** (`.nvmrc`, root `engines`, and Dockerfile), **pnpm 12.8.1** (root `packageManager`; pnpm self-manages to this version), and **Docker with Docker Compose** for local infrastructure. The Dockerfile's `ARG PNPM_VERSION` chooses its base image, not the project's pnpm version. Start your Docker daemon before running Compose.
|
||||||
|
|
||||||
|
Shared dependency versions live in the default `catalog` in `pnpm-workspace.yaml`. Use `catalog:` in workspace manifests when that shared range applies; keep intentional exact pins and peer dependency ranges explicit.
|
||||||
|
|
||||||
|
Run commands from the workspace root unless stated otherwise:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm install --frozen-lockfile
|
||||||
|
test -e .env.local || cp .env.example .env.local
|
||||||
|
docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket
|
||||||
|
docker compose -f compose.dev.yml ps
|
||||||
|
```
|
||||||
|
|
||||||
|
Copy the environment template only when `.env.local` does not already exist. For host-run development, edit these values in `.env.local`; the template uses container hostnames:
|
||||||
|
|
||||||
|
```dotenv
|
||||||
|
APP_URL=http://localhost:3000
|
||||||
|
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||||
|
S3_ENDPOINT=http://localhost:8333
|
||||||
|
REDIS_URL=redis://localhost:6379
|
||||||
|
```
|
||||||
|
|
||||||
|
Set `AUTH_SECRET` to a generated secret (`openssl rand -hex 32`). If using saved AI providers or the assistant, also set a separate `ENCRYPTION_SECRET` of at least 32 characters. For database-only development, start just `postgres` and set `STORAGE_BACKEND=local` to avoid the template's S3 defaults.
|
||||||
|
|
||||||
|
## Development workflow
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm dev
|
||||||
|
pnpm dev:web
|
||||||
|
pnpm db:generate
|
||||||
|
pnpm db:migrate
|
||||||
|
pnpm db:studio
|
||||||
|
```
|
||||||
|
|
||||||
|
- `pnpm dev` runs Vite on `PORT` (default `3000`), Hono on `SERVER_PORT` (default `3001`), and the email template preview on `3002`. Vite proxies API requests to Hono. Vite supplies hot reload; `tsx watch` restarts the server.
|
||||||
|
- `pnpm dev:web` starts only Vite; API workflows still need a server. If ports are busy, change `PORT` and `SERVER_PORT` consistently in `.env.local`; keep the email preview's `3002` port free when running all dev tasks.
|
||||||
|
- Server startup applies migrations before initializing auth and serving traffic. `pnpm db:migrate` applies them without starting the app; `pnpm db:studio` opens the database UI.
|
||||||
|
- After adding user-facing strings, use Lingui macros and run `pnpm lingui:extract`. Catalogs live in `apps/web/locales/*.po`; `pnpm pdf:translations` regenerates PDF translations. Root build/check scripts run PDF translation generation automatically.
|
||||||
|
- `pnpm docs:gen` regenerates the OpenAPI spec and semantic CSS reference. Use it when changing those public surfaces.
|
||||||
|
|
||||||
## Ownership map
|
## Ownership map
|
||||||
|
|
||||||
Where each concern lives, and where new code for it goes:
|
Where each concern lives, and where new code for it goes:
|
||||||
|
|
||||||
| Area | Owner |
|
| Area | Owner |
|
||||||
|------|-------|
|
| ----------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Web routes, loaders, user-facing workflows | `apps/web/src/routes`, `apps/web/src/features` (file-based; never hand-edit `routeTree.gen.ts`) |
|
| Web routes, loaders, user-facing workflows | `apps/web/src/routes`, `apps/web/src/features` (file-based; never hand-edit `routeTree.gen.ts`) |
|
||||||
| Server HTTP routes/adapters, startup checks, static handlers, MCP transport, OpenAPI/well-known | `apps/server/src/{http,rpc,mcp,openapi,static,startup}` |
|
| Server HTTP routes/adapters, startup checks, static handlers, MCP transport, OpenAPI/well-known | `apps/server/src/{http,rpc,mcp,openapi,static,startup}` |
|
||||||
| Authenticated API contracts + business logic | `packages/api/src/features/*` (oRPC routers, DTOs, rate limiting; aggregated at `@reactive-resume/api/routers` for `/api/rpc`) |
|
| Authenticated API contracts + business logic | `packages/api/src/features/*` (oRPC routers, DTOs, rate limiting; aggregated at `@reactive-resume/api/routers` for `/api/rpc`) |
|
||||||
| Auth | `packages/auth` (Better Auth config/helpers/types; `apps/server/src/http/auth.ts` delegates to `auth.handler`) |
|
| Auth | `packages/auth` (Better Auth config/helpers/types; `apps/server/src/http/auth.ts` delegates to `auth.handler`) |
|
||||||
| DB client + schema | `packages/db` (Drizzle; migrations at repo root `migrations/`) |
|
| DB client + schema | `packages/db` (Drizzle; migrations at repo root `migrations/`) |
|
||||||
| Server env validation | `packages/env` (auto-loads root `.env`) |
|
| Server env validation | `packages/env` (auto-loads root `.env`) |
|
||||||
| Resume/page/template Zod schemas | `packages/schema` |
|
| Resume/page/template Zod schemas | `packages/schema` |
|
||||||
| Pure resume-domain behavior (no DB/HTTP/DOM/renderer deps) | `packages/resume` (JSON Patch helpers, social-network icons) |
|
| Pure resume-domain behavior (no DB/HTTP/DOM/renderer deps) | `packages/resume` (JSON Patch helpers, social-network icons) |
|
||||||
| Resume PDF rendering | `packages/pdf` (React PDF document, font registration, template primitives, browser/server adapters) |
|
| Resume PDF rendering | `packages/pdf` (React templates converted through `src/forme` to Forme documents, font resolution, browser/server adapters) |
|
||||||
| PDF.js viewer/canvas UI | `apps/web/src/features/resume` — never in `packages/pdf` |
|
| PDF.js viewer/canvas UI | `apps/web/src/features/resume` — never in `packages/pdf` |
|
||||||
| DOCX export | `packages/docx` |
|
| DOCX export | `packages/docx` |
|
||||||
| MCP tools/prompts/resources/server-card | `packages/mcp` |
|
| MCP tools/prompts/resources/server-card | `packages/mcp` |
|
||||||
| Generic UI primitives + hooks | `packages/ui` (Base UI/shadcn-style); workflow-specific UI stays in the owning web feature |
|
| Generic UI primitives + hooks | `packages/ui` (Base UI/shadcn-style); workflow-specific UI stays in the owning web feature |
|
||||||
| Focused support surfaces | `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/config` — prefer existing exports over cross-package shortcuts |
|
| DeepSeek Harness integration | `packages/dsh-plugin` (separately built/published plugin) |
|
||||||
| Dev-only scripts | `tooling/`, not `packages/`, so packages only hold runtime-bundled code |
|
| Focused support surfaces | `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/config` — prefer existing exports over cross-package shortcuts |
|
||||||
|
| Dev-only scripts | `tooling/`, not `packages/`, so packages only hold runtime-bundled code |
|
||||||
|
|
||||||
Narrow cross-cutting helpers go in `packages/utils` only after checking no domain package is a better owner. Specifically: resume JSON Patch behavior belongs in `@reactive-resume/resume/patch` and DOCX builders in `@reactive-resume/docx` — not in `@reactive-resume/utils`.
|
Narrow cross-cutting helpers go in `packages/utils` only after checking no domain package is a better owner. Specifically: resume JSON Patch behavior belongs in `@reactive-resume/resume/patch` and DOCX builders in `@reactive-resume/docx` — not in `@reactive-resume/utils`.
|
||||||
|
|
||||||
## Web app conventions
|
## Web app conventions
|
||||||
|
|
||||||
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context instead of refetching these ad hoc.
|
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context instead of refetching these ad hoc.
|
||||||
- The web app is a client-rendered SPA. `apps/server` serves `index.html` and injects page metadata (OpenGraph, canonical, JSON-LD) in `apps/server/src/static/web.ts`; there is no React SSR.
|
- The web app is a client-rendered SPA. The web build prerenders marketing homepages per locale; there is no request-time React SSR. `apps/server/src/static/web.ts` serves HTML and injects OpenGraph, canonical, and JSON-LD metadata. When adding a public marketing route, update its server fallback/SEO handling as well as the TanStack route; Vite's dev fallback can otherwise hide production 404s.
|
||||||
- Builder shell: `apps/web/src/routes/builder/$resumeId`. Public resume route: `apps/web/src/routes/$username/$slug.tsx`.
|
- Builder shell: `apps/web/src/routes/builder/$resumeId`. Public resume route: `apps/web/src/routes/$username/$slug.tsx`.
|
||||||
- Browser-only preview code: `apps/web/src/features/resume/preview`. Public PDF viewer: `apps/web/src/features/resume/public`. Keep PDF.js/canvas code in these features, not in `packages/pdf`.
|
- Browser-only preview code: `apps/web/src/features/resume/preview`. Public PDF viewer: `apps/web/src/features/resume/public`. Keep PDF.js/canvas code in these features, not in `packages/pdf`.
|
||||||
- oRPC client: `apps/web/src/libs/orpc/client.ts` calls `/api/rpc` with credentials included. `apps/web/src/libs/orpc/fetch.ts` stages large request bodies through Blob on Vercel.
|
- oRPC client: `apps/web/src/libs/orpc/client.ts` calls `/api/rpc` with credentials included. `apps/web/src/libs/orpc/fetch.ts` stages large request bodies through Blob on Vercel.
|
||||||
@@ -79,46 +130,111 @@ Narrow cross-cutting helpers go in `packages/utils` only after checking no domai
|
|||||||
- Workspace deps go through package names and export maps. Never import another workspace's `src` tree via repo paths, `@reactive-resume/*/src/*`, or TS path aliases.
|
- Workspace deps go through package names and export maps. Never import another workspace's `src` tree via repo paths, `@reactive-resume/*/src/*`, or TS path aliases.
|
||||||
- Workspace `turbo.json` files declare coarse tags: `app:web`, `app:server`, `runtime:server` (server-only packages: API/auth/db/env/email/MCP), `runtime:browser` (browser-only shared UI), `runtime:universal` (environment-neutral domain packages), plus `role:domain|infra|adapter|api|rendering|tooling` for intent.
|
- Workspace `turbo.json` files declare coarse tags: `app:web`, `app:server`, `runtime:server` (server-only packages: API/auth/db/env/email/MCP), `runtime:browser` (browser-only shared UI), `runtime:universal` (environment-neutral domain packages), plus `role:domain|infra|adapter|api|rendering|tooling` for intent.
|
||||||
- Runtime-specific code lives behind explicit export subpaths (`@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, `@reactive-resume/env/server`). Keep root exports environment-neutral unless the package is intentionally server-only.
|
- Runtime-specific code lives behind explicit export subpaths (`@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, `@reactive-resume/env/server`). Keep root exports environment-neutral unless the package is intentionally server-only.
|
||||||
- Wildcard exports are allowed only for leaf libraries with an intentionally file-like surface — currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages owning runtime behavior.
|
- Wildcard exports are allowed only for leaf libraries with an intentionally file-like surface — currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume/application model files. Prefer explicit exports for packages owning runtime behavior.
|
||||||
- Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures. Expose only intentional public surfaces through `packages/api/package.json`.
|
- Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures. Expose only intentional public surfaces through `packages/api/package.json`.
|
||||||
- Shared PDF section filtering: `packages/pdf/src/templates/shared/filtering.ts`. Template-specific visual exceptions stay in the owning template directory unless multiple templates need the behavior. `packages/pdf/src/hooks/use-register-fonts.ts` owns font registration, standard PDF fonts, CJK fallback stacks, and global hyphenation.
|
- Shared PDF section filtering: `packages/pdf/src/templates/shared/filtering.ts`. Template-specific visual exceptions stay in the owning template directory unless multiple templates need the behavior. `packages/pdf/src/hooks/use-register-fonts.ts` resolves font families, weights, and script fallback stacks; the Forme adapter owns conversion/rendering. PDF generation needs no Browserless or Chromium service.
|
||||||
|
|
||||||
Multi-place changes:
|
Multi-place changes:
|
||||||
|
|
||||||
- **Resume data shape**: `packages/schema/src/resume/*` first, then API DTOs, importers, PDF rendering, and web forms consuming it.
|
- **Resume data shape**: `packages/schema/src/resume/*` first, then API DTOs, importers, PDF rendering, and web forms consuming it.
|
||||||
- **New template**: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, source under `packages/pdf/src/templates/<name>/`, and previews under `apps/web/public/templates/{jpg,pdf}`.
|
- **New template**: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, source under `packages/pdf/src/templates/<name>/`, and previews under `apps/web/public/templates/{jpg,pdf}`.
|
||||||
- **New DB column/table**: `packages/db/src/schema/*`, then `dotenvx run -f .env.local -- pnpm db:generate`.
|
- **New DB column/table**: `packages/db/src/schema/*`, then `pnpm db:generate`.
|
||||||
- **New env var**: `packages/env/src/server.ts` **and** the `globalEnv` array in `turbo.json`. Turborepo 2.x strict env mode filters out unlisted vars, so the variable will be `undefined` in child processes at runtime even when correctly set in the OS/container environment.
|
- **New env var**: `packages/env/src/server.ts`, `.env.example`, **and** the `globalPassThroughEnv` array and applicable test-task `env` arrays in `turbo.json`. Add deployment aliases in `packages/env/src/deployment.ts` when needed. Turborepo strict env mode filters unlisted injected variables from task processes.
|
||||||
|
|
||||||
## Environment and database
|
## Environment and database
|
||||||
|
|
||||||
Copy `.env.example` to `.env.local`. Three required vars: `APP_URL` (default `http://localhost:3000`), `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`), `AUTH_SECRET` (any non-empty string).
|
Host development requires `APP_URL`, `DATABASE_URL`, and non-empty `AUTH_SECRET`. `packages/env/src/server.ts` also loads root `.env` through Node's native `process.loadEnvFile`; existing process variables take precedence. Root dev/database scripts explicitly load `.env.local` through `dotenvx`. Tests and application code can have their own environment loaders; do not assume every command loads `.env.local`.
|
||||||
|
|
||||||
- **S3/SeaweedFS optional.** If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. `.env.example` ships SeaweedFS defaults, so either start the `seaweedfs` compose service or comment those vars out to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
- **Storage**: explicit `STORAGE_BACKEND=local|s3|blob` wins. Otherwise, complete S3 credentials select S3; Vercel selects private Blob; other deployments select local storage. `.env.example` ships SeaweedFS defaults, so either run SeaweedFS or select `local`/remove the S3 credentials. Local storage defaults to `<workspace>/data` in development and `/app/data` in Docker. `LOCAL_STORAGE_PATH` must be absolute and writable; persist it in deployed installations.
|
||||||
- **`REDIS_URL` and `ENCRYPTION_SECRET`** are optional for core resume flows but both required for saved AI providers and the authenticated `/agent` workspace. Host-run dev uses `REDIS_URL=redis://localhost:6379`; the container-run app uses `redis://redis:6379`.
|
- **`ENCRYPTION_SECRET`** is required for saved AI providers and the assistant. **`REDIS_URL`** is optional outside Vercel; it shares rate limits, cancellation and resumable replies between processes. Vercel deployment preparation requires Redis. Host-run dev uses `REDIS_URL=redis://localhost:6379`; the container-run app uses `redis://redis:6379`.
|
||||||
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. Run migration commands through `dotenvx`.
|
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. The root migration scripts load `.env.local` through `dotenvx` before invoking Drizzle Kit.
|
||||||
- The production server auto-runs migrations at startup before serving traffic, so manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
- `DATABASE_MIGRATION_URL` supplies a direct migration connection when runtime `DATABASE_URL` is pooled. Review generated migration SQL before applying it; avoid resetting databases or deleting volumes to fix setup errors.
|
||||||
|
- Startup verifies the migrated schema. `STRICT_SCHEMA_CHECK=true` makes detected drift fatal; otherwise the server logs it and continues.
|
||||||
|
|
||||||
## Commands
|
## Testing and checks
|
||||||
|
|
||||||
Prefix dev servers and migration commands with `dotenvx run -f .env.local --`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need it; if one fails on a missing env var, rerun it with the prefix.
|
Prefer package-scoped checks for the files changed. Package names come from their `package.json`: the apps are `web` and `server`, most shared packages are `@reactive-resume/<name>`.
|
||||||
|
|
||||||
```
|
```sh
|
||||||
sudo docker compose -f compose.dev.yml up -d postgres # DB only
|
pnpm --filter web typecheck
|
||||||
sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket # full infra
|
pnpm --filter @reactive-resume/pdf test
|
||||||
dotenvx run -f .env.local -- pnpm dev # port 3000 (dev:web for web only)
|
pnpm --filter @reactive-resume/pdf test src/templates/shared/filtering.test.ts
|
||||||
dotenvx run -f .env.local -- pnpm db:generate # db:migrate to apply
|
pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/filtering.test.ts -t "filterItems"
|
||||||
pnpm check # Biome — WRITE-CAPABLE (--write --unsafe)
|
pnpm --filter @reactive-resume/pdf test:coverage
|
||||||
pnpm test | pnpm typecheck | pnpm build | pnpm exec turbo boundaries
|
pnpm exec oxlint --deny-warnings apps/web/src/features/resume
|
||||||
|
pnpm exec oxfmt --check apps/web/src/features/resume
|
||||||
|
pnpm exec turbo boundaries
|
||||||
```
|
```
|
||||||
|
|
||||||
Prefer package filters over repo-wide runs, e.g. `pnpm --filter web typecheck`, `pnpm --filter @reactive-resume/pdf test`. Vitest paths are package-relative under `pnpm --filter <package> test -- <path>`.
|
- Vitest tests live alongside source as `src/**/*.test.ts(x)` or `src/**/*.spec.ts(x)` (including integration tests). Paths under `pnpm --filter <package>` are package-relative. Pass paths directly after `test`: an extra `--` currently prevents Vitest from filtering the run. Shared settings live in `vitest.shared.mts` and setup in `vitest.setup.ts`; most packages use Node, while `packages/ui` uses `happy-dom`.
|
||||||
|
- Coverage uses V8 and writes package-local `coverage/` reports. No shared minimum coverage threshold is configured. `test:ci` writes JSON/JUnit results under package-local `reports/`.
|
||||||
|
- Root `pnpm test`, `pnpm test:coverage`, and `pnpm typecheck` run workspace checks through Turbo. CI checks boundaries and affected-package typechecks, then runs all unit suites with `pnpm exec turbo run test:ci --concurrency=1` to avoid CPU contention in PDF/rate-limit suites. Unit/browser and Vercel workflows persist `.turbo/cache`; cached coverage and test reports restore to package-local output directories.
|
||||||
|
- Real-database unit suites use `COVER_LETTER_TEST_DATABASE_URL` and `OAUTH_TEST_DATABASE_URL`; see `.github/workflows/e2e.yml` for isolated database setup. Never point test fixtures at production data.
|
||||||
|
- After changing shared contracts, exports, or imports, check affected consumers and run `pnpm exec turbo boundaries`.
|
||||||
|
|
||||||
|
### Browser tests
|
||||||
|
|
||||||
|
Playwright specs live in `tests/e2e/specs/*.spec.ts`, with fixtures in `tests/e2e/fixtures`. Configure a disposable PostgreSQL database and export test environment variables before building/running; these root scripts do not wrap `dotenvx`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm exec playwright install chromium
|
||||||
|
pnpm build
|
||||||
|
pnpm test:e2e
|
||||||
|
pnpm test:e2e tests/e2e/specs/auth.spec.ts
|
||||||
|
pnpm test:e2e:ui
|
||||||
|
```
|
||||||
|
|
||||||
|
- `playwright.config.ts` starts `node apps/server/dist/index.mjs` in production mode and waits for `/api/health`; locally it can reuse an existing server. Build first. Keep the direct Node command: pnpm's script process groups can prevent Playwright from cleaning up a server started through `pnpm start`.
|
||||||
|
- Export `APP_URL`, `PORT`, `DATABASE_URL`, `AUTH_SECRET`, and `ENCRYPTION_SECRET`, and choose an absolute writable `LOCAL_STORAGE_PATH`. Auth fixtures need signups/email auth enabled; `FLAG_DISABLE_API_RATE_LIMIT=true` is appropriate for this isolated test installation.
|
||||||
|
- Assistant specs use a deterministic local AI stub and need `FLAG_ALLOW_UNSAFE_AI_BASE_URL=true`; otherwise those specs skip. See `tests/e2e/README.md` for the full environment recipe; adapt its example storage path to your machine.
|
||||||
|
- Playwright runs Chromium with no retries. CI uses one worker and retains failure traces, screenshots, videos, and reports. PDF/DOCX rasterization and visual regression are outside this browser gate.
|
||||||
|
|
||||||
|
## Code style
|
||||||
|
|
||||||
|
- TypeScript is strict, including `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`, and unused-symbol checks; packages typecheck with `tsgo --noEmit`.
|
||||||
|
- Oxlint checks code with its native React Compiler and accessibility rules. Oxfmt uses tabs, double quotes, 120-column lines, separated type import groups, and sorted Tailwind classes in `clsx`, `cva`, and `cn`. Use existing file naming and feature-local conventions.
|
||||||
|
- **`pnpm check` modifies files**: it regenerates PDF translations, applies safe Oxlint fixes, runs Oxfmt, then fails on remaining lint errors or warnings. Review the diff; use `pnpm lint` and `pnpm format:check` for non-mutating checks.
|
||||||
|
- Lefthook's pre-commit hook checks conflict markers, applies safe Oxlint fixes, runs Oxfmt, then checks staged files with warnings denied and stages the fixes. The commit-message hook enforces Conventional Commits (`fix:`, `feat:`, `docs:`, etc.).
|
||||||
|
|
||||||
|
## Linting and formatting for coding agents
|
||||||
|
|
||||||
|
- After code changes, run `pnpm exec oxlint --fix <changed paths>`, then `pnpm exec oxfmt <changed paths>`. Safe lint fixes run before formatting and import/Tailwind sorting. Keep side-effect import order intact.
|
||||||
|
- Before finishing, run `pnpm lint:agent` (`oxlint --deny-warnings --format=agent`) and `pnpm format:check`. Fix diagnostics and recheck; do not disable rules merely to make a check pass. Any necessary inline suppression must name its rule and explain why.
|
||||||
|
- Lint rules live in `.oxlintrc.json`; formatting and import groups live in `.oxfmtrc.json`. Generated route trees, build outputs, migrations, OpenAPI JSON, generated schema references, and byte-sensitive PDF CSS fixtures are excluded where appropriate.
|
||||||
|
- `@shadcn/lint` is registered as a JS plugin. Its design-system rules are opt-in: configure them in `.oxlintrc.json` after choosing the policy. It discovers the shared UI exports and Tailwind theme through `apps/web/components.json` and `packages/ui/components.json`. See [available rules](https://github.com/shadcn-ui/lint#rules). Keep UI-specific policies scoped to the web app and UI package.
|
||||||
|
- Async test doubles may return a Promise without awaiting; the test override permits this. Playwright fixture callbacks named `use` are not React hooks, so `rules-of-hooks` is disabled only in the fixture adapter. Path references in declaration shims remain supported. CSS is formatted by Oxfmt; Oxlint checks JavaScript/TypeScript rather than CSS declarations.
|
||||||
|
- Editor setup is checked in under `.vscode/`: install the recommended Oxc extension for lint fixes and formatting on save. This workflow follows the [Oxc coding-agent guide](https://oxc.rs/docs/guide/usage/coding-agents.html). Restart Codex sessions after changing these instructions.
|
||||||
|
|
||||||
|
## Build and deployment
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm build
|
||||||
|
NODE_ENV=production pnpm start
|
||||||
|
docker compose up -d --build
|
||||||
|
```
|
||||||
|
|
||||||
|
- Build outputs: `apps/web/dist` (SPA/assets), `apps/web/dist-prerender` (localized marketing HTML), and `apps/server/dist` (`index.mjs` plus server/deployment chunks). `pnpm start` runs the built server; set `NODE_ENV=production` so it uses `PORT` instead of `SERVER_PORT`. Export runtime variables or provide root `.env`; `.env.local` is not loaded by `start`.
|
||||||
|
- Production Compose loads `.env.example` then `.env`, not `.env.local`. Configure `.env` with container hostnames (`postgres`, `redis`, `seaweedfs`) and production secrets before running it. The Docker image runs as `node`, listens on `3000`, and persists local storage through `/app/data`. Health endpoint: `/api/health`.
|
||||||
|
- `vercel.json` defines Vercel Services (project framework must be `Services`): `frontend` (`apps/web`, static `dist`) and `backend` (`apps/server`, entrypoint `apps/server/vercel.mjs` re-exporting the tsdown build). The backend build runs `pnpm build` for both apps, then `node apps/server/dist/prepare-deployment.mjs`. Top-level rewrites send paths whose last segment has a file extension to `frontend` and everything else, including HTML shells, to `backend`, except the server-owned paths listed first. The Function uses Node 24 and a 300-second budget. `outputDirectory: "."` on `backend` stops the builder from treating `dist/index.mjs` (the Docker entrypoint) as the handler.
|
||||||
|
- Vercel environment normalization accepts `POSTGRES_URL`, direct/unpooled DB aliases, and `KV_URL`. `APP_URL` can be derived from Vercel host variables. Blob is the default when no S3 credentials are set. Preview deployments require isolated resources before enabling `ALLOW_PREVIEW_MIGRATIONS=true`; see `docs/self-hosting/vercel.mdx`.
|
||||||
|
- `.github/workflows/e2e.yml` gates core unit/browser flows; `vercel.yml` builds and checks the serverless artifact on PRs and pushes to `main`. `autofix.yml` runs write-capable `pnpm knip --fix` and `pnpm check`. GitHub runners are the default; `USE_BLACKSMITH=true` switches runners and paired actions.
|
||||||
|
- `docker-build.yml` publishes native AMD64/ARM64 images. `main` publishes nightly aliases; release tags/explicit release dispatch publish stable aliases and can trigger configured production integrations. See `docs/agents/container-publishing.md` before release work.
|
||||||
|
- Deployment smoke tests create/delete accounts and files; run only against a dedicated test installation. Details: `docs/contributing/deployment-checks.mdx`.
|
||||||
|
|
||||||
|
## Security and pull requests
|
||||||
|
|
||||||
|
- Keep credentials and personal resume data out of source, logs, test artifacts, issues, and PRs. Do not commit local environment files or substitute production secrets for test values.
|
||||||
|
- Authenticated procedures use `protectedProcedure`; enforce resource ownership in feature logic. Reuse shared auth resolution for API keys, bearer tokens, and cookies rather than adding a separate auth path.
|
||||||
|
- Keep unsafe OAuth redirect/AI URL flags disabled on public deployments. They relax redirect validation and SSRF protections for trusted self-hosted/test use.
|
||||||
|
- Keep PRs focused. Describe the problem, resulting behavior, and checks actually run; link the relevant GitHub issue. Conventional Commits are enforced for commit messages; no separate PR-title convention is configured.
|
||||||
|
- Before submitting, run applicable typechecks/tests and non-mutating lint checks; run the production build for runtime/bundling changes. Match CI's database/browser prerequisites when reproducing its checks. Report skipped checks and failures instead of claiming they passed.
|
||||||
|
- Never add AI attribution, co-author trailers naming AI tools, or session/chat links to commits or PR descriptions.
|
||||||
|
|
||||||
## Gotchas
|
## Gotchas
|
||||||
|
|
||||||
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
|
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
|
||||||
- `lefthook.yml` pre-commit runs `biome check` on staged files. Run `pnpm check` before committing.
|
- Database connection errors: check `docker compose -f compose.dev.yml ps` and use `localhost` for host-run code, service names inside containers.
|
||||||
- `pnpm check` is write-capable. Call that out when using it, and use narrower Biome commands for a non-mutating inspection.
|
- S3 errors: check `docker compose -f compose.dev.yml logs seaweedfs seaweedfs_create_bucket`; verify endpoint and bucket, or select local storage.
|
||||||
- Biome: tabs, double quotes, line width 120, organized import groups, sorted Tailwind classes for `clsx`, `cva`, `cn`.
|
- Route-tree errors after adding routes: run Vite dev/build to regenerate `apps/web/src/routeTree.gen.ts`; never edit it by hand.
|
||||||
- Most packages typecheck with `tsgo --noEmit` and test with `vitest run --passWithNoTests`.
|
- Serverless module-loading failures: inspect `bundledInteropPackages` in `apps/server/tsdown.config.ts` and the Vercel compatibility workflow. External CommonJS server dependencies break on Vercel because its service builder drops their pnpm links; bundle them with their dependencies.
|
||||||
- There may be unrelated local edits in the worktree. Check `git status --short` first; do not revert files you did not touch.
|
- Most test scripts use `--passWithNoTests`; a successful run with zero tests does not verify the behavior you changed.
|
||||||
|
|||||||
@@ -1,347 +0,0 @@
|
|||||||
---
|
|
||||||
version: alpha
|
|
||||||
name: Reactive Resume
|
|
||||||
description: A monochrome, content-first design system for a free and open-source resume builder. Dark-by-default with light mode support.
|
|
||||||
colors:
|
|
||||||
primary: "#343434"
|
|
||||||
primary-foreground: "#FBFBFB"
|
|
||||||
secondary: "#F7F7F7"
|
|
||||||
secondary-foreground: "#343434"
|
|
||||||
background: "#FFFFFF"
|
|
||||||
foreground: "#252525"
|
|
||||||
muted: "#F7F7F7"
|
|
||||||
muted-foreground: "#8E8E8E"
|
|
||||||
card: "#FFFFFF"
|
|
||||||
card-foreground: "#252525"
|
|
||||||
border: "#EBEBEB"
|
|
||||||
input: "#EBEBEB"
|
|
||||||
ring: "#B5B5B5"
|
|
||||||
destructive: "#DC2626"
|
|
||||||
on-destructive: "#FFFFFF"
|
|
||||||
typography:
|
|
||||||
heading:
|
|
||||||
fontFamily: IBM Plex Sans Variable
|
|
||||||
fontSize: 1rem
|
|
||||||
fontWeight: 500
|
|
||||||
body:
|
|
||||||
fontFamily: IBM Plex Sans Variable
|
|
||||||
fontSize: 0.875rem
|
|
||||||
fontWeight: 400
|
|
||||||
body-sm:
|
|
||||||
fontFamily: IBM Plex Sans Variable
|
|
||||||
fontSize: 0.75rem
|
|
||||||
fontWeight: 400
|
|
||||||
label:
|
|
||||||
fontFamily: IBM Plex Sans Variable
|
|
||||||
fontSize: 0.8rem
|
|
||||||
fontWeight: 500
|
|
||||||
hero-heading:
|
|
||||||
fontFamily: IBM Plex Sans Variable
|
|
||||||
fontSize: 3.75rem
|
|
||||||
fontWeight: 700
|
|
||||||
letterSpacing: -0.025em
|
|
||||||
rounded:
|
|
||||||
sm: 0.18rem
|
|
||||||
md: 0.24rem
|
|
||||||
lg: 0.3rem
|
|
||||||
xl: 0.42rem
|
|
||||||
2xl: 0.54rem
|
|
||||||
3xl: 0.66rem
|
|
||||||
4xl: 0.78rem
|
|
||||||
spacing:
|
|
||||||
xs: 4px
|
|
||||||
sm: 8px
|
|
||||||
md: 16px
|
|
||||||
lg: 24px
|
|
||||||
xl: 32px
|
|
||||||
2xl: 48px
|
|
||||||
components:
|
|
||||||
button-default:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.primary-foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 10px
|
|
||||||
height: 36px
|
|
||||||
button-outline:
|
|
||||||
backgroundColor: "{colors.background}"
|
|
||||||
textColor: "{colors.foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 10px
|
|
||||||
height: 36px
|
|
||||||
button-secondary:
|
|
||||||
backgroundColor: "{colors.secondary}"
|
|
||||||
textColor: "{colors.secondary-foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 10px
|
|
||||||
height: 36px
|
|
||||||
button-ghost:
|
|
||||||
backgroundColor: "{colors.background}"
|
|
||||||
textColor: "{colors.foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 10px
|
|
||||||
height: 36px
|
|
||||||
button-destructive:
|
|
||||||
backgroundColor: "{colors.destructive}"
|
|
||||||
textColor: "{colors.on-destructive}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 10px
|
|
||||||
height: 36px
|
|
||||||
card:
|
|
||||||
backgroundColor: "{colors.card}"
|
|
||||||
textColor: "{colors.card-foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 16px
|
|
||||||
input:
|
|
||||||
backgroundColor: "{colors.background}"
|
|
||||||
textColor: "{colors.foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
height: 36px
|
|
||||||
padding: 10px
|
|
||||||
input-focus:
|
|
||||||
backgroundColor: "{colors.background}"
|
|
||||||
textColor: "{colors.foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
height: 36px
|
|
||||||
padding: 10px
|
|
||||||
badge:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.primary-foreground}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: 4px
|
|
||||||
popover:
|
|
||||||
backgroundColor: "{colors.card}"
|
|
||||||
textColor: "{colors.card-foreground}"
|
|
||||||
rounded: "{rounded.xl}"
|
|
||||||
padding: 4px
|
|
||||||
sidebar:
|
|
||||||
backgroundColor: "{colors.muted}"
|
|
||||||
textColor: "{colors.foreground}"
|
|
||||||
padding: 8px
|
|
||||||
sidebar-item:
|
|
||||||
backgroundColor: "{colors.muted}"
|
|
||||||
textColor: "{colors.muted-foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 8px
|
|
||||||
sidebar-item-active:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.primary-foreground}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
padding: 8px
|
|
||||||
tooltip:
|
|
||||||
backgroundColor: "{colors.primary}"
|
|
||||||
textColor: "{colors.primary-foreground}"
|
|
||||||
rounded: "{rounded.md}"
|
|
||||||
padding: 6px
|
|
||||||
separator:
|
|
||||||
backgroundColor: "{colors.border}"
|
|
||||||
height: 1px
|
|
||||||
dialog:
|
|
||||||
backgroundColor: "{colors.card}"
|
|
||||||
textColor: "{colors.card-foreground}"
|
|
||||||
rounded: "{rounded.xl}"
|
|
||||||
padding: 24px
|
|
||||||
input-invalid:
|
|
||||||
backgroundColor: "{colors.background}"
|
|
||||||
textColor: "{colors.destructive}"
|
|
||||||
rounded: "{rounded.lg}"
|
|
||||||
height: 36px
|
|
||||||
padding: 10px
|
|
||||||
---
|
|
||||||
|
|
||||||
## Overview
|
|
||||||
|
|
||||||
Reactive Resume is a monochrome, content-first design system built for a resume builder used by tens of thousands of people worldwide. The visual identity prioritizes readability and unobtrusiveness — the user's resume content is always the hero, never the chrome around it.
|
|
||||||
|
|
||||||
The system defaults to dark mode with a warm near-black backdrop that makes the resume preview "float" as the visual anchor. Light mode is supported as a full alternative. The authenticated app shell (dashboard, builder, settings) uses an entirely achromatic grayscale palette — the sole chromatic exception is destructive red for dangerous actions. The landing page introduces subtle chromatic accents: blue-tinted spotlight gradients on the hero, a multicolor text-mask animation on hover, and social auth provider brand colors (Google blue, LinkedIn blue) on the login page.
|
|
||||||
|
|
||||||
The overall aesthetic is a professional tool UI: clean grid lines, subtle borders, generous whitespace, and typography that steps back to let the content shine. Think "VS Code meets Figma" — a productivity workspace, not a marketing site.
|
|
||||||
|
|
||||||
One deliberate counterpoint to the serious UI: all resume templates are named after Pokemon (Azurill, Bronzor, Chikorita, Ditgar, Gengar, Pikachu, etc.). This is an intentional brand choice — playful naming for templates injects personality into an otherwise utilitarian interface, making templates feel collectible and memorable rather than generic ("Template 1", "Modern", "Classic").
|
|
||||||
|
|
||||||
## Colors
|
|
||||||
|
|
||||||
The palette is rooted in achromatic OKLch values (chroma = 0), producing a pure grayscale scale without warm or cool casts. Colors are defined as CSS custom properties using `oklch()` and consumed through Tailwind CSS 4 theme tokens. Always prefer CSS variables (e.g., `var(--primary)`) or Tailwind tokens (e.g., `bg-primary`) over raw color values. The hex values in this document's YAML front matter are agent-friendly approximations of the canonical OKLch definitions in `packages/ui/src/styles/globals.css` — use hex only where OKLch is unavailable.
|
|
||||||
|
|
||||||
- **Primary (#343434 light / #EBEBEB dark):** Used for high-emphasis interactive surfaces — default buttons, selected states, and text selection. In dark mode this inverts to near-white so buttons remain prominent.
|
|
||||||
- **Foreground (#252525 light / #FBFBFB dark):** Body text and headings. High contrast against the background in both themes.
|
|
||||||
- **Background (#FFFFFF light / #252525 dark):** The canvas. Pure white in light mode, warm near-black in dark mode.
|
|
||||||
- **Card (#FFFFFF light / #343434 dark):** Elevated surface for cards, panels, and the builder sidebar. In dark mode, one step lighter than the background to create subtle depth.
|
|
||||||
- **Muted (#F7F7F7 light / #454545 dark):** De-emphasized backgrounds for secondary UI regions, hover states, and inactive tabs.
|
|
||||||
- **Muted Foreground (#8E8E8E light / #B5B5B5 dark):** Captions, helper text, timestamps, and metadata. Deliberately low-contrast against the background to recede visually.
|
|
||||||
- **Border (#EBEBEB light / white at 10% opacity dark):** Thin separator lines. In dark mode, uses transparent white rather than a solid gray to blend naturally with any underlying surface color.
|
|
||||||
- **Input (#EBEBEB light / white at 15% opacity dark):** Form field borders, slightly more prominent than general borders to make input areas discoverable.
|
|
||||||
- **Destructive (#DC2626 light / #EF4444 dark):** The only chromatic color in the palette. Reserved exclusively for delete actions, error states, and danger-zone operations. Used at 10% opacity as a background tint with full saturation for text, creating a soft but unmistakable warning.
|
|
||||||
- **Ring (#B5B5B5 light / #8E8E8E dark):** Focus ring indicator at 50% opacity, surrounding focused interactive elements.
|
|
||||||
- **Sidebar Primary (dark only, #6366F1):** An indigo value inherited from the shadcn/ui defaults. Not actively used in the current UI — sidebar active states use the standard grayscale primary token instead. Retained in the CSS custom properties for potential future customization.
|
|
||||||
|
|
||||||
Resume templates have their own independent color system — users pick primary, text, and background colors per resume through a color picker in the builder's Design panel. These template colors are completely separate from the app shell palette.
|
|
||||||
|
|
||||||
## Typography
|
|
||||||
|
|
||||||
The entire application uses a single typeface: **IBM Plex Sans Variable**. This is a humanist sans-serif with an extensive weight range (100–900) and excellent readability at small sizes, both on screen and in PDFs.
|
|
||||||
|
|
||||||
- **Hero heading (responsive: 2.25rem mobile / 3rem tablet / 3.75rem desktop, weight 700, tracking-tight):** Landing page headline only. Large, bold, and commanding. Scales across three breakpoints.
|
|
||||||
- **Section heading (1rem / 16px, weight 500):** Used for section titles in the builder sidebar, settings panels, and dashboard cards. Medium weight provides hierarchy without shouting.
|
|
||||||
- **Body (0.875rem / 14px, weight 400):** The workhorse. All form labels, descriptions, card content, and general UI text.
|
|
||||||
- **Small body (0.75rem / 12px, weight 400):** Captions, helper text, timestamps, and metadata.
|
|
||||||
- **Label (0.8rem / ~13px, weight 500):** Button text, badge labels, and form field labels. Slightly heavier than body to denote interactivity.
|
|
||||||
|
|
||||||
The resume content itself uses a separate font system — users choose from 1,000+ Google Fonts for their resume headings and body text, with category-aware fallback stacks including CJK support (Noto Sans SC, PingFang SC, Hiragino Sans GB for sans-serif; Noto Serif SC, Songti SC for serif). Standard PDF fonts (Helvetica, Courier, Times-Roman) are available as offline fallbacks.
|
|
||||||
|
|
||||||
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
|
|
||||||
|
|
||||||
## Layout
|
|
||||||
|
|
||||||
### Builder (Three-Panel Workspace)
|
|
||||||
|
|
||||||
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
|
|
||||||
|
|
||||||
- **Left sidebar (default 22%):** Resume section forms — personal info, experience, education, skills, and custom sections. Scrollable with collapsible section groups.
|
|
||||||
- **Center artboard (default 56%):** Live resume preview rendered via PDF.js canvas. Supports zoom, pan, and pinch gestures via `react-zoom-pan-pinch`. The preview maintains A4 aspect ratio (210:297) with a subtle shadow to simulate a physical page.
|
|
||||||
- **Right sidebar (default 22%):** Design controls — template picker, font selection, color picker, layout manager (page assignments, section ordering via drag-and-drop).
|
|
||||||
|
|
||||||
Panel sizes persist in cookies. On mobile (< 768px), sidebars collapse to 0% width and become toggleable overlays (max 95% width when open). The desktop minimum collapsed width is 48px (icon rail).
|
|
||||||
|
|
||||||
### Dashboard
|
|
||||||
|
|
||||||
Standard sidebar navigation layout using the `Sidebar` component system. The sidebar contains: logo, resume list link, agent link, settings subnavigation (profile, preferences, authentication, API keys, integrations, danger zone), and a footer with user avatar. Content area shows a responsive grid of resume cards.
|
|
||||||
|
|
||||||
### Landing Page
|
|
||||||
|
|
||||||
Full-width single-column marketing layout:
|
|
||||||
1. **Floating builder preview** — A non-interactive screenshot of the builder as a hero visual, creating an immediate "this is what you get" impression.
|
|
||||||
2. **Hero** — Centered headline, subheadline, and two CTAs (primary "Get Started" with arrow, ghost "Learn More" with icon).
|
|
||||||
3. **Features grid** — 4-column responsive grid with icon + title + description cards, separated by thin border lines.
|
|
||||||
4. **Template carousel** — Horizontally scrolling row of template preview thumbnails with Pokemon-themed names.
|
|
||||||
5. **Testimonials** — Tiled user quotes in a masonry-style grid.
|
|
||||||
6. **Support / FAQ / Footer** — Accordion FAQ, community section, and a 4-column footer with logo, resource links, community links, and license info.
|
|
||||||
|
|
||||||
### Responsive Breakpoints
|
|
||||||
|
|
||||||
Mobile detection uses a 768px threshold via `MediaQueryList`. The layout is optimized for workspace productivity on larger screens, with responsive mobile support that adapts the multi-panel builder into a streamlined single-panel experience. Both desktop and mobile are supported experiences — the builder's three-panel layout leverages desktop space, while mobile surfaces the same editing capabilities through collapsible overlays.
|
|
||||||
|
|
||||||
### Page Aspect Ratio
|
|
||||||
|
|
||||||
A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions wherever resume pages are rendered (builder preview, public view, PDF export).
|
|
||||||
|
|
||||||
## Animation
|
|
||||||
|
|
||||||
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
|
|
||||||
|
|
||||||
**Entrance animations** use a fade-up reveal: elements start at `opacity: 0, y: 20-100` and animate to `opacity: 1, y: 0`. The hero section uses a larger y-offset (100px) for dramatic effect; subsequent sections use 20px for subtlety.
|
|
||||||
|
|
||||||
**Timing principles:**
|
|
||||||
- **Base duration:** 0.35s–0.6s for standard section reveals, 0.45s for hero elements, up to 1.1s for the hero video entrance.
|
|
||||||
- **Stagger pattern:** Sequential delays within a group, typically 0.1s–0.15s apart (hero: 0.55s, 0.7s, 0.82s, 0.95s). For grids, use `index * 0.03`–`0.1` for per-item stagger.
|
|
||||||
- **Easing:** `easeOut` for entrances (elements decelerate into position). `easeInOut` for looping/ambient animations.
|
|
||||||
- **Performance:** Apply `will-change-[transform,opacity]` on animated elements and `will-change-transform` on continuously animated elements.
|
|
||||||
|
|
||||||
**Hover/interaction animations** are quick (0.2s) and subtle — small scale bumps (`scale: 1.01`), slight y-offsets (`y: -2`), and `active:translate-y-px` for button press.
|
|
||||||
|
|
||||||
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
|
|
||||||
|
|
||||||
**Reduced motion:** All CSS transitions and animations collapse to `0.01ms` duration and single iteration when `prefers-reduced-motion: reduce` is active. Motion library animations should also respect this preference.
|
|
||||||
|
|
||||||
## Elevation & Depth
|
|
||||||
|
|
||||||
Elevation is handled through background color layering rather than drop shadows:
|
|
||||||
|
|
||||||
- **Level 0 — Background:** The base canvas (`--background`).
|
|
||||||
- **Level 1 — Card:** One step lighter in dark mode (`--card`), used for sidebars, panels, and cards.
|
|
||||||
- **Level 2 — Popover:** Same as card, but appears above the content layer in popovers, dropdowns, and command palette.
|
|
||||||
- **Level 3 — Overlay:** Backdrop blur (`backdrop-blur-xs` at 0.5px or `backdrop-blur-2xl` at 40px) with `backdrop-saturate-150` for modal overlays, creating a frosted-glass effect over the workspace.
|
|
||||||
|
|
||||||
The resume preview page uses a subtle drop shadow to simulate a physical sheet of paper floating above the dark artboard — one of the few places actual shadows appear.
|
|
||||||
|
|
||||||
## Shapes
|
|
||||||
|
|
||||||
Border radius follows a multiplicative scale from a single `--radius` base of `0.3rem`:
|
|
||||||
|
|
||||||
| Token | Value | Usage |
|
|
||||||
|:------|:------|:------|
|
|
||||||
| `sm` | 0.18rem (≈3px) | Small badges, inline chips |
|
|
||||||
| `md` | 0.24rem (≈4px) | XS/SM buttons, compact elements |
|
|
||||||
| `lg` | 0.3rem (≈5px) | Default buttons, cards, inputs |
|
|
||||||
| `xl` | 0.42rem (≈7px) | Larger cards, modal corners |
|
|
||||||
| `2xl` | 0.54rem (≈9px) | Dialog containers |
|
|
||||||
| `3xl` | 0.66rem (≈11px) | Large panels |
|
|
||||||
| `4xl` | 0.78rem (≈12px) | Full-page modals |
|
|
||||||
|
|
||||||
The radius scale is deliberately tight — the largest value (0.78rem) is still quite subtle. This avoids the "rounded everything" aesthetic and keeps the UI feeling precise and tool-like. Interactive elements consistently use `rounded-lg` as the default.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Buttons
|
|
||||||
|
|
||||||
Six variants, all sharing `rounded-lg` corners, `font-medium`, `text-sm`, and a 1px `translate-y` on active press (except when the button opens a popup):
|
|
||||||
|
|
||||||
- **Default:** Solid primary background. The highest-emphasis action on any screen.
|
|
||||||
- **Outline:** Transparent with a border. For secondary actions that need clear boundaries.
|
|
||||||
- **Secondary:** Muted background. For paired actions alongside a primary button.
|
|
||||||
- **Ghost:** No background or border. For toolbar actions and inline controls where chrome would be noise.
|
|
||||||
- **Destructive:** Red at 10% opacity background with red text. Visually alarming without being garish.
|
|
||||||
- **Link:** Underline-on-hover text. For inline navigation within prose.
|
|
||||||
|
|
||||||
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
|
|
||||||
|
|
||||||
### Cards
|
|
||||||
|
|
||||||
White/dark surface with foreground text. Composed of `CardHeader`, `CardTitle`, `CardDescription`, `CardContent`, `CardFooter`, and `CardAction` slots. Default vertical padding is `py-4` (compact: `py-3`).
|
|
||||||
|
|
||||||
### Forms
|
|
||||||
|
|
||||||
Built on TanStack Form with Zod validation. Composed of `FormItem`, `FormLabel`, `FormControl`, `FormMessage`, and `FormDescription`. Validation errors only appear after field touch. Invalid fields get a red destructive border with a ring.
|
|
||||||
|
|
||||||
### Dialogs
|
|
||||||
|
|
||||||
Centralized dialog manager with 40+ dialog types, all rendered via pattern matching (`ts-pattern`). Dialogs support before-close validation, form blocking for unsaved changes, and confirmation prompts. Used for all CRUD operations on resume sections, settings changes, and import/export flows.
|
|
||||||
|
|
||||||
### Command Palette
|
|
||||||
|
|
||||||
Triggered by `Cmd+K` / `Ctrl+K`. Built on `cmdk` with fuzzy search via `Fuse.js`. Multi-page navigation (resumes, settings, preferences) with back navigation via Backspace. Screen-reader accessible with `sr-only` headings.
|
|
||||||
|
|
||||||
### Toast Notifications
|
|
||||||
|
|
||||||
Powered by Sonner, positioned bottom-right with rich colors. Used for auto-save feedback, form submission status, error reporting, and donation prompts. Loading toasts are used during async operations (PDF generation, resume creation) with dismiss-on-complete.
|
|
||||||
|
|
||||||
### Drag and Drop
|
|
||||||
|
|
||||||
Powered by `@dnd-kit` with `PointerSensor` and `KeyboardSensor`. Used in chip inputs (skill tags, URL lists) and page layout management (section ordering across resume pages). Smooth animations via Motion library.
|
|
||||||
|
|
||||||
## Internationalization
|
|
||||||
|
|
||||||
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
|
|
||||||
|
|
||||||
**Direction:** The `<html>` element receives `dir="rtl"` or `dir="ltr"` based on the active locale, detected via `isRTL()` which checks the language prefix against a known RTL set. All layout mirroring flows from this single attribute.
|
|
||||||
|
|
||||||
**Logical properties:** Use CSS logical properties (`ps-`, `pe-`, `ms-`, `me-`, `inline-start`, `inline-end`, `inset-s-`, `inset-e-`) instead of physical (`pl-`, `pr-`, `ml-`, `mr-`, `left`, `right`). Button components already use `has-data-[icon=inline-start]:ps-2` and `has-data-[icon=inline-end]:pe-2` patterns. This ensures correct spacing in both LTR and RTL layouts without separate stylesheets.
|
|
||||||
|
|
||||||
**Variable-length text:** Translations can be 30–50% longer than English (German, Finnish) or significantly shorter (CJK). UI elements should accommodate variable text length — avoid fixed widths on buttons and labels. Use `whitespace-nowrap` only where truncation is acceptable, and prefer `min-w-0` with `truncate` over fixed-width containers.
|
|
||||||
|
|
||||||
**Icons:** Directional icons (arrows, chevrons, progress indicators) should mirror in RTL contexts. Phosphor Icons provides mirrored variants for directional icons. Non-directional icons (settings gear, checkmark, delete) do not mirror.
|
|
||||||
|
|
||||||
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
|
|
||||||
|
|
||||||
## Do's and Don'ts
|
|
||||||
|
|
||||||
### Do
|
|
||||||
|
|
||||||
- **Use the grayscale palette for all app chrome.** The absence of color is the brand. The resume content is the only thing that should be colorful.
|
|
||||||
- **Default to dark mode.** The dark workspace makes resume previews pop and reduces eye strain during extended editing sessions.
|
|
||||||
- **Use `text-sm` (14px) as the base text size.** The UI is information-dense — form fields, section labels, metadata — and needs to be scannable without feeling cramped.
|
|
||||||
- **Keep border radius tight.** Use `rounded-lg` (0.3rem) as the default. The tool should feel precise, not playful.
|
|
||||||
- **Respect reduced motion preferences.** All animations collapse to 0.01ms when `prefers-reduced-motion: reduce` is active.
|
|
||||||
- **Use Phosphor Icons consistently.** Regular weight, `size-4` (16px) default. Icons should be functional labels, not decorative.
|
|
||||||
- **Maintain the three-panel builder proportions.** The center artboard should always dominate. Sidebars are support panels, not equal peers.
|
|
||||||
- **Use transparent-white borders in dark mode.** `oklch(1 0 0 / 10%)` blends naturally with any surface rather than introducing a distinct gray band.
|
|
||||||
|
|
||||||
### Don't
|
|
||||||
|
|
||||||
- **Don't introduce accent colors into the app shell.** No blues, greens, or purples for primary actions. The only chromatic color is destructive red. The inherited indigo sidebar-primary token exists in CSS custom properties but is not actively used.
|
|
||||||
- **Don't use drop shadows for elevation.** Rely on background color layering and border separation. The one exception is the resume page preview shadow.
|
|
||||||
- **Don't make the UI compete with the resume content.** If a new feature draws more visual attention than the resume preview, it needs to be toned down.
|
|
||||||
- **Don't use large border radii.** Nothing above `rounded-xl` on standard components. Large pills and full-round shapes conflict with the precision-tool aesthetic.
|
|
||||||
- **Don't hardcode colors outside the token system.** All colors flow through CSS custom properties so that dark/light mode switching works automatically.
|
|
||||||
- **Don't use multiple typefaces in the app shell.** IBM Plex Sans Variable is the only UI font. Resume templates have their own font system, but the chrome stays single-family.
|
|
||||||
- **Don't skip the `data-slot` attribute on components.** It's used for styling hooks and accessibility selectors throughout the component library.
|
|
||||||
- **Don't forget RTL.** The app supports 40+ locales including Arabic, Hebrew, Persian, and Urdu. Use logical properties (`ps`, `pe`, `ms`, `me`) instead of physical (`pl`, `pr`, `ml`, `mr`).
|
|
||||||
+6
-3
@@ -3,10 +3,12 @@
|
|||||||
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
|
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
|
||||||
ARG PNPM_VERSION=11.21.0
|
ARG PNPM_VERSION=11.21.0
|
||||||
ARG NODE_VERSION=24
|
ARG NODE_VERSION=24
|
||||||
|
ARG TURBO_VERSION=2.11.5
|
||||||
|
|
||||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
|
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
|
||||||
|
|
||||||
ARG NODE_VERSION
|
ARG NODE_VERSION
|
||||||
|
ARG TURBO_VERSION
|
||||||
|
|
||||||
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
||||||
|
|
||||||
@@ -17,7 +19,7 @@ ENV TURBO_TELEMETRY_DISABLED=1
|
|||||||
FROM base AS pruner
|
FROM base AS pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
pnpm dlx turbo@2.9.12 prune web server --docker
|
pnpm dlx turbo@${TURBO_VERSION} prune web server --docker
|
||||||
|
|
||||||
FROM base AS builder
|
FROM base AS builder
|
||||||
COPY --from=pruner /app/out/json/ ./
|
COPY --from=pruner /app/out/json/ ./
|
||||||
@@ -26,12 +28,12 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
|||||||
pnpm install --frozen-lockfile
|
pnpm install --frozen-lockfile
|
||||||
|
|
||||||
COPY --from=pruner /app/out/full/ ./
|
COPY --from=pruner /app/out/full/ ./
|
||||||
RUN rm -rf apps/web/dist apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
|
RUN rm -rf apps/web/dist apps/web/dist-prerender apps/server/dist && pnpm turbo run build --filter=web --filter=server --force
|
||||||
|
|
||||||
FROM base AS runtime-pruner
|
FROM base AS runtime-pruner
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
pnpm dlx turbo@2.9.12 prune server --docker
|
pnpm dlx turbo@${TURBO_VERSION} prune server --docker
|
||||||
|
|
||||||
FROM base AS runtime-deps
|
FROM base AS runtime-deps
|
||||||
COPY --from=runtime-pruner /app/out/json/ ./
|
COPY --from=runtime-pruner /app/out/json/ ./
|
||||||
@@ -63,6 +65,7 @@ COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/
|
|||||||
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
|
COPY --from=runtime-deps --chown=node:node /app/apps/server/package.json ./apps/server/package.json
|
||||||
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
|
COPY --from=runtime-deps --chown=node:node /app/apps/server/node_modules ./apps/server/node_modules
|
||||||
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
|
COPY --from=builder --chown=node:node /app/apps/web/dist ./apps/web/dist
|
||||||
|
COPY --from=builder --chown=node:node /app/apps/web/dist-prerender ./apps/web/dist-prerender
|
||||||
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
|
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
|
||||||
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
||||||
|
|
||||||
|
|||||||
+5
-1
@@ -1,5 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1.7
|
# syntax=docker/dockerfile:1.7
|
||||||
|
|
||||||
|
# Base image only; pnpm self-manages to the packageManager version.
|
||||||
ARG PNPM_VERSION=11.21.0
|
ARG PNPM_VERSION=11.21.0
|
||||||
ARG NODE_VERSION=24
|
ARG NODE_VERSION=24
|
||||||
|
|
||||||
@@ -15,7 +16,6 @@ ENV NODE_ENV=development \
|
|||||||
TURBO_TELEMETRY_DISABLED=1
|
TURBO_TELEMETRY_DISABLED=1
|
||||||
|
|
||||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||||
COPY patches ./patches
|
|
||||||
COPY apps/server/package.json ./apps/server/package.json
|
COPY apps/server/package.json ./apps/server/package.json
|
||||||
COPY apps/web/package.json ./apps/web/package.json
|
COPY apps/web/package.json ./apps/web/package.json
|
||||||
COPY packages/ai/package.json ./packages/ai/package.json
|
COPY packages/ai/package.json ./packages/ai/package.json
|
||||||
@@ -31,6 +31,10 @@ COPY packages/pdf/package.json ./packages/pdf/package.json
|
|||||||
COPY packages/schema/package.json ./packages/schema/package.json
|
COPY packages/schema/package.json ./packages/schema/package.json
|
||||||
COPY packages/ui/package.json ./packages/ui/package.json
|
COPY packages/ui/package.json ./packages/ui/package.json
|
||||||
COPY packages/utils/package.json ./packages/utils/package.json
|
COPY packages/utils/package.json ./packages/utils/package.json
|
||||||
|
COPY packages/docx/package.json ./packages/docx/package.json
|
||||||
|
COPY packages/mcp/package.json ./packages/mcp/package.json
|
||||||
|
COPY packages/resume/package.json ./packages/resume/package.json
|
||||||
|
COPY packages/dsh-plugin/package.json ./packages/dsh-plugin/package.json
|
||||||
COPY tooling/package.json ./tooling/package.json
|
COPY tooling/package.json ./tooling/package.json
|
||||||
|
|
||||||
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
|
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
|
||||||
|
|||||||
+98
-10
@@ -8,7 +8,7 @@ most common English sense, and gets it wrong. Every entry below has been mistran
|
|||||||
in at least one shipped locale.
|
in at least one shipped locale.
|
||||||
|
|
||||||
**If you are translating, read the term here before translating it.** When the English word has
|
**If you are translating, read the term here before translating it.** When the English word has
|
||||||
a common sense that is *not* the one used here, that wrong sense is listed explicitly.
|
a common sense that is _not_ the one used here, that wrong sense is listed explicitly.
|
||||||
|
|
||||||
Terms are grouped by the part of the product they belong to. Source references point at where the
|
Terms are grouped by the part of the product they belong to. Source references point at where the
|
||||||
string is defined, so you can read the surrounding code when this file is not enough.
|
string is defined, so you can read the surrounding code when this file is not enough.
|
||||||
@@ -21,12 +21,26 @@ the catalog already uses one consistently):
|
|||||||
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
|
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
|
||||||
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
|
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
|
||||||
|
|
||||||
|
Also keep LinkedIn, Discord, Figma, Claude Desktop, PDF.js, models.dev, iOS, MIT License,
|
||||||
|
and WCAG 2.1 AA unchanged. **Word**, when naming an import or download format, means
|
||||||
|
**Microsoft Word**; do not translate it as the ordinary noun "word".
|
||||||
|
|
||||||
|
Names in sample documents and job postings are proper nouns too: Alex Morgan, Amruth Pillai,
|
||||||
|
Fieldnote, Northwind Labs, Parcel & Co., Lumen, and University of Porto. Keep these names
|
||||||
|
unchanged, including inside longer sentences. Translate surrounding job titles and descriptions,
|
||||||
|
not the person's, company's, or institution's name. Preserve place names such as Lisbon, Porto,
|
||||||
|
and Portugal in these examples as well.
|
||||||
|
|
||||||
|
Configuration identifiers such as `ENCRYPTION_SECRET`, file extensions, example email addresses,
|
||||||
|
and literal URLs are syntax, not translatable prose. Keep them exactly as written.
|
||||||
|
|
||||||
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
|
AI provider names are brand names and stay in English: OpenAI, Anthropic Claude, Google
|
||||||
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
|
Gemini, Vercel AI Gateway, OpenRouter, Mistral AI, Cohere, xAI Grok, Groq, DeepSeek, Together.ai,
|
||||||
Fireworks, Cerebras, Perplexity, Ollama Cloud.
|
Fireworks, Cerebras, Perplexity, Ollama.
|
||||||
|
|
||||||
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
|
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
|
||||||
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Rhyhorn, Scizor.
|
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Porygon, Rhyhorn, Scizor,
|
||||||
|
Smeargle.
|
||||||
|
|
||||||
## The document
|
## The document
|
||||||
|
|
||||||
@@ -38,8 +52,10 @@ Where a locale's normal word for this document is CV, use CV.
|
|||||||
|
|
||||||
**Resumes** — plural of the above. A list of the user's documents.
|
**Resumes** — plural of the above. A list of the user's documents.
|
||||||
|
|
||||||
**Cover letter** — the letter accompanying a resume. Stored as a resume section, not a separate
|
**Cover letter** — the letter accompanying a resume. Stored as its own document, with optional links to a resume and an application.
|
||||||
document.
|
|
||||||
|
**Letter / Letters** — shorthand for cover letter(s) in the document library and letter editor.
|
||||||
|
Not an alphabetic character.
|
||||||
|
|
||||||
**Builder** — the editor where a resume is composed. A tool, not a construction worker or a
|
**Builder** — the editor where a resume is composed. A tool, not a construction worker or a
|
||||||
person who builds.
|
person who builds.
|
||||||
@@ -95,6 +111,20 @@ Not a theatre stage or a phase of construction.
|
|||||||
**Source** — where the user found the job listing (a job board, a referral, a company site).
|
**Source** — where the user found the job listing (a job board, a referral, a company site).
|
||||||
Singular, and specific to one application. Not a source code file and not a data source.
|
Singular, and specific to one application. Not a source code file and not a data source.
|
||||||
|
|
||||||
|
**Posting / Job posting** — the employer's advertisement for an open position, including its
|
||||||
|
description and requirements. Not a social-media post, a postal delivery, or a transaction entry.
|
||||||
|
`Posting terms` are keywords from that advertisement, not terms and conditions.
|
||||||
|
|
||||||
|
**Role** — a job position, either the position being applied for or a past position in Experience.
|
||||||
|
Not a theatrical role or an account permission. `Contact role` describes the contact person's job,
|
||||||
|
such as recruiter or hiring manager.
|
||||||
|
|
||||||
|
**Follow up / Follow-up** — contacting a recruiter again about an application, or the reminder
|
||||||
|
to do so. `Follow up` is a button action; `Set a follow-up` schedules that reminder.
|
||||||
|
|
||||||
|
**Screening** — an initial interview to assess a candidate, often a short phone call. Not a
|
||||||
|
medical screening or a display screen.
|
||||||
|
|
||||||
**Pipeline** — the sequence of stages an application moves through. A recruiting funnel, not a
|
**Pipeline** — the sequence of stages an application moves through. A recruiting funnel, not a
|
||||||
physical pipe, duct, conduit, or oil pipeline. Seven locales translated it as plumbing.
|
physical pipe, duct, conduit, or oil pipeline. Seven locales translated it as plumbing.
|
||||||
|
|
||||||
@@ -138,7 +168,7 @@ variant, and not a "style" or "pattern".
|
|||||||
|
|
||||||
**Working resume** — the resume a thread is currently editing. "Working" describes the draft
|
**Working resume** — the resume a thread is currently editing. "Working" describes the draft
|
||||||
being worked on, not the user's employment. It is not their work history, not a "job resume",
|
being worked on, not the user's employment. It is not their work history, not a "job resume",
|
||||||
and not a *functional résumé*, which is a real and different résumé format.
|
and not a _functional résumé_, which is a real and different résumé format.
|
||||||
|
|
||||||
**Tailor** — a verb: to adapt a resume to a specific job description. Nothing to do with
|
**Tailor** — a verb: to adapt a resume to a specific job description. Nothing to do with
|
||||||
dressmaking or sewing.
|
dressmaking or sewing.
|
||||||
@@ -165,11 +195,17 @@ order, not the builder's layout settings.
|
|||||||
**Blocker, Warning, Tip** — the three severity levels of a finding.
|
**Blocker, Warning, Tip** — the three severity levels of a finding.
|
||||||
|
|
||||||
**Note** — the label for an informational finding, in
|
**Note** — the label for an informational finding, in
|
||||||
`apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx`. A severity label,
|
the resume editor's Check panel. A severity label,
|
||||||
not a written note. Unrelated to **Notes** in the application tracker.
|
not a written note. Unrelated to **Notes** in the application tracker.
|
||||||
|
|
||||||
**Parse / parsing** — software reading text out of the PDF.
|
**Parse / parsing** — software reading text out of the PDF.
|
||||||
|
|
||||||
|
**Bullet / Bullets** — a list entry describing experience or an achievement; sometimes its list
|
||||||
|
marker. Never ammunition. `Find weak bullets` asks for review of the writing in those entries.
|
||||||
|
|
||||||
|
**Issue / Issues** — findings that may make a resume hard for software to read. Not a magazine
|
||||||
|
edition or a GitHub issue. `Open issues` means unresolved findings.
|
||||||
|
|
||||||
## Account and security
|
## Account and security
|
||||||
|
|
||||||
**Passkey / Passkeys** — a WebAuthn credential that replaces a password, stored on the user's
|
**Passkey / Passkeys** — a WebAuthn credential that replaces a password, stored on the user's
|
||||||
@@ -208,7 +244,59 @@ section, above.
|
|||||||
**Custom** — in `color-picker.tsx`, a user-chosen color as opposed to a preset. An adjective.
|
**Custom** — in `color-picker.tsx`, a user-chosen color as opposed to a preset. An adjective.
|
||||||
|
|
||||||
**Public URL** — the shareable address of a published resume. Use one term consistently; the
|
**Public URL** — the shareable address of a published resume. Use one term consistently; the
|
||||||
English strings say "public URL" rather than "public link".
|
English interface strings say "Public link"; URL refers to the address itself.
|
||||||
|
|
||||||
|
## Redesigned workspace
|
||||||
|
|
||||||
|
These terms arrive with the redesigned interface (see `DESIGN.md`).
|
||||||
|
|
||||||
|
**Documents** — the library that holds resumes and cover letters together. A plural noun, not the
|
||||||
|
verb "to document".
|
||||||
|
|
||||||
|
**Trash** — where deleted documents wait 30 days before they're removed for good. A place (noun),
|
||||||
|
like a recycle bin. Not the verb "to trash".
|
||||||
|
|
||||||
|
**Write · Design · Check** — the three modes of the editor, shown side by side as a switch. Each is
|
||||||
|
the name of a mode, so translate them as short, parallel labels. **Write** is editing the content,
|
||||||
|
**Design** is choosing how the resume looks (a noun here), and **Check** is reviewing whether
|
||||||
|
software can read it (a noun here, like "review"), not a bank cheque or a checkmark.
|
||||||
|
|
||||||
|
**Share & export** — the sheet with the public link, downloads and version history.
|
||||||
|
|
||||||
|
**Assistant** — the AI panel beside the page. It replaces both the "AI agent" page and the "AI
|
||||||
|
assistant" sheet, so there is now only one AI term.
|
||||||
|
|
||||||
|
**Proposed edit** — a change the assistant or Check suggests but hasn't made. It becomes part of the
|
||||||
|
resume only when the person accepts it. **Accept** and **Reject** are imperative verbs on buttons;
|
||||||
|
**Out of date** means the line was edited by hand after the suggestion was made.
|
||||||
|
|
||||||
|
**Version** — a saved state of a document in its history, which can be previewed and restored. Not
|
||||||
|
a software release.
|
||||||
|
|
||||||
|
**Next step** — the next thing to do for a job application, such as an interview or a follow-up.
|
||||||
|
|
||||||
|
**Closed** — the final stage of an application, whatever the outcome (not selected, withdrawn,
|
||||||
|
another offer accepted, no response). Not "shut" or "locked".
|
||||||
|
|
||||||
|
**System** — in Appearance, the option that follows the operating system's light or dark setting.
|
||||||
|
|
||||||
|
**Type**, in Design or `Font pairing` descriptions — typography: the chosen fonts and their
|
||||||
|
appearance. Not a document category, a personality type, or the verb "to type". `Document type`
|
||||||
|
and `Interview type` do mean categories; `Type … to confirm` is the verb for entering text.
|
||||||
|
|
||||||
|
**Accent / Accent colour** — the visual highlight colour used for headings and icons. Not a
|
||||||
|
pronunciation accent or an accented letter.
|
||||||
|
|
||||||
|
**Fit**, in `Fit to one page`, `Fit page to width`, and similar layout controls — make the
|
||||||
|
document fit within a page count or the available display width. Distinct from suitability for a
|
||||||
|
job in `How well do I fit this role?`.
|
||||||
|
|
||||||
|
**Present**, in date ranges such as `2021 – Present` — continuing up to now, for a current job
|
||||||
|
or education entry. Not a gift, attendance status, or the verb "to present".
|
||||||
|
|
||||||
|
**Sent / Submitted** — documents actually used when submitting a job application. Merely linking
|
||||||
|
a resume or letter to the application does not mean it was sent. `Version sent` is the saved
|
||||||
|
document state used for that submission.
|
||||||
|
|
||||||
## Verbs that read as adjectives or nouns
|
## Verbs that read as adjectives or nouns
|
||||||
|
|
||||||
@@ -216,10 +304,10 @@ Button labels and `aria-label` strings are usually **imperative verbs**: they sa
|
|||||||
control does. Read as a noun or an adjective, they turn into nonsense. This is the most common
|
control does. Read as a noun or an adjective, they turn into nonsense. This is the most common
|
||||||
error in the catalogs after the ambiguous nouns above.
|
error in the catalogs after the ambiguous nouns above.
|
||||||
|
|
||||||
**Open** — the verb. `Open AI agent` means *open the AI agent panel*; it does not describe an
|
**Open** — the verb. `Open AI agent` means _open the AI agent panel_; it does not describe an
|
||||||
agent that is "open", and it is **not a reference to OpenAI, the company**. Around forty-five of
|
agent that is "open", and it is **not a reference to OpenAI, the company**. Around forty-five of
|
||||||
the fifty-three catalogs got this wrong, split between "an open AI agent" and a transliteration
|
the fifty-three catalogs got this wrong, split between "an open AI agent" and a transliteration
|
||||||
of *OpenAI*. The same applies to `Open in builder`.
|
of _OpenAI_. The same applies to `Open in builder`.
|
||||||
|
|
||||||
**Close** — likewise the verb, as in `Close AI assistant`. Not "an assistant for closing things",
|
**Close** — likewise the verb, as in `Close AI assistant`. Not "an assistant for closing things",
|
||||||
and not the adjective "close/nearby".
|
and not the adjective "close/nearby".
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ You own your data. The codebase is open source under the MIT license, with no tr
|
|||||||
|
|
||||||
**Templates**
|
**Templates**
|
||||||
|
|
||||||
- 15 templates to choose from
|
- 17 templates to choose from
|
||||||
- A4 and Letter page sizes
|
- A4 and Letter page sizes
|
||||||
- Customizable colors, fonts, and spacing
|
- Customizable colors, fonts, and spacing
|
||||||
- Structured Style Rules for section and text styling
|
- Structured Style Rules for section and text styling
|
||||||
@@ -140,6 +140,14 @@ You own your data. The codebase is open source under the MIT license, with no tr
|
|||||||
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
|
<img src="apps/web/public/templates/jpg/scizor.jpg" alt="Scizor" width="150" />
|
||||||
<br /><sub><b>Scizor</b></sub>
|
<br /><sub><b>Scizor</b></sub>
|
||||||
</td>
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="apps/web/public/templates/jpg/porygon.jpg" alt="Porygon" width="150" />
|
||||||
|
<br /><sub><b>Porygon</b></sub>
|
||||||
|
</td>
|
||||||
|
<td align="center">
|
||||||
|
<img src="apps/web/public/templates/jpg/smeargle.jpg" alt="Smeargle" width="150" />
|
||||||
|
<br /><sub><b>Smeargle</b></sub>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</table>
|
</table>
|
||||||
|
|
||||||
@@ -163,17 +171,17 @@ For detailed setup instructions, environment configuration, and self-hosting gui
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
| Category | Technology |
|
| Category | Technology |
|
||||||
| ---------------- | ------------------------------- |
|
| ---------------- | -------------------------------- |
|
||||||
| Framework | TanStack Router (React 19, Vite) |
|
| Framework | TanStack Router (React 19, Vite) |
|
||||||
| Runtime | Node.js |
|
| Runtime | Node.js |
|
||||||
| Language | TypeScript |
|
| Language | TypeScript |
|
||||||
| Database | PostgreSQL with Drizzle ORM |
|
| Database | PostgreSQL with Drizzle ORM |
|
||||||
| API | ORPC (Type-safe RPC) |
|
| API | ORPC (Type-safe RPC) |
|
||||||
| Auth | Better Auth |
|
| Auth | Better Auth |
|
||||||
| Styling | Tailwind CSS |
|
| Styling | Tailwind CSS |
|
||||||
| UI Components | Base UI + shadcn-style package |
|
| UI Components | Base UI + shadcn-style package |
|
||||||
| State Management | Zustand + TanStack Query |
|
| State Management | Zustand + TanStack Query |
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
@@ -189,18 +197,20 @@ The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me):
|
|||||||
|
|
||||||
## Self-Hosting
|
## Self-Hosting
|
||||||
|
|
||||||
Reactive Resume supports Docker and Vercel Hobby.
|
Reactive Resume supports Docker, Vercel Hobby, and Cloudflare Workers Paid.
|
||||||
|
|
||||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
|
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
|
||||||
|
|
||||||
Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration.
|
Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration.
|
||||||
|
|
||||||
|
Cloudflare deployment uses Workers with Static Assets, private R2 storage, SQLite Durable Objects, and PostgreSQL through Hyperdrive. No Redis or container is required. See the [Cloudflare guide](docs/self-hosting/cloudflare.mdx) for setup, pricing, and runtime limits. The first version requires an existing PostgreSQL database; fully Cloudflare-native database provisioning and a deploy button are separate work.
|
||||||
|
|
||||||
For Docker, the stack includes:
|
For Docker, the stack includes:
|
||||||
|
|
||||||
- **PostgreSQL** — Database for storing user data and resumes
|
- **PostgreSQL** — Database for storing user data and resumes
|
||||||
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
||||||
|
|
||||||
> **From v5.1.0 onwards** — PDF generation runs entirely client-side via `@react-pdf/renderer`. New deployments no longer need Browserless, Chromium, or any external print service. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
|
> **From v6 onwards** — PDF generation uses Forme in the browser and on the server. New deployments no longer need Browserless, Chromium, or any external print service. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
|
||||||
|
|
||||||
Pull the latest image from Docker Hub or GitHub Container Registry:
|
Pull the latest image from Docker Hub or GitHub Container Registry:
|
||||||
|
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from "../apps/server/dist/vercel.mjs";
|
|
||||||
+61
-61
@@ -4,103 +4,103 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "tsx watch src/index.ts",
|
"dev": "tsx watch --tsconfig ../../tsconfig.json src/index.ts",
|
||||||
"build": "tsdown",
|
"build": "tsdown",
|
||||||
"start": "node dist/index.mjs",
|
"start": "node dist/index.mjs",
|
||||||
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
||||||
"typecheck": "tsgo --noEmit",
|
"typecheck": "tsgo --noEmit",
|
||||||
"test": "vitest run --passWithNoTests",
|
"test": "vitest run --passWithNoTests",
|
||||||
"test:coverage": "vitest run --coverage --passWithNoTests",
|
"test:coverage": "vitest run --coverage --passWithNoTests",
|
||||||
"test:ci": "vitest run --coverage --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
"test:ci": "vitest run --reporter=default --reporter=github-actions --reporter=json --reporter=junit --outputFile.json=reports/vitest-results.json --outputFile.junit=reports/vitest-junit.xml --passWithNoTests",
|
||||||
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
"test:agent": "vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests"
|
||||||
},
|
},
|
||||||
"imports": {
|
|
||||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ai-sdk/anthropic": "^4.0.58",
|
"@ai-sdk/anthropic": "^4.0.71",
|
||||||
"@ai-sdk/cerebras": "^3.0.53",
|
"@ai-sdk/cerebras": "^3.0.62",
|
||||||
"@ai-sdk/cohere": "^4.0.46",
|
"@ai-sdk/cohere": "^4.0.54",
|
||||||
"@ai-sdk/deepseek": "^3.0.49",
|
"@ai-sdk/deepseek": "^3.0.58",
|
||||||
"@ai-sdk/fireworks": "^3.0.56",
|
"@ai-sdk/fireworks": "^3.0.65",
|
||||||
"@ai-sdk/google": "^4.0.76",
|
"@ai-sdk/google": "^4.0.87",
|
||||||
"@ai-sdk/groq": "^4.0.46",
|
"@ai-sdk/groq": "^4.0.54",
|
||||||
"@ai-sdk/mistral": "^4.0.48",
|
"@ai-sdk/mistral": "^4.0.56",
|
||||||
"@ai-sdk/openai": "^4.0.71",
|
"@ai-sdk/openai": "^4.0.83",
|
||||||
"@ai-sdk/openai-compatible": "^3.0.53",
|
"@ai-sdk/openai-compatible": "^3.0.62",
|
||||||
"@ai-sdk/perplexity": "^4.0.48",
|
"@ai-sdk/perplexity": "^5.0.5",
|
||||||
"@ai-sdk/togetherai": "^3.0.54",
|
"@ai-sdk/togetherai": "^3.0.63",
|
||||||
"@ai-sdk/xai": "^5.0.4",
|
"@ai-sdk/xai": "^5.0.14",
|
||||||
"@aws-sdk/client-s3": "^3.1136.0",
|
"@aws-sdk/client-s3": "^3.1144.0",
|
||||||
"@better-auth/api-key": "^1.7.5",
|
"@better-auth/api-key": "catalog:",
|
||||||
"@better-auth/drizzle-adapter": "^1.7.5",
|
"@better-auth/drizzle-adapter": "1.7.7",
|
||||||
"@better-auth/infra": "^0.4.9",
|
"@better-auth/infra": "catalog:",
|
||||||
"@better-auth/oauth-provider": "^1.7.5",
|
"@better-auth/oauth-provider": "catalog:",
|
||||||
"@better-auth/passkey": "^1.7.5",
|
"@better-auth/passkey": "catalog:",
|
||||||
"@bramus/specificity": "^2.4.2",
|
"@bramus/specificity": "^2.4.2",
|
||||||
"@hono/node-server": "^2.1.1",
|
"@formepdf/core": "0.26.0",
|
||||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
"@formepdf/react": "0.26.0",
|
||||||
"@orpc/client": "^1.15.2",
|
"@hono/node-server": "^2.1.3",
|
||||||
"@orpc/experimental-ratelimit": "^1.15.2",
|
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||||
"@orpc/json-schema": "^1.15.2",
|
"@orpc/client": "catalog:",
|
||||||
"@orpc/openapi": "^1.15.2",
|
"@orpc/experimental-ratelimit": "^1.15.4",
|
||||||
"@orpc/server": "^1.15.2",
|
"@orpc/json-schema": "^1.15.4",
|
||||||
"@orpc/zod": "^1.15.2",
|
"@orpc/openapi": "^1.15.4",
|
||||||
"@react-pdf/hyphenate": "0.1.0",
|
"@orpc/server": "catalog:",
|
||||||
"@react-pdf/renderer": "^4.9.0",
|
"@orpc/zod": "^1.15.4",
|
||||||
"@reactive-resume/api": "workspace:*",
|
"@reactive-resume/api": "workspace:*",
|
||||||
"@reactive-resume/auth": "workspace:*",
|
"@reactive-resume/auth": "workspace:*",
|
||||||
"@reactive-resume/db": "workspace:*",
|
"@reactive-resume/db": "workspace:*",
|
||||||
"@reactive-resume/env": "workspace:*",
|
"@reactive-resume/env": "workspace:*",
|
||||||
"@reactive-resume/mcp": "workspace:*",
|
"@reactive-resume/mcp": "workspace:*",
|
||||||
|
"@reactive-resume/pdf": "workspace:*",
|
||||||
"@reactive-resume/schema": "workspace:*",
|
"@reactive-resume/schema": "workspace:*",
|
||||||
"@reactive-resume/utils": "workspace:*",
|
"@reactive-resume/utils": "workspace:*",
|
||||||
"@sindresorhus/slugify": "^3.0.1",
|
"@sindresorhus/slugify": "^3.0.1",
|
||||||
"@t3-oss/env-core": "^0.13.11",
|
"@t3-oss/env-core": "^0.13.11",
|
||||||
"@uiw/color-convert": "^2.10.3",
|
"@uiw/color-convert": "catalog:",
|
||||||
"@vercel/blob": "^2.8.0",
|
"@vercel/blob": "^2.8.0",
|
||||||
"@vercel/functions": "^3.9.9",
|
"@vercel/functions": "^3.9.9",
|
||||||
"ai": "^7.0.107",
|
"ai": "catalog:",
|
||||||
"bcrypt": "^6.0.0",
|
"bcryptjs": "catalog:",
|
||||||
"better-auth": "1.7.5",
|
"better-auth": "catalog:",
|
||||||
"cjk-regex": "^3.5.0",
|
"cjk-regex": "^3.5.0",
|
||||||
"css-tree": "^3.2.1",
|
"css-tree": "^3.2.1",
|
||||||
"deepmerge-ts": "^8.0.2",
|
"docx": "^9.8.1",
|
||||||
"drizzle-orm": "1.0.0-rc.4",
|
"drizzle-orm": "catalog:",
|
||||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||||
"es-toolkit": "^1.52.0",
|
"es-toolkit": "catalog:",
|
||||||
"fast-json-patch": "^3.1.1",
|
"fast-json-patch": "^3.1.1",
|
||||||
"fast-png": "^8.0.0",
|
"fast-png": "^8.0.0",
|
||||||
"hono": "^4.13.8",
|
"fflate": "catalog:",
|
||||||
"ioredis": "^6.0.0",
|
"hono": "^4.13.12",
|
||||||
|
"ioredis": "catalog:",
|
||||||
"jose": "^6.2.12",
|
"jose": "^6.2.12",
|
||||||
"jsonrepair": "^3.15.0",
|
"jsonrepair": "catalog:",
|
||||||
"node-html-parser": "^9.0.4",
|
"node-html-parser": "^9.0.4",
|
||||||
"nodemailer": "^10.0.10",
|
"nodemailer": "^10.0.13",
|
||||||
"ollama-ai-provider-v2": "^4.0.1",
|
"ollama-ai-provider-v2": "^4.0.1",
|
||||||
"pg": "^8.23.0",
|
"pdfjs-dist": "6.3.289",
|
||||||
"phosphor-icons-react-pdf": "^0.1.3",
|
"pg": "catalog:",
|
||||||
"react": "^19.3.0",
|
"react": "catalog:",
|
||||||
"react-email": "^6.9.5",
|
"react-email": "^6.11.0",
|
||||||
"react-pdf-html": "^2.1.5",
|
"react-reconciler": "0.34.0",
|
||||||
"resumable-stream": "^2.2.13",
|
"resumable-stream": "^2.2.13",
|
||||||
"sanitize-html": "^2.17.7",
|
"sanitize-html": "^2.18.0",
|
||||||
"sharp": "^0.35.4",
|
"sharp": "^0.35.5",
|
||||||
"tokenx": "^2.1.0",
|
"tokenx": "^2.1.0",
|
||||||
"ts-pattern": "^5.9.0",
|
"ts-pattern": "catalog:",
|
||||||
"unique-names-generator": "^4.7.1",
|
"unique-names-generator": "^4.7.1",
|
||||||
"uuid": "^14.0.2",
|
"uuid": "^14.0.2",
|
||||||
"zod": "^4.6.5"
|
"zod": "catalog:"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@cloudflare/workers-types": "catalog:",
|
||||||
"@reactive-resume/config": "workspace:*",
|
"@reactive-resume/config": "workspace:*",
|
||||||
"@types/node": "^26.6.2",
|
"@types/node": "catalog:",
|
||||||
"@types/pg": "^8.23.1",
|
"@types/pg": "catalog:",
|
||||||
"@types/react": "^19.3.0",
|
"@types/react": "catalog:",
|
||||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
"@typescript/native-preview": "catalog:",
|
||||||
"tsdown": "^0.23.0",
|
"tsdown": "^0.23.0",
|
||||||
"tsx": "^4.23.13",
|
"tsx": "^4.23.15",
|
||||||
"typescript": "^7.0.2",
|
"typescript": "catalog:",
|
||||||
"vitest": "^5.0.1"
|
"vitest": "catalog:"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1 +1,4 @@
|
|||||||
export const appVersion = typeof __APP_VERSION__ === "undefined" ? "0.0.0" : __APP_VERSION__;
|
// @boundaries-ignore root release metadata
|
||||||
|
import { version } from "../../../package.json";
|
||||||
|
|
||||||
|
export const appVersion = typeof __APP_VERSION__ === "undefined" ? version : __APP_VERSION__;
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { DurableObjectState, WebSocket } from "@cloudflare/workers-types";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
|
const inputSchema = z.discriminatedUnion("operation", [
|
||||||
|
z.object({
|
||||||
|
operation: z.literal("consume"),
|
||||||
|
window: z.number().positive(),
|
||||||
|
max: z.number().int().positive(),
|
||||||
|
rolling: z.boolean().optional(),
|
||||||
|
}),
|
||||||
|
z.object({ operation: z.literal("set"), value: z.string(), ttl: z.number().positive() }),
|
||||||
|
z.object({ operation: z.literal("get") }),
|
||||||
|
z.object({ operation: z.literal("publish"), value: z.string().max(8192) }),
|
||||||
|
]);
|
||||||
|
type Entry = { expiresAt: number; count?: number; value?: string };
|
||||||
|
declare const WebSocketPair: new () => { 0: WebSocket; 1: WebSocket };
|
||||||
|
|
||||||
|
/** One object per key: unrelated users never contend for the same counter or run state. */
|
||||||
|
export class Coordination {
|
||||||
|
constructor(private readonly ctx: DurableObjectState) {}
|
||||||
|
|
||||||
|
async fetch(request: Request): Promise<Response> {
|
||||||
|
if (request.headers.get("upgrade") === "websocket") {
|
||||||
|
const pair = new WebSocketPair();
|
||||||
|
this.ctx.acceptWebSocket(pair[1]);
|
||||||
|
return new Response(null, { status: 101, webSocket: pair[0] } as ResponseInit);
|
||||||
|
}
|
||||||
|
const input = inputSchema.parse(await request.json());
|
||||||
|
if (input.operation === "publish") {
|
||||||
|
for (const socket of this.ctx.getWebSockets()) {
|
||||||
|
try {
|
||||||
|
socket.send(input.value);
|
||||||
|
} catch {
|
||||||
|
socket.close(1011, "Delivery failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Response.json(null);
|
||||||
|
}
|
||||||
|
const now = Date.now();
|
||||||
|
const result = this.ctx.storage.transactionSync(() => {
|
||||||
|
const stored = this.ctx.storage.kv.get<Entry>("entry");
|
||||||
|
const entry = stored && stored.expiresAt > now ? stored : undefined;
|
||||||
|
if (input.operation === "get") return entry?.value ?? null;
|
||||||
|
if (input.operation === "set") {
|
||||||
|
this.ctx.storage.kv.put("entry", { value: input.value, expiresAt: now + input.ttl });
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const count = entry?.count ?? 0;
|
||||||
|
const allowed = count < input.max;
|
||||||
|
const expiresAt = allowed && input.rolling ? now + input.window : (entry?.expiresAt ?? now + input.window);
|
||||||
|
if (allowed) this.ctx.storage.kv.put("entry", { count: count + 1, expiresAt });
|
||||||
|
return { allowed, remaining: Math.max(0, input.max - count - (allowed ? 1 : 0)), reset: expiresAt };
|
||||||
|
});
|
||||||
|
// Schedule only once per active key. The alarm reschedules itself if accepted requests extend the expiry.
|
||||||
|
const entry = this.ctx.storage.kv.get<Entry>("entry");
|
||||||
|
if (entry && (await this.ctx.storage.getAlarm()) === null) await this.ctx.storage.setAlarm(entry.expiresAt);
|
||||||
|
return Response.json(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
async alarm(): Promise<void> {
|
||||||
|
const entry = this.ctx.storage.kv.get<Entry>("entry");
|
||||||
|
if (entry && entry.expiresAt > Date.now()) await this.ctx.storage.setAlarm(entry.expiresAt);
|
||||||
|
else await this.ctx.storage.deleteAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
webSocketClose(socket: WebSocket, code: number, reason: string): void {
|
||||||
|
socket.close(code, reason);
|
||||||
|
}
|
||||||
|
webSocketError(socket: WebSocket): void {
|
||||||
|
socket.close(1011, "Connection failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import type {
|
||||||
|
DurableObjectNamespace,
|
||||||
|
ExecutionContext,
|
||||||
|
Fetcher,
|
||||||
|
Hyperdrive,
|
||||||
|
R2Bucket,
|
||||||
|
} from "@cloudflare/workers-types";
|
||||||
|
import type { CoordinationService } from "@reactive-resume/db/coordination";
|
||||||
|
import { AsyncLocalStorage } from "node:async_hooks";
|
||||||
|
import { on } from "node:events";
|
||||||
|
import { isIP } from "node:net";
|
||||||
|
import wasm from "@formepdf/core/pkg-web/forme_bg.wasm";
|
||||||
|
import { init } from "@formepdf/core/worker";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams";
|
||||||
|
import { configureStorageService, getStorageService } from "@reactive-resume/api/features/storage";
|
||||||
|
import { initializeAuth } from "@reactive-resume/auth/config";
|
||||||
|
import { withDatabasePool } from "@reactive-resume/db/client";
|
||||||
|
import { configureCoordination } from "@reactive-resume/db/coordination";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { configureOwnPictureReader } from "@reactive-resume/pdf/server";
|
||||||
|
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||||
|
import { createApp } from "../http/app";
|
||||||
|
import { R2StorageService } from "./r2";
|
||||||
|
|
||||||
|
export { Coordination } from "./coordination";
|
||||||
|
|
||||||
|
export type CloudflareBindings = {
|
||||||
|
ASSETS: Fetcher;
|
||||||
|
HYPERDRIVE: Hyperdrive;
|
||||||
|
BUCKET: R2Bucket;
|
||||||
|
COORDINATION: DurableObjectNamespace;
|
||||||
|
};
|
||||||
|
|
||||||
|
const requests = new AsyncLocalStorage<{ bindings: CloudflareBindings; ctx: ExecutionContext }>();
|
||||||
|
configureAgentStreamLifetime((promise) => requests.getStore()?.ctx.waitUntil(promise));
|
||||||
|
configureOwnPictureReader(async (key) => {
|
||||||
|
const file = await getStorageService().read(key);
|
||||||
|
if (!file || file.size > 12_000_000) throw new Error("Picture unavailable or exceeds 12 MB");
|
||||||
|
return file.data;
|
||||||
|
});
|
||||||
|
|
||||||
|
const sharedCall = async <T>(key: string, input: object): Promise<T> => {
|
||||||
|
const namespace = requests.getStore()?.bindings.COORDINATION;
|
||||||
|
if (!namespace) throw new Error("Cloudflare coordination binding is missing");
|
||||||
|
const response = await namespace.get(namespace.idFromName(key)).fetch("https://coordination/", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify(input),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error("Cloudflare coordination is unavailable");
|
||||||
|
return response.json<T>();
|
||||||
|
};
|
||||||
|
configureCoordination({
|
||||||
|
consume: (key, rule) => sharedCall(key, { operation: "consume", ...rule }),
|
||||||
|
get: (key) => sharedCall(key, { operation: "get" }),
|
||||||
|
set: async (key, value, ttl) => {
|
||||||
|
await sharedCall(key, { operation: "set", value, ttl });
|
||||||
|
},
|
||||||
|
publish: async (key, value) => {
|
||||||
|
await sharedCall(key, { operation: "publish", value });
|
||||||
|
},
|
||||||
|
async *subscribe(key, signal) {
|
||||||
|
const namespace = requests.getStore()?.bindings.COORDINATION;
|
||||||
|
if (!namespace) throw new Error("Cloudflare coordination binding is missing");
|
||||||
|
const response = await namespace
|
||||||
|
.get(namespace.idFromName(key))
|
||||||
|
.fetch("https://coordination/", { headers: { upgrade: "websocket" } });
|
||||||
|
const socket = response.webSocket;
|
||||||
|
if (!socket) throw new Error("Cloudflare subscription is unavailable");
|
||||||
|
const closed = new AbortController();
|
||||||
|
const stopped = signal ? AbortSignal.any([signal, closed.signal]) : closed.signal;
|
||||||
|
const messages = on(socket as unknown as EventTarget, "message", { signal: stopped });
|
||||||
|
socket.addEventListener("close", () => closed.abort());
|
||||||
|
socket.addEventListener("error", () => closed.abort());
|
||||||
|
socket.accept();
|
||||||
|
try {
|
||||||
|
for await (const [event] of messages) {
|
||||||
|
const data = (event as MessageEvent).data as unknown;
|
||||||
|
if (typeof data === "string") yield data;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (!stopped.aborted) throw error;
|
||||||
|
} finally {
|
||||||
|
socket.close(1000, "Subscription ended");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
} satisfies CoordinationService);
|
||||||
|
|
||||||
|
const app = createApp({
|
||||||
|
serveStatic: false,
|
||||||
|
trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown",
|
||||||
|
readWebFile: async (path) => {
|
||||||
|
const assetPath = path.includes("dist-prerender/")
|
||||||
|
? `/_prerender/${path.split("dist-prerender/")[1]}`
|
||||||
|
: "/index.html";
|
||||||
|
const response = await requests.getStore()?.bindings.ASSETS.fetch(new URL(assetPath, env.APP_URL).href);
|
||||||
|
if (!response?.ok) throw new Error("Web asset is unavailable");
|
||||||
|
return response.text();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Keep the request's pool alive until streaming finishes, including client cancellation. */
|
||||||
|
function closePoolAfterResponse(
|
||||||
|
response: Response,
|
||||||
|
pool: Pool,
|
||||||
|
bindings: CloudflareBindings,
|
||||||
|
ctx: ExecutionContext,
|
||||||
|
): Response {
|
||||||
|
let closed = false;
|
||||||
|
const close = () => {
|
||||||
|
if (!closed) {
|
||||||
|
closed = true;
|
||||||
|
ctx.waitUntil(pool.end());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const run = <T>(callback: () => T) => requests.run({ bindings, ctx }, () => withDatabasePool(pool, callback));
|
||||||
|
if (!response.body) {
|
||||||
|
close();
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
const reader = response.body.getReader();
|
||||||
|
return new Response(
|
||||||
|
new ReadableStream<Uint8Array>({
|
||||||
|
pull(controller) {
|
||||||
|
return run(async () => {
|
||||||
|
try {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) {
|
||||||
|
controller.close();
|
||||||
|
close();
|
||||||
|
} else controller.enqueue(value);
|
||||||
|
} catch (error) {
|
||||||
|
controller.error(error);
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
cancel(reason) {
|
||||||
|
return run(async () => {
|
||||||
|
try {
|
||||||
|
await reader.cancel(reason);
|
||||||
|
} finally {
|
||||||
|
close();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
response,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
async fetch(request: Request, bindings: CloudflareBindings, ctx: ExecutionContext): Promise<Response> {
|
||||||
|
if (new URL(request.url).pathname.startsWith("/_prerender")) return new Response("Not Found", { status: 404 });
|
||||||
|
if (!env.CLOUDFLARE || env.STORAGE_BACKEND !== "r2" || !env.FLAG_DISABLE_IMAGE_PROCESSING || env.REDIS_URL) {
|
||||||
|
throw new Error("Cloudflare requires CLOUDFLARE=1, R2, disabled image processing and no REDIS_URL.");
|
||||||
|
}
|
||||||
|
configureStorageService(new R2StorageService(bindings.BUCKET, env.DEPLOYMENT_NAMESPACE));
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
const ip = headers.get("cf-connecting-ip");
|
||||||
|
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
|
||||||
|
if (ip && isIP(ip)) {
|
||||||
|
headers.set("x-real-ip", ip);
|
||||||
|
headers.set("x-forwarded-for", ip);
|
||||||
|
}
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: bindings.HYPERDRIVE.connectionString,
|
||||||
|
max: 1,
|
||||||
|
connectionTimeoutMillis: 10_000,
|
||||||
|
});
|
||||||
|
const logError = (error: Error) => {
|
||||||
|
if (!pool.ending) console.error("[cloudflare] Database connection failed", error.message);
|
||||||
|
};
|
||||||
|
pool.on("error", logError);
|
||||||
|
pool.on("connect", (client) => client.on("error", logError));
|
||||||
|
try {
|
||||||
|
const response = await requests.run({ bindings, ctx }, () =>
|
||||||
|
withDatabasePool(pool, async () => {
|
||||||
|
await initializeAuth();
|
||||||
|
await init(wasm);
|
||||||
|
return app.fetch(new Request(request, { headers }));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
return closePoolAfterResponse(response, pool, bindings, ctx);
|
||||||
|
} catch (error) {
|
||||||
|
ctx.waitUntil(pool.end());
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import type { R2Bucket } from "@cloudflare/workers-types";
|
||||||
|
import type { StorageService } from "@reactive-resume/api/features/storage";
|
||||||
|
|
||||||
|
/** Keep the bucket private. Public upload routes and authenticated attachment reads own access control. */
|
||||||
|
export class R2StorageService implements StorageService {
|
||||||
|
constructor(
|
||||||
|
private readonly bucket: R2Bucket,
|
||||||
|
private readonly namespace: string,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private path(key: string) {
|
||||||
|
if (key.startsWith("/") || key.includes("\\") || key.split("/").some((part) => part === "." || part === "..")) {
|
||||||
|
throw new Error("Invalid storage key");
|
||||||
|
}
|
||||||
|
return `${this.namespace}/${key}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async list(prefix: string): Promise<string[]> {
|
||||||
|
const keys: string[] = [];
|
||||||
|
let cursor: string | undefined;
|
||||||
|
do {
|
||||||
|
const page = await this.bucket.list({ prefix: this.path(prefix), ...(cursor ? { cursor } : {}) });
|
||||||
|
keys.push(...page.objects.map((object) => object.key.slice(this.namespace.length + 1)));
|
||||||
|
cursor = page.truncated ? page.cursor : undefined;
|
||||||
|
} while (cursor);
|
||||||
|
return keys;
|
||||||
|
}
|
||||||
|
|
||||||
|
async write(input: { key: string; data: Uint8Array; contentType: string; private?: boolean }): Promise<void> {
|
||||||
|
await this.bucket.put(this.path(input.key), input.data, { httpMetadata: { contentType: input.contentType } });
|
||||||
|
}
|
||||||
|
|
||||||
|
async read(key: string) {
|
||||||
|
const object = await this.bucket.get(this.path(key));
|
||||||
|
if (!object) return null;
|
||||||
|
return {
|
||||||
|
data: new Uint8Array(await object.arrayBuffer()),
|
||||||
|
size: object.size,
|
||||||
|
etag: object.httpEtag,
|
||||||
|
lastModified: object.uploaded,
|
||||||
|
...(object.httpMetadata?.contentType ? { contentType: object.httpMetadata.contentType } : {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async delete(key: string): Promise<boolean> {
|
||||||
|
const prefix = key.endsWith("/") ? key : `${key}/`;
|
||||||
|
const keys = (await this.list(key)).filter((candidate) => candidate === key || candidate.startsWith(prefix));
|
||||||
|
// R2 permits up to 1,000 keys per delete operation.
|
||||||
|
for (let start = 0; start < keys.length; start += 1_000) {
|
||||||
|
await this.bucket.delete(keys.slice(start, start + 1_000).map((candidate) => this.path(candidate)));
|
||||||
|
}
|
||||||
|
return keys.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async healthcheck() {
|
||||||
|
try {
|
||||||
|
await this.bucket.list({ prefix: this.path(".health"), limit: 1 });
|
||||||
|
return { status: "healthy" as const, type: "r2" as const, message: "R2 storage is accessible" };
|
||||||
|
} catch {
|
||||||
|
return { status: "unhealthy" as const, type: "r2" as const, message: "R2 storage is unavailable" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default function sharp(): never {
|
||||||
|
throw new Error(
|
||||||
|
"Cloudflare Workers requires FLAG_DISABLE_IMAGE_PROCESSING=true; upload processing uses native Node.js modules.",
|
||||||
|
);
|
||||||
|
}
|
||||||
Vendored
+4
@@ -0,0 +1,4 @@
|
|||||||
|
declare module "*.wasm" {
|
||||||
|
const wasm: WebAssembly.Module;
|
||||||
|
export default wasm;
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { gunzipSync } from "node:zlib";
|
||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
|
trustedProxies: [] as string[],
|
||||||
handleAuth: vi.fn(),
|
handleAuth: vi.fn(),
|
||||||
handleOAuth: vi.fn(),
|
handleOAuth: vi.fn(),
|
||||||
handleRpc: vi.fn(),
|
handleRpc: vi.fn(),
|
||||||
@@ -22,6 +24,11 @@ const mocks = vi.hoisted(() => ({
|
|||||||
handleWebApp: vi.fn(),
|
handleWebApp: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/env/server", async (original) => {
|
||||||
|
const { env } = await original<typeof import("@reactive-resume/env/server")>();
|
||||||
|
return { env: { ...env, TRUSTED_PROXIES: mocks.trustedProxies } };
|
||||||
|
});
|
||||||
|
|
||||||
vi.mock("./auth", () => ({
|
vi.mock("./auth", () => ({
|
||||||
handleAuth: mocks.handleAuth,
|
handleAuth: mocks.handleAuth,
|
||||||
handleOAuth: mocks.handleOAuth,
|
handleOAuth: mocks.handleOAuth,
|
||||||
@@ -81,6 +88,7 @@ const transportEnv = (remoteAddress: string) =>
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
mocks.trustedProxies.length = 0;
|
||||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||||
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||||
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||||
@@ -103,6 +111,24 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("createApp", () => {
|
describe("createApp", () => {
|
||||||
|
it.each([
|
||||||
|
["127.0.0.1", "127.0.0.1", "198.51.100.1", "198.51.100.1"],
|
||||||
|
["127.0.0.1", "::ffff:127.0.0.1", "198.51.100.1", "198.51.100.1"],
|
||||||
|
["10.0.0.0/8", "10.2.3.4", "198.51.100.1, 10.3.4.5", "198.51.100.1"],
|
||||||
|
["::1/128", "::1", "2001:db8::1", "2001:db8::1"],
|
||||||
|
["127.0.0.1", "127.0.0.1", "192.0.2.99, 198.51.100.1", "198.51.100.1"],
|
||||||
|
["127.0.0.1", "203.0.113.9", "198.51.100.1", "203.0.113.9"],
|
||||||
|
["127.0.0.1", "127.0.0.1", "invalid, 198.51.100.1", "127.0.0.1"],
|
||||||
|
])("resolves auth client through trusted %s from socket %s", async (proxy, peer, forwarded, expected) => {
|
||||||
|
mocks.trustedProxies.push(proxy);
|
||||||
|
const { createApp } = await import("./app");
|
||||||
|
const request = new Request("http://localhost/api/auth/sign-in/email", {
|
||||||
|
headers: { "x-forwarded-for": forwarded },
|
||||||
|
});
|
||||||
|
await createApp().fetch(request, transportEnv(peer));
|
||||||
|
expect(mocks.handleAuth).toHaveBeenCalledWith(request, expected);
|
||||||
|
});
|
||||||
|
|
||||||
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||||
const { createApp } = await import("./app");
|
const { createApp } = await import("./app");
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
@@ -113,20 +139,8 @@ describe("createApp", () => {
|
|||||||
await expect(response.text()).resolves.toBe("oauth");
|
await expect(response.text()).resolves.toBe("oauth");
|
||||||
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||||
});
|
// The first test pays for the cold import of the whole app, which takes seconds under a parallel run.
|
||||||
|
}, 15_000);
|
||||||
it("routes signed resume PDF downloads before the web fallback", async () => {
|
|
||||||
const { createApp } = await import("./app");
|
|
||||||
const app = createApp();
|
|
||||||
const request = new Request("http://localhost:3001/api/resumes/resume-1/pdf?token=signed");
|
|
||||||
|
|
||||||
const response = await app.fetch(request);
|
|
||||||
|
|
||||||
await expect(response.text()).resolves.toBe("pdf");
|
|
||||||
expect(mocks.handleResumePdfDownload).toHaveBeenCalledWith(request, "resume-1");
|
|
||||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
||||||
const { createApp } = await import("./app");
|
const { createApp } = await import("./app");
|
||||||
@@ -173,37 +187,43 @@ describe("createApp", () => {
|
|||||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([
|
it("routes GET / to the web app before static files", async () => {
|
||||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
|
||||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
|
||||||
["GET", "/sitemap.xml", "sitemap", mocks.handleSitemap],
|
|
||||||
["HEAD", "/sitemap.xml", "", mocks.handleSitemap],
|
|
||||||
["GET", "/llms.txt", "llms", mocks.handleLlms],
|
|
||||||
["HEAD", "/llms.txt", "", mocks.handleLlms],
|
|
||||||
])("routes %s %s before the static fallback", async (method, pathname, expectedBody, handler) => {
|
|
||||||
const { createApp } = await import("./app");
|
const { createApp } = await import("./app");
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
const request = new Request(`http://localhost:3001${pathname}`, { method });
|
const request = new Request("http://localhost:3001/");
|
||||||
|
|
||||||
const response = await app.fetch(request);
|
|
||||||
|
|
||||||
await expect(response.text()).resolves.toBe(expectedBody);
|
|
||||||
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
|
||||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
|
||||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
|
|
||||||
const { createApp } = await import("./app");
|
|
||||||
const app = createApp();
|
|
||||||
const request = new Request("http://localhost:3001/", { method });
|
|
||||||
|
|
||||||
const response = await app.fetch(request);
|
const response = await app.fetch(request);
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
expect(response.status).toBe(200);
|
||||||
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
|
expect(await response.text()).toBe("web");
|
||||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("compresses the web app's HTML but never API streams or the Vercel app", async () => {
|
||||||
|
const { createApp } = await import("./app");
|
||||||
|
const html = `<!doctype html>${"<p>Reactive Resume</p>".repeat(200)}`;
|
||||||
|
const htmlResponse = () =>
|
||||||
|
new Response(html, {
|
||||||
|
headers: { "Content-Type": "text/html; charset=UTF-8", "Cache-Control": "private, no-store", Vary: "Cookie" },
|
||||||
|
});
|
||||||
|
const stream = () => new Response("data: x\n\n".repeat(500), { headers: { "Content-Type": "application/json" } });
|
||||||
|
mocks.handleWebApp.mockImplementation(async () => htmlResponse());
|
||||||
|
mocks.handleRpc.mockImplementation(async () => stream());
|
||||||
|
mocks.handleMcp.mockImplementation(async () => stream());
|
||||||
|
const headers = { "Accept-Encoding": "br, gzip" };
|
||||||
|
|
||||||
|
const page = await createApp().request("http://localhost:3000/", { headers });
|
||||||
|
const rpc = await createApp().request("http://localhost:3000/api/rpc/agent/chat", { headers });
|
||||||
|
const mcp = await createApp().request("http://localhost:3000/mcp", { headers });
|
||||||
|
const vercelPage = await createApp({ serveStatic: false }).request("http://localhost:3000/", { headers });
|
||||||
|
|
||||||
|
expect(page.headers.get("content-encoding")).toBe("gzip");
|
||||||
|
expect(page.headers.get("vary")).toBe("Cookie, Accept-Encoding");
|
||||||
|
expect(page.headers.get("cache-control")).toBe("private, no-store");
|
||||||
|
expect(gunzipSync(Buffer.from(await page.arrayBuffer())).toString()).toBe(html);
|
||||||
|
for (const response of [rpc, mcp, vercelPage]) expect(response.headers.get("content-encoding")).toBeNull();
|
||||||
|
expect(vercelPage.headers.get("vary")).toBe("Cookie");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
|
it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or caching %s", async (path) => {
|
||||||
@@ -219,3 +239,16 @@ it.each(["/auth/consent", "/auth/consent/", "/auth/login"])("prevents framing or
|
|||||||
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("rejects oversized REST requests before parsing multipart uploads", async () => {
|
||||||
|
const { createApp } = await import("./app");
|
||||||
|
const response = await createApp().request("http://localhost:3000/api/openapi/files", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Length": String(40 * 1024 * 1024 + 1) },
|
||||||
|
body: "x",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(413);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "PAYLOAD_TOO_LARGE", status: 413 });
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(mocks.handleOpenApi).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|||||||
+51
-10
@@ -1,9 +1,13 @@
|
|||||||
|
import type { ReadWebFile } from "../static/web";
|
||||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||||
import type { Context } from "hono";
|
import type { Context } from "hono";
|
||||||
import { isIP } from "node:net";
|
import { BlockList, isIP } from "node:net";
|
||||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||||
import { Hono } from "hono";
|
import { Hono } from "hono";
|
||||||
|
import { bodyLimit } from "hono/body-limit";
|
||||||
|
import { compress } from "hono/compress";
|
||||||
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { handleMcp } from "../mcp/handler";
|
import { handleMcp } from "../mcp/handler";
|
||||||
import { handleOpenApi } from "../openapi/handler";
|
import { handleOpenApi } from "../openapi/handler";
|
||||||
import {
|
import {
|
||||||
@@ -25,10 +29,22 @@ import { handleResumePdfDownload } from "./resume-pdf";
|
|||||||
|
|
||||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||||
|
|
||||||
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
const getTrustedClient = (context: Context<ServerEnvironment>, proxies: BlockList): string => {
|
||||||
try {
|
try {
|
||||||
const address = getConnInfo(context).remote.address?.trim();
|
const address = getConnInfo(context).remote.address?.trim();
|
||||||
return address && isIP(address) ? address : "unknown";
|
if (!address || !isIP(address)) return "unknown";
|
||||||
|
const trusted = (ip: string) => proxies.check(ip, isIP(ip) === 4 ? "ipv4" : "ipv6");
|
||||||
|
if (!trusted(address)) return address;
|
||||||
|
const forwarded = context.req.header("x-forwarded-for");
|
||||||
|
if (!forwarded) return address;
|
||||||
|
const chain = forwarded.split(",").map((ip) => ip.trim());
|
||||||
|
if (chain.some((ip) => !isIP(ip))) return address;
|
||||||
|
let client = address;
|
||||||
|
for (const hop of chain.reverse()) {
|
||||||
|
if (!trusted(client)) break;
|
||||||
|
client = hop;
|
||||||
|
}
|
||||||
|
return client;
|
||||||
} catch {
|
} catch {
|
||||||
return "unknown";
|
return "unknown";
|
||||||
}
|
}
|
||||||
@@ -37,11 +53,20 @@ const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
|||||||
type AppOptions = {
|
type AppOptions = {
|
||||||
serveStatic?: boolean;
|
serveStatic?: boolean;
|
||||||
trustedClient?: (request: Request) => string;
|
trustedClient?: (request: Request) => string;
|
||||||
|
readWebFile?: ReadWebFile;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function createApp(options: AppOptions = {}) {
|
export function createApp(options: AppOptions = {}) {
|
||||||
const app = new Hono<ServerEnvironment>();
|
const app = new Hono<ServerEnvironment>();
|
||||||
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
|
const proxies = new BlockList();
|
||||||
|
for (const range of env.TRUSTED_PROXIES) {
|
||||||
|
const [address, prefix] = range.split("/");
|
||||||
|
if (!address) continue;
|
||||||
|
const family = isIP(address) === 4 ? "ipv4" : "ipv6";
|
||||||
|
if (prefix === undefined) proxies.addAddress(address, family);
|
||||||
|
else proxies.addSubnet(address, Number(prefix), family);
|
||||||
|
}
|
||||||
|
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c, proxies);
|
||||||
|
|
||||||
app.use("/auth/*", async (c, next) => {
|
app.use("/auth/*", async (c, next) => {
|
||||||
await next();
|
await next();
|
||||||
@@ -51,13 +76,24 @@ export function createApp(options: AppOptions = {}) {
|
|||||||
c.header("Cache-Control", "no-store");
|
c.header("Cache-Control", "no-store");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.use(
|
||||||
|
"/api/openapi/*",
|
||||||
|
bodyLimit({
|
||||||
|
maxSize: 40 * 1024 * 1024,
|
||||||
|
onError: (c) => {
|
||||||
|
c.header("Cache-Control", "no-store");
|
||||||
|
return c.json({ defined: false, code: "PAYLOAD_TOO_LARGE", status: 413, message: "Payload too large" }, 413);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
app.post("/api/storage/stage", (c) => prepareStagedBody(c.req.raw));
|
app.post("/api/storage/stage", (c) => prepareStagedBody(c.req.raw));
|
||||||
app.all("/api/rpc", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
app.all("/api/rpc", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
||||||
app.all("/api/rpc/*", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
app.all("/api/rpc/*", (c) => withStagedBody(c.req.raw, (request) => handleRpc(request, client(c))));
|
||||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, client(c)));
|
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, client(c)));
|
||||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, client(c)));
|
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, client(c)));
|
||||||
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
app.get("/api/auth/oauth", (c) => handleOAuth(c.req.raw));
|
||||||
app.all("/api/auth/*", (c) => handleAuth(c.req.raw));
|
app.all("/api/auth/*", (c) => handleAuth(c.req.raw, client(c)));
|
||||||
app.get("/api/health", () => handleHealth());
|
app.get("/api/health", () => handleHealth());
|
||||||
app.get("/api/resumes/:username/:slug/pdf", (c) =>
|
app.get("/api/resumes/:username/:slug/pdf", (c) =>
|
||||||
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), client(c)),
|
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), client(c)),
|
||||||
@@ -66,8 +102,8 @@ export function createApp(options: AppOptions = {}) {
|
|||||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||||
app.get("/schema.json", () => handleSchemaJson());
|
app.get("/schema.json", () => handleSchemaJson());
|
||||||
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
app.all("/mcp", (c) => handleMcp(c.req.raw, client(c)));
|
||||||
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
|
app.all("/mcp/*", (c) => handleMcp(c.req.raw, client(c)));
|
||||||
|
|
||||||
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
||||||
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||||
@@ -81,11 +117,16 @@ export function createApp(options: AppOptions = {}) {
|
|||||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||||
|
|
||||||
|
// Compresses only the web app's files and HTML shells: every route registered above answers before reaching
|
||||||
|
// it, so API, MCP, and upload streams are never buffered or re-encoded. Where a CDN serves the static files
|
||||||
|
// (Vercel), it also compresses at its edge.
|
||||||
|
if (options.serveStatic !== false) app.use("/*", compress());
|
||||||
|
|
||||||
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
|
// Must precede the static middleware: serveStatic resolves "/" to dist/index.html and would
|
||||||
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
|
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
|
||||||
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
|
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw, options.readWebFile));
|
||||||
if (options.serveStatic !== false) app.use("/*", serveWebDistStatic);
|
if (options.serveStatic !== false && serveWebDistStatic) app.use("/*", serveWebDistStatic);
|
||||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw, options.readWebFile));
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,6 +36,25 @@ beforeEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("handleAuth", () => {
|
describe("handleAuth", () => {
|
||||||
|
it.each(["203.0.113.9", "unknown"])("uses only the adapter's client address (%s)", async (trustedClient) => {
|
||||||
|
const { handleAuth } = await import("./auth");
|
||||||
|
await handleAuth(
|
||||||
|
new Request("http://localhost:3000/api/auth/get-session", {
|
||||||
|
headers: {
|
||||||
|
"cf-connecting-ip": "198.51.100.1",
|
||||||
|
"true-client-ip": "198.51.100.2",
|
||||||
|
"x-forwarded-for": "198.51.100.3, 192.0.2.1",
|
||||||
|
"x-real-ip": "198.51.100.4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
trustedClient,
|
||||||
|
);
|
||||||
|
const request = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||||
|
expect(request.headers.get("cf-connecting-ip")).toBeNull();
|
||||||
|
expect(request.headers.get("true-client-ip")).toBeNull();
|
||||||
|
expect(request.headers.get("x-forwarded-for")).toBeNull();
|
||||||
|
expect(request.headers.get("x-real-ip")).toBe(trustedClient === "unknown" ? null : trustedClient);
|
||||||
|
});
|
||||||
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
|
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
|
||||||
"rejects non-object registration payload %j",
|
"rejects non-object registration payload %j",
|
||||||
async (body) => {
|
async (body) => {
|
||||||
@@ -53,21 +72,6 @@ describe("handleAuth", () => {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("registers third-party https callbacks so remote MCP clients can complete DCR", async () => {
|
|
||||||
const { handleAuth } = await import("./auth");
|
|
||||||
|
|
||||||
const response = await handleAuth(
|
|
||||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] }),
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
|
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
|
||||||
const { handleAuth } = await import("./auth");
|
const { handleAuth } = await import("./auth");
|
||||||
|
|
||||||
@@ -87,21 +91,6 @@ describe("handleAuth", () => {
|
|||||||
expect(mocks.handler).not.toHaveBeenCalled();
|
expect(mocks.handler).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
|
||||||
const { handleAuth } = await import("./auth");
|
|
||||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = true;
|
|
||||||
|
|
||||||
const response = await handleAuth(
|
|
||||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
|
||||||
method: "POST",
|
|
||||||
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
|
||||||
headers: { "content-type": "application/json" },
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
|
||||||
});
|
|
||||||
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
||||||
"infers native application type for exact %s loopback callbacks",
|
"infers native application type for exact %s loopback callbacks",
|
||||||
async (host) => {
|
async (host) => {
|
||||||
@@ -158,20 +147,6 @@ describe("handleAuth", () => {
|
|||||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||||
expect((await forwarded.json()).application_type).not.toBe("native");
|
expect((await forwarded.json()).application_type).not.toBe("native");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves repeated resource indicators during authorization sanitization", async () => {
|
|
||||||
const { handleAuth } = await import("./auth");
|
|
||||||
await handleAuth(
|
|
||||||
new Request(
|
|
||||||
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
|
|
||||||
),
|
|
||||||
);
|
|
||||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
|
||||||
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
|
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:3000/mcp",
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("handleOAuth", () => {
|
describe("handleOAuth", () => {
|
||||||
@@ -216,15 +191,6 @@ describe("handleOAuth", () => {
|
|||||||
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("preserves provider failures instead of issuing an authorization code", async () => {
|
|
||||||
const { handleOAuth } = await import("./auth");
|
|
||||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
|
||||||
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
|
|
||||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
|
|
||||||
expect(response.status).toBe(400);
|
|
||||||
expect(response.headers.get("location")).toBeNull();
|
|
||||||
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
|
|
||||||
});
|
|
||||||
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
||||||
const { handleOAuth } = await import("./auth");
|
const { handleOAuth } = await import("./auth");
|
||||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import { isIP } from "node:net";
|
||||||
import { APIError } from "better-auth/api";
|
import { APIError } from "better-auth/api";
|
||||||
import { auth } from "@reactive-resume/auth/config";
|
import { auth } from "@reactive-resume/auth/config";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||||
|
|
||||||
const oauthAuthorizeSanitizedParams = [
|
const oauthAuthorizeSanitizedParams = [
|
||||||
@@ -134,7 +136,12 @@ async function validateDynamicClientRegistrationRequest(request: Request): Promi
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function handleAuth(request: Request) {
|
export async function handleAuth(incomingRequest: Request, trustedClient = "unknown") {
|
||||||
|
// Only the server adapter may supply the client address. Never forward client-sent proxy headers to auth.
|
||||||
|
const headers = new Headers(incomingRequest.headers);
|
||||||
|
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
|
||||||
|
if (isIP(trustedClient)) headers.set("X-Real-IP", trustedClient);
|
||||||
|
const request = new Request(incomingRequest, { headers });
|
||||||
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
const registrationValidationError = await validateDynamicClientRegistrationRequest(request);
|
||||||
if (registrationValidationError) return registrationValidationError;
|
if (registrationValidationError) return registrationValidationError;
|
||||||
|
|
||||||
|
|||||||
@@ -1,13 +1,3 @@
|
|||||||
export function getCookie(request: Request, name: string): string | undefined {
|
|
||||||
const cookieHeader = request.headers.get("cookie");
|
|
||||||
if (!cookieHeader) return;
|
|
||||||
|
|
||||||
for (const part of cookieHeader.split(";")) {
|
|
||||||
const [rawName, ...rawValue] = part.trim().split("=");
|
|
||||||
if (rawName === name && rawValue.length > 0) return rawValue.join("=");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||||
if ([...headers].length === 0) return response;
|
if ([...headers].length === 0) return response;
|
||||||
|
|
||||||
|
|||||||
@@ -1,53 +1,28 @@
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn() }));
|
const { execute, healthcheck, ping } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn(), ping: vi.fn() }));
|
||||||
|
|
||||||
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
|
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
|
||||||
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
|
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
|
||||||
vi.mock("../app-version", () => ({ appVersion: "9.8.7" }));
|
vi.mock("@reactive-resume/db/redis", () => ({ getRedis: () => ({ ping }) }));
|
||||||
|
|
||||||
import { handleHealth } from "./health";
|
import { handleHealth } from "./health";
|
||||||
|
|
||||||
describe("health version reporting", () => {
|
describe("health failure reporting", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
execute.mockResolvedValue([]);
|
execute.mockResolvedValue([]);
|
||||||
healthcheck.mockResolvedValue({ status: "healthy" });
|
healthcheck.mockResolvedValue({ status: "healthy" });
|
||||||
|
ping.mockResolvedValue("PONG");
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.unstubAllEnvs();
|
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("reports the built application version when launched directly by Node", async () => {
|
it.each(["database", "storage", "redis"])("keeps thrown %s error details in server logs only", async (dependency) => {
|
||||||
vi.stubEnv("npm_package_version", undefined);
|
|
||||||
|
|
||||||
const response = await handleHealth();
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("ignores a package manager's workspace package version", async () => {
|
|
||||||
vi.stubEnv("npm_package_version", "0.0.0");
|
|
||||||
|
|
||||||
expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps the version available when a dependency is unhealthy", async () => {
|
|
||||||
vi.stubEnv("npm_package_version", undefined);
|
|
||||||
execute.mockRejectedValueOnce(new Error("Database unavailable"));
|
|
||||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
||||||
|
|
||||||
const response = await handleHealth();
|
|
||||||
|
|
||||||
expect(response.status).toBe(503);
|
|
||||||
expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" });
|
|
||||||
});
|
|
||||||
it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => {
|
|
||||||
const detail = "Connection failed for private-user at internal.example:5432";
|
const detail = "Connection failed for private-user at internal.example:5432";
|
||||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||||
(dependency === "database" ? execute : healthcheck).mockRejectedValueOnce(new Error(detail));
|
({ database: execute, storage: healthcheck, redis: ping })[dependency]?.mockRejectedValueOnce(new Error(detail));
|
||||||
|
|
||||||
const response = await handleHealth();
|
const response = await handleHealth();
|
||||||
const body = await response.json();
|
const body = await response.json();
|
||||||
|
|||||||
@@ -39,7 +39,9 @@ function publicCheck(check: CheckResult, name: "Database" | "Storage" | "Redis")
|
|||||||
status: check.status,
|
status: check.status,
|
||||||
latencyMs: check.latencyMs,
|
latencyMs: check.latencyMs,
|
||||||
error: `${name} health check failed.`,
|
error: `${name} health check failed.`,
|
||||||
...(check.type === "local" || check.type === "s3" || check.type === "blob" ? { type: check.type } : {}),
|
...(check.type === "local" || check.type === "s3" || check.type === "blob" || check.type === "r2"
|
||||||
|
? { type: check.type }
|
||||||
|
: {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,16 +79,8 @@ export async function handleHealth() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (status === "unhealthy") {
|
if (status === "unhealthy") {
|
||||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage });
|
console.warn("[Healthcheck]", { route: "/api/health", database, storage, ...(redis ? { redis } : {}) });
|
||||||
}
|
}
|
||||||
|
|
||||||
const headers = new Headers();
|
return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 });
|
||||||
const body = JSON.stringify(checks);
|
|
||||||
headers.set("Content-Type", "application/json; charset=UTF-8");
|
|
||||||
headers.set("Content-Length", Buffer.byteLength(body, "utf-8").toString());
|
|
||||||
|
|
||||||
return new Response(body, {
|
|
||||||
headers,
|
|
||||||
status: checks.status === "unhealthy" ? 503 : 200,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -156,89 +156,6 @@ describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
|
|||||||
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
|
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
|
||||||
expect((await handleAuth(tokenRequest())).status).toBe(400);
|
expect((await handleAuth(tokenRequest())).status).toBe(400);
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
it("exchanges a code for a confidential client that registered client_secret_basic", async () => {
|
|
||||||
if (!databaseURL) return;
|
|
||||||
process.env.DATABASE_URL = databaseURL;
|
|
||||||
process.env.APP_URL = "http://localhost:33920";
|
|
||||||
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
|
|
||||||
const { handleAuth, handleOAuth } = await import("./auth");
|
|
||||||
const origin = process.env.APP_URL;
|
|
||||||
const redirectURI = "http://127.0.0.1:33921/callback";
|
|
||||||
const request = (path: string, body: object, cookie = "") =>
|
|
||||||
new Request(`${origin}/api/auth/${path}`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: { "content-type": "application/json", origin, cookie },
|
|
||||||
body: JSON.stringify(body),
|
|
||||||
});
|
|
||||||
|
|
||||||
const registration = await handleAuth(
|
|
||||||
request("oauth2/register", {
|
|
||||||
client_name: "Confidential MCP client",
|
|
||||||
redirect_uris: [redirectURI],
|
|
||||||
token_endpoint_auth_method: "client_secret_basic",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(registration.status, await registration.clone().text()).toBe(201);
|
|
||||||
const client = await registration.json();
|
|
||||||
// Downgrading this to a public client leaves the client without a secret, and its
|
|
||||||
// Basic-authenticated token exchange then fails with 401 invalid_client.
|
|
||||||
expect(client.token_endpoint_auth_method).toBe("client_secret_basic");
|
|
||||||
expect(client.client_secret).toBeTruthy();
|
|
||||||
|
|
||||||
const verifier = randomBytes(32).toString("base64url");
|
|
||||||
const query = new URLSearchParams({
|
|
||||||
client_id: client.client_id,
|
|
||||||
redirect_uri: redirectURI,
|
|
||||||
response_type: "code",
|
|
||||||
scope: "openid profile offline_access",
|
|
||||||
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
|
||||||
code_challenge_method: "S256",
|
|
||||||
resource: `${origin}/mcp`,
|
|
||||||
state: "opaque-state",
|
|
||||||
});
|
|
||||||
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
|
|
||||||
const login = await handleOAuth(new Request(new URL(authorize.headers.get("location") ?? "", origin)));
|
|
||||||
const callbackURL = new URL(login.headers.get("location") ?? "", origin).searchParams.get("callbackURL");
|
|
||||||
|
|
||||||
const unique = randomBytes(6).toString("hex");
|
|
||||||
const signup = await handleAuth(
|
|
||||||
request("sign-up/email", {
|
|
||||||
name: "Confidential Test",
|
|
||||||
email: `confidential-${unique}@example.com`,
|
|
||||||
username: `confidential-${unique}`,
|
|
||||||
password: "password123",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(signup.status, await signup.clone().text()).toBe(200);
|
|
||||||
const cookie = signup.headers
|
|
||||||
.getSetCookie()
|
|
||||||
.map((value) => value.split(";", 1)[0])
|
|
||||||
.join("; ");
|
|
||||||
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
|
|
||||||
const oauth_query = new URL(callback.headers.get("location") ?? "", origin).search.slice(1);
|
|
||||||
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
|
|
||||||
expect(accepted.status, await accepted.clone().text()).toBe(200);
|
|
||||||
const code = new URL((await accepted.json()).url).searchParams.get("code");
|
|
||||||
|
|
||||||
const tokenResponse = await handleAuth(
|
|
||||||
new Request(`${origin}/api/auth/oauth2/token`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"content-type": "application/x-www-form-urlencoded",
|
|
||||||
authorization: `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString("base64")}`,
|
|
||||||
},
|
|
||||||
body: new URLSearchParams({
|
|
||||||
grant_type: "authorization_code",
|
|
||||||
code: code ?? "",
|
|
||||||
redirect_uri: redirectURI,
|
|
||||||
code_verifier: verifier,
|
|
||||||
resource: `${origin}/mcp`,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
|
|
||||||
await expect(tokenResponse.json()).resolves.toMatchObject({ token_type: "Bearer" });
|
|
||||||
}, 30_000);
|
|
||||||
it.each(["login", "max-age", "create"])(
|
it.each(["login", "max-age", "create"])(
|
||||||
"requires fresh authentication for %s without looping",
|
"requires fresh authentication for %s without looping",
|
||||||
async (mode) => {
|
async (mode) => {
|
||||||
|
|||||||
@@ -39,41 +39,4 @@ describe("handlePublicResumePdf", () => {
|
|||||||
trustedClient,
|
trustedClient,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps password and private responses uncacheable", async () => {
|
|
||||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
|
||||||
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
|
|
||||||
filename: "resume.pdf",
|
|
||||||
});
|
|
||||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
|
|
||||||
|
|
||||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
|
||||||
|
|
||||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
|
||||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
|
||||||
username: "jane",
|
|
||||||
slug: "resume",
|
|
||||||
requestHeaders: request.headers,
|
|
||||||
trustedClient,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[{ code: "NEED_PASSWORD" }, 401],
|
|
||||||
[{ code: "NOT_FOUND" }, 404],
|
|
||||||
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
|
|
||||||
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
|
|
||||||
])("maps controlled API errors without caching the response", async (error, status) => {
|
|
||||||
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
|
|
||||||
|
|
||||||
const response = await handlePublicResumePdf(
|
|
||||||
new Request("https://example.com/api/resumes/jane/resume/pdf"),
|
|
||||||
"jane",
|
|
||||||
"resume",
|
|
||||||
trustedClient,
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(status);
|
|
||||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export async function handlePublicResumePdf(
|
|||||||
request: Request,
|
request: Request,
|
||||||
username: string,
|
username: string,
|
||||||
slug: string,
|
slug: string,
|
||||||
trustedClient = "unknown",
|
trustedClient: string,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const result = await createPublicResumePdf({
|
const result = await createPublicResumePdf({
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ describe("handleResumePdfDownload", () => {
|
|||||||
ok: true,
|
ok: true,
|
||||||
resumeId: "resume-1",
|
resumeId: "resume-1",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
target: "resume",
|
|
||||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||||
});
|
});
|
||||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||||
@@ -41,91 +40,13 @@ describe("handleResumePdfDownload", () => {
|
|||||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
expect(await response.text()).toBe("%PDF");
|
expect(await response.text()).toBe("%PDF");
|
||||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the cover letter target through to PDF rendering", async () => {
|
|
||||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
|
|
||||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
|
||||||
ok: true,
|
|
||||||
resumeId: "resume-1",
|
|
||||||
userId: "user-1",
|
|
||||||
target: "cover-letter",
|
|
||||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
|
||||||
});
|
|
||||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
|
||||||
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
|
|
||||||
body: pdf,
|
|
||||||
});
|
|
||||||
|
|
||||||
await handleResumePdfDownload(
|
|
||||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
|
||||||
"resume-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
||||||
id: "resume-1",
|
id: "resume-1",
|
||||||
userId: "user-1",
|
userId: "user-1",
|
||||||
target: "cover-letter",
|
resHeaders: expect.any(Headers),
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it("defaults a legacy token without a target to resume", async () => {
|
|
||||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
|
||||||
ok: true,
|
|
||||||
resumeId: "resume-1",
|
|
||||||
userId: "user-1",
|
|
||||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
|
||||||
});
|
|
||||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
|
||||||
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
|
|
||||||
body: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
|
|
||||||
});
|
|
||||||
|
|
||||||
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
|
|
||||||
|
|
||||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
|
||||||
id: "resume-1",
|
|
||||||
userId: "user-1",
|
|
||||||
target: "resume",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a cover-letter target for a legacy token without one", async () => {
|
|
||||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
|
||||||
ok: true,
|
|
||||||
resumeId: "resume-1",
|
|
||||||
userId: "user-1",
|
|
||||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await handleResumePdfDownload(
|
|
||||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
|
|
||||||
"resume-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects a target that differs from the signed token", async () => {
|
|
||||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
|
||||||
ok: true,
|
|
||||||
resumeId: "resume-1",
|
|
||||||
userId: "user-1",
|
|
||||||
target: "resume",
|
|
||||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await handleResumePdfDownload(
|
|
||||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
|
||||||
"resume-1",
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(401);
|
|
||||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||||
let response = await handleResumePdfDownload(
|
let response = await handleResumePdfDownload(
|
||||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||||
@@ -150,4 +71,17 @@ describe("handleResumePdfDownload", () => {
|
|||||||
expect(response.status).toBe(410);
|
expect(response.status).toBe(410);
|
||||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
it("returns the shared renderer's rate limit as HTTP 429", async () => {
|
||||||
|
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: true, userId: "user-1" });
|
||||||
|
mocks.createResumePdfDownload.mockRejectedValueOnce({
|
||||||
|
code: "TOO_MANY_REQUESTS",
|
||||||
|
data: { reset: Date.now() + 30_000 },
|
||||||
|
});
|
||||||
|
const response = await handleResumePdfDownload(
|
||||||
|
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
|
||||||
|
"resume-1",
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(429);
|
||||||
|
expect(Number(response.headers.get("Retry-After"))).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
||||||
|
|
||||||
|
const downloadHeaders = {
|
||||||
|
"Cache-Control": "private, no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"Referrer-Policy": "no-referrer",
|
||||||
|
};
|
||||||
|
|
||||||
function unauthorizedResponse() {
|
function unauthorizedResponse() {
|
||||||
return new Response("Unauthorized", {
|
return new Response("Unauthorized", {
|
||||||
status: 401,
|
status: 401,
|
||||||
headers: {
|
headers: downloadHeaders,
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function expiredResponse() {
|
function expiredResponse() {
|
||||||
return new Response("Download link expired", {
|
return new Response("Download link expired", {
|
||||||
status: 410,
|
status: 410,
|
||||||
headers: {
|
headers: downloadHeaders,
|
||||||
"Cache-Control": "private, no-store",
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function errorStatus(error: unknown) {
|
function errorStatus(error: unknown) {
|
||||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||||
|
if (code === "TOO_MANY_REQUESTS" || code === "RATE_LIMIT_EXCEEDED") return 429;
|
||||||
return code === "NOT_FOUND" ? 404 : 500;
|
return code === "NOT_FOUND" ? 404 : 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,33 +33,38 @@ export async function handleResumePdfDownload(request: Request, id: string) {
|
|||||||
|
|
||||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||||
const queryTarget = searchParams.get("target");
|
// Links made before letters left resumes may ask for the resume's cover letter, which is now a letter of its own.
|
||||||
if (
|
const target = searchParams.get("target");
|
||||||
verification.target
|
if (target && target !== "resume") return new Response("Not found", { status: 404, headers: downloadHeaders });
|
||||||
? queryTarget !== null && queryTarget !== verification.target
|
|
||||||
: queryTarget && queryTarget !== "resume"
|
|
||||||
)
|
|
||||||
return unauthorizedResponse();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const target = verification.target ?? "resume";
|
const resHeaders = new Headers();
|
||||||
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
|
const download = await createResumePdfDownload({ id, userId: verification.userId, resHeaders });
|
||||||
|
|
||||||
return new Response(download.body, {
|
return new Response(download.body, {
|
||||||
headers: {
|
headers: {
|
||||||
|
...Object.fromEntries(resHeaders),
|
||||||
"Content-Type": download.body.type || "application/pdf",
|
"Content-Type": download.body.type || "application/pdf",
|
||||||
"Content-Disposition": download.headers["content-disposition"],
|
"Content-Disposition": download.headers["content-disposition"],
|
||||||
"Cache-Control": "private, no-store",
|
...downloadHeaders,
|
||||||
"X-Content-Type-Options": "nosniff",
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("[PDF Download]", error);
|
const status = errorStatus(error);
|
||||||
return new Response("Failed to generate resume PDF", {
|
if (status === 500) console.error("[PDF Download]", { name: error instanceof Error ? error.name : "Unknown" });
|
||||||
status: errorStatus(error),
|
const reset =
|
||||||
headers: {
|
typeof error === "object" && error && "data" in error ? (error.data as { reset?: number })?.reset : undefined;
|
||||||
"Cache-Control": "private, no-store",
|
return new Response(
|
||||||
|
status === 429 ? "Too many PDF exports. Retry after the rate limit resets." : "Failed to generate resume PDF",
|
||||||
|
{
|
||||||
|
status,
|
||||||
|
headers: {
|
||||||
|
...downloadHeaders,
|
||||||
|
...(status === 429 && {
|
||||||
|
"Retry-After": String(reset ? Math.max(1, Math.ceil((reset - Date.now()) / 1000)) : 60),
|
||||||
|
}),
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
|
import { once } from "node:events";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { serve } from "@hono/node-server";
|
||||||
|
|
||||||
const events = vi.hoisted(() => [] as string[]);
|
const events = vi.hoisted(() => [] as string[]);
|
||||||
|
const appFetch = vi.hoisted(() => vi.fn());
|
||||||
vi.mock("./startup/checks", () => ({
|
vi.mock("./startup/checks", () => ({
|
||||||
runStartupChecks: async () => {
|
runStartupChecks: async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
@@ -12,7 +15,7 @@ vi.mock("./http/app", () => {
|
|||||||
return {
|
return {
|
||||||
createApp: () => {
|
createApp: () => {
|
||||||
events.push("app created");
|
events.push("app created");
|
||||||
return { fetch: vi.fn() };
|
return { fetch: appFetch };
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
@@ -23,19 +26,63 @@ vi.mock("@reactive-resume/auth/config", () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("@hono/node-server", () => ({
|
vi.mock("@hono/node-server", () => ({
|
||||||
serve: () => {
|
serve: vi.fn(() => {
|
||||||
events.push("server listening");
|
events.push("server listening");
|
||||||
},
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 3001 } }));
|
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 0 } }));
|
||||||
afterEach(() => vi.restoreAllMocks());
|
afterEach(() => vi.restoreAllMocks());
|
||||||
|
|
||||||
describe("server startup", () => {
|
describe("server startup", () => {
|
||||||
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
|
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
|
||||||
vi.spyOn(process, "on").mockReturnValue(process);
|
vi.spyOn(process, "on").mockReturnValue(process);
|
||||||
|
vi.spyOn(process, "once").mockReturnValue(process);
|
||||||
const entry = await import("./index");
|
const entry = await import("./index");
|
||||||
expect(events).toEqual([]);
|
expect(events).toEqual([]);
|
||||||
await entry.main();
|
await entry.main();
|
||||||
expect(events).toEqual(["migrations complete", "auth imported", "auth ready", "app created", "server listening"]);
|
expect(events).toEqual(["migrations complete", "auth imported", "auth ready", "app created", "server listening"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["SIGTERM", "SIGINT"])("drains active requests before exiting on %s", async (signal) => {
|
||||||
|
vi.spyOn(process, "on").mockReturnValue(process);
|
||||||
|
const signals = vi.spyOn(process, "once").mockReturnValue(process);
|
||||||
|
const exit = vi.spyOn(process, "exit").mockImplementation(() => undefined as never);
|
||||||
|
const started = Promise.withResolvers<void>();
|
||||||
|
const finished = Promise.withResolvers<Response>();
|
||||||
|
appFetch.mockImplementation(() => {
|
||||||
|
started.resolve();
|
||||||
|
return finished.promise;
|
||||||
|
});
|
||||||
|
const { serve: realServe } = await vi.importActual<typeof import("@hono/node-server")>("@hono/node-server");
|
||||||
|
let server: ReturnType<typeof serve> | undefined;
|
||||||
|
vi.mocked(serve).mockImplementationOnce((options, callback) => {
|
||||||
|
server = realServe(options, callback);
|
||||||
|
return server;
|
||||||
|
});
|
||||||
|
await (await import("./index")).main();
|
||||||
|
if (!server) throw new Error("Server did not start");
|
||||||
|
const runningServer = server;
|
||||||
|
try {
|
||||||
|
if (!server.listening) await once(server, "listening");
|
||||||
|
const address = server.address();
|
||||||
|
if (!address || typeof address === "string") throw new Error("Missing HTTP address");
|
||||||
|
const url = `http://127.0.0.1:${address.port}`;
|
||||||
|
const response = fetch(url);
|
||||||
|
await started.promise;
|
||||||
|
const shutdown = signals.mock.calls.find(([name]) => name === signal)?.[1];
|
||||||
|
expect(shutdown).toBeTypeOf("function");
|
||||||
|
const closed = once(server, "close");
|
||||||
|
shutdown?.();
|
||||||
|
shutdown?.();
|
||||||
|
expect(exit).not.toHaveBeenCalled();
|
||||||
|
await expect(fetch(url)).rejects.toThrow();
|
||||||
|
finished.resolve(new Response("drained"));
|
||||||
|
expect(await (await response).text()).toBe("drained");
|
||||||
|
await closed;
|
||||||
|
expect(exit).toHaveBeenCalledExactlyOnceWith(0);
|
||||||
|
} finally {
|
||||||
|
finished.resolve(new Response("drained"));
|
||||||
|
await new Promise<void>((resolve) => runningServer.close(() => resolve()));
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ export async function main() {
|
|||||||
|
|
||||||
const app = createApp();
|
const app = createApp();
|
||||||
|
|
||||||
serve(
|
const server = serve(
|
||||||
{
|
{
|
||||||
fetch: app.fetch,
|
fetch: app.fetch,
|
||||||
port,
|
port,
|
||||||
@@ -33,6 +33,22 @@ export async function main() {
|
|||||||
console.info(`🚀 Up and running on http://localhost:${info.port}`);
|
console.info(`🚀 Up and running on http://localhost:${info.port}`);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let shuttingDown = false;
|
||||||
|
const shutdown = () => {
|
||||||
|
if (shuttingDown) return;
|
||||||
|
shuttingDown = true;
|
||||||
|
// Stop accepting connections, then wait for active requests before exiting.
|
||||||
|
server.close((error) => {
|
||||||
|
if (error) {
|
||||||
|
console.error("Failed to drain HTTP requests", error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
process.exit(0);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
process.once("SIGTERM", shutdown);
|
||||||
|
process.once("SIGINT", shutdown);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const resolve = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@reactive-resume/api/context", () => ({ resolveAuthenticationFromRequestHeaders: resolve }));
|
||||||
|
|
||||||
|
import { AuthError, authenticateRequest } from "./auth";
|
||||||
|
|
||||||
|
beforeEach(() => resolve.mockReset());
|
||||||
|
it("resolves MCP credentials through the shared API auth policy without accepting cookies", async () => {
|
||||||
|
resolve.mockResolvedValue({ user: { id: "user-1" }, method: "bearer", permissions: ["read"] });
|
||||||
|
await authenticateRequest(
|
||||||
|
new Request("https://resume.example/mcp", {
|
||||||
|
headers: { authorization: "Bearer valid-token", "x-api-key": "expired-key", cookie: "session=browser" },
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const headers = resolve.mock.calls[0]?.[0] as Headers;
|
||||||
|
expect(headers.get("authorization")).toBe("Bearer valid-token");
|
||||||
|
expect(headers.get("x-api-key")).toBe("expired-key");
|
||||||
|
expect(headers.has("cookie")).toBe(false);
|
||||||
|
});
|
||||||
|
it("rejects requests when shared credential resolution finds no user", async () => {
|
||||||
|
resolve.mockResolvedValue(null);
|
||||||
|
await expect(authenticateRequest(new Request("https://resume.example/mcp"))).rejects.toBeInstanceOf(AuthError);
|
||||||
|
});
|
||||||
@@ -1,20 +1,4 @@
|
|||||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
import { resolveAuthenticationFromRequestHeaders } from "@reactive-resume/api/context";
|
||||||
|
|
||||||
const OAUTH_WARN_THROTTLE_MS = 60_000;
|
|
||||||
let lastOAuthWarnAt = 0;
|
|
||||||
|
|
||||||
function warnOAuthThrottled(message: string, detail?: unknown): void {
|
|
||||||
const now = Date.now();
|
|
||||||
if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return;
|
|
||||||
lastOAuthWarnAt = now;
|
|
||||||
|
|
||||||
if (detail !== undefined) {
|
|
||||||
console.warn(message, detail);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
console.warn(message);
|
|
||||||
}
|
|
||||||
|
|
||||||
export class AuthError extends Error {
|
export class AuthError extends Error {
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -22,29 +6,11 @@ export class AuthError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function authenticateRequest(request: Request): Promise<void> {
|
export async function authenticateRequest(request: Request) {
|
||||||
const authHeader = request.headers.get("authorization");
|
// MCP accepts API keys and bearer tokens; share their priority and validation with its oRPC tools.
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
if (authHeader?.startsWith("Bearer ")) {
|
headers.delete("cookie");
|
||||||
try {
|
const authentication = await resolveAuthenticationFromRequestHeaders(headers);
|
||||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
if (authentication) return authentication;
|
||||||
if (payload?.sub) return;
|
|
||||||
warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim");
|
|
||||||
} catch (error) {
|
|
||||||
warnOAuthThrottled("[MCP] OAuth token verification failed:", error);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const apiKey = request.headers.get("x-api-key");
|
|
||||||
|
|
||||||
if (apiKey) {
|
|
||||||
try {
|
|
||||||
const result = await auth.api.verifyApiKey({ body: { key: apiKey } });
|
|
||||||
if (result.valid) return;
|
|
||||||
} catch {
|
|
||||||
// Invalid or malformed key; fall through to AuthError.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new AuthError();
|
throw new AuthError();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, expect, it, vi } from "vitest";
|
||||||
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
|
||||||
|
const mocks = vi.hoisted(() => ({ authentication: vi.fn(), limit: vi.fn() }));
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({ env: { APP_URL: "https://resume.example" } }));
|
||||||
|
vi.mock("@reactive-resume/api/context", () => ({ resolveAuthenticationFromRequestHeaders: mocks.authentication }));
|
||||||
|
vi.mock("@reactive-resume/api/features/mcp/transport", () => ({
|
||||||
|
consumeMcpRequestLimit: mocks.limit,
|
||||||
|
consumeMcpUserLimit: mocks.limit,
|
||||||
|
}));
|
||||||
|
vi.mock("./server", () => ({
|
||||||
|
createMcpServer: () => new McpServer({ name: "transport-test", version: "1.0.0" }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { handleMcp } from "./handler";
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mocks.limit.mockResolvedValue(undefined);
|
||||||
|
mocks.authentication.mockResolvedValue({ user: { id: "user-1" }, method: "bearer", permissions: ["read"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
function initialize(origin?: string) {
|
||||||
|
return new Request("https://resume.example/mcp", {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
Accept: "application/json, text/event-stream",
|
||||||
|
...(origin && { Origin: origin }),
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
jsonrpc: "2.0",
|
||||||
|
id: 1,
|
||||||
|
method: "initialize",
|
||||||
|
params: { protocolVersion: "2025-11-25", capabilities: {}, clientInfo: { name: "test", version: "1" } },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it("serves native and same-origin MCP initialization as complete JSON with private response headers", async () => {
|
||||||
|
for (const origin of [undefined, "https://resume.example"]) {
|
||||||
|
const response = await handleMcp(initialize(origin), "127.0.0.1");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect((await response.json()).result.protocolVersion).toBe("2025-11-25");
|
||||||
|
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||||
|
expect(response.headers.get("x-content-type-options")).toBe("nosniff");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects foreign origins and standalone GET streams before authentication", async () => {
|
||||||
|
expect((await handleMcp(initialize("https://attacker.example"))).status).toBe(403);
|
||||||
|
const response = await handleMcp(
|
||||||
|
new Request("https://resume.example/mcp", { headers: { Accept: "text/event-stream" } }),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(405);
|
||||||
|
expect(response.headers.get("allow")).toBe("POST");
|
||||||
|
expect(mocks.authentication).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns actionable rate limits and conceals unexpected authentication errors", async () => {
|
||||||
|
mocks.limit.mockRejectedValueOnce(new ORPCError("TOO_MANY_REQUESTS", { data: { reset: Date.now() + 30_000 } }));
|
||||||
|
const limited = await handleMcp(initialize());
|
||||||
|
expect(limited.status).toBe(429);
|
||||||
|
expect(Number(limited.headers.get("retry-after"))).toBeGreaterThan(0);
|
||||||
|
mocks.authentication.mockRejectedValueOnce(new Error("private database connection string"));
|
||||||
|
const failed = await handleMcp(initialize());
|
||||||
|
expect(failed.status).toBe(500);
|
||||||
|
expect(await failed.text()).not.toContain("private database connection string");
|
||||||
|
});
|
||||||
@@ -1,42 +1,72 @@
|
|||||||
|
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
|
import { randomUUID } from "node:crypto";
|
||||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||||
|
import { ORPCError } from "@orpc/server";
|
||||||
|
import { consumeMcpRequestLimit, consumeMcpUserLimit } from "@reactive-resume/api/features/mcp/transport";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { AuthError, authenticateRequest } from "./auth";
|
import { AuthError, authenticateRequest } from "./auth";
|
||||||
import { createMcpServer } from "./server";
|
import { createMcpServer } from "./server";
|
||||||
|
|
||||||
export async function handleMcp(request: Request) {
|
const responseHeaders = {
|
||||||
|
"Cache-Control": "no-store",
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
};
|
||||||
|
|
||||||
|
function errorResponse(status: number, message: string, headers?: Record<string, string>) {
|
||||||
|
return Response.json(
|
||||||
|
{ id: null, jsonrpc: "2.0", error: { code: -32603, message } },
|
||||||
|
{ status, headers: { ...responseHeaders, ...headers } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function handleMcp(request: Request, trustedClient = "unknown") {
|
||||||
|
let server: McpServer | undefined;
|
||||||
|
let transport: WebStandardStreamableHTTPServerTransport | undefined;
|
||||||
try {
|
try {
|
||||||
await authenticateRequest(request);
|
const origin = request.headers.get("origin");
|
||||||
|
if (origin !== null && origin !== new URL(env.APP_URL).origin) {
|
||||||
const server = createMcpServer(request);
|
return errorResponse(403, "Origin is not allowed. Use the configured application origin or a native MCP client.");
|
||||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
}
|
||||||
|
if (request.method !== "POST") {
|
||||||
|
return errorResponse(405, "This stateless MCP endpoint accepts POST requests.", { Allow: "POST" });
|
||||||
|
}
|
||||||
|
await consumeMcpRequestLimit(trustedClient);
|
||||||
|
const authentication = await authenticateRequest(request);
|
||||||
|
await consumeMcpUserLimit(authentication.user.id);
|
||||||
|
const resHeaders = new Headers(responseHeaders);
|
||||||
|
server = createMcpServer(request, authentication, trustedClient, resHeaders);
|
||||||
|
transport = new WebStandardStreamableHTTPServerTransport({
|
||||||
enableJsonResponse: true,
|
enableJsonResponse: true,
|
||||||
|
// Larger files use authenticated upload references rather than base64 in JSON-RPC.
|
||||||
|
maxRequestBodySize: 4 * 1024 * 1024,
|
||||||
});
|
});
|
||||||
|
|
||||||
await server.connect(transport);
|
await server.connect(transport);
|
||||||
|
const response = await transport.handleRequest(request);
|
||||||
return await transport.handleRequest(request);
|
const headers = new Headers(response.headers);
|
||||||
|
for (const [key, value] of resHeaders) headers.set(key, value);
|
||||||
|
return new Response(response.body, { status: response.status, statusText: response.statusText, headers });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof AuthError) {
|
if (error instanceof AuthError) {
|
||||||
return Response.json(
|
return errorResponse(401, "Unauthorized", {
|
||||||
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
||||||
{
|
});
|
||||||
status: 401,
|
}
|
||||||
headers: {
|
if (error instanceof ORPCError && error.code === "TOO_MANY_REQUESTS") {
|
||||||
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
const reset = (error.data as { reset?: number } | undefined)?.reset;
|
||||||
},
|
return errorResponse(429, "Too many MCP requests. Retry after the rate limit resets.", {
|
||||||
},
|
"Retry-After": String(reset ? Math.max(1, Math.ceil((reset - Date.now()) / 1000)) : 60),
|
||||||
);
|
});
|
||||||
|
}
|
||||||
|
const diagnosticId = randomUUID();
|
||||||
|
console.error("[MCP] Request failed", { diagnosticId, name: error instanceof Error ? error.name : "Unknown" });
|
||||||
|
return errorResponse(500, `MCP request failed. Retry or contact support with diagnostic ID ${diagnosticId}.`);
|
||||||
|
} finally {
|
||||||
|
// JSON responses are complete before handleRequest resolves; no standalone streams remain.
|
||||||
|
try {
|
||||||
|
if (server) await server.close();
|
||||||
|
else if (transport) await transport.close();
|
||||||
|
} catch {
|
||||||
|
console.error("[MCP] Transport cleanup failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
console.error("[MCP]", error);
|
|
||||||
|
|
||||||
return Response.json({
|
|
||||||
id: null,
|
|
||||||
jsonrpc: "2.0",
|
|
||||||
error: {
|
|
||||||
code: -32603,
|
|
||||||
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
import type { RouterClient } from "@orpc/server";
|
import type { RouterClient } from "@orpc/server";
|
||||||
|
import type { RequestAuthentication } from "@reactive-resume/api/context";
|
||||||
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||||
import { onError } from "@orpc/client";
|
import { onError } from "@orpc/client";
|
||||||
import { createRouterClient } from "@orpc/server";
|
import { createRouterClient } from "@orpc/server";
|
||||||
import router from "@reactive-resume/api/routers";
|
|
||||||
import {
|
import {
|
||||||
buildMcpServerInfo,
|
buildMcpServerInfo,
|
||||||
MCP_TOOL_NAME,
|
MCP_TOOL_NAME,
|
||||||
|
MCP_ROUTER,
|
||||||
|
registerParityTools,
|
||||||
registerPrompts,
|
registerPrompts,
|
||||||
registerResources,
|
registerResources,
|
||||||
registerTools,
|
registerTools,
|
||||||
@@ -13,22 +15,43 @@ import {
|
|||||||
import { appVersion } from "../app-version";
|
import { appVersion } from "../app-version";
|
||||||
import { getRequestLocale } from "../rpc/locale";
|
import { getRequestLocale } from "../rpc/locale";
|
||||||
|
|
||||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
function createRequestClient(
|
||||||
return createRouterClient(router, {
|
request: Request,
|
||||||
|
authentication: RequestAuthentication,
|
||||||
|
trustedClient: string,
|
||||||
|
resHeaders: Headers,
|
||||||
|
): RouterClient<typeof MCP_ROUTER> {
|
||||||
|
const reqHeaders = new Headers(request.headers);
|
||||||
|
reqHeaders.delete("cookie");
|
||||||
|
return createRouterClient(MCP_ROUTER, {
|
||||||
interceptors: [
|
interceptors: [
|
||||||
|
(options) => {
|
||||||
|
request.signal.throwIfAborted();
|
||||||
|
return options.next({
|
||||||
|
...options,
|
||||||
|
signal: options.signal ? AbortSignal.any([request.signal, options.signal]) : request.signal,
|
||||||
|
});
|
||||||
|
},
|
||||||
onError((error) => {
|
onError((error) => {
|
||||||
console.error("[MCP oRPC]", error);
|
console.error("[MCP oRPC]", { name: error instanceof Error ? error.name : "Unknown" });
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
context: () => ({
|
context: () => ({
|
||||||
locale: getRequestLocale(request),
|
locale: getRequestLocale(request),
|
||||||
reqHeaders: request.headers,
|
reqHeaders,
|
||||||
resHeaders: new Headers(),
|
resHeaders,
|
||||||
|
trustedClient,
|
||||||
|
authentication,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createMcpServer(request: Request) {
|
export function createMcpServer(
|
||||||
|
request: Request,
|
||||||
|
authentication: RequestAuthentication,
|
||||||
|
trustedClient: string,
|
||||||
|
resHeaders: Headers,
|
||||||
|
) {
|
||||||
const server = new McpServer(buildMcpServerInfo(appVersion), {
|
const server = new McpServer(buildMcpServerInfo(appVersion), {
|
||||||
instructions: [
|
instructions: [
|
||||||
"You are connected to Reactive Resume over MCP.",
|
"You are connected to Reactive Resume over MCP.",
|
||||||
@@ -37,16 +60,25 @@ export function createMcpServer(request: Request) {
|
|||||||
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
||||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true).`,
|
||||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
|
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`. Export letters separately with \`${MCP_TOOL_NAME.exportCoverLetter}\`.`,
|
||||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
|
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
|
||||||
].join(" "),
|
].join(" "),
|
||||||
});
|
});
|
||||||
|
|
||||||
const client = createRequestClient(request);
|
const client = createRequestClient(request, authentication, trustedClient, resHeaders);
|
||||||
|
const headers = new Headers(request.headers);
|
||||||
|
headers.delete("cookie");
|
||||||
registerResources(server, client);
|
registerResources(server, client);
|
||||||
registerTools(server, client, request.headers);
|
registerTools(server, client, headers, authentication);
|
||||||
registerPrompts(server);
|
registerParityTools(server, client, headers, {
|
||||||
|
authentication,
|
||||||
|
resHeaders,
|
||||||
|
trustedClient,
|
||||||
|
locale: getRequestLocale(request),
|
||||||
|
signal: request.signal,
|
||||||
|
});
|
||||||
|
registerPrompts(server, client);
|
||||||
|
|
||||||
return server;
|
return server;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { StylesheetChange } from "@reactive-resume/api/features/resume/legacy-styles-migration";
|
||||||
|
import { closeSync, openSync, readFileSync, writeSync } from "node:fs";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
import { drizzle } from "drizzle-orm/node-postgres";
|
||||||
|
import { Pool } from "pg";
|
||||||
|
import { migrateLegacyStyles, restoreLegacyStyles } from "@reactive-resume/api/features/resume/legacy-styles-migration";
|
||||||
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
|
||||||
|
const usage = `Converts resumes and letters still styled by the old style editor (legacy style rules) to Semantic CSS.
|
||||||
|
Uses DATABASE_URL. Run it once after deploying the version without the legacy renderer.
|
||||||
|
|
||||||
|
node apps/server/dist/migrate-legacy-styles.mjs
|
||||||
|
Dry run: converts every row that needs it in memory and reports the counts. Writes nothing.
|
||||||
|
|
||||||
|
node apps/server/dist/migrate-legacy-styles.mjs --apply --backup <file>
|
||||||
|
Converts and saves. Every replaced stylesheet is appended to <file> (NDJSON) before its row is written.
|
||||||
|
Safe to run again or after an interruption: converted rows are skipped. Use a new file or the same one.
|
||||||
|
|
||||||
|
node apps/server/dist/migrate-legacy-styles.mjs --restore <file>
|
||||||
|
Puts back the stylesheets recorded in <file>, except on rows whose stylesheet was edited since.
|
||||||
|
`;
|
||||||
|
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: {
|
||||||
|
apply: { type: "boolean", default: false },
|
||||||
|
backup: { type: "string" },
|
||||||
|
restore: { type: "string" },
|
||||||
|
help: { type: "boolean", default: false },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (values.help || (values.apply && !values.backup) || (values.restore && (values.apply || values.backup))) {
|
||||||
|
console.info(usage);
|
||||||
|
process.exit(values.help ? 0 : 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Opened before connecting, so an unwritable path fails before anything changes.
|
||||||
|
const backup = values.backup ? openSync(values.backup, "a") : undefined;
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: env.DATABASE_URL, max: 1, connectionTimeoutMillis: 10_000 });
|
||||||
|
const client = await pool.connect();
|
||||||
|
const log = (message: string) => console.info(`[${new Date().toISOString()}] ${message}`);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Finding the rows is one scan per table, which can outlast the database's default statement timeout.
|
||||||
|
await client.query("SET statement_timeout = 0");
|
||||||
|
const db = drizzle({ client });
|
||||||
|
|
||||||
|
if (values.restore) {
|
||||||
|
const changes = readFileSync(values.restore, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => JSON.parse(line) as StylesheetChange);
|
||||||
|
log(`Restoring ${changes.length} stylesheets from ${values.restore}`);
|
||||||
|
log(`Done: ${JSON.stringify(await restoreLegacyStyles(db, changes))}`);
|
||||||
|
} else {
|
||||||
|
log(values.apply ? `Converting, backing up to ${values.backup}` : "Dry run: nothing will be written");
|
||||||
|
const summary = await migrateLegacyStyles(db, {
|
||||||
|
apply: values.apply,
|
||||||
|
log,
|
||||||
|
...(backup === undefined ? {} : { onChange: (change) => writeSync(backup, `${JSON.stringify(change)}\n`) }),
|
||||||
|
});
|
||||||
|
log(`Done: ${JSON.stringify(summary)}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (backup !== undefined) closeSync(backup);
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
@@ -1,9 +1,5 @@
|
|||||||
import { readFile } from "node:fs/promises";
|
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import z from "zod";
|
|
||||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
|
||||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||||
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
|
||||||
|
|
||||||
// Spec generation reads procedure contracts without executing authentication. Keep the
|
// Spec generation reads procedure contracts without executing authentication. Keep the
|
||||||
// provider's resource seeding out of this unit test; real OAuth initialization is covered
|
// provider's resource seeding out of this unit test; real OAuth initialization is covered
|
||||||
@@ -17,11 +13,6 @@ type GeneratedSpecView = {
|
|||||||
Record<
|
Record<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
tags?: string[];
|
|
||||||
operationId?: string;
|
|
||||||
summary?: string;
|
|
||||||
description?: string;
|
|
||||||
responses?: Record<string, { description?: string }>;
|
|
||||||
requestBody?: {
|
requestBody?: {
|
||||||
content?: Record<string, { schema?: unknown }>;
|
content?: Record<string, { schema?: unknown }>;
|
||||||
};
|
};
|
||||||
@@ -78,88 +69,10 @@ function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
describe("generateOpenApiSpec", () => {
|
describe("generateOpenApiSpec", () => {
|
||||||
it("documents all cover-letter procedures with REST metadata", async () => {
|
it("keeps instance homepage resolution out of the public API", async () => {
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
const expected = [
|
expect(spec.paths).not.toHaveProperty("/resume/getRoot");
|
||||||
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
|
|
||||||
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
|
|
||||||
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
|
|
||||||
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
|
|
||||||
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
|
|
||||||
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
|
|
||||||
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Delete a cover letter", "200"],
|
|
||||||
["post", "/cover-letters/from-resume", "copyEmbeddedCoverLetter", "Copy an embedded cover letter", "200"],
|
|
||||||
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
|
|
||||||
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
for (const [method, path, operationId, summary, successStatus] of expected) {
|
|
||||||
const operation = spec.paths?.[path]?.[method];
|
|
||||||
expect(operation).toMatchObject({
|
|
||||||
tags: ["Cover Letters"],
|
|
||||||
operationId,
|
|
||||||
summary,
|
|
||||||
description: expect.any(String),
|
|
||||||
responses: { [successStatus]: { description: expect.any(String) } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
|
|
||||||
const published = JSON.parse(
|
|
||||||
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
|
|
||||||
) as GeneratedSpecView;
|
|
||||||
const runtime = await generateSpec();
|
|
||||||
const coverLetterPaths = (spec: GeneratedSpecView) =>
|
|
||||||
Object.fromEntries(
|
|
||||||
Object.entries(spec.paths ?? {}).filter(
|
|
||||||
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const publishedPaths = coverLetterPaths(published);
|
|
||||||
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
|
|
||||||
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
|
|
||||||
expect(publishedPaths).toEqual(runtimePaths);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses caller-provided application URL and version", async () => {
|
|
||||||
const spec = await generateSpec();
|
|
||||||
|
|
||||||
expect(spec.info).toMatchObject({
|
|
||||||
title: "Reactive Resume",
|
|
||||||
version: "9.8.7",
|
|
||||||
});
|
|
||||||
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
|
|
||||||
expect(spec.externalDocs).toEqual({
|
|
||||||
url: "https://docs.rxresu.me",
|
|
||||||
description: "Reactive Resume Documentation",
|
|
||||||
});
|
|
||||||
}, 15_000);
|
}, 15_000);
|
||||||
|
|
||||||
it("documents the public health endpoint at its actual URL", async () => {
|
|
||||||
const spec = await generateSpec();
|
|
||||||
const health = spec.paths?.["/api/health"]?.get;
|
|
||||||
|
|
||||||
expect(health).toMatchObject({
|
|
||||||
operationId: "getHealth",
|
|
||||||
security: [],
|
|
||||||
servers: [{ url: "https://rxresu.me" }],
|
|
||||||
});
|
|
||||||
for (const status of ["200", "503"]) {
|
|
||||||
expect(health?.responses?.[status]).toMatchObject({
|
|
||||||
content: {
|
|
||||||
"application/json": {
|
|
||||||
schema: {
|
|
||||||
required: expect.arrayContaining(["service", "version", "status"]),
|
|
||||||
properties: { version: { type: "string" } },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||||
@@ -170,87 +83,63 @@ describe("generateOpenApiSpec", () => {
|
|||||||
data: { $ref: "#/components/schemas/ResumeData" },
|
data: { $ref: "#/components/schemas/ResumeData" },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
});
|
}, 15_000);
|
||||||
|
|
||||||
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
|
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
|
||||||
|
|
||||||
expect(spec.components?.schemas?.ResumeData).toMatchObject({
|
|
||||||
properties: {
|
|
||||||
picture: { required: expect.not.arrayContaining(["fit"]) },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("publishes the custom-section type and item correlation", async () => {
|
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
|
||||||
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
|
||||||
const mismatched = {
|
|
||||||
...defaultResumeData,
|
|
||||||
customSections: [
|
|
||||||
{
|
|
||||||
id: "custom-experience",
|
|
||||||
type: "experience",
|
|
||||||
title: "Experience",
|
|
||||||
icon: "",
|
|
||||||
columns: 1,
|
|
||||||
hidden: false,
|
|
||||||
keepTogether: false,
|
|
||||||
startOnNewPage: false,
|
|
||||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("enforces the same submitted bounds as the published request schema", async () => {
|
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
|
||||||
const published = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
|
||||||
for (const marginX of [0, 100, -1, 500]) {
|
|
||||||
const data = structuredClone(defaultResumeData);
|
|
||||||
data.metadata.page.marginX = marginX;
|
|
||||||
const expected = marginX === 0 || marginX === 100;
|
|
||||||
expect(published.safeParse(data).success).toBe(expected);
|
|
||||||
expect(writableResumeDataSchema.safeParse(data).success).toBe(expected);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not publish impossible request schemas", async () => {
|
it("does not publish impossible request schemas", async () => {
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||||
|
|
||||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("checks every request body media type for impossible schemas", () => {
|
it("documents anonymous routes, all supported credentials, and additive PATCH routes", async () => {
|
||||||
const spec: GeneratedSpecView = {
|
const spec = await generateSpec();
|
||||||
paths: {
|
expect(spec.paths?.["/flags"]?.get?.security).toEqual([]);
|
||||||
"/documents": {
|
expect(spec.paths?.["/resumes/{username}/{slug}"]?.get?.security).toEqual([]);
|
||||||
post: {
|
expect(spec.paths?.["/resumes"]?.get?.security).toEqual([{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }]);
|
||||||
requestBody: {
|
for (const path of ["/applications/{id}", "/cover-letters/{id}"]) {
|
||||||
content: {
|
expect(spec.paths?.[path]?.put?.requestBody).toBeDefined();
|
||||||
"application/json": { schema: { type: "object" } },
|
expect(spec.paths?.[path]?.patch?.requestBody).toBeDefined();
|
||||||
"multipart/form-data": { schema: { not: {} } },
|
}
|
||||||
},
|
expect(spec.paths?.["/files"]?.post?.requestBody).toHaveProperty("content.multipart/form-data");
|
||||||
},
|
expect(spec.paths?.["/files"]?.post?.requestBody).not.toHaveProperty("content.application/json");
|
||||||
},
|
expect(spec.paths?.["/resumes"]?.get?.parameters).toEqual(
|
||||||
},
|
expect.arrayContaining([
|
||||||
},
|
expect.objectContaining({ name: "limit", in: "query" }),
|
||||||
};
|
expect.objectContaining({ name: "offset", in: "query" }),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
it("gives every published app operation an explicit route and input/output contracts", async () => {
|
||||||
});
|
const { openAPIRouter } = await import("./generator");
|
||||||
|
const visit = (node: unknown, path: string) => {
|
||||||
|
if (!node || typeof node !== "object") return;
|
||||||
|
if ("~orpc" in node) {
|
||||||
|
const definition = node["~orpc"] as {
|
||||||
|
route: { tags?: string[]; method?: string; path?: string };
|
||||||
|
inputSchema?: unknown;
|
||||||
|
outputSchema?: unknown;
|
||||||
|
};
|
||||||
|
if (definition.route.tags?.includes("Internal")) return;
|
||||||
|
expect(definition.route.method, path).toBeDefined();
|
||||||
|
expect(definition.route.path, path).toBeDefined();
|
||||||
|
expect(definition.inputSchema, path).toBeDefined();
|
||||||
|
expect(definition.outputSchema, path).toBeDefined();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const [key, value] of Object.entries(node)) visit(value, `${path}.${key}`);
|
||||||
|
};
|
||||||
|
visit(openAPIRouter, "api");
|
||||||
|
});
|
||||||
|
|
||||||
it("documents imported data as an accepted ResumeData input", async () => {
|
it("describes empty responses and common errors without impossible payloads", async () => {
|
||||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
const spec = await generateSpec();
|
||||||
|
const deleted = spec.paths?.["/files"]?.delete?.responses?.["200"];
|
||||||
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
expect(deleted).toBeDefined();
|
||||||
type: "object",
|
expect(deleted).not.toHaveProperty("content");
|
||||||
properties: {
|
expect(spec.paths?.["/resumes"]?.get?.responses?.default).toHaveProperty(
|
||||||
data: { $ref: "#/components/schemas/ResumeData" },
|
"content.application/json.schema.properties.code",
|
||||||
},
|
);
|
||||||
required: ["data"],
|
expect(spec.paths?.["/resumes"]?.get?.responses?.["200"]).toHaveProperty("headers.X-Total-Count");
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { OpenAPI } from "@orpc/openapi";
|
|||||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||||
|
import { restAliases } from "@reactive-resume/api/rest";
|
||||||
import router from "@reactive-resume/api/routers";
|
import router from "@reactive-resume/api/routers";
|
||||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||||
@@ -9,6 +10,7 @@ import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
|||||||
|
|
||||||
export const openAPIRouter = {
|
export const openAPIRouter = {
|
||||||
...router,
|
...router,
|
||||||
|
rest: restAliases,
|
||||||
resume: {
|
resume: {
|
||||||
...router.resume,
|
...router.resume,
|
||||||
downloadPdf: downloadResumePdfProcedure,
|
downloadPdf: downloadResumePdfProcedure,
|
||||||
@@ -79,11 +81,12 @@ const healthResponseSchema = {
|
|||||||
} satisfies OpenAPI.SchemaObject;
|
} satisfies OpenAPI.SchemaObject;
|
||||||
|
|
||||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||||
return await openAPIGenerator.generate(openAPIRouter, {
|
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||||
info: {
|
info: {
|
||||||
title: "Reactive Resume",
|
title: "Reactive Resume",
|
||||||
version,
|
version,
|
||||||
description: "Reactive Resume API",
|
description:
|
||||||
|
"Reactive Resume API. Mutations do not support Idempotency-Key. Do not automatically retry POST, PUT, PATCH or DELETE requests after a timeout: first read the resource to determine whether the operation succeeded. Existing enum values and response shapes are retained for compatibility.",
|
||||||
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
|
license: { name: "MIT", url: "https://github.com/reactive-resume/reactive-resume/blob/main/LICENSE" },
|
||||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||||
},
|
},
|
||||||
@@ -116,6 +119,19 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
|
|||||||
},
|
},
|
||||||
components: {
|
components: {
|
||||||
securitySchemes: {
|
securitySchemes: {
|
||||||
|
bearerAuth: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
description: "An OAuth access token issued by this instance for its API/MCP resource.",
|
||||||
|
},
|
||||||
|
cookieAuth: {
|
||||||
|
type: "apiKey",
|
||||||
|
in: "cookie",
|
||||||
|
name: "better-auth.session_token",
|
||||||
|
description:
|
||||||
|
"Browser session (secure deployments use the __Secure- prefix). Cookie requests must originate from this instance.",
|
||||||
|
},
|
||||||
apiKey: {
|
apiKey: {
|
||||||
type: "apiKey",
|
type: "apiKey",
|
||||||
name: "x-api-key",
|
name: "x-api-key",
|
||||||
@@ -124,7 +140,66 @@ export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSp
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
security: [{ apiKey: [] }],
|
security: [{ apiKey: [] }, { bearerAuth: [] }, { cookieAuth: [] }],
|
||||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||||
});
|
});
|
||||||
|
// Void results have no HTTP body; Zod's impossible JSON schema is not a response payload.
|
||||||
|
const isVoid = (schema: unknown): boolean => {
|
||||||
|
if (!schema || typeof schema !== "object") return false;
|
||||||
|
const value = schema as { not?: object; anyOf?: unknown[] };
|
||||||
|
return (
|
||||||
|
(value.not !== undefined && Object.keys(value.not).length === 0) ||
|
||||||
|
(Array.isArray(value.anyOf) && value.anyOf.every(isVoid))
|
||||||
|
);
|
||||||
|
};
|
||||||
|
for (const [path, item] of Object.entries(spec.paths ?? {})) {
|
||||||
|
if (!item || path === "/api/health") continue;
|
||||||
|
for (const method of ["get", "post", "put", "patch", "delete"] as const) {
|
||||||
|
const operation = item[method];
|
||||||
|
if (!operation) continue;
|
||||||
|
const body = operation.requestBody;
|
||||||
|
if (body && !("$ref" in body) && body.content["multipart/form-data"]) delete body.content["application/json"];
|
||||||
|
operation.responses ??= {};
|
||||||
|
operation.responses.default = {
|
||||||
|
description: "Structured API error. See status and code; do not branch on message text.",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: {
|
||||||
|
type: "object",
|
||||||
|
required: ["defined", "code", "status", "message"],
|
||||||
|
properties: {
|
||||||
|
defined: { type: "boolean" },
|
||||||
|
code: { type: "string" },
|
||||||
|
status: { type: "integer" },
|
||||||
|
message: { type: "string" },
|
||||||
|
data: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
for (const response of Object.values(operation.responses)) {
|
||||||
|
if ("$ref" in response) continue;
|
||||||
|
const schema = response.content?.["application/json"]?.schema;
|
||||||
|
if (isVoid(schema)) delete response.content;
|
||||||
|
if (
|
||||||
|
schema &&
|
||||||
|
"type" in schema &&
|
||||||
|
schema.type === "array" &&
|
||||||
|
operation.parameters?.some((parameter) => "name" in parameter && parameter.name === "limit")
|
||||||
|
) {
|
||||||
|
response.headers = {
|
||||||
|
...response.headers,
|
||||||
|
"X-Total-Count": { description: "Total matching results before pagination.", schema: { type: "integer" } },
|
||||||
|
"X-Limit": { description: "Page size, when pagination is requested.", schema: { type: "integer" } },
|
||||||
|
"X-Offset": {
|
||||||
|
description: "Zero-based offset, when pagination is requested.",
|
||||||
|
schema: { type: "integer" },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return spec;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
vi.mock("@reactive-resume/auth/config", () => ({
|
||||||
|
auth: {
|
||||||
|
api: { getSession: vi.fn().mockResolvedValue(null), verifyApiKey: vi.fn().mockResolvedValue({ valid: false }) },
|
||||||
|
},
|
||||||
|
verifyOAuthToken: vi.fn().mockRejectedValue(new Error("invalid token")),
|
||||||
|
isCustomOAuthProviderEnabled: () => false,
|
||||||
|
}));
|
||||||
|
const { handleOpenApi } = await import("./handler");
|
||||||
|
const request = (path: string, init?: RequestInit) =>
|
||||||
|
handleOpenApi(new Request(`http://localhost:3000/api/openapi${path}`, init), "127.0.0.1");
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.spyOn(console, "error").mockImplementation(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("REST boundary", () => {
|
||||||
|
it("serves public configuration without authentication and marks it uncacheable", async () => {
|
||||||
|
const response = await request("/flags");
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.json()).toHaveProperty("disableSignups");
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("no-store");
|
||||||
|
});
|
||||||
|
it.each(["/resume/getRoot", "/storage/uploadFile", "/storage/deleteFile", "/missing"])(
|
||||||
|
"does not expose internal or unknown routes: %s",
|
||||||
|
async (path) => {
|
||||||
|
const response = await request(path, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{}",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "NOT_FOUND", status: 404 });
|
||||||
|
},
|
||||||
|
);
|
||||||
|
it.each([
|
||||||
|
"/resumes",
|
||||||
|
"/applications",
|
||||||
|
"/documents",
|
||||||
|
"/ai-providers",
|
||||||
|
"/agent/threads",
|
||||||
|
"/cover-letters",
|
||||||
|
"/resumes/private/exports/json",
|
||||||
|
"/cover-letters/private/exports/pdf",
|
||||||
|
])("rejects unauthenticated private reads: %s", async (path) => {
|
||||||
|
const response = await request(path);
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "UNAUTHORIZED", status: 401 });
|
||||||
|
});
|
||||||
|
it("decodes multipart public checks and returns structured errors for invalid PDF content", async () => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.set("file", new File(["not a PDF"], "resume.pdf", { type: "application/pdf" }));
|
||||||
|
const response = await request("/pdf-checks", { method: "POST", body: form });
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({
|
||||||
|
code: "BAD_REQUEST",
|
||||||
|
message: "Provide a PDF no larger than 25 MB.",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
it("returns the same JSON error envelope for malformed JSON", async () => {
|
||||||
|
const response = await request("/resumes", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: "{",
|
||||||
|
});
|
||||||
|
expect(response.status).toBe(400);
|
||||||
|
expect(await response.json()).toMatchObject({ code: "BAD_REQUEST", status: 400 });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||||
import { onError } from "@orpc/server";
|
import { onError } from "@orpc/server";
|
||||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
import { RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
import { appVersion } from "../app-version";
|
import { appVersion } from "../app-version";
|
||||||
@@ -10,8 +10,8 @@ import { getRequestLocale } from "../rpc/locale";
|
|||||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||||
|
|
||||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||||
|
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||||
plugins: [
|
plugins: [
|
||||||
new BatchHandlerPlugin(),
|
|
||||||
new RequestHeadersPlugin(),
|
new RequestHeadersPlugin(),
|
||||||
new StrictGetMethodPlugin(),
|
new StrictGetMethodPlugin(),
|
||||||
new SmartCoercionPlugin({
|
new SmartCoercionPlugin({
|
||||||
@@ -25,8 +25,9 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
export async function handleOpenApi(request: Request, trustedClient: string) {
|
||||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
const pathname = new URL(request.url).pathname;
|
||||||
|
if (request.method === "GET" && ["/api/openapi/spec.json", "/api/openapi/spec"].includes(pathname)) {
|
||||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -36,6 +37,12 @@ export async function handleOpenApi(request: Request, trustedClient = "unknown")
|
|||||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
resHeaders.set("Cache-Control", "no-store");
|
||||||
|
resHeaders.set("X-Content-Type-Options", "nosniff");
|
||||||
|
if (!response)
|
||||||
|
return Response.json(
|
||||||
|
{ defined: false, code: "NOT_FOUND", status: 404, message: "Not found" },
|
||||||
|
{ status: 404, headers: resHeaders },
|
||||||
|
);
|
||||||
return mergeResponseHeaders(response, resHeaders);
|
return mergeResponseHeaders(response, resHeaders);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const rpcHandler = new RPCHandler(router, {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function handleRpc(request: Request, trustedClient = "unknown") {
|
export async function handleRpc(request: Request, trustedClient: string) {
|
||||||
const resHeaders = new Headers();
|
const resHeaders = new Headers();
|
||||||
const { response } = await rpcHandler.handle(request, {
|
const { response } = await rpcHandler.handle(request, {
|
||||||
prefix: "/api/rpc",
|
prefix: "/api/rpc",
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { Locale } from "@reactive-resume/utils/locale";
|
import type { Locale } from "@reactive-resume/utils/locale";
|
||||||
|
import { parse } from "hono/utils/cookie";
|
||||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||||
import { getCookie } from "../http/headers";
|
|
||||||
|
|
||||||
export function getRequestLocale(request: Request): Locale {
|
export function getRequestLocale(request: Request): Locale {
|
||||||
const locale = getCookie(request, "locale");
|
const locale = parse(request.headers.get("cookie") ?? "", "locale").locale;
|
||||||
return isLocale(locale) ? locale : defaultLocale;
|
return isLocale(locale) ? locale : defaultLocale;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,6 @@ describe("collectExpectedColumns", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("verifyMigratedSchema", () => {
|
describe("verifyMigratedSchema", () => {
|
||||||
it("passes when the catalog reports nothing missing", async () => {
|
|
||||||
const queryable = { query: async () => ({ rows: [] }) };
|
|
||||||
await expect(verifyMigratedSchema(queryable)).resolves.toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("fails with the table name when every column of a table is missing", async () => {
|
it("fails with the table name when every column of a table is missing", async () => {
|
||||||
const rows = collectExpectedColumns()
|
const rows = collectExpectedColumns()
|
||||||
.filter((e) => e.tableName === "ai_providers")
|
.filter((e) => e.tableName === "ai_providers")
|
||||||
@@ -24,27 +19,4 @@ describe("verifyMigratedSchema", () => {
|
|||||||
const queryable = { query: async () => ({ rows }) };
|
const queryable = { query: async () => ({ rows }) };
|
||||||
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('table "ai_providers"');
|
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('table "ai_providers"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("fails with the qualified column name when only some columns are missing", async () => {
|
|
||||||
const queryable = { query: async () => ({ rows: [{ table_name: "user", column_name: "role" }] }) };
|
|
||||||
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('"user"."role"');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("passes the expected table and column lists to the catalog query", async () => {
|
|
||||||
let captured: unknown[] | undefined;
|
|
||||||
const queryable = {
|
|
||||||
query: (_text: string, values?: unknown[]) => {
|
|
||||||
captured = values;
|
|
||||||
return Promise.resolve({ rows: [] });
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
await verifyMigratedSchema(queryable);
|
|
||||||
|
|
||||||
const [tables, columns] = captured as [string[], string[]];
|
|
||||||
// The query relies on $1/$2 being index-aligned, so each table name must pair
|
|
||||||
// with its own column name at the same index.
|
|
||||||
const pairs = tables.map((table, index) => `${table}.${columns[index]}`);
|
|
||||||
expect(pairs).toContain("ai_providers.user_id");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
|
||||||
import z from "zod";
|
|
||||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
|
||||||
import { handleSchemaJson } from "./schema";
|
|
||||||
|
|
||||||
describe("handleSchemaJson", () => {
|
|
||||||
it("publishes the custom-section type and item correlation", async () => {
|
|
||||||
const response = handleSchemaJson();
|
|
||||||
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
|
|
||||||
const mismatched = {
|
|
||||||
...defaultResumeData,
|
|
||||||
customSections: [
|
|
||||||
{
|
|
||||||
id: "custom-experience",
|
|
||||||
type: "experience",
|
|
||||||
title: "Experience",
|
|
||||||
icon: "",
|
|
||||||
columns: 1,
|
|
||||||
hidden: false,
|
|
||||||
keepTogether: false,
|
|
||||||
startOnNewPage: false,
|
|
||||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,69 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
vi.mock("@reactive-resume/env/server", () => ({
|
|
||||||
env: {
|
|
||||||
APP_URL: "https://app.example.com/",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { handleLlms, handleRobots, handleSitemap } = await import("./seo");
|
|
||||||
|
|
||||||
describe("SEO static endpoints", () => {
|
|
||||||
it("generates robots.txt from the normalized app URL", async () => {
|
|
||||||
const response = handleRobots();
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(text).toContain("User-agent: *");
|
|
||||||
expect(text).toContain("Allow: /");
|
|
||||||
expect(text).toContain("Disallow: /api/rpc");
|
|
||||||
expect(text).toContain("Disallow: /api/auth");
|
|
||||||
expect(text).toContain("Disallow: /mcp");
|
|
||||||
expect(text).toContain("Disallow: /.well-known");
|
|
||||||
expect(text).toContain("Sitemap: https://app.example.com/sitemap.xml");
|
|
||||||
expect(text).toContain("Sitemap: https://docs.rxresu.me/sitemap.xml");
|
|
||||||
expect(text).not.toMatch(/GPTBot|ClaudeBot|PerplexityBot|CCBot|ChatGPT-User/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates an app-domain-only sitemap", async () => {
|
|
||||||
const response = handleSitemap();
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
|
|
||||||
expect(text).toContain("<loc>https://app.example.com/</loc>");
|
|
||||||
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
|
|
||||||
expect(text).not.toContain("docs.rxresu.me");
|
|
||||||
expect(text).not.toContain("/auth");
|
|
||||||
expect(text).not.toContain("/dashboard");
|
|
||||||
expect(text).not.toContain("/builder");
|
|
||||||
expect(text).not.toContain("/templates");
|
|
||||||
expect(text).not.toContain("/schema.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("generates a lightweight llms.txt product index", async () => {
|
|
||||||
const response = handleLlms();
|
|
||||||
const text = await response.text();
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(text).toContain("# Reactive Resume");
|
|
||||||
expect(text).toContain("- Product: https://app.example.com");
|
|
||||||
expect(text).toContain("- Documentation: https://docs.rxresu.me");
|
|
||||||
expect(text).toContain("- Documentation sitemap: https://docs.rxresu.me/sitemap.xml");
|
|
||||||
expect(text).toContain("- Documentation llms.txt: https://docs.rxresu.me/llms.txt");
|
|
||||||
expect(text).toContain("- API documentation: https://docs.rxresu.me/api-reference");
|
|
||||||
expect(text).toContain("- Resume schema: https://app.example.com/schema.json");
|
|
||||||
expect(text).toContain("- MCP documentation: https://docs.rxresu.me/guides/using-the-mcp-server");
|
|
||||||
expect(text).toContain("- OpenAPI specification: https://app.example.com/api/openapi/spec.json");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns headers without a body for HEAD responses", async () => {
|
|
||||||
const response = handleLlms({ head: true });
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(await response.text()).toBe("");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||||
|
import { getLocaleAlternates } from "@reactive-resume/utils/locale";
|
||||||
|
|
||||||
const DOCS_URL = "https://docs.rxresu.me";
|
const DOCS_URL = "https://docs.rxresu.me";
|
||||||
|
|
||||||
@@ -34,17 +36,24 @@ export function handleRobots(options?: StaticSeoOptions) {
|
|||||||
return textResponse(body, options);
|
return textResponse(body, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The indexable pages; everything else is the signed-in app or a public resume, both served noindex.
|
||||||
|
const sitemapPaths = ["/", "/ats-checker"];
|
||||||
|
|
||||||
export function handleSitemap(options?: StaticSeoOptions) {
|
export function handleSitemap(options?: StaticSeoOptions) {
|
||||||
const baseUrl = appUrl();
|
const baseUrl = appUrl();
|
||||||
|
// Each page lists its languages, so every `?locale=` address is discoverable without a sitemap entry of its own.
|
||||||
|
const urls = sitemapPaths.map((path) => {
|
||||||
|
const pageUrl = `${baseUrl}${path}`;
|
||||||
|
const alternates = getLocaleAlternates(pageUrl).map(
|
||||||
|
({ hreflang, href }) =>
|
||||||
|
` <xhtml:link rel="alternate" hreflang="${hreflang}" href="${href.replaceAll("&", "&")}"/>`,
|
||||||
|
);
|
||||||
|
return [" <url>", ` <loc>${pageUrl}</loc>`, ...alternates, " </url>"].join("\n");
|
||||||
|
});
|
||||||
const body = [
|
const body = [
|
||||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||||
" <url>",
|
...urls,
|
||||||
` <loc>${baseUrl}/</loc>`,
|
|
||||||
" </url>",
|
|
||||||
" <url>",
|
|
||||||
` <loc>${baseUrl}/ats-checker</loc>`,
|
|
||||||
" </url>",
|
|
||||||
"</urlset>",
|
"</urlset>",
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
@@ -59,18 +68,41 @@ export function handleLlms(options?: StaticSeoOptions) {
|
|||||||
const body = [
|
const body = [
|
||||||
"# Reactive Resume",
|
"# Reactive Resume",
|
||||||
"",
|
"",
|
||||||
"Reactive Resume is an open-source resume builder for creating, managing, and exporting resumes.",
|
`> Reactive Resume is a free and open-source resume builder. Write a resume in an editor beside a live page, pick one of ${templateSchema.options.length} templates, check that applicant tracking systems can read it, tailor it to a job posting, and share it at a public link or download it. No ads, no tracking, no paid tier; it is funded by donations and released under the MIT License.`,
|
||||||
"",
|
"",
|
||||||
"## Links",
|
"## Product",
|
||||||
"",
|
"",
|
||||||
`- Product: ${baseUrl}`,
|
`- [Homepage](${baseUrl}/): what Reactive Resume does, with answers to common questions.`,
|
||||||
`- Documentation: ${DOCS_URL}`,
|
`- [ATS checker](${baseUrl}/ats-checker): a free tool that shows the text applicant tracking systems extract from a resume PDF and what to fix. It runs in the browser; the file is never uploaded.`,
|
||||||
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
|
`- [Get started](${baseUrl}/dashboard): create an account and a first resume.`,
|
||||||
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
|
"",
|
||||||
`- API documentation: ${DOCS_URL}/api-reference`,
|
"## Facts",
|
||||||
`- Resume schema: ${baseUrl}/schema.json`,
|
"",
|
||||||
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
|
"- Price: free, every feature. Optional donations through GitHub Sponsors and Open Collective.",
|
||||||
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
|
"- Export: PDF, Word (DOCX), Markdown and JSON.",
|
||||||
|
"- Import: PDF, LinkedIn data export, JSON Resume, Reactive Resume JSON; Word files with an AI provider.",
|
||||||
|
"- Sharing: private by default; a public link or a password-protected link, changeable at any time.",
|
||||||
|
"- AI: optional, with the user's own API key (OpenAI, Anthropic, Google Gemini, OpenRouter, Ollama and others). Nothing is sent to an AI service without one.",
|
||||||
|
"- Also includes: cover letters, a job application tracker, version history, passkeys and two-factor authentication.",
|
||||||
|
"- Languages: the interface is translated into more than 50 languages by volunteers on Crowdin.",
|
||||||
|
"- Self-hosting: a Docker image, with PostgreSQL and local or S3-compatible storage.",
|
||||||
|
"",
|
||||||
|
"## Documentation",
|
||||||
|
"",
|
||||||
|
`- [Documentation](${DOCS_URL}): guides for using and self-hosting Reactive Resume.`,
|
||||||
|
`- [Documentation llms.txt](${DOCS_URL}/llms.txt)`,
|
||||||
|
`- [Self-hosting with Docker](${DOCS_URL}/self-hosting/docker)`,
|
||||||
|
`- [API reference](${DOCS_URL}/api-reference)`,
|
||||||
|
`- [OpenAPI specification](${baseUrl}/api/openapi/spec.json)`,
|
||||||
|
`- [Resume JSON schema](${baseUrl}/schema.json)`,
|
||||||
|
`- [MCP server guide](${DOCS_URL}/guides/using-the-mcp-server)`,
|
||||||
|
"",
|
||||||
|
"## Community",
|
||||||
|
"",
|
||||||
|
"- [Source code on GitHub](https://github.com/reactive-resume/reactive-resume)",
|
||||||
|
"- [Discord](https://discord.gg/aSyA5ZSxpb)",
|
||||||
|
"- [Subreddit](https://www.reddit.com/r/reactiveresume)",
|
||||||
|
"- [Translations on Crowdin](https://crowdin.com/project/reactive-resume)",
|
||||||
"",
|
"",
|
||||||
].join("\n");
|
].join("\n");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { mkdtemp, rm, stat } from "node:fs/promises";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { afterAll, beforeAll, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const envMock = vi.hoisted(() => ({
|
||||||
|
APP_URL: "https://resume.example.com",
|
||||||
|
STORAGE_BACKEND: "local",
|
||||||
|
LOCAL_STORAGE_PATH: "",
|
||||||
|
}));
|
||||||
|
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||||
|
|
||||||
|
let storage: ReturnType<typeof import("@reactive-resume/api/features/storage").getStorageService>;
|
||||||
|
let handleUpload: typeof import("./uploads").handleUpload;
|
||||||
|
beforeAll(async () => {
|
||||||
|
envMock.LOCAL_STORAGE_PATH = await mkdtemp(join(tmpdir(), "resume-private-upload-"));
|
||||||
|
storage = (await import("@reactive-resume/api/features/storage")).getStorageService();
|
||||||
|
({ handleUpload } = await import("./uploads"));
|
||||||
|
});
|
||||||
|
afterAll(async () => {
|
||||||
|
await rm(envMock.LOCAL_STORAGE_PATH, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stores a private local attachment for authenticated reads and excludes it from public uploads", async () => {
|
||||||
|
const key = "uploads/user-1/agent/thread-1/attachment-1";
|
||||||
|
const data = new TextEncoder().encode("private attachment");
|
||||||
|
await storage.write({ key, data, contentType: "text/plain", private: true });
|
||||||
|
const stored = await storage.read(key);
|
||||||
|
expect(stored).not.toBeNull();
|
||||||
|
expect(Uint8Array.from(stored?.data ?? [])).toEqual(data);
|
||||||
|
if (process.platform !== "win32")
|
||||||
|
expect((await stat(join(envMock.LOCAL_STORAGE_PATH, key))).mode & 0o777).toBe(0o600);
|
||||||
|
expect((await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`))).status).toBe(404);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["uploads/user-1/pictures/private", "uploads/../agent/thread-1/private", "uploads/user-1/agent/../private"])(
|
||||||
|
"rejects private writes outside the canonical attachment namespace: %s",
|
||||||
|
async (key) => {
|
||||||
|
await expect(
|
||||||
|
storage.write({ key, data: new Uint8Array([1]), contentType: "text/plain", private: true }),
|
||||||
|
).rejects.toThrow();
|
||||||
|
expect(await storage.read(key)).toBeNull();
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -8,6 +8,7 @@ const envMock = vi.hoisted(() => ({
|
|||||||
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
||||||
S3_REGION: "us-east-1",
|
S3_REGION: "us-east-1",
|
||||||
S3_ENDPOINT: "",
|
S3_ENDPOINT: "",
|
||||||
|
STORAGE_BACKEND: "s3",
|
||||||
S3_BUCKET: "test-bucket",
|
S3_BUCKET: "test-bucket",
|
||||||
S3_FORCE_PATH_STYLE: true,
|
S3_FORCE_PATH_STYLE: true,
|
||||||
}));
|
}));
|
||||||
@@ -15,6 +16,12 @@ vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
|||||||
|
|
||||||
type StoredObject = { data: Buffer; contentType: string };
|
type StoredObject = { data: Buffer; contentType: string };
|
||||||
type StorageRequest = { method: string; path: string; headers: IncomingHttpHeaders };
|
type StorageRequest = { method: string; path: string; headers: IncomingHttpHeaders };
|
||||||
|
const resolveAuthentication = vi.hoisted(() => vi.fn());
|
||||||
|
vi.mock("@reactive-resume/api/context", () => ({
|
||||||
|
resolveAuthenticationFromRequestHeaders: resolveAuthentication,
|
||||||
|
resolveUserFromRequestHeaders: async (headers: Headers) => (await resolveAuthentication(headers))?.user ?? null,
|
||||||
|
}));
|
||||||
|
|
||||||
const objects = new Map<string, StoredObject>();
|
const objects = new Map<string, StoredObject>();
|
||||||
const requests: StorageRequest[] = [];
|
const requests: StorageRequest[] = [];
|
||||||
|
|
||||||
@@ -42,11 +49,6 @@ const server = createServer(async (request, response) => {
|
|||||||
response.writeHead(200, { ETag: '"test-etag"' });
|
response.writeHead(200, { ETag: '"test-etag"' });
|
||||||
return response.end();
|
return response.end();
|
||||||
}
|
}
|
||||||
if (request.method === "DELETE") {
|
|
||||||
objects.delete(path);
|
|
||||||
response.writeHead(204);
|
|
||||||
return response.end();
|
|
||||||
}
|
|
||||||
const object = objects.get(path);
|
const object = objects.get(path);
|
||||||
if (!object) return fail(404, "NoSuchKey");
|
if (!object) return fail(404, "NoSuchKey");
|
||||||
response.writeHead(200, { "Content-Type": object.contentType, "Content-Length": object.data.length });
|
response.writeHead(200, { "Content-Type": object.contentType, "Content-Length": object.data.length });
|
||||||
@@ -66,6 +68,7 @@ beforeAll(async () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
|
resolveAuthentication.mockReset();
|
||||||
objects.clear();
|
objects.clear();
|
||||||
requests.length = 0;
|
requests.length = 0;
|
||||||
});
|
});
|
||||||
@@ -75,12 +78,6 @@ afterAll(async () => {
|
|||||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps the ACL-disabled storage health check healthy", async () => {
|
|
||||||
expect(await storage.healthcheck()).toMatchObject({ status: "healthy", type: "s3" });
|
|
||||||
expect(requests.map(({ method }) => method)).toEqual(["PUT", "DELETE"]);
|
|
||||||
expect(objects.size).toBe(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("stores images without ACLs and serves them through the signed application proxy", async () => {
|
it("stores images without ACLs and serves them through the signed application proxy", async () => {
|
||||||
const key = "uploads/user-1/pictures/photo.png";
|
const key = "uploads/user-1/pictures/photo.png";
|
||||||
const data = new Uint8Array([137, 80, 78, 71]);
|
const data = new Uint8Array([137, 80, 78, 71]);
|
||||||
@@ -108,3 +105,39 @@ it("stores private attachments without ACLs while keeping them outside the publi
|
|||||||
expect(direct.status).toBe(403);
|
expect(direct.status).toBe(403);
|
||||||
expect((await storage.read(key))?.data).toEqual(data);
|
expect((await storage.read(key))?.data).toEqual(data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["text/html", "image/svg+xml"])("downloads stored %s uploads instead of rendering them", async (type) => {
|
||||||
|
const key = "uploads/user-1/pictures/upload.bin";
|
||||||
|
await storage.write({ key, data: new TextEncoder().encode("<script>alert(1)</script>"), contentType: type });
|
||||||
|
resolveAuthentication.mockResolvedValue({ user: { id: "user-1" }, permissions: ["read"] });
|
||||||
|
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(response.headers.get("Content-Type")).toBe("application/octet-stream");
|
||||||
|
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="upload.bin"');
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the upload owner before serving application PDFs, including conditional requests", async () => {
|
||||||
|
const key = "uploads/user-1/pictures/application.pdf";
|
||||||
|
await storage.write({
|
||||||
|
key,
|
||||||
|
data: new TextEncoder().encode("private application PDF"),
|
||||||
|
contentType: "application/pdf",
|
||||||
|
});
|
||||||
|
for (const viewer of [
|
||||||
|
null,
|
||||||
|
{ user: { id: "other-user" }, permissions: ["read"] },
|
||||||
|
{ user: { id: "user-1" }, permissions: ["write"] },
|
||||||
|
]) {
|
||||||
|
resolveAuthentication.mockResolvedValue(viewer);
|
||||||
|
const response = await handleUpload(
|
||||||
|
new Request(`${envMock.APP_URL}/api/${key}`, { headers: { "If-None-Match": '"test-etag"' } }),
|
||||||
|
);
|
||||||
|
expect(response.status).toBe(404);
|
||||||
|
expect(await response.text()).not.toContain("private application PDF");
|
||||||
|
}
|
||||||
|
resolveAuthentication.mockResolvedValue({ user: { id: "user-1" }, permissions: ["read"] });
|
||||||
|
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||||
|
expect(response.status).toBe(200);
|
||||||
|
expect(await response.text()).toBe("private application PDF");
|
||||||
|
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
||||||
|
|
||||||
const readMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("@reactive-resume/api/features/storage", () => ({
|
|
||||||
getStorageService: () => ({
|
|
||||||
read: readMock,
|
|
||||||
}),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@reactive-resume/env/server", () => ({
|
|
||||||
env: {
|
|
||||||
APP_URL: "https://example.com",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { handleUpload } = await import("./uploads");
|
|
||||||
|
|
||||||
describe("handleUpload", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
readMock.mockReset();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("serves public upload keys", async () => {
|
|
||||||
readMock.mockResolvedValueOnce({
|
|
||||||
data: new TextEncoder().encode("image"),
|
|
||||||
size: 5,
|
|
||||||
contentType: "image/jpeg",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await handleUpload(new Request("https://example.com/api/uploads/user-1/pictures/photo.jpeg"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
|
||||||
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
|
|
||||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
|
||||||
readMock.mockResolvedValueOnce({
|
|
||||||
data: new TextEncoder().encode("secret"),
|
|
||||||
size: 6,
|
|
||||||
contentType: "text/plain",
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = await handleUpload(
|
|
||||||
new Request("https://example.com/api/uploads/user-1/agent/thread-1/attachment.txt"),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(readMock).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { basename, extname, normalize } from "node:path";
|
import { basename, normalize } from "node:path";
|
||||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||||
|
|
||||||
|
// Uploads share the app origin and S3/Blob hand back the client-declared type, so only raster
|
||||||
|
// images render inline. Anything else (HTML, SVG, PDF, unknown) downloads, whatever was stored.
|
||||||
|
const INLINE_CONTENT_TYPES = new Set(["image/gif", "image/jpeg", "image/png", "image/webp"]);
|
||||||
|
|
||||||
export async function handleUpload(request: Request) {
|
export async function handleUpload(request: Request) {
|
||||||
const { userId, filePath } = parseRouteParams(request.url);
|
const { userId, filePath } = parseRouteParams(request.url);
|
||||||
|
|
||||||
@@ -16,13 +20,19 @@ export async function handleUpload(request: Request) {
|
|||||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||||
|
|
||||||
const filename = filePath.split("/").pop() ?? filePath;
|
const filename = filePath.split("/").pop() ?? filePath;
|
||||||
const ext = extname(filename).toLowerCase();
|
|
||||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||||
|
const isPublicPicture = filePath.startsWith("pictures/") && INLINE_CONTENT_TYPES.has(contentType);
|
||||||
|
if (!isPublicPicture) {
|
||||||
|
const { resolveAuthenticationFromRequestHeaders } = await import("@reactive-resume/api/context");
|
||||||
|
const authentication = await resolveAuthenticationFromRequestHeaders(request.headers).catch(() => null);
|
||||||
|
if (authentication?.user.id !== userId || !authentication.permissions.includes("read"))
|
||||||
|
return new Response("Not Found", { status: 404, headers: { "Cache-Control": "no-store" } });
|
||||||
|
}
|
||||||
const etag = createEtag(storedFile);
|
const etag = createEtag(storedFile);
|
||||||
|
|
||||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
if (isPublicPicture && isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||||
|
|
||||||
const shouldForceDownload = [".pdf"].includes(ext);
|
const shouldForceDownload = !INLINE_CONTENT_TYPES.has(contentType);
|
||||||
|
|
||||||
const headers = new Headers();
|
const headers = new Headers();
|
||||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||||
@@ -32,7 +42,7 @@ export async function handleUpload(request: Request) {
|
|||||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
headers.set("Cache-Control", isPublicPicture ? "public, max-age=31536000, immutable" : "private, no-store");
|
||||||
headers.set("ETag", etag);
|
headers.set("ETag", etag);
|
||||||
headers.set("X-Content-Type-Options", "nosniff");
|
headers.set("X-Content-Type-Options", "nosniff");
|
||||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
|
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
|
||||||
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
|
serveStatic: vi.fn(() => vi.fn()),
|
||||||
getPublicResumeSocialMeta: vi.fn(),
|
getPublicResumeSocialMeta: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
@@ -29,18 +29,7 @@ vi.mock("@reactive-resume/env/server", () => ({
|
|||||||
env: mocks.env,
|
env: mocks.env,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
type StaticOptions = {
|
|
||||||
onFound?: (
|
|
||||||
path: string,
|
|
||||||
context: {
|
|
||||||
req: { path: string };
|
|
||||||
header: (name: string, value: string) => void;
|
|
||||||
},
|
|
||||||
) => void | Promise<void>;
|
|
||||||
};
|
|
||||||
|
|
||||||
const { handleWebApp } = await import("./web");
|
const { handleWebApp } = await import("./web");
|
||||||
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
|
|
||||||
|
|
||||||
describe("web app fallback classification", () => {
|
describe("web app fallback classification", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -50,15 +39,6 @@ describe("web app fallback classification", () => {
|
|||||||
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
|
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves the shell for the root app route without noindex", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
|
||||||
expect(await response.text()).toBe("<html>app</html>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
|
it("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(`
|
vi.mocked(fs.readFile).mockResolvedValue(`
|
||||||
<!doctype html>
|
<!doctype html>
|
||||||
@@ -78,18 +58,41 @@ describe("web app fallback classification", () => {
|
|||||||
const html = await response.text();
|
const html = await response.text();
|
||||||
|
|
||||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
||||||
expect(html).toContain('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
|
|
||||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
|
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
|
||||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
expect(html).toContain('<script type="application/ld+json">');
|
||||||
expect(html).toContain('id="reactive-resume-structured-data"');
|
expect(html).toContain(
|
||||||
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
|
'<meta property="og:description" content="Reactive Resume is a free and open-source resume builder that makes it easy to create, update, and share your resume.">',
|
||||||
expect(html).toContain('"url":"https://rxresu.me/"');
|
);
|
||||||
|
expect(html).toContain('"contactType":"support","email":"hello@amruthpillai.com"');
|
||||||
expect(html).not.toContain("utm_source");
|
expect(html).not.toContain("utm_source");
|
||||||
|
|
||||||
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
||||||
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
|
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("serves the homepage prerendered in the requested or saved locale, with hreflang alternates", async () => {
|
||||||
|
vi.mocked(fs.readFile).mockImplementation((path) => {
|
||||||
|
const locale = String(path).match(/dist-prerender\/home\/(.+)\.html$/)?.[1];
|
||||||
|
return Promise.resolve(`<html><head></head><body><div id="app">${locale ?? "shell"}</div></body></html>`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const german = await handleWebApp(new Request("https://example.com/?locale=de-DE"));
|
||||||
|
const html = await german.text();
|
||||||
|
expect(html).toContain('<div id="app">de-DE</div>');
|
||||||
|
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/?locale=de-DE">');
|
||||||
|
expect(html).toContain('<link rel="alternate" hreflang="x-default" href="https://rxresu.me/">');
|
||||||
|
expect(german.headers.get("Vary")).toBe("Cookie");
|
||||||
|
|
||||||
|
const saved = new Request("https://example.com/", { headers: { cookie: "theme=dark; locale=ar-SA" } });
|
||||||
|
const savedHtml = await (await handleWebApp(saved)).text();
|
||||||
|
expect(savedHtml).toContain('<div id="app">ar-SA</div>');
|
||||||
|
expect(savedHtml).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
||||||
|
|
||||||
|
// Only known locales name a file, so the parameter can't point anywhere else.
|
||||||
|
const unknown = await handleWebApp(new Request("https://example.com/?locale=../../index"));
|
||||||
|
expect(await unknown.text()).toContain('<div id="app">en-US</div>');
|
||||||
|
});
|
||||||
|
|
||||||
describe("the ATS checker page", () => {
|
describe("the ATS checker page", () => {
|
||||||
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
||||||
|
|
||||||
@@ -103,60 +106,27 @@ describe("web app fallback classification", () => {
|
|||||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("replaces the shell metadata with the checker's own", async () => {
|
it("serves the prerendered page in the requested locale, with its own title on the social cards", async () => {
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
vi.mocked(fs.readFile).mockImplementation((path) => {
|
||||||
|
const match = String(path).match(/dist-prerender\/ats-checker\/(.+)\.html$/);
|
||||||
|
if (!match) return Promise.resolve(shell);
|
||||||
|
return Promise.resolve(
|
||||||
|
`<html><head><title>ATS-Prüfung & mehr</title><meta name="description" content="Lesbar?"></head><body><div id="app">${match[1]}</div></body></html>`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
|
const html = await (await handleWebApp(new Request("https://example.com/ats-checker?locale=de-DE"))).text();
|
||||||
|
|
||||||
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
|
expect(html).toContain('<div id="app">de-DE</div>');
|
||||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
|
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker?locale=de-DE">');
|
||||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
|
expect(html).toContain('<meta property="og:title" content="ATS-Prüfung & mehr">');
|
||||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
|
expect(html).toContain('<meta property="og:locale" content="de_DE">');
|
||||||
expect(html).toContain('id="ats-checker-structured-data"');
|
|
||||||
expect(html).not.toContain("Marketing copy.");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("answers HEAD without a body", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(await response.text()).toBe("");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("does not treat the checker path as a public resume owner", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("public resume social cards", () => {
|
describe("public resume social cards", () => {
|
||||||
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
||||||
|
|
||||||
it("injects resume-specific social metadata and replaces the shell title", async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
|
||||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
|
||||||
name: "Jane Doe",
|
|
||||||
title: "Jane Doe — Staff Engineer",
|
|
||||||
description: "Builds resilient distributed systems.",
|
|
||||||
template: "azurill",
|
|
||||||
});
|
|
||||||
|
|
||||||
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
|
||||||
|
|
||||||
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
|
|
||||||
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
|
|
||||||
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
|
|
||||||
expect(html).not.toContain("Marketing copy.");
|
|
||||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
|
|
||||||
expect(html).toContain('<meta property="og:type" content="profile">');
|
|
||||||
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
|
|
||||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
|
||||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
|
||||||
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
|
||||||
});
|
|
||||||
|
|
||||||
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||||
@@ -174,42 +144,20 @@ describe("web app fallback classification", () => {
|
|||||||
expect(html).toContain('content="Ends with " and & ampersand"');
|
expect(html).toContain('content="Ends with " and & ampersand"');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves the plain shell when the resume is not publicly shareable", async () => {
|
it("keeps replacement patterns in resume text literal", async () => {
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||||
|
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||||
|
name: "Jane $& $' Doe",
|
||||||
|
title: "Jane Doe",
|
||||||
|
description: "Costs $$ and $` nothing",
|
||||||
|
template: "azurill",
|
||||||
|
});
|
||||||
|
|
||||||
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
|
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
||||||
|
|
||||||
expect(html).toBe(shell);
|
expect(html).toContain("<title>Jane $& $' Doe - Reactive Resume</title>");
|
||||||
|
expect(html).toContain('<meta name="description" content="Costs $$ and $` nothing">');
|
||||||
});
|
});
|
||||||
|
|
||||||
it("serves the plain shell when the lookup fails", async () => {
|
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
|
||||||
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
|
|
||||||
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
await expect(response.text()).resolves.toBe(shell);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("caches versioned homepage media immutably", async () => {
|
|
||||||
const headers = new Headers();
|
|
||||||
|
|
||||||
await staticOptions?.onFound?.("", {
|
|
||||||
req: { path: "/videos/timelapse-v1.mp4" },
|
|
||||||
header: (name, value) => headers.set(name, value),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
|
||||||
|
|
||||||
const unversionedHeaders = new Headers();
|
|
||||||
await staticOptions?.onFound?.("", {
|
|
||||||
req: { path: "/videos/timelapse.mp4" },
|
|
||||||
header: (name, value) => unversionedHeaders.set(name, value),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
|
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
|
||||||
@@ -232,72 +180,9 @@ describe("web app fallback classification", () => {
|
|||||||
expect(await response.text()).toBe("<html>app</html>");
|
expect(await response.text()).toBe("<html>app</html>");
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
it("serves noindex shell for public resume shaped routes", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(200);
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
|
||||||
expect(await response.text()).toBe("<html>app</html>");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns noindex 404 for unknown non-asset routes", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/unknown/extra/path"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
|
||||||
expect(await response.text()).toBe("Not Found");
|
|
||||||
expect(fs.readFile).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
|
|
||||||
"does not treat reserved two-segment path %s as a public resume",
|
|
||||||
async (pathname) => {
|
|
||||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
|
||||||
expect(await response.text()).toBe("Not Found");
|
|
||||||
expect(fs.readFile).not.toHaveBeenCalled();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
it("returns plain 404 for missing asset-looking paths", async () => {
|
|
||||||
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
|
||||||
|
|
||||||
expect(response.status).toBe(404);
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
|
||||||
expect(await response.text()).toBe("Not Found");
|
|
||||||
expect(fs.readFile).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
|
||||||
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
|
|
||||||
const unknownResponse = await handleWebApp(
|
|
||||||
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(knownResponse.status).toBe(200);
|
|
||||||
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
|
||||||
expect(knownResponse.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
|
||||||
expect(await knownResponse.text()).toBe("");
|
|
||||||
|
|
||||||
expect(unknownResponse.status).toBe(404);
|
|
||||||
expect(unknownResponse.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
|
||||||
expect(unknownResponse.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
|
||||||
expect(await unknownResponse.text()).toBe("");
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("configured root shell", () => {
|
describe("configured root shell", () => {
|
||||||
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
|
|
||||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
|
||||||
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
|
|
||||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
|
||||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
|
||||||
});
|
|
||||||
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
|
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
|
||||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
||||||
vi.mocked(fs.readFile).mockResolvedValue(
|
vi.mocked(fs.readFile).mockResolvedValue(
|
||||||
|
|||||||
+213
-134
@@ -1,8 +1,11 @@
|
|||||||
|
import type { Locale } from "@reactive-resume/utils/locale";
|
||||||
import { existsSync } from "node:fs";
|
import { existsSync } from "node:fs";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
import { serveStatic } from "@hono/node-server/serve-static";
|
import { serveStatic } from "@hono/node-server/serve-static";
|
||||||
import { env } from "@reactive-resume/env/server";
|
import { env } from "@reactive-resume/env/server";
|
||||||
|
import { templateSchema } from "@reactive-resume/schema/templates";
|
||||||
|
import { defaultLocale, getLocaleAlternates, isLocale, localizedUrl } from "@reactive-resume/utils/locale";
|
||||||
|
|
||||||
function resolveWebDistPath() {
|
function resolveWebDistPath() {
|
||||||
const candidates = [
|
const candidates = [
|
||||||
@@ -19,6 +22,9 @@ function resolveWebDistPath() {
|
|||||||
|
|
||||||
const staticRoot = resolveWebDistPath();
|
const staticRoot = resolveWebDistPath();
|
||||||
const indexHtmlPath = `${staticRoot}/index.html`;
|
const indexHtmlPath = `${staticRoot}/index.html`;
|
||||||
|
// The marketing pages prerendered per locale by the web build (apps/web/vite.config.ts), as <page>/<locale>.html, kept
|
||||||
|
// beside dist/ so they're never served at an address of their own.
|
||||||
|
const prerenderRoot = `${staticRoot}-prerender`;
|
||||||
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
const noindexShellPrefixes = ["/auth", "/dashboard", "/builder", "/agent", "/templates"];
|
||||||
/**
|
/**
|
||||||
* Marketing pages the SPA owns that search engines should index.
|
* Marketing pages the SPA owns that search engines should index.
|
||||||
@@ -63,144 +69,148 @@ const BASE_SECURITY_HEADERS = {
|
|||||||
"X-Frame-Options": "DENY",
|
"X-Frame-Options": "DENY",
|
||||||
"X-Content-Type-Options": "nosniff",
|
"X-Content-Type-Options": "nosniff",
|
||||||
"Referrer-Policy": "strict-origin-when-cross-origin",
|
"Referrer-Policy": "strict-origin-when-cross-origin",
|
||||||
|
// `wasm-unsafe-eval` lets the PDF engine (Forme, WebAssembly) start in the browser.
|
||||||
"Content-Security-Policy-Report-Only":
|
"Content-Security-Policy-Report-Only":
|
||||||
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
|
||||||
|
// The English copy, for a build without prerendered pages; a prerendered page carries its own locale's title and
|
||||||
|
// description, and the social cards reuse them.
|
||||||
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
||||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
|
||||||
const ROOT_DESCRIPTION =
|
const ROOT_DESCRIPTION =
|
||||||
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
|
"Free, open-source resume builder. Create, update, and share your resume, with PDF and Word downloads, no ads and no paywall.";
|
||||||
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
|
const ATS_CHECKER_TITLE = "Free ATS resume checker — Reactive Resume";
|
||||||
const ROOT_FAQ_ITEMS = [
|
const ATS_CHECKER_DESCRIPTION =
|
||||||
{
|
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
|
||||||
question: "Is Reactive Resume really free?",
|
|
||||||
answer:
|
|
||||||
"Yes. Reactive Resume is free to use, with no hidden costs, premium tiers, or subscription fees. It's open source, and it will stay free.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How is my data protected?",
|
|
||||||
answer:
|
|
||||||
"Your data is stored securely and never shared with third parties. If you want full control over it, you can self-host Reactive Resume on your own servers.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Can I export my resume to PDF?",
|
|
||||||
answer: "Yes. One click exports your resume to PDF, with your formatting and styling intact.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "Is Reactive Resume available in multiple languages?",
|
|
||||||
answer:
|
|
||||||
"Yes. Pick your language on the settings page, or with the language switcher in the top right corner. If your language is missing, or the existing translation could be better, you can contribute to the translations on Crowdin.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "What makes Reactive Resume different from other resume builders?",
|
|
||||||
answer:
|
|
||||||
"Reactive Resume is open source, private, and free. It shows no ads, doesn't track what you do, and doesn't lock features behind a paywall.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
question: "How do I share my resume?",
|
|
||||||
answer: "Share it with a public URL, put a password on that URL, or download the PDF and send it yourself.",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
function createRootSeoMarkup(canonicalUrl: string) {
|
type StructuredData = Record<string, unknown>;
|
||||||
const origin = new URL(canonicalUrl).origin;
|
|
||||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
/** Who makes Reactive Resume, referenced by id from every page's structured data. */
|
||||||
const structuredData = {
|
function organization(origin: string): StructuredData {
|
||||||
|
return {
|
||||||
|
"@type": "Organization",
|
||||||
|
"@id": `${origin}/#organization`,
|
||||||
|
name: "Reactive Resume",
|
||||||
|
url: `${origin}/`,
|
||||||
|
contactPoint: { "@type": "ContactPoint", contactType: "support", email: "hello@amruthpillai.com" },
|
||||||
|
logo: { "@type": "ImageObject", url: `${origin}/pwa-512x512.png`, width: 512, height: 512 },
|
||||||
|
sameAs: [
|
||||||
|
githubUrl,
|
||||||
|
"https://www.linkedin.com/company/reactive-resume",
|
||||||
|
"https://opencollective.com/reactive-resume",
|
||||||
|
"https://www.reddit.com/r/reactiveresume",
|
||||||
|
"https://discord.gg/aSyA5ZSxpb",
|
||||||
|
"https://crowdin.com/project/reactive-resume",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function homepageStructuredData(origin: string): StructuredData {
|
||||||
|
const rootUrl = `${origin}/`;
|
||||||
|
return {
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@graph": [
|
"@graph": [
|
||||||
|
organization(origin),
|
||||||
{
|
{
|
||||||
"@type": "WebSite",
|
"@type": "WebSite",
|
||||||
|
"@id": `${origin}/#website`,
|
||||||
name: "Reactive Resume",
|
name: "Reactive Resume",
|
||||||
url: canonicalUrl,
|
url: rootUrl,
|
||||||
|
publisher: { "@id": `${origin}/#organization` },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"@type": ["SoftwareApplication", "WebApplication"],
|
"@type": ["SoftwareApplication", "WebApplication"],
|
||||||
name: "Reactive Resume",
|
name: "Reactive Resume",
|
||||||
url: canonicalUrl,
|
url: rootUrl,
|
||||||
description: ROOT_DESCRIPTION,
|
description: ROOT_DESCRIPTION,
|
||||||
applicationCategory: "BusinessApplication",
|
applicationCategory: "BusinessApplication",
|
||||||
operatingSystem: "Web",
|
operatingSystem: "Web",
|
||||||
isAccessibleForFree: true,
|
isAccessibleForFree: true,
|
||||||
offers: {
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||||
"@type": "Offer",
|
license: `${githubUrl}/blob/main/LICENSE`,
|
||||||
price: "0",
|
codeRepository: githubUrl,
|
||||||
priceCurrency: "USD",
|
publisher: { "@id": `${origin}/#organization` },
|
||||||
},
|
featureList: [
|
||||||
codeRepository: "https://github.com/reactive-resume/reactive-resume",
|
"Resume editor with a live page preview",
|
||||||
},
|
`${templateSchema.options.length} templates`,
|
||||||
{
|
"PDF, Word (DOCX), Markdown and JSON export",
|
||||||
"@type": "Project",
|
"Import from PDF, LinkedIn, JSON Resume and Word",
|
||||||
name: "Reactive Resume",
|
"ATS readability checker",
|
||||||
url: canonicalUrl,
|
"Public or password-protected sharing links",
|
||||||
sameAs: ["https://github.com/reactive-resume/reactive-resume"],
|
"Cover letters and a job application tracker",
|
||||||
},
|
"Optional AI assistant with your own API key",
|
||||||
{
|
"Self-hosting with Docker",
|
||||||
"@type": "FAQPage",
|
],
|
||||||
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
|
|
||||||
"@type": "Question",
|
|
||||||
name: item.question,
|
|
||||||
acceptedAnswer: {
|
|
||||||
"@type": "Answer",
|
|
||||||
text: item.answer,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function atsCheckerStructuredData(origin: string): StructuredData {
|
||||||
|
return {
|
||||||
|
"@context": "https://schema.org",
|
||||||
|
"@graph": [
|
||||||
|
organization(origin),
|
||||||
|
{
|
||||||
|
"@type": "WebApplication",
|
||||||
|
name: "ATS Checker",
|
||||||
|
url: `${origin}/ats-checker`,
|
||||||
|
description: ATS_CHECKER_DESCRIPTION,
|
||||||
|
applicationCategory: "BusinessApplication",
|
||||||
|
operatingSystem: "Web",
|
||||||
|
isAccessibleForFree: true,
|
||||||
|
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||||
|
isPartOf: { "@type": "WebSite", "@id": `${origin}/#website`, name: "Reactive Resume", url: `${origin}/` },
|
||||||
|
provider: { "@id": `${origin}/#organization` },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageSeoOptions = {
|
||||||
|
/** The page's plain address: the canonical for the default locale, and the base of its hreflang alternates. */
|
||||||
|
canonicalUrl: string;
|
||||||
|
locale: Locale;
|
||||||
|
/** A `?locale=` request is canonical for its own language. */
|
||||||
|
requested: boolean;
|
||||||
|
/** Already HTML-escaped, as the page's own <title> and description are. */
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
imageUrl: string;
|
||||||
|
structuredData: StructuredData;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPageSeoMarkup(options: PageSeoOptions) {
|
||||||
|
const pageUrl = options.requested ? localizedUrl(options.canonicalUrl, options.locale) : options.canonicalUrl;
|
||||||
|
const alternates = getLocaleAlternates(options.canonicalUrl)
|
||||||
|
.map(({ hreflang, href }) => `<link rel="alternate" hreflang="${hreflang}" href="${escapeAttribute(href)}">`)
|
||||||
|
.join("");
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<link rel="canonical" href="${canonicalUrl}">
|
<link rel="canonical" href="${escapeAttribute(pageUrl)}">
|
||||||
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
|
${alternates}
|
||||||
<meta property="og:type" content="website">
|
<meta property="og:type" content="website">
|
||||||
<meta property="og:site_name" content="Reactive Resume">
|
<meta property="og:site_name" content="Reactive Resume">
|
||||||
<meta property="og:title" content="${ROOT_TITLE}">
|
<meta property="og:locale" content="${options.locale.replace("-", "_")}">
|
||||||
<meta property="og:description" content="${ROOT_DESCRIPTION}">
|
<meta property="og:title" content="${options.title}">
|
||||||
<meta property="og:url" content="${canonicalUrl}">
|
<meta property="og:description" content="${options.description}">
|
||||||
<meta property="og:image" content="${imageUrl}">
|
<meta property="og:url" content="${escapeAttribute(pageUrl)}">
|
||||||
|
<meta property="og:image" content="${options.imageUrl}">
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
<meta name="twitter:card" content="summary_large_image">
|
||||||
<meta name="twitter:title" content="${ROOT_TITLE}">
|
<meta name="twitter:title" content="${options.title}">
|
||||||
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
|
<meta name="twitter:description" content="${options.description}">
|
||||||
<meta name="twitter:image" content="${imageUrl}">
|
<meta name="twitter:image" content="${options.imageUrl}">
|
||||||
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
<script type="application/ld+json">${JSON.stringify(options.structuredData)}</script>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
|
/** The title and description a page was built with, still HTML-escaped. */
|
||||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
function readPageMeta(html: string, fallback: { title: string; description: string }) {
|
||||||
const ATS_CHECKER_DESCRIPTION =
|
return {
|
||||||
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
|
title: html.match(/<title>([^<]*)<\/title>/)?.[1] ?? fallback.title,
|
||||||
|
description: html.match(/<meta\s+name="description"\s+content="([^"]*)"/)?.[1] ?? fallback.description,
|
||||||
function createAtsCheckerSeoMarkup(origin: string) {
|
|
||||||
const canonicalUrl = `${origin}/ats-checker`;
|
|
||||||
const imageUrl = `${origin}/opengraph/ats-checker.png`;
|
|
||||||
const structuredData = {
|
|
||||||
"@context": "https://schema.org",
|
|
||||||
"@type": "WebApplication",
|
|
||||||
name: "ATS Checker",
|
|
||||||
url: canonicalUrl,
|
|
||||||
description: ATS_CHECKER_DESCRIPTION,
|
|
||||||
applicationCategory: "BusinessApplication",
|
|
||||||
operatingSystem: "Web",
|
|
||||||
isAccessibleForFree: true,
|
|
||||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
|
||||||
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return `
|
|
||||||
<link rel="canonical" href="${canonicalUrl}">
|
|
||||||
<meta property="og:type" content="website">
|
|
||||||
<meta property="og:site_name" content="Reactive Resume">
|
|
||||||
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
|
|
||||||
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
|
|
||||||
<meta property="og:url" content="${canonicalUrl}">
|
|
||||||
<meta property="og:image" content="${imageUrl}">
|
|
||||||
<meta name="twitter:card" content="summary_large_image">
|
|
||||||
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
|
|
||||||
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
|
|
||||||
<meta name="twitter:image" content="${imageUrl}">
|
|
||||||
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
|
||||||
`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
||||||
@@ -248,17 +258,19 @@ async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const serveWebDistStatic = serveStatic({
|
export const serveWebDistStatic = env.CLOUDFLARE
|
||||||
root: staticRoot,
|
? undefined
|
||||||
onFound: (_path, context) => {
|
: serveStatic({
|
||||||
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
root: staticRoot,
|
||||||
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
onFound: (_path, context) => {
|
||||||
}
|
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
||||||
},
|
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||||
});
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
function getFallbackResponseHeaders(pathname: string) {
|
function getFallbackResponseHeaders(pathname: string) {
|
||||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
||||||
return {
|
return {
|
||||||
"Content-Type": "text/html; charset=UTF-8",
|
"Content-Type": "text/html; charset=UTF-8",
|
||||||
"X-Robots-Tag": "noindex, follow",
|
"X-Robots-Tag": "noindex, follow",
|
||||||
@@ -280,6 +292,18 @@ function getFallbackResponseHeaders(pathname: string) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A prerendered page's language: a `?locale=` address first (its hreflang alternates), then the visitor's saved
|
||||||
|
* choice, in the order the app reads them (apps/web/src/libs/locale.ts).
|
||||||
|
*/
|
||||||
|
function getPageLocale(request: Request) {
|
||||||
|
const requested = new URL(request.url).searchParams.get("locale");
|
||||||
|
if (isLocale(requested)) return { locale: requested, requested: true };
|
||||||
|
|
||||||
|
const saved = request.headers.get("cookie")?.match(/(?:^|;\s*)locale=([^;]*)/)?.[1] ?? "";
|
||||||
|
return { locale: isLocale(saved) ? saved : defaultLocale, requested: false };
|
||||||
|
}
|
||||||
|
|
||||||
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||||
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
||||||
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||||
@@ -290,8 +314,43 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const withMeta = (html: string, meta: { title: string; description: string }) =>
|
||||||
|
html
|
||||||
|
.replace(/<title>[^<]*<\/title>/, `<title>${meta.title}</title>`)
|
||||||
|
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${meta.description}">`);
|
||||||
|
|
||||||
|
/** The indexable pages the web build prerenders, by path. */
|
||||||
|
const prerenderedPages: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
name: string;
|
||||||
|
meta: { title: string; description: string };
|
||||||
|
image: string;
|
||||||
|
structuredData: (origin: string) => StructuredData;
|
||||||
|
/** The page when the build has no prerendered copy: the app shell, titled for the page. */
|
||||||
|
fallback: (shell: string) => string;
|
||||||
|
}
|
||||||
|
> = {
|
||||||
|
"/": {
|
||||||
|
name: "home",
|
||||||
|
meta: { title: ROOT_TITLE, description: ROOT_DESCRIPTION },
|
||||||
|
image: "/opengraph/banner.jpg",
|
||||||
|
structuredData: homepageStructuredData,
|
||||||
|
fallback: (shell) => shell,
|
||||||
|
},
|
||||||
|
"/ats-checker": {
|
||||||
|
name: "ats-checker",
|
||||||
|
meta: { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION },
|
||||||
|
image: "/opengraph/ats-checker.png",
|
||||||
|
structuredData: atsCheckerStructuredData,
|
||||||
|
fallback: (shell) => withMeta(shell, { title: ATS_CHECKER_TITLE, description: ATS_CHECKER_DESCRIPTION }),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// ponytail: GET and HEAD share the same routing logic; method determines body presence
|
// ponytail: GET and HEAD share the same routing logic; method determines body presence
|
||||||
export async function handleWebApp(request: Request) {
|
export type ReadWebFile = (path: string) => Promise<string>;
|
||||||
|
|
||||||
|
export async function handleWebApp(request: Request, readFile: ReadWebFile = (path) => fs.readFile(path, "utf-8")) {
|
||||||
const isHead = request.method === "HEAD";
|
const isHead = request.method === "HEAD";
|
||||||
const pathname = new URL(request.url).pathname;
|
const pathname = new URL(request.url).pathname;
|
||||||
|
|
||||||
@@ -304,42 +363,62 @@ export async function handleWebApp(request: Request) {
|
|||||||
|
|
||||||
if (isHead) return new Response(null, { status: 200, headers });
|
if (isHead) return new Response(null, { status: 200, headers });
|
||||||
|
|
||||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
const html = await readFile(indexHtmlPath);
|
||||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
|
||||||
|
|
||||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
||||||
|
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||||
// Root configuration never discloses a target in the HTML shell. The public API
|
// Root configuration never discloses a target in the HTML shell. The public API
|
||||||
// gates data and browser metadata; shell requests must not count extra views.
|
// gates data and browser metadata; shell requests must not count extra views.
|
||||||
const shell = html
|
const shell = html
|
||||||
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
||||||
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
|
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
|
||||||
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
|
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
|
||||||
return new Response(shell.replace("</head>", `${markup}</head>`), { headers });
|
return new Response(
|
||||||
|
shell.replace("</head>", () => `${markup}</head>`),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname === "/") {
|
const prerendered = prerenderedPages[pathname];
|
||||||
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
|
if (prerendered) {
|
||||||
}
|
const { locale, requested } = getPageLocale(request);
|
||||||
|
|
||||||
if (pathname === "/ats-checker") {
|
|
||||||
const origin = new URL(env.APP_URL).origin;
|
const origin = new URL(env.APP_URL).origin;
|
||||||
const withTitle = html
|
// Without a prerendered page (a build that skipped it), the app renders the page in the browser.
|
||||||
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
|
const page = await readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`).catch(() =>
|
||||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
|
prerendered.fallback(html),
|
||||||
|
);
|
||||||
|
const markup = createPageSeoMarkup({
|
||||||
|
canonicalUrl: new URL(pathname, env.APP_URL).toString(),
|
||||||
|
locale,
|
||||||
|
requested,
|
||||||
|
...readPageMeta(page, prerendered.meta),
|
||||||
|
imageUrl: `${origin}${prerendered.image}`,
|
||||||
|
structuredData: prerendered.structuredData(origin),
|
||||||
|
});
|
||||||
|
|
||||||
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
|
// The saved locale changes what this address shows, so caches have to key on the cookie.
|
||||||
|
return new Response(
|
||||||
|
page.replace("</head>", () => `${markup}</head>`),
|
||||||
|
{ headers: { ...headers, Vary: "Cookie" } },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isPublicResumePath(pathname)) {
|
if (isPublicResumePath(pathname)) {
|
||||||
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
|
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
|
||||||
if (resumeSeo) {
|
if (resumeSeo) {
|
||||||
// The shell's generic title/description are replaced so shares and previews show the resume,
|
// The shell's generic title/description are replaced so shares and previews show the resume,
|
||||||
// not the marketing copy baked into index.html.
|
// not the marketing copy baked into index.html. Function replacers keep `$&`, `$'` etc. in user text literal.
|
||||||
const withTitle = html
|
const withTitle = html
|
||||||
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
|
.replace(/<title>[^<]*<\/title>/, () => `<title>${resumeSeo.pageTitle}</title>`)
|
||||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
|
.replace(
|
||||||
|
/<meta\s+name="description"[^>]*>/,
|
||||||
|
() => `<meta name="description" content="${resumeSeo.description}">`,
|
||||||
|
);
|
||||||
|
|
||||||
return new Response(withTitle.replace("</head>", `${resumeSeo.markup}</head>`), { headers });
|
return new Response(
|
||||||
|
withTitle.replace("</head>", () => `${resumeSeo.markup}</head>`),
|
||||||
|
{ headers },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,12 +9,11 @@ const mocks = vi.hoisted(() => ({
|
|||||||
configureAgentStreamLifetime: vi.fn(),
|
configureAgentStreamLifetime: vi.fn(),
|
||||||
pool: {},
|
pool: {},
|
||||||
getPool: vi.fn(),
|
getPool: vi.fn(),
|
||||||
createApp:
|
createApp: vi.fn<
|
||||||
vi.fn<
|
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
|
||||||
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
|
fetch: (request: Request) => Promise<Response>;
|
||||||
fetch: (request: Request) => Promise<Response>;
|
}
|
||||||
}
|
>(),
|
||||||
>(),
|
|
||||||
handle: vi.fn<(request: Request) => Promise<Response>>(),
|
handle: vi.fn<(request: Request) => Promise<Response>>(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,20 @@ const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", im
|
|||||||
version?: string;
|
version?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Lambda disables require(ESM) and uses stricter CJS export detection than standalone Node.
|
// Lambda disables require(ESM) and uses stricter CJS export detection than standalone Node. Vercel's service builder
|
||||||
|
// also loads external CommonJS packages through pnpm links it leaves out of the Function, so CommonJS dependencies
|
||||||
|
// (ioredis, react-reconciler) are bundled together with their own dependencies.
|
||||||
const bundledInteropPackages = new Set([
|
const bundledInteropPackages = new Set([
|
||||||
|
"ioredis",
|
||||||
|
"@ioredis/commands",
|
||||||
|
"cluster-key-slot",
|
||||||
|
"debug",
|
||||||
|
"ms",
|
||||||
|
"denque",
|
||||||
|
"redis-errors",
|
||||||
|
"standard-as-callback",
|
||||||
|
"react-reconciler",
|
||||||
|
"scheduler",
|
||||||
"@uiw/color-convert",
|
"@uiw/color-convert",
|
||||||
"@babel/runtime",
|
"@babel/runtime",
|
||||||
"sanitize-html",
|
"sanitize-html",
|
||||||
@@ -29,15 +41,24 @@ const bundledInteropPackages = new Set([
|
|||||||
"source-map-js",
|
"source-map-js",
|
||||||
"launder",
|
"launder",
|
||||||
"dayjs",
|
"dayjs",
|
||||||
|
"wink-porter2-stemmer",
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const shouldExternalizeThirdParty = (id: string) => {
|
const packageNameOf = (id: string) =>
|
||||||
const packageName = id
|
id
|
||||||
.split("/")
|
.split("/")
|
||||||
.slice(0, id.startsWith("@") ? 2 : 1)
|
.slice(0, id.startsWith("@") ? 2 : 1)
|
||||||
.join("/");
|
.join("/");
|
||||||
if (id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageName)) return false;
|
|
||||||
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
|
// Matches subpath imports too, such as `react-reconciler/constants.js`.
|
||||||
|
const shouldBundle = (id: string) =>
|
||||||
|
id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageNameOf(id));
|
||||||
|
|
||||||
|
const shouldExternalizeThirdParty = (id: string) => {
|
||||||
|
if (shouldBundle(id)) return false;
|
||||||
|
// Subpath imports (`#…`) are resolved by the workspace package that declares them, so they're bundled with it.
|
||||||
|
if (id.startsWith("@/") || id.startsWith("#") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0"))
|
||||||
|
return false;
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
};
|
};
|
||||||
@@ -60,7 +81,12 @@ const promptAssetsPlugin: TsdownPlugin = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
entry: { index: "src/index.ts", vercel: "src/vercel.ts", "prepare-deployment": "src/prepare-deployment.ts" },
|
entry: {
|
||||||
|
index: "src/index.ts",
|
||||||
|
vercel: "src/vercel.ts",
|
||||||
|
"prepare-deployment": "src/prepare-deployment.ts",
|
||||||
|
"migrate-legacy-styles": "src/migrate-legacy-styles.ts",
|
||||||
|
},
|
||||||
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
|
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
|
||||||
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
|
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
|
||||||
format: "esm",
|
format: "esm",
|
||||||
@@ -76,7 +102,7 @@ export default defineConfig({
|
|||||||
suppressWarnings: [/dynamic import will not move module into another chunk/],
|
suppressWarnings: [/dynamic import will not move module into another chunk/],
|
||||||
outExtensions: () => ({ js: ".mjs" }),
|
outExtensions: () => ({ js: ".mjs" }),
|
||||||
deps: {
|
deps: {
|
||||||
alwaysBundle: [/^@reactive-resume\//, ...bundledInteropPackages],
|
alwaysBundle: shouldBundle,
|
||||||
neverBundle: shouldExternalizeThirdParty,
|
neverBundle: shouldExternalizeThirdParty,
|
||||||
},
|
},
|
||||||
plugins: [promptAssetsPlugin],
|
plugins: [promptAssetsPlugin],
|
||||||
|
|||||||
+10
-4
@@ -2,12 +2,18 @@
|
|||||||
"extends": ["//"],
|
"extends": ["//"],
|
||||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||||
"tasks": {
|
"tasks": {
|
||||||
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
"test": {
|
||||||
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||||
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
},
|
||||||
|
"test:coverage": {
|
||||||
|
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||||
|
},
|
||||||
|
"test:agent": {
|
||||||
|
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||||
|
},
|
||||||
"test:ci": {
|
"test:ci": {
|
||||||
"cache": false,
|
"cache": false,
|
||||||
"env": ["OAUTH_TEST_DATABASE_URL"]
|
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// Vercel service entrypoint. It must exist before the build, so it re-exports the adapter that tsdown emits.
|
||||||
|
export { default } from "./dist/vercel.mjs";
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"buildPath": "code"
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"files": ["index.html"],
|
||||||
|
"insertBefore": "</body>",
|
||||||
|
"commentSyntax": "html",
|
||||||
|
"cspChecked": true
|
||||||
|
}
|
||||||
@@ -0,0 +1,294 @@
|
|||||||
|
---
|
||||||
|
version: 6.0.0
|
||||||
|
name: Reactive Resume · Desk & Paper
|
||||||
|
description: A warm, quiet interface around bright paper. Moss green marks primary actions, selection, and progress. Light and dark themes keep document paper white.
|
||||||
|
colors:
|
||||||
|
light:
|
||||||
|
bg: "#F8F7F3"
|
||||||
|
surface: "#FEFDFC"
|
||||||
|
raised: "#FFFFFF"
|
||||||
|
sunken: "#F0EFEB"
|
||||||
|
line: "#DFDEDA"
|
||||||
|
line-2: "#C5C4BE"
|
||||||
|
ink: "#1C1B15"
|
||||||
|
ink-2: "#4F4D47"
|
||||||
|
ink-3: "#6D6C65"
|
||||||
|
accent: "#337344"
|
||||||
|
accent-hover: "#206133"
|
||||||
|
on-accent: "#F7FEF8"
|
||||||
|
accent-soft: "#DCF2DF"
|
||||||
|
accent-text: "#195C2E"
|
||||||
|
danger: "#BA3630"
|
||||||
|
danger-soft: "#FFE7E4"
|
||||||
|
danger-text: "#A92321"
|
||||||
|
warn: "#D29922"
|
||||||
|
warn-soft: "#FCEDCD"
|
||||||
|
warn-text: "#81520A"
|
||||||
|
info-soft: "#E0F1FF"
|
||||||
|
info-text: "#1D5B92"
|
||||||
|
dark:
|
||||||
|
bg: "#100F0C"
|
||||||
|
surface: "#171613"
|
||||||
|
raised: "#1F1E1A"
|
||||||
|
sunken: "#0B0A08"
|
||||||
|
line: "#2C2B27"
|
||||||
|
line-2: "#494843"
|
||||||
|
ink: "#EFEEEB"
|
||||||
|
ink-2: "#BCBAB5"
|
||||||
|
ink-3: "#979590"
|
||||||
|
accent: "#6FC082"
|
||||||
|
accent-hover: "#83D494"
|
||||||
|
on-accent: "#07150A"
|
||||||
|
accent-soft: "#1A3520"
|
||||||
|
accent-text: "#8FD89E"
|
||||||
|
danger: "#D9544B"
|
||||||
|
danger-soft: "#47211D"
|
||||||
|
danger-text: "#FDA297"
|
||||||
|
warn: "#E4B750"
|
||||||
|
warn-soft: "#3E2D10"
|
||||||
|
warn-text: "#EFCC83"
|
||||||
|
info-soft: "#192F46"
|
||||||
|
info-text: "#9DC9F7"
|
||||||
|
paper: "#FFFFFF"
|
||||||
|
stages:
|
||||||
|
saved: "#908C7F"
|
||||||
|
applied: "#5590CC"
|
||||||
|
screening: "#00A0A6"
|
||||||
|
interview: "#AF8433"
|
||||||
|
offer: "#579F68"
|
||||||
|
closed: "#C67067"
|
||||||
|
typography:
|
||||||
|
display: { fontFamily: Newsreader, fontSize: 44px, lineHeight: 48px, fontWeight: 500, letterSpacing: -0.01em }
|
||||||
|
title: { fontFamily: Newsreader, fontSize: 30px, lineHeight: 36px, fontWeight: 500 }
|
||||||
|
sheet-title: { fontFamily: Newsreader, fontSize: 22px, lineHeight: 28px, fontWeight: 500 }
|
||||||
|
heading: { fontFamily: Hanken Grotesk, fontSize: 20px, lineHeight: 28px, fontWeight: 600 }
|
||||||
|
section-heading: { fontFamily: Hanken Grotesk, fontSize: 17px, lineHeight: 24px, fontWeight: 600 }
|
||||||
|
label: { fontFamily: Hanken Grotesk, fontSize: 15px, lineHeight: 22px, fontWeight: 600 }
|
||||||
|
field-label: { fontFamily: Hanken Grotesk, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||||
|
body: { fontFamily: Hanken Grotesk, fontSize: 15px, lineHeight: 24px, fontWeight: 400 }
|
||||||
|
ui: { fontFamily: Hanken Grotesk, fontSize: 14px, lineHeight: 20px, fontWeight: 400 }
|
||||||
|
small: { fontFamily: Hanken Grotesk, fontSize: 13px, lineHeight: 18px, fontWeight: 400 }
|
||||||
|
caption: { fontFamily: Hanken Grotesk, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||||
|
mono: { fontFamily: JetBrains Mono, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||||
|
rounded:
|
||||||
|
sm: 6px
|
||||||
|
md: 8px
|
||||||
|
lg: 10px
|
||||||
|
xl: 12px
|
||||||
|
2xl: 16px
|
||||||
|
3xl: 18px
|
||||||
|
4xl: 24px
|
||||||
|
full: 999px
|
||||||
|
spacing: [4, 8, 12, 16, 24, 32, 48, 64]
|
||||||
|
motion:
|
||||||
|
quick: 120ms
|
||||||
|
standard: 200ms
|
||||||
|
emphasized: 320ms
|
||||||
|
easing: cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||||
|
exit: 70% of the entering duration
|
||||||
|
movement-easing: cubic-bezier(0.77, 0, 0.175, 1)
|
||||||
|
marketing:
|
||||||
|
typography:
|
||||||
|
display: { fontFamily: Anybody, fontWeight: "300–400", fontStretch: "86%–112%" }
|
||||||
|
write-title: { fontFamily: Anybody, fontSize: "clamp(72px, 9vw, 160px)", lineHeight: 0.9, fontWeight: 300 }
|
||||||
|
numeral:
|
||||||
|
{ fontFamily: Anybody, fontSize: "clamp(56px, 7.5vw, 136px)", fontWeight: 300, fontVariantNumeric: tabular-nums }
|
||||||
|
body: { fontFamily: Newsreader, fontSize: "16–21px", lineHeight: "1.45–1.5", fontWeight: 400 }
|
||||||
|
accent: { fontFamily: Newsreader, fontStyle: italic, color: accent-text }
|
||||||
|
label:
|
||||||
|
{ fontFamily: Martian Mono, fontSize: 11px, fontWeight: 500, letterSpacing: 0.08em, textTransform: uppercase }
|
||||||
|
wordmark: { fontFamily: Anybody, fontSize: 17.5cqw, lineHeight: 0.84, fontWeight: 800, fontStretch: 78% }
|
||||||
|
colors:
|
||||||
|
graphite: { light: "oklch(0.38 0.01 95 / .3)", dark: "oklch(0.9 0.01 95 / .18)" }
|
||||||
|
night-1: "oklch(0.24 0.03 265)"
|
||||||
|
night-2: "oklch(0.15 0.02 265)"
|
||||||
|
night-accent: "oklch(0.8 0.13 150)"
|
||||||
|
star: "oklch(0.72 0.14 80)"
|
||||||
|
receipt: "#FDFCF8"
|
||||||
|
bulb-glass: "oklch(0.95 0.11 92)"
|
||||||
|
bulb-filament: "oklch(0.7 0.16 60)"
|
||||||
|
shadow:
|
||||||
|
paper:
|
||||||
|
light: "0 1px 2px oklch(0.2 0.01 95 / 0.12), 0 40px 80px -30px oklch(0.2 0.01 95 / 0.5), 0 0 0 1px oklch(0.2 0.01 95 / 0.04)"
|
||||||
|
dark: "0 1px 2px oklch(0 0 0 / 0.5), 0 40px 80px -30px oklch(0 0 0 / 0.8)"
|
||||||
|
motion:
|
||||||
|
pull-easing: cubic-bezier(0.3, 1.7, 0.5, 1)
|
||||||
|
doodles: { opacity: { light: 0.72, dark: 0.42 }, darkFilter: "invert(1) brightness(1.1)" }
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Reactive Resume uses “Desk & Paper”: a warm, quiet interface around a bright resume or letter. Low-contrast surfaces, thin rules, and a moss-green accent keep attention on the document.
|
||||||
|
|
||||||
|
This reference covers the current app and homepage. Implementation details live in the source files named below; [REDESIGN_PLAN.md](REDESIGN_PLAN.md) records the redesign milestones and deviations from the original handoff.
|
||||||
|
|
||||||
|
Five principles guide decisions:
|
||||||
|
|
||||||
|
1. **The page is the interface.** Keep the live document visible while editing. Selecting a supported block opens its fields.
|
||||||
|
2. **One obvious next step.** Each app view has one primary action. Reserve accent fills for that action, selection, and progress.
|
||||||
|
3. **Nothing is lost.** Edits autosave, reversible changes offer undo, and confirmations are reserved for irreversible actions.
|
||||||
|
4. **Detail on demand.** Make defaults useful; put advanced controls one disclosure deeper.
|
||||||
|
5. **AI proposes, you decide.** Show AI edits as reviewable proposals before applying them.
|
||||||
|
|
||||||
|
Resume templates retain their Pokémon names, fonts, and palettes. App typography and control styling do not dictate template appearance.
|
||||||
|
|
||||||
|
## Tokens
|
||||||
|
|
||||||
|
`packages/ui/src/styles/globals.css` defines light tokens on `:root` and dark overrides on `.dark`. OKLCH values are authoritative; the front matter lists approximate sRGB equivalents. Tailwind exposes semantic utilities such as `bg-bg`, `bg-surface`, `border-line`, `text-ink`, and `bg-accent`.
|
||||||
|
|
||||||
|
- **Surfaces:** `bg` for the app desk; `surface` for panels and cards; `raised` for menus, dialogs, and inputs; `sunken` for wells, tracks, and the page canvas.
|
||||||
|
- **Text:** `ink` for primary text, `ink-2` for secondary text, and `ink-3` for metadata and placeholders. Use no lighter text token, and check contrast on tinted backgrounds.
|
||||||
|
- **Signals:** `danger` for errors and irreversible actions; `warn` for issues to review; `info-soft` and `info-text` for neutral guidance. Success uses `accent-soft` and `accent-text`.
|
||||||
|
- **Overlays:** `hover` and `press` are translucent interaction states; `scrim` and `scrim-sheet` dim the background behind layers.
|
||||||
|
- **Paper:** `--paper` remains white in both themes. Switching the app theme must not invert documents.
|
||||||
|
- **Stages:** `stage-saved`, `stage-applied`, `stage-screening`, `stage-interview`, `stage-offer`, and `stage-closed` identify application stages. Use small dots or stepper bars beside stage names.
|
||||||
|
|
||||||
|
Use semantic tokens in app code. Legacy names such as `background`, `foreground`, `primary`, `muted`, and `sidebar-*` are no longer defined.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
The front matter records the app type scale. Field labels use the separate `field-label` size.
|
||||||
|
|
||||||
|
- **Newsreader:** page, dialog, and sheet titles; empty-state headlines; large statistics. Use `font-display`.
|
||||||
|
- **Hanken Grotesk:** functional UI and body text. Use `font-sans` or `font-ui`.
|
||||||
|
- **JetBrains Mono:** shortcuts, URLs, slugs, filenames, counts, and section eyebrows. Use `font-mono`.
|
||||||
|
- Field labels are 12px, medium weight, in `ink-2`, with a 6px gap above the control. Group eyebrows are 12px, semibold, uppercase, in `ink-3`, with 0.02em tracking.
|
||||||
|
- Touch inputs use at least 16px text to prevent iOS zoom.
|
||||||
|
- Fonts are self-hosted through `@fontsource-variable`.
|
||||||
|
|
||||||
|
## Iconography
|
||||||
|
|
||||||
|
App icons use **Material Symbols Rounded**, weight 300, through `Icon` from `@reactive-resume/ui/components/icon`. The self-hosted subset is defined in `packages/ui/src/icons/names.ts`.
|
||||||
|
|
||||||
|
To add a glyph:
|
||||||
|
|
||||||
|
1. Add its name to `names.ts`.
|
||||||
|
2. Run `pnpm icons:build` to validate names and rebuild the subset and manifest.
|
||||||
|
|
||||||
|
Use 20px icons on desktop and 24px on touch interfaces. Outline is the default; reserve filled app icons for selected navigation. Marketing illustrations may use filled symbols, such as the GitHub star.
|
||||||
|
|
||||||
|
Pair icons with text. Back, close, more, undo/redo, history, assistant, and zoom controls may use `IconButton`, which requires an accessible label and supplies a tooltip with an optional shortcut. `Icon` is decorative (`aria-hidden`, `translate="no"`); CSS draws its glyph from `data-icon`, keeping the name out of text content. Directional arrows, chevrons, undo, and redo mirror in RTL layouts.
|
||||||
|
|
||||||
|
Icons inside resumes use Phosphor names stored in resume data; they remain separate from app icons.
|
||||||
|
|
||||||
|
## Space, shape, and elevation
|
||||||
|
|
||||||
|
- **Spacing:** use the 4px scale in the front matter. Cards typically have 16px padding, panels 16–24px, mobile pages 16px margins, and desktop pages 32–40px margins.
|
||||||
|
- **Radius:** 6px for chips and small buttons; 8px for inputs and controls; 10px for list items; 12px for cards and menus; 16px for dialogs; 18px for mobile sheets. Use `rounded-full` for pills; `rounded-4xl` is 24px where needed.
|
||||||
|
- **Elevation:** `shadow-e1` for cards; `shadow-e2` for menus and popovers; `shadow-e3` for dialogs, sheets, and toasts; `shadow-page` for editor paper.
|
||||||
|
- **Controls:** button heights are 28px (`sm`), 36px (`default`), and 44px (`lg`). Icon button sizes range from 28px to 44px. Inputs grow from 36px to 44px on touch devices; `touch-target` expands smaller controls' hit areas to at least 44×44px.
|
||||||
|
- **Layout variables:** `--editor-bar: 56px`, `--editor-panel: 400px`, `--app-sidebar: 240px`, `--sheet-share: 440px`, `--sheet-detail: 480px`, and `--assistant: 400px`.
|
||||||
|
|
||||||
|
## Motion
|
||||||
|
|
||||||
|
| Token | Duration | Use |
|
||||||
|
| --------------------- | -------- | ------------------------------------------------------ |
|
||||||
|
| `duration-quick` | 120ms | Hover, press, toggles, checkboxes, and focus |
|
||||||
|
| `duration-standard` | 200ms | Menus, popovers, expansion, content swaps, and dialogs |
|
||||||
|
| `duration-emphasized` | 320ms | Sheets, toasts, and the assistant column |
|
||||||
|
|
||||||
|
- Entering and changing state use `ease-enter` (`cubic-bezier(0.2, 0.8, 0.2, 1)`). Exits take 70% of the entry duration.
|
||||||
|
- Sliding indicators, reordering, and settling use `ease-in-out-strong` (`cubic-bezier(0.77, 0, 0.175, 1)`). Swipe-dismissed bottom sheets use `ease-drawer`.
|
||||||
|
- Keep app motion brief and purposeful. Small entrance fades, status transitions, and the mobile tab indicator's spring are supported. Loading placeholders stay still; document reflow is never animated. Keyboard mode switches are instant.
|
||||||
|
- Reduced motion sets duration tokens to 1ms and collapses CSS transitions and animations. Status spinners continue turning.
|
||||||
|
- `apps/web/src/libs/motion.ts` mirrors CSS timings. `MotionConfig reducedMotion="user"` and `followReducedMotion()` make Motion animations respect the preference.
|
||||||
|
|
||||||
|
The homepage has separate motion rules below.
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
Generic primitives live in `packages/ui/src/components`, using Base UI and cmdk for the command palette. Feature-specific UI belongs in its owning `apps/web` feature.
|
||||||
|
|
||||||
|
- **Buttons:** `primary`, `secondary`, `ghost`, `danger`, and `link`. `loading` adds a spinner, sets `aria-busy`, and blocks activation. Use a progress label such as “Preparing…”.
|
||||||
|
- **Inputs:** `raised` background and `line-2` border; focus adds an accent border and a 3px `accent-soft` ring. Invalid fields use danger styling. Show errors after blur or submission, with an icon and a specific remedy.
|
||||||
|
- **Switches:** prefer `SwitchRow` so the label is part of the target. Checkboxes are 18px with a 5px radius; radios are 18px with an 8px accent dot.
|
||||||
|
- **Segments and tabs:** use `SegmentedControl` for 2–4 options in a radio group. Use `Tabs` to switch panels; set `TabsList variant="line"` for underline tabs.
|
||||||
|
- **Menus:** 12px radius and 36px items. Size popups for their content and trigger; put destructive items last, after a separator.
|
||||||
|
- **Layers:** menus and popovers provide lightweight choices; sheets hold tasks beside the document and use the bottom variant on mobile; dialogs hold decisions. Use `AlertDialog` for destructive confirmation and name what cancel keeps.
|
||||||
|
- **Toasts:** one visible at a time, bottom center, with `bg-ink` and `text-bg`. The default timeout is six seconds; `timeout: 0` keeps a toast visible. Undo is an optional underlined action.
|
||||||
|
- **Alerts:** `info`, `success`, `warn`, and `error`; only the error variant adds `role="alert"` by default.
|
||||||
|
- **Empty states:** a 22px Newsreader headline, concise 14px body text, and a primary action. Add a secondary action only when useful.
|
||||||
|
|
||||||
|
## Accessibility
|
||||||
|
|
||||||
|
Target WCAG 2.2 AA:
|
||||||
|
|
||||||
|
- Show a 2px accent focus outline with a 2px gap on `:focus-visible`. Inputs use their border and soft ring instead.
|
||||||
|
- Provide at least 24px pointer targets and 44px touch targets. Every drag operation needs keyboard and menu alternatives.
|
||||||
|
- Pair color signals with text; add icons where they clarify status.
|
||||||
|
- Trap focus in modal sheets and dialogs. Escape closes the top layer; closing returns focus to its trigger.
|
||||||
|
- Announce save state and routine feedback politely. Reserve assertive announcements for errors that need immediate attention.
|
||||||
|
|
||||||
|
## Themes
|
||||||
|
|
||||||
|
The shared `theme` cookie stores `light`, `dark`, or `system` (the default). System mode follows `prefers-color-scheme` live. `ThemeProvider` manages the `.dark` class on `<html>`; an inline script in `apps/web/index.html` applies it before first paint. The homepage uses this same preference.
|
||||||
|
|
||||||
|
## Internationalization
|
||||||
|
|
||||||
|
- Translate user-facing text and accessible labels through Lingui (`t`, `msg`, or `<Trans>`). Catalogs live in `apps/web/locales`.
|
||||||
|
- UI primitives receive translated labels as props, such as `closeLabel`; they do not depend on Lingui.
|
||||||
|
- Supported locales come from `packages/utils/src/locale.ts`. The root route updates `<html lang>` and `<html dir>` and passes direction to Base UI's `DirectionProvider`.
|
||||||
|
- Prefer logical properties and utilities (`ps`, `pe`, `ms`, `me`, `start`, `end`, `inset-s`, `inset-e`).
|
||||||
|
- Allow 30–50% text expansion; avoid fixed widths for translated labels.
|
||||||
|
|
||||||
|
## Marketing site
|
||||||
|
|
||||||
|
The public homepage in `apps/web/src/features/homepage` shares app colors and themes but has its own typography, illustrations, and motion. `landing.css` defines its fonts, graphite color, paper shadows, and ambient animations. Other illustration colors in the front matter are scene values, not global app tokens.
|
||||||
|
|
||||||
|
### Brand and type
|
||||||
|
|
||||||
|
- **Header:** a 30px logomark from `apps/web/public/icon/{light,dark}.svg`. Keep “Reactive Resume” as visually hidden text inside the home link.
|
||||||
|
- **Footer:** a 140px logo from `apps/web/public/logo/{light,dark}.svg`, followed by community and MIT license copy.
|
||||||
|
- **Wordmark:** Anybody 800, at 17.5cqw and 78% width, with “Reactive” in `ink` and “Resume” in `accent-text`. A 23cqw container crops it through a gradient mask. It rises from 60% translation as the footer enters and is decorative (`aria-hidden`).
|
||||||
|
- **Anybody:** hero and closing headlines, selected scene titles, large numerals, and the wordmark. Use weights 300–400 for main display text; reserve 800 for the wordmark.
|
||||||
|
- **Newsreader:** body copy, italic accents, and serif title treatments in the Design scene. The app's `font-display` still maps to Newsreader; use `font-anybody` explicitly.
|
||||||
|
- **Martian Mono:** 11px uppercase labels at weight 500, with 0.08em tracking. Use `font-martian`.
|
||||||
|
- **Hanken Grotesk:** buttons and mock app UI. All four families are self-hosted.
|
||||||
|
|
||||||
|
### Controls
|
||||||
|
|
||||||
|
- Repeat the same primary CTA, “Build your resume,” in the header, hero, and closing section. Buttons are pills in Hanken Grotesk 600, at 38px, 54px, and 56px respectively.
|
||||||
|
- Scene navigation appears from 1240px. Martian Mono labels use `ink` when active and `ink-3` when inactive; a 6px accent dot marks the active scene. The Share night scene uses its own light inks.
|
||||||
|
- The GitHub link appears from 1024px, with the GitHub mark, a filled gold star, and a live compact count in Martian Mono. Format counts with the locale's `Intl.NumberFormat`; include the full count in the accessible name.
|
||||||
|
- The homepage theme control is a fixed 30px pull-cord button at the top end corner. Its height is 92px at rest, 112px on hover, and 124px during a pull. It uses a 450ms spring curve, toggles the shared theme after 170ms, and sways every seven seconds until first activated. The bulb glows in dark mode.
|
||||||
|
|
||||||
|
### Motion
|
||||||
|
|
||||||
|
Pinned scenes contain a sticky `100svh` stage. Scroll progress is `p = clamp(0, −top / max(1, height − viewportHeight), 1)`; `scroll.ts` writes it to `--p` through one animation-frame-throttled scroll listener. CSS derives continuous motion from progress; React receives only coarse scene and step changes.
|
||||||
|
|
||||||
|
| Scene | Below 900px | From 900px |
|
||||||
|
| ------ | ----------- | ---------- |
|
||||||
|
| Hero | 190vh | 260vh |
|
||||||
|
| Write | 280vh | 330vh |
|
||||||
|
| Design | 380vh | 440vh |
|
||||||
|
| Check | 260vh | 320vh |
|
||||||
|
| Tailor | 280vh | 330vh |
|
||||||
|
| Share | 240vh | 300vh |
|
||||||
|
|
||||||
|
Light mode combines breathing window light (16 seconds), a drifting mullion shadow (90 seconds), and 18 dust motes. Dark mode adds a neutral 620px cursor glow at 6% opacity. These are decorative and must not obscure text.
|
||||||
|
|
||||||
|
Reduced motion fixes scenes at their end state and collapses pinned sections to `100svh`. Disable scroll scrubbing, parallax, ambient movement, cord sway, and count animations. The Languages word rotation has a pause control and stays still with reduced motion.
|
||||||
|
|
||||||
|
### Illustration
|
||||||
|
|
||||||
|
- Ten graphite doodles live in `apps/web/public/doodles/` as WebP: pencil, paperclip, eraser, curve, magnifier, scissors, plane, globe, jar, and note.
|
||||||
|
- Doodles appear through a 110° mask wipe with subtle parallax. Default opacity is 0.72 in light mode and 0.42 in dark, with inversion and a slight brightness increase. The Share plane uses a brighter treatment against the night sky.
|
||||||
|
- Most doodles hide below 900px; pencil and plane remain. Keep them decorative, noninteractive, and clear of readable text.
|
||||||
|
- Construction guides use `graphite` lines with an SVG turbulence filter for a pencil effect.
|
||||||
|
|
||||||
|
### Content and delivery
|
||||||
|
|
||||||
|
- Prerender the homepage and public ATS checker per locale at build time through `prerender.tsx` and `apps/web/vite.config.ts`. The app is a client-rendered SPA; `apps/server/src/static/web.ts` serves the generated HTML and adds canonical, Open Graph, `hreflang`, and structured data. Keep headings and body copy in the initial HTML.
|
||||||
|
- Include title and description metadata and `SoftwareApplication` structured data with a zero-price offer.
|
||||||
|
- Use one `h1`, section headings, landmarks, and a skip link. Animated character treatments expose the complete string once to assistive technology. Nothing relies on hover alone.
|
||||||
|
- Translate copy, accessible labels, and demo resume text through Lingui. Names and addresses may stay literal; the Languages display intentionally preserves native words and language names. Allow text expansion and RTL layouts.
|
||||||
|
|
||||||
|
## Do and don't
|
||||||
|
|
||||||
|
- **Do** make the primary action obvious and reserve accent for actions and state.
|
||||||
|
- **Do** keep text readable and pair color signals with words.
|
||||||
|
- **Do** provide empty, loading, error, and success states; keep loading placeholders at their final size.
|
||||||
|
- **Don't** introduce arbitrary app colors or palette classes such as `amber-600`; use semantic tokens.
|
||||||
|
- **Don't** use Newsreader for small functional app text. Homepage prose follows its separate type rules.
|
||||||
|
- **Don't** confirm reversible actions; offer undo.
|
||||||
|
- **Don't** omit `data-slot` on UI primitives; styles and tests rely on it.
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Reactive Resume: Web Product
|
||||||
|
|
||||||
|
<!-- impeccable:product-schema 1 -->
|
||||||
|
|
||||||
|
## Platform
|
||||||
|
|
||||||
|
web
|
||||||
|
|
||||||
|
## Users
|
||||||
|
|
||||||
|
Job seekers preparing and tailoring resumes and cover letters, sharing application documents, and tracking their job search.
|
||||||
|
|
||||||
|
## Product Purpose
|
||||||
|
|
||||||
|
Help people turn their experience into professional application documents and manage their applications. Success means creating and tailoring a resume or cover letter, exporting or sharing it, and keeping track of applications.
|
||||||
|
|
||||||
|
## Positioning
|
||||||
|
|
||||||
|
Reactive Resume is a free, open-source resume builder with privacy and data ownership as durable commitments. Users can run the application on their own infrastructure. Document editing, reviewable AI assistance, and application tracking belong to the same workflow.
|
||||||
|
|
||||||
|
## Operating Context
|
||||||
|
|
||||||
|
- Browser-based use on desktop and mobile; mobile web remains the same product.
|
||||||
|
- Create or import a resume, edit with a live document preview, choose a template, and export or share it.
|
||||||
|
- Tailor resumes and cover letters for individual applications and track progress through the job search.
|
||||||
|
- Use optional AI assistance to propose changes; users review proposals before applying them.
|
||||||
|
|
||||||
|
## Capabilities and Constraints
|
||||||
|
|
||||||
|
- Resume creation, importing, template customization, PDF/JSON/DOCX export, and public sharing.
|
||||||
|
- Cover-letter editing and application tracking.
|
||||||
|
- Preserve free and open-source access, privacy, and user control of their data.
|
||||||
|
- Preserve multilingual and right-to-left support throughout workflows and accessible labels.
|
||||||
|
- The web app is a client-rendered React SPA. Public marketing pages are prerendered at build time.
|
||||||
|
- Feature UI belongs in `src/features` and routes in `src/routes`; shared UI primitives belong in `../../packages/ui`.
|
||||||
|
|
||||||
|
## Brand Commitments
|
||||||
|
|
||||||
|
Keep the Reactive Resume name and existing brand assets. The incumbent visual system is documented in the inherited `../../DESIGN.md`; product setup does not replace that system. Resume templates retain their own identities independently of app styling.
|
||||||
|
|
||||||
|
## Evidence on Hand
|
||||||
|
|
||||||
|
- `../../README.md`: product description, capabilities, licensing, and self-hosting information.
|
||||||
|
- `src/features/homepage`: existing product demonstrations and public copy.
|
||||||
|
- `src/features/ats-checker`: browser-based PDF checks.
|
||||||
|
- `public/templates`: real template previews and PDF samples.
|
||||||
|
- `public/icon` and `public/logo`: existing brand assets.
|
||||||
|
|
||||||
|
Do not invent testimonials, hiring outcomes, customer counts, or guarantees that an ATS score predicts hiring success.
|
||||||
|
|
||||||
|
## Product Principles
|
||||||
|
|
||||||
|
1. Keep users in control of their documents and personal data.
|
||||||
|
2. Keep the document visible and the next useful action clear.
|
||||||
|
3. Protect work through autosave and reversible edits.
|
||||||
|
4. Offer useful defaults and reveal advanced controls when needed.
|
||||||
|
5. AI proposes; the user decides what is applied.
|
||||||
|
|
||||||
|
## Accessibility & Inclusion
|
||||||
|
|
||||||
|
Target WCAG 2.2 AA; this is a design requirement, not a claim of audited compliance. Preserve keyboard access, visible focus, accessible labels, alternatives to drag operations, reduced-motion support, and usable touch targets. Support translated content, text expansion, and right-to-left layouts.
|
||||||
+50
-11
@@ -1,16 +1,34 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
<meta name="theme-color" content="#09090B" />
|
<meta name="theme-color" content="#F8F7F3" media="(prefers-color-scheme: light)" />
|
||||||
|
<meta name="theme-color" content="#100F0C" media="(prefers-color-scheme: dark)" />
|
||||||
|
<!-- Apply the saved or system theme before first paint, so nothing flashes. Mirrors libs/theme.ts. -->
|
||||||
|
<script>
|
||||||
|
(() => {
|
||||||
|
try {
|
||||||
|
const match = document.cookie.match(/(?:^|; )theme=([^;]*)/);
|
||||||
|
const theme = match ? decodeURIComponent(match[1]) : "system";
|
||||||
|
const dark =
|
||||||
|
theme === "dark" || (theme !== "light" && window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||||
|
document.documentElement.classList.toggle("dark", dark);
|
||||||
|
} catch {
|
||||||
|
// Without cookies or matchMedia the page starts light, and the app corrects it once it loads.
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
<meta name="application-name" content="Reactive Resume" />
|
<meta name="application-name" content="Reactive Resume" />
|
||||||
<meta name="mobile-web-app-capable" content="yes" />
|
<meta name="mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
|
<!-- Keep the homepage description between 120 and 160 characters. -->
|
||||||
<meta name="description" content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.">
|
<meta
|
||||||
|
name="description"
|
||||||
|
content="Free, open-source resume builder. Create, update, and share a professional resume in minutes, with PDF and Word downloads — no ads, no paywall."
|
||||||
|
/>
|
||||||
|
|
||||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||||
@@ -20,16 +38,21 @@
|
|||||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
|
<!-- Empty here; the server prerenders the homepage into it (apps/server/src/static/web.ts). -->
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
|
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
|
||||||
<div id="initial-loader">
|
<div id="initial-loader">
|
||||||
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
|
<img class="initial-loader__logo-light" src="/icon/light.svg" width="48" height="48" alt="Reactive Resume" />
|
||||||
|
<img class="initial-loader__logo-dark" src="/icon/dark.svg" width="48" height="48" alt="" />
|
||||||
<div class="initial-loader__spinner"></div>
|
<div class="initial-loader__spinner"></div>
|
||||||
<span class="initial-loader__sr-only">Loading</span>
|
<span class="initial-loader__sr-only">Loading</span>
|
||||||
</div>
|
</div>
|
||||||
<style>
|
<style>
|
||||||
@keyframes app-spin { to { transform: rotate(360deg) } }
|
@keyframes app-spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
#initial-loader {
|
#initial-loader {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
@@ -38,17 +61,33 @@
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 24px;
|
gap: 24px;
|
||||||
background: #09090b;
|
background: #f8f7f3;
|
||||||
|
}
|
||||||
|
html.dark #initial-loader {
|
||||||
|
background: #100f0c;
|
||||||
|
}
|
||||||
|
.initial-loader__logo-dark,
|
||||||
|
html.dark .initial-loader__logo-light {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html.dark .initial-loader__logo-dark {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
#app:not(:empty) ~ #initial-loader {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
#app:not(:empty) ~ #initial-loader { display: none; }
|
|
||||||
.initial-loader__spinner {
|
.initial-loader__spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 2px solid rgba(250, 250, 250, 0.2);
|
border: 2px solid rgba(28, 27, 21, 0.2);
|
||||||
border-top-color: #fafafa;
|
border-top-color: #1c1b15;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
animation: app-spin 0.7s linear infinite;
|
animation: app-spin 0.7s linear infinite;
|
||||||
}
|
}
|
||||||
|
html.dark .initial-loader__spinner {
|
||||||
|
border-color: rgba(239, 238, 235, 0.2);
|
||||||
|
border-top-color: #efeeeb;
|
||||||
|
}
|
||||||
.initial-loader__sr-only {
|
.initial-loader__sr-only {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 1px;
|
width: 1px;
|
||||||
|
|||||||
+5806
-3545
File diff suppressed because it is too large
Load Diff
+5813
-3552
File diff suppressed because it is too large
Load Diff
+5821
-3560
File diff suppressed because it is too large
Load Diff
+5820
-3559
File diff suppressed because it is too large
Load Diff
+5810
-3549
File diff suppressed because it is too large
Load Diff
+5816
-3555
File diff suppressed because it is too large
Load Diff
+5810
-3549
File diff suppressed because it is too large
Load Diff
+5811
-3550
File diff suppressed because it is too large
Load Diff
+5813
-3552
File diff suppressed because it is too large
Load Diff
+5811
-3550
File diff suppressed because it is too large
Load Diff
+5816
-3555
File diff suppressed because it is too large
Load Diff
+5815
-3554
File diff suppressed because it is too large
Load Diff
+5814
-3553
File diff suppressed because it is too large
Load Diff
+5820
-3559
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user