mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-30 16:54:26 +10:00
Compare commits
533
Commits
v5.2.9
..
release/v6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
712298843b | ||
|
|
8c40313980 | ||
|
|
73ed3f9b03 | ||
|
|
f0bc26cb3d | ||
|
|
0ac320b0e9 | ||
|
|
d3131e0977 | ||
|
|
28d0170b05 | ||
|
|
ac69dd3f1a | ||
|
|
3d4ae8679a | ||
|
|
4fde62df6d | ||
|
|
b5ff720f9d | ||
|
|
b953435f2c | ||
|
|
a30bf371ff | ||
|
|
582a6fb429 | ||
|
|
24d9e5fb5c | ||
|
|
2a2d08a8d2 | ||
|
|
b42eb6ec06 | ||
|
|
fbf1f8fbac | ||
|
|
232f48578b | ||
|
|
e6a6bf0e6a | ||
|
|
96c7142fbc | ||
|
|
3c5908819c | ||
|
|
3e129c9d9d | ||
|
|
fd3494ccac | ||
|
|
f89acb4368 | ||
|
|
08e61ded7b | ||
|
|
f1d5c6bab4 | ||
|
|
e3717251cb | ||
|
|
30b21fa1e3 | ||
|
|
d77cb93494 | ||
|
|
ce996349fa | ||
|
|
a62ee22f20 | ||
|
|
0a4608bf9d | ||
|
|
9550910f17 | ||
|
|
4076b1a523 | ||
|
|
9699dbf2d8 | ||
|
|
31d6ee6251 | ||
|
|
d9fdf7a30a | ||
|
|
81341a107f | ||
|
|
3fc0896a34 | ||
|
|
7aaed8e30b | ||
|
|
730f795073 | ||
|
|
dc8f9787a4 | ||
|
|
742526af53 | ||
|
|
812d396120 | ||
|
|
1106562169 | ||
|
|
607eafd3e8 | ||
|
|
ffe889b832 | ||
|
|
51ac77295e | ||
|
|
c0c658c00c | ||
|
|
6416da28a4 | ||
|
|
614a1ff9df | ||
|
|
4f60856706 | ||
|
|
ad91a0838c | ||
|
|
15d6443b4f | ||
|
|
1f8c46b4f1 | ||
|
|
de9a6dcfad | ||
|
|
e19f706efd | ||
|
|
86a72bef13 | ||
|
|
d915ba3670 | ||
|
|
e272037bec | ||
|
|
a3784558b7 | ||
|
|
e0648e840a | ||
|
|
858c8ae88a | ||
|
|
07ca5d7c9e | ||
|
|
f573bf5998 | ||
|
|
f3622e8753 | ||
|
|
d7b2a843ca | ||
|
|
d277518d28 | ||
|
|
df2e21ef9e | ||
|
|
e2cb6f111f | ||
|
|
52949fcb4a | ||
|
|
55f6253603 | ||
|
|
cea27a97bb | ||
|
|
7fef84078d | ||
|
|
a1611c3b80 | ||
|
|
54366c5d29 | ||
|
|
64f68a12be | ||
|
|
778fd4b7d9 | ||
|
|
26f2360cf0 | ||
|
|
42527ad83b | ||
|
|
86e200a4da | ||
|
|
483b7a89b2 | ||
|
|
981d7581f5 | ||
|
|
138f3bbd12 | ||
|
|
ea9632d1b1 | ||
|
|
c6746fd9a9 | ||
|
|
11d619d3d9 | ||
|
|
25e044c86c | ||
|
|
f447f429a9 | ||
|
|
20cdb95caa | ||
|
|
5f5dca8445 | ||
|
|
10eb3bdbc7 | ||
|
|
d17e188b03 | ||
|
|
0e5994f243 | ||
|
|
75d102718d | ||
|
|
61526094d5 | ||
|
|
d409b3bef4 | ||
|
|
69d2a35cdc | ||
|
|
ce372b54bb | ||
|
|
b9a4397c93 | ||
|
|
d4fba09741 | ||
|
|
b53789964f | ||
|
|
f89873f083 | ||
|
|
1653d04c3f | ||
|
|
3e62a1d604 | ||
|
|
acd2a9cfe9 | ||
|
|
3987254061 | ||
|
|
903f9280d5 | ||
|
|
bc620b2783 | ||
|
|
9f0202eace | ||
|
|
cdb7bdd2fe | ||
|
|
77a5499881 | ||
|
|
5aeefa6dff | ||
|
|
1232d5dfb2 | ||
|
|
e71b5e6e91 | ||
|
|
ef36b76017 | ||
|
|
f783908b0e | ||
|
|
397d9e43ba | ||
|
|
313cfab631 | ||
|
|
4d593922e3 | ||
|
|
6ee4ee3a4c | ||
|
|
5e8284e49f | ||
|
|
f2769dce54 | ||
|
|
001ca16cad | ||
|
|
30f4edf45d | ||
|
|
c8a10b3d3b | ||
|
|
58ee4eead7 | ||
|
|
f97d1b736e | ||
|
|
63d6f3936d | ||
|
|
9ea9318303 | ||
|
|
368858a56f | ||
|
|
f39c1d604c | ||
|
|
e73a5610be | ||
|
|
ae8e2f76f1 | ||
|
|
66c25efe18 | ||
|
|
cf51fb84d7 | ||
|
|
45fd3fb5e0 | ||
|
|
61b58ae9a3 | ||
|
|
b20ac75927 | ||
|
|
578cb496aa | ||
|
|
4a9dced530 | ||
|
|
97f34b7ccd | ||
|
|
2687191041 | ||
|
|
2a4a1583be | ||
|
|
3d6fe265a0 | ||
|
|
ea97de5ec4 | ||
|
|
a6057abd79 | ||
|
|
137587ebc0 | ||
|
|
6ca0f2416e | ||
|
|
744eaa902e | ||
|
|
870388192e | ||
|
|
8c6cb46597 | ||
|
|
38832014b9 | ||
|
|
0fbeeeb4c4 | ||
|
|
78e16e4195 | ||
|
|
ccd34e4278 | ||
|
|
b85d285b69 | ||
|
|
836ed5db48 | ||
|
|
19966c52fa | ||
|
|
695cdb8514 | ||
|
|
999cd618cb | ||
|
|
b8b03c8be0 | ||
|
|
bc8a912ce7 | ||
|
|
ab67831e4b | ||
|
|
5850230f89 | ||
|
|
549135bb36 | ||
|
|
8c5804ed05 | ||
|
|
772bf14525 | ||
|
|
ee52636c10 | ||
|
|
2e711fd14c | ||
|
|
a4bdc54b2c | ||
|
|
8b5399aa6d | ||
|
|
4a407fdd87 | ||
|
|
d25f1bb815 | ||
|
|
22831058b1 | ||
|
|
cc76138197 | ||
|
|
43136b7acd | ||
|
|
8a71a7fbaf | ||
|
|
3bdf14b1d2 | ||
|
|
21ba966d4e | ||
|
|
6a71b91063 | ||
|
|
93623d8b79 | ||
|
|
08f88d964a | ||
|
|
ec6747b90e | ||
|
|
7d87847ead | ||
|
|
d5c1febc58 | ||
|
|
18bbee8d22 | ||
|
|
74936f2674 | ||
|
|
1051751351 | ||
|
|
bbf9ffbc01 | ||
|
|
1178c1d9b3 | ||
|
|
7a9106414e | ||
|
|
cb94e6621c | ||
|
|
f2893fd677 | ||
|
|
02b53ee3d2 | ||
|
|
b9da6ed587 | ||
|
|
0384989c43 | ||
|
|
cc36be9fd7 | ||
|
|
12046ead9e | ||
|
|
4a803e0c04 | ||
|
|
2e742da698 | ||
|
|
5f53956fae | ||
|
|
9d33aa6d44 | ||
|
|
18a24bdae8 | ||
|
|
1a602ceafd | ||
|
|
f4b16a9adb | ||
|
|
09cc6cf37a | ||
|
|
4fe9ab2a0d | ||
|
|
036829a8c7 | ||
|
|
7cea541aef | ||
|
|
16a27d91b4 | ||
|
|
451f3204d0 | ||
|
|
16d4dbefa6 | ||
|
|
1e4d8ddea2 | ||
|
|
23b71e9f99 | ||
|
|
f6fb3d7b75 | ||
|
|
7c4f41d6f1 | ||
|
|
5c7d03b72d | ||
|
|
7930d670d1 | ||
|
|
0a68d53f5b | ||
|
|
e03dd83e5d | ||
|
|
30bd8a8e04 | ||
|
|
156f24063e | ||
|
|
9bdde33ddf | ||
|
|
ad97b8a88c | ||
|
|
a5d0527090 | ||
|
|
1da0397abf | ||
|
|
bcd5cf0ce9 | ||
|
|
3180672543 | ||
|
|
e6e11c41b2 | ||
|
|
654f8898b6 | ||
|
|
142555302e | ||
|
|
0a7b158ee3 | ||
|
|
f01a590389 | ||
|
|
0a14ca78f7 | ||
|
|
c3d98241a7 | ||
|
|
e81de44adf | ||
|
|
c87aae562e | ||
|
|
18d49376ce | ||
|
|
c66a15bc68 | ||
|
|
02de0e9fcb | ||
|
|
39c564cdf1 | ||
|
|
6f09cea66d | ||
|
|
124f9d8a2e | ||
|
|
22dcb838f0 | ||
|
|
01f4963762 | ||
|
|
8f7faca67d | ||
|
|
699229f2c5 | ||
|
|
ddc60756db | ||
|
|
7c827a42f0 | ||
|
|
04029ec7f5 | ||
|
|
b852518335 | ||
|
|
9ecf340b9d | ||
|
|
a2557b2ad4 | ||
|
|
50f5dd7214 | ||
|
|
7a98f6662f | ||
|
|
05e48a7cbc | ||
|
|
d10eb4a55d | ||
|
|
1536dc48d9 | ||
|
|
8d4cf8a2f8 | ||
|
|
f468651c79 | ||
|
|
5c8338c175 | ||
|
|
873835a571 | ||
|
|
14c7c06516 | ||
|
|
9fdcec2eca | ||
|
|
1d4194a207 | ||
|
|
cce6d64afa | ||
|
|
ef47baf243 | ||
|
|
1f0844b39c | ||
|
|
8df1b25550 | ||
|
|
ea2beb8450 | ||
|
|
861ba8bf60 | ||
|
|
e0c2f6d88a | ||
|
|
ea3980cba0 | ||
|
|
cddb01f037 | ||
|
|
c4eb9d860b | ||
|
|
fe9b59e111 | ||
|
|
bf71253ca4 | ||
|
|
0207e5dfcc | ||
|
|
a2d6bc0c63 | ||
|
|
b2c3ab62b1 | ||
|
|
fa41150723 | ||
|
|
d53b89ba2d | ||
|
|
779ea5cb4a | ||
|
|
5a6f5d4d68 | ||
|
|
0878b256a9 | ||
|
|
bf27792ca0 | ||
|
|
cd1c597ff0 | ||
|
|
93e8d192a4 | ||
|
|
a95e63246e | ||
|
|
a3585a24e0 | ||
|
|
6d39074c58 | ||
|
|
a12e32ddac | ||
|
|
f629ea1ea3 | ||
|
|
b6842fb769 | ||
|
|
aada380888 | ||
|
|
7d809da6f8 | ||
|
|
57fee67d2d | ||
|
|
47fc16d806 | ||
|
|
1f308af728 | ||
|
|
321f2fb43f | ||
|
|
18b5aa4745 | ||
|
|
8354c39c45 | ||
|
|
7390c81b76 | ||
|
|
35cecf9c91 | ||
|
|
735e700929 | ||
|
|
a9973c0054 | ||
|
|
97ccb4ba06 | ||
|
|
165841af4e | ||
|
|
53288fcd3f | ||
|
|
ddbbbde803 | ||
|
|
2cbb0f63e7 | ||
|
|
00a1357deb | ||
|
|
e549d114ea | ||
|
|
84645f122b | ||
|
|
0a092ee2a4 | ||
|
|
f29b92e2fb | ||
|
|
f046f6fc51 | ||
|
|
3fa9de140c |
+29
-5
@@ -9,11 +9,26 @@ SERVER_PORT="3001"
|
||||
# OpenGraph metadata, and absolute upload URLs.
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Optional: serve one already-public resume at /. Use the ID from /builder/<id>.
|
||||
# Unset or blank keeps the marketing home. Restart after changes.
|
||||
# ROOT_RESUME_ID=
|
||||
|
||||
# Vercel: APP_URL can be omitted; production uses VERCEL_PROJECT_PRODUCTION_URL.
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# Optional direct connection for migrations (Neon: DATABASE_URL_UNPOOLED alias).
|
||||
# DATABASE_MIGRATION_URL=""
|
||||
# DATABASE_POOL_MAX="10"
|
||||
|
||||
# When "true", the server refuses to boot if the live database schema has drifted from
|
||||
# the migration ledger (e.g. a table dropped outside migrations). Default "false" logs
|
||||
# the drift loudly at startup and continues.
|
||||
STRICT_SCHEMA_CHECK="false"
|
||||
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
|
||||
@@ -62,7 +77,15 @@ SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# --- Storage (optional) ---
|
||||
# If all S3 keys are disabled, the app uses local filesystem storage instead.
|
||||
# Backend defaults to S3 when all credentials are present, otherwise local.
|
||||
# Vercel defaults to private Blob. Explicit selection: local, s3, blob.
|
||||
# STORAGE_BACKEND="local"
|
||||
# BLOB_READ_WRITE_TOKEN=""
|
||||
# BLOB_STORE_ID=""
|
||||
# DEPLOYMENT_NAMESPACE="default"
|
||||
# Vercel previews need isolated resources before setting ALLOW_PREVIEW_MIGRATIONS=true.
|
||||
|
||||
# If all S3 keys are disabled, Docker uses local filesystem storage instead.
|
||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
|
||||
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
|
||||
@@ -77,7 +100,10 @@ S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- AI Agent Workspace (optional) ---
|
||||
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||
# ENCRYPTION_SECRET is required for saved AI providers and the assistant.
|
||||
# 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.
|
||||
REDIS_URL="redis://redis:6379"
|
||||
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||
|
||||
@@ -93,12 +119,10 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||
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.
|
||||
FLAG_DISABLE_API_RATE_LIMIT="false"
|
||||
|
||||
# This flag shows sponsor placements on the public landing page.
|
||||
FLAG_SHOW_SPONSORS="false"
|
||||
|
||||
# Allows dynamic OAuth client registration to use any parseable redirect URI,
|
||||
# including custom schemes, private hosts, and non-loopback http:// URLs.
|
||||
|
||||
@@ -15,13 +15,13 @@
|
||||
{
|
||||
"guid": "reactive-resume",
|
||||
"name": "Reactive Resume",
|
||||
"description": "A free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.",
|
||||
"description": "A free and open-source resume builder that makes it easy to create, update, and share your resume.",
|
||||
"webpageUrl": {
|
||||
"url": "https://rxresu.me"
|
||||
},
|
||||
"repositoryUrl": {
|
||||
"url": "https://github.com/amruthpillai/reactive-resume",
|
||||
"wellKnown": "https://github.com/amruthpillai/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
|
||||
"url": "https://github.com/reactive-resume/reactive-resume",
|
||||
"wellKnown": "https://github.com/reactive-resume/reactive-resume/blob/main/.github/.well-known/funding-manifest-urls"
|
||||
},
|
||||
"licenses": ["spdx:MIT"],
|
||||
"tags": ["data", "design", "productivity", "resume-builder"]
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and support
|
||||
url: https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a
|
||||
url: https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a
|
||||
about: Get help with setup, configuration, and using Reactive Resume.
|
||||
- name: Security vulnerability
|
||||
url: https://github.com/amruthpillai/reactive-resume/security/advisories/new
|
||||
url: https://github.com/reactive-resume/reactive-resume/security/advisories/new
|
||||
about: Report security vulnerabilities privately.
|
||||
|
||||
@@ -13,12 +13,17 @@ env:
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Checkout Repository (Blacksmith)
|
||||
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||
uses: useblacksmith/checkout@v1
|
||||
|
||||
- name: Check for merge conflict markers
|
||||
run: |
|
||||
if git grep -nEI '<{7} |>{7} |^={7}$' -- ':(exclude)*.md' ':(exclude)*.mdx'; then
|
||||
@@ -32,7 +37,7 @@ jobs:
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "lts/*"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
|
||||
@@ -10,7 +10,7 @@ concurrency:
|
||||
|
||||
jobs:
|
||||
crowdin-sync:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
@@ -18,12 +18,22 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
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
|
||||
uses: crowdin/github-action@v2
|
||||
with:
|
||||
download_translations: true
|
||||
export_only_approved: true
|
||||
skip_untranslated_strings: true
|
||||
localization_branch_name: "l10n"
|
||||
commit_message: "[skip ci] chore(i18n): sync translations from crowdin"
|
||||
pull_request_title: "Sync Translations from Crowdin"
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Build Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: Publish release aliases and redeploy production (false runs a cache-only build, then publishes a canary)
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -13,30 +18,39 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKER_IMAGE: docker.io/amruthpillai/reactive-resume
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
mode:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
outputs:
|
||||
nightly: ${{ steps.mode.outputs.nightly }}
|
||||
release: ${{ steps.mode.outputs.release }}
|
||||
matrix: ${{ steps.mode.outputs.matrix }}
|
||||
canary: ${{ steps.mode.outputs.canary }}
|
||||
|
||||
steps:
|
||||
- name: Determine publishing mode
|
||||
id: mode
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GIT_REF: ${{ github.ref }}
|
||||
RELEASE: ${{ inputs.release }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
|
||||
echo "nightly=true" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
|
||||
echo "canary=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$RELEASE" != "true" ]]; then
|
||||
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "canary=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
|
||||
echo "canary=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
@@ -44,7 +58,14 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
@@ -57,16 +78,53 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout Repository (Blacksmith)
|
||||
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||
uses: useblacksmith/checkout@v1
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
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
|
||||
name: Determine registries
|
||||
id: registries
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dockerhub=false
|
||||
ghcr_image="${GHCR_IMAGE,,}"
|
||||
docker_image="${DOCKER_IMAGE,,}"
|
||||
images="$ghcr_image"
|
||||
if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then
|
||||
dockerhub=true
|
||||
images="${images}"$'\n'"$docker_image"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "ghcr_image=$ghcr_image"
|
||||
echo "docker_image=$docker_image"
|
||||
echo "images<<EOF"
|
||||
echo "$images"
|
||||
echo "EOF"
|
||||
echo "dockerhub=$dockerhub"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
@@ -83,16 +141,28 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
images: ${{ steps.registries.outputs.images }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-,suffix=-${{ matrix.arch }}
|
||||
|
||||
- name: Cache-only smoke build
|
||||
if: ${{ needs.mode.outputs.canary == 'true' && vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: docker/build-push-action@v7
|
||||
with: &cache-only-build
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
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
|
||||
id: build
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
with: &build-push
|
||||
context: .
|
||||
sbom: true
|
||||
push: true
|
||||
@@ -101,13 +171,17 @@ jobs:
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
annotations: ${{ steps.meta.outputs.annotations }}
|
||||
cache-from: type=gha,scope=${{ env.IMAGE }}-${{ matrix.arch }}
|
||||
cache-to: type=gha,mode=max,scope=${{ env.IMAGE }}-${{ matrix.arch }}
|
||||
|
||||
- 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
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.build.outputs.digest }}"
|
||||
digest="${{ steps.build.outputs.digest || steps.build-blacksmith.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest
|
||||
@@ -123,7 +197,11 @@ jobs:
|
||||
- mode
|
||||
- build
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
env:
|
||||
DEPLOY: ${{ secrets.SSH_KEY != '' && secrets.SSH_HOST != '' && secrets.SSH_USER != '' }}
|
||||
PURGE_CLOUDFLARE: ${{ secrets.CLOUDFLARE_ZONE_ID != '' && secrets.CLOUDFLARE_API_TOKEN != '' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -133,11 +211,17 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
with: &checkout-package-json
|
||||
sparse-checkout: package.json
|
||||
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
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
@@ -152,7 +236,10 @@ jobs:
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- *registries
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
@@ -179,11 +266,10 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
images: ${{ steps.registries.outputs.images }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=canary-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ needs.mode.outputs.canary == 'true' }}
|
||||
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
@@ -199,6 +285,8 @@ jobs:
|
||||
|
||||
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
|
||||
FINAL_TAG="nightly"
|
||||
elif [[ "${{ needs.mode.outputs.canary }}" == "true" ]]; then
|
||||
FINAL_TAG="canary-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
else
|
||||
FINAL_TAG="v${{ steps.version.outputs.version }}"
|
||||
fi
|
||||
@@ -211,17 +299,19 @@ jobs:
|
||||
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
|
||||
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
|
||||
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
|
||||
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
|
||||
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
||||
$(printf '${{ steps.registries.outputs.ghcr_image }}@sha256:%s ' *)
|
||||
|
||||
# Get the digest of the multi-arch manifest
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3
|
||||
@@ -229,18 +319,40 @@ jobs:
|
||||
- name: Sign images with Cosign
|
||||
run: |
|
||||
# Sign GHCR image
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
|
||||
cosign sign --yes ${{ steps.registries.outputs.ghcr_image }}@${{ steps.manifest.outputs.ghcr_digest }}
|
||||
|
||||
# Sign Docker Hub image
|
||||
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
# Sign Docker Hub image
|
||||
cosign sign --yes ${{ steps.registries.outputs.docker_image }}@${{ steps.manifest.outputs.docker_digest }}
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${{ steps.manifest.outputs.final_tag }}
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${{ steps.manifest.outputs.final_tag }}
|
||||
fi
|
||||
|
||||
- name: Verify anonymous pulls on both architectures
|
||||
run: |
|
||||
set -euo pipefail
|
||||
registry_config=$(mktemp -d)
|
||||
trap 'rm -rf "$registry_config"' EXIT
|
||||
# Prevent Docker from discovering a system credential helper.
|
||||
printf '%s\n' '{"auths":{"ghcr.io":{},"https://index.docker.io/v1/":{}}}' > "$registry_config/config.json"
|
||||
images=("${{ steps.registries.outputs.ghcr_image }}")
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
images+=("${{ steps.registries.outputs.docker_image }}")
|
||||
fi
|
||||
for image in "${images[@]}"; do
|
||||
for platform in linux/amd64 linux/arm64; do
|
||||
docker --config "$registry_config" pull --quiet --platform "$platform" \
|
||||
"$image:${{ steps.manifest.outputs.final_tag }}"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Redeploy Stack
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
if: ${{ needs.mode.outputs.release == 'true' && env.DEPLOY == 'true' }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
@@ -251,7 +363,7 @@ jobs:
|
||||
./manage_stack.sh up reactive_resume
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
if: ${{ needs.mode.outputs.release == 'true' && env.PURGE_CLOUDFLARE == 'true' }}
|
||||
env:
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -17,10 +17,16 @@ env:
|
||||
FLAG_DISABLE_EMAIL_AUTH: "false"
|
||||
FLAG_DISABLE_API_RATE_LIMIT: "true"
|
||||
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
|
||||
OAUTH_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/oauth_test
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
@@ -40,26 +46,31 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout Repository (Blacksmith)
|
||||
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||
uses: useblacksmith/checkout@v1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: "24"
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run Server and Tooling Tests
|
||||
run: pnpm exec turbo run test:ci --filter=server --filter=@reactive-resume/tooling
|
||||
|
||||
- name: Install Playwright Browser
|
||||
timeout-minutes: 10
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Test Secrets
|
||||
@@ -73,11 +84,23 @@ jobs:
|
||||
- name: Run Database Migrations
|
||||
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
|
||||
# cannot silently lose coverage by being left out. Serial execution: the PDF
|
||||
# rasterization and API rate-limit suites time out when several packages' Vitest
|
||||
# thread pools oversubscribe the runner at once.
|
||||
- name: Run Unit Tests
|
||||
run: pnpm exec turbo run test:ci --concurrency=1
|
||||
|
||||
- name: Build
|
||||
run: pnpm build
|
||||
|
||||
- name: Run Baseline E2E Tests
|
||||
run: pnpm exec playwright test --grep-invert "@semantic-css"
|
||||
- name: Run E2E Tests
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
|
||||
@@ -10,14 +10,21 @@ permissions:
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Checkout Repository (Blacksmith)
|
||||
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||
uses: useblacksmith/checkout@v1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Apply Form Labels
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
|
||||
@@ -10,7 +10,7 @@ permissions:
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Close Inactive Issues Awaiting Information
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
name: Vercel compatibility
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
artifact:
|
||||
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
|
||||
env:
|
||||
APP_URL: http://localhost:3000
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
AUTH_SECRET: isolated-ci-auth-secret-32-characters
|
||||
ENCRYPTION_SECRET: isolated-ci-encryption-secret-32-characters
|
||||
REDIS_URL: redis://localhost:6379
|
||||
STORAGE_BACKEND: blob
|
||||
BLOB_READ_WRITE_TOKEN: vercel_blob_rw_ci_fake_build_only
|
||||
VERCEL: "1"
|
||||
VERCEL_ENV: production
|
||||
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
|
||||
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
|
||||
- name: Build Vercel artifact against isolated PostgreSQL
|
||||
run: |
|
||||
mkdir -p .vercel
|
||||
node --input-type=module - <<'JS'
|
||||
import { writeFileSync } from 'node:fs';
|
||||
writeFileSync('.vercel/project.json', JSON.stringify({
|
||||
projectId: 'prj_ci', orgId: 'team_ci', projectName: 'reactive-resume-ci',
|
||||
settings: { framework: 'services', nodeVersion: '24.x', createdAt: 0 }
|
||||
}));
|
||||
JS
|
||||
pnpm dlx --allow-build=esbuild vercel@61.0.0 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 backend Function loading and budget
|
||||
run: |
|
||||
node --no-experimental-require-module --input-type=module - <<'JS'
|
||||
import assert from 'node:assert/strict';
|
||||
import { cpSync, lstatSync, mkdirSync, readFileSync, readlinkSync, symlinkSync } from 'node:fs';
|
||||
import { dirname, 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.maxDuration, 300);
|
||||
assert.equal(config.handler, 'apps/server/vercel.mjs');
|
||||
const root = join(process.env.RUNNER_TEMP, 'backend-function');
|
||||
cpSync(func, root, { recursive: true, verbatimSymlinks: true });
|
||||
for (const [path, source] of Object.entries(config.filePathMap ?? {})) {
|
||||
mkdirSync(dirname(join(root, path)), { recursive: true });
|
||||
if (lstatSync(source).isSymbolicLink()) symlinkSync(readlinkSync(source), join(root, path));
|
||||
else cpSync(source, join(root, path));
|
||||
}
|
||||
const { default: app } = await import(join(root, config.handler));
|
||||
const stage = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
|
||||
assert.equal(stage.status, 401);
|
||||
const home = await app.fetch(new Request('http://localhost:3000/'));
|
||||
assert.equal(home.status, 200);
|
||||
assert.match(await home.text(), /application\/ld\+json/);
|
||||
process.exit(0);
|
||||
JS
|
||||
|
||||
live-smoke:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
environment: vercel-smoke
|
||||
timeout-minutes: 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: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
- name: Smoke-test dedicated deployment
|
||||
env:
|
||||
SMOKE_URL: ${{ vars.VERCEL_SMOKE_URL }}
|
||||
SMOKE_AI_BASE_URL: ${{ vars.VERCEL_SMOKE_AI_BASE_URL }}
|
||||
SMOKE_AI_API_KEY: ${{ secrets.VERCEL_SMOKE_AI_API_KEY }}
|
||||
run: node tooling/deployment/smoke.mjs
|
||||
+6
-1
@@ -4,6 +4,7 @@ node_modules
|
||||
|
||||
# Build Outputs
|
||||
dist
|
||||
dist-prerender
|
||||
.vercel
|
||||
.wrangler
|
||||
|
||||
@@ -50,15 +51,19 @@ temp
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.codegraph
|
||||
.superpowers
|
||||
.worktrees
|
||||
.migration
|
||||
graphify-out
|
||||
/plans
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Redesign handoff (design references, not source)
|
||||
/design_handoff_reactive_resume_redesign
|
||||
|
||||
# Git 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/**"
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
const betaPackages = ["drizzle-zod"];
|
||||
const rcPackages = ["drizzle-orm", "drizzle-kit"];
|
||||
|
||||
/** @type {import('npm-check-updates').RunOptions} */
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
workspaces: true,
|
||||
install: "always",
|
||||
packageManager: "pnpm",
|
||||
target: (packageName) => {
|
||||
if (betaPackages.includes(packageName)) return "@beta";
|
||||
if (rcPackages.includes(packageName)) return "@rc";
|
||||
return "latest";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
.env*
|
||||
!.env.example
|
||||
.git
|
||||
.codegraph
|
||||
.superpowers
|
||||
.agents
|
||||
.codex
|
||||
.claude
|
||||
.turbo
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/coverage
|
||||
**/reports
|
||||
data
|
||||
apps/web/data
|
||||
/screenshots
|
||||
.vercel
|
||||
.wrangler
|
||||
.tanstack
|
||||
.worktrees
|
||||
.migration
|
||||
.supermemory
|
||||
.cache
|
||||
tmp
|
||||
temp
|
||||
**/test-results
|
||||
**/playwright-report
|
||||
@@ -1,3 +1,7 @@
|
||||
# Reactive Resume: agent instructions
|
||||
|
||||
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/).
|
||||
|
||||
<!-- intent-skills:start -->
|
||||
## Skill Loading
|
||||
|
||||
@@ -27,188 +31,207 @@ Auto-Clarity: drop caveman for security warnings, irreversible actions, user con
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
|
||||
<!-- graphify-begin -->
|
||||
## graphify
|
||||
<!-- BEGIN:turborepo-agent-rules -->
|
||||
|
||||
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
|
||||
# This is NOT the Turborepo you know
|
||||
|
||||
When the user types `/graphify`, use the installed graphify skill or instructions before doing anything else.
|
||||
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`.
|
||||
|
||||
Rules:
|
||||
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
|
||||
- Dirty graphify-out/ files are expected after hooks or incremental updates; dirty graph files are not a reason to skip graphify. Only skip graphify if the task is about stale or incorrect graph output, or the user explicitly says not to use it.
|
||||
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
|
||||
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
|
||||
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).
|
||||
<!-- graphify-end -->
|
||||
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.
|
||||
|
||||
# AGENTS.md
|
||||
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
|
||||
|
||||
### Issue tracker
|
||||
- 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.
|
||||
|
||||
Issues and specs are tracked in GitHub Issues for `amruthpillai/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
||||
## Overview
|
||||
|
||||
### Domain docs
|
||||
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.
|
||||
|
||||
This repository uses a multi-context domain-doc layout. See `docs/agents/domain.md`.
|
||||
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.
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
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.
|
||||
|
||||
### Overview
|
||||
## Setup
|
||||
|
||||
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000, with `apps/server` mounting the API/auth/MCP/static routes and serving the built web app.
|
||||
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.
|
||||
|
||||
Internal packages are source-consumed through `package.json` export maps that point at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
||||
Run commands from the workspace root unless stated otherwise:
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- **Node.js 24** (matches Dockerfile `ARG NODE_VERSION=24`). Use `nvm install 24 && nvm use 24` if needed.
|
||||
- **Docker** is required to run PostgreSQL. Start it with `sudo dockerd &` if the daemon isn't running.
|
||||
- **pnpm 11.21.0**. Install pnpm directly using the [official installation guide](https://pnpm.io/installation).
|
||||
|
||||
### Codebase map
|
||||
|
||||
- `apps/web` owns TanStack Start routes, Vite config, PWA setup, oRPC browser client wiring, web features, and the resume builder UI.
|
||||
- `apps/server` owns the production Hono app, route composition, auth/RPC/MCP/OpenAPI handlers, static uploads, schema JSON, web-dist fallback serving, and startup checks.
|
||||
- `packages/api` contains oRPC routers, DTOs, rate limiting, and feature-owned API modules under `packages/api/src/features/*`. The router export at `@reactive-resume/api/routers` aggregates those feature routers for `/api/rpc`.
|
||||
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The server auth adapter in `apps/server/src/http/auth.ts` delegates to `auth.handler`.
|
||||
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
|
||||
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
|
||||
- `packages/schema` contains Zod schemas and typed resume/page/template models.
|
||||
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, template implementations, and browser/server PDF generation adapters. PDF.js viewer UI stays in `apps/web`.
|
||||
- `packages/resume` contains pure resume-domain behavior such as JSON Patch helpers and social-network icon mapping.
|
||||
- `packages/docx` contains DOCX export generation.
|
||||
- `packages/mcp` contains MCP tools, prompts, resources, server-card generation, and tool metadata.
|
||||
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
|
||||
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, and `packages/config` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
|
||||
- Development-only scripts live in `tooling/`, not under `packages/`, so packages only contain code bundled by the app/runtime.
|
||||
|
||||
### Web app conventions
|
||||
|
||||
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
|
||||
- Server-owned HTTP behavior lives in `apps/server/src/{http,rpc,mcp,openapi,static,startup}`. Keep API/RPC/auth/MCP/static route wiring in `apps/server`, not in web routes.
|
||||
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
|
||||
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
|
||||
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
|
||||
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
|
||||
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
|
||||
|
||||
```ts
|
||||
type IntentSelectFieldProps<TValue extends string> = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
};
|
||||
|
||||
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
|
||||
// ...
|
||||
}
|
||||
```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
|
||||
```
|
||||
|
||||
### Package and feature boundaries
|
||||
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:
|
||||
|
||||
- Workspace dependencies must go through package names and package export maps. Do not import another workspace's `src` tree through repository paths, `@reactive-resume/*/src/*`, or TypeScript path aliases.
|
||||
- `turbo boundaries` is the executable package-boundary check. Workspace-level `turbo.json` files declare coarse tags:
|
||||
- `app:web` for the TanStack Start app.
|
||||
- `app:server` and `runtime:server` for the Node/Hono process.
|
||||
- `runtime:server` for server-only packages such as API/auth/db/env/email/MCP.
|
||||
- `runtime:browser` for browser-only shared UI.
|
||||
- `runtime:universal` for environment-neutral domain packages.
|
||||
- `role:domain`, `role:infra`, `role:adapter`, `role:api`, `role:rendering`, and `role:tooling` for package intent.
|
||||
- Browser/server runtime-specific code should live behind explicit export subpaths such as `@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, or `@reactive-resume/env/server`. Keep root exports environment-neutral unless the package is intentionally server-only.
|
||||
- Wildcard exports are allowed only for leaf libraries whose public surface is intentionally file-like, currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages that own runtime behavior.
|
||||
- Add new API procedures and business logic inside the owning `packages/api/src/features/*` module. Keep route wiring, DTO usage, helpers, and services colocated by feature/capability, then expose only intentional public surfaces through `packages/api/package.json`. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
|
||||
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `dotenvx run -f .env.local -- pnpm db:generate`.
|
||||
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
|
||||
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
|
||||
- Resume JSON Patch behavior belongs in `@reactive-resume/resume/patch`; do not put resume-domain helpers in `@reactive-resume/utils`.
|
||||
- DOCX export behavior belongs in `@reactive-resume/docx`; do not put DOCX builders in `@reactive-resume/utils`.
|
||||
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
|
||||
- PDF generation helpers live behind `@reactive-resume/pdf/browser` and `@reactive-resume/pdf/server`; locale-specific section-title resolution stays in the caller.
|
||||
- MCP implementation belongs in `@reactive-resume/mcp`; app packages must not import MCP implementation from another app's source tree.
|
||||
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
|
||||
|
||||
Placement decision tree:
|
||||
|
||||
1. If the change is a web route, route loader, or user-facing web workflow, start in `apps/web/src/routes` or `apps/web/src/features`.
|
||||
2. If the change is a server HTTP route/adapter, startup check, static handler, MCP transport, or OpenAPI/well-known handler, start in `apps/server/src`.
|
||||
3. If it is authenticated API behavior, put the contract and implementation in the owning `packages/api/src/features/*` module.
|
||||
4. If it is pure resume data behavior with no DB, HTTP, DOM, or PDF renderer dependency, put it in `packages/resume`.
|
||||
5. If it renders resume PDFs, put shared React PDF/template code in `packages/pdf`; put PDF.js viewer/canvas UI in `apps/web/src/features/resume`.
|
||||
6. If it creates DOCX exports, put it in `packages/docx`.
|
||||
7. If it exposes MCP tools/prompts/resources, put it in `packages/mcp`.
|
||||
8. If it is a generic UI primitive or hook, put it in `packages/ui`; if it is workflow-specific UI, keep it in the owning web feature.
|
||||
9. If it is a narrow cross-cutting helper, add an explicit `packages/utils` export only after checking that no domain package is a better owner.
|
||||
|
||||
### Database
|
||||
|
||||
PostgreSQL runs via Docker Compose:
|
||||
|
||||
```
|
||||
sudo docker compose -f compose.dev.yml up -d postgres
|
||||
```dotenv
|
||||
APP_URL=http://localhost:3000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
S3_ENDPOINT=http://localhost:8333
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||
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.
|
||||
|
||||
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. Run migration commands through `dotenvx`, for example `dotenvx run -f .env.local -- pnpm db:migrate`, so `DATABASE_URL` is present in the process environment.
|
||||
|
||||
The production server runs migrations during startup before serving traffic. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env.local`. The three required variables are:
|
||||
|
||||
- `APP_URL` (default `http://localhost:3000`)
|
||||
- `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`)
|
||||
- `AUTH_SECRET` (any non-empty string)
|
||||
|
||||
S3/SeaweedFS is optional. If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. The checked-in `.env.example` sets SeaweedFS defaults, so either start the `seaweedfs` compose service too or comment out those S3 vars to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||
|
||||
`REDIS_URL` and `ENCRYPTION_SECRET` are optional for core resume flows, but both are required for saved AI providers and the authenticated `/agent` workspace. Start the `redis` compose service and set both vars in `.env.local` when working on those features. For host-run development, use `REDIS_URL=redis://localhost:6379`; the container-run app uses `REDIS_URL=redis://redis:6379`.
|
||||
|
||||
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
|
||||
|
||||
### Common commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install deps | `pnpm install` |
|
||||
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
|
||||
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
|
||||
| Start full dev infrastructure | `sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket` |
|
||||
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
|
||||
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
|
||||
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
|
||||
| Web dev server only | `dotenvx run -f .env.local -- pnpm dev:web` |
|
||||
| Lint/format | `pnpm check` (Biome) |
|
||||
| Boundary check | `pnpm exec turbo boundaries` |
|
||||
| Tests | `pnpm test` (Vitest) |
|
||||
| Build | `pnpm build` |
|
||||
| Typecheck | `pnpm typecheck` |
|
||||
|
||||
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||
## 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
|
||||
|
||||
Where each concern lives, and where new code for it goes:
|
||||
|
||||
| Area | Owner |
|
||||
|------|-------|
|
||||
| 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}` |
|
||||
| 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`) |
|
||||
| DB client + schema | `packages/db` (Drizzle; migrations at repo root `migrations/`) |
|
||||
| Server env validation | `packages/env` (auto-loads root `.env`) |
|
||||
| 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) |
|
||||
| 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` |
|
||||
| DOCX export | `packages/docx` |
|
||||
| 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 |
|
||||
| DeepSeek Harness integration | `packages/dsh-plugin` (separately built/published plugin) |
|
||||
| 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`.
|
||||
|
||||
## 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.
|
||||
- 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`.
|
||||
- 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.
|
||||
- For React components with explicit props, use a named props type (e.g. `type FooProps = {...}` with `function Foo(props: FooProps)`) rather than inline object annotations, especially with more than one field or with generics.
|
||||
|
||||
## Package boundaries
|
||||
|
||||
`pnpm exec turbo boundaries` is the executable check. Rules:
|
||||
|
||||
- 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.
|
||||
- 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/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`.
|
||||
- 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:
|
||||
|
||||
- **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 DB column/table**: `packages/db/src/schema/*`, then `pnpm db:generate`.
|
||||
- **New env var**: `packages/env/src/server.ts`, `.env.example`, **and** the `globalEnv` array 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
|
||||
|
||||
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`.
|
||||
|
||||
- **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.
|
||||
- **`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`. The root migration scripts load `.env.local` through `dotenvx` before invoking Drizzle Kit.
|
||||
- `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.
|
||||
|
||||
## Testing and checks
|
||||
|
||||
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
|
||||
pnpm --filter web typecheck
|
||||
pnpm --filter @reactive-resume/pdf test
|
||||
pnpm --filter @reactive-resume/api test
|
||||
pnpm --filter @reactive-resume/pdf test src/templates/shared/filtering.test.ts
|
||||
pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/filtering.test.ts -t "filterItems"
|
||||
pnpm --filter @reactive-resume/pdf test:coverage
|
||||
pnpm exec biome check apps/web/src/features/resume
|
||||
pnpm exec turbo boundaries
|
||||
```
|
||||
|
||||
Vitest test paths are package-relative when running through `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 runs `pnpm exec turbo run test:ci --concurrency=1` to avoid CPU contention in PDF/rate-limit suites.
|
||||
- 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`.
|
||||
|
||||
### Gotchas
|
||||
### Browser tests
|
||||
|
||||
- The server startup path auto-runs migrations before serving traffic, so `pnpm db:migrate` is mainly needed for first-time setup, migration debugging, or applying migrations without starting the app.
|
||||
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
|
||||
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
|
||||
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
|
||||
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
|
||||
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
|
||||
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
|
||||
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
|
||||
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`.
|
||||
- Biome uses tabs, double quotes, 120-column lines, separated type imports, organized 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 and runs Biome with `--write --unsafe`. Call out its write behavior and review the diff; use narrow non-mutating commands when inspecting unrelated edits.
|
||||
- Lefthook's pre-commit hook checks conflict markers and runs write-capable Biome on supported staged files, staging fixes. The commit-message hook enforces Conventional Commits (`fix:`, `feat:`, `docs:`, etc.).
|
||||
|
||||
## 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
|
||||
|
||||
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
|
||||
- Database connection errors: check `docker compose -f compose.dev.yml ps` and use `localhost` for host-run code, service names inside containers.
|
||||
- S3 errors: check `docker compose -f compose.dev.yml logs seaweedfs seaweedfs_create_bucket`; verify endpoint and bucket, or select local storage.
|
||||
- Route-tree errors after adding routes: run Vite dev/build to regenerate `apps/web/src/routeTree.gen.ts`; never edit it by hand.
|
||||
- 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.
|
||||
- Most test scripts use `--passWithNoTests`; a successful run with zero tests does not verify the behavior you changed.
|
||||
|
||||
@@ -1,347 +1,205 @@
|
||||
---
|
||||
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.
|
||||
version: 6.0.0
|
||||
name: Reactive Resume · Desk & Paper
|
||||
description: A quiet, warm desk around a bright page. The resume is the only white, detailed object on screen; one moss-green accent marks the next action. Light and dark themes, with the page always white.
|
||||
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"
|
||||
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:
|
||||
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
|
||||
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 }
|
||||
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: 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
|
||||
sm: 6px
|
||||
md: 8px
|
||||
lg: 10px
|
||||
xl: 12px
|
||||
2xl: 16px
|
||||
3xl: 18px
|
||||
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
|
||||
---
|
||||
|
||||
## 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.
|
||||
Reactive Resume's interface is a quiet, warm desk around a bright page. The resume or letter page is the only white, detailed object on screen; everything around it uses low-contrast warm neutrals, thin rules instead of boxes, and a single moss-green accent.
|
||||
|
||||
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 full specification lives in the redesign handoff (`design_handoff_reactive_resume_redesign/README.md`, kept out of version control) and the milestone plan in `REDESIGN_PLAN.md`. This document records the rules the code follows.
|
||||
|
||||
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.
|
||||
Five principles decide most questions:
|
||||
|
||||
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").
|
||||
1. **The page is the interface.** The live page is on screen in every editor mode. Clicking a line on the page opens its field.
|
||||
2. **One obvious next step.** Each view has at most one accent-filled button. Accent means "do this next" or "this is working" and is never decoration.
|
||||
3. **Nothing is lost.** Everything autosaves and can be undone. Confirmation dialogs are only for irreversible actions.
|
||||
4. **Detail on demand.** Defaults cover most people; advanced controls sit one disclosure deeper.
|
||||
5. **AI proposes, you decide.** The assistant never writes directly; every change is a reviewable proposal.
|
||||
|
||||
## Colors
|
||||
Resume templates keep their Pokémon names (Azurill, Onyx, Glalie…), their own fonts and their own colors. None of the rules below apply inside a template.
|
||||
|
||||
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.
|
||||
## Tokens
|
||||
|
||||
- **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.
|
||||
Tokens are CSS custom properties in `packages/ui/src/styles/globals.css`, light on `:root` and dark on `.dark`. The source of truth is oklch; the hex values above are sRGB approximations. Tailwind exposes each one under the same name: `bg-bg`, `bg-surface`, `bg-raised`, `bg-sunken`, `border-line`, `border-line-2`, `text-ink`, `text-ink-2`, `text-ink-3`, `bg-accent`, `text-on-accent`, `bg-accent-soft`, `text-accent-text`, `bg-danger`, `bg-danger-soft`, `text-danger-text`, `bg-warn`, `bg-warn-soft`, `text-warn-text`, `bg-info-soft`, `text-info-text`, `bg-hover`, `bg-press`, `bg-scrim`, `bg-paper` and `bg-stage-*`.
|
||||
|
||||
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.
|
||||
- **Surfaces:** `bg` is the app desk, `surface` holds panels and cards, `raised` holds menus, dialogs and inputs, `sunken` is for wells, tracks and the page canvas.
|
||||
- **Text:** `ink` for primary text, `ink-2` for secondary text, `ink-3` for meta and placeholders. `ink-3` is the lightest color allowed for text (about 4.9:1).
|
||||
- **Signals:** `danger` for errors and irreversible actions, `warn` for check issues and things to review, `info` for neutral guidance and the assistant's questions. Success uses `accent-soft`.
|
||||
- **Overlays:** `hover` and `press` are translucent, so they work on any surface.
|
||||
- **Paper:** `--paper` is white in both themes. Pages never invert.
|
||||
- **Stages:** application stage colors share lightness and chroma. They appear only as 8px dots or 6px stepper bars, always next to the stage name.
|
||||
|
||||
The previous shadcn-style names (`background`, `foreground`, `primary`, `muted`, `border`, `input`, `ring`, `destructive`, `card`, `popover`, `sidebar-*`) still resolve to these tokens so screens that haven't been rebuilt stay legible. Don't use them in new code; they're removed once every screen has moved.
|
||||
|
||||
## 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.
|
||||
- **Newsreader** (display serif, optical sizes 6–72) is only for page titles, dialog and sheet titles, empty-state headlines and large stat numerals. Use `font-display`.
|
||||
- **Hanken Grotesk** handles everything functional. It's the default `font-sans`.
|
||||
- **JetBrains Mono** is for shortcuts, URLs and slugs, file names, counts and section eyebrows. Use `font-mono`.
|
||||
- Field labels are 12px, medium weight, `ink-2`, above the control with a 5–6px gap. Uppercase group eyebrows are 12px semibold `ink-3` with 0.02em tracking.
|
||||
- Inputs render at 16px on touch devices so iOS doesn't zoom.
|
||||
- All three fonts are self-hosted through `@fontsource-variable`.
|
||||
|
||||
- **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.
|
||||
## Iconography
|
||||
|
||||
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.
|
||||
App icons are **Material Symbols Rounded** at weight 300, rendered by `Icon` from `@reactive-resume/ui/components/icon`. The font is a self-hosted subset that contains only the glyphs listed in `packages/ui/src/icons/names.ts`:
|
||||
|
||||
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
|
||||
1. Add the name to that list (TypeScript then accepts it in `<Icon name="…" />`).
|
||||
2. Run `pnpm icons:build`. The script checks every name against the published codepoints, downloads the subset and updates the manifest. A unit test fails if the manifest and the list disagree.
|
||||
|
||||
## Layout
|
||||
Rules:
|
||||
|
||||
### Builder (Three-Panel Workspace)
|
||||
- 20px on desktop, 24px on touch. Outline by default; `filled` only for the selected navigation item.
|
||||
- Icons always sit beside a text label, except back, close, more, undo/redo, history, assistant and zoom. Those use `IconButton`, which requires a label and shows it in a tooltip with the shortcut.
|
||||
- `Icon` is `aria-hidden` and `translate="no"`, so the ligature text never becomes an accessible name.
|
||||
- Directional icons (arrows, chevrons, undo, redo) mirror in right-to-left layouts automatically.
|
||||
- Icons inside resumes are a separate system: Phosphor, because resume data stores Phosphor names and the PDF renderer draws them.
|
||||
|
||||
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
|
||||
## Space, shape and elevation
|
||||
|
||||
- **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).
|
||||
- **Spacing** follows a 4pt scale: 4, 8, 12, 16, 24, 32, 48, 64. Cards use 16px padding, panels 16–24px, the mobile margin is 16px and the desktop page margin 32–40px.
|
||||
- **Radius:** `rounded-sm` 6px for chips and small buttons, `rounded-md` 8px for controls and inputs, `rounded-lg` 10px for list items, `rounded-xl` 12px for cards and menus, `rounded-2xl` 16px for dialogs, `rounded-3xl` 18px for mobile sheets, `rounded-full` for pills.
|
||||
- **Elevation:** `shadow-e1` for cards, `shadow-e2` for menus, popovers and hover-lifted cards, `shadow-e3` for dialogs, sheets and toasts, `shadow-page` for the resume page on the canvas.
|
||||
- **Control heights:** 28px small, 36px default, 44px touch. Icon buttons are 32–36px on desktop and 44px on touch.
|
||||
- **Layout constants** are CSS variables: `--editor-bar` 56px, `--editor-panel` 400px, `--app-sidebar` 240px, `--sheet-share` 440px, `--sheet-detail` 480px, `--assistant` 400px.
|
||||
|
||||
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).
|
||||
## Motion
|
||||
|
||||
### Dashboard
|
||||
| Token | Duration | Use |
|
||||
|---|---|---|
|
||||
| `duration-quick` | 120ms | hover, press, toggle, checkbox, focus |
|
||||
| `duration-standard` | 200ms | menus, popovers, expand and collapse, mode switch, dialogs |
|
||||
| `duration-emphasized` | 320ms | side and bottom sheets, toasts, the assistant column |
|
||||
|
||||
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.
|
||||
- Everything that enters uses `ease-enter` (`cubic-bezier(0.2, 0.8, 0.2, 1)`). Exits run at 70% of the duration.
|
||||
- Motion explains where something went. Nothing loops, bounces or plays on load; loading placeholders stay still. Reflowing the page after an edit is never animated.
|
||||
- With `prefers-reduced-motion`, the duration tokens become 1ms and every CSS transition collapses; spinners keep turning because they're status.
|
||||
- Motion (`motion/react`) animations run under `MotionConfig reducedMotion="user"`; mirror the tokens in `apps/web/src/libs/motion.ts` when one needs them.
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
Generic primitives live in `packages/ui/src/components` and wrap Base UI (and cmdk for the command bar). Feature-specific UI lives with its feature in `apps/web`.
|
||||
|
||||
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):
|
||||
- **Buttons:** `primary` (accent fill, the one filled button per view), `secondary` (bordered surface), `ghost`, `danger`, `link`. Sizes `sm` 28, `default` 36, `lg` 44 (touch), plus icon sizes. `loading` shows a spinner, sets `aria-busy` and blocks activation; pair it with a present-participle label ("Preparing…").
|
||||
- **Inputs:** 36px (44px on touch), `raised` background, `line-2` border. Focus is an accent border plus a 3px `accent-soft` ring. Errors appear after the first blur, in `danger-text`, with an icon and words that say how to fix it.
|
||||
- **Switches:** prefer `SwitchRow`, where the whole row is the switch. Checkboxes are 18px with a 5px radius; radios are 18px with an 8px accent dot.
|
||||
- **Segmented controls:** `SegmentedControl` for 2–4 options (a radio group); `Tabs` with the default variant when segments switch panels, `Tabs variant="line"` for underline tabs.
|
||||
- **Menus** (dropdown, context, combobox lists): 220px minimum width, 12px radius, 36px items, destructive items last after a separator.
|
||||
- **Layers, lightest to heaviest:** menu, popover, sheet, dialog. Sheets are for tasks beside the page and become bottom sheets on mobile. Dialogs are for decisions; destructive confirmations use `AlertDialog` and the cancel label says what is kept.
|
||||
- **Toasts:** one at a time, bottom center, ink on the desk color, 6 seconds, with an optional underlined Undo action.
|
||||
- **Alerts:** `info`, `success`, `warn` and `error`; only errors are announced (`role="alert"`).
|
||||
- **Empty states:** a Newsreader 22px headline, a 14px body up to 300px wide, then a primary and a secondary action.
|
||||
|
||||
- **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.
|
||||
## Accessibility
|
||||
|
||||
Size scale: `xs` (28px), `sm` (32px), `default` (36px), `lg` (40px), plus `icon` variants at each size for square icon-only buttons.
|
||||
WCAG 2.2 AA is the floor.
|
||||
|
||||
### Cards
|
||||
- Every interactive element shows a 2px accent focus ring with a 2px gap on `:focus-visible`. Never remove it; inputs replace it with their accent border and soft ring.
|
||||
- Pointer targets are at least 24px and touch targets at least 44px. Every drag has a keyboard and a menu alternative.
|
||||
- Color is never the only signal: stages, issues and states always pair color with text and an icon.
|
||||
- Sheets and dialogs trap focus; Esc closes the top layer and returns focus to its trigger. Save state and toasts announce through a polite live region.
|
||||
|
||||
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`).
|
||||
## Themes
|
||||
|
||||
### 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.
|
||||
The theme cookie holds `light`, `dark` or `system` (the default); `system` follows `prefers-color-scheme` live. `ThemeProvider` owns the `.dark` class on `<html>`, and an inline script in `index.html` sets it before first paint. Resumes and letters always render on white paper, whatever the theme.
|
||||
|
||||
## Internationalization
|
||||
|
||||
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
|
||||
- Every user-facing string goes through Lingui (`t`, `msg`, `<Trans>`); catalogs are PO files in `apps/web/locales`. Primitives in `packages/ui` can't use Lingui, so they take labels as props (for example `closeLabel`).
|
||||
- 55 interface languages, including right-to-left ones. `<html dir>` follows the locale and `DirectionProvider` passes it to Base UI.
|
||||
- Use logical properties (`ps`, `pe`, `ms`, `me`, `inset-s`, `inset-e`) instead of physical ones.
|
||||
- Translations run 30–50% longer than English; avoid fixed widths on text.
|
||||
|
||||
**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.
|
||||
## Do and don't
|
||||
|
||||
**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`).
|
||||
- **Do** keep one accent-filled button per view, and keep accent for next actions and working states.
|
||||
- **Do** use `ink-3` as the lightest text color, and pair every color signal with text.
|
||||
- **Do** build states in full: empty, loading (placeholders at their real size), error (why and what to do) and success.
|
||||
- **Don't** hard-code colors, including Tailwind palette classes such as `amber-600`; use the semantic tokens.
|
||||
- **Don't** use Newsreader for anything smaller than a sheet title.
|
||||
- **Don't** ask for confirmation for something that can be undone; use an undo toast instead.
|
||||
- **Don't** skip `data-slot` on primitives; tests and styles rely on it.
|
||||
|
||||
+4
-2
@@ -1,5 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Base image only; pnpm self-manages to the `packageManager` version in package.json.
|
||||
ARG PNPM_VERSION=11.21.0
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
@@ -25,7 +26,7 @@ RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
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
|
||||
COPY . .
|
||||
@@ -47,7 +48,7 @@ LABEL org.opencontainers.image.description="A free and open-source resume builde
|
||||
LABEL org.opencontainers.image.vendor="Amruth Pillai"
|
||||
LABEL org.opencontainers.image.url="https://rxresu.me"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
|
||||
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
|
||||
LABEL org.opencontainers.image.source="https://github.com/reactive-resume/reactive-resume"
|
||||
|
||||
ENV NODE_ENV="production" \
|
||||
PORT=3000 \
|
||||
@@ -62,6 +63,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/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-prerender ./apps/web/dist-prerender
|
||||
COPY --from=builder --chown=node:node /app/apps/server/dist ./apps/server/dist
|
||||
COPY --from=pruner --chown=node:node /app/migrations ./migrations
|
||||
|
||||
|
||||
+5
-1
@@ -1,5 +1,6 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
# Base image only; pnpm self-manages to the packageManager version.
|
||||
ARG PNPM_VERSION=11.21.0
|
||||
ARG NODE_VERSION=24
|
||||
|
||||
@@ -15,7 +16,6 @@ ENV NODE_ENV=development \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
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/web/package.json ./apps/web/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/ui/package.json ./packages/ui/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
|
||||
|
||||
RUN --mount=type=cache,id=reactive-resume-dev-pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
|
||||
+293
@@ -0,0 +1,293 @@
|
||||
# Glossary
|
||||
|
||||
What the recurring terms in Reactive Resume's interface actually mean.
|
||||
|
||||
This file exists because most of the interface is translated from short, standalone strings.
|
||||
A translator, human or machine, sees `Board` or `Resume` with no surrounding sentence, picks the
|
||||
most common English sense, and gets it wrong. Every entry below has been mistranslated that way
|
||||
in at least one shipped locale.
|
||||
|
||||
**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.
|
||||
|
||||
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.
|
||||
|
||||
## Always left untranslated
|
||||
|
||||
Product and technology names stay in English (or in the locale's established transliteration, if
|
||||
the catalog already uses one consistently):
|
||||
|
||||
Reactive Resume, GitHub, Crowdin, Docker, PostgreSQL, Better Auth, TanStack, Microsoft Word,
|
||||
PDF, DOCX, JSON, CSV, API, MCP, oRPC, SSO, CSS, URL, JSON Resume.
|
||||
|
||||
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,
|
||||
Fireworks, Cerebras, Perplexity, Ollama.
|
||||
|
||||
Template names are proper nouns and are never translated: Azurill, Bronzor, Chikorita, Ditgar,
|
||||
Ditto, Gengar, Glalie, Kakuna, Lapras, Leafish, Meowth, Onyx, Pikachu, Rhyhorn, Scizor.
|
||||
|
||||
## The document
|
||||
|
||||
**Resume** — the job-application document the app builds. Always a noun.
|
||||
Not the verb "to resume", "to continue", or "to restart". This is the single most common
|
||||
mistranslation in the catalogs: many locales render the standalone `Resume` label as the verb.
|
||||
In `application-form-sheet.tsx` the label marks the resume attached to a job application.
|
||||
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.
|
||||
|
||||
**Cover letter** — the letter accompanying a resume. Stored as its own document, with optional links to a resume and an application.
|
||||
|
||||
**Builder** — the editor where a resume is composed. A tool, not a construction worker or a
|
||||
person who builds.
|
||||
|
||||
**Template** — a visual design for a resume. Not a "model" in the machine-learning sense, and
|
||||
not a "sample" or "example" document. Beware in languages where the natural word for template
|
||||
is also the word for model: the app uses "model" separately, for AI models.
|
||||
|
||||
**Section** — one block of a resume, such as Experience or Education. Not a legal section or a
|
||||
document chapter.
|
||||
|
||||
**Item** — one entry inside a section, for example a single job or a single degree. Generic on
|
||||
purpose. Not "product", "article", or "column".
|
||||
|
||||
**Page** — one physical page of the rendered resume. Not a web page.
|
||||
|
||||
**Columns** — the column count of a resume layout. Not database or spreadsheet columns.
|
||||
|
||||
**Slug** — the URL-safe identifier in a resume's public address. Usually kept in English or
|
||||
transliterated; never translated as "snail".
|
||||
|
||||
### Resume section names
|
||||
|
||||
These are the built-in section presets, defined in `apps/web/src/libs/resume/section.tsx` and
|
||||
`apps/web/src/dialogs/resume/sections/custom.tsx`. Translate them the way a resume in the target
|
||||
language would label them:
|
||||
|
||||
**Basics** — name, contact details, and headline. Not "fundamentals" or "basic settings".
|
||||
|
||||
**Summary** — the short personal statement at the top of a resume. Not a summary of the app, and
|
||||
not an AI-generated abstract.
|
||||
|
||||
**Profiles** — links to the user's social and professional accounts (LinkedIn, GitHub). Plural.
|
||||
Distinct from **Profile**, below, which is the user's own account page. These two are different
|
||||
things and several catalogs have collapsed them into one word.
|
||||
|
||||
**Volunteer** — volunteering experience. A noun naming a section, not the verb "to volunteer".
|
||||
|
||||
Also: Experience, Education, Skills, Languages, Awards, Certifications, Interests, Projects,
|
||||
Publications, References, Custom.
|
||||
|
||||
## The application tracker
|
||||
|
||||
**Applications** — job applications the user has submitted. Not software applications, apps, or
|
||||
programs. Frequently mistranslated as the software sense.
|
||||
|
||||
**Board** — the kanban board view of applications, arranged in columns by stage. Not a board of
|
||||
directors, a committee, a plank, or a noticeboard.
|
||||
|
||||
**Stage** — where an application sits in the pipeline (applied, interviewing, offer, rejected).
|
||||
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).
|
||||
Singular, and specific to one application. Not a source code file and not a data source.
|
||||
|
||||
**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.
|
||||
|
||||
**Table** — the table view of applications, one of the view options next to Board and List. Not a
|
||||
piece of furniture.
|
||||
|
||||
**Archive** — a verb in this context: to move an application out of the active list. Not the
|
||||
noun "an archive". It is a menu action and pairs with **Unarchive**; almost every locale had the
|
||||
noun here.
|
||||
|
||||
**Applied on** — the date the user submitted the application. "Applied" is the job-application
|
||||
verb, not "applied a substance onto a surface" and not "applied a patch".
|
||||
|
||||
**Mark rejected / Mark as…** — "Mark" is the verb, to set a status. It is not the given name Mark.
|
||||
|
||||
**Match score** — how well a resume matches a job description. A degree of correspondence, not a
|
||||
sporting fixture.
|
||||
|
||||
**Fit**, as in "Score my fit" or "Strong fit" — how well the user suits the role. Not physical
|
||||
fitness, and not how clothing fits.
|
||||
|
||||
**A stretch** — a role the user is unlikely to get, an ambitious application. Not a stretching
|
||||
exercise.
|
||||
|
||||
**Notes** — the user's free-text notes on an application. Compare **Note** in the ATS checker,
|
||||
which is not the same thing.
|
||||
|
||||
**Timeline** — the dated history of one application.
|
||||
|
||||
## The AI agent
|
||||
|
||||
**Threads** — conversations with the AI agent. The chat sense, as in a message thread. Not
|
||||
sewing thread, not string, not yarn, and not a CPU thread. Several locales use the textile word.
|
||||
|
||||
**Provider** — a third-party AI service the user configures, such as OpenAI or Anthropic. A
|
||||
service supplier. Not a healthcare provider, and not a person who provides for a family.
|
||||
|
||||
**Model** — the specific AI model chosen from a provider, such as Claude Sonnet or GPT. Not a
|
||||
**Template** (several locales used the same word for both), not a device model or product
|
||||
variant, and not a "style" or "pattern".
|
||||
|
||||
**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",
|
||||
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
|
||||
dressmaking or sewing.
|
||||
|
||||
**Sources** — the citations the agent attaches to an answer. Plural, and distinct from **Source**
|
||||
in the application tracker above.
|
||||
|
||||
**Draft** — a working copy of a resume the agent edits. A noun.
|
||||
|
||||
**Patch** — a set of JSON Patch operations the agent proposes. Kept in English in most catalogs.
|
||||
Not a cloth patch, a scrap of fabric, an adhesive bandage, or a connector.
|
||||
|
||||
## The ATS checker
|
||||
|
||||
**ATS** — applicant tracking system: recruiting software that parses resumes. Spell it out on
|
||||
first use in languages where the acronym is unfamiliar. It is not a drug test, a transmission,
|
||||
or any other expansion of the letters; at least one catalog translated `ATS Check` as a test for
|
||||
amphetamines.
|
||||
|
||||
**Readability, Layout, Sections, Contact details, Dates, Writing** — the six check categories, in
|
||||
`apps/web/src/features/ats-checker/messages.ts`. "Layout" here means page geometry and reading
|
||||
order, not the builder's layout settings.
|
||||
|
||||
**Blocker, Warning, Tip** — the three severity levels of a finding.
|
||||
|
||||
**Note** — the label for an informational finding, in
|
||||
the resume editor's Check panel. A severity label,
|
||||
not a written note. Unrelated to **Notes** in the application tracker.
|
||||
|
||||
**Parse / parsing** — software reading text out of the PDF.
|
||||
|
||||
## Account and security
|
||||
|
||||
**Passkey / Passkeys** — a WebAuthn credential that replaces a password, stored on the user's
|
||||
device or security key. **It is not a password.** Many catalogs translate it with their word for
|
||||
"password", which is actively confusing: both appear together on the security settings page, so
|
||||
the user cannot tell which credential a message refers to. If the target language has no
|
||||
established term, keep "passkey" in English rather than reusing the word for password.
|
||||
|
||||
**Password** — the ordinary secret. Distinct from the above, always.
|
||||
|
||||
**Two-factor authentication (2FA)** — a second verification step at sign-in.
|
||||
|
||||
**Backup codes** — single-use codes for signing in when the second factor is unavailable.
|
||||
|
||||
**API key** — a token for programmatic access. **Key** on its own, in `ai-section.tsx`, means the
|
||||
AI provider's API key. Not a physical door key, not a keyboard key, and not the adjective "key"
|
||||
in the sense of crucial or main.
|
||||
|
||||
**Session** — an active sign-in on one device.
|
||||
|
||||
**Sign in / Sign out** — the app's chosen verbs. Prefer the locale's equivalent of "sign in"
|
||||
over "log in" where both exist, and keep whichever the catalog already uses consistently.
|
||||
|
||||
## Navigation and app shell
|
||||
|
||||
**Dashboard** — the main page after signing in, listing resumes and applications. Not a vehicle
|
||||
dashboard, an instrument panel, or a control panel in the machinery sense.
|
||||
|
||||
**Profile** — the user's own account settings page. Distinct from **Profiles**, the resume
|
||||
section, above.
|
||||
|
||||
**Lock / Unlock** — verbs: to make a resume read-only, and to release it.
|
||||
|
||||
**Tags** — user-defined labels for organizing resumes and applications.
|
||||
|
||||
**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
|
||||
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.
|
||||
|
||||
## Verbs that read as adjectives or nouns
|
||||
|
||||
Button labels and `aria-label` strings are usually **imperative verbs**: they say what the
|
||||
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.
|
||||
|
||||
**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
|
||||
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`.
|
||||
|
||||
**Close** — likewise the verb, as in `Close AI assistant`. Not "an assistant for closing things",
|
||||
and not the adjective "close/nearby".
|
||||
|
||||
The app names two different surfaces here, and both strings are real: **AI agent** is the
|
||||
full workspace at `/agent`, opened from the builder dock (`Open AI agent`), while **AI assistant**
|
||||
is the panel that slides out inside the builder (`Open AI assistant`, `Close AI assistant`).
|
||||
Translate them as two distinct names, the way the English does.
|
||||
|
||||
**Clear** — the verb, to empty a field or remove filters. Not the adjective "transparent",
|
||||
"obvious", or "clear-cut".
|
||||
|
||||
**Lock / Unlock** — verbs. `Unlock` is specifically the opposite of `Lock`, not a synonym for
|
||||
`Open`; several catalogs collapsed the two and produced two identical menu items.
|
||||
|
||||
**Archive / Unarchive**, **Mark**, **Tailor**, **Duplicate**, **Import**, **Export**, **Share**,
|
||||
**Star** — all verbs when they appear as a control label. Check the `#:` source reference if you
|
||||
are unsure whether a given string is a button or a heading.
|
||||
|
||||
## Message syntax
|
||||
|
||||
These are not words to translate, and breaking them breaks the interface:
|
||||
|
||||
- `{name}`, `{count}`, `{email}`, `{MAX_IMPORT}`, `{overflow}` — value placeholders. Keep the
|
||||
spelling exactly, keep every one that appears in the source, and add none.
|
||||
- `{count, plural, one {# item} other {# items}}` — ICU plurals. Translate only the text inside
|
||||
the inner braces, keep the `#`, and use the plural categories your language actually needs
|
||||
(Arabic and the Slavic languages legitimately have more than English).
|
||||
- `<0>…</0>`, `<1>…</1>`, `<0/>` — indexes pointing at interface elements such as links and bold
|
||||
spans. Keep every index and keep the pairs matched. You may move a tag inside the sentence for
|
||||
word order, as long as it still wraps the corresponding words.
|
||||
|
||||
A missing or renamed placeholder is a runtime error, not a style problem.
|
||||
|
||||
## Adding to this file
|
||||
|
||||
When a translator asks what a term means, the answer belongs here. When you add a term, say what
|
||||
it means in this app and, if the English word is ambiguous, say plainly which sense is wrong.
|
||||
@@ -1,3 +1,9 @@
|
||||
> [!IMPORTANT]
|
||||
> **Repository moved:** Reactive Resume now lives at **[`reactive-resume/reactive-resume`](https://github.com/reactive-resume/reactive-resume)** on GitHub.
|
||||
> **Docker Hub stays at `amruthpillai/reactive-resume`.** GHCR builds now publish to `ghcr.io/reactive-resume/reactive-resume`.
|
||||
> Verified image tags: `latest`, `v5`, `v5.3`, and `v5.3.0` (AMD64 and ARM64). The current version was rebuilt and production redeployed for this rename; no new GitHub release or version bump was made. See [migration details](https://github.com/reactive-resume/reactive-resume/issues/3503).
|
||||
> GitHub Sponsors and Open Collective funding links remain unchanged.
|
||||
|
||||
<div align="center">
|
||||
<a href="https://rxresu.me">
|
||||
<img src="apps/web/public/opengraph/banner.jpg" alt="Reactive Resume" />
|
||||
@@ -5,7 +11,7 @@
|
||||
|
||||
<h1>Reactive Resume</h1>
|
||||
|
||||
<p>Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.</p>
|
||||
<p>Reactive Resume is a free and open-source resume builder that makes it easy to create, update, and share your resume.</p>
|
||||
|
||||
<p>
|
||||
<a href="https://rxresu.me"><strong>Get Started</strong></a>
|
||||
@@ -14,9 +20,9 @@
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<img src="https://img.shields.io/github/package-json/v/amruthpillai/reactive-resume?style=flat-square" alt="Reactive Resume Version">
|
||||
<img src="https://img.shields.io/github/stars/amruthpillai/Reactive-Resume?style=flat-square" alt="GitHub Stars">
|
||||
<img src="https://img.shields.io/github/license/amruthpillai/Reactive-Resume?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/github/package-json/v/reactive-resume/reactive-resume?style=flat-square" alt="Reactive Resume Version">
|
||||
<img src="https://img.shields.io/github/stars/reactive-resume/reactive-resume?style=flat-square" alt="GitHub Stars">
|
||||
<img src="https://img.shields.io/github/license/reactive-resume/reactive-resume?style=flat-square" alt="License" />
|
||||
<img src="https://img.shields.io/docker/pulls/amruthpillai/reactive-resume?style=flat-square" alt="Docker Pulls" />
|
||||
<a href="https://discord.gg/aSyA5ZSxpb"><img src="https://img.shields.io/discord/1173518977851473940?style=flat-square&label=discord" alt="Discord" /></a>
|
||||
<a href="https://crowdin.com/project/reactive-resume"><img src="https://badges.crowdin.net/reactive-resume/localized.svg?style=flat-square" alt="Crowdin" /></a>
|
||||
@@ -27,38 +33,24 @@
|
||||
|
||||
---
|
||||
|
||||
Reactive Resume makes building resumes straightforward. Pick a template, fill in your details, and export to PDF—no account required for basic use. For those who want more control, the entire application can be self-hosted on your own infrastructure.
|
||||
Pick a template, fill in your details, and export to PDF. Basic use needs no account. If you want more control, you can run the whole application on your own infrastructure.
|
||||
|
||||
Built with privacy as a core principle, Reactive Resume gives you complete ownership of your data. The codebase is fully open-source under the MIT license, with no tracking, no ads, and no hidden costs.
|
||||
|
||||
## Sponsors
|
||||
|
||||
Reactive Resume stays free, open-source, and independent because companies choose to support the work behind it. Thank you to every sponsor who helps fund hosting, maintenance, and continued development for the community.
|
||||
|
||||
<p>
|
||||
<a href="https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume">
|
||||
<img src="apps/web/public/sponsors/atlas-cloud-logo-white.svg" alt="Atlas Cloud" width="320" />
|
||||
</a>
|
||||
</p>
|
||||
|
||||
[Atlas Cloud](https://www.atlascloud.ai/?utm_source=github&utm_medium=link&utm_campaign=reactive-resume) supports Reactive Resume as a project sponsor. Atlas Cloud provides a unified AI platform for developers, with access to hundreds of models for chat, image generation, video generation, media processing, and GPU cloud workloads through one API key, one endpoint, and one billing account.
|
||||
|
||||
If your company would like to sponsor Reactive Resume, email [hello@amruthpillai.com](mailto:hello@amruthpillai.com).
|
||||
You own your data. The codebase is open source under the MIT license, with no tracking, no ads, and no hidden costs.
|
||||
|
||||
## Features
|
||||
|
||||
**Resume Building**
|
||||
|
||||
- Real-time preview as you type
|
||||
- Live preview as you type
|
||||
- Multiple export formats (PDF, JSON, DOCX)
|
||||
- Drag-and-drop section ordering
|
||||
- Custom sections for any content type
|
||||
- Rich text editor with formatting support
|
||||
- Rich text editor
|
||||
|
||||
**Templates**
|
||||
|
||||
- Professionally designed templates
|
||||
- A4 and Letter size support
|
||||
- 15 templates to choose from
|
||||
- A4 and Letter page sizes
|
||||
- Customizable colors, fonts, and spacing
|
||||
- Structured Style Rules for section and text styling
|
||||
|
||||
@@ -75,7 +67,7 @@ If your company would like to sponsor Reactive Resume, email [hello@amruthpillai
|
||||
- Multi-language support
|
||||
- Share resumes via unique links
|
||||
- Import from JSON Resume format
|
||||
- Dark mode support
|
||||
- Dark mode
|
||||
- Passkey and two-factor authentication
|
||||
|
||||
## Templates
|
||||
@@ -157,7 +149,7 @@ The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||
git clone --depth=1 https://github.com/reactive-resume/reactive-resume.git reactive-resume
|
||||
cd reactive-resume
|
||||
|
||||
# Start all services
|
||||
@@ -167,15 +159,13 @@ docker compose up -d
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
[](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
|
||||
|
||||
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
| ---------------- | ------------------------------- |
|
||||
| Framework | TanStack Start (React 19, Vite) |
|
||||
| Framework | TanStack Router (React 19, Vite) |
|
||||
| Runtime | Node.js |
|
||||
| Language | TypeScript |
|
||||
| Database | PostgreSQL with Drizzle ORM |
|
||||
@@ -187,24 +177,30 @@ For detailed setup instructions, environment configuration, and self-hosting gui
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
|
||||
| Guide | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------- |
|
||||
| [Getting Started](https://docs.rxresu.me/getting-started) | First-time setup and basic usage |
|
||||
| [Self-Hosting](https://docs.rxresu.me/self-hosting/docker) | Deploy on your own server |
|
||||
| [Development Setup](https://docs.rxresu.me/contributing/development) | Local development environment |
|
||||
| [Project Architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
|
||||
| [Development setup](https://docs.rxresu.me/contributing/development) | Local development environment |
|
||||
| [Project architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
|
||||
| [Exporting Your Resume](https://docs.rxresu.me/guides/exporting-your-resume) | PDF and JSON export options |
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
Reactive Resume can be self-hosted using Docker. The stack includes:
|
||||
Reactive Resume supports Docker and Vercel Hobby.
|
||||
|
||||
[](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.
|
||||
|
||||
For Docker, the stack includes:
|
||||
|
||||
- **PostgreSQL** — Database for storing user data and resumes
|
||||
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
||||
|
||||
> **From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. 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:
|
||||
|
||||
@@ -213,14 +209,14 @@ Pull the latest image from Docker Hub or GitHub Container Registry:
|
||||
docker pull amruthpillai/reactive-resume:latest
|
||||
|
||||
# GitHub Container Registry
|
||||
docker pull ghcr.io/amruthpillai/reactive-resume:latest
|
||||
docker pull ghcr.io/reactive-resume/reactive-resume:latest
|
||||
```
|
||||
|
||||
See the [self-hosting guide](https://docs.rxresu.me/self-hosting/docker) for complete instructions.
|
||||
|
||||
## Support
|
||||
|
||||
Reactive Resume is and always will be free and open-source. If it has helped you land a job or saved you time, please consider supporting continued development:
|
||||
Reactive Resume is and always will be free and open source. If it has helped you land a job or saved you time, please consider supporting continued development:
|
||||
|
||||
<p>
|
||||
<a href="https://github.com/sponsors/AmruthPillai">
|
||||
@@ -235,23 +231,27 @@ Other ways to support:
|
||||
|
||||
- Star this repository
|
||||
- Report reproducible bugs and suggest actionable features
|
||||
- Help other users in [GitHub Discussions](https://github.com/amruthpillai/reactive-resume/discussions/categories/q-a)
|
||||
- Help other users in [GitHub Discussions](https://github.com/reactive-resume/reactive-resume/discussions/categories/q-a)
|
||||
- Improve documentation
|
||||
- Help with translations
|
||||
|
||||
<a href="https://blacksmith.sh/">
|
||||
<img width="368" height="126" alt="powered-by-blacksmith" src="https://github.com/user-attachments/assets/3e95d11b-4579-4082-8d0c-6b574f925625" />
|
||||
</a>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=amruthpillai%2Freactive-resume&type=date&legend=top-left">
|
||||
<a href="https://www.star-history.com/?repos=reactive-resume%2Freactive-resume&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=amruthpillai/reactive-resume&type=date&legend=top-left&sealed_token=QmaOn4Ech499R6kpQe8ONn911UjGUaJfQBT0MXlQLU9hTo-Ie7lTxIILWbBvmtzDGHk7ziWKN_N5iM5mgP8widn_FGHd9-PHNokPtSji8XLgbFpqatgyqIDPnOys-IhO40W3J0HeH07FL-Q8Bq6ArRk3LDtJDwjh4m0ya-2L59ULb7BaqxkSDuCytkCr" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=reactive-resume/reactive-resume&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions make open-source thrive. Whether fixing a typo or adding a feature, all contributions are welcome.
|
||||
Every contribution helps, whether it is a typo fix or a new feature.
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch (`git checkout -b feature/amazing-feature`)
|
||||
@@ -259,9 +259,9 @@ Contributions make open-source thrive. Whether fixing a typo or adding a feature
|
||||
4. Push to the branch (`git push origin feature/amazing-feature`)
|
||||
5. Open a Pull Request
|
||||
|
||||
See the [development setup guide](https://docs.rxresu.me/contributing/development) for detailed instructions on how to set up the project locally.
|
||||
See the [development setup guide](https://docs.rxresu.me/contributing/development) for how to run the project locally.
|
||||
|
||||
Maintainers review the [`status: needs triage` queue](https://github.com/amruthpillai/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
|
||||
Maintainers review the [`status: needs triage` queue](https://github.com/reactive-resume/reactive-resume/issues?q=is%3Aissue+is%3Aopen+label%3A%22status%3A+needs+triage%22)
|
||||
weekly. Triaged bugs become `status: confirmed`; feature proposals become `status: accepted`; reports that need details become
|
||||
`status: needs info`.
|
||||
|
||||
|
||||
+1624
File diff suppressed because it is too large
Load Diff
+57
-54
@@ -11,42 +11,40 @@
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --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"
|
||||
},
|
||||
"imports": {
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.44",
|
||||
"@ai-sdk/cerebras": "^3.0.39",
|
||||
"@ai-sdk/cohere": "^4.0.33",
|
||||
"@ai-sdk/deepseek": "^3.0.35",
|
||||
"@ai-sdk/fireworks": "^3.0.42",
|
||||
"@ai-sdk/google": "^4.0.54",
|
||||
"@ai-sdk/groq": "^4.0.33",
|
||||
"@ai-sdk/mistral": "^4.0.35",
|
||||
"@ai-sdk/openai": "^4.0.50",
|
||||
"@ai-sdk/openai-compatible": "^3.0.39",
|
||||
"@ai-sdk/perplexity": "^4.0.34",
|
||||
"@ai-sdk/togetherai": "^3.0.40",
|
||||
"@ai-sdk/xai": "^4.0.47",
|
||||
"@aws-sdk/client-s3": "^3.1119.0",
|
||||
"@better-auth/api-key": "^1.7.2",
|
||||
"@better-auth/drizzle-adapter": "^1.7.2",
|
||||
"@better-auth/infra": "^0.4.3",
|
||||
"@better-auth/oauth-provider": "^1.7.2",
|
||||
"@better-auth/passkey": "^1.7.2",
|
||||
"@ai-sdk/anthropic": "^4.0.68",
|
||||
"@ai-sdk/cerebras": "^3.0.59",
|
||||
"@ai-sdk/cohere": "^4.0.52",
|
||||
"@ai-sdk/deepseek": "^3.0.56",
|
||||
"@ai-sdk/fireworks": "^3.0.62",
|
||||
"@ai-sdk/google": "^4.0.85",
|
||||
"@ai-sdk/groq": "^4.0.52",
|
||||
"@ai-sdk/mistral": "^4.0.54",
|
||||
"@ai-sdk/openai": "^4.0.81",
|
||||
"@ai-sdk/openai-compatible": "^3.0.59",
|
||||
"@ai-sdk/perplexity": "^5.0.3",
|
||||
"@ai-sdk/togetherai": "^3.0.60",
|
||||
"@ai-sdk/xai": "^5.0.12",
|
||||
"@aws-sdk/client-s3": "^3.1143.0",
|
||||
"@better-auth/api-key": "^1.7.6",
|
||||
"@better-auth/drizzle-adapter": "^1.7.6",
|
||||
"@better-auth/infra": "^0.4.13",
|
||||
"@better-auth/oauth-provider": "^1.7.6",
|
||||
"@better-auth/passkey": "^1.7.6",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.0",
|
||||
"@orpc/client": "^1.15.0",
|
||||
"@orpc/experimental-ratelimit": "^1.15.0",
|
||||
"@orpc/json-schema": "^1.15.0",
|
||||
"@orpc/openapi": "^1.15.0",
|
||||
"@orpc/server": "^1.15.0",
|
||||
"@orpc/zod": "^1.15.0",
|
||||
"@react-pdf/renderer": "^4.8.1",
|
||||
"@formepdf/core": "0.25.0",
|
||||
"@formepdf/react": "0.25.0",
|
||||
"@hono/node-server": "^2.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"@orpc/client": "^1.15.4",
|
||||
"@orpc/experimental-ratelimit": "^1.15.4",
|
||||
"@orpc/json-schema": "^1.15.4",
|
||||
"@orpc/openapi": "^1.15.4",
|
||||
"@orpc/server": "^1.15.4",
|
||||
"@orpc/zod": "^1.15.4",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
@@ -54,46 +52,51 @@
|
||||
"@reactive-resume/mcp": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@sindresorhus/slugify": "^3.0.1",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^7.0.83",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.7.2",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"@vercel/blob": "^2.8.0",
|
||||
"@vercel/functions": "^3.9.9",
|
||||
"ai": "^7.0.122",
|
||||
"bcryptjs": "^3.0.3",
|
||||
"better-auth": "1.7.6",
|
||||
"cjk-regex": "^3.5.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"deepmerge-ts": "^8.0.2",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.51.0",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.13.5",
|
||||
"fast-png": "^8.0.0",
|
||||
"fflate": "^0.8.3",
|
||||
"hono": "^4.13.11",
|
||||
"ioredis": "^6.0.0",
|
||||
"jose": "^6.2.12",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^9.0.1",
|
||||
"nodemailer": "^9.0.5",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"nodemailer": "^10.0.12",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pg": "^8.23.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.8",
|
||||
"react-email": "^6.9.3",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.35.4",
|
||||
"react": "^19.3.0",
|
||||
"react-email": "^6.11.0",
|
||||
"react-reconciler": "0.34.0",
|
||||
"resumable-stream": "^2.2.13",
|
||||
"sanitize-html": "^2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"tokenx": "^2.1.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.4.3"
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^26.4.0",
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react": "^19.3.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"tsdown": "^0.22.14",
|
||||
"tsx": "^4.23.12",
|
||||
"tsdown": "^0.23.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^4.1.11"
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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__;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -113,20 +114,8 @@ describe("createApp", () => {
|
||||
await expect(response.text()).resolves.toBe("oauth");
|
||||
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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();
|
||||
});
|
||||
// The first test pays for the cold import of the whole app, which takes seconds under a parallel run.
|
||||
}, 15_000);
|
||||
|
||||
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
@@ -173,30 +162,10 @@ describe("createApp", () => {
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["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) => {
|
||||
it("routes GET / to the web app handler so SEO markup is injected", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request(`http://localhost:3001${pathname}`, { method });
|
||||
|
||||
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 request = new Request("http://localhost:3001/");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
@@ -204,4 +173,44 @@ describe("createApp", () => {
|
||||
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
|
||||
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) => {
|
||||
const { createApp } = await import("./app");
|
||||
mocks.serveWebDistStatic.mockImplementationOnce(async (_context: unknown, next: () => Promise<void>) => {
|
||||
await next();
|
||||
});
|
||||
const response = await createApp().request(`http://localhost:3000${path}?sig=signed`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("web");
|
||||
expect(response.headers.get("content-security-policy")).toBe("frame-ancestors 'none'");
|
||||
expect(response.headers.get("x-frame-options")).toBe("DENY");
|
||||
expect(response.headers.get("referrer-policy")).toBe("no-referrer");
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
});
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { Context } from "hono";
|
||||
import { isIP } from "node:net";
|
||||
import { getConnInfo } from "@hono/node-server/conninfo";
|
||||
import { Hono } from "hono";
|
||||
import { compress } from "hono/compress";
|
||||
import { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
import {
|
||||
@@ -33,18 +35,33 @@ const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||
}
|
||||
};
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono<ServerEnvironment>();
|
||||
type AppOptions = {
|
||||
serveStatic?: boolean;
|
||||
trustedClient?: (request: Request) => string;
|
||||
};
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw, getTrustedClient(c)));
|
||||
export function createApp(options: AppOptions = {}) {
|
||||
const app = new Hono<ServerEnvironment>();
|
||||
const client = (c: Context<ServerEnvironment>) => options.trustedClient?.(c.req.raw) ?? getTrustedClient(c);
|
||||
|
||||
app.use("/auth/*", async (c, next) => {
|
||||
await next();
|
||||
c.header("Content-Security-Policy", "frame-ancestors 'none'");
|
||||
c.header("X-Frame-Options", "DENY");
|
||||
c.header("Referrer-Policy", "no-referrer");
|
||||
c.header("Cache-Control", "no-store");
|
||||
});
|
||||
|
||||
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/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.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/resumes/:username/:slug/pdf", (c) =>
|
||||
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), getTrustedClient(c)),
|
||||
handlePublicResumePdf(c.req.raw, c.req.param("username"), c.req.param("slug"), client(c)),
|
||||
);
|
||||
app.get("/api/resumes/:id/pdf", (c) => handleResumePdfDownload(c.req.raw, c.req.param("id")));
|
||||
app.get("/api/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
@@ -65,10 +82,15 @@ export function createApp() {
|
||||
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" }));
|
||||
|
||||
// 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
|
||||
// return it verbatim, skipping the OpenGraph/Twitter/canonical/JSON-LD injection in handleWebApp.
|
||||
app.on(["GET", "HEAD"], "/", (c) => handleWebApp(c.req.raw));
|
||||
app.use("/*", serveWebDistStatic);
|
||||
if (options.serveStatic !== false) app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
return app;
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
consent: vi.fn(),
|
||||
continueOAuth: vi.fn(),
|
||||
handler: vi.fn(),
|
||||
env: {
|
||||
SERVER_PORT: 3001,
|
||||
@@ -14,6 +16,8 @@ vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
oauth2Consent: mocks.consent,
|
||||
oauth2Continue: mocks.continueOAuth,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
@@ -32,13 +36,49 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
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: [] }]])(
|
||||
"rejects non-object registration payload %j",
|
||||
async (body) => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({ message: "Invalid registration payload" });
|
||||
expect(mocks.handler).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", 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://evil.example.com/callback"] }),
|
||||
body: JSON.stringify({ redirect_uris: ["https://192.168.1.10/callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
@@ -51,20 +91,61 @@ describe("handleAuth", () => {
|
||||
expect(mocks.handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards custom-scheme dynamic OAuth redirect URIs when unsafe mode is enabled", async () => {
|
||||
it.each(["localhost", "127.0.0.1", "[::1]"])(
|
||||
"infers native application type for exact %s loopback callbacks",
|
||||
async (host) => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ redirect_uris: [`http://${host}:3210/callback`] }),
|
||||
}),
|
||||
);
|
||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
await expect(forwarded.json()).resolves.toMatchObject({
|
||||
application_type: "native",
|
||||
token_endpoint_auth_method: "none",
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["client_secret_basic", "client_secret_post"])(
|
||||
"keeps an explicitly registered %s so the client receives a client secret",
|
||||
async (method) => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
redirect_uris: ["https://example.com/callback"],
|
||||
token_endpoint_auth_method: method,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
await expect(forwarded.json()).resolves.toMatchObject({ token_endpoint_auth_method: method });
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
{ redirect_uris: ["https://example.com/callback"] },
|
||||
{ redirect_uris: ["http://localhost.evil.example/callback"] },
|
||||
{ redirect_uris: ["http://localhost:3210/callback"], application_type: "web" },
|
||||
{ redirect_uris: ["http://localhost:3210/callback", "https://example.com/callback"] },
|
||||
])("does not infer native for explicit web or non-loopback clients: %j", async (body) => {
|
||||
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", {
|
||||
await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["myapp://callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
expect((await forwarded.json()).application_type).not.toBe("native");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -91,7 +172,55 @@ describe("handleOAuth", () => {
|
||||
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
||||
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
||||
expect(callbackUrl.searchParams.has("exp")).toBe(false);
|
||||
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||
expect(callbackUrl.searchParams.get("exp")).toBe("123");
|
||||
expect(callbackUrl.searchParams.get("sig")).toBe("456");
|
||||
});
|
||||
it("continues signed authorization without approving consent on GET", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
mocks.continueOAuth.mockResolvedValueOnce(
|
||||
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
|
||||
);
|
||||
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
|
||||
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
|
||||
expect(mocks.continueOAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
|
||||
);
|
||||
expect(mocks.consent).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
||||
});
|
||||
|
||||
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
|
||||
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
|
||||
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
|
||||
mocks.continueOAuth.mockResolvedValueOnce(
|
||||
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
|
||||
);
|
||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
|
||||
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth provider response validation", () => {
|
||||
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
|
||||
"fails closed for malformed provider response %j",
|
||||
async (body) => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
|
||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(mocks.consent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { isIP } from "node:net";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
@@ -19,8 +17,6 @@ const oauthAuthorizeSanitizedParams = [
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
@@ -33,9 +29,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (!values.length) return;
|
||||
url.searchParams.delete(key);
|
||||
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||
@@ -56,6 +53,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
@@ -66,13 +67,29 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
const payload: unknown = await cloned.json();
|
||||
if (!isRegistrationPayload(payload)) return request;
|
||||
body = payload;
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
// MCP native clients often omit OIDC application_type. Infer it only for
|
||||
// exact HTTP loopback callbacks; the provider still validates every URI.
|
||||
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
|
||||
const allLoopback = body.redirect_uris.every(
|
||||
(uri: unknown) =>
|
||||
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
|
||||
);
|
||||
if (allLoopback) body.application_type = "native";
|
||||
}
|
||||
|
||||
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
|
||||
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
|
||||
// forcing it to "none" issues no client secret, so the client's own Basic/post
|
||||
// credentials are rejected at the token endpoint with 401 invalid_client.
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
body.token_endpoint_auth_method ??= "none";
|
||||
}
|
||||
|
||||
return new Request(url.toString(), {
|
||||
@@ -92,7 +109,11 @@ async function validateDynamicClientRegistrationRequest(request: Request): Promi
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
const payload: unknown = await cloned.json();
|
||||
if (!isRegistrationPayload(payload)) {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
body = payload;
|
||||
} catch {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
@@ -115,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);
|
||||
if (registrationValidationError) return registrationValidationError;
|
||||
|
||||
@@ -125,90 +151,68 @@ export async function handleAuth(request: Request) {
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export async function handleOAuth(request: Request) {
|
||||
try {
|
||||
return await resumeOAuth(request);
|
||||
} catch (error) {
|
||||
// Before-hooks can throw even when the provider is called with asResponse.
|
||||
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeOAuth(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000);
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
// Resume authorization without granting consent. The provider decides whether
|
||||
// the user must sign in, explicitly approve a client, or reuse an existing grant.
|
||||
// Its signed query must survive the login round trip byte-for-byte.
|
||||
const response = await auth.api.oauth2Continue({
|
||||
asResponse: true,
|
||||
request,
|
||||
headers: request.headers,
|
||||
body: { postLogin: true, oauth_query: url.search.slice(1) },
|
||||
});
|
||||
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
|
||||
if (!response.ok) return response;
|
||||
const result: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== "object" ||
|
||||
!("url" in result) ||
|
||||
typeof result.url !== "string" ||
|
||||
!result.url ||
|
||||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
|
||||
!URL.canParse(result.url, env.APP_URL)
|
||||
)
|
||||
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
const target = new URL(result.url, env.APP_URL);
|
||||
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
|
||||
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
|
||||
}
|
||||
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
|
||||
return redirectToOAuthLogin(target, true, headers);
|
||||
}
|
||||
headers.set("Location", result.url);
|
||||
return new Response(null, { status: 302, headers });
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
||||
return redirectToOAuthLogin(url);
|
||||
}
|
||||
|
||||
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
|
||||
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
|
||||
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
|
||||
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
|
||||
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
if ([...headers].length === 0) return response;
|
||||
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
|
||||
vi.mock("@reactive-resume/db/redis", () => ({ getRedis: () => ({ ping }) }));
|
||||
|
||||
import { handleHealth } from "./health";
|
||||
|
||||
describe("health failure reporting", () => {
|
||||
beforeEach(() => {
|
||||
execute.mockResolvedValue([]);
|
||||
healthcheck.mockResolvedValue({ status: "healthy" });
|
||||
ping.mockResolvedValue("PONG");
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it.each(["database", "storage", "redis"])("keeps thrown %s error details in server logs only", async (dependency) => {
|
||||
const detail = "Connection failed for private-user at internal.example:5432";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
({ database: execute, storage: healthcheck, redis: ping })[dependency]?.mockRejectedValueOnce(new Error(detail));
|
||||
|
||||
const response = await handleHealth();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(JSON.stringify(body)).not.toContain(detail);
|
||||
expect(body[dependency]).toMatchObject({
|
||||
status: "unhealthy",
|
||||
error: expect.stringContaining("health check failed"),
|
||||
});
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[Healthcheck]",
|
||||
expect.objectContaining({
|
||||
[dependency]: expect.objectContaining({ error: detail }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("redacts returned storage failures while preserving diagnostics in server logs", async () => {
|
||||
const detail = "Access denied to bucket private-bucket on internal.example";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
healthcheck.mockResolvedValueOnce({
|
||||
status: "unhealthy",
|
||||
type: "s3",
|
||||
message: detail,
|
||||
error: detail,
|
||||
internalDetail: detail,
|
||||
});
|
||||
|
||||
const response = await handleHealth();
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(body.storage).toEqual({
|
||||
status: "unhealthy",
|
||||
type: "s3",
|
||||
latencyMs: expect.any(Number),
|
||||
error: "Storage health check failed.",
|
||||
});
|
||||
expect(JSON.stringify(body)).not.toContain(detail);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"[Healthcheck]",
|
||||
expect.objectContaining({ storage: expect.objectContaining({ error: detail, message: detail }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -2,6 +2,8 @@ import { sql } from "drizzle-orm";
|
||||
import { withTimeout } from "es-toolkit";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { getRedis } from "@reactive-resume/db/redis";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const HEALTHCHECK_TIMEOUT_MS = 1_500;
|
||||
|
||||
@@ -31,6 +33,16 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
}
|
||||
}
|
||||
|
||||
function publicCheck(check: CheckResult, name: "Database" | "Storage" | "Redis"): CheckResult {
|
||||
if (check.status === "healthy") return check;
|
||||
return {
|
||||
status: check.status,
|
||||
latencyMs: check.latencyMs,
|
||||
error: `${name} health check failed.`,
|
||||
...(check.type === "local" || check.type === "s3" || check.type === "blob" ? { type: check.type } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
// ponytail: inner try/catches removed; runCheck's outer catch handles all errors
|
||||
async function checkDatabase() {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
@@ -40,30 +52,33 @@ async function checkDatabase() {
|
||||
const checkStorage = () => getStorageService().healthcheck();
|
||||
|
||||
export async function handleHealth() {
|
||||
const [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
const redisClient = getRedis();
|
||||
const [database, storage, redis] = await Promise.all([
|
||||
runCheck(checkDatabase),
|
||||
runCheck(checkStorage),
|
||||
redisClient
|
||||
? runCheck(async () => {
|
||||
await redisClient.ping();
|
||||
return { status: "healthy" };
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
const status = [database, storage, redis].some((check) => check?.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
version: appVersion,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
database: publicCheck(database, "Database"),
|
||||
storage: publicCheck(storage, "Storage"),
|
||||
...(redis ? { redis: publicCheck(redis, "Redis") } : {}),
|
||||
};
|
||||
|
||||
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();
|
||||
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,
|
||||
});
|
||||
return Response.json(checks, { status: checks.status === "unhealthy" ? 503 : 200 });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("@reactive-resume/email/transport", () => ({ sendEmail: vi.fn() }));
|
||||
|
||||
// Run only against an explicitly supplied disposable database, after applying migrations.
|
||||
const databaseURL = process.env.OAUTH_TEST_DATABASE_URL;
|
||||
|
||||
describe.skipIf(!databaseURL)("MCP OAuth flow with PostgreSQL", () => {
|
||||
it("registers public clients, resumes login, and exchanges a resource-bound PKCE code", 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");
|
||||
// Better Auth disables origin checks by default in test mode; exercise production behavior.
|
||||
const { auth } = await import("@reactive-resume/auth/config");
|
||||
(await auth.$context).skipOriginCheck = false;
|
||||
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: "OAuth integration", redirect_uris: [redirectURI] }),
|
||||
);
|
||||
expect(registration.status, await registration.clone().text()).toBe(201);
|
||||
const client = await registration.json();
|
||||
expect(client.token_endpoint_auth_method).toBe("none");
|
||||
|
||||
const deniedRegistration = await handleAuth(
|
||||
request("oauth2/register", {
|
||||
client_name: "Denied resource",
|
||||
redirect_uris: [redirectURI],
|
||||
resources: ["https://untrusted.example/mcp"],
|
||||
}),
|
||||
);
|
||||
expect(deniedRegistration.status).toBe(400);
|
||||
await expect(deniedRegistration.json()).resolves.toMatchObject({ error: "invalid_target" });
|
||||
|
||||
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}`));
|
||||
expect(authorize.status, await authorize.clone().text()).toBe(302);
|
||||
const bridgeURL = authorize.headers.get("location");
|
||||
expect(bridgeURL).toBeTruthy();
|
||||
const login = await handleOAuth(new Request(new URL(bridgeURL ?? "", origin)));
|
||||
const loginURL = new URL(login.headers.get("location") ?? "", origin);
|
||||
const callbackURL = loginURL.searchParams.get("callbackURL");
|
||||
expect(callbackURL).toContain("sig=");
|
||||
expect(callbackURL).toContain("resource=");
|
||||
|
||||
const unique = randomBytes(6).toString("hex");
|
||||
const signup = await handleAuth(
|
||||
request("sign-up/email", {
|
||||
name: "OAuth Test",
|
||||
email: `oauth-${unique}@example.com`,
|
||||
username: `oauth-${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 tamperedURL = new URL(`${origin}${callbackURL}`);
|
||||
tamperedURL.searchParams.set("state", "tampered");
|
||||
const tampered = await handleOAuth(new Request(tamperedURL, { headers: { cookie } }));
|
||||
expect(tampered.status).toBe(400);
|
||||
await expect(tampered.json()).resolves.toMatchObject({ error: "invalid_signature" });
|
||||
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
|
||||
expect(callback.status, await callback.clone().text()).toBe(302);
|
||||
const consentURL = new URL(callback.headers.get("location") ?? "", origin);
|
||||
expect(consentURL.pathname).toBe("/auth/consent");
|
||||
expect(consentURL.searchParams.has("code")).toBe(false);
|
||||
const oauth_query = consentURL.search.slice(1);
|
||||
const consents = async () => {
|
||||
const response = await handleAuth(new Request(`${origin}/api/auth/oauth2/get-consents`, { headers: { cookie } }));
|
||||
expect(response.status).toBe(200);
|
||||
return response.json();
|
||||
};
|
||||
expect(await consents()).toEqual([]);
|
||||
const silent = await handleAuth(
|
||||
new Request(`${origin}/api/auth/oauth2/authorize?${query}&prompt=none`, { headers: { cookie } }),
|
||||
);
|
||||
expect(new URL(silent.headers.get("location") ?? "").searchParams.get("error")).toBe("consent_required");
|
||||
const tamperedConsentQuery = new URLSearchParams(oauth_query);
|
||||
tamperedConsentQuery.set("scope", "openid profile email offline_access");
|
||||
const tamperedConsent = await handleAuth(
|
||||
request(
|
||||
"oauth2/consent",
|
||||
{
|
||||
accept: true,
|
||||
oauth_query: tamperedConsentQuery.toString(),
|
||||
},
|
||||
cookie,
|
||||
),
|
||||
);
|
||||
expect(tamperedConsent.status).toBe(400);
|
||||
expect(await consents()).toEqual([]);
|
||||
const csrf = await handleAuth(
|
||||
new Request(`${origin}/api/auth/oauth2/consent`, {
|
||||
method: "POST",
|
||||
headers: { cookie, origin: "https://untrusted.example", "content-type": "application/json" },
|
||||
body: JSON.stringify({ accept: true, oauth_query }),
|
||||
}),
|
||||
);
|
||||
expect(csrf.status).toBe(403);
|
||||
const denied = await handleAuth(request("oauth2/consent", { accept: false, oauth_query }, cookie));
|
||||
expect(denied.status, await denied.clone().text()).toBe(200);
|
||||
const deniedURL = new URL((await denied.json()).url);
|
||||
expect(deniedURL.searchParams.get("error")).toBe("access_denied");
|
||||
expect(deniedURL.searchParams.get("state")).toBe("opaque-state");
|
||||
expect(deniedURL.searchParams.has("code")).toBe(false);
|
||||
expect(await consents()).toEqual([]);
|
||||
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
|
||||
expect(accepted.status, await accepted.clone().text()).toBe(200);
|
||||
expect(await consents()).toHaveLength(1);
|
||||
const codeURL = new URL((await accepted.json()).url);
|
||||
expect(codeURL.origin).toBe(new URL(redirectURI).origin);
|
||||
expect(codeURL.searchParams.get("state")).toBe("opaque-state");
|
||||
const code = codeURL.searchParams.get("code");
|
||||
expect(code).toBeTruthy();
|
||||
const tokenRequest = () =>
|
||||
new Request(`${origin}/api/auth/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/x-www-form-urlencoded" },
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
client_id: client.client_id,
|
||||
code: code ?? "",
|
||||
redirect_uri: redirectURI,
|
||||
code_verifier: verifier,
|
||||
resource: `${origin}/mcp`,
|
||||
}),
|
||||
});
|
||||
const tokenResponse = await handleAuth(tokenRequest());
|
||||
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
|
||||
const token = await tokenResponse.json();
|
||||
expect(token.access_token).toBeTruthy();
|
||||
expect(token.refresh_token).toBeTruthy();
|
||||
const claims = JSON.parse(Buffer.from(token.access_token.split(".")[1], "base64url").toString());
|
||||
expect([claims.aud].flat()).toContain(`${origin}/mcp`);
|
||||
expect((await handleAuth(tokenRequest())).status).toBe(400);
|
||||
}, 30_000);
|
||||
it.each(["login", "max-age", "create"])(
|
||||
"requires fresh authentication for %s without looping",
|
||||
async (mode) => {
|
||||
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 cookieOf = (response: Response) =>
|
||||
response.headers
|
||||
.getSetCookie()
|
||||
.map((value) => value.split(";", 1)[0])
|
||||
.join("; ");
|
||||
const post = (path: string, body: object, cookie = "") =>
|
||||
handleAuth(
|
||||
new Request(`${origin}/api/auth/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin, cookie },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const unique = randomBytes(6).toString("hex");
|
||||
const credentials = {
|
||||
name: "Reauth Test",
|
||||
email: `reauth-${unique}@example.com`,
|
||||
username: `reauth-${unique}`,
|
||||
password: "password123",
|
||||
};
|
||||
const existingSignup = await post("sign-up/email", credentials);
|
||||
expect(existingSignup.status).toBe(200);
|
||||
const oldCookie = cookieOf(existingSignup);
|
||||
const registration = await post("oauth2/register", {
|
||||
client_name: "Reauth integration",
|
||||
redirect_uris: ["http://127.0.0.1:33921/callback"],
|
||||
});
|
||||
expect(registration.status).toBe(201);
|
||||
const client = await registration.json();
|
||||
const query = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: "http://127.0.0.1:33921/callback",
|
||||
response_type: "code",
|
||||
scope: "openid profile",
|
||||
resource: `${origin}/mcp`,
|
||||
code_challenge: createHash("sha256").update(randomBytes(32)).digest("base64url"),
|
||||
code_challenge_method: "S256",
|
||||
...(mode === "max-age" ? { max_age: "0" } : { prompt: mode }),
|
||||
});
|
||||
const authorization = await handleAuth(
|
||||
new Request(`${origin}/api/auth/oauth2/authorize?${query}`, { headers: { cookie: oldCookie } }),
|
||||
);
|
||||
expect(authorization.status).toBe(302);
|
||||
const bridge = await handleOAuth(
|
||||
new Request(new URL(authorization.headers.get("location") ?? "", origin), { headers: { cookie: oldCookie } }),
|
||||
);
|
||||
expect(bridge.status).toBe(302);
|
||||
const loginURL = new URL(bridge.headers.get("location") ?? "", origin);
|
||||
expect(loginURL.pathname).toBe(mode === "create" ? "/auth/register" : "/auth/login");
|
||||
expect(loginURL.searchParams.get("reauthenticate")).toBe("true");
|
||||
const callbackURL = new URL(loginURL.searchParams.get("callbackURL") ?? "", origin);
|
||||
const oauth_query = callbackURL.search.slice(1);
|
||||
const authenticated =
|
||||
mode === "create"
|
||||
? await post(
|
||||
"sign-up/email",
|
||||
{ ...credentials, email: `new-${unique}@example.com`, username: `new-${unique}` },
|
||||
oldCookie,
|
||||
)
|
||||
: await post(
|
||||
"sign-in/email",
|
||||
{ email: credentials.email, password: credentials.password, oauth_query },
|
||||
oldCookie,
|
||||
);
|
||||
expect(authenticated.status, await authenticated.clone().text()).toBe(200);
|
||||
const newCookie = cookieOf(authenticated);
|
||||
expect(newCookie).not.toBe(oldCookie);
|
||||
const continuation =
|
||||
mode === "create" ? await post("oauth2/continue", { created: true, oauth_query }, newCookie) : authenticated;
|
||||
expect(continuation.status, await continuation.clone().text()).toBe(200);
|
||||
const result = await continuation.json();
|
||||
let target = new URL(result.url, origin);
|
||||
if (target.pathname === "/api/auth/oauth") {
|
||||
const response = await handleOAuth(new Request(target, { headers: { cookie: newCookie } }));
|
||||
expect(response.status, await response.clone().text()).toBe(302);
|
||||
target = new URL(response.headers.get("location") ?? "", origin);
|
||||
}
|
||||
expect(target.pathname).toBe("/auth/consent");
|
||||
const accepted = await post("oauth2/consent", { accept: true, oauth_query: target.search.slice(1) }, newCookie);
|
||||
expect(accepted.status, await accepted.clone().text()).toBe(200);
|
||||
target = new URL((await accepted.json()).url, origin);
|
||||
expect(target.origin).toBe("http://127.0.0.1:33921");
|
||||
expect(target.searchParams.get("code")).toBeTruthy();
|
||||
},
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
@@ -39,41 +39,4 @@ describe("handlePublicResumePdf", () => {
|
||||
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,
|
||||
username: string,
|
||||
slug: string,
|
||||
trustedClient = "unknown",
|
||||
trustedClient: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const result = await createPublicResumePdf({
|
||||
@@ -35,6 +35,7 @@ export async function handlePublicResumePdf(
|
||||
});
|
||||
} catch (error) {
|
||||
const status = errorStatus(error);
|
||||
if (status === 500) console.error("Public resume PDF generation failed", error);
|
||||
return noStoreResponse(
|
||||
status === 500 ? "Failed to generate public resume PDF" : "Public resume PDF unavailable",
|
||||
status,
|
||||
|
||||
@@ -23,7 +23,6 @@ describe("handleResumePdfDownload", () => {
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
@@ -41,89 +40,7 @@ describe("handleResumePdfDownload", () => {
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
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({
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
});
|
||||
});
|
||||
|
||||
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();
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
|
||||
@@ -30,17 +30,13 @@ export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : unauthorizedResponse();
|
||||
const queryTarget = searchParams.get("target");
|
||||
if (
|
||||
verification.target
|
||||
? queryTarget !== null && queryTarget !== verification.target
|
||||
: queryTarget && queryTarget !== "resume"
|
||||
)
|
||||
return unauthorizedResponse();
|
||||
// Links made before letters left resumes may ask for the resume's cover letter, which is now a letter of its own.
|
||||
const target = searchParams.get("target");
|
||||
if (target && target !== "resume")
|
||||
return new Response("Not found", { status: 404, headers: { "Cache-Control": "private, no-store" } });
|
||||
|
||||
try {
|
||||
const target = verification.target ?? "resume";
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { once } from "node:events";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { serve } from "@hono/node-server";
|
||||
|
||||
const events = vi.hoisted(() => [] as string[]);
|
||||
const appFetch = vi.hoisted(() => vi.fn());
|
||||
vi.mock("./startup/checks", () => ({
|
||||
runStartupChecks: async () => {
|
||||
await Promise.resolve();
|
||||
events.push("migrations complete");
|
||||
},
|
||||
}));
|
||||
vi.mock("./http/app", () => {
|
||||
events.push("auth imported");
|
||||
return {
|
||||
createApp: () => {
|
||||
events.push("app created");
|
||||
return { fetch: appFetch };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
initializeAuth: async () => {
|
||||
await Promise.resolve();
|
||||
events.push("auth ready");
|
||||
},
|
||||
}));
|
||||
vi.mock("@hono/node-server", () => ({
|
||||
serve: vi.fn(() => {
|
||||
events.push("server listening");
|
||||
}),
|
||||
}));
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 0 } }));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("server startup", () => {
|
||||
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
|
||||
vi.spyOn(process, "on").mockReturnValue(process);
|
||||
vi.spyOn(process, "once").mockReturnValue(process);
|
||||
const entry = await import("./index");
|
||||
expect(events).toEqual([]);
|
||||
await entry.main();
|
||||
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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,16 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { createApp } from "./http/app";
|
||||
import { runStartupChecks } from "./startup/checks";
|
||||
|
||||
export { createApp } from "./http/app";
|
||||
|
||||
async function main() {
|
||||
export async function main() {
|
||||
await runStartupChecks();
|
||||
|
||||
// Load and initialize auth only after migrations have created the provider tables.
|
||||
const { createApp } = await import("./http/app");
|
||||
const { initializeAuth } = await import("@reactive-resume/auth/config");
|
||||
await initializeAuth();
|
||||
|
||||
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
|
||||
// stray promise must not take the server down for everyone, so log and keep serving.
|
||||
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
|
||||
@@ -22,7 +24,7 @@ async function main() {
|
||||
|
||||
const app = createApp();
|
||||
|
||||
serve(
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
@@ -31,6 +33,22 @@ async function main() {
|
||||
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) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import { beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const resolve = vi.hoisted(() => vi.fn());
|
||||
vi.mock("@reactive-resume/api/context", () => ({ resolveUserFromRequestHeaders: 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({ id: "user-1" });
|
||||
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";
|
||||
|
||||
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);
|
||||
}
|
||||
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
@@ -23,28 +7,9 @@ export class AuthError extends Error {
|
||||
}
|
||||
|
||||
export async function authenticateRequest(request: Request): Promise<void> {
|
||||
const authHeader = request.headers.get("authorization");
|
||||
|
||||
if (authHeader?.startsWith("Bearer ")) {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
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.
|
||||
}
|
||||
}
|
||||
|
||||
// MCP accepts API keys and bearer tokens; share their priority and validation with its oRPC tools.
|
||||
const headers = new Headers(request.headers);
|
||||
headers.delete("cookie");
|
||||
if (await resolveUserFromRequestHeaders(headers)) return;
|
||||
throw new AuthError();
|
||||
}
|
||||
|
||||
@@ -3,7 +3,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { onError } from "@orpc/client";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||
import {
|
||||
buildMcpServerInfo,
|
||||
MCP_TOOL_NAME,
|
||||
registerPrompts,
|
||||
registerResources,
|
||||
registerTools,
|
||||
} from "@reactive-resume/mcp";
|
||||
import { appVersion } from "../app-version";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
@@ -23,41 +29,19 @@ function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
}
|
||||
|
||||
export function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
version: appVersion,
|
||||
title: "Reactive Resume",
|
||||
websiteUrl: "https://rxresu.me",
|
||||
description:
|
||||
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
|
||||
icons: [
|
||||
{
|
||||
src: "https://rxresu.me/icon/light.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
src: "https://rxresu.me/icon/dark.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "dark",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||
`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}\`.`,
|
||||
`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).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
);
|
||||
const server = new McpServer(buildMcpServerInfo(appVersion), {
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||
`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}\`.`,
|
||||
`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).`,
|
||||
`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}\`.`,
|
||||
].join(" "),
|
||||
});
|
||||
|
||||
const client = createRequestClient(request);
|
||||
registerResources(server, client);
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export async function generateOpenApiDocumentation(
|
||||
async function generateOpenApiDocumentation(
|
||||
target = fileURLToPath(new URL("../../../../docs/spec.json", import.meta.url)),
|
||||
) {
|
||||
const packageJson = JSON.parse(await readFile(new URL("../../../../package.json", import.meta.url), "utf8")) as {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
|
||||
// Spec generation reads procedure contracts without executing authentication. Keep the
|
||||
// provider's resource seeding out of this unit test; real OAuth initialization is covered
|
||||
// by the opt-in PostgreSQL integration suite after migrations run.
|
||||
vi.mock("@reactive-resume/auth/config", () => ({ auth: {}, verifyOAuthToken: vi.fn() }));
|
||||
|
||||
type GeneratedSpecView = {
|
||||
components?: { schemas?: Record<string, unknown> };
|
||||
paths?: Record<
|
||||
@@ -66,20 +69,10 @@ function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
||||
}
|
||||
|
||||
describe("generateOpenApiSpec", () => {
|
||||
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",
|
||||
});
|
||||
it("keeps instance homepage resolution out of the public API", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
expect(spec.paths).not.toHaveProperty("/resume/getRoot");
|
||||
}, 15_000);
|
||||
|
||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const { $schema: _dialect, ...canonicalInputSchema } = createResumeDataJsonSchema();
|
||||
@@ -90,65 +83,11 @@ describe("generateOpenApiSpec", () => {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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);
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("does not publish impossible request schemas", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every request body media type for impossible schemas", () => {
|
||||
const spec: GeneratedSpecView = {
|
||||
paths: {
|
||||
"/documents": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { type: "object" } },
|
||||
"multipart/form-data": { schema: { not: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
||||
});
|
||||
|
||||
it("documents imported data as an accepted ResumeData input", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import type { OpenAPI } from "@orpc/openapi";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
import { JSON_SCHEMA_INPUT_REGISTRY, ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
||||
|
||||
export const openAPIRouter = {
|
||||
...router,
|
||||
@@ -16,6 +18,7 @@ export const openAPIRouter = {
|
||||
const { $schema: _dialect, ...resumeDataInputSchema } = createResumeDataJsonSchema();
|
||||
type ResumeDataInputJsonSchema = Parameters<typeof JSON_SCHEMA_INPUT_REGISTRY.add<typeof resumeDataSchema>>[1];
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(resumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(writableResumeDataSchema, resumeDataInputSchema as unknown as ResumeDataInputJsonSchema);
|
||||
const importResumeInputSchema = openAPIRouter.resume.import["~orpc"].inputSchema;
|
||||
if (importResumeInputSchema) {
|
||||
JSON_SCHEMA_INPUT_REGISTRY.add(importResumeInputSchema, {
|
||||
@@ -50,19 +53,66 @@ type GenerateOpenApiSpecOptions = {
|
||||
version: string;
|
||||
};
|
||||
|
||||
const healthDependencySchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
status: { type: "string", enum: ["healthy", "unhealthy"] },
|
||||
latencyMs: { type: "number" },
|
||||
error: { type: "string", description: "Generic failure message. Detailed diagnostics are logged on the server." },
|
||||
},
|
||||
required: ["status", "latencyMs"],
|
||||
additionalProperties: true,
|
||||
} satisfies OpenAPI.SchemaObject;
|
||||
|
||||
const healthResponseSchema = {
|
||||
type: "object",
|
||||
properties: {
|
||||
service: { type: "string", enum: ["reactive-resume"] },
|
||||
version: { type: "string", description: "The running application's build version." },
|
||||
status: { type: "string", enum: ["healthy", "unhealthy"] },
|
||||
timestamp: { type: "string", format: "date-time" },
|
||||
uptime: { type: "string" },
|
||||
database: healthDependencySchema,
|
||||
storage: healthDependencySchema,
|
||||
},
|
||||
required: ["service", "version", "status", "timestamp", "uptime", "database", "storage"],
|
||||
} satisfies OpenAPI.SchemaObject;
|
||||
|
||||
export async function generateOpenApiSpec({ appUrl, version }: GenerateOpenApiSpecOptions) {
|
||||
return await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/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" },
|
||||
},
|
||||
servers: [{ url: `${appUrl}/api/openapi` }],
|
||||
paths: {
|
||||
"/api/health": {
|
||||
get: {
|
||||
operationId: "getHealth",
|
||||
tags: ["System"],
|
||||
summary: "Get application health and version",
|
||||
description: "Checks database and storage availability. Does not require authentication.",
|
||||
servers: [{ url: appUrl }],
|
||||
security: [],
|
||||
responses: {
|
||||
"200": {
|
||||
description: "The application and its dependencies are healthy.",
|
||||
content: { "application/json": { schema: healthResponseSchema } },
|
||||
},
|
||||
"503": {
|
||||
description: "One or more application dependencies are unhealthy.",
|
||||
content: { "application/json": { schema: healthResponseSchema } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema, strategy: "input" },
|
||||
ResumeData: { schema: writableResumeDataSchema, strategy: "input" },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
||||
@@ -25,7 +25,7 @@ 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"))) {
|
||||
return Response.json(await generateOpenApiSpec({ appUrl: env.APP_URL, version: appVersion }));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { initializeAuth } from "@reactive-resume/auth/config";
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { runDatabaseMigrations } from "./startup/checks";
|
||||
|
||||
if (process.env.VERCEL_ENV === "preview" && process.env.ALLOW_PREVIEW_MIGRATIONS !== "true") {
|
||||
throw new Error(
|
||||
"Preview deployment needs an isolated database. Set ALLOW_PREVIEW_MIGRATIONS=true only after connecting one.",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.VERCEL === "1") {
|
||||
if (env.STORAGE_BACKEND !== "blob")
|
||||
throw new Error("Vercel requires private Blob storage for direct uploads. Docker supports local, S3, and Blob.");
|
||||
if (!env.REDIS_URL || !env.ENCRYPTION_SECRET) throw new Error("Vercel requires Redis and ENCRYPTION_SECRET.");
|
||||
}
|
||||
await runDatabaseMigrations();
|
||||
|
||||
await initializeAuth();
|
||||
await getPool().end();
|
||||
@@ -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 { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { parse } from "hono/utils/cookie";
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
import { getCookie } from "../http/headers";
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||
import { verifyMigratedSchema } from "./schema-check";
|
||||
|
||||
function resolveFromCurrentModule(relativePath: string) {
|
||||
return fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
@@ -24,25 +25,54 @@ function resolveWorkspaceFolder(folderName: string): string {
|
||||
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||
}
|
||||
|
||||
async function runDatabaseMigrations() {
|
||||
export async function runDatabaseMigrations() {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
const db = drizzle({ client: pool });
|
||||
const pool = new Pool({
|
||||
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock(721830451)");
|
||||
const db = drizzle({ client });
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Post-migration verification is not a migration failure, so it gets its own log
|
||||
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
|
||||
// makes the drift fatal instead.
|
||||
try {
|
||||
await verifyMigratedSchema(client);
|
||||
} catch (error) {
|
||||
console.error("Database schema verification failed", { error });
|
||||
if (env.STRICT_SCHEMA_CHECK) throw error;
|
||||
console.error(
|
||||
"Continuing with a drifted database schema; set STRICT_SCHEMA_CHECK=true to refuse startup instead.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock(721830451)");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateLocalStoragePath() {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||
if (env.STORAGE_BACKEND !== "local") return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectExpectedColumns, verifyMigratedSchema } from "./schema-check";
|
||||
|
||||
describe("collectExpectedColumns", () => {
|
||||
it("collects every column of every schema table", () => {
|
||||
const expected = collectExpectedColumns();
|
||||
expect(expected.length).toBeGreaterThan(0);
|
||||
expect(expected).toContainEqual({ tableName: "ai_providers", columnName: "user_id" });
|
||||
expect(expected).toContainEqual({ tableName: "user", columnName: "id" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("verifyMigratedSchema", () => {
|
||||
it("fails with the table name when every column of a table is missing", async () => {
|
||||
const rows = collectExpectedColumns()
|
||||
.filter((e) => e.tableName === "ai_providers")
|
||||
.map((e) => ({ table_name: e.tableName, column_name: e.columnName }));
|
||||
|
||||
const queryable = { query: async () => ({ rows }) };
|
||||
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('table "ai_providers"');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,71 @@
|
||||
import { is } from "drizzle-orm";
|
||||
import { getTableConfig, PgTable } from "drizzle-orm/pg-core";
|
||||
import * as schema from "@reactive-resume/db/schema";
|
||||
|
||||
interface SchemaQueryable {
|
||||
query(text: string, values?: unknown[]): Promise<{ rows: { table_name: string; column_name: string }[] }>;
|
||||
}
|
||||
|
||||
export function collectExpectedColumns() {
|
||||
const expected: { tableName: string; columnName: string }[] = [];
|
||||
|
||||
for (const value of Object.values(schema)) {
|
||||
if (!is(value, PgTable)) continue;
|
||||
const config = getTableConfig(value);
|
||||
for (const column of config.columns) expected.push({ tableName: config.name, columnName: column.name });
|
||||
}
|
||||
|
||||
return expected;
|
||||
}
|
||||
|
||||
// The migration ledger (drizzle.__drizzle_migrations) only records that a migration ran; it
|
||||
// cannot detect objects that were dropped or lost outside the migrator (a partial restore,
|
||||
// a manual DROP TABLE, or a recreated "public" schema while the "drizzle" schema survives).
|
||||
// Comparing the live catalog with the declared schema turns that silent drift into a startup
|
||||
// failure instead of runtime "relation does not exist" (42P01) errors. The comparison covers
|
||||
// tables and columns only — indexes, constraints, and enums are intentionally out of scope.
|
||||
export async function verifyMigratedSchema(queryable: SchemaQueryable): Promise<void> {
|
||||
const expected = collectExpectedColumns();
|
||||
if (expected.length === 0) return;
|
||||
|
||||
// $1 and $2 are index-aligned: $1[i] is the name of the table expected to contain $2[i].
|
||||
// Names are qualified as "public.<table>" so the lookup does not follow the connection's
|
||||
// search_path — migrations always create these tables in the public schema.
|
||||
const result = await queryable.query(
|
||||
`select e.table_name, e.column_name
|
||||
from unnest($1::text[], $2::text[]) as e(table_name, column_name)
|
||||
where to_regclass('public.' || e.table_name) is null
|
||||
or not exists (
|
||||
select 1 from pg_catalog.pg_attribute a
|
||||
where a.attrelid = to_regclass('public.' || e.table_name)
|
||||
and a.attname = e.column_name
|
||||
and a.attnum > 0 and not a.attisdropped
|
||||
)
|
||||
order by e.table_name, e.column_name`,
|
||||
[expected.map((e) => e.tableName), expected.map((e) => e.columnName)],
|
||||
);
|
||||
if (result.rows.length === 0) return;
|
||||
|
||||
const expectedPerTable = new Map<string, number>();
|
||||
for (const e of expected) expectedPerTable.set(e.tableName, (expectedPerTable.get(e.tableName) ?? 0) + 1);
|
||||
|
||||
const missingByTable = new Map<string, Set<string>>();
|
||||
for (const row of result.rows) {
|
||||
const columns = missingByTable.get(row.table_name) ?? new Set<string>();
|
||||
columns.add(row.column_name);
|
||||
missingByTable.set(row.table_name, columns);
|
||||
}
|
||||
|
||||
const missing = [...missingByTable.entries()].map(([table, columns]) =>
|
||||
columns.size === expectedPerTable.get(table)
|
||||
? `table "${table}"`
|
||||
: `column(s) ${[...columns].map((column) => `"${table}"."${column}"`).join(", ")}`,
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
`Database schema does not match the migration ledger: ${missing.join(", ")} ` +
|
||||
"missing even though all migrations are marked as applied. This usually means the database was " +
|
||||
"restored from a backup that did not include these objects, or they were dropped outside of " +
|
||||
"migrations. Restore a consistent backup or recreate the missing objects, then restart the server.",
|
||||
);
|
||||
}
|
||||
@@ -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 { templateSchema } from "@reactive-resume/schema/templates";
|
||||
import { getLocaleAlternates } from "@reactive-resume/utils/locale";
|
||||
|
||||
const DOCS_URL = "https://docs.rxresu.me";
|
||||
|
||||
@@ -34,17 +36,24 @@ export function handleRobots(options?: StaticSeoOptions) {
|
||||
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) {
|
||||
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 = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/</loc>`,
|
||||
" </url>",
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/ats-checker</loc>`,
|
||||
" </url>",
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
...urls,
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
@@ -59,18 +68,41 @@ export function handleLlms(options?: StaticSeoOptions) {
|
||||
const body = [
|
||||
"# 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}`,
|
||||
`- Documentation: ${DOCS_URL}`,
|
||||
`- Documentation sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
`- Documentation llms.txt: ${DOCS_URL}/llms.txt`,
|
||||
`- API documentation: ${DOCS_URL}/api-reference`,
|
||||
`- Resume schema: ${baseUrl}/schema.json`,
|
||||
`- MCP documentation: ${DOCS_URL}/guides/using-the-mcp-server`,
|
||||
`- OpenAPI specification: ${baseUrl}/api/openapi/spec.json`,
|
||||
`- [Homepage](${baseUrl}/): what Reactive Resume does, with answers to common questions.`,
|
||||
`- [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.`,
|
||||
`- [Get started](${baseUrl}/dashboard): create an account and a first resume.`,
|
||||
"",
|
||||
"## Facts",
|
||||
"",
|
||||
"- Price: free, every feature. Optional donations through GitHub Sponsors and Open Collective.",
|
||||
"- 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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { IncomingHttpHeaders } from "node:http";
|
||||
import { createServer } from "node:http";
|
||||
import { afterAll, beforeAll, beforeEach, expect, it, vi } from "vitest";
|
||||
|
||||
const envMock = vi.hoisted(() => ({
|
||||
APP_URL: "https://resume.example.com",
|
||||
S3_ACCESS_KEY_ID: "test-access-key",
|
||||
S3_SECRET_ACCESS_KEY: "test-secret-key",
|
||||
S3_REGION: "us-east-1",
|
||||
S3_ENDPOINT: "",
|
||||
STORAGE_BACKEND: "s3",
|
||||
S3_BUCKET: "test-bucket",
|
||||
S3_FORCE_PATH_STYLE: true,
|
||||
}));
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: envMock }));
|
||||
|
||||
type StoredObject = { data: Buffer; contentType: string };
|
||||
type StorageRequest = { method: string; path: string; headers: IncomingHttpHeaders };
|
||||
const objects = new Map<string, StoredObject>();
|
||||
const requests: StorageRequest[] = [];
|
||||
|
||||
// Wire-contract stub, not an AWS emulator. It applies the documented BucketOwnerEnforced
|
||||
// PUT rule to real SDK requests: no ACL or bucket-owner-full-control is accepted.
|
||||
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/object-ownership-error-responses.html
|
||||
const server = createServer(async (request, response) => {
|
||||
const path = new URL(request.url ?? "/", "http://localhost").pathname;
|
||||
requests.push({ method: request.method ?? "", path, headers: request.headers });
|
||||
const fail = (status: number, code: string) => {
|
||||
response.writeHead(status, { "Content-Type": "application/xml" });
|
||||
response.end(`<Error><Code>${code}</Code><Message>${code}</Message></Error>`);
|
||||
};
|
||||
// Only checks that the SDK authenticates its requests; this stub does not verify signatures.
|
||||
if (!request.headers.authorization?.startsWith("AWS4-HMAC-SHA256 ")) return fail(403, "AccessDenied");
|
||||
if (request.method === "PUT") {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) chunks.push(Buffer.from(chunk));
|
||||
const acl = request.headers["x-amz-acl"];
|
||||
if (acl && acl !== "bucket-owner-full-control") return fail(400, "AccessControlListNotSupported");
|
||||
objects.set(path, {
|
||||
data: Buffer.concat(chunks),
|
||||
contentType: request.headers["content-type"] ?? "application/octet-stream",
|
||||
});
|
||||
response.writeHead(200, { ETag: '"test-etag"' });
|
||||
return response.end();
|
||||
}
|
||||
const object = objects.get(path);
|
||||
if (!object) return fail(404, "NoSuchKey");
|
||||
response.writeHead(200, { "Content-Type": object.contentType, "Content-Length": object.data.length });
|
||||
response.end(object.data);
|
||||
});
|
||||
|
||||
let storage: ReturnType<typeof import("@reactive-resume/api/features/storage").getStorageService>;
|
||||
let handleUpload: typeof import("./uploads").handleUpload;
|
||||
|
||||
beforeAll(async () => {
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") throw new Error("Missing stub TCP address");
|
||||
envMock.S3_ENDPOINT = `http://127.0.0.1:${address.port}`;
|
||||
storage = (await import("@reactive-resume/api/features/storage")).getStorageService();
|
||||
({ handleUpload } = await import("./uploads"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
objects.clear();
|
||||
requests.length = 0;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
server.closeAllConnections();
|
||||
await new Promise<void>((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
|
||||
});
|
||||
|
||||
it("stores images without ACLs and serves them through the signed application proxy", async () => {
|
||||
const key = "uploads/user-1/pictures/photo.png";
|
||||
const data = new Uint8Array([137, 80, 78, 71]);
|
||||
await storage.write({ key, data, contentType: "image/png" });
|
||||
expect(requests[0]?.headers["x-amz-acl"]).toBeUndefined();
|
||||
const direct = await fetch(`${envMock.S3_ENDPOINT}/${envMock.S3_BUCKET}/${key}`);
|
||||
expect(direct.status).toBe(403);
|
||||
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("image/png");
|
||||
expect(new Uint8Array(await response.arrayBuffer())).toEqual(data);
|
||||
expect(requests.at(-1)?.headers.authorization).toMatch(/^AWS4-HMAC-SHA256 /);
|
||||
});
|
||||
|
||||
it("stores private attachments without ACLs while keeping them outside the public proxy", async () => {
|
||||
const key = "uploads/user-1/agent/thread-1/private.txt";
|
||||
const data = new TextEncoder().encode("private attachment");
|
||||
await storage.write({ key, data, contentType: "text/plain", private: true });
|
||||
expect(requests[0]?.headers["x-amz-acl"]).toBeUndefined();
|
||||
const requestCount = requests.length;
|
||||
const response = await handleUpload(new Request(`${envMock.APP_URL}/api/${key}`));
|
||||
expect(response.status).toBe(404);
|
||||
expect(requests).toHaveLength(requestCount);
|
||||
const direct = await fetch(`${envMock.S3_ENDPOINT}/${envMock.S3_BUCKET}/${key}`);
|
||||
expect(direct.status).toBe(403);
|
||||
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 });
|
||||
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"');
|
||||
});
|
||||
@@ -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 { basename, extname, normalize } from "node:path";
|
||||
import { basename, normalize } from "node:path";
|
||||
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) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
|
||||
@@ -16,13 +20,12 @@ export async function handleUpload(request: Request) {
|
||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const shouldForceDownload = !INLINE_CONTENT_TYPES.has(contentType);
|
||||
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
|
||||
@@ -2,8 +2,8 @@ import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
env: { APP_URL: "https://rxresu.me" },
|
||||
serveStatic: vi.fn((_options?: unknown) => vi.fn()),
|
||||
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
getPublicResumeSocialMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
@@ -29,35 +29,16 @@ vi.mock("@reactive-resume/env/server", () => ({
|
||||
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 staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.ROOT_RESUME_ID = undefined;
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
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 () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`
|
||||
<!doctype html>
|
||||
@@ -66,7 +47,7 @@ describe("web app fallback classification", () => {
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
<meta
|
||||
name="description"
|
||||
content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume."
|
||||
content="Reactive Resume is a free and open-source resume builder that makes it easy to create, update, and share your resume."
|
||||
>
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
@@ -77,18 +58,37 @@ describe("web app fallback classification", () => {
|
||||
const html = await response.text();
|
||||
|
||||
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:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
expect(html).toContain('id="reactive-resume-structured-data"');
|
||||
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
|
||||
expect(html).toContain('"url":"https://rxresu.me/"');
|
||||
expect(html).toContain('<script type="application/ld+json">');
|
||||
expect(html).not.toContain("utm_source");
|
||||
|
||||
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
||||
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", () => {
|
||||
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>`;
|
||||
|
||||
@@ -102,60 +102,27 @@ describe("web app fallback classification", () => {
|
||||
expect(response.headers.get("X-Robots-Tag")).toBeNull();
|
||||
});
|
||||
|
||||
it("replaces the shell metadata with the checker's own", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
it("serves the prerendered page in the requested locale, with its own title on the social cards", async () => {
|
||||
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('<link rel="canonical" href="https://rxresu.me/ats-checker">');
|
||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
|
||||
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();
|
||||
expect(html).toContain('<div id="app">de-DE</div>');
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker?locale=de-DE">');
|
||||
expect(html).toContain('<meta property="og:title" content="ATS-Prüfung & mehr">');
|
||||
expect(html).toContain('<meta property="og:locale" content="de_DE">');
|
||||
});
|
||||
});
|
||||
|
||||
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>`;
|
||||
|
||||
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/templates/jpg/azurill.jpg">');
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
||||
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/templates/jpg/azurill.jpg">');
|
||||
});
|
||||
|
||||
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||
@@ -173,42 +140,20 @@ describe("web app fallback classification", () => {
|
||||
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);
|
||||
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) => {
|
||||
@@ -231,61 +176,23 @@ describe("web app fallback classification", () => {
|
||||
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" }),
|
||||
describe("configured root shell", () => {
|
||||
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
|
||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
||||
vi.mocked(fs.readFile).mockResolvedValue(
|
||||
'<html><head><title>Marketing title</title><meta name="description" content="Marketing copy."></head><body></body></html>',
|
||||
);
|
||||
|
||||
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("");
|
||||
const html = await (
|
||||
await handleWebApp(
|
||||
new Request("https://attacker.example/?id=other", {
|
||||
headers: { host: "attacker.example", "x-forwarded-host": "evil.example" },
|
||||
}),
|
||||
)
|
||||
).text();
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/" data-root-resume-shell>');
|
||||
expect(html).toContain('<meta name="robots" content="noindex, follow" data-root-resume-shell>');
|
||||
expect(html).not.toMatch(/private-or-missing-id|attacker|evil|Marketing|application\/ld\+json|timelapse/);
|
||||
});
|
||||
});
|
||||
|
||||
+211
-122
@@ -1,8 +1,11 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { serveStatic } from "@hono/node-server/serve-static";
|
||||
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() {
|
||||
const candidates = [
|
||||
@@ -19,6 +22,9 @@ function resolveWebDistPath() {
|
||||
|
||||
const staticRoot = resolveWebDistPath();
|
||||
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"];
|
||||
/**
|
||||
* Marketing pages the SPA owns that search engines should index.
|
||||
@@ -63,146 +69,146 @@ const BASE_SECURITY_HEADERS = {
|
||||
"X-Frame-Options": "DENY",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"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":
|
||||
"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";
|
||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
||||
const ROOT_DESCRIPTION =
|
||||
"Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall.";
|
||||
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
|
||||
const ROOT_FAQ_ITEMS = [
|
||||
{
|
||||
question: "Is Reactive Resume really free?",
|
||||
answer:
|
||||
"Yes! Reactive Resume is completely free to use, with no hidden costs, premium tiers, or subscription fees. It's open-source and will always remain free.",
|
||||
},
|
||||
{
|
||||
question: "How is my data protected?",
|
||||
answer:
|
||||
"Your data is stored securely and is never shared with third parties. You can also self-host Reactive Resume on your own servers for complete control over your data.",
|
||||
},
|
||||
{
|
||||
question: "Can I export my resume to PDF?",
|
||||
answer:
|
||||
"Absolutely! You can export your resume to PDF with a single click. The exported PDF maintains all your formatting and styling perfectly.",
|
||||
},
|
||||
{
|
||||
question: "Is Reactive Resume available in multiple languages?",
|
||||
answer:
|
||||
"Yes, Reactive Resume is available in multiple languages. You can choose your preferred language in the settings page, or using the language switcher in the top right corner. If you don't see your language, or you would like to improve the existing translations, you can contribute to the translations on Crowdin.",
|
||||
},
|
||||
{
|
||||
question: "What makes Reactive Resume different from other resume builders?",
|
||||
answer:
|
||||
"Reactive Resume is open-source, privacy-focused, and completely free. Unlike other resume builders, it doesn't show ads, track your data, or limit your features behind a paywall.",
|
||||
},
|
||||
{
|
||||
question: "How do I share my resume?",
|
||||
answer:
|
||||
"You can share your resume via a unique public URL, protect it with a password, or download it as a PDF to share directly. The choice is yours!",
|
||||
},
|
||||
] as const;
|
||||
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
|
||||
const ATS_CHECKER_TITLE = "Free ATS resume checker — Reactive Resume";
|
||||
const ATS_CHECKER_DESCRIPTION =
|
||||
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
|
||||
|
||||
function createRootSeoMarkup(canonicalUrl: string) {
|
||||
const origin = new URL(canonicalUrl).origin;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const structuredData = {
|
||||
type StructuredData = Record<string, unknown>;
|
||||
|
||||
/** Who makes Reactive Resume, referenced by id from every page's structured data. */
|
||||
function organization(origin: string): StructuredData {
|
||||
return {
|
||||
"@type": "Organization",
|
||||
"@id": `${origin}/#organization`,
|
||||
name: "Reactive Resume",
|
||||
url: `${origin}/`,
|
||||
logo: { "@type": "ImageObject", url: `${origin}/pwa-512x512.png`, width: 512, height: 512 },
|
||||
sameAs: [
|
||||
githubUrl,
|
||||
"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",
|
||||
"@graph": [
|
||||
organization(origin),
|
||||
{
|
||||
"@type": "WebSite",
|
||||
"@id": `${origin}/#website`,
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
url: rootUrl,
|
||||
publisher: { "@id": `${origin}/#organization` },
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
url: rootUrl,
|
||||
description: ROOT_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
codeRepository: "https://github.com/amruthpillai/reactive-resume",
|
||||
},
|
||||
{
|
||||
"@type": "Project",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
sameAs: ["https://github.com/amruthpillai/reactive-resume"],
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
license: `${githubUrl}/blob/main/LICENSE`,
|
||||
codeRepository: githubUrl,
|
||||
publisher: { "@id": `${origin}/#organization` },
|
||||
featureList: [
|
||||
"Resume editor with a live page preview",
|
||||
`${templateSchema.options.length} templates`,
|
||||
"PDF, Word (DOCX), Markdown and JSON export",
|
||||
"Import from PDF, LinkedIn, JSON Resume and Word",
|
||||
"ATS readability checker",
|
||||
"Public or password-protected sharing links",
|
||||
"Cover letters and a job application tracker",
|
||||
"Optional AI assistant with your own API key",
|
||||
"Self-hosting with Docker",
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
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 `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
|
||||
<link rel="canonical" href="${escapeAttribute(pageUrl)}">
|
||||
${alternates}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${ROOT_TITLE}">
|
||||
<meta property="og:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta property="og:locale" content="${options.locale.replace("-", "_")}">
|
||||
<meta property="og:title" content="${options.title}">
|
||||
<meta property="og:description" content="${options.description}">
|
||||
<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:title" content="${ROOT_TITLE}">
|
||||
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
||||
<meta name="twitter:title" content="${options.title}">
|
||||
<meta name="twitter:description" content="${options.description}">
|
||||
<meta name="twitter:image" content="${options.imageUrl}">
|
||||
<script type="application/ld+json">${JSON.stringify(options.structuredData)}</script>
|
||||
`;
|
||||
}
|
||||
|
||||
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
|
||||
// Kept under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
||||
const ATS_CHECKER_DESCRIPTION =
|
||||
"Check whether software can read your resume PDF. Runs in your browser, so your file is never uploaded.";
|
||||
|
||||
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}/` },
|
||||
/** The title and description a page was built with, still HTML-escaped. */
|
||||
function readPageMeta(html: string, fallback: { title: string; description: string }) {
|
||||
return {
|
||||
title: html.match(/<title>([^<]*)<\/title>/)?.[1] ?? fallback.title,
|
||||
description: html.match(/<meta name="description" content="([^"]*)"/)?.[1] ?? fallback.description,
|
||||
};
|
||||
|
||||
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
|
||||
@@ -226,7 +232,7 @@ async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
|
||||
if (!meta) return null;
|
||||
|
||||
const canonicalUrl = `${origin}/${username}/${slug}`;
|
||||
const imageUrl = `${origin}/templates/jpg/${meta.template}.jpg`;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
|
||||
const title = escapeAttribute(meta.title);
|
||||
const description = escapeAttribute(meta.description);
|
||||
@@ -260,6 +266,14 @@ export const serveWebDistStatic = serveStatic({
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
"Cache-Control": "private, no-store",
|
||||
...BASE_SECURITY_HEADERS,
|
||||
};
|
||||
}
|
||||
if (pathname === "/" || indexableAppPaths.has(pathname)) {
|
||||
return { "Content-Type": "text/html; charset=UTF-8", ...BASE_SECURITY_HEADERS };
|
||||
}
|
||||
@@ -274,6 +288,18 @@ function getFallbackResponseHeaders(pathname: string) {
|
||||
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 } = {}) {
|
||||
const headers = new Headers({ "Content-Type": "text/plain; charset=UTF-8" });
|
||||
if (options.noindex) headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
@@ -284,6 +310,39 @@ 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
|
||||
export async function handleWebApp(request: Request) {
|
||||
const isHead = request.method === "HEAD";
|
||||
@@ -299,31 +358,61 @@ export async function handleWebApp(request: Request) {
|
||||
if (isHead) return new Response(null, { status: 200, headers });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
|
||||
if (pathname === "/") {
|
||||
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
|
||||
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
|
||||
// gates data and browser metadata; shell requests must not count extra views.
|
||||
const shell = html
|
||||
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
||||
.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>`;
|
||||
return new Response(
|
||||
shell.replace("</head>", () => `${markup}</head>`),
|
||||
{ headers },
|
||||
);
|
||||
}
|
||||
|
||||
if (pathname === "/ats-checker") {
|
||||
const prerendered = prerenderedPages[pathname];
|
||||
if (prerendered) {
|
||||
const { locale, requested } = getPageLocale(request);
|
||||
const origin = new URL(env.APP_URL).origin;
|
||||
const withTitle = html
|
||||
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
|
||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
|
||||
// Without a prerendered page (a build that skipped it), the app renders the page in the browser.
|
||||
const page = await fs
|
||||
.readFile(`${prerenderRoot}/${prerendered.name}/${locale}.html`, "utf-8")
|
||||
.catch(() => 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)) {
|
||||
const resumeSeo = await createPublicResumeSeoMarkup(pathname, new URL(env.APP_URL).origin);
|
||||
if (resumeSeo) {
|
||||
// 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
|
||||
.replace(/<title>[^<]*<\/title>/, `<title>${resumeSeo.pageTitle}</title>`)
|
||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${resumeSeo.description}">`);
|
||||
.replace(/<title>[^<]*<\/title>/, () => `<title>${resumeSeo.pageTitle}</title>`)
|
||||
.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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ipAddress: vi.fn<(request: Request) => string | undefined>(),
|
||||
waitUntil: vi.fn(),
|
||||
initializeAuth: vi.fn(),
|
||||
attachDatabasePool: vi.fn(),
|
||||
configureAgentStreamLifetime: vi.fn(),
|
||||
pool: {},
|
||||
getPool: vi.fn(),
|
||||
createApp:
|
||||
vi.fn<
|
||||
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
|
||||
fetch: (request: Request) => Promise<Response>;
|
||||
}
|
||||
>(),
|
||||
handle: vi.fn<(request: Request) => Promise<Response>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@vercel/functions", () => ({
|
||||
ipAddress: mocks.ipAddress,
|
||||
waitUntil: mocks.waitUntil,
|
||||
attachDatabasePool: mocks.attachDatabasePool,
|
||||
}));
|
||||
vi.mock("@reactive-resume/api/features/agent/streams", () => ({
|
||||
configureAgentStreamLifetime: mocks.configureAgentStreamLifetime,
|
||||
}));
|
||||
vi.mock("@reactive-resume/auth/config", () => ({ initializeAuth: mocks.initializeAuth }));
|
||||
vi.mock("@reactive-resume/db/client", () => ({ getPool: mocks.getPool }));
|
||||
vi.mock("./http/app", () => ({ createApp: mocks.createApp }));
|
||||
|
||||
function spoofedRequest() {
|
||||
return new Request("https://resume.test/api/rpc?batch=1", {
|
||||
method: "POST",
|
||||
body: "original RPC body",
|
||||
headers: {
|
||||
...Object.fromEntries(TRUSTED_IP_HEADERS.map((header) => [header, "192.0.2.66"])),
|
||||
"x-forwarded-for": "192.0.2.66, 192.0.2.77",
|
||||
cookie: "session=original",
|
||||
authorization: "Bearer original",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.getPool.mockReturnValue(mocks.pool);
|
||||
mocks.handle.mockResolvedValue(new Response("handled"));
|
||||
mocks.createApp.mockReturnValue({ fetch: mocks.handle });
|
||||
});
|
||||
|
||||
describe("Vercel adapter", () => {
|
||||
it("registers platform lifetime hooks and disables filesystem static serving", async () => {
|
||||
await import("./vercel");
|
||||
expect(mocks.configureAgentStreamLifetime).toHaveBeenCalledExactlyOnceWith(mocks.waitUntil);
|
||||
expect(mocks.attachDatabasePool).toHaveBeenCalledExactlyOnceWith(mocks.pool);
|
||||
expect(mocks.createApp).toHaveBeenCalledExactlyOnceWith({
|
||||
serveStatic: false,
|
||||
trustedClient: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["203.0.113.9", "2001:db8::9"])("replaces all spoofed IP headers with platform IP %s", async (ip) => {
|
||||
mocks.ipAddress.mockReturnValue(ip);
|
||||
const { default: adapter } = await import("./vercel");
|
||||
const request = spoofedRequest();
|
||||
expect(await (await adapter.fetch(request)).text()).toBe("handled");
|
||||
expect(mocks.ipAddress).toHaveBeenCalledExactlyOnceWith(request);
|
||||
const forwarded = mocks.handle.mock.calls[0]?.[0];
|
||||
if (!forwarded) throw new Error("Expected forwarded request");
|
||||
for (const header of TRUSTED_IP_HEADERS) {
|
||||
const expected = ["x-real-ip", "x-forwarded-for"].includes(header.toLowerCase()) ? ip : null;
|
||||
expect(forwarded.headers.get(header)).toBe(expected);
|
||||
}
|
||||
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe(ip);
|
||||
expect(forwarded.url).toBe(request.url);
|
||||
expect(forwarded.method).toBe("POST");
|
||||
expect(await forwarded.text()).toBe("original RPC body");
|
||||
expect(forwarded.headers.get("cookie")).toBe("session=original");
|
||||
expect(forwarded.headers.get("authorization")).toBe("Bearer original");
|
||||
expect(forwarded.headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it.each([undefined, "", "invalid-ip", "203.0.113.9, 192.0.2.66"])(
|
||||
"clears attacker headers when platform IP is missing or invalid: %s",
|
||||
async (ip) => {
|
||||
mocks.ipAddress.mockReturnValue(ip);
|
||||
const { default: adapter } = await import("./vercel");
|
||||
await adapter.fetch(spoofedRequest());
|
||||
const forwarded = mocks.handle.mock.calls[0]?.[0];
|
||||
if (!forwarded) throw new Error("Expected forwarded request");
|
||||
for (const header of TRUSTED_IP_HEADERS) expect(forwarded.headers.has(header)).toBe(false);
|
||||
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe("unknown");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isIP } from "node:net";
|
||||
import { attachDatabasePool, ipAddress, waitUntil } from "@vercel/functions";
|
||||
import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams";
|
||||
import { initializeAuth } from "@reactive-resume/auth/config";
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
import { createApp } from "./http/app";
|
||||
|
||||
configureAgentStreamLifetime(waitUntil);
|
||||
attachDatabasePool(getPool());
|
||||
const app = createApp({
|
||||
serveStatic: false,
|
||||
trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown",
|
||||
});
|
||||
|
||||
export default {
|
||||
async fetch(request: Request) {
|
||||
await initializeAuth();
|
||||
const ip = ipAddress(request);
|
||||
const headers = new Headers(request.headers);
|
||||
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
|
||||
headers.delete("x-real-ip");
|
||||
if (ip && isIP(ip)) {
|
||||
headers.set("x-real-ip", ip);
|
||||
headers.set("x-forwarded-for", ip);
|
||||
}
|
||||
return app.fetch(new Request(request, { headers }));
|
||||
},
|
||||
};
|
||||
@@ -8,9 +8,56 @@ const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", im
|
||||
version?: string;
|
||||
};
|
||||
|
||||
// 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([
|
||||
"ioredis",
|
||||
"@ioredis/commands",
|
||||
"cluster-key-slot",
|
||||
"debug",
|
||||
"ms",
|
||||
"denque",
|
||||
"redis-errors",
|
||||
"standard-as-callback",
|
||||
"react-reconciler",
|
||||
"scheduler",
|
||||
"@uiw/color-convert",
|
||||
"@babel/runtime",
|
||||
"sanitize-html",
|
||||
"htmlparser2",
|
||||
"domhandler",
|
||||
"domutils",
|
||||
"domelementtype",
|
||||
"dom-serializer",
|
||||
"entities",
|
||||
"deepmerge",
|
||||
"escape-string-regexp",
|
||||
"is-plain-object",
|
||||
"parse-srcset",
|
||||
"postcss",
|
||||
"nanoid",
|
||||
"picocolors",
|
||||
"source-map-js",
|
||||
"launder",
|
||||
"dayjs",
|
||||
]);
|
||||
|
||||
const packageNameOf = (id: string) =>
|
||||
id
|
||||
.split("/")
|
||||
.slice(0, id.startsWith("@") ? 2 : 1)
|
||||
.join("/");
|
||||
|
||||
// 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 (id.startsWith("@reactive-resume/")) return false;
|
||||
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
|
||||
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;
|
||||
};
|
||||
@@ -33,7 +80,14 @@ const promptAssetsPlugin: TsdownPlugin = {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.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.
|
||||
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
@@ -47,7 +101,7 @@ export default defineConfig({
|
||||
suppressWarnings: [/dynamic import will not move module into another chunk/],
|
||||
outExtensions: () => ({ js: ".mjs" }),
|
||||
deps: {
|
||||
alwaysBundle: [/^@reactive-resume\//],
|
||||
alwaysBundle: shouldBundle,
|
||||
neverBundle: shouldExternalizeThirdParty,
|
||||
},
|
||||
plugins: [promptAssetsPlugin],
|
||||
|
||||
@@ -2,8 +2,12 @@
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:ci": {
|
||||
"cache": false
|
||||
"cache": false,
|
||||
"env": ["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";
|
||||
+26
-7
@@ -1,9 +1,23 @@
|
||||
<!doctype html>
|
||||
<html lang="en" class="dark">
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
@@ -20,11 +34,12 @@
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
</head>
|
||||
<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>
|
||||
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
|
||||
<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>
|
||||
<span class="initial-loader__sr-only">Loading</span>
|
||||
</div>
|
||||
@@ -38,17 +53,21 @@
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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; }
|
||||
.initial-loader__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(250, 250, 250, 0.2);
|
||||
border-top-color: #fafafa;
|
||||
border: 2px solid rgba(28, 27, 21, 0.2);
|
||||
border-top-color: #1c1b15;
|
||||
border-radius: 9999px;
|
||||
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 {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
|
||||
+5727
-3154
File diff suppressed because it is too large
Load Diff
+5718
-3145
File diff suppressed because it is too large
Load Diff
+5708
-3135
File diff suppressed because it is too large
Load Diff
+5714
-3141
File diff suppressed because it is too large
Load Diff
+5710
-3137
File diff suppressed because it is too large
Load Diff
+5714
-3141
File diff suppressed because it is too large
Load Diff
+5711
-3138
File diff suppressed because it is too large
Load Diff
+5706
-3133
File diff suppressed because it is too large
Load Diff
+5704
-3131
File diff suppressed because it is too large
Load Diff
+5697
-3124
File diff suppressed because it is too large
Load Diff
+5698
-3125
File diff suppressed because it is too large
Load Diff
+5692
-3119
File diff suppressed because it is too large
Load Diff
+5681
-3108
File diff suppressed because it is too large
Load Diff
+5695
-3122
File diff suppressed because it is too large
Load Diff
+5700
-3127
File diff suppressed because it is too large
Load Diff
+5700
-3127
File diff suppressed because it is too large
Load Diff
+5696
-3123
File diff suppressed because it is too large
Load Diff
+5697
-3124
File diff suppressed because it is too large
Load Diff
+5704
-3131
File diff suppressed because it is too large
Load Diff
+5696
-3123
File diff suppressed because it is too large
Load Diff
+5700
-3127
File diff suppressed because it is too large
Load Diff
+5695
-3122
File diff suppressed because it is too large
Load Diff
+5698
-3125
File diff suppressed because it is too large
Load Diff
+5699
-3126
File diff suppressed because it is too large
Load Diff
+5705
-3132
File diff suppressed because it is too large
Load Diff
+5696
-3123
File diff suppressed because it is too large
Load Diff
+5696
-3123
File diff suppressed because it is too large
Load Diff
+5698
-3125
File diff suppressed because it is too large
Load Diff
+5699
-3126
File diff suppressed because it is too large
Load Diff
+5704
-3131
File diff suppressed because it is too large
Load Diff
+5709
-3136
File diff suppressed because it is too large
Load Diff
+5706
-3133
File diff suppressed because it is too large
Load Diff
+5705
-3132
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