mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-10-01 09:14:29 +10:00
Compare commits
865
Commits
v5.1.4
...
release/v6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcfb18fe5a | ||
|
|
3e7849e51f | ||
|
|
0bdf8066cd | ||
|
|
bec152b7e9 | ||
|
|
ec0932c5d2 | ||
|
|
51faba9e9d | ||
|
|
e67b1c7ae9 | ||
|
|
b87b31f7e9 | ||
|
|
179861e39e | ||
|
|
50af92e2fc | ||
|
|
9a803305c8 | ||
|
|
d6f03a3e67 | ||
|
|
d46b4b5815 | ||
|
|
883045b14c | ||
|
|
31c58baed4 | ||
|
|
36ba314e2a | ||
|
|
49fcc630d1 | ||
|
|
1e8b638a6c | ||
|
|
d3ed651d65 | ||
|
|
6d4ceefbdf | ||
|
|
725be158c0 | ||
|
|
d4265e76e0 | ||
|
|
f8981502f1 | ||
|
|
8ded9de46e | ||
|
|
f82e9f33f3 | ||
|
|
5b84bf800a | ||
|
|
01e87a6f1b | ||
|
|
48b27802fd | ||
|
|
6cfcdea327 | ||
|
|
b399e289d6 | ||
|
|
708a956174 | ||
|
|
8a2fa26451 | ||
|
|
c0c7984712 | ||
|
|
bc1aaedf1f | ||
|
|
2622eeff12 | ||
|
|
03c3a88841 | ||
|
|
461d3c4e64 | ||
|
|
acdb9d88d3 | ||
|
|
54a9bfc307 | ||
|
|
23ea18241b | ||
|
|
c6c51e5b23 | ||
|
|
eaccd1d5c0 | ||
|
|
61f481055a | ||
|
|
e74403e12f | ||
|
|
a9c4b84d38 | ||
|
|
bdfe6fe421 | ||
|
|
16008a95d6 | ||
|
|
394e59e1af | ||
|
|
dc7e1e0431 | ||
|
|
a6ef340c09 | ||
|
|
39f30ac9a1 | ||
|
|
7304c38303 | ||
|
|
59337fcd51 | ||
|
|
18597a6de0 | ||
|
|
c0e9d3fc62 | ||
|
|
38447bd041 | ||
|
|
861feb22eb | ||
|
|
73a3dfc423 | ||
|
|
92459122c5 | ||
|
|
677ff17c1f | ||
|
|
c8aead4ff0 | ||
|
|
a325232a09 | ||
|
|
db97381b3d | ||
|
|
cfb272229b | ||
|
|
9acf289b11 | ||
|
|
25131b3a82 | ||
|
|
31fb576099 | ||
|
|
a6676b3268 | ||
|
|
0a5323520f | ||
|
|
3d65aea58c | ||
|
|
09134035a8 | ||
|
|
e3d72ab0e7 | ||
|
|
8a8de96a96 | ||
|
|
8e0a76bcb1 | ||
|
|
c652a288ba | ||
|
|
28eea458d9 | ||
|
|
607c561792 | ||
|
|
7827ff11bf | ||
|
|
d1aabaecb3 | ||
|
|
30819e2fc7 | ||
|
|
2a71d74e7f | ||
|
|
08c513ca26 | ||
|
|
0bc128ff3a | ||
|
|
d9979bbc8d | ||
|
|
500cabaf4a | ||
|
|
982e688e65 | ||
|
|
741b080296 | ||
|
|
3c71b4e7c3 | ||
|
|
ab2e263a2c | ||
|
|
7c33ebae11 | ||
|
|
218aad51a8 | ||
|
|
eae7de3fb0 | ||
|
|
bf3ca81c49 | ||
|
|
6e412c2f6b | ||
|
|
29647ec634 | ||
|
|
22dbbdc547 | ||
|
|
538fd316a1 | ||
|
|
36163a52b4 | ||
|
|
cb3c594655 | ||
|
|
c302faa70b | ||
|
|
e368e5955d | ||
|
|
0f6e08a922 | ||
|
|
a42cacc057 | ||
|
|
8951f45a3a | ||
|
|
2506509538 | ||
|
|
de3ffeacca | ||
|
|
48793e04be | ||
|
|
b775cbc15c | ||
|
|
bfb19ce56e | ||
|
|
cbc76b03b1 | ||
|
|
29ab0326b2 | ||
|
|
3405200cf4 | ||
|
|
ce2f1857f9 | ||
|
|
5dc67c3bd4 | ||
|
|
49422e98f2 | ||
|
|
17d25c5ffa | ||
|
|
6c2bc74f92 | ||
|
|
098df60230 | ||
|
|
11fe2b7a28 | ||
|
|
5f7ed15a5b | ||
|
|
722f5fa14c | ||
|
|
c0bf1aebaf | ||
|
|
bc28441b66 | ||
|
|
195fbaaaf4 | ||
|
|
82e2c92a51 | ||
|
|
9062114379 | ||
|
|
92dc11bd2f | ||
|
|
62572a20ca | ||
|
|
dcddc5639a | ||
|
|
55a3284360 | ||
|
|
f8767c32f1 | ||
|
|
3a69dfc4d5 | ||
|
|
f2eb230f69 | ||
|
|
d2ef001be9 | ||
|
|
3604d6feb2 | ||
|
|
991d7e0c32 | ||
|
|
2ca57fab80 | ||
|
|
d9ed63f720 | ||
|
|
6282bf77db | ||
|
|
cc28f78537 | ||
|
|
a043b28867 | ||
|
|
abca49120c | ||
|
|
9f809890e4 | ||
|
|
55db11aea8 | ||
|
|
cbd47ec1fb | ||
|
|
4acc5f19d4 | ||
|
|
3ef8eaeb26 | ||
|
|
e1d5b9ba9b | ||
|
|
26044b5346 | ||
|
|
6463a9e9c1 | ||
|
|
e86179187e | ||
|
|
12c7869ea7 | ||
|
|
2bf88584e8 | ||
|
|
82fa327900 | ||
|
|
aa9a5113c0 | ||
|
|
fdad39c632 | ||
|
|
c061367a27 | ||
|
|
2a41d45efc | ||
|
|
8dc45ee7e7 | ||
|
|
462db6011a | ||
|
|
ce0b4c606a | ||
|
|
f5a3fa35eb | ||
|
|
25306630e7 | ||
|
|
4fbc0d7b1d | ||
|
|
1253ef08a6 | ||
|
|
3397c77917 | ||
|
|
565424631a | ||
|
|
1608d56903 | ||
|
|
e26ce08fc5 | ||
|
|
0e36729b80 | ||
|
|
7f162bf230 | ||
|
|
7831cc04a7 | ||
|
|
443897dcb0 | ||
|
|
daa7d331f3 | ||
|
|
d4406c729b | ||
|
|
a568f64b43 | ||
|
|
dc6de786f7 | ||
|
|
13787333d4 | ||
|
|
6540c2aee9 | ||
|
|
c8df749258 | ||
|
|
060750a868 | ||
|
|
31e8dc39d4 | ||
|
|
c18911aaca | ||
|
|
448db84e50 | ||
|
|
4da00ddce1 | ||
|
|
49464bb7ff | ||
|
|
9dd38433d6 | ||
|
|
1ef7c9fa10 | ||
|
|
85352ee11b | ||
|
|
7244485d6e | ||
|
|
1b079dd5bd | ||
|
|
de85121f5e | ||
|
|
e2e5c15580 | ||
|
|
a82741f42a | ||
|
|
bda01febd5 | ||
|
|
fa19b891db | ||
|
|
91f1aba2e5 | ||
|
|
ffa16f2efa | ||
|
|
f2a76b2f69 | ||
|
|
639abf12b6 | ||
|
|
f73238ba3b | ||
|
|
a887a72d77 | ||
|
|
90d7d0a19b | ||
|
|
9e041e140b | ||
|
|
0f934bd849 | ||
|
|
4485825dfe | ||
|
|
def4f72169 | ||
|
|
b0aecdfb5a | ||
|
|
a7f1829484 | ||
|
|
328bf73cee | ||
|
|
1b78e546e2 | ||
|
|
fb756026fa | ||
|
|
73e7a3cb6d | ||
|
|
685fcab605 | ||
|
|
6db26e9b5c | ||
|
|
2b31d70a8a | ||
|
|
d0d20ce0fd | ||
|
|
b48a9c2142 | ||
|
|
0cb83602f5 | ||
|
|
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 | ||
|
|
c288675b16 | ||
|
|
e065a10824 | ||
|
|
b47f805321 | ||
|
|
2761bd6715 | ||
|
|
a416d01112 | ||
|
|
7fac6f29c0 | ||
|
|
d3dddf229b | ||
|
|
3c195dc3f8 | ||
|
|
3221afda9d | ||
|
|
8ce899a04b | ||
|
|
39f36b4ac5 | ||
|
|
39590eaff6 | ||
|
|
c8081ac2fe | ||
|
|
dbbab6fd76 | ||
|
|
8acde4c1ac | ||
|
|
4d53a6d1de | ||
|
|
ab811b5f10 | ||
|
|
65618a82a0 | ||
|
|
6f0c727770 | ||
|
|
ebcaa4729f | ||
|
|
f14e120b00 | ||
|
|
d9da31e7bc | ||
|
|
128916b9a0 | ||
|
|
00be67f702 | ||
|
|
5392728f22 | ||
|
|
0b0b4ef13b | ||
|
|
24c15cd8cd | ||
|
|
6e3853fe13 | ||
|
|
b080fcddad | ||
|
|
9dc2aade46 | ||
|
|
e2554c9be8 | ||
|
|
eedf2faf02 | ||
|
|
da2f1f8244 | ||
|
|
7a14b0dfbc | ||
|
|
23ceee2148 | ||
|
|
170550ed59 | ||
|
|
ac062bbcbd | ||
|
|
bfdd29f941 | ||
|
|
e8508e6d03 | ||
|
|
60d0440763 | ||
|
|
f4bf6887b9 | ||
|
|
817d4ef971 | ||
|
|
7c7dbaf21d | ||
|
|
762b999d1e | ||
|
|
9d0dc36706 | ||
|
|
d0fa9ae8da | ||
|
|
1e23a453a0 | ||
|
|
36c35c9bd5 | ||
|
|
0c7c3ac4c4 | ||
|
|
9509b5bc2e | ||
|
|
f848e57436 | ||
|
|
a4bc2693be | ||
|
|
104e954b77 | ||
|
|
118f3679a3 | ||
|
|
6c1280dca9 | ||
|
|
8affc567e3 | ||
|
|
409d09809a | ||
|
|
6d9ebccc63 | ||
|
|
45303fb465 | ||
|
|
f64d02df7f | ||
|
|
bad431b2fc | ||
|
|
9f13638eab | ||
|
|
13e584d522 | ||
|
|
6035402832 | ||
|
|
69961210bd | ||
|
|
7eb6d3bdbf | ||
|
|
5fc9c3ee04 | ||
|
|
a8d1f5a685 | ||
|
|
dd9843172b | ||
|
|
28d698635f | ||
|
|
3635b3d578 | ||
|
|
5a75eda893 | ||
|
|
e4b28e9825 | ||
|
|
2d6ea9ce8d | ||
|
|
0e463883af | ||
|
|
3a5b12e2a4 | ||
|
|
035d94183b | ||
|
|
efd950bd93 | ||
|
|
04100aa9ef | ||
|
|
c292968314 | ||
|
|
ba1f469950 | ||
|
|
b4f245a38e | ||
|
|
e6a31aab97 | ||
|
|
88a19619da | ||
|
|
36232b631d | ||
|
|
9eec1520a1 | ||
|
|
131c1492cd | ||
|
|
ba8e1be2ab | ||
|
|
4a8f87ab8f | ||
|
|
186c400ab7 | ||
|
|
d314361ad6 | ||
|
|
b071a118a3 | ||
|
|
3589b534f5 | ||
|
|
1ee24e5a9f | ||
|
|
93bf1e882d | ||
|
|
ae8d48bcee | ||
|
|
517199471a | ||
|
|
15f8bce988 | ||
|
|
164a279306 | ||
|
|
79e4a3ddc8 | ||
|
|
d2ffbf9618 | ||
|
|
4ac19f81b3 | ||
|
|
b303b89758 | ||
|
|
c6ac3fd1a9 | ||
|
|
fe6f84e06d | ||
|
|
9d6426b2e0 | ||
|
|
d34a429dea | ||
|
|
b69583c181 | ||
|
|
50f50b2672 | ||
|
|
fb8c73be76 | ||
|
|
18468a5658 | ||
|
|
048eab3b49 | ||
|
|
ca774c77c8 | ||
|
|
a4897c20d7 | ||
|
|
bed14a72af | ||
|
|
93c06934bd | ||
|
|
1e665fbe7e | ||
|
|
30812f8a8e | ||
|
|
dd0531091b | ||
|
|
a2901bfb2e | ||
|
|
418c7887ee | ||
|
|
12407d473d | ||
|
|
36a46cfd66 | ||
|
|
0868a92e62 | ||
|
|
822d6f9431 | ||
|
|
994093b981 | ||
|
|
9110e86997 | ||
|
|
bb1fb3a7d6 | ||
|
|
e34e7be6e0 | ||
|
|
34c03b1f73 | ||
|
|
0eb9ce012e | ||
|
|
966bc3ed58 | ||
|
|
08d859010c | ||
|
|
47349e7ab3 | ||
|
|
3266066826 | ||
|
|
6503da7e49 | ||
|
|
2a0782517c | ||
|
|
d4cf260aed | ||
|
|
e6b4733c5f | ||
|
|
689e7e24d4 | ||
|
|
9085a199cf | ||
|
|
d536b1921f | ||
|
|
2b0aac820c | ||
|
|
ac98139096 | ||
|
|
d50948ddee | ||
|
|
42bac75ae2 | ||
|
|
c77745f34e | ||
|
|
ed5d10c491 | ||
|
|
18d0c14aa1 | ||
|
|
1124d3dfda | ||
|
|
90105cb148 | ||
|
|
73daf22b2f | ||
|
|
25021507a0 | ||
|
|
8570c1c70a | ||
|
|
5270a2a9a0 | ||
|
|
b87a9d8282 | ||
|
|
46afc65cc6 | ||
|
|
dfc5559625 | ||
|
|
d37ac57cc5 | ||
|
|
fb9c217af2 | ||
|
|
0a64312bf8 | ||
|
|
b404dbd42a | ||
|
|
be43b4556b | ||
|
|
0d1bfd4e6b | ||
|
|
20c803e934 | ||
|
|
6e7fc68068 | ||
|
|
a28e3baa61 | ||
|
|
9f9268f380 | ||
|
|
8416a92153 | ||
|
|
3f6e22addb | ||
|
|
25b70c24f1 | ||
|
|
da40422dfa | ||
|
|
e15edafbff | ||
|
|
d5b177aa89 | ||
|
|
d32227ff43 | ||
|
|
7a0d1e93f3 | ||
|
|
560956bbe6 | ||
|
|
7f458dc58d | ||
|
|
361480445f | ||
|
|
57fb23145c | ||
|
|
6207cbc026 | ||
|
|
a149e614a7 | ||
|
|
eab7534ea4 | ||
|
|
79a69c5507 | ||
|
|
70df113ee6 | ||
|
|
44e9a8a29f | ||
|
|
e47cb37ab9 | ||
|
|
02538836a9 | ||
|
|
22398a502b | ||
|
|
e00348ef84 | ||
|
|
8d17ec6583 | ||
|
|
e93a56d753 | ||
|
|
975cea84e3 | ||
|
|
34398a578b | ||
|
|
27efeab796 | ||
|
|
f5ec471318 | ||
|
|
376977a9f7 | ||
|
|
9b9d5c833c | ||
|
|
15448cad6a | ||
|
|
afd734dd61 | ||
|
|
493ef12a9a | ||
|
|
a5935dee0f | ||
|
|
5226f04e86 | ||
|
|
a1fb0597a3 | ||
|
|
a2a2c0a768 | ||
|
|
f2ec6a499f | ||
|
|
0fb81ad772 | ||
|
|
19470c8cd2 | ||
|
|
3f050e5213 | ||
|
|
91c4a2421c | ||
|
|
82d961241e | ||
|
|
f3a60432df | ||
|
|
0701f3b62a | ||
|
|
cf738b9306 | ||
|
|
fcc10c6b31 | ||
|
|
3e96605d4c | ||
|
|
7e35e8b657 | ||
|
|
8de15822fb | ||
|
|
e2099b9002 | ||
|
|
5762eb6a3e | ||
|
|
dfe75390cd | ||
|
|
439ae114f9 | ||
|
|
9b41edb43d | ||
|
|
d87c6758ab | ||
|
|
44fa2badb4 | ||
|
|
0abb5a07e6 | ||
|
|
a9a38ff5dc | ||
|
|
bf70705f1f | ||
|
|
332aa210c4 | ||
|
|
da6a9f2c78 | ||
|
|
4541cf1cdc | ||
|
|
27df724d2a | ||
|
|
bc09430fdf | ||
|
|
e936f93e3a | ||
|
|
3ba566506a | ||
|
|
a7c599b724 | ||
|
|
dbb0b179c3 | ||
|
|
fc634a202d | ||
|
|
7fab23870f | ||
|
|
20a8a3df9d | ||
|
|
e38e37383d | ||
|
|
d45116b2ba | ||
|
|
6ad4f13914 | ||
|
|
2f5d321051 | ||
|
|
57e9c8c487 | ||
|
|
09bc6ec521 | ||
|
|
50885176e0 | ||
|
|
cbeecf6596 | ||
|
|
ee970f2961 | ||
|
|
578a983209 | ||
|
|
617135466d | ||
|
|
fa4c8adf78 | ||
|
|
5b8ab33888 | ||
|
|
0ba44865c7 | ||
|
|
a4999c04af | ||
|
|
2a80e6a1df | ||
|
|
4c8cc5c016 | ||
|
|
d3735ebe27 | ||
|
|
8eab8fdaa0 | ||
|
|
fbb9938af6 | ||
|
|
5080fddf51 | ||
|
|
dfd2c77bc9 | ||
|
|
56c90947e4 | ||
|
|
ae2a1dac12 | ||
|
|
dcf1b28c22 | ||
|
|
f14d8ce693 | ||
|
|
2317a82106 | ||
|
|
a523e13bfd | ||
|
|
1be75240dd | ||
|
|
7275da7303 | ||
|
|
bc498449d3 | ||
|
|
3937f7ed2b | ||
|
|
d6de3f830f | ||
|
|
ef5ff30b13 | ||
|
|
37faf592b7 | ||
|
|
76bd1e80f7 | ||
|
|
042d076efa | ||
|
|
b9e4ab78ef | ||
|
|
90a9bb9cf1 | ||
|
|
5fb4976ec9 | ||
|
|
d6a9bc6c4b | ||
|
|
0dcdcd2960 | ||
|
|
e96a51f31c | ||
|
|
1507d869c7 | ||
|
|
b932711f08 | ||
|
|
1522794733 | ||
|
|
e00ff8ceca | ||
|
|
8e72311bc6 | ||
|
|
a8c70d784c | ||
|
|
0df7f21130 | ||
|
|
6852f586ea | ||
|
|
1414fecade | ||
|
|
c1d11236ae | ||
|
|
d09ad2cdc0 | ||
|
|
9ce5bacd22 | ||
|
|
1d761be05b | ||
|
|
c875541001 | ||
|
|
16f4d2c072 | ||
|
|
b491582637 | ||
|
|
c6a654191c | ||
|
|
8461aa65d5 | ||
|
|
b04eef1479 | ||
|
|
7bff6644d8 | ||
|
|
8da780c868 | ||
|
|
dd1e37e579 | ||
|
|
19b412d84d | ||
|
|
7eea6675c0 | ||
|
|
273e17c0d3 | ||
|
|
17cddbad65 | ||
|
|
7557ab13ab | ||
|
|
c66560ee12 | ||
|
|
24c882fa9f | ||
|
|
86fff7237f | ||
|
|
266bc291eb | ||
|
|
6ec4da7914 | ||
|
|
75e9446134 | ||
|
|
39e88dd365 | ||
|
|
3596102c63 | ||
|
|
c77684d317 | ||
|
|
62f8270b3e | ||
|
|
5b1297fa2b | ||
|
|
dd7623f11e | ||
|
|
63e8c3ca33 | ||
|
|
e62090cce0 | ||
|
|
0510c7103b | ||
|
|
1a5c5252d1 |
@@ -3,6 +3,7 @@
|
||||
.gitignore
|
||||
.cursor
|
||||
.DS_Store
|
||||
.vite-hooks
|
||||
|
||||
# Local configuration and runtime state
|
||||
.env*
|
||||
|
||||
+66
-16
@@ -1,15 +1,33 @@
|
||||
# --- Application ---
|
||||
# Port used by the web server in local development and self-hosted containers.
|
||||
# Public port used by the production server and the Vite web server in local development.
|
||||
PORT="3000"
|
||||
|
||||
# Port used by the Hono server in local development. Vite proxies API requests to this port.
|
||||
SERVER_PORT="3001"
|
||||
|
||||
# Public URL where the app is served. Used for auth callbacks, OAuth issuer URLs,
|
||||
# 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@localhost:5432/postgres"
|
||||
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`
|
||||
@@ -48,22 +66,26 @@ OAUTH_USER_INFO_URL=""
|
||||
# Space-separated scopes requested from the custom OAuth provider.
|
||||
OAUTH_SCOPES="openid profile email"
|
||||
|
||||
# Comma-separated extra hosts/origins allowed for dynamic OAuth client redirect URIs.
|
||||
# By default, only the APP_URL origin is allowed.
|
||||
OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS=""
|
||||
|
||||
# --- Email (optional) ---
|
||||
# If SMTP_HOST, SMTP_USER, SMTP_PASS, or SMTP_FROM is missing, the app logs the
|
||||
# email to the console instead.
|
||||
SMTP_HOST="localhost"
|
||||
SMTP_PORT="1025"
|
||||
SMTP_HOST=""
|
||||
SMTP_PORT=""
|
||||
SMTP_USER=""
|
||||
SMTP_PASS=""
|
||||
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.
|
||||
@@ -73,18 +95,35 @@ SMTP_SECURE="false"
|
||||
S3_ACCESS_KEY_ID="seaweedfs"
|
||||
S3_SECRET_ACCESS_KEY="seaweedfs"
|
||||
S3_REGION="us-east-1"
|
||||
S3_ENDPOINT="http://localhost:8333"
|
||||
S3_ENDPOINT="http://seaweedfs:8333"
|
||||
S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- AI Agent Workspace (optional) ---
|
||||
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||
REDIS_URL="redis://localhost:6379"
|
||||
# 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"
|
||||
|
||||
# Optional Cloudflare Browser Run credentials used as a URL extraction fallback for agent web access.
|
||||
CLOUDFLARE_ACCOUNT_ID=""
|
||||
CLOUDFLARE_API_TOKEN=""
|
||||
# --- Web access (optional) ---
|
||||
# One shared connection supplies search and enhanced reading: firecrawl, tavily or exa.
|
||||
# Leave unset to use the built-in reader and let users connect a personal key (requires ENCRYPTION_SECRET).
|
||||
# WEB_ACCESS_PROVIDER="tavily"
|
||||
# WEB_ACCESS_API_KEY=""
|
||||
# Optional custom Firecrawl service, without /v2; may be keyless. Other providers use fixed cloud endpoints.
|
||||
# WEB_ACCESS_PROVIDER="firecrawl"
|
||||
# WEB_ACCESS_API_URL="http://localhost:3102"
|
||||
# Legacy aliases still work when all WEB_ACCESS_* variables are unset.
|
||||
# FIRECRAWL_API_URL="http://localhost:3102"
|
||||
# FIRECRAWL_API_KEY=""
|
||||
|
||||
# Optional shared AI provider. When set, personal AI providers are disabled.
|
||||
# AI_PROVIDER="openai"
|
||||
# AI_MODEL="gpt-5-mini"
|
||||
# AI_API_KEY=""
|
||||
# AI_BASE_URL=""
|
||||
|
||||
# --- Feature Flags ---
|
||||
# This flag disables new signups, both on the web app and the server.
|
||||
@@ -98,6 +137,17 @@ 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 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"
|
||||
|
||||
|
||||
# Allows dynamic OAuth client registration to use any parseable redirect URI,
|
||||
# including custom schemes, private hosts, and non-loopback http:// URLs.
|
||||
# WARNING: Enabling this on a public or multi-tenant deployment can enable phishing
|
||||
# or token exfiltration. Only enable this on a trusted, self-hosted instance.
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI="false"
|
||||
|
||||
# Allows AI providers to be configured with any base URL, including http:// and
|
||||
# private/loopback addresses (e.g. http://localhost:11434 for a local Ollama instance).
|
||||
# WARNING: Enabling this on a multi-tenant deployment is a Server-Side Request Forgery (SSRF)
|
||||
@@ -113,4 +163,4 @@ GOOGLE_CLOUD_API_KEY=""
|
||||
# Crowdin (optional)
|
||||
# For translation tooling.
|
||||
CROWDIN_PROJECT_ID=""
|
||||
CROWDIN_PERSONAL_TOKEN=""
|
||||
CROWDIN_API_TOKEN=""
|
||||
|
||||
@@ -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,68 +1,124 @@
|
||||
name: 🐞 Bug Report
|
||||
|
||||
description: Create a bug report to help improve Reactive Resume
|
||||
description: Report a reproducible problem with Reactive Resume
|
||||
|
||||
title: "[Bug] <title>"
|
||||
labels: [bug, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
labels: ["bug", "status: needs triage"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this?
|
||||
description: Please search to see if an issue already exists for the bug you encountered.
|
||||
label: Existing issue
|
||||
description: Search open and closed issues before submitting a new report.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues and none of them match my problem.
|
||||
- label: I searched the existing issues and could not find a matching report.
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Product Variant
|
||||
description: What variant of Reactive Resume are you using?
|
||||
label: Product variant
|
||||
description: Where does the problem occur?
|
||||
options:
|
||||
- Cloud (https://rxresu.me)
|
||||
- Self-Hosted
|
||||
- Cloud
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: version
|
||||
attributes:
|
||||
label: Reactive Resume version
|
||||
description: Find this in Settings or provide the container image tag or commit SHA.
|
||||
placeholder: 5.2.6
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Choose the part of Reactive Resume most closely related to the problem.
|
||||
options:
|
||||
- Resume builder & data
|
||||
- Templates, preview & export
|
||||
- Accounts & sharing
|
||||
- AI & Agent
|
||||
- Language & localization
|
||||
- Self-hosting
|
||||
- API & integrations
|
||||
- Applications & cover letters
|
||||
- Other / unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include your operating system and browser. For self-hosted installations, also include the deployment method.
|
||||
placeholder: Firefox 143 on Ubuntu 26.04, deployed with Docker Compose
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: summary
|
||||
attributes:
|
||||
label: Describe the bug you're experiencing
|
||||
description: A detailed description of what you're experiencing. Please provide as much detail as possible as it will help me diagnose and fix the issue faster.
|
||||
label: Summary
|
||||
description: Briefly describe the problem and its impact.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: Provide the smallest reliable sequence that demonstrates the problem.
|
||||
placeholder: |
|
||||
1. Open ...
|
||||
2. Select ...
|
||||
3. Observe ...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: template
|
||||
attributes:
|
||||
label: What template are you using?
|
||||
description: Leave blank if the issue applies to all templates, or is not template-specific.
|
||||
multiple: false
|
||||
label: Template
|
||||
description: Leave blank when the problem is not template-specific.
|
||||
options:
|
||||
- Azurill
|
||||
- Bronzor
|
||||
- Chikorita
|
||||
- Ditto
|
||||
- Ditgar
|
||||
- Ditto
|
||||
- Gengar
|
||||
- Glalie
|
||||
- Kakuna
|
||||
- Lapras
|
||||
- Leafish
|
||||
- Meowth
|
||||
- Onyx
|
||||
- Pikachu
|
||||
- Rhyhorn
|
||||
- Scizor
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: logs
|
||||
attributes:
|
||||
label: Anything else?
|
||||
description: |
|
||||
Links? References? Anything that will give us more context about the issue you are encountering!
|
||||
|
||||
Tip: You can attach images or log files by clicking this area to highlight it and then dragging files in.
|
||||
validations:
|
||||
required: false
|
||||
label: Logs and screenshots
|
||||
description: Add relevant logs, screenshots, or a minimal reproduction. Remove secrets and personal resume data first.
|
||||
|
||||
@@ -1,23 +1,82 @@
|
||||
name: ✨ Feature Request
|
||||
|
||||
description: Suggest an feature or idea that you would like to see in Reactive Resume
|
||||
description: Propose an actionable improvement to Reactive Resume
|
||||
|
||||
title: "[Feature] <title>"
|
||||
labels: [enhancement, v5, needs triage]
|
||||
assignees: "AmruthPillai"
|
||||
labels: ["enhancement", "status: needs triage"]
|
||||
assignees: []
|
||||
|
||||
body:
|
||||
- type: checkboxes
|
||||
attributes:
|
||||
label: Is there an existing issue for this feature?
|
||||
description: Please search to see if an issue already exists for the feature you requested.
|
||||
label: Existing issue
|
||||
description: Search open and closed issues before submitting a new proposal.
|
||||
options:
|
||||
- label: Yes, I have searched the existing issues and it doesn't exist.
|
||||
- label: I searched the existing issues and could not find a matching proposal.
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
- type: dropdown
|
||||
id: variant
|
||||
attributes:
|
||||
label: Feature Description
|
||||
description: A detailed description of the feature you would like to see in Reactive Resume. Please provide as much detail as possible as it will help me implement the feature faster.
|
||||
label: Product variant
|
||||
description: Choose the primary environment for this proposal.
|
||||
options:
|
||||
- Cloud
|
||||
- Self-hosted
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Choose the part of Reactive Resume most closely related to the proposal.
|
||||
options:
|
||||
- Resume builder & data
|
||||
- Templates, preview & export
|
||||
- Accounts & sharing
|
||||
- AI & Agent
|
||||
- Language & localization
|
||||
- Self-hosting
|
||||
- API & integrations
|
||||
- Applications & cover letters
|
||||
- Other / unsure
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem
|
||||
description: What user problem or limitation should Reactive Resume solve?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: outcome
|
||||
attributes:
|
||||
label: Desired outcome
|
||||
description: Describe the behavior you want without prescribing an implementation.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Describe current workarounds or alternatives. Write "None" if there are none.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: scope
|
||||
attributes:
|
||||
label: Proposed scope
|
||||
description: Explain what should be included and what can remain out of scope.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Add examples, mockups, or related issues when useful. Remove personal resume data first.
|
||||
|
||||
@@ -1 +1,8 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Questions and support
|
||||
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/reactive-resume/reactive-resume/security/advisories/new
|
||||
about: Report security vulnerabilities privately.
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
<!-- caveman-begin -->
|
||||
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
Rules:
|
||||
|
||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||
- Pattern: [thing] [action] [reason]. [next step].
|
||||
- Not: "Sure! I'd be happy to help you with that."
|
||||
- Yes: "Bug in auth middleware. Fix:"
|
||||
|
||||
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
|
||||
Stop: "stop caveman" or "normal mode"
|
||||
|
||||
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
|
||||
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
@@ -13,19 +13,31 @@ 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
|
||||
echo "::error::Merge conflict markers found in tracked files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- 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,26 +2,69 @@ 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
|
||||
tags:
|
||||
- "v*"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref }}
|
||||
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: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
outputs:
|
||||
nightly: ${{ steps.mode.outputs.nightly }}
|
||||
release: ${{ steps.mode.outputs.release }}
|
||||
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 [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
|
||||
echo "nightly=true" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$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 "canary=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: mode
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ubuntu-latest
|
||||
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
runner: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404-arm' || 'ubuntu-24.04-arm' }}
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
@@ -35,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 }}
|
||||
@@ -61,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
|
||||
@@ -79,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
|
||||
@@ -97,9 +193,15 @@ jobs:
|
||||
retention-days: 1
|
||||
|
||||
merge:
|
||||
needs: build
|
||||
needs:
|
||||
- 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
|
||||
@@ -109,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"
|
||||
@@ -128,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 }}
|
||||
@@ -155,21 +266,31 @@ 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=latest
|
||||
type=raw,value=v${{ steps.version.outputs.version }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}
|
||||
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' }}
|
||||
type=raw,value=v${{ steps.version.outputs.version }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }}.${{ steps.semver.outputs.minor }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
type=raw,value=v${{ steps.semver.outputs.major }},enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
|
||||
- name: Create manifest list and push
|
||||
id: manifest
|
||||
working-directory: /tmp/digests
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
|
||||
docker buildx imagetools create \
|
||||
$(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
--annotation "index:org.opencontainers.image.licenses=MIT" \
|
||||
@@ -178,16 +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 }}:v${{ steps.version.outputs.version }} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }} --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
|
||||
@@ -195,17 +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 }}:v${{ steps.version.outputs.version }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:v${{ steps.version.outputs.version }}
|
||||
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' && env.DEPLOY == 'true' }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
@@ -214,3 +361,24 @@ jobs:
|
||||
script: |
|
||||
cd docker
|
||||
./manage_stack.sh up reactive_resume
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
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 }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
response=$(curl -fsS --max-time 10 --retry 3 --retry-delay 5 --retry-connrefused -X POST \
|
||||
"https://api.cloudflare.com/client/v4/zones/${CLOUDFLARE_ZONE_ID}/purge_cache" \
|
||||
-H "Authorization: Bearer ${CLOUDFLARE_API_TOKEN}" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data '{"purge_everything":true}')
|
||||
|
||||
if [ "$(jq -r '.success' <<< "$response")" != "true" ]; then
|
||||
echo "$response" | jq .
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Cloudflare cache purged successfully."
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
name: E2E Tests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
push:
|
||||
branches: ["main"]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
APP_URL: http://localhost:3000
|
||||
PORT: "3000"
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
FLAG_DISABLE_SIGNUPS: "false"
|
||||
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
|
||||
INTEGRATIONS_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
OAUTH_TEST_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/oauth_test
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-32vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
timeout-minutes: 30
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16
|
||||
env:
|
||||
POSTGRES_DB: postgres
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
options: >-
|
||||
--health-cmd "pg_isready -U postgres -d postgres"
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
if: ${{ vars.USE_BLACKSMITH != 'true' }}
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Checkout Repository (Blacksmith)
|
||||
if: ${{ vars.USE_BLACKSMITH == 'true' }}
|
||||
uses: useblacksmith/checkout@v1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Cache Turbo artifacts
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .turbo/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Check Package Boundaries
|
||||
run: pnpm exec turbo boundaries
|
||||
|
||||
- name: Typecheck Affected Packages
|
||||
run: pnpm exec turbo run typecheck --affected
|
||||
|
||||
- name: Install Playwright Browser
|
||||
timeout-minutes: 10
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
- name: Generate Test Secrets
|
||||
run: |
|
||||
echo "AUTH_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
echo "ENCRYPTION_SECRET=$(openssl rand -hex 32)" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Prepare Storage
|
||||
run: mkdir -p "$LOCAL_STORAGE_PATH"
|
||||
|
||||
- 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 E2E Tests
|
||||
run: pnpm exec playwright test
|
||||
|
||||
- name: Upload Playwright Report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: playwright-report
|
||||
path: |
|
||||
playwright-report
|
||||
test-results
|
||||
if-no-files-found: ignore
|
||||
retention-days: 7
|
||||
@@ -0,0 +1,41 @@
|
||||
name: Label New Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
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:
|
||||
script: |
|
||||
const { getIssueLabels } = await import(`${process.env.GITHUB_WORKSPACE}/tooling/issue-labels.mjs`);
|
||||
const labels = getIssueLabels(context.payload.issue.body ?? "");
|
||||
|
||||
if (labels.length > 0) {
|
||||
await github.rest.issues.addLabels({
|
||||
...context.repo,
|
||||
issue_number: context.issue.number,
|
||||
labels,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
name: Close Issues Awaiting Information
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "23 4 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ${{ vars.USE_BLACKSMITH == 'true' && 'blacksmith-2vcpu-ubuntu-2404' || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Close Inactive Issues Awaiting Information
|
||||
uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11
|
||||
with:
|
||||
only-issue-labels: "status: needs info"
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
stale-issue-message: >-
|
||||
This issue is waiting for information requested by a maintainer. It will close in 7 days if no new information is provided.
|
||||
close-issue-message: >-
|
||||
Closing because the requested information was not provided. Add the missing details in a comment and a maintainer can reopen the issue.
|
||||
close-issue-reason: not_planned
|
||||
remove-issue-stale-when-updated: true
|
||||
@@ -0,0 +1,123 @@
|
||||
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
|
||||
- name: Cache Turbo artifacts
|
||||
uses: actions/cache@v5
|
||||
with:
|
||||
path: .turbo/cache
|
||||
key: ${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-${{ github.sha }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-${{ runner.arch }}-turbo-${{ github.workflow }}-${{ hashFiles('pnpm-lock.yaml', '.nvmrc') }}-
|
||||
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# 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
|
||||
+17
-2
@@ -3,7 +3,8 @@ node_modules
|
||||
.pnpm-store
|
||||
|
||||
# Build Outputs
|
||||
.output
|
||||
dist
|
||||
dist-prerender
|
||||
.vercel
|
||||
.wrangler
|
||||
|
||||
@@ -36,6 +37,8 @@ logs
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Cache
|
||||
tmp
|
||||
@@ -44,11 +47,23 @@ temp
|
||||
|
||||
# AI
|
||||
.codex
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.codegraph
|
||||
.superpowers
|
||||
docs/superpowers
|
||||
.worktrees
|
||||
.migration
|
||||
/plans
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Redesign handoff (design references, not source)
|
||||
/design_handoff_reactive_resume_redesign
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks
|
||||
|
||||
|
||||
-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,62 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"useTabs": true,
|
||||
"printWidth": 120,
|
||||
"sortPackageJson": false,
|
||||
"sortImports": {
|
||||
"newlinesBetween": false,
|
||||
"customGroups": [
|
||||
{
|
||||
"groupName": "types",
|
||||
"selector": "type"
|
||||
},
|
||||
{
|
||||
"groupName": "tests",
|
||||
"elementNamePattern": ["vitest", "vitest/**", "@testing-library/**"]
|
||||
},
|
||||
{
|
||||
"groupName": "workspace",
|
||||
"elementNamePattern": ["@reactive-resume/**"]
|
||||
}
|
||||
],
|
||||
"groups": [
|
||||
"types",
|
||||
"value-builtin",
|
||||
"tests",
|
||||
"value-external",
|
||||
"workspace",
|
||||
["value-internal", "value-parent", "value-sibling", "value-index"],
|
||||
"unknown"
|
||||
]
|
||||
},
|
||||
"sortTailwindcss": {
|
||||
"stylesheet": "./packages/ui/src/styles/globals.css",
|
||||
"functions": ["clsx", "cva", "cn"]
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/.turbo/**",
|
||||
"**/.output/**",
|
||||
"**/dist/**",
|
||||
"**/dist-prerender/**",
|
||||
"**/.vercel/**",
|
||||
"**/.wrangler/**",
|
||||
"**/coverage/**",
|
||||
"**/reports/**",
|
||||
"**/routeTree.gen.ts",
|
||||
"packages/pdf/src/semantic/__fixtures__/**/*.css",
|
||||
"pnpm-lock.yaml",
|
||||
"migrations/**",
|
||||
"docs/spec.json",
|
||||
"docs/guides/json-resume-schema.mdx",
|
||||
"skills/resume-builder/references/schema.md"
|
||||
],
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.md", "**/*.mdx"],
|
||||
"options": {
|
||||
"proseWrap": "preserve",
|
||||
"useTabs": false
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["typescript", "unicorn", "oxc", "react", "jsx-a11y"],
|
||||
"jsPlugins": ["@shadcn/lint"],
|
||||
"categories": {
|
||||
"correctness": "error"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
"**/.turbo/**",
|
||||
"**/.output/**",
|
||||
"**/dist/**",
|
||||
"**/dist-prerender/**",
|
||||
"**/.vercel/**",
|
||||
"**/.wrangler/**",
|
||||
"**/coverage/**",
|
||||
"**/reports/**",
|
||||
"**/routeTree.gen.ts"
|
||||
],
|
||||
"rules": {
|
||||
"typescript/no-explicit-any": "error",
|
||||
"require-await": "error",
|
||||
"typescript/consistent-type-imports": [
|
||||
"error",
|
||||
{
|
||||
"prefer": "type-imports",
|
||||
"fixStyle": "separate-type-imports",
|
||||
"disallowTypeAnnotations": false
|
||||
}
|
||||
],
|
||||
"typescript/no-non-null-assertion": "error",
|
||||
"no-restricted-imports": [
|
||||
"error",
|
||||
{
|
||||
"patterns": [
|
||||
{
|
||||
"regex": "@reactive-resume/[^/]+/src(?:/|$)|(?:^|/)(?:apps|packages)/[^/]+/src(?:/|$)",
|
||||
"message": "Use the workspace package export map instead of private src paths, including re-exports and dynamic imports."
|
||||
},
|
||||
{
|
||||
"group": ["apps/**", "packages/**"],
|
||||
"message": "Do not import another workspace by repository path; use an explicit package export."
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"no-param-reassign": "error",
|
||||
"typescript/prefer-as-const": "error",
|
||||
"default-param-last": "error",
|
||||
"typescript/prefer-enum-initializers": "error",
|
||||
"react/self-closing-comp": "error",
|
||||
"one-var": ["error", "never"],
|
||||
"unicorn/prefer-number-properties": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"no-else-return": "error",
|
||||
"react/no-array-index-key": "off",
|
||||
"react/exhaustive-deps": "warn",
|
||||
"react/unsupported-syntax": "error",
|
||||
"no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
"argsIgnorePattern": "^_",
|
||||
"varsIgnorePattern": "^_",
|
||||
"caughtErrorsIgnorePattern": "^_"
|
||||
}
|
||||
],
|
||||
"typescript/triple-slash-reference": [
|
||||
"error",
|
||||
{
|
||||
"path": "always",
|
||||
"types": "prefer-import",
|
||||
"lib": "always"
|
||||
}
|
||||
],
|
||||
"jsx-a11y/prefer-tag-over-role": "off",
|
||||
"jsx-a11y/control-has-associated-label": "off",
|
||||
"jsx-a11y/no-noninteractive-element-interactions": "off",
|
||||
"jsx-a11y/no-autofocus": [
|
||||
"error",
|
||||
{
|
||||
"ignoreNonDOM": true
|
||||
}
|
||||
],
|
||||
"unicorn/no-new-array": "off",
|
||||
"no-irregular-whitespace": [
|
||||
"error",
|
||||
{
|
||||
"skipComments": true
|
||||
}
|
||||
],
|
||||
"react/no-danger": "error",
|
||||
"react/rules-of-hooks": "error"
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"files": ["**/*.test.{ts,tsx,mts,mjs}", "**/*.spec.{ts,tsx,mts,mjs}"],
|
||||
"rules": {
|
||||
"require-await": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
"files": ["tests/e2e/fixtures/test.ts"],
|
||||
"rules": {
|
||||
"react/rules-of-hooks": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -1,332 +0,0 @@
|
||||
<!-- refreshed: 2026-05-11 -->
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## System Overview
|
||||
|
||||
Reactive Resume is a single full-stack web application running as one Node.js process on port 3000. The web app is a TanStack Start application (Vite + React 19 + Nitro server) packaged in a pnpm/Turborepo monorepo. All API surface area, server-side rendering, file uploads, OAuth, OpenAPI, MCP, and PWA assets are served from the same process. Internal packages are consumed as TypeScript source through `package.json` `exports` maps that point directly at `src` files; there is no per-package `dist` output to depend on.
|
||||
|
||||
```text
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Browser (React 19) │
|
||||
│ TanStack Router · TanStack Query · Zustand stores · React PDF view │
|
||||
│ `apps/web/src/router.tsx` · `apps/web/src/routes/__root.tsx` │
|
||||
└──────────┬─────────────────────────────────────────────────────┬───────────┘
|
||||
│ HTTP / SSR hydration │ /api/rpc (RPCLink)
|
||||
▼ ▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Nitro server entry (TanStack Start) │
|
||||
│ `apps/web/src/server.ts` · Nitro plugins (`apps/web/plugins/`) │
|
||||
├──────────────────────────┬─────────────────────────────────────────────────┤
|
||||
│ File-based routes │ Route `server.handlers` blocks │
|
||||
│ `apps/web/src/routes/*` │ `api/rpc.$.ts` · `api/auth.$.ts` · `api/health.ts`│
|
||||
│ │ `api/openapi.$.ts` · `api/uploads/$userId.$.ts`│
|
||||
│ │ `mcp/index.ts` · `[.]well-known/*` · `schema.json.ts`│
|
||||
└──────────┬───────────────┴──────────────────────────────┬─────────────────┘
|
||||
│ in-process router client │ HTTP handler
|
||||
▼ ▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ oRPC routers │
|
||||
│ `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts` │
|
||||
│ Procedures: `publicProcedure` / `protectedProcedure` │
|
||||
│ Middleware: `packages/api/src/middleware/rate-limit/index.ts` │
|
||||
└──────────┬─────────────────────────────────────────────────────────────────┘
|
||||
│
|
||||
▼
|
||||
┌────────────────────────────────────────────────────────────────────────────┐
|
||||
│ Services & helpers │
|
||||
│ `packages/api/src/services/{resume,ai,auth,storage,flags,statistics}.ts` │
|
||||
│ `packages/api/src/helpers/{resume-access,resume-access-policy}.ts` │
|
||||
│ `packages/api/src/services/resume-events.ts` (Postgres LISTEN/NOTIFY) │
|
||||
│ `packages/auth/src/config.ts` (Better Auth) │
|
||||
└──────────┬──────────────────────────────────────┬──────────────────────────┘
|
||||
│ Drizzle ORM │ S3 / local FS
|
||||
▼ ▼
|
||||
┌──────────────────────────────────┐ ┌──────────────────────────────────┐
|
||||
│ PostgreSQL (via `pg`) │ │ Storage (S3 or `<workspace>/data`)│
|
||||
│ `packages/db/src/client.ts` │ │ `packages/api/src/services/storage.ts`│
|
||||
│ schema: `packages/db/src/schema/*`│ │ served by `routes/uploads/$userId.$.tsx`│
|
||||
│ migrations: `migrations/` │ └──────────────────────────────────┘
|
||||
└──────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Component Responsibilities
|
||||
|
||||
| Component | Responsibility | File |
|
||||
|-----------|----------------|------|
|
||||
| Web app shell | TanStack Start app, route tree, SSR/CSR boundary, PWA, builder UI | `apps/web/src/router.tsx`, `apps/web/src/routes/__root.tsx` |
|
||||
| Server entry | Nitro fetch handler wrapping `react-start/server-entry` | `apps/web/src/server.ts` |
|
||||
| Migration plugin | Walks up to repo root, runs Drizzle migrations on boot | `apps/web/plugins/1.migrate.ts` |
|
||||
| Storage plugin | Validates `<workspace>/data` writability when S3 is unused | `apps/web/plugins/2.storage.ts` |
|
||||
| oRPC router root | Aggregates all sub-routers exposed at `/api/rpc` and OpenAPI | `packages/api/src/routers/index.ts` |
|
||||
| oRPC context | Header-based auth resolution, `publicProcedure`/`protectedProcedure` | `packages/api/src/context.ts` |
|
||||
| Resume service | CRUD, patch (RFC 6902), password, lock, statistics, analysis, events | `packages/api/src/services/resume.ts` |
|
||||
| Storage service | S3 + local FS abstraction, image processing via `sharp` | `packages/api/src/services/storage.ts` |
|
||||
| Resume access policy | Owner/viewer/redaction rules for `getBySlug` and statistics | `packages/api/src/helpers/resume-access-policy.ts` |
|
||||
| Resume events | Postgres `LISTEN/NOTIFY` channel for live updates | `packages/api/src/services/resume-events.ts` |
|
||||
| Auth | Better Auth config, OAuth provider, passkey, 2FA, API keys, JWKS | `packages/auth/src/config.ts` |
|
||||
| Database | Drizzle client singleton, schema, generated migrations | `packages/db/src/client.ts`, `packages/db/src/schema/*.ts`, `migrations/` |
|
||||
| Schema | Zod resume/page/template models shared across web + API + PDF + MCP | `packages/schema/src/resume/data.ts`, `packages/schema/src/templates.ts` |
|
||||
| PDF rendering | React PDF `Document`, font registration, 14 template implementations | `packages/pdf/src/document.tsx`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/hooks/use-register-fonts.ts` |
|
||||
| Shared UI | Base UI / shadcn-style component library and hooks | `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx` |
|
||||
| MCP server | Model Context Protocol server backed by oRPC routers | `apps/web/src/routes/mcp/index.ts`, `apps/web/src/routes/mcp/-helpers/*` |
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Modular monolith. One deployable web app + a constellation of source-only TypeScript packages communicating through typed `package.json` `exports`. Browser ↔ server communication uses **oRPC** (typed RPC with REST/OpenAPI generation) instead of REST/tRPC. SSR and client share the same router/queryClient via TanStack Start.
|
||||
|
||||
**Key Characteristics:**
|
||||
- Source-consumed workspace packages (no per-package `dist` build) keep types end-to-end.
|
||||
- The oRPC router is mounted twice: as a Fetch handler at `/api/rpc` and as an in-process `createRouterClient` for SSR/server functions, with identical types on both paths.
|
||||
- Browser-only code (React PDF preview, PDF.js, canvas) is isolated behind explicit `.browser.tsx` files and `ssr: false` / `ssr: "data-only"` route opts to keep SSR bundles small and safe.
|
||||
- Postgres is both the data store and the event bus (`pg_notify` channel `resume_updated` for live builder sync).
|
||||
- Shared concerns (auth, theme, locale, feature flags, oRPC client, query client) are loaded once and passed through TanStack Router `context` rather than fetched per-route.
|
||||
|
||||
## Layers
|
||||
|
||||
**Routes (`apps/web/src/routes/`):**
|
||||
- Purpose: File-based TanStack Router routes; some are pure UI, others embed `server.handlers` blocks that act as HTTP endpoints.
|
||||
- Location: `apps/web/src/routes/`
|
||||
- Contains: Page components, route loaders, server-only API handlers, layout wrappers.
|
||||
- Depends on: `packages/api/routers` (mounted at `/api/rpc`), `packages/auth/config` (mounted at `/api/auth`), `packages/db/client`, `packages/api/services/*`.
|
||||
- Used by: TanStack Router (route tree is regenerated into `apps/web/src/routeTree.gen.ts`).
|
||||
|
||||
**oRPC API layer (`packages/api/src/routers/`):**
|
||||
- Purpose: Public typed contract for the browser, in-process callers, and OpenAPI/MCP consumers.
|
||||
- Location: `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage}.ts`, aggregated in `packages/api/src/routers/index.ts`.
|
||||
- Contains: Procedure definitions, Zod input/output schemas, REST metadata, rate-limit middleware bindings.
|
||||
- Depends on: `packages/api/src/context.ts`, `packages/api/src/dto/*`, `packages/api/src/services/*`.
|
||||
- Used by: `apps/web/src/routes/api/rpc.$.ts` (RPCHandler), `apps/web/src/routes/api/openapi.$.ts` (OpenAPIHandler), `apps/web/src/libs/orpc/client.ts` (isomorphic client), MCP tools in `apps/web/src/routes/mcp/-helpers/tools.ts`.
|
||||
|
||||
**Services / business logic (`packages/api/src/services/`):**
|
||||
- Purpose: All cross-cutting business rules — resume CRUD, statistics, AI orchestration, storage, feature flags, auth helpers, event publishing.
|
||||
- Location: `packages/api/src/services/*.ts`
|
||||
- Contains: Side-effecting functions with explicit `userId`-style inputs. No request/response coupling.
|
||||
- Depends on: `packages/db/client`, `packages/db/schema`, `packages/auth/config`, `packages/schema/resume/*`, `packages/utils/*`, `packages/email/transport`, AI SDKs.
|
||||
- Used by: Routers, MCP helpers, the `/api/health` route.
|
||||
|
||||
**Persistence (`packages/db/`):**
|
||||
- Purpose: Drizzle ORM client + schema definitions; the only place SQL is written.
|
||||
- Location: `packages/db/src/client.ts`, `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
|
||||
- Contains: Postgres `Pool` singleton (stored on `globalThis.__pool` to survive HMR), `drizzle()` client, table definitions, relations.
|
||||
- Depends on: `pg`, `drizzle-orm`, `packages/env/server`.
|
||||
- Migrations: Generated by `drizzle-kit` into the repo-root `migrations/` directory (see `packages/db/drizzle.config.ts`) and applied at startup by `apps/web/plugins/1.migrate.ts`.
|
||||
|
||||
**Auth (`packages/auth/`):**
|
||||
- Purpose: Better Auth instance with email/password, OAuth (Google/GitHub/LinkedIn + generic), passkey, 2FA, API keys, JWKS, and a JWT-issuing OAuth provider for MCP clients.
|
||||
- Location: `packages/auth/src/config.ts`, `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
|
||||
- Mounted at: `apps/web/src/routes/api/auth.$.ts` (delegates `request → auth.handler(request)` after sanitizing OAuth params).
|
||||
- Verifies tokens for MCP at `apps/web/src/routes/mcp/index.ts` via `verifyOAuthToken`.
|
||||
|
||||
**PDF rendering (`packages/pdf/`):**
|
||||
- Purpose: React PDF document and 14 visual templates (named after Pokémon).
|
||||
- Location: `packages/pdf/src/document.tsx` mounts `getTemplatePage(template)`; templates live under `packages/pdf/src/templates/<name>/`.
|
||||
- Shared template primitives: `packages/pdf/src/templates/shared/{filtering,rich-text,sections,primitives,picture,page-size,columns}.ts(x)`.
|
||||
- Fonts: `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation.
|
||||
- Consumed by: Web builder preview (`apps/web/src/components/resume/preview*.tsx`, `pdf-canvas.tsx`), public resume page (`apps/web/src/routes/$username/$slug.tsx`), and the OpenAPI `/resumes/{id}/download` procedure (`apps/web/src/routes/api/-helpers/resume-pdf.ts`).
|
||||
|
||||
**Shared schemas (`packages/schema/`):**
|
||||
- Purpose: Zod source of truth for resume data, templates, page settings, and AI analysis.
|
||||
- Location: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
|
||||
- Used by: API DTOs (`packages/api/src/dto/resume.ts`), DB column typing (`packages/db/src/schema/resume.ts`), import package, PDF rendering, web forms, MCP tool descriptions, and the public JSON schema at `/schema.json`.
|
||||
|
||||
**Shared UI (`packages/ui/`):**
|
||||
- Purpose: Headless/styled component library (Base UI + shadcn-style) used by `apps/web`.
|
||||
- Location: `packages/ui/src/components/*.tsx`, `packages/ui/src/hooks/*.tsx`.
|
||||
- Examples: `dialog`, `dropdown-menu`, `command`, `resizable`, `sonner`, `tooltip`, `form`, `direction`.
|
||||
- Styles: `packages/ui/src/styles/globals.css` (Tailwind v4 entry).
|
||||
|
||||
**Support packages:**
|
||||
- `packages/utils` — small focused helpers (`color`, `date`, `field`, `file`, `html`, `level`, `locale`, `network-icons`, `rate-limit`, `sanitize`, `string`, `style`, `url`, plus Node-only `monorepo.node`, `url-security.node`, and `resume/{docx,patch}`).
|
||||
- `packages/env` — `@t3-oss/env-core` server schema; `dotenv` loads the repo-root `.env` (`packages/env/src/server.ts`).
|
||||
- `packages/email` — `nodemailer` transport + `react-email` templates (`packages/email/src/transport.ts`, `packages/email/src/templates/*.tsx`).
|
||||
- `packages/import` — converters for JSON Resume, Reactive Resume v3/v4 JSON (`packages/import/src/*.tsx`).
|
||||
- `packages/ai` — Zustand store, AI prompts (`packages/ai/src/prompts/*.md`), patch-resume tool, sanitize/extraction helpers consumed by the AI router.
|
||||
- `packages/fonts` — generated Google Fonts metadata (`packages/fonts/src/webfontlist.json`, `packages/fonts/src/index.ts`).
|
||||
- `packages/scripts` — repo-level scripts (`packages/scripts/database/reset.ts`, `packages/scripts/fonts/generate.ts`).
|
||||
- `packages/config` — shared TypeScript/Vitest base configs (`packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`).
|
||||
- `packages/runtime-externals` — declares `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they remain runtime-only (externalized in `apps/web/vite.config.ts`).
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Primary Request Path (browser RPC call)
|
||||
|
||||
1. Browser route uses `orpc.resume.getById.queryOptions(...)` from `apps/web/src/libs/orpc/client.ts:84` to fetch data.
|
||||
2. The isomorphic oRPC client (`apps/web/src/libs/orpc/client.ts:28-47`) creates an `RPCLink` pointing at `${window.location.origin}/api/rpc` with `credentials: "include"` and a `BatchLinkPlugin`.
|
||||
3. Request hits the file route `apps/web/src/routes/api/rpc.$.ts`, where `RPCHandler` (line 9) dispatches with `BatchHandlerPlugin`, `RequestHeadersPlugin`, and `StrictGetMethodPlugin`.
|
||||
4. `publicProcedure` (`packages/api/src/context.ts:79`) resolves the user from headers (`x-api-key` → bearer JWT via JWKS → Better Auth session cookie).
|
||||
5. `protectedProcedure` (`packages/api/src/context.ts:90`) rejects unauthenticated callers with `ORPCError("UNAUTHORIZED")`.
|
||||
6. Router handler in `packages/api/src/routers/resume.ts` calls into `packages/api/src/services/resume.ts`, which queries Drizzle (`packages/db/src/client.ts:32`).
|
||||
7. Response is serialized back through oRPC and consumed by TanStack Query / the route component.
|
||||
|
||||
### SSR / Server-Side Path
|
||||
|
||||
1. During SSR, `apps/web/src/libs/orpc/client.ts:13-27` short-circuits the HTTP path via `createRouterClient(router, { context: async () => ({ locale, reqHeaders }) })`.
|
||||
2. The same `publicProcedure`/`protectedProcedure` middleware runs in-process — no socket hop — but still resolves auth from the original request headers via `getRequestHeaders()` from `@tanstack/react-start/server`.
|
||||
3. Route loaders (e.g. `apps/web/src/routes/builder/$resumeId/route.tsx:39-44`) populate the query cache via `context.queryClient.ensureQueryData(orpc.resume.getById.queryOptions(...))`.
|
||||
|
||||
### Resume Live-Update Flow
|
||||
|
||||
1. `subscribe` procedure in `packages/api/src/routers/resume.ts:76` returns an async generator.
|
||||
2. On any mutation, `packages/api/src/services/resume-events.ts:37` calls `pg_notify('resume_updated', JSON.stringify(event))`.
|
||||
3. Subscribing clients receive `resume.updated` SSE-style events via oRPC streaming (`apps/web/src/libs/orpc/client.ts:51-82`, `streamClient`).
|
||||
4. The builder route consumes them through `useResumeUpdateSubscription` (`apps/web/src/components/resume/builder-resume-draft.ts`).
|
||||
|
||||
### PDF Download Path
|
||||
|
||||
1. Web client requests `GET /api/openapi/resumes/{id}/download` (oRPC OpenAPI handler at `apps/web/src/routes/api/openapi.$.ts`).
|
||||
2. `downloadResumePdfProcedure` in `apps/web/src/routes/api/-helpers/resume-pdf.ts` loads the resume, renders `ResumeDocument` from `packages/pdf/src/document.tsx`, persists to storage via `getStorageService()`, and returns/streams the PDF.
|
||||
|
||||
### Public Resume Path
|
||||
|
||||
1. `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"` — server fetches `resume.getBySlug` but renders the React PDF preview only on the client.
|
||||
2. `packages/api/src/helpers/resume-access-policy.ts` enforces visibility, redacts non-public fields, and throws `NEED_PASSWORD` for password-protected resumes (the route then redirects to `/auth/resume-password`).
|
||||
|
||||
**State Management:**
|
||||
- Server cache: TanStack Query (`apps/web/src/libs/query/client.ts`) wrapped by oRPC's `createTanstackQueryUtils`.
|
||||
- Local UI state: Zustand stores under `apps/web/src/routes/builder/$resumeId/-store/{section,sidebar}.ts`, `apps/web/src/dialogs/store.ts`, `apps/web/src/components/command-palette/store.ts`, `apps/web/src/components/resume/builder-resume-draft.ts`.
|
||||
- Router context: `theme`, `locale`, `session`, `flags`, `queryClient`, `orpc` are computed once in `apps/web/src/router.tsx` (and again in the root route `beforeLoad`) and reused by descendants.
|
||||
- Cookies: builder layout (`BUILDER_LAYOUT_COOKIE_NAME`), theme, and locale are persisted via `getCookie`/`setCookie` server functions.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**oRPC procedures (`os.$context<ORPCContext>()`):**
|
||||
- Purpose: Typed RPC procedures that double as REST endpoints and MCP tool surfaces.
|
||||
- Examples: `publicProcedure` and `protectedProcedure` in `packages/api/src/context.ts:79`/`:90`.
|
||||
- Pattern: `procedure.route({...openapi metadata}).input(zodSchema).use(rateLimitMiddleware).output(zodSchema).handler(async ({ context, input }) => ...)`.
|
||||
|
||||
**Drizzle tables:**
|
||||
- Purpose: Strongly-typed Postgres schema; `.jsonb()` columns are typed against Zod-derived TypeScript (`ResumeData`, `StoredResumeAnalysis`).
|
||||
- Examples: `packages/db/src/schema/resume.ts` (resume, resumeStatistics, resumeAnalysis), `packages/db/src/schema/auth.ts` (Better Auth tables).
|
||||
- Pattern: `pg.pgTable("name", { ... }, (t) => [pg.index().on(...), pg.unique().on(...)])`.
|
||||
|
||||
**Template pages (React PDF):**
|
||||
- Purpose: One `TemplatePage` component per visual template, mapped by name in `packages/pdf/src/templates/index.ts`.
|
||||
- Examples: `packages/pdf/src/templates/azurill/AzurillPage.tsx`, `packages/pdf/src/templates/onyx/OnyxPage.tsx`.
|
||||
- Pattern: `(props: { page: LayoutPage; pageIndex: number }) => JSX`, consuming `RenderProvider` from `packages/pdf/src/context.tsx`.
|
||||
|
||||
**Base UI components:**
|
||||
- Purpose: Headless primitives styled with Tailwind v4 and exported as composable parts.
|
||||
- Examples: `packages/ui/src/components/dialog.tsx`, `packages/ui/src/components/command.tsx`, `packages/ui/src/components/resizable.tsx`.
|
||||
- Imported via deep paths: `import { Dialog } from "@reactive-resume/ui/components/dialog";`.
|
||||
|
||||
**TanStack Router file routes:**
|
||||
- Purpose: Page components, loaders, and optional `server.handlers` blocks per file.
|
||||
- Examples: `apps/web/src/routes/builder/$resumeId/route.tsx`, `apps/web/src/routes/api/rpc.$.ts`.
|
||||
- Pattern: `export const Route = createFileRoute("/path")({ component, loader, beforeLoad, server: { handlers: { GET, POST } }, ssr });`.
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Vite + Nitro build entry:**
|
||||
- Location: `apps/web/vite.config.ts`
|
||||
- Triggers: `pnpm dev`, `pnpm build`. Wires TanStack Start, Tailwind v4, Lingui (i18n), Nitro plugins, and the Vite PWA plugin.
|
||||
- Externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (declared in `packages/runtime-externals`).
|
||||
|
||||
**Server fetch entry:**
|
||||
- Location: `apps/web/src/server.ts`
|
||||
- Responsibilities: Wraps `@tanstack/react-start/server-entry` and substitutes `srvx`'s `FastResponse` as the global `Response`.
|
||||
|
||||
**Nitro startup plugins:**
|
||||
- `apps/web/plugins/1.migrate.ts` — resolves the repo-root `migrations/` folder, opens its own `pg.Pool`, runs Drizzle migrations on boot, then closes the pool.
|
||||
- `apps/web/plugins/2.storage.ts` — when S3 env vars are absent, ensures the local storage directory is writable before serving requests.
|
||||
|
||||
**Router entry:**
|
||||
- Location: `apps/web/src/router.tsx`
|
||||
- Responsibilities: Builds `queryClient`, loads `theme`/`locale`/`session`/`flags` in parallel, creates the TanStack Router with router context, registers SSR query integration.
|
||||
|
||||
**Root route:**
|
||||
- Location: `apps/web/src/routes/__root.tsx`
|
||||
- Responsibilities: HTML shell, providers (`I18nProvider`, `ThemeProvider`, `HotkeysProvider`, `DirectionProvider`, `TooltipProvider`, `ConfirmDialogProvider`, `PromptDialogProvider`), PWA head/scripts, `DialogManager`, `CommandPalette`, `Toaster`.
|
||||
|
||||
**HTTP endpoints (route `server.handlers`):**
|
||||
- `apps/web/src/routes/api/rpc.$.ts` — `/api/rpc/*` oRPC handler (browser RPC).
|
||||
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` Better Auth handler (with OAuth payload sanitization).
|
||||
- `apps/web/src/routes/api/health.ts` — `/api/health` JSON probe (db + storage with timeouts).
|
||||
- `apps/web/src/routes/api/openapi.$.ts` — `/api/openapi/*` OpenAPI handler + spec.
|
||||
- `apps/web/src/routes/api/uploads/$userId.$.ts` and `apps/web/src/routes/uploads/$userId.$.tsx` — signed/etagged static file serving from storage.
|
||||
- `apps/web/src/routes/mcp/index.ts` — `/mcp` Model Context Protocol server (OAuth-protected).
|
||||
- `apps/web/src/routes/[.]well-known/*` — `/.well-known/oauth-authorization-server`, `/.well-known/oauth-protected-resource`, `/.well-known/openid-configuration`, `/.well-known/mcp` discovery documents.
|
||||
- `apps/web/src/routes/schema[.]json.ts` — `/schema.json` public JSON Schema for `ResumeData`.
|
||||
|
||||
**Builder + public resume entry points:**
|
||||
- `apps/web/src/routes/builder/$resumeId/route.tsx` — authenticated builder shell (header + resizable left/right sidebars + artboard outlet + assistant).
|
||||
- `apps/web/src/routes/builder/$resumeId/index.tsx` — `ssr: false`; lazy-loaded `PreviewPage` running React PDF/canvas only in the browser.
|
||||
- `apps/web/src/routes/$username/$slug.tsx` — `ssr: "data-only"`; public/shared resume view (with password gating via `NEED_PASSWORD` ORPCError).
|
||||
|
||||
## Architectural Constraints
|
||||
|
||||
- **Single Node process:** Everything (web, oRPC, auth, MCP, OpenAPI, file serving) runs in one Node 24 process on port 3000. No separate API service.
|
||||
- **Source-only workspace packages:** `package.json` `exports` point at `src/*.ts(x)`; do not assume `dist` output exists. The Vite build externalizes `bcrypt`, `sharp`, `@aws-sdk/client-s3` (`apps/web/vite.config.ts:55`).
|
||||
- **Globals on `globalThis` for DB pool:** `packages/db/src/client.ts:8-11` caches the `pg.Pool` and Drizzle client on `globalThis.__pool` / `globalThis.__drizzle` to survive HMR reloads.
|
||||
- **Browser-only PDF rendering:** PDF.js, `@react-pdf/renderer` canvas, and the builder preview must stay off the SSR path. Use `.browser.tsx` suffix files and `ssr: false` (builder preview) or `ssr: "data-only"` (public resume).
|
||||
- **Generated route tree:** `apps/web/src/routeTree.gen.ts` is regenerated by TanStack Router tooling; never edit by hand.
|
||||
- **Migrations folder location:** `drizzle-kit` writes to `../../migrations` from `packages/db/drizzle.config.ts`, so all migration directories live at the repo root, not inside the package.
|
||||
- **DATABASE_URL not auto-loaded for drizzle-kit:** `pnpm db:migrate` / `pnpm db:generate` require `DATABASE_URL` exported in the shell; only the runtime Node code loads `.env` via `packages/env/src/server.ts`.
|
||||
- **Rate limiting is production-only:** `packages/api/src/middleware/rate-limit/index.ts:5` and the Better Auth config gate rate limits on `process.env.NODE_ENV === "production"`.
|
||||
- **OAuth audience binding:** `verifyOAuthToken` (`packages/auth/src/config.ts:40`) only accepts JWTs whose `aud` matches `${APP_URL}` (with/without trailing slash) or `${APP_URL}/mcp`.
|
||||
- **Postgres LISTEN/NOTIFY coupling:** Live resume updates depend on a single shared `pg.Pool` (`packages/db/src/client.ts:13`); scaling beyond one process requires replacing `pg_notify` with a broker.
|
||||
|
||||
## Anti-Patterns
|
||||
|
||||
### Ad-hoc fetching of router context
|
||||
|
||||
**What happens:** Components separately calling `getSession()`, `getTheme()`, or `getLocale()` instead of reading them from TanStack Router context.
|
||||
**Why it's wrong:** The root route's `beforeLoad` already loads these in parallel (`apps/web/src/routes/__root.tsx:82-93`) and exposes them via `Route.useRouteContext()`; duplicating the calls causes extra round-trips during SSR and triggers locale reloads.
|
||||
**Do this instead:** Use `Route.useRouteContext()` or read from a parent route loader, as `apps/web/src/routes/__root.tsx:101` does for theme/locale.
|
||||
|
||||
### Direct DB or storage imports in client code
|
||||
|
||||
**What happens:** Importing `@reactive-resume/db/client` or `@reactive-resume/api/services/storage` from a non-route component.
|
||||
**Why it's wrong:** Pulls `pg`, `sharp`, `bcrypt`, `@aws-sdk/client-s3` into the client bundle (which Vite externalizes — the build will fail or break at runtime).
|
||||
**Do this instead:** Call the corresponding oRPC procedure from `packages/api/src/routers/*`. Server-only imports belong inside route `server.handlers` blocks or `.server.tsx` files like `apps/web/src/libs/resume/pdf-document.server.tsx`.
|
||||
|
||||
### PDF/canvas code in shared modules
|
||||
|
||||
**What happens:** Importing `@react-pdf/renderer` or PDF.js from a file that participates in SSR.
|
||||
**Why it's wrong:** These libraries crash under Node SSR (canvas/DOM dependencies).
|
||||
**Do this instead:** Keep browser code in `*.browser.tsx` / `pdf-canvas.tsx` and gate with `ssr: false` (e.g. `apps/web/src/routes/builder/$resumeId/index.tsx:6`) or `ssr: "data-only"` (e.g. `apps/web/src/routes/$username/$slug.tsx:40`).
|
||||
|
||||
### Bypassing the resume access policy
|
||||
|
||||
**What happens:** Reading resume rows directly from Drizzle in a public procedure without applying redaction.
|
||||
**Why it's wrong:** Leaks owner-only fields (password hash, private flags, statistics) and breaks the password-gate flow that depends on `NEED_PASSWORD` errors.
|
||||
**Do this instead:** Route through `packages/api/src/helpers/resume-access-policy.ts` (`assertCanView`, `redactResumeForViewer`, `shouldCountForStatistics`) as `packages/api/src/services/resume.ts` does.
|
||||
|
||||
### Hand-editing generated files
|
||||
|
||||
**What happens:** Modifying `apps/web/src/routeTree.gen.ts` or migration SQL after Drizzle writes it.
|
||||
**Why it's wrong:** Edits are overwritten on the next `tanstack-router` regen or migration; data drift between snapshots and SQL breaks future migrations.
|
||||
**Do this instead:** Add a route file under `apps/web/src/routes/`, or change the Drizzle schema in `packages/db/src/schema/*.ts` and run `pnpm db:generate`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Typed errors via `ORPCError` codes; HTTP responses for low-level handlers; route-level `defaultErrorComponent` and `onError` for UI fallbacks.
|
||||
|
||||
**Patterns:**
|
||||
- Procedures throw `ORPCError("UNAUTHORIZED"|"NOT_FOUND"|"NEED_PASSWORD"|...)` or use `.errors({ ... })` to declare typed application errors (e.g. `RESUME_SLUG_ALREADY_EXISTS`, `RESUME_VERSION_CONFLICT` in `packages/api/src/routers/resume.ts`).
|
||||
- The OAuth bearer / API key / session resolvers in `packages/api/src/context.ts:14-55` swallow verification errors and log via `console.warn` so unauthenticated requests fall through to the next strategy.
|
||||
- `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts` install `onError` interceptors that log every server error with a tag (`[oRPC Server]`, `[OpenAPI]`).
|
||||
- Route-level `onError` (e.g. `apps/web/src/routes/$username/$slug.tsx:24`) translates `NEED_PASSWORD` into a redirect.
|
||||
- Top-level UI fallbacks: `apps/web/src/components/layout/error-screen.tsx`, `loading-screen.tsx`, `not-found-screen.tsx` (wired in `apps/web/src/router.tsx:30-32`).
|
||||
- Healthcheck uses a 1.5s timeout helper (`apps/web/src/routes/api/health.ts:22-34`) so a stuck dependency cannot stall the probe.
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:** `console.info`/`console.warn`/`console.error` with bracketed prefixes (e.g. `[oRPC Server]`, `[Healthcheck]`, `[oRPC client]`). No external log shipping is wired.
|
||||
|
||||
**Validation:** Zod 4 everywhere — Drizzle column types (`packages/db/src/schema/resume.ts`), oRPC input/output schemas, AI tool inputs, environment variables (`packages/env/src/server.ts`), and the public `/schema.json` route.
|
||||
|
||||
**Authentication:** Better Auth in `packages/auth/src/config.ts` (Drizzle adapter, email/password + OAuth + passkey + 2FA + admin + API keys + JWT + generic OAuth + dynamic client registration + custom `oauthProvider` for MCP). The unified resolver in `packages/api/src/context.ts:64` is the only place that decides which credential wins.
|
||||
|
||||
**Internationalization:** Lingui — `apps/web/lingui.config.ts`, `apps/web/locales/*.po`, `apps/web/src/libs/locale.ts`, RTL toggling in `__root.tsx`.
|
||||
|
||||
**Theming:** `apps/web/src/libs/theme.ts` (cookie-backed), `apps/web/src/components/theme/provider.tsx` (`next-themes`).
|
||||
|
||||
**Rate limiting:** `@orpc/experimental-ratelimit` with an in-memory ratelimiter from `packages/api/src/middleware/rate-limit/index.ts`. Trusted IP headers come from `packages/utils/src/rate-limit.ts`.
|
||||
|
||||
**Feature flags:** Server-resolved at boot (`packages/api/src/routers/flags.ts` and `packages/api/src/services/flags.ts`), then carried in router context via `apps/web/src/router.tsx:20`.
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-05-11*
|
||||
@@ -1,393 +0,0 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## TODO / FIXME / HACK / XXX Comments
|
||||
|
||||
A repo-wide grep for `TODO`, `FIXME`, `HACK`, and `XXX` markers across `apps/web/src/**` and `packages/**` returned **zero hits** in source code. The team appears to track follow-ups in PRs/issues rather than inline. Two referenced issues remain anchored in inline comments:
|
||||
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts:22` — references issue `#2986` (CJK glyph-level font fallback).
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts:103` — references issue `#2986` again for CJK textkit substitution.
|
||||
|
||||
These are stable design references, not unresolved debt — included for traceability only.
|
||||
|
||||
The remaining inline-comment "concerns" found during exploration sit in the **Anti-debt narrative** below, derived from code shape rather than comment markers.
|
||||
|
||||
---
|
||||
|
||||
## High Severity
|
||||
|
||||
### Security: SMTP-disabled fallback logs full email bodies (incl. verification/reset links)
|
||||
|
||||
When `SMTP_HOST` / `SMTP_USER` / `SMTP_PASS` / `SMTP_FROM` are not all set, the email transport logs the entire payload — including `text` and `html` bodies — to `stdout`.
|
||||
|
||||
- File: `packages/email/src/transport.ts:59-66`
|
||||
|
||||
```ts
|
||||
console.info("SMTP not configured; skipping email send.", {
|
||||
to: payload.to,
|
||||
subject: payload.subject,
|
||||
text: payload.text,
|
||||
html: payload.html,
|
||||
});
|
||||
```
|
||||
|
||||
**Impact:**
|
||||
- Password reset, email verification, and email-change confirmation URLs (which are credential-equivalent bearer tokens) are written to server logs whenever SMTP is not fully configured.
|
||||
- In any shared-log / log-shipping environment (Docker, Kubernetes, journald, cloud logging) this is a credential leak.
|
||||
- The README and `AGENTS.md:101` describe this as a dev convenience; nothing prevents an operator from running production with partial SMTP config.
|
||||
|
||||
**Fix approach:**
|
||||
1. Add a server-startup assertion that, if `NODE_ENV === "production"`, `isSmtpEnabled()` must be true.
|
||||
2. Redact `text` / `html` from the `console.info` call — log only `to` and `subject`.
|
||||
3. Document the production SMTP requirement in `.env.example` alongside `AUTH_SECRET`.
|
||||
|
||||
### Security: rate limiting is silently disabled in non-production
|
||||
|
||||
Both the oRPC rate-limit middleware and Better Auth's rate-limit config gate on `process.env.NODE_ENV === "production"`. Anything that does not set `NODE_ENV=production` at runtime (default `node` invocation, custom Docker entrypoints that forget to set it, self-hosters running `pnpm start` without `NODE_ENV`) runs with **all rate limiting disabled**, including:
|
||||
- `/sign-in/email`, `/sign-up/email`, password-reset, OAuth `register`/`authorize`/`token` (`packages/auth/src/config.ts:30, :69-78, :249-251, :388-390`)
|
||||
- Resume password verification, AI calls, PDF export, storage uploads/deletes, resume mutations (`packages/api/src/middleware/rate-limit/index.ts:5, :75`)
|
||||
|
||||
**Impact:** brute-force-friendly. A self-hosted deployment that omits `NODE_ENV=production` is wide open on auth and resume-password endpoints.
|
||||
|
||||
**Fix approach:**
|
||||
- Either default `isRateLimitEnabled = true` and provide a `RATE_LIMIT_DISABLED` opt-out env var, or assert `NODE_ENV === "production"` at boot when not running tests.
|
||||
|
||||
### Security: rate limiter is in-process memory only — broken under horizontal scaling
|
||||
|
||||
`MemoryRatelimiter` is used for every oRPC rate limit (`packages/api/src/middleware/rate-limit/index.ts:59-66`). Better Auth's `rateLimit` and `apiKey.rateLimit` blocks (`packages/auth/src/config.ts:248-251, :387-391`) similarly do not configure a distributed store.
|
||||
|
||||
**Impact:** Running >1 Node instance behind a load balancer multiplies the effective rate limit by the instance count. Brute-force attacks bypass the limit by retrying until they hit a different replica.
|
||||
|
||||
**Fix approach:** Swap `MemoryRatelimiter` for a Redis-backed ratelimiter (the package supports it via `@orpc/experimental-ratelimit`) once a redis dependency is acceptable. Until then, document the single-instance constraint in the deployment docs.
|
||||
|
||||
### Security: file upload accepts arbitrary MIME with image processing disabled
|
||||
|
||||
`uploadFile` (`packages/api/src/routers/storage.ts:42-71`) only runs the sharp image-processing pipeline for files where `isImageFile(file.type)` returns true based on the **client-supplied `file.type`**, and even that pipeline is skipped if `FLAG_DISABLE_IMAGE_PROCESSING=true` (`packages/api/src/services/storage.ts:96-101`).
|
||||
|
||||
**Impact:**
|
||||
- A client can claim `content-type: text/html` (or any non-image MIME) on a 10 MB upload, and the file is stored verbatim into `uploads/{userId}/pictures/...` with the user-claimed content type.
|
||||
- Served back from `apps/web/src/routes/uploads/$userId.$.tsx`. `X-Content-Type-Options: nosniff` is set (`:159`) and the storage route forces `application/octet-stream` only for `.pdf` (`:39, :148-153`), so a stored HTML body could be served as HTML if the inferred extension matches. The picture key always ends in `.jpeg` (`storage.ts:60`), which mitigates this in the picture path, but there is no MIME allow-list enforced at upload time.
|
||||
- With `FLAG_DISABLE_IMAGE_PROCESSING` on, the same applies to genuine images (no resize/strip-metadata path), so EXIF data is preserved unredacted.
|
||||
|
||||
**Fix approach:**
|
||||
1. Add a strict allow-list at the router boundary (`packages/api/src/routers/storage.ts:9`) — `z.file().mime(["image/png", "image/jpeg", "image/webp", "image/gif"])` plus magic-byte validation.
|
||||
2. Reject upload if claimed MIME does not match the sharp-detected MIME; do not fall back to client claim.
|
||||
3. Surface a startup warning when `FLAG_DISABLE_IMAGE_PROCESSING=true` so it is not enabled in production unknowingly.
|
||||
|
||||
### Tech Debt: web app has near-zero test coverage
|
||||
|
||||
- `apps/web/src` has **11 test files** across **~224 source files** (~5%) — `find apps/web/src -name "*.test.*"`.
|
||||
- `apps/web/src/routes` has **1 test file** across **125 route files** (`apps/web/src/routes/builder/$resumeId/-components/donation-toast.test.tsx`).
|
||||
- Total repo: ~93 test files / ~385 source files (~24%). Most coverage lives in `packages/ui`, `packages/utils`, `packages/pdf/src/templates/shared`, and `packages/api` (5 tests).
|
||||
|
||||
**Impact:**
|
||||
- Refactors to routes, builder shell, sidebar forms, resume preview wiring, MCP tools, and uploads handler land without a regression net.
|
||||
- Particularly thin: `apps/web/src/routes/api/**` (rpc, auth, openapi, mcp, uploads) and `apps/web/src/routes/builder/$resumeId/**`.
|
||||
|
||||
**Fix approach:** Phase-by-phase, add server-handler integration tests for `api/health.ts`, `api/auth.$.ts` (registration-validation path), `uploads/$userId.$.tsx` (path traversal), and at least smoke tests around the builder store in `apps/web/src/components/resume/builder-resume-draft.ts`.
|
||||
|
||||
---
|
||||
|
||||
## Medium Severity
|
||||
|
||||
### Fragile: PDF.js / canvas SSR boundary is enforced by convention only
|
||||
|
||||
The SSR/CSR split for the resume preview is hand-maintained:
|
||||
|
||||
- `apps/web/src/components/resume/preview.tsx:14` returns `null` until `useIsClient()` resolves and then lazy-loads the browser bundle.
|
||||
- `apps/web/src/components/resume/preview.browser.tsx:2` imports `@react-pdf/renderer` (`pdf`).
|
||||
- `apps/web/src/components/resume/pdf-canvas.tsx:1, :4, :9` imports PDF.js types/runtime and sets a module-level `GlobalWorkerOptions.workerSrc`.
|
||||
- `apps/web/src/routes/templates/$.tsx:1` imports `PDFViewer` at the **top level** but the route component itself bails on `!isClient`. Top-level import means the bundle reaches the SSR chunk; correctness relies on the import being tree-shaken away when SSR runs.
|
||||
- `apps/web/src/routes/dashboard/resumes/-components/cards/resume-thumbnail.tsx` uses dynamic `await import("pdfjs-dist")` — a different convention from `pdf-canvas.tsx`'s static import.
|
||||
- SSR mode hints: `apps/web/src/routes/builder/$resumeId/index.tsx:4` (`ssr: false`), `apps/web/src/routes/$username/$slug.tsx` (`ssr: "data-only"`).
|
||||
|
||||
**Impact:** A future contributor adding a static PDF.js import inside a SSR-rendered route component will break SSR with a `window is not defined` error at build/run time. There is no lint rule or build-time guard enforcing this.
|
||||
|
||||
**Fix approach:**
|
||||
1. Document the boundary explicitly at the top of `apps/web/src/components/resume/preview.tsx` and `pdf-canvas.tsx` (currently only described in `AGENTS.md:35`).
|
||||
2. Consider a Vite SSR-externals config that aborts the build if `pdfjs-dist` / `@react-pdf/renderer` is reachable from an SSR-eligible route.
|
||||
3. Switch `apps/web/src/routes/templates/$.tsx:1` to a dynamic `import("@react-pdf/renderer")` inside the `useIsClient()` branch for consistency with the rest of the preview pipeline.
|
||||
|
||||
### Fragile: `getStorageService()` is captured at module load in router
|
||||
|
||||
`packages/api/src/routers/storage.ts:7` calls `getStorageService()` at module top level. Because `apps/web/src/routes/api/rpc.$.ts` constructs a new `RPCHandler` on every request, the router's storage reference is fixed for the lifetime of the Node process.
|
||||
|
||||
**Impact:** Switching backends (e.g. flipping S3 vars on at runtime) requires a process restart, which is normally fine — but the singleton is also held inside Nitro's HMR boundary in dev, so config changes during `pnpm dev` need a full restart (not just a save).
|
||||
|
||||
**Fix approach:** Call `getStorageService()` inside each handler instead, or invalidate the cached service when env values change.
|
||||
|
||||
### Fragile: `RPCHandler` instantiated per-request
|
||||
|
||||
`apps/web/src/routes/api/rpc.$.ts:8-16` creates a new `RPCHandler` (with plugins) for every incoming request. Each instance re-walks the router tree and re-constructs plugin pipelines.
|
||||
|
||||
**Impact:** Measurable per-request cost on high-RPS dashboards; not catastrophic but unnecessary.
|
||||
|
||||
**Fix approach:** Move `new RPCHandler(...)` out of the handler and reuse a module-level instance; only `getLocale()` should run per-request.
|
||||
|
||||
### Performance: PDF preview regenerates entire PDF on every change
|
||||
|
||||
`apps/web/src/components/resume/preview.browser.tsx:103-131` debounces PDF generation by 100ms and calls `pdf(resumeDocument).toBlob()` on every resume change. For multi-page resumes with images, this re-renders the full document and re-loads it into PDF.js.
|
||||
|
||||
**Impact:** Builder feels sluggish on slower hardware when typing into fields; CPU spikes on each keystroke after debounce.
|
||||
|
||||
**Mitigation in place:** `UPDATE_DEBOUNCE_MS = 100`, crossfade between staged/active layers (`:20-80`).
|
||||
|
||||
**Fix approach:** Long-term, switch to incremental rendering or page-level memoization keyed by section hash. Short-term, raise debounce to 200–300 ms while typing.
|
||||
|
||||
### Performance: font registration cost
|
||||
|
||||
`packages/pdf/src/hooks/use-register-fonts.ts:18, :86` keeps a module-level `registeredFontVariants` Set keyed by `family:weight:style`. Per-resume registration calls `Font.register` once per (family × weight × italic × CJK-fallback) combination, with web-font fetches resolved through `getWebFontSource`. This Set never expires — if many resumes with different typography are previewed in one session, the registered-font count grows for the page lifetime.
|
||||
|
||||
**Impact:** Memory grows in the builder for sessions that switch typography frequently. Not a leak in the GC sense, but bounded only by the size of the registered-font universe.
|
||||
|
||||
**Fix approach:** Acceptable for current usage. Document the cap and reconsider if typography switching becomes more common.
|
||||
|
||||
### Performance: large source files hint at oversized modules
|
||||
|
||||
Top offenders by line count (excluding generated/test fixtures):
|
||||
|
||||
| Lines | File |
|
||||
|------:|------|
|
||||
| 1535 | `packages/schema/src/icons.ts` (static data) |
|
||||
| 1088 | `apps/web/src/routes/builder/$resumeId/-components/assistant.tsx` |
|
||||
| 900 | `packages/pdf/src/templates/shared/sections.tsx` |
|
||||
| 789 | `apps/web/src/components/input/rich-input.tsx` |
|
||||
| 785 | `packages/import/src/reactive-resume-v4-json.tsx` |
|
||||
| 685 | `packages/ui/src/components/sidebar.tsx` |
|
||||
| 556 | `apps/web/src/routes/builder/$resumeId/-sidebar/left/sections/picture.tsx` |
|
||||
| 543 | `packages/api/src/services/resume.ts` |
|
||||
|
||||
`assistant.tsx` (1088 lines) and `sections.tsx` (900 lines) are particularly likely to accumulate further complexity without splitting. `rich-input.tsx` (789 lines) is the rich-text editor — likely justifies its size but has zero direct tests.
|
||||
|
||||
**Fix approach:** Split `assistant.tsx` along tool boundaries; extract per-section renderers from `sections.tsx` if any individual section grows further.
|
||||
|
||||
### Security: `verifyPassword` rate limit keyed by `username:slug:ip`
|
||||
|
||||
`packages/api/src/middleware/rate-limit/index.ts:77-83` keys the resume-password limiter on `resume-password:{username}:{slug}:{clientKey}` where `clientKey` is the client IP. The window/max is 5 attempts per 10 minutes (`packages/utils/src/rate-limit.ts:43`). This is reasonable, but the global Better Auth global rule for `/two-factor/verify-otp` is also 5 per 600s. An attacker on a botnet (different IPs) is not blocked at the resource level — each IP gets its own 5/10min budget against the same resume.
|
||||
|
||||
**Impact:** Limited but present brute-force surface on password-protected public resumes.
|
||||
|
||||
**Fix approach:** Add a per-resume global cap on top of the per-IP limit (e.g. 50/hour per `username:slug` regardless of IP).
|
||||
|
||||
### Security: `as string` cast on password hash
|
||||
|
||||
`packages/api/src/services/resume.ts:487` casts `resume.password` to `string` after a `isNotNull(schema.resume.password)` WHERE clause. The cast is correct in context, but if a future refactor drops the `isNotNull` guard the cast silently allows `null` through `bcrypt.compare`.
|
||||
|
||||
**Fix approach:** Replace `as string` with a runtime `if (!resume.password) throw new ORPCError(...)` check.
|
||||
|
||||
### Security: trust of `TRUSTED_IP_HEADERS` is unconditional
|
||||
|
||||
`packages/utils/src/rate-limit.ts:1-7` defines a list of trusted IP headers (`CF-Connecting-IP`, `True-Client-IP`, `X-Forwarded-For`, etc.) that the rate limiter and Better Auth (`packages/auth/src/config.ts:276`) honour from any caller.
|
||||
|
||||
**Impact:** If the app is deployed without a proxy that strips client-supplied versions of these headers, any client can spoof their rate-limit identity by setting `X-Forwarded-For: 1.2.3.4`.
|
||||
|
||||
**Fix approach:** Document that operators **must** terminate at a trusted proxy (Cloudflare, nginx, Caddy) that strips inbound `X-Forwarded-For` / `X-Real-IP`. Optionally, add a `TRUST_PROXY` env flag and only honour those headers when set.
|
||||
|
||||
### Fragile: `cachedTransport` in `email/transport.ts` ignores env mutation
|
||||
|
||||
`packages/email/src/transport.ts:21-37` caches the nodemailer transport on first use. If SMTP creds change at runtime (e.g. credential rotation in a deployed instance), the cached transport keeps using the stale credentials until the process restarts.
|
||||
|
||||
**Fix approach:** Detect cred changes and rebuild, or document the restart-on-rotation behaviour.
|
||||
|
||||
### Storage gotcha: statistics cache is filesystem-bound even with S3 configured
|
||||
|
||||
`packages/api/src/services/statistics.ts:21-52` caches user/resume/star counts as files in `getLocalDataDirectory(env.LOCAL_STORAGE_PATH)` regardless of whether S3 is enabled.
|
||||
|
||||
**Impact:** When S3 is configured and `LOCAL_STORAGE_PATH` is on ephemeral storage (e.g. container scratch), the cache is recreated on every redeploy — meaning a cold start always queries the DB / GitHub API rather than re-using the cache. Also breaks horizontal scaling — each replica has its own cache file.
|
||||
|
||||
**Fix approach:** Cache via the configured storage service (`getStorageService`) instead of raw `fs`, or move to an in-memory + TTL cache.
|
||||
|
||||
---
|
||||
|
||||
## Low Severity
|
||||
|
||||
### Tech Debt: generated file you must not edit
|
||||
|
||||
`apps/web/src/routeTree.gen.ts` (983 lines, `eslint-disable`, `@ts-nocheck` at top) is auto-generated by TanStack Router. It is correctly excluded from Biome (`biome.json:14`) and noted in `AGENTS.md:31`.
|
||||
|
||||
**Action required of contributors:** Never hand-edit. Regenerate by running `pnpm dev` (TanStack tooling watches `apps/web/src/routes/**`).
|
||||
|
||||
### Tech Debt: dev workflow — `pnpm check` is write-capable
|
||||
|
||||
`package.json:20` defines `"check": "biome check --write --unsafe ."`. Running `pnpm check` will modify files. The lefthook pre-commit (`lefthook.yml:7-9`) runs the same command on staged files only, and `stage_fixed: true` re-stages them.
|
||||
|
||||
**Impact:** Surprise file modifications when a contributor runs `pnpm check` expecting a non-mutating audit.
|
||||
|
||||
**Fix approach:** Add a parallel `pnpm check:ci` (no `--write`) for inspection, and document the distinction (already covered in `AGENTS.md:103`).
|
||||
|
||||
### Tech Debt: dev gotcha — `drizzle-kit` does not auto-load `.env`
|
||||
|
||||
`packages/db/drizzle.config.ts:8` reads `process.env.DATABASE_URL || ""`. The `@reactive-resume/env` package auto-loads `.env` via dotenv (`packages/env/src/server.ts:9-11`), but `drizzle-kit` runs as a separate process that does not import `@reactive-resume/env`.
|
||||
|
||||
**Impact:** Fresh `pnpm db:migrate` silently fails with an empty connection string unless `DATABASE_URL` is exported in the shell. Documented in `AGENTS.md:58, :79-80`.
|
||||
|
||||
**Fix approach:** Either import the env package from `drizzle.config.ts` to inherit the `.env` load, or wrap the script with a one-liner that exports `DATABASE_URL`.
|
||||
|
||||
### Tech Debt: Nitro plugin auto-runs migrations on dev/prod boot
|
||||
|
||||
`apps/web/plugins/1.migrate.ts:23-40` runs migrations on every Nitro startup. This is convenient but couples app-boot health to migration health.
|
||||
|
||||
**Impact:**
|
||||
- A bad migration takes down the whole web service on boot, not just future migration runs.
|
||||
- Two app instances starting concurrently both call `migrate()`; Drizzle's `migrations` table uses transactions to avoid duplicate apply, but the race adds startup latency.
|
||||
- For prod self-hosters, there is no "boot-without-migrate" knob.
|
||||
|
||||
**Fix approach:** Gate on an env flag (e.g. `RUN_MIGRATIONS_ON_BOOT=true`, default true) and document running `pnpm db:migrate` separately in zero-downtime deploys.
|
||||
|
||||
### Tech Debt: S3 toggle is all-or-nothing
|
||||
|
||||
`packages/api/src/services/storage.ts:336` and `apps/web/plugins/2.storage.ts:7`: storage backend selection requires all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` to be set. Setting two of three silently falls back to local storage.
|
||||
|
||||
**Impact:** Operator misconfigures S3 (e.g. forgets `S3_BUCKET`), app silently writes to local FS — uploads disappear on next container restart on ephemeral disks.
|
||||
|
||||
**Fix approach:** Treat "any S3 var set" as "S3 intended" — throw on partial config rather than silently downgrading.
|
||||
|
||||
### Security: no global CSP / X-Frame-Options on HTML responses
|
||||
|
||||
A repo-wide grep for `Content-Security-Policy`, `X-Frame-Options`, `Strict-Transport-Security` finds them only in the uploads route (`apps/web/src/routes/uploads/$userId.$.tsx:159-165`) and the schema JSON route (`apps/web/src/routes/schema[.]json.ts`). The HTML shell (`apps/web/src/routes/__root.tsx`) sets no CSP, no HSTS, no `Permissions-Policy`, no `Referrer-Policy`.
|
||||
|
||||
**Impact:**
|
||||
- No clickjacking protection on the resume builder or public resume pages — they can be framed by any origin.
|
||||
- No CSP means an XSS through rich-text rendering (`packages/utils/src/sanitize.*`, `packages/pdf/src/templates/shared/rich-text-html.ts`) has no defence-in-depth.
|
||||
|
||||
**Fix approach:** Add a Nitro response hook that sets `Content-Security-Policy`, `Strict-Transport-Security`, `Referrer-Policy: strict-origin-when-cross-origin`, `X-Content-Type-Options: nosniff`, and `X-Frame-Options: SAMEORIGIN` on all HTML responses. Validate that the CSP allows `pdf.worker.min.mjs`, `@react-pdf/renderer` font fetches, and the configured S3/SeaweedFS origin.
|
||||
|
||||
### Security: OAuth dynamic-client registration allows unauthenticated callers
|
||||
|
||||
`packages/auth/src/config.ts:395-401` enables `allowDynamicClientRegistration: true` and `allowUnauthenticatedClientRegistration: true` on the oauthProvider plugin. The comment (`:397-399`) explicitly states this is required for MCP onboarding (RFC 7591) and that the phishing vector is closed by the redirect-URI allowlist in `hooks.before` (`:253-271`) and `apps/web/src/routes/api/auth.$.ts:97-111`.
|
||||
|
||||
**Impact:** Anyone can register an OAuth client. The protection depends entirely on the redirect-URI allowlist correctness in `parseAllowedHostList(env.OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS)` and `isAllowedOAuthRedirectUri`.
|
||||
|
||||
**Fix approach:** Already mitigated in code; the residual risk is operator misconfiguration of `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Add a startup warning when `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS` is empty (i.e. allowlist is APP_URL-only).
|
||||
|
||||
### Tech Debt: log noise on auth fallthrough
|
||||
|
||||
`packages/api/src/context.ts:25, :37, :53` calls `console.warn` for every failed Bearer / session / API-key validation. In an unauthenticated user flow that hits the same route via Bearer-not-present → session-cookie path, no warning fires, but in an actual token-mismatch path the warning fires on every request.
|
||||
|
||||
**Impact:** Log volume in production when API keys expire or rotate. Logs may leak token shape information indirectly via repeated warnings.
|
||||
|
||||
**Fix approach:** Drop to `console.debug` (which is no-op in default Node), or rate-limit the warning per token-hash.
|
||||
|
||||
### Tech Debt: `getStorageService()` cached service singleton in module-load order
|
||||
|
||||
`packages/api/src/services/storage.ts:343-350` caches the service at first call. Combined with the `packages/api/src/routers/storage.ts:7` top-level call, if storage env vars are not present at module-load time (e.g. `.env` not loaded yet) the local backend is wired in permanently for the process.
|
||||
|
||||
**Fix approach:** Lazy-validate env on first use, not at module load.
|
||||
|
||||
### Anti-pattern: empty catch swallows preview generation errors
|
||||
|
||||
`apps/web/src/components/resume/preview.browser.tsx:120`:
|
||||
|
||||
```ts
|
||||
} catch {}
|
||||
```
|
||||
|
||||
A failed PDF generation in the builder preview is silently swallowed. The crossfade machinery keeps showing the previous preview, masking template bugs or runtime errors from contributors during development.
|
||||
|
||||
**Fix approach:** At minimum, `console.error` the failure (matching the pattern used in `pdf-canvas.tsx:68`). Better: surface a toast or banner so failed-preview state is visible.
|
||||
|
||||
### Anti-pattern: `console.warn`/`error` lacks structure
|
||||
|
||||
Every error log in `packages/api/src` uses `console.warn`/`console.error` with positional args (e.g. `services/resume.ts:158, :293, :363`, `context.ts:25, :37, :53`). There is no centralised logger, no structured fields, no request correlation ID.
|
||||
|
||||
**Fix approach:** Introduce a minimal logger (pino-light or a thin wrapper) with `level`, `event`, `userId`, `requestId` fields. Already partly done in the healthcheck (`apps/web/src/routes/api/health.ts:65, "[Healthcheck]"`).
|
||||
|
||||
### Fragile: in-process pub/sub for resume update events
|
||||
|
||||
`packages/api/src/services/resume-events.ts` (the subscribe path consumed by `packages/api/src/routers/resume.ts:99-103`'s `subscribeResumeUpdates`) is in-process. Two web replicas will not see each other's resume update events.
|
||||
|
||||
**Impact:** Multi-tab / multi-device builder sync across replicas does not work behind a load balancer.
|
||||
|
||||
**Fix approach:** Redis pub/sub or Postgres `LISTEN/NOTIFY` once distributed deployment becomes a target.
|
||||
|
||||
---
|
||||
|
||||
## Migration & Schema Concerns
|
||||
|
||||
### Migration count is small but each is unversioned in app
|
||||
|
||||
- 13 migration files in `migrations/` (`migrations/20260114102228_*` through `migrations/20260507144406_*`).
|
||||
- The Nitro plugin runs every pending migration on every boot (`apps/web/plugins/1.migrate.ts:23-40`).
|
||||
- There is no "down" / rollback story. Drizzle-kit generates forward-only migrations.
|
||||
|
||||
**Action:** Standard for drizzle workflows. Document that downgrade requires manual SQL.
|
||||
|
||||
### Schema notes
|
||||
|
||||
- `packages/db/src/schema/resume.ts:24` stores `password` as plaintext column type `text`, but content is always bcrypt-hashed at write (`packages/api/src/services/resume.ts:453`). Column name is misleading — should be `password_hash`. Renaming requires a non-trivial migration.
|
||||
|
||||
---
|
||||
|
||||
## Dependency Risk
|
||||
|
||||
### Pre-1.0 / preview dependencies in production critical path
|
||||
|
||||
| Package | Version | Risk |
|
||||
|---|---|---|
|
||||
| `drizzle-orm` | `1.0.0-beta.22` | Beta. Breaking changes possible until 1.0.0 stable. (`packages/api/package.json`, `packages/auth/package.json`, `packages/db/package.json`, `apps/web/package.json`) |
|
||||
| `drizzle-kit` | `1.0.0-beta.22` | Beta migrator. Snapshot format may change. (`packages/db/package.json`) |
|
||||
| `drizzle-zod` | `1.0.0-beta.14-a36c63d` | Beta + specific commit hash — version drift risk. (`packages/api/package.json`) |
|
||||
| `nitro` | `3.0.260429-beta` | Beta server runtime in the critical path. (`apps/web/package.json`) |
|
||||
| `@typescript/native-preview` | `7.0.0-dev.20260510.1` | Dev build of `tsgo`. All packages use it for `typecheck`. |
|
||||
| `typescript` | `^6.0.3` | TS 6 — recent major. |
|
||||
| `vite` | `^8.0.11` | Vite 8 — recent major. |
|
||||
| `react` / `react-dom` | `^19.2.6` | React 19. |
|
||||
| `@orpc/experimental-ratelimit` | `^1.14.2` | Explicitly experimental in the package name. (`packages/api/package.json`) |
|
||||
| `@tanstack/react-start` | `^1.167.65` | TanStack Start is pre-1.x semver but not labelled beta. |
|
||||
| `better-auth` | `1.6.10` (exact) | Pinned exact, not `^`. Manual upgrade required for security patches. |
|
||||
|
||||
**Impact:** Library upgrades in this stack are high-risk; the team must follow each upstream's release notes closely. The `BETA` / `DEV` versions also affect lockfile churn.
|
||||
|
||||
**Fix approach:**
|
||||
- Set up Renovate / Dependabot for the beta packages specifically, so security patches are caught.
|
||||
- Run `pnpm knip` and `pnpm dlx npm-check-updates` regularly (both are devDependencies, `package.json:34, :40`).
|
||||
|
||||
### Several heavy native dependencies are externalised at build time
|
||||
|
||||
`apps/web/vite.config.ts:55-57` externals `bcrypt`, `sharp`, `@aws-sdk/client-s3`. `packages/runtime-externals` is the workspace package that wraps these. Knip is configured to ignore them (`knip.json:14`).
|
||||
|
||||
**Risk:** Operators must ensure these are installed at runtime (covered in `Dockerfile`). Self-hosters using a Node base image without build-essentials may hit `bcrypt` build failures.
|
||||
|
||||
**Fix approach:** Mostly documented; consider switching `bcrypt` → `bcryptjs` (pure JS) to remove a build dependency.
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Hard-to-Find Logic
|
||||
|
||||
These are non-obvious surfaces that consumers of this map should know about before changing related code:
|
||||
|
||||
1. **PDF section filtering** — `packages/pdf/src/templates/shared/filtering.ts:25-60` decides which resume sections render in PDFs based on hidden flags and required title fields. Per-template visual exceptions live in each template directory; cross-template visual changes go here. Noted in `AGENTS.md:44`.
|
||||
|
||||
2. **React-PDF font registration & CJK fallback stack** — `packages/pdf/src/hooks/use-register-fonts.ts:68-133`. Owns standard-PDF-font handling (`isStandardPdfFontFamily`), CJK glyph-level fallback (#2986), and global hyphenation callback. Module-level registration cache (`:18`). Noted in `AGENTS.md:45`.
|
||||
|
||||
3. **Resume access policy** — `packages/api/src/helpers/resume-access-policy.ts`. Single source of truth for owner-vs-viewer redaction (`name` and `metadata.notes` stripped for non-owners), `NOT_FOUND`-vs-`FORBIDDEN` choice (not-found used to avoid existence disclosure), and self-view statistics exclusion. Owner-only mutations rely on SQL `WHERE userId =` clauses, not this policy — drift between SQL guards and policy is silent.
|
||||
|
||||
4. **Resume password cookie** — `packages/api/src/helpers/resume-access.ts`. Cookie name `resume_access_{resumeId}`, signed value is `sha256(resumeId:passwordHash)`, TTL 10 minutes, `httpOnly`, `sameSite: lax`, `secure` only when `APP_URL` starts with `https`. Forgetting to set `APP_URL=https://...` in production drops the secure flag.
|
||||
|
||||
5. **OAuth `authorize` request sanitization** — `apps/web/src/routes/api/auth.$.ts:6-51` strips control chars from OAuth parameters and decodes broken-but-decodable redirect URIs before passing to Better Auth. Easy to bypass if a new OAuth flow is added that does not route through this handler.
|
||||
|
||||
6. **Dynamic client registration coercion to public client** — `apps/web/src/routes/api/auth.$.ts:53-80` forces `token_endpoint_auth_method = "none"` for unauthenticated registrations (specifically for Claude.ai's MCP onboarding quirk). This is non-standard behaviour buried in a request preprocessor.
|
||||
|
||||
7. **Builder draft sync** — `apps/web/src/components/resume/builder-resume-draft.ts:46-100` manages per-resume zustand stores keyed by resume id, with debounced patch-and-resync. Failure to clean up `runtimes` Map on resume close = subscription/timer leaks.
|
||||
|
||||
---
|
||||
|
||||
## Test Coverage Gaps (Priority Map)
|
||||
|
||||
| Area | Source files | Test files | Priority |
|
||||
|------|-------------:|-----------:|----------|
|
||||
| `apps/web/src/routes/**` | 125 | 1 | **High** — covers all server handlers and the builder shell |
|
||||
| `apps/web/src/routes/api/**` (rpc/auth/uploads/openapi/mcp) | ~10 | 0 | **High** — security-sensitive route handlers |
|
||||
| `apps/web/src/components/resume/**` | 5 | 2 | Medium |
|
||||
| `apps/web/src/dialogs/**` | ~30 | 1 (`store.test.ts`) | Medium |
|
||||
| `packages/api/src/routers/**` | 7 | 0 (DTO test only) | **High** — auth-protected procedures |
|
||||
| `packages/api/src/services/**` | 8 | 1 (`ai.test.ts`) | High |
|
||||
| `packages/auth/src/**` | 3 | 0 | **High** — central auth config never directly tested |
|
||||
| `packages/email/src/**` | ~5 | 0 | Medium |
|
||||
| `packages/import/src/**` | several importers | 1 (v4) | Medium |
|
||||
| `packages/pdf/src/templates/shared/**` | ~20 | 11 | Low (well covered) |
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-05-11*
|
||||
@@ -1,238 +0,0 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
This document is the canonical short-form reference for code style, structure, and feature-boundary rules in the Reactive Resume monorepo. The authoritative long-form reference lives in `AGENTS.md` at the repo root — when in doubt, defer to it.
|
||||
|
||||
## Tooling Stack
|
||||
|
||||
- **Formatter + linter:** Biome 2.x (`biome.json`). Pre-commit hook (`lefthook.yml`) runs `biome check --write --unsafe` on staged JS/TS/JSON files.
|
||||
- **Type checker:** `tsgo --noEmit` (the `@typescript/native-preview` TS implementation) in every workspace package and `apps/web`. Use `pnpm typecheck` at the root or `pnpm --filter <pkg> typecheck` per package.
|
||||
- **TypeScript base config:** `packages/config/tsconfig.base.json`, consumed by `tsconfig.json` at root and per-package `tsconfig.json` files.
|
||||
- **Commit hook:** commitlint with `@commitlint/config-conventional` (`commitlint.config.cjs`). `body-max-line-length` is disabled.
|
||||
- **Internal CLI:** `pnpm check` is **write-capable** (`biome check --write --unsafe .`). Use `biome check .` (no `--write`) for read-only inspection.
|
||||
|
||||
## Biome Configuration (`biome.json`)
|
||||
|
||||
**Formatter:**
|
||||
- `lineWidth: 120`
|
||||
- `indentStyle: "tab"`
|
||||
- `javascript.formatter.quoteStyle: "double"`
|
||||
- CSS parser has `tailwindDirectives: true`
|
||||
|
||||
**Linter rules (notable):**
|
||||
- `recommended: true`
|
||||
- `suspicious.noExplicitAny: "error"` — `any` is forbidden
|
||||
- `suspicious.noArrayIndexKey: "off"`
|
||||
- `correctness.useExhaustiveDependencies: "info"` (not an error)
|
||||
- `style.useImportType: { level: "on", options: { style: "separatedType" } }` — `import type` is required when an import is type-only and must be on its own line (not inlined per-specifier)
|
||||
- `style.noInferrableTypes: "error"` — drop redundant annotations like `const x: number = 1`
|
||||
- `style.noUselessElse: "error"`
|
||||
- `style.useSelfClosingElements: "error"`
|
||||
- `style.useSingleVarDeclarator: "error"`
|
||||
- `style.noParameterAssign: "error"`
|
||||
- `style.useDefaultParameterLast: "error"`
|
||||
- `nursery.useSortedClasses: { level: "warn", fix: "safe", functions: ["clsx", "cva", "cn"] }` — Tailwind class strings inside these wrappers are sorted automatically
|
||||
|
||||
**Import organization:** Biome's `assist.actions.source.organizeImports` is `on`, grouped in this order (`biome.json` lines 32-39):
|
||||
|
||||
1. Type-only imports (`{ "type": true }`)
|
||||
2. Node built-ins (`":NODE:"`, excluding Bun)
|
||||
3. Vitest + Testing Library (`vitest`, `vitest/**`, `@testing-library/**`)
|
||||
4. External npm packages (`:PACKAGE:`, excluding `@reactive-resume/**`)
|
||||
5. Internal workspace packages (`@reactive-resume/**`)
|
||||
6. App-local aliases / relative paths (`:ALIAS:`, `:PATH:`)
|
||||
|
||||
A representative file that demonstrates the order: `packages/api/src/services/resume.ts` (type imports → node-free externals → `@reactive-resume/*` → relatives).
|
||||
|
||||
**Biome ignores:** `**/.turbo`, `**/.output`, `**/.vercel`, `**/.wrangler`, `**/coverage`, `**/reports`, `**/routeTree.gen.ts`.
|
||||
|
||||
## TypeScript Conventions
|
||||
|
||||
**Strictness flags** in `packages/config/tsconfig.base.json` are intentionally aggressive:
|
||||
|
||||
- `strict: true`
|
||||
- `verbatimModuleSyntax: true` (pairs with Biome's `useImportType` enforcement)
|
||||
- `exactOptionalPropertyTypes: true`
|
||||
- `noUncheckedIndexedAccess: true`
|
||||
- `noUncheckedSideEffectImports: true`
|
||||
- `noUnusedLocals: true`, `noUnusedParameters: true`
|
||||
- `noFallthroughCasesInSwitch: true`
|
||||
- `isolatedModules: true`, `moduleResolution: "bundler"`
|
||||
- `target: "ESNext"`, `module: "ESNext"`
|
||||
|
||||
**Type-only imports:** Always use `import type { … }` on its own line when an import is only used in type positions. See `packages/api/src/services/resume.ts:1-5` and `packages/api/src/context.ts:1-2`.
|
||||
|
||||
**`any`:** Banned by Biome (`noExplicitAny: "error"`). Use `unknown` and narrow, or use a discriminated union.
|
||||
|
||||
**Path aliases (web app only):** `apps/web/tsconfig.json` declares:
|
||||
- `@/*` → `./src/*`
|
||||
- `@reactive-resume/ui/*` → `../../packages/ui/src/*` (build-time alias for direct source resolution)
|
||||
|
||||
Internal packages do NOT use `@/*` aliases — they import siblings via relative paths and cross-package code via the `@reactive-resume/*` export maps.
|
||||
|
||||
## Package Export Conventions (source-consumed packages)
|
||||
|
||||
**Internal packages export `src` files directly via `package.json` `exports`.** There is no per-package build step; consumers pick up the TS source through bundler/vitest resolution. Do not assume any `dist/` output.
|
||||
|
||||
Sample (`packages/utils/package.json`):
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "@reactive-resume/utils",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./color": "./src/color.ts",
|
||||
"./date": "./src/date.ts",
|
||||
"./resume/docx": "./src/resume/docx/index.ts",
|
||||
"./resume/patch": "./src/resume/patch.ts",
|
||||
"./url-security.node": "./src/url-security.node.ts"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Conventions when adding cross-package exports:**
|
||||
|
||||
- Use **explicit subpath exports**, not wildcards in `@reactive-resume/utils`/`@reactive-resume/db`. Some packages (`@reactive-resume/api`) do use wildcards like `"./services/*"` — match the style of the package you're editing.
|
||||
- Filenames ending in `.node.ts` (e.g. `packages/utils/src/url-security.node.ts`, `packages/utils/src/monorepo.node.ts`) are reserved for Node-only code that must not be imported from the browser bundle.
|
||||
- Filename suffixes `.browser.tsx` (e.g. `apps/web/src/components/resume/preview.browser.tsx`) mark code that must stay out of SSR paths.
|
||||
|
||||
## React & Web App Conventions
|
||||
|
||||
**Routing:** TanStack Router with file-based routes under `apps/web/src/routes`.
|
||||
- Each route file calls `createFileRoute("/path")({ … })` and exports `Route`. Example: `apps/web/src/routes/auth/login.tsx:18`.
|
||||
- `apps/web/src/routeTree.gen.ts` is generated — never hand-edit it (also Biome-ignored).
|
||||
- Server-only handlers live in `server.handlers` blocks on routes like `apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/auth.$.ts`, `apps/web/src/routes/api/health.ts`.
|
||||
- Public resume route `apps/web/src/routes/$username/$slug.tsx` is `ssr: "data-only"`; nested builder preview is `ssr: false`.
|
||||
|
||||
**Router context** (`apps/web/src/router.tsx`) provides `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Read them via `Route.useRouteContext()` rather than refetching.
|
||||
|
||||
**Components:**
|
||||
- Functional components only. React 19.
|
||||
- File naming: **kebab-case** for files and directories (e.g. `apps/web/src/components/command-palette/`, `packages/ui/src/components/alert-dialog.tsx`, `apps/web/src/dialogs/api-key/create.tsx`).
|
||||
- Test file: `<name>.test.ts(x)` colocated with the implementation (e.g. `packages/ui/src/components/button.tsx` + `packages/ui/src/components/button.test.tsx`).
|
||||
- shadcn/Base UI primitive components in `packages/ui/src/components/*.tsx` are exported via the `./components/*` subpath. Hooks via `./hooks/*`.
|
||||
|
||||
**Tailwind class strings:** Always wrap in `clsx`, `cva`, or `cn` so Biome's `useSortedClasses` rule can sort them safely.
|
||||
|
||||
## oRPC Conventions (`packages/api`)
|
||||
|
||||
- **Procedures:** Build new procedures with `publicProcedure` or `protectedProcedure` from `packages/api/src/context.ts:79-99`. `protectedProcedure` adds the authenticated `User` to context and throws `ORPCError("UNAUTHORIZED")` otherwise; prefer it for anything authenticated.
|
||||
- **Routers** live in `packages/api/src/routers/*.ts` and are composed in `packages/api/src/routers/index.ts` (`ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). Each router file may export sub-routers internally (see `tagsRouter`, `statisticsRouter`, `analysisRouter`, `updatesRouter` in `packages/api/src/routers/resume.ts`).
|
||||
- **Business logic** belongs in `packages/api/src/services/*.ts`. Handlers must stay thin: validate input, call a service, return its output. Example pattern: `packages/api/src/routers/resume.ts:24-26` calls `resumeService.tags.list(...)`.
|
||||
- **DTOs / IO schemas** live in `packages/api/src/dto/*.ts` and are imported as `resumeDto.<op>.input` / `resumeDto.<op>.output`.
|
||||
- **Errors:** Declare typed errors with `.errors({ CODE: { message, status } })` on the procedure (see `packages/api/src/routers/resume.ts:282-291`). Throw `new ORPCError("CODE")` inside services. The web side translates codes to user-facing strings in `apps/web/src/libs/error-message.ts`.
|
||||
- **Route metadata:** Every procedure declares `.route({ method, path, tags, operationId, summary, description, successDescription })` so the OpenAPI/MCP endpoints stay accurate.
|
||||
- **Rate limiting:** Apply via `.use(resumeMutationRateLimit)` / `.use(resumePasswordRateLimit)` from `packages/api/src/middleware/rate-limit`.
|
||||
- **Web exposure:** Routers are mounted at `/api/rpc` by `apps/web/src/routes/api/rpc.$.ts`. The isomorphic client lives at `apps/web/src/libs/orpc/client.ts` — server-side calls use the in-process router client and browser calls hit `/api/rpc` with credentials.
|
||||
|
||||
## Drizzle Conventions (`packages/db`)
|
||||
|
||||
- **Schema location:** `packages/db/src/schema/*.ts`. Tables exported from `packages/db/src/schema/index.ts`.
|
||||
- **Client:** `packages/db/src/client.ts`, exported as `@reactive-resume/db/client`.
|
||||
- **Migrations:** Generated to **`migrations/` at the repo root** by `drizzle-kit generate`. Use `pnpm db:generate` after schema changes.
|
||||
- **`DATABASE_URL` handling:** `drizzle-kit` does **not** auto-load `.env`. Always export `DATABASE_URL` in the shell (or prefix the command) before running `pnpm db:generate` / `pnpm db:migrate`.
|
||||
- **Runtime migration:** `apps/web/plugins/1.migrate.ts` runs migrations on Nitro startup, so `pnpm db:migrate` is mostly used for first-time setup or debugging.
|
||||
- **Patterns observed in `packages/db/src/schema/resume.ts`:**
|
||||
- Primary keys use `pg.text("id").$defaultFn(() => generateId())` (UUIDv7 from `@reactive-resume/utils/string`).
|
||||
- `createdAt` / `updatedAt` use `withTimezone: true`, `.defaultNow()`, and `$onUpdate(() => new Date())`.
|
||||
- JSONB columns get `.$type<T>()` for end-to-end typing.
|
||||
- Composite uniques/indexes are declared in the table's tuple callback.
|
||||
- Foreign keys use `onDelete: "cascade"`.
|
||||
|
||||
## Schema-First Change Workflow
|
||||
|
||||
When changing resume data shape, propagate in this order (per `AGENTS.md`):
|
||||
|
||||
1. **`packages/schema/src/resume/*.ts`** — Zod schemas and types (entry point).
|
||||
2. **`packages/api/src/dto/*.ts`** — API DTOs that re-use those schemas.
|
||||
3. **`packages/import/src/*.tsx`** — importers (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
|
||||
4. **`packages/pdf/src/templates/**`** — PDF rendering for every template (`azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`). Shared filtering: `packages/pdf/src/templates/shared/filtering.ts`.
|
||||
5. **`apps/web/src/`** — builder forms and any consumer hooks.
|
||||
|
||||
Adding/renaming a template requires changes in `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, the template directory `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}/`.
|
||||
|
||||
## Error Handling Patterns
|
||||
|
||||
- **Services:** Throw `new ORPCError("CODE")` (e.g. `NOT_FOUND`, `UNAUTHORIZED`, custom `RESUME_LOCKED`). Example: `packages/api/src/services/resume.ts:54`.
|
||||
- **Routers:** Declare expected codes via `.errors({ … })` so callers get typed error narrowing.
|
||||
- **Auth helpers** in `packages/api/src/context.ts:14-55` catch verification errors and `console.warn(...)` rather than throwing, returning `null` so the caller can fall through to the next auth method.
|
||||
- **Web side:** `apps/web/src/libs/error-message.ts` exposes `getReadableErrorMessage`, `getOrpcErrorMessage`, and `getResumeErrorMessage` for translating raw errors into UI-safe strings. Pair with `sonner` toasts (see `apps/web/src/routes/auth/login.tsx:45-64`).
|
||||
|
||||
## Logging
|
||||
|
||||
- No dedicated logging framework. Use `console.warn` / `console.error` for diagnostic output, scoped tightly (see `packages/api/src/context.ts:25`).
|
||||
- Server logs are also where dev-mode email verification links surface when SMTP is unconfigured.
|
||||
|
||||
## i18n & Translations (Lingui)
|
||||
|
||||
- **Library:** `@lingui/core`, `@lingui/react` with the babel macro plugin enabled in `apps/web/vitest.config.ts` and Vite config.
|
||||
- **Config:** `apps/web/lingui.config.ts` — source locale `en-US`, pseudo locale `zu-ZA`, 50+ supported locales. Catalogs live in `apps/web/locales/{locale}.po`.
|
||||
- **Usage:**
|
||||
- Import macros: `import { t } from "@lingui/core/macro"` and `import { Trans } from "@lingui/react/macro"` (`apps/web/src/routes/auth/login.tsx:1-2`).
|
||||
- Wrap displayed text in `<Trans>...</Trans>` for JSX or `` t`...` `` / `t({ message, comment })` for strings.
|
||||
- Provide `comment:` for ambiguous fallback strings (see `login.tsx:57-60`).
|
||||
- **Extraction:** `pnpm lingui:extract` (turbo task in `apps/web`). Crowdin sync runs via `.github/workflows/crowdin-sync.yml`.
|
||||
|
||||
## Comments Policy
|
||||
|
||||
Comments stay short and explain **why**, not what. Patterns observed:
|
||||
|
||||
- One-line `//` comments before a non-obvious decision (`vitest.setup.ts:5-7` explains why `cleanup()` is registered manually; `packages/utils/src/monorepo.node.test.ts:11` explains the `realpathSync` call).
|
||||
- JSDoc/TSDoc only on cross-package public functions where intent matters (e.g. `packages/api/src/context.ts:57-63` documents `resolveUserFromRequestHeaders`).
|
||||
- Inline `/* @__PURE__ */` annotations on `$onUpdate(() => new Date())` in Drizzle schemas (`packages/db/src/schema/resume.ts:36`).
|
||||
- No commented-out code in commits.
|
||||
|
||||
## Git Hooks & Commit Style
|
||||
|
||||
**Lefthook (`lefthook.yml`):**
|
||||
|
||||
```yaml
|
||||
pre-commit:
|
||||
parallel: true
|
||||
jobs:
|
||||
- name: lint and format
|
||||
glob: "*.{js,ts,cjs,mjs,d.cts,d.mts,jsx,tsx,json,jsonc}"
|
||||
run: pnpm biome check --write --unsafe --no-errors-on-unmatched --files-ignore-unknown=true {staged_files}
|
||||
stage_fixed: true
|
||||
|
||||
commit-msg:
|
||||
jobs:
|
||||
- name: commitlint
|
||||
run: pnpm commitlint --edit {1}
|
||||
```
|
||||
|
||||
The pre-commit hook **rewrites staged files** with Biome fixes (`stage_fixed: true`). Run `pnpm check` before staging to avoid surprises.
|
||||
|
||||
**Commit messages:** Conventional Commits (`commitlint.config.cjs` extends `@commitlint/config-conventional`). Examples from `git log`:
|
||||
|
||||
- `feat: implement an AI chat window for agentic resume building`
|
||||
- `fix(pdf): register CJK fallback font so Chinese/Japanese/Korean text renders correctly`
|
||||
- `fix(lapras): adjust lapras border color to fixed gray`
|
||||
- `chore: migrate from jsdom to happy-dom for testing environment`
|
||||
- `docs: update AGENTS.md with detailed codebase structure`
|
||||
- `test: add unit and component tests across the monorepo`
|
||||
- `chore(release): v5.1.2`
|
||||
|
||||
Allowed types: `feat`, `fix`, `chore`, `docs`, `test`, `refactor`, `style`, `perf`, `build`, `ci`, `revert`. Scope is optional and lowercase. `body-max-line-length` is disabled, so long PR bodies are fine.
|
||||
|
||||
## CI Workflows
|
||||
|
||||
- **`.github/workflows/autofix.yml`** runs on every PR and push to `main`: `pnpm install --frozen-lockfile` → `pnpm knip --fix` (prune unused deps) → `pnpm check` (Biome) → `autofix-ci/action` opens fix commits.
|
||||
- **`.github/workflows/docker-build.yml`** is `workflow_dispatch` only, builds multi-arch Docker images.
|
||||
- **`.github/workflows/crowdin-sync.yml`** syncs translation catalogs.
|
||||
- There is no CI workflow that runs `pnpm test` today. Tests run locally via `pnpm test` / `pnpm test:ci` and through turbo's `test:agent` reporter for agent-driven runs.
|
||||
|
||||
## Function & Module Design
|
||||
|
||||
- **Function size:** Most service functions stay under ~40 lines. Bigger flows (e.g. `resumeService.patch`) are decomposed into helpers in `packages/api/src/helpers/*` and `packages/api/src/services/resume-events.ts`.
|
||||
- **Parameters:** Service helpers consistently take a single object argument (`async ({ id, userId })`) rather than positional args. See `packages/api/src/services/resume.ts:27,41,65`.
|
||||
- **Return values:** Services return plain typed objects; routers shape the response via `.output(schema)` so Zod validates at the boundary.
|
||||
- **Module boundaries:**
|
||||
- Cross-package imports must go through declared `exports` subpaths. Reaching into `packages/<x>/src/internal-file` directly is not allowed.
|
||||
- `packages/utils` exports are narrowly scoped — if you need a new helper for another package, add a new explicit subpath in `packages/utils/package.json`.
|
||||
- `packages/runtime-externals` and `packages/scripts` are support packages; avoid importing them into runtime code.
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-05-11*
|
||||
@@ -1,239 +0,0 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**AI Providers (user-supplied API keys, called per-request from `packages/api/src/services/ai.ts`):**
|
||||
- OpenAI — Default base URL `https://api.openai.com/v1` (`packages/ai/src/types.ts`)
|
||||
- SDK: `@ai-sdk/openai ^3.0.63` via `createOpenAI(...).chat(model)`
|
||||
- API key supplied per-call from the resume's `ai` config; not read from server env
|
||||
- Anthropic — Default base URL `https://api.anthropic.com/v1`
|
||||
- SDK: `@ai-sdk/anthropic ^3.0.76` via `createAnthropic(...).languageModel(model)`
|
||||
- Google Gemini — Default base URL `https://generativelanguage.googleapis.com/v1beta`
|
||||
- SDK: `@ai-sdk/google ^3.0.71` via `createGoogleGenerativeAI(...).languageModel(model)`
|
||||
- Vercel AI Gateway — Default base URL `https://ai-gateway.vercel.sh/v3/ai`
|
||||
- SDK: `ai ^6.0.177` via `createGateway(...).languageModel(model)`
|
||||
- OpenRouter — Default base URL `https://openrouter.ai/api/v1`
|
||||
- SDK: `@ai-sdk/openai-compatible ^2.0.47` via `createOpenAICompatible({ name: "openrouter", ... })`
|
||||
- Ollama — Default base URL `https://ollama.com/api`
|
||||
- SDK: `ollama-ai-provider-v2 ^3.5.0` via `createOllama(...)`
|
||||
- Security: `packages/api/src/services/ai.ts` `resolveBaseUrl` rejects non-HTTPS URLs, credentialed URLs, and private/loopback hosts (via `packages/utils/src/url-security.node.ts`).
|
||||
- File-input AI calls are capped at 10MB (`MAX_AI_FILE_BYTES`).
|
||||
|
||||
**OAuth Identity Providers (server-side env-configured, optional):**
|
||||
- Google — `GOOGLE_CLIENT_ID` / `GOOGLE_CLIENT_SECRET` (config in `packages/auth/src/config.ts`).
|
||||
- GitHub — `GITHUB_CLIENT_ID` / `GITHUB_CLIENT_SECRET`.
|
||||
- LinkedIn — `LINKEDIN_CLIENT_ID` / `LINKEDIN_CLIENT_SECRET`.
|
||||
- Custom Generic OAuth — `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, plus either `OAUTH_DISCOVERY_URL` or all three of `OAUTH_AUTHORIZATION_URL`/`OAUTH_TOKEN_URL`/`OAUTH_USER_INFO_URL`. Scopes from `OAUTH_SCOPES` (defaults `openid profile email`). Wired via Better Auth's `genericOAuth` plugin.
|
||||
- Trusted providers for account linking (`packages/auth/src/config.ts`): `google`, `github`, `linkedin`.
|
||||
|
||||
**Translation / Localization:**
|
||||
- Crowdin — `CROWDIN_PROJECT_ID`, `CROWDIN_PERSONAL_TOKEN`. Config in `crowdin.yml`; pull request automation labelled `l10n`. Source catalog `apps/web/locales/en-US.po`.
|
||||
|
||||
**Font Catalog Tooling:**
|
||||
- Google Fonts Developer API — `GOOGLE_CLOUD_API_KEY` consumed by `packages/scripts/fonts/generate.ts` hitting `https://www.googleapis.com/webfonts/v1/webfonts`. Output committed at `packages/fonts/src/webfontlist.json`.
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL — Required relational database.
|
||||
- Connection env: `DATABASE_URL` (validated as `postgres(ql)://...` in `packages/env/src/server.ts`)
|
||||
- Client: `drizzle-orm 1.0.0-beta.22` with `pg ^8.20.0` Pool, instantiated in `packages/db/src/client.ts` (singleton via `globalThis.__pool` / `globalThis.__drizzle`).
|
||||
- Schema location: `packages/db/src/schema/index.ts` (auth + resume tables) with `packages/db/src/relations.ts`.
|
||||
- Migrations: generated by `drizzle-kit` into repo-root `migrations/` (e.g. `20260507144406_fast_nova/`). Generator config at `packages/db/drizzle.config.ts`.
|
||||
- Auto-migration on app start: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved migrations folder.
|
||||
- `drizzle-kit` does NOT auto-load `.env` — `DATABASE_URL` must be exported before running `pnpm db:generate` / `pnpm db:migrate` (see `AGENTS.md` "Important" callout).
|
||||
- Health probe: `packages/api/src/services/storage.ts` healthcheck + `apps/web/src/routes/api/health.ts` runs `SELECT 1` through Drizzle (1.5s timeout).
|
||||
|
||||
**File Storage (selected at runtime in `packages/api/src/services/storage.ts`):**
|
||||
- S3-compatible object storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set.
|
||||
- Client: `@aws-sdk/client-s3 ^3.1045.0` (`S3Client` constructed in `S3StorageService`).
|
||||
- Options: `S3_REGION` (default `us-east-1`), `S3_ENDPOINT` (for non-AWS), `S3_FORCE_PATH_STYLE`.
|
||||
- Tested compatible backends: AWS S3 and SeaweedFS (see `compose.dev.yml` / `compose.yml`).
|
||||
- All writes use `ACL: "public-read"` (consumed publicly from `/uploads/$userId/$` route, with path validation).
|
||||
- Local filesystem fallback (`LocalStorageService`) used when any of the three S3 vars is missing.
|
||||
- Path: `LOCAL_STORAGE_PATH` if set (must be absolute); otherwise `<workspace>/data` in dev or `/app/data` in the Docker image.
|
||||
- Validated at boot by `apps/web/plugins/2.storage.ts` (mkdir + access check).
|
||||
- Key layout (built in `packages/api/src/services/storage.ts`):
|
||||
- `uploads/{userId}/pictures/{timestamp}.jpeg`
|
||||
- `uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg`
|
||||
- `uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf`
|
||||
- Public read route: `apps/web/src/routes/uploads/$userId.$.tsx` (ETag, content-type sniffing, path traversal protection).
|
||||
- Image preprocessing: `sharp ^0.34.5` resizes to 800x800 and re-encodes JPEG at quality 80, unless `FLAG_DISABLE_IMAGE_PROCESSING=true`.
|
||||
|
||||
**Caching:**
|
||||
- No external cache (Redis/Memcached) configured.
|
||||
- In-process rate limiter: `@orpc/experimental-ratelimit` `MemoryRatelimiter` (`packages/api/src/middleware/rate-limit/index.ts`).
|
||||
- Workbox precache in the service worker (PWA) — `apps/web/vite.config.ts` `VitePWA` setup (skipWaiting, clientsClaim, cleanupOutdatedCaches).
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:** Better Auth `1.6.10`, configured in `packages/auth/src/config.ts`.
|
||||
- Mounted as a TanStack Start server handler at `/api/auth/*` via `apps/web/src/routes/api/auth.$.ts`.
|
||||
- Drizzle adapter: `@better-auth/drizzle-adapter` bound to `db` + schema (provider `pg`).
|
||||
- Trusted origins: `http://localhost:3000`, `http://127.0.0.1:3000`, and the normalized origin of `APP_URL`.
|
||||
- Secure cookies enabled automatically when `APP_URL` is `https://`.
|
||||
- Trusted IP headers (used by both Better Auth `advanced.ipAddress.ipAddressHeaders` and the oRPC rate limiter): `CF-Connecting-IP`, `CF-Connecting-IPv6`, `True-Client-IP`, `X-Forwarded-For`, `X-Real-IP` (`packages/utils/src/rate-limit.ts`).
|
||||
- Telemetry: explicitly disabled.
|
||||
|
||||
**Email/Password:**
|
||||
- Enabled unless `FLAG_DISABLE_EMAIL_AUTH=true`.
|
||||
- Password hash: `bcrypt` (cost 10) via `hash` / `compare` from `bcrypt ^6.0.0`.
|
||||
- Min 8, max 64 char passwords.
|
||||
- Email verification: sent on signup using `VerifyEmail` template from `@reactive-resume/email/templates/auth`.
|
||||
- Reset password: `sendResetPassword` -> `ResetPasswordEmail` template.
|
||||
- Email change: `sendChangeEmailConfirmation` -> `VerifyEmailChange` template.
|
||||
|
||||
**Better Auth Plugins (in `packages/auth/src/config.ts`):**
|
||||
- `jwt()` — JWKS published at `/api/auth/jwks` (also referenced by `verifyOAuthToken`).
|
||||
- `admin()` — Admin operations.
|
||||
- `passkey()` (`@better-auth/passkey ^1.6.10`) — WebAuthn passkeys.
|
||||
- `genericOAuth({ config })` — Driven by `OAUTH_*` env vars when configured.
|
||||
- `twoFactor({ issuer: "Reactive Resume" })` — TOTP + backup codes; UI routes `/auth/verify-2fa` and `/auth/verify-2fa-backup`.
|
||||
- `apiKey()` (`@better-auth/api-key ^1.6.10`) — Header `x-api-key`; `enableSessionForAPIKeys: true`; per-key rate limit 1000 req/hour.
|
||||
- `oauthProvider()` (`@better-auth/oauth-provider ^1.6.10`) — Makes this app act as an OAuth 2.1 authorization server for MCP clients. Allows dynamic and unauthenticated client registration (RFC 7591) but redirect URIs are gated by an allowlist in the auth `hooks.before` middleware and `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`. Audiences whitelist: `${APP_URL}`, `${APP_URL}/`, `${APP_URL}/mcp`, `${APP_URL}/mcp/`.
|
||||
- `username()` — Normalized lowercase usernames (`^[a-z0-9._-]+$`, length 3–64).
|
||||
- `dash({ apiKey: BETTER_AUTH_API_KEY })` — Better Auth Dashboard (only when `BETTER_AUTH_API_KEY` is set).
|
||||
|
||||
**Social Providers:**
|
||||
- Google, GitHub, LinkedIn — Each activates only when both `*_CLIENT_ID` and `*_CLIENT_SECRET` env vars are set (`packages/auth/src/config.ts`). `disableImplicitSignUp: true`; account linking enabled.
|
||||
|
||||
**Session / Access in API context:**
|
||||
- `packages/api/src/context.ts` exposes `protectedProcedure` (referenced in `AGENTS.md`) for authenticated procedures.
|
||||
- oRPC middleware reads request headers via `RequestHeadersPlugin` (`apps/web/src/routes/api/rpc.$.ts`, `apps/web/src/routes/api/openapi.$.ts`).
|
||||
|
||||
**API Key Auth for HTTP / MCP:**
|
||||
- `x-api-key` header recognised by Better Auth API Key plugin.
|
||||
- OpenAPI spec at `/api/openapi/spec.json` declares `apiKey` security scheme (`apps/web/src/routes/api/openapi.$.ts`).
|
||||
- MCP server first tries OAuth Bearer (`Authorization: Bearer ...`), then falls back to `x-api-key` (`apps/web/src/routes/mcp/index.ts`).
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None — no Sentry/Datadog/Rollbar SDK present.
|
||||
- Errors are logged via `console.error` (oRPC interceptors `onError` in `apps/web/src/routes/api/rpc.$.ts` and `apps/web/src/routes/api/openapi.$.ts`).
|
||||
|
||||
**Health Checks:**
|
||||
- `GET /api/health` — `apps/web/src/routes/api/health.ts` returns JSON with DB and storage status; 503 if unhealthy. Used by Docker `HEALTHCHECK`.
|
||||
|
||||
**Logs:**
|
||||
- `console.info`/`console.warn`/`console.error` only. SMTP failures, missing config, sanitization diagnostics, and migration progress are all written to stdout/stderr.
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting / Distribution:**
|
||||
- Self-hosted Docker image — `Dockerfile` builds final image around `node:24-slim`, expose port 3000, default `LOCAL_STORAGE_PATH=/app/data`. Multi-stage uses `turbo prune` for both the web app and the `runtime-externals` package (which carries `bcrypt`, `sharp`, `@aws-sdk/client-s3` as native deps).
|
||||
- Container labels point to `https://rxresu.me` and `https://docs.rxresu.me`.
|
||||
|
||||
**CI Pipeline:**
|
||||
- Not present in this worktree (no `.github/workflows/` enumerated here). Scripts produce CI-friendly Vitest reports (`reports/vitest-junit.xml`, `reports/vitest-results.json`) via the `test:ci` task.
|
||||
|
||||
**Local Orchestration:**
|
||||
- `compose.dev.yml` — `postgres:latest`, `seaweedfs:latest`, `seaweedfs_create_bucket` (uses `quay.io/minio/mc:latest`).
|
||||
- `compose.yml` — Same services plus `reactive_resume` app container, networks `data_network` / `storage_network`.
|
||||
|
||||
**Git Hooks:**
|
||||
- Lefthook (`lefthook.yml`) — `pre-commit` runs `biome check --write --unsafe` on staged JS/TS/JSON files; `commit-msg` runs `commitlint --edit`.
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars (validated by Zod in `packages/env/src/server.ts`):**
|
||||
- `APP_URL` — http(s) URL, used for auth base URL, OAuth audiences, OG metadata, and public upload URLs.
|
||||
- `DATABASE_URL` — `postgres(ql)://...`.
|
||||
- `AUTH_SECRET` — Better Auth signing secret (`openssl rand -hex 32`).
|
||||
|
||||
**Optional auth env vars:**
|
||||
- `BETTER_AUTH_API_KEY` — Enables Better Auth Dashboard plugin.
|
||||
- `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`.
|
||||
- `GITHUB_CLIENT_ID`, `GITHUB_CLIENT_SECRET`.
|
||||
- `LINKEDIN_CLIENT_ID`, `LINKEDIN_CLIENT_SECRET`.
|
||||
- `OAUTH_PROVIDER_NAME`, `OAUTH_CLIENT_ID`, `OAUTH_CLIENT_SECRET`, `OAUTH_DISCOVERY_URL`, `OAUTH_AUTHORIZATION_URL`, `OAUTH_TOKEN_URL`, `OAUTH_USER_INFO_URL`, `OAUTH_SCOPES`, `OAUTH_DYNAMIC_CLIENT_REDIRECT_HOSTS`.
|
||||
|
||||
**Optional SMTP env vars (all required together to enable real sends):**
|
||||
- `SMTP_HOST`, `SMTP_PORT` (default 587), `SMTP_USER`, `SMTP_PASS`, `SMTP_FROM`, `SMTP_SECURE` (default false). Fallback in `packages/email/src/transport.ts`: log to console with subject/body when SMTP is not fully configured.
|
||||
|
||||
**Optional storage env vars:**
|
||||
- `LOCAL_STORAGE_PATH` — Must be absolute when set.
|
||||
- `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_REGION` (default `us-east-1`), `S3_ENDPOINT`, `S3_BUCKET`, `S3_FORCE_PATH_STYLE` (default false).
|
||||
|
||||
**Optional feature flags:**
|
||||
- `FLAG_DISABLE_SIGNUPS` — Disables signups across all providers.
|
||||
- `FLAG_DISABLE_EMAIL_AUTH` — Disables email/password auth and verification flows.
|
||||
- `FLAG_DISABLE_IMAGE_PROCESSING` — Skips Sharp resize/encode (useful on resource-constrained hardware).
|
||||
|
||||
**Optional tooling env vars:**
|
||||
- `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN` for Crowdin translation sync.
|
||||
- `GOOGLE_CLOUD_API_KEY` — For `packages/scripts/fonts/generate.ts`.
|
||||
|
||||
**Secrets location:**
|
||||
- `.env` at the repo root (gitignored). `.env.example` provides documented defaults. Existence noted: `.env.local`, `.env.production` files present locally (contents not read; may contain secrets).
|
||||
- Turbo cache invalidation tied to env vars via `turbo.json` `globalEnv` whitelist.
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- OAuth provider callbacks served by Better Auth: `/api/auth/oauth2/callback/{providerId}` (e.g. `/api/auth/oauth2/callback/custom` configured in `packages/auth/src/config.ts`).
|
||||
- OAuth Authorization Server endpoints (when this app acts as an OAuth provider for MCP clients): handled by Better Auth `oauthProvider` plugin under `/api/auth/oauth2/*`. Login/consent pages at `/auth/oauth` (`apps/web/src/routes/auth/oauth.ts`).
|
||||
- `.well-known` discovery routes:
|
||||
- `apps/web/src/routes/[.]well-known/oauth-authorization-server.ts` and `.../oauth-authorization-server.$.ts`
|
||||
- `apps/web/src/routes/[.]well-known/oauth-protected-resource.ts` and `.../oauth-protected-resource.$.ts`
|
||||
- `apps/web/src/routes/[.]well-known/openid-configuration.ts`
|
||||
- `apps/web/src/routes/[.]well-known/mcp/server-card[.]json.ts`
|
||||
- Generic catch-all: `apps/web/src/routes/[.]well-known/$.ts`
|
||||
- MCP Streamable HTTP transport: `apps/web/src/routes/mcp/index.ts` (uses `WebStandardStreamableHTTPServerTransport`). Authenticates via OAuth Bearer or `x-api-key`.
|
||||
|
||||
**Outgoing:**
|
||||
- AI provider HTTPS requests (see "AI Providers" above) — initiated per user request from `packages/api/src/services/ai.ts`.
|
||||
- SMTP outbound (`packages/email/src/transport.ts`) for verification, password reset, and email-change confirmations.
|
||||
- S3 PUT/GET/DELETE/LIST through `@aws-sdk/client-s3` when S3 storage is configured.
|
||||
- Crowdin and Google Fonts requests only from CI / scripts, not from the runtime web app.
|
||||
|
||||
## API Endpoints Exposed by the App
|
||||
|
||||
| Endpoint | Handler | Purpose |
|
||||
|----------|---------|---------|
|
||||
| `/api/health` | `apps/web/src/routes/api/health.ts` | Liveness/readiness JSON (DB + storage). |
|
||||
| `/api/auth/*` | `apps/web/src/routes/api/auth.$.ts` | Better Auth handler (sessions, OAuth, passkey, JWKS, etc.). |
|
||||
| `/api/rpc/*` | `apps/web/src/routes/api/rpc.$.ts` | oRPC server (`packages/api/src/routers/index.ts`: `ai`, `auth`, `flags`, `resume`, `statistics`, `storage`). |
|
||||
| `/api/openapi/*` | `apps/web/src/routes/api/openapi.$.ts` | OpenAPI-style REST wrapper of the oRPC router; spec at `/api/openapi/spec.json`. Auth via `x-api-key`. |
|
||||
| `/mcp` | `apps/web/src/routes/mcp/index.ts` | MCP Streamable HTTP server (`@modelcontextprotocol/sdk`). |
|
||||
| `/uploads/{userId}/...` | `apps/web/src/routes/uploads/$userId.$.tsx` | Public read of stored images/PDFs with ETag + path validation. |
|
||||
| `/schema.json` | `apps/web/src/routes/schema[.]json.ts` | Resume JSON schema. |
|
||||
| `/.well-known/...` | `apps/web/src/routes/[.]well-known/*` | OAuth/OIDC/MCP discovery documents. |
|
||||
|
||||
## Rate Limiting
|
||||
|
||||
**Better Auth (production only, see `packages/auth/src/config.ts` `isRateLimitEnabled`):**
|
||||
- Global default: 60 req / 60s.
|
||||
- `/sign-in/email`: 5/60s; `/sign-up/email`: 3/60s.
|
||||
- `/request-password-reset`, `/send-verification-email`: 3/600s.
|
||||
- `/two-factor/verify-otp`, `/verify-totp`, `/verify-backup-code`: 5/600s.
|
||||
- `/is-username-available`: 20/60s.
|
||||
- OAuth provider endpoints: `register` 5/60s, `authorize` 30/60s, `token` 20/60s, `introspect` 60/60s, `revoke` 30/60s, `userinfo` 60/60s.
|
||||
- API keys: 1000 req / hour per key.
|
||||
- Source: `packages/utils/src/rate-limit.ts`.
|
||||
|
||||
**oRPC procedure-level (in-memory, production only, see `packages/api/src/middleware/rate-limit/index.ts`):**
|
||||
- `resumePassword`: 5 / 10min.
|
||||
- `pdfExport`: 5 / 60s.
|
||||
- `aiRequest`: 20 / 60s.
|
||||
- `jobsSearch`: 30 / 60s.
|
||||
- `jobsTestConnection`: 10 / 60s.
|
||||
- `storageUpload`: 20 / 60s.
|
||||
- `storageDelete`: 30 / 60s.
|
||||
- `resumeMutations`: 60 / 60s.
|
||||
- Keys are derived from authenticated user id when present, otherwise from the first trusted-IP header or a UA+language fingerprint.
|
||||
|
||||
## Optional Services Declared in Compose Files
|
||||
|
||||
| Service | Image | Purpose | File |
|
||||
|---------|-------|---------|------|
|
||||
| `postgres` | `postgres:latest` | Required PostgreSQL DB | `compose.dev.yml`, `compose.yml` |
|
||||
| `seaweedfs` | `chrislusf/seaweedfs:latest` | Optional S3-compatible storage for dev/prod | `compose.dev.yml`, `compose.yml` |
|
||||
| `seaweedfs_create_bucket` | `quay.io/minio/mc:latest` | One-shot init that creates `reactive-resume` bucket | `compose.dev.yml`, `compose.yml` |
|
||||
| `reactive_resume` | Built from `Dockerfile` | The app itself in prod compose | `compose.yml` |
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-05-11*
|
||||
@@ -1,210 +0,0 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript `^6.0.3` — All workspace code under `apps/web/src` and `packages/*/src`. Typechecked with the experimental TS native compiler `@typescript/native-preview` (`7.0.0-dev.20260510.1`) via `tsgo --noEmit`.
|
||||
- TSX (React 19) — UI components in `apps/web/src`, `packages/ui/src/components`, `packages/pdf/src/templates`, and email templates in `packages/email/src/templates`.
|
||||
|
||||
**Secondary:**
|
||||
- JavaScript (ESM, `"type": "module"`) — A handful of config files such as `commitlint.config.cjs` and `apps/web/postcss.config` style snippets.
|
||||
- JSON / JSONC — `package.json`, `tsconfig.json`, `biome.json`, `turbo.json`, `knip.json`, `apps/web/components.json`, `packages/schema/schema.json`, locale and webfont metadata.
|
||||
- SQL — Drizzle-generated migrations under `migrations/` (e.g. `migrations/20260507144406_fast_nova/`).
|
||||
- YAML — `compose.yml`, `compose.dev.yml`, `lefthook.yml`, `crowdin.yml`, `pnpm-workspace.yaml`.
|
||||
- Markdown — `AGENTS.md`, `README.md`, `SECURITY.md`, `LICENSE`, docs under `docs/`.
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js `24` — Pinned in `Dockerfile` via `ARG NODE_VERSION=24`. Single Node process exposes the web app on port `3000`.
|
||||
- Browser runtime — React 19 SSR + hydration via TanStack Start. PWA service worker registered from `apps/web/vite.config.ts`.
|
||||
|
||||
**Package Manager:**
|
||||
- pnpm `11.0.9` — Pinned in `package.json` `packageManager` field (with integrity hash). Managed via Corepack (`corepack enable`) per `AGENTS.md`.
|
||||
- Workspace topology: `pnpm-workspace.yaml` includes `apps/*` and `packages/*`.
|
||||
- Lockfile: `pnpm-lock.yaml` is present and committed at repo root.
|
||||
- `allowBuilds` in `pnpm-workspace.yaml`: `bcrypt`, `esbuild`, `lefthook`, `msw`, `sharp`.
|
||||
- `postcss` is pinned by an override to `^8.5.14` in `pnpm-workspace.yaml`.
|
||||
|
||||
**Build Orchestrator:**
|
||||
- Turborepo `^2.9.12` — `turbo.json` defines tasks (`build`, `dev`, `typecheck`, `test`, `db:generate`, `db:migrate`, `db:studio`, `lingui:extract`) and the `globalEnv` whitelist for cache invalidation.
|
||||
- The Docker build also pins `turbo@2.9.9` via `pnpm dlx` for the pruner stages.
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core (apps/web):**
|
||||
- React `^19.2.6` with `react-dom ^19.2.6` (from `apps/web/package.json`).
|
||||
- TanStack Start `^1.167.65` — Full-stack framework wiring Vite, Nitro, and React Router. Server handlers live in route files under `apps/web/src/routes`.
|
||||
- TanStack Router `^1.169.2` — File-based router. `apps/web/src/routeTree.gen.ts` is generated.
|
||||
- TanStack React Query `^5.100.9` with SSR bridge `@tanstack/react-router-ssr-query ^1.166.12`.
|
||||
- TanStack React Form `^1.32.0` and React Hotkeys `^0.10.0`.
|
||||
- Vite `^8.0.11` (rolldown-based) — `apps/web/vite.config.ts` orchestrates plugins.
|
||||
- Nitro `3.0.260429-beta` — Server framework used through `nitro/vite`. Plugins at `apps/web/plugins/1.migrate.ts` and `apps/web/plugins/2.storage.ts` run on Nitro startup.
|
||||
- `srvx ^0.11.15` — HTTP server runtime used by Nitro/TanStack Start.
|
||||
|
||||
**RPC / API:**
|
||||
- oRPC `^1.14.2` family — `@orpc/server`, `@orpc/client`, `@orpc/openapi`, `@orpc/json-schema`, `@orpc/zod`, `@orpc/tanstack-query`, `@orpc/experimental-ratelimit`.
|
||||
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server exposed at `/mcp` via `apps/web/src/routes/mcp/index.ts`.
|
||||
|
||||
**Auth:**
|
||||
- Better Auth `1.6.10` — Core auth framework configured in `packages/auth/src/config.ts`.
|
||||
- Better Auth plugins: `@better-auth/api-key ^1.6.10`, `@better-auth/drizzle-adapter ^1.6.10`, `@better-auth/infra ^0.2.6` (dashboard), `@better-auth/oauth-provider ^1.6.10`, `@better-auth/passkey ^1.6.10`, plus built-ins (`admin`, `jwt`, `twoFactor`, `username`, `genericOAuth`).
|
||||
- `jose ^6.2.3` — JWT verification for OAuth tokens (MCP authentication).
|
||||
- `bcrypt ^6.0.0` — Password hashing (10 rounds in `packages/auth/src/config.ts`).
|
||||
|
||||
**Database & ORM:**
|
||||
- Drizzle ORM `1.0.0-beta.22` with PostgreSQL driver `pg ^8.20.0` (node-postgres).
|
||||
- `drizzle-kit 1.0.0-beta.22` — Migration tooling. Config at `packages/db/drizzle.config.ts` (dialect `postgresql`, schema `./src/schema/index.ts`, out `../../migrations`).
|
||||
- `drizzle-zod 1.0.0-beta.14-a36c63d` — Schema-derived Zod validators in `packages/api`.
|
||||
|
||||
**PDF / Rendering:**
|
||||
- `@react-pdf/renderer ^4.5.1` with types `@react-pdf/types ^2.11.1` — Used by `packages/pdf/src/document.tsx` and all templates under `packages/pdf/src/templates/<name>/`.
|
||||
- `pdfjs-dist 5.7.284` — Client-side PDF rendering and parsing (browser-only paths).
|
||||
- `react-pdf-html ^2.1.5`, `node-html-parser ^7.1.0`, `phosphor-icons-react-pdf ^0.1.3`, `cjk-regex ^3.4.0` — PDF helpers and CJK fallback.
|
||||
- Font registration owned by `packages/pdf/src/hooks/use-register-fonts.ts`; webfont catalog at `packages/fonts/src/webfontlist.json`.
|
||||
|
||||
**UI / Styling:**
|
||||
- Base UI / shadcn-style components — `@base-ui/react ^1.4.1`, `shadcn ^4.7.0` (CLI), components live in `packages/ui/src/components/*.tsx`. Config at `apps/web/components.json` (style `base-nova`, base color `zinc`, icon library `phosphor`).
|
||||
- Tailwind CSS `^4.3.0` via `@tailwindcss/vite ^4.3.0` and `@tailwindcss/postcss ^4.3.0`. Plugin `@tailwindcss/typography ^0.5.19`. Global stylesheet at `packages/ui/src/styles/globals.css`.
|
||||
- PostCSS `^8.5.14` and `tw-animate-css ^1.4.0`.
|
||||
- `class-variance-authority ^0.7.1`, `clsx ^2.1.1`, `tailwind-merge ^3.6.0`.
|
||||
- Icons: `@phosphor-icons/react ^2.1.10`, `@phosphor-icons/web ^2.1.2`.
|
||||
- Fonts: `@fontsource-variable/ibm-plex-sans ^5.2.8` (shipped with `packages/ui`).
|
||||
- Theming: `next-themes ^0.4.6`.
|
||||
- Animation: `motion ^12.38.0`.
|
||||
- Toasts: `sonner ^2.0.7`.
|
||||
- Command palette: `cmdk ^1.1.1`.
|
||||
- Misc UI: `react-resizable-panels ^4.11.0`, `react-window ^2.2.7`, `react-zoom-pan-pinch ^4.0.3`, `qrcode.react ^4.2.0`, `@uiw/color-convert ^2.10.1`, `@uiw/react-color-colorful ^2.10.1`.
|
||||
|
||||
**Drag & Drop and Editing:**
|
||||
- `@dnd-kit/core ^6.3.1`, `@dnd-kit/sortable ^10.0.0`, `@dnd-kit/utilities ^3.2.2`.
|
||||
- TipTap `^3.23.1` editor with `starter-kit`, `pm`, `react`, plus extensions `color`, `highlight`, `table`, `text-align`, `text-style`.
|
||||
|
||||
**State / Validation / Patterns:**
|
||||
- Zustand `^5.0.13` — Client and AI state stores (`packages/ai/src/store.ts`).
|
||||
- Immer `^11.1.8`.
|
||||
- Zod `^4.4.3` — Validators across schema, api, env, ai, import, utils packages.
|
||||
- `ts-pattern ^5.9.0` — Pattern matching in API services.
|
||||
- `es-toolkit ^1.46.1` and `fuse.js ^7.3.0`.
|
||||
|
||||
**Internationalization (i18n):**
|
||||
- Lingui `^6.0.1` family — `@lingui/core`, `@lingui/react`, `@lingui/cli`, `@lingui/format-po`, `@lingui/vite-plugin`, `@lingui/babel-plugin-lingui-macro`.
|
||||
- Babel transformer chain via `@rolldown/plugin-babel ^0.2.3` and `babel-plugin-macros ^3.1.0`.
|
||||
- Locale config at `apps/web/lingui.config.ts` (source locale `en-US`, ~55 target locales, pseudo-locale `zu-ZA`).
|
||||
- Translation catalogs at `apps/web/locales/{locale}.po`. Crowdin sync configured in `crowdin.yml`.
|
||||
|
||||
**Email:**
|
||||
- React Email `^6.1.1` with `@react-email/ui ^6.1.1` — Templates in `packages/email/src/templates/auth.tsx`.
|
||||
- Nodemailer `^8.0.7` SMTP transport — `packages/email/src/transport.ts`.
|
||||
|
||||
**AI:**
|
||||
- Vercel AI SDK `ai ^6.0.177` and `@ai-sdk/react ^3.0.179`.
|
||||
- Provider SDKs: `@ai-sdk/openai ^3.0.63`, `@ai-sdk/anthropic ^3.0.76`, `@ai-sdk/google ^3.0.71`, `@ai-sdk/openai-compatible ^2.0.47`, `ollama-ai-provider-v2 ^3.5.0`.
|
||||
- Supported providers enumerated at `packages/ai/src/types.ts`: `openai`, `anthropic`, `gemini`, `vercel-ai-gateway`, `openrouter`, `ollama`.
|
||||
- JSON patch / repair: `fast-json-patch ^3.1.1`, `jsonrepair ^3.14.0`, `deepmerge-ts ^7.1.5`.
|
||||
|
||||
**Storage:**
|
||||
- AWS SDK v3 — `@aws-sdk/client-s3 ^3.1045.0` used in `packages/api/src/services/storage.ts`. Marked external in the rolldown build (see `apps/web/vite.config.ts`) and isolated into `packages/runtime-externals` for Docker.
|
||||
|
||||
**Image Processing:**
|
||||
- Sharp `^0.34.5` — Used in `packages/api/src/services/storage.ts` `processImageForUpload`. Disabled when `FLAG_DISABLE_IMAGE_PROCESSING` is true.
|
||||
|
||||
**Document Generation / Import:**
|
||||
- `docx ^9.6.1` — DOCX export utilities in `packages/utils/src/resume/docx/`.
|
||||
- JSON Resume importers in `packages/import` (`json-resume`, `reactive-resume-json`, `reactive-resume-v4-json`).
|
||||
|
||||
**HTML Sanitization:**
|
||||
- `dompurify ^3.4.2` — Used in `packages/utils/src/sanitize.ts`.
|
||||
- `@sindresorhus/slugify ^3.0.0`, `unique-names-generator ^4.7.1`, `uuid ^14.0.0`.
|
||||
|
||||
**PWA:**
|
||||
- `vite-plugin-pwa ^1.3.0` — Workbox-based service worker, manifest defined in `apps/web/src/libs/pwa`.
|
||||
|
||||
**Testing:**
|
||||
- Vitest `^4.1.5` with `@vitest/coverage-v8 ^4.1.5` (provider `v8`).
|
||||
- Shared config in `vitest.shared.ts`; per-package configs at `<pkg>/vitest.config.ts`.
|
||||
- DOM: `happy-dom ^20.9.0` (`disableJavaScriptFileLoading`, `disableCSSFileLoading`, navigation disabled in `vitest.shared.ts`).
|
||||
- Testing Library: `@testing-library/react ^16.3.2`, `@testing-library/dom ^10.4.1`, `@testing-library/jest-dom ^6.9.1`, `@testing-library/user-event ^14.6.1`.
|
||||
- Tests are co-located in each package's `src/**/*.{test,spec}.{ts,tsx}` and discovered automatically.
|
||||
|
||||
**Lint / Format / Tooling:**
|
||||
- Biome `^2.4.15` — Sole linter + formatter (`biome.json`: tabs, double quotes, line width 120, organized import groups, `useSortedClasses` for `clsx`/`cva`/`cn`). Pre-commit runs through Lefthook.
|
||||
- Lefthook `^2.1.6` — Git hooks defined in `lefthook.yml` (`biome check --write --unsafe` on staged JS/TS/JSON files; `commitlint --edit` on commit messages).
|
||||
- Commitlint `^21.0.0` with `@commitlint/config-conventional` (see `commitlint.config.cjs`).
|
||||
- Knip `^6.12.2` — Dead-code/unused-deps detection (`knip.json`).
|
||||
- `npm-check-updates ^22.1.1`.
|
||||
- `tsx ^4.21.0` — Used by `packages/scripts` for ad hoc TS scripts.
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- `@tanstack/react-start ^1.167.65` — App framework boundary; route server handlers depend on it.
|
||||
- `@orpc/server ^1.14.2` — Type-safe RPC; backbone of `/api/rpc` and `/api/openapi`.
|
||||
- `better-auth 1.6.10` — Identity, sessions, OAuth provider, MCP auth.
|
||||
- `drizzle-orm 1.0.0-beta.22` + `pg ^8.20.0` — All DB access.
|
||||
- `@react-pdf/renderer ^4.5.1` — Resume PDF generation.
|
||||
- `@aws-sdk/client-s3 ^3.1045.0` — S3-compatible object storage.
|
||||
- `@modelcontextprotocol/sdk ^1.29.0` — MCP server endpoint.
|
||||
- `ai ^6.0.177` + `@ai-sdk/*` — Resume AI features (analysis, parsing, chat).
|
||||
|
||||
**Infrastructure:**
|
||||
- `vite ^8.0.11` + `nitro 3.0.260429-beta` — Build and server runtime.
|
||||
- `turbo ^2.9.12` — Workspace task graph and caching.
|
||||
- `dotenv ^17.4.2` — Loaded by `packages/env/src/server.ts` for app/server code (drizzle-kit does NOT auto-load).
|
||||
- `@t3-oss/env-core ^0.13.11` — Server env validation in `packages/env/src/server.ts`.
|
||||
|
||||
## Configuration
|
||||
|
||||
**TypeScript:**
|
||||
- Root `tsconfig.json` extends `@reactive-resume/config/tsconfig.base.json` (`packages/config/tsconfig.base.json`).
|
||||
- Each package has its own `tsconfig.json` and runs `tsgo --noEmit`.
|
||||
- Apps/packages export source from `src/*.ts` via package.json `exports` — no `dist/` artifacts unless explicitly built (e.g. `apps/web/.output`).
|
||||
|
||||
**Build:**
|
||||
- `apps/web/vite.config.ts` orchestrates `tailwindcss`, `tanstackStart`, `viteReact`, `lingui`, `babel` (Lingui macro preset), `nitro` (with `1.migrate.ts` and `2.storage.ts` plugins), and `VitePWA`.
|
||||
- Rolldown externals: `bcrypt`, `sharp`, `@aws-sdk/client-s3` (kept out of the client/server bundle and provided by `packages/runtime-externals` in Docker).
|
||||
- Output: `apps/web/.output/server/index.mjs` (Nitro), public assets in `apps/web/.output/public`.
|
||||
|
||||
**Database:**
|
||||
- Drizzle config: `packages/db/drizzle.config.ts` — `dialect: "postgresql"`, schema in `packages/db/src/schema/index.ts`, migrations written to repo-root `migrations/`.
|
||||
- Client: `packages/db/src/client.ts` exposes singleton `db` plus `Pool` via `pg`, using `env.DATABASE_URL`.
|
||||
- Startup auto-migration: `apps/web/plugins/1.migrate.ts` runs `drizzle-orm/node-postgres/migrator` against the resolved `migrations/` folder.
|
||||
|
||||
**Environment:**
|
||||
- Server env contract validated by Zod in `packages/env/src/server.ts` (via `@t3-oss/env-core`).
|
||||
- `dotenv` auto-loads root `.env` from `packages/env/src/server.ts` using `findWorkspaceRoot()`.
|
||||
- Required: `APP_URL`, `DATABASE_URL`, `AUTH_SECRET`.
|
||||
- Cache invalidation env vars listed in `turbo.json` `globalEnv`.
|
||||
- `.env.example` present at repo root; `.env.local` and `.env.production` exist locally (contents not read — may contain secrets).
|
||||
|
||||
**Linting / Formatting:**
|
||||
- `biome.json` — Tabs, 120-col, double quotes, sorted Tailwind classes for `clsx|cva|cn`, organized import groups (`type` imports first, then Node built-ins, test packages, third-party, `@reactive-resume/**`, then aliases/relative).
|
||||
- `lefthook.yml` — Pre-commit Biome on staged files; commit-msg Commitlint.
|
||||
- `commitlint.config.cjs` — Conventional commits.
|
||||
- `knip.json` — Workspace-level ignores for `runtime-externals` external deps.
|
||||
|
||||
**Container / Compose:**
|
||||
- `Dockerfile` (multi-stage): `base` (Node 24 slim + corepack), `pruner` (turbo prune), `builder` (frozen lockfile install + `pnpm turbo run build --filter=web --force`), `runtime-pruner` + `runtime-deps` (deploy `@reactive-resume/runtime-externals` with native deps), final `runtime` image running `node .output/server/index.mjs`, HEALTHCHECK on `/api/health`.
|
||||
- `compose.dev.yml` — Dev `postgres:latest` + `seaweedfs:latest` (S3 emulator) + `seaweedfs_create_bucket` init container using `quay.io/minio/mc:latest`.
|
||||
- `compose.yml` — Same services plus the app container `reactive_resume` built from `Dockerfile`, with `data_network`/`storage_network` and S3 env defaults pointing at SeaweedFS.
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js 24, pnpm 11.0.9 (via Corepack), Docker (for Postgres / SeaweedFS).
|
||||
- Repo-local `data/` directory for local-storage mode (auto-created by `apps/web/plugins/2.storage.ts`).
|
||||
- `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||
- SMTP optional; without it `packages/email/src/transport.ts` logs the email to console.
|
||||
|
||||
**Production:**
|
||||
- Single Node 24 process on port 3000 (`apps/web/.output/server/index.mjs`).
|
||||
- Official Docker image listed in `Dockerfile` labels (`org.opencontainers.image.url=https://rxresu.me`).
|
||||
- Production uses S3-compatible storage when all three of `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, `S3_BUCKET` are set; otherwise falls back to local filesystem under `/app/data` (per Dockerfile `ENV LOCAL_STORAGE_PATH=/app/data`).
|
||||
- HEALTHCHECK polls `GET /api/health` (handler at `apps/web/src/routes/api/health.ts` checks DB and storage with a 1.5s timeout).
|
||||
- Rate limiting only active when `NODE_ENV=production` (see `packages/auth/src/config.ts` and `packages/api/src/middleware/rate-limit/index.ts`).
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-05-11*
|
||||
@@ -1,394 +0,0 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
reactive-resume/
|
||||
├── apps/
|
||||
│ └── web/ # Sole deployable application (TanStack Start / Vite / Nitro)
|
||||
│ ├── plugins/ # Nitro startup plugins (run on `pnpm dev` / `pnpm start`)
|
||||
│ ├── public/ # Static assets (PWA, opengraph, templates, screenshots)
|
||||
│ ├── locales/ # Lingui `.po` translation catalogs (~40 locales)
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # Reusable web components grouped by feature
|
||||
│ │ ├── dialogs/ # Global dialog system + dialog implementations
|
||||
│ │ ├── hooks/ # App-level React hooks
|
||||
│ │ ├── libs/ # Browser/server glue (auth, orpc, query, resume, theme, locale, pwa)
|
||||
│ │ ├── routes/ # File-based TanStack Router routes (UI + `server.handlers`)
|
||||
│ │ ├── router.tsx # Router factory (builds query client, context, SSR query integration)
|
||||
│ │ ├── routeTree.gen.ts # GENERATED — do not hand-edit
|
||||
│ │ ├── server.ts # Nitro fetch entry (wraps `react-start/server-entry`)
|
||||
│ │ └── index.css # Tailwind v4 entry
|
||||
│ ├── components.json # shadcn config
|
||||
│ ├── lingui.config.ts # i18n extract config
|
||||
│ ├── vite.config.ts # Vite + TanStack Start + Nitro + PWA + Lingui
|
||||
│ └── vitest.config.ts
|
||||
├── packages/
|
||||
│ ├── ai/ # AI prompts, Zustand store, patch-resume tool
|
||||
│ ├── api/ # oRPC routers, services, helpers, DTOs, rate-limit middleware
|
||||
│ ├── auth/ # Better Auth config and helpers
|
||||
│ ├── config/ # Shared tsconfig + vitest base configs
|
||||
│ ├── db/ # Drizzle client + schema (Postgres)
|
||||
│ ├── email/ # Nodemailer transport + react-email templates
|
||||
│ ├── env/ # `@t3-oss/env-core` server env (auto-loads root `.env`)
|
||||
│ ├── fonts/ # Google Fonts metadata
|
||||
│ ├── import/ # JSON Resume / Reactive Resume v3/v4 importers
|
||||
│ ├── pdf/ # React PDF document, fonts, 14 templates, shared primitives
|
||||
│ ├── runtime-externals/ # Declares bcrypt, sharp, @aws-sdk/client-s3 as runtime-only
|
||||
│ ├── schema/ # Zod schemas (resume data, templates, page, analysis, icons)
|
||||
│ ├── scripts/ # Standalone tsx scripts (db reset, font generation)
|
||||
│ ├── ui/ # Shared Base UI + shadcn-style components
|
||||
│ └── utils/ # Small focused helpers (color, date, html, sanitize, etc.)
|
||||
├── migrations/ # Drizzle-generated SQL + snapshots (one folder per migration)
|
||||
├── docs/ # Project documentation
|
||||
├── skills/ # Internal agent skill definitions
|
||||
├── data/ # Local-FS storage root (when S3 is unset) — gitignored runtime data
|
||||
├── .vite-hooks/ # Lefthook-managed git hooks (commit-msg, pre-commit)
|
||||
├── .github/ # GitHub Actions / issue templates
|
||||
├── .claude/ # Project-local Claude/Codex agent assets
|
||||
├── .codex/
|
||||
├── .planning/ # GSD planning + codebase maps (this directory)
|
||||
├── .vscode/
|
||||
├── compose.dev.yml # Postgres + SeaweedFS for local dev
|
||||
├── compose.yml # Production-shaped compose
|
||||
├── Dockerfile # Node 24 multi-stage build
|
||||
├── AGENTS.md # Canonical agent guide (symlinked from CLAUDE.md)
|
||||
├── README.md
|
||||
├── SECURITY.md
|
||||
├── LICENSE
|
||||
├── package.json # Root scripts (turbo run …) + workspace devDeps
|
||||
├── pnpm-workspace.yaml # `apps/*` + `packages/*`
|
||||
├── pnpm-lock.yaml
|
||||
├── turbo.json # Pipeline + globalEnv allowlist
|
||||
├── biome.json # Biome (lint + format) config
|
||||
├── knip.json # Dead-code/dep analysis config
|
||||
├── lefthook.yml # Git hooks
|
||||
├── commitlint.config.cjs
|
||||
├── crowdin.yml # Translation sync config
|
||||
├── tsconfig.json # Root TS solution config
|
||||
├── vitest.shared.ts / vitest.setup.ts # Shared vitest config and global setup
|
||||
├── .ncurc.cjs # npm-check-updates ignore list
|
||||
├── .env.example # Documented env vars
|
||||
└── .gitignore / .dockerignore
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`apps/web/`:**
|
||||
- Purpose: The only deployed app — TanStack Start / React 19 / Vite / Nitro.
|
||||
- Contains: Routes, components, dialogs, hooks, libs, server entry, build config.
|
||||
- Key files: `apps/web/vite.config.ts`, `apps/web/src/router.tsx`, `apps/web/src/server.ts`, `apps/web/src/routes/__root.tsx`, `apps/web/plugins/1.migrate.ts`, `apps/web/plugins/2.storage.ts`.
|
||||
|
||||
**`apps/web/src/routes/`:**
|
||||
- Purpose: TanStack Router file-based route tree. Each file is either a UI route, a server-only endpoint (`server.handlers`), or both.
|
||||
- Notable subtrees:
|
||||
- `__root.tsx` — global HTML shell, providers, head/meta, PWA scripts.
|
||||
- `_home/` — public marketing layout (`route.tsx`, `index.tsx`, `-sections/{hero,features,faq,testimonials,...}.tsx`).
|
||||
- `auth/` — login/register/2FA/password flows and OAuth callback (`oauth.ts`).
|
||||
- `dashboard/` — authenticated dashboard (`route.tsx`, `index.tsx`, `resumes/`, `settings/{profile,preferences,api-keys,authentication,integrations,job-search,danger-zone}.tsx`, `-components/{header,sidebar,functions}.{ts,tsx}`).
|
||||
- `builder/$resumeId/` — resume builder (`route.tsx` shell, `index.tsx` browser-only preview, `-components/`, `-sidebar/{left,right}/`, `-store/{section,sidebar}.ts`).
|
||||
- `$username/$slug.tsx` — public/shared resume route (`ssr: "data-only"`).
|
||||
- `api/` — server-only endpoints (`rpc.$.ts`, `auth.$.ts`, `health.ts`, `openapi.$.ts`, `uploads/$userId.$.ts`, `-helpers/resume-pdf.ts`).
|
||||
- `mcp/` — Model Context Protocol server (`index.ts`, `-helpers/{tools,resources,prompts,mcp-server-card,mcp-tool-names,tool-annotations}.ts`).
|
||||
- `[.]well-known/` — OAuth/OIDC/MCP discovery documents.
|
||||
- `uploads/$userId.$.tsx` — etag/security-validated file serving.
|
||||
- `templates/$.tsx` — template preview/download.
|
||||
- `schema[.]json.ts` — `/schema.json` endpoint.
|
||||
|
||||
**`apps/web/src/components/`:**
|
||||
- Purpose: Reusable, app-scoped components organized by domain.
|
||||
- Subdirectories:
|
||||
- `animation/` — Motion-driven UI (`comet-card`, `count-up`, `spotlight`, `text-mask`).
|
||||
- `command-palette/` — Cmd-K UI (`index.tsx`, `store.ts`, `pages/`).
|
||||
- `input/` — Custom inputs (`chip-input`, `color-picker`, `github-stars-button`, `icon-picker`, `rich-input`, `url-input`).
|
||||
- `layout/` — Error/loading/not-found screens, breakpoint indicator.
|
||||
- `level/`, `locale/`, `theme/`, `typography/` — combobox/toggle utilities for those concerns.
|
||||
- `resume/` — Builder preview surface (`preview.tsx`, `preview.browser.tsx`, `preview.shared.tsx`, `pdf-canvas.tsx`, `builder-resume-draft.ts`).
|
||||
- `ui/` — App-level UI extensions (`combobox.tsx`, `copyright.tsx`).
|
||||
- `user/` — User dropdown menu.
|
||||
|
||||
**`apps/web/src/libs/`:**
|
||||
- Purpose: Glue between UI and external systems.
|
||||
- Contents:
|
||||
- `auth/{client.ts,session.ts}` — Better Auth browser client + SSR session getter.
|
||||
- `orpc/client.ts` — Isomorphic oRPC client (in-process server + RPCLink browser).
|
||||
- `query/client.ts` — TanStack Query client factory.
|
||||
- `resume/` — Section, PDF, and ordering helpers (`make-section-item.ts`, `move-item.ts`, `section-actions.ts`, `section-title.ts`, `section-title-locale.ts`, `pdf-document.tsx`, `pdf-document.server.tsx`, `section.tsx`).
|
||||
- `theme.ts`, `locale.ts`, `pwa.ts`, `error-message.ts`, `tanstack-form.tsx` — direct app utilities.
|
||||
|
||||
**`apps/web/src/dialogs/`:**
|
||||
- Purpose: Global imperative dialog system.
|
||||
- Contents: `manager.tsx` (renders open dialogs), `store.ts` (Zustand store), then domain dialogs under `auth/`, `resume/{sections,template,index.tsx,import.tsx}`, `api-key/`.
|
||||
|
||||
**`apps/web/src/hooks/`:**
|
||||
- Purpose: Web-app hooks. (Shared cross-package hooks live in `packages/ui/src/hooks/`.)
|
||||
- Contents: `use-confirm.tsx`, `use-controlled-state.tsx`, `use-form-blocker.tsx`, `use-mobile.tsx`, `use-prompt.tsx`, `use-sync-form-values.ts`.
|
||||
|
||||
**`apps/web/plugins/`:**
|
||||
- Purpose: Nitro startup plugins.
|
||||
- Contents: `1.migrate.ts` (runs Drizzle migrations on boot), `2.storage.ts` (validates the local data dir when S3 isn't configured).
|
||||
|
||||
**`apps/web/public/`:**
|
||||
- Purpose: Static assets served at the site root.
|
||||
- Notable subdirs: `templates/{jpg,pdf}/` (per-template previews), `screenshots/` (PWA + marketing), `opengraph/`, `icon/`, `logo/`, `fonts/`, `photos/`, `sounds/`, `videos/`. Top-level files include `favicon.{ico,svg}`, `apple-touch-icon-180x180.png`, `manifest.webmanifest`, `pwa-{64,192,512}x.png`, `maskable-icon-512x512.png`, `robots.txt`, `sitemap.xml`, `funding.json`.
|
||||
|
||||
**`apps/web/locales/`:**
|
||||
- Purpose: Lingui `.po` translation catalogs (~40 languages, `en-US` is source).
|
||||
- Synced via `crowdin.yml` and `pnpm lingui:extract`.
|
||||
|
||||
**`packages/ai/`:**
|
||||
- Purpose: AI prompt assets, patch-resume tool, sanitize/extraction helpers.
|
||||
- Key paths: `packages/ai/src/prompts/{chat,analyze-resume,docx-parser,pdf-parser}-*.md`, `packages/ai/src/store.ts`, `packages/ai/src/tools/{patch-resume,patch-proposal}.ts`, `packages/ai/src/resume/{extraction-template,sanitize}.ts`.
|
||||
|
||||
**`packages/api/`:**
|
||||
- Purpose: Server-side business logic exposed over oRPC. The only place oRPC procedures live.
|
||||
- Key paths: `packages/api/src/context.ts` (auth context + `publicProcedure`/`protectedProcedure`), `packages/api/src/routers/{ai,auth,flags,resume,statistics,storage,index}.ts`, `packages/api/src/services/{resume,resume-events,storage,ai,auth,flags,statistics}.ts`, `packages/api/src/dto/resume.ts`, `packages/api/src/helpers/{resume-access,resume-access-policy}.ts`, `packages/api/src/middleware/rate-limit/index.ts`.
|
||||
|
||||
**`packages/auth/`:**
|
||||
- Purpose: Better Auth instance and types reused by web routes and API context.
|
||||
- Key paths: `packages/auth/src/config.ts` (Drizzle adapter, OAuth/passkey/2FA/api-key/JWT/MCP OAuth provider), `packages/auth/src/functions.ts`, `packages/auth/src/types.ts`.
|
||||
|
||||
**`packages/config/`:**
|
||||
- Purpose: Shared tsconfig and vitest base configs consumed via workspace devDep.
|
||||
- Key paths: `packages/config/tsconfig.base.json`, `packages/config/vitest.config.ts`.
|
||||
|
||||
**`packages/db/`:**
|
||||
- Purpose: Drizzle client and schema. Migration tooling but **migrations live in repo-root `migrations/`** (`packages/db/drizzle.config.ts` → `out: "../../migrations"`).
|
||||
- Key paths: `packages/db/src/client.ts` (singleton `pg.Pool` + `drizzle()` on `globalThis`), `packages/db/src/schema/{auth,resume,index}.ts`, `packages/db/src/relations.ts`.
|
||||
|
||||
**`packages/email/`:**
|
||||
- Purpose: SMTP transport + react-email templates.
|
||||
- Key paths: `packages/email/src/transport.ts`, `packages/email/src/templates/{auth,reset-password,verify-email,verify-email-change}.tsx`.
|
||||
|
||||
**`packages/env/`:**
|
||||
- Purpose: Type-safe server environment variables. Auto-loads the repo-root `.env` for app/server callers (drizzle-kit must export `DATABASE_URL` manually).
|
||||
- Key paths: `packages/env/src/server.ts`.
|
||||
|
||||
**`packages/fonts/`:**
|
||||
- Purpose: Generated Google Fonts metadata used by the typography combobox and React PDF font registration.
|
||||
- Key paths: `packages/fonts/src/index.ts`, `packages/fonts/src/webfontlist.json` (regenerated by `packages/scripts/fonts/generate.ts`).
|
||||
|
||||
**`packages/import/`:**
|
||||
- Purpose: Importers that convert external resume formats into the shared `ResumeData` schema.
|
||||
- Key paths: `packages/import/src/{json-resume,reactive-resume-json,reactive-resume-v4-json}.tsx`.
|
||||
|
||||
**`packages/pdf/`:**
|
||||
- Purpose: React PDF rendering — the same code paths used by the browser preview and the server-side PDF download.
|
||||
- Key paths: `packages/pdf/src/document.tsx`, `packages/pdf/src/context.tsx`, `packages/pdf/src/section-title.ts`, `packages/pdf/src/hooks/use-register-fonts.ts`, `packages/pdf/src/templates/index.ts`, `packages/pdf/src/templates/<name>/<Name>Page.tsx` (14 templates: `azurill`, `bronzor`, `chikorita`, `ditgar`, `ditto`, `gengar`, `glalie`, `kakuna`, `lapras`, `leafish`, `meowth`, `onyx`, `pikachu`, `rhyhorn`, `scizor`), shared primitives under `packages/pdf/src/templates/shared/` (`filtering.ts`, `rich-text.tsx`, `sections.tsx`, `primitives.tsx`, `picture.ts`, `page-size.ts`, `columns.ts`, `metrics.ts`, `meta-line.tsx`, `contact.ts`, `contact-item.tsx`, `level-display.tsx`, `section-links.ts`, `rich-text-html.ts`, `rich-text-spacing.ts`, `styles.ts`, `types.ts`, `context.tsx`).
|
||||
|
||||
**`packages/runtime-externals/`:**
|
||||
- Purpose: Vendors `bcrypt`, `sharp`, `@aws-sdk/client-s3` so they stay runtime-only (Vite externalizes them in `apps/web/vite.config.ts:55`).
|
||||
- No `src/` — `package.json` is the entire surface.
|
||||
|
||||
**`packages/schema/`:**
|
||||
- Purpose: Source-of-truth Zod schemas for resume data, templates, page settings, AI analysis, and icon catalog.
|
||||
- Key paths: `packages/schema/src/resume/{data,default,sample,analysis}.ts`, `packages/schema/src/templates.ts`, `packages/schema/src/page.ts`, `packages/schema/src/icons.ts`.
|
||||
|
||||
**`packages/scripts/`:**
|
||||
- Purpose: Standalone tsx scripts; no exports.
|
||||
- Key paths: `packages/scripts/database/reset.ts` (`pnpm --filter @reactive-resume/scripts db:reset`), `packages/scripts/fonts/generate.ts` (`pnpm --filter @reactive-resume/scripts fonts:generate`).
|
||||
|
||||
**`packages/ui/`:**
|
||||
- Purpose: Shared component library (Base UI primitives + shadcn-style wrappers, Tailwind v4 styles).
|
||||
- Key paths: `packages/ui/src/components/{dialog,dropdown-menu,command,resizable,form,tooltip,sonner,…}.tsx`, `packages/ui/src/hooks/{use-confirm,use-controlled-state,use-mobile,use-prompt}.tsx`, `packages/ui/src/styles/globals.css`.
|
||||
|
||||
**`packages/utils/`:**
|
||||
- Purpose: Pure utility functions used everywhere. Each helper has its own export path; do not import internal files.
|
||||
- Key paths: `packages/utils/src/{color,date,field,file,html,level,locale,network-icons,rate-limit,sanitize,string,style,url}.ts`, Node-only `packages/utils/src/{monorepo.node,url-security.node}.ts`, and `packages/utils/src/resume/{docx/index.ts,patch.ts}`.
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: Drizzle-generated migration directories (one per migration), kept at the repo root.
|
||||
- Layout: each `YYYYMMDDhhmmss_<adjective>_<noun>/` contains `migration.sql` (the SQL Drizzle will apply) and `snapshot.json` (Drizzle's internal state).
|
||||
- Applied by `apps/web/plugins/1.migrate.ts` on every server boot, and manually by `pnpm db:migrate`. Generated by `pnpm db:generate` (which writes here because of `out: "../../migrations"` in `packages/db/drizzle.config.ts`). Do not hand-edit `migration.sql`/`snapshot.json`.
|
||||
|
||||
**`data/` (and `apps/web/data/`):**
|
||||
- Purpose: Default local-filesystem storage root when S3 vars are unset. `<workspace>/data` is validated/created at boot by `apps/web/plugins/2.storage.ts`. Override with `LOCAL_STORAGE_PATH` (must be absolute).
|
||||
- `data/statistics/` and `apps/web/data/statistics/` exist as runtime byproducts; treat as gitignored runtime state.
|
||||
|
||||
**`.vite-hooks/`:**
|
||||
- Purpose: Lefthook-managed git hooks (`pre-commit` runs `biome check`, `commit-msg` runs commitlint). Configured by `lefthook.yml` and `commitlint.config.cjs`.
|
||||
|
||||
**`.planning/`:**
|
||||
- Purpose: GSD planning artifacts.
|
||||
- Subdirectories: `.planning/codebase/` (this directory — analysis docs).
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `apps/web/src/server.ts` — Nitro fetch entry.
|
||||
- `apps/web/src/router.tsx` — Router factory.
|
||||
- `apps/web/src/routes/__root.tsx` — Root route + global providers.
|
||||
- `apps/web/plugins/1.migrate.ts` — Migration on boot.
|
||||
- `apps/web/plugins/2.storage.ts` — Local storage validation on boot.
|
||||
|
||||
**Configuration:**
|
||||
- `apps/web/vite.config.ts` — Vite + TanStack Start + Nitro + PWA + Lingui.
|
||||
- `turbo.json` — Pipeline + `globalEnv` allowlist.
|
||||
- `pnpm-workspace.yaml` — Workspaces (`apps/*` + `packages/*`).
|
||||
- `biome.json` — Lint + format rules.
|
||||
- `knip.json` — Dead-code analysis config.
|
||||
- `lefthook.yml` — Git hooks.
|
||||
- `packages/db/drizzle.config.ts` — Drizzle migration config (writes to `../../migrations`).
|
||||
- `packages/env/src/server.ts` — Server env schema + `.env` loader.
|
||||
- `apps/web/lingui.config.ts` — i18n extract config.
|
||||
- `compose.dev.yml` / `compose.yml` — Postgres + SeaweedFS (dev) and prod-shaped compose.
|
||||
|
||||
**Core API:**
|
||||
- `packages/api/src/routers/index.ts` — Router root.
|
||||
- `packages/api/src/context.ts` — Auth resolver + procedure factories.
|
||||
- `packages/api/src/services/resume.ts` — Resume CRUD/patch/lock/password/duplication.
|
||||
- `packages/api/src/services/storage.ts` — S3 + local FS storage abstraction.
|
||||
- `packages/api/src/helpers/resume-access-policy.ts` — Visibility/redaction policy.
|
||||
|
||||
**Core data:**
|
||||
- `packages/db/src/schema/resume.ts` — Resume, statistics, analysis tables.
|
||||
- `packages/db/src/schema/auth.ts` — Better Auth tables.
|
||||
- `packages/db/src/client.ts` — Singleton `pg.Pool` + `drizzle()` client.
|
||||
- `packages/schema/src/resume/data.ts` — Canonical Zod schema for `ResumeData`.
|
||||
- `packages/schema/src/templates.ts` — Enum of template names.
|
||||
|
||||
**Core PDF:**
|
||||
- `packages/pdf/src/document.tsx` — `ResumeDocument` root component.
|
||||
- `packages/pdf/src/templates/index.ts` — Template registry.
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts` — Font registration + CJK fallbacks.
|
||||
- `packages/pdf/src/templates/shared/filtering.ts` — Shared section filtering.
|
||||
|
||||
**Auth:**
|
||||
- `packages/auth/src/config.ts` — Better Auth instance.
|
||||
- `apps/web/src/routes/api/auth.$.ts` — `/api/auth/*` handler with OAuth sanitization.
|
||||
- `apps/web/src/libs/auth/{client.ts,session.ts}` — Browser client + SSR session helper.
|
||||
|
||||
**Testing:**
|
||||
- `vitest.shared.ts`, `vitest.setup.ts` — Repo-wide setup (Testing Library, jest-dom, happy-dom env).
|
||||
- `packages/config/vitest.config.ts` — Reusable Vitest base.
|
||||
- `apps/web/vitest.config.ts` — Web app Vitest config.
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- Source files: `kebab-case.ts` / `kebab-case.tsx` (e.g. `resume-access-policy.ts`, `command-palette.tsx`).
|
||||
- Component PascalCase is reserved for component names *inside* files; filenames stay kebab-case (e.g. `Button` exported from `packages/ui/src/components/button.tsx`).
|
||||
- PDF template components are the one PascalCase exception: `packages/pdf/src/templates/azurill/AzurillPage.tsx` (kept that way because the directory name doubles as the template enum value).
|
||||
- Tests sit next to their subject: `foo.ts` ↔ `foo.test.ts`, `foo.tsx` ↔ `foo.test.tsx`.
|
||||
- Browser-only modules use a `.browser.tsx` suffix (e.g. `apps/web/src/components/resume/preview.browser.tsx`).
|
||||
- Server-only modules use a `.server.tsx` suffix when they live next to browser counterparts (e.g. `apps/web/src/libs/resume/pdf-document.server.tsx`).
|
||||
- Generated files use `.gen.ts` (e.g. `apps/web/src/routeTree.gen.ts`).
|
||||
- Node-only utilities use a `.node.ts` suffix (e.g. `packages/utils/src/monorepo.node.ts`, `packages/utils/src/url-security.node.ts`).
|
||||
|
||||
**Routes (TanStack Router file conventions):**
|
||||
- `$param.tsx` — dynamic path segment (e.g. `apps/web/src/routes/$username/$slug.tsx`).
|
||||
- `$.tsx` — splat (matches the rest of the path; used for `api/rpc/$`, `api/auth/$`, `[.]well-known/$`).
|
||||
- `_layout/` (underscore prefix) — pathless layout group (e.g. `apps/web/src/routes/_home/`).
|
||||
- `-folder/` (dash prefix) — colocated, non-route helpers (`-components/`, `-sidebar/`, `-store/`, `-sections/`, `-helpers/`). The router ignores these.
|
||||
- `[.]well-known` — escaped folder name for paths starting with a dot.
|
||||
- `name[.]json.ts` — escaped dot in a route filename (used for `/schema.json`).
|
||||
- `route.tsx` — layout/wrapper route at a directory level; `index.tsx` — index route inside it.
|
||||
|
||||
**Directories:**
|
||||
- `apps/<app>` and `packages/<name>` — kebab-case workspace members.
|
||||
- `packages/<name>/src/<subdomain>/<file>.ts` — every public path in `package.json` exports points into `src/`.
|
||||
|
||||
**Package names:** All workspace packages are scoped under `@reactive-resume/*` (`api`, `auth`, `db`, `ui`, etc.). The web app is just `web`.
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New oRPC procedure:**
|
||||
- Define in `packages/api/src/routers/<domain>.ts`, register it on the exported router map, and add corresponding business logic to `packages/api/src/services/<domain>.ts`.
|
||||
- If authenticated, prefer `protectedProcedure` from `packages/api/src/context.ts`.
|
||||
- If it mutates resumes, attach `resumeMutationRateLimit` from `packages/api/src/middleware/rate-limit/index.ts`.
|
||||
- Define input/output Zod schemas in `packages/api/src/dto/<domain>.ts` when reused.
|
||||
|
||||
**New database table or column:**
|
||||
- Add the table/column to `packages/db/src/schema/<file>.ts`, update `packages/db/src/relations.ts` if needed, re-export from `packages/db/src/schema/index.ts`.
|
||||
- Run `DATABASE_URL=... pnpm db:generate` to write a migration directory under `migrations/`.
|
||||
- Apply with `DATABASE_URL=... pnpm db:migrate` (or just start the app — `apps/web/plugins/1.migrate.ts` runs them on boot).
|
||||
|
||||
**New resume field or section:**
|
||||
- Update Zod first in `packages/schema/src/resume/data.ts` (and `default.ts` / `sample.ts` if applicable).
|
||||
- Adjust API DTOs (`packages/api/src/dto/resume.ts`), importers (`packages/import/src/*.tsx`), PDF templates and shared primitives (`packages/pdf/src/templates/...`), and the builder forms under `apps/web/src/routes/builder/$resumeId/-sidebar/`.
|
||||
|
||||
**New resume template:**
|
||||
- Add to the template enum in `packages/schema/src/templates.ts`.
|
||||
- Implement the page component at `packages/pdf/src/templates/<name>/<Name>Page.tsx` and register it in `packages/pdf/src/templates/index.ts`.
|
||||
- Drop static previews into `apps/web/public/templates/jpg/<name>.jpg` and `apps/web/public/templates/pdf/<name>.pdf`.
|
||||
|
||||
**New web route:**
|
||||
- Add the file under `apps/web/src/routes/...`. Use `$param.tsx` for dynamic segments, `_layout/` for pathless groups, and `-folder/` for colocated helpers.
|
||||
- The route tree regenerates into `apps/web/src/routeTree.gen.ts` — do not hand-edit.
|
||||
- For browser-only sub-routes, set `ssr: false` (see `apps/web/src/routes/builder/$resumeId/index.tsx`) or `ssr: "data-only"` (see `apps/web/src/routes/$username/$slug.tsx`).
|
||||
|
||||
**New server-only HTTP endpoint:**
|
||||
- Add a route file (typically under `apps/web/src/routes/api/`) and export `Route = createFileRoute(...)({ server: { handlers: { GET/POST/ANY: handler } } })`.
|
||||
- Import server-only deps (`@reactive-resume/db/client`, `@reactive-resume/api/services/*`) only inside the handler module so they stay out of the client bundle.
|
||||
|
||||
**New shared component:**
|
||||
- App-only: `apps/web/src/components/<group>/<name>.tsx` (with colocated test if behaviour is non-trivial).
|
||||
- Reused across packages: `packages/ui/src/components/<name>.tsx` (export via deep path `@reactive-resume/ui/components/<name>`).
|
||||
|
||||
**New shared utility:**
|
||||
- Add to `packages/utils/src/<topic>.ts` and an explicit `"./<topic>": "./src/<topic>.ts"` entry in `packages/utils/package.json` `exports`. Never import private files across packages.
|
||||
|
||||
**New dialog:**
|
||||
- Implement under `apps/web/src/dialogs/<group>/<name>.tsx`.
|
||||
- Register with the dialog store and ensure `apps/web/src/dialogs/manager.tsx` renders it.
|
||||
|
||||
**New AI prompt or tool:**
|
||||
- Prompts go in `packages/ai/src/prompts/<name>.md` (re-exported via `packages/ai/src/prompts.ts`).
|
||||
- Tools go in `packages/ai/src/tools/<name>.ts` and are exposed through the AI router in `packages/api/src/routers/ai.ts`.
|
||||
|
||||
**New MCP tool/resource/prompt:**
|
||||
- Register in `apps/web/src/routes/mcp/-helpers/{tools,resources,prompts}.ts`; reuse existing oRPC services for actual logic.
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`apps/web/src/routes/`:**
|
||||
- Purpose: Source for the file-based route tree.
|
||||
- Generated artifact: `apps/web/src/routeTree.gen.ts`.
|
||||
- Committed: Yes (the tree file is committed; regenerated by TanStack Router tooling on dev/build).
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: Drizzle-generated SQL + snapshots.
|
||||
- Generated: Yes (by `pnpm db:generate`).
|
||||
- Committed: Yes — do not hand-edit, but always commit new migration folders.
|
||||
|
||||
**`apps/web/.output/`:**
|
||||
- Purpose: Nitro/Vite production build output (server + client + PWA assets).
|
||||
- Generated: Yes (by `pnpm build`).
|
||||
- Committed: No (gitignored).
|
||||
|
||||
**`apps/web/locales/`:**
|
||||
- Purpose: Lingui `.po` catalogs.
|
||||
- Generated: `en-US.po` is the source; other locales are synced via Crowdin (`crowdin.yml`).
|
||||
- Committed: Yes.
|
||||
|
||||
**`data/` and `apps/web/data/`:**
|
||||
- Purpose: Default local storage root when S3 is unset (`<workspace>/data`).
|
||||
- Generated: Yes (at runtime by `apps/web/plugins/2.storage.ts` or service calls).
|
||||
- Committed: No (gitignored runtime state).
|
||||
|
||||
**`.turbo/`, `.pnpm-store/`, `node_modules/`:**
|
||||
- Purpose: Tool caches and dependency stores.
|
||||
- Committed: No.
|
||||
|
||||
**`.vite-hooks/`:**
|
||||
- Purpose: Lefthook-installed git hooks (`commit-msg`, `pre-commit`).
|
||||
- Committed: Yes (so contributors get hooks automatically), but the underlying behavior is defined by `lefthook.yml`.
|
||||
|
||||
**`packages/runtime-externals/`:**
|
||||
- Purpose: Marks `bcrypt`, `sharp`, `@aws-sdk/client-s3` as runtime dependencies that Vite externalizes.
|
||||
- No source files — package.json is the entire contract.
|
||||
|
||||
## Files / Locations to Avoid Hand-Editing
|
||||
|
||||
- `apps/web/src/routeTree.gen.ts` — regenerated by TanStack Router tooling.
|
||||
- `migrations/<timestamp>_<name>/migration.sql` and `snapshot.json` — generated by `drizzle-kit`. Add a new migration via `pnpm db:generate` instead of editing past ones.
|
||||
- `pnpm-lock.yaml` — managed by pnpm. Update via `pnpm install`.
|
||||
- `apps/web/.output/`, `apps/web/coverage/`, `packages/*/coverage/`, `packages/*/reports/` — build/test artifacts.
|
||||
- `apps/web/locales/*.po` (except `en-US.po`) — synced from Crowdin per `crowdin.yml`.
|
||||
- `apps/web/public/screenshots/`, `apps/web/public/opengraph/`, `apps/web/public/templates/{jpg,pdf}/` — regenerated assets; replace files wholesale rather than diff-editing.
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-05-11*
|
||||
@@ -1,253 +0,0 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-05-11
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:** Vitest 4.x.
|
||||
- Root devDependency in `package.json`: `"vitest": "^4.1.5"`, `"@vitest/coverage-v8": "^4.1.5"`.
|
||||
- Every workspace package and `apps/web` has its own `vitest.config.ts` that delegates to the shared factory at `vitest.shared.ts`.
|
||||
|
||||
**DOM environment:** `happy-dom` 20.x (migrated from jsdom in commit `7a60a42a0`). Default test environment per `vitest.shared.ts:19` is `"node"`; per-package configs opt into browser-like envs.
|
||||
|
||||
**Assertion / testing libraries** (root `package.json` devDependencies):
|
||||
|
||||
- `@testing-library/react` ^16.3.2
|
||||
- `@testing-library/dom` ^10.4.1
|
||||
- `@testing-library/jest-dom` ^6.9.1 — registered globally in `vitest.setup.ts:1`
|
||||
- `@testing-library/user-event` ^14.6.1
|
||||
|
||||
**Run commands** (root `package.json`):
|
||||
|
||||
```bash
|
||||
pnpm test # turbo run test → vitest run --passWithNoTests in every package
|
||||
pnpm test:coverage # turbo run test:coverage → adds --coverage flag
|
||||
pnpm test:ci # adds GitHub Actions, JSON, and JUnit reporters
|
||||
pnpm test:agent # agent-friendly reporter + JSON output for agentic runs
|
||||
```
|
||||
|
||||
Per-package commands (uniform across all packages, see `packages/api/package.json:13-19`):
|
||||
|
||||
```bash
|
||||
pnpm --filter @reactive-resume/utils test
|
||||
pnpm --filter @reactive-resume/api test
|
||||
pnpm --filter web test
|
||||
```
|
||||
|
||||
Vitest test paths are package-relative when filtering: `pnpm --filter @reactive-resume/utils test -- src/string.test.ts`.
|
||||
|
||||
## Shared Vitest Configuration
|
||||
|
||||
`vitest.shared.ts` exports `createVitestProjectConfig({ name, dirname, environment, plugins })`. Highlights:
|
||||
|
||||
- `root: dirname` — each package runs in isolation.
|
||||
- `envDir: workspaceRoot` — `.env` at repo root is loaded for every package.
|
||||
- `resolve: { tsconfigPaths: true }` — TS path aliases resolve from the package's own `tsconfig.json`.
|
||||
- `setupFiles: [./vitest.setup.ts]` — global hooks applied to all projects.
|
||||
- `include: ["src/**/*.{test,spec}.?(c|m)[jt]s?(x)"]` — both `.test.*` and `.spec.*` are picked up.
|
||||
- `exclude: ["node_modules", "dist", ".output", "coverage", "reports"]`.
|
||||
- `pool: "threads"`, `isolate: false` — fast threaded execution with shared module state inside a worker.
|
||||
- `passWithNoTests: true` — packages without tests don't fail CI.
|
||||
- `environmentOptions.happyDOM` disables JS/CSS file loading and navigation for safety.
|
||||
|
||||
Coverage is configured directly in `vitest.shared.ts:49-56`:
|
||||
|
||||
- Provider: `v8`
|
||||
- Output: `./coverage` per package
|
||||
- Reporters: `text`, `text-summary`, `json-summary`, `json`, `lcov`, `html`
|
||||
- Include: `src/**/*.{ts,tsx}`
|
||||
- Exclude: `src/**/*.{test,spec}.*`, `src/**/*.d.ts`, `src/routeTree.gen.ts`
|
||||
- `reportOnFailure: true`
|
||||
|
||||
No global coverage thresholds are enforced (no `thresholds: {...}` block). Per-package coverage HTML lives under each package's `coverage/` directory after `pnpm test:coverage`.
|
||||
|
||||
## Global Setup (`vitest.setup.ts`)
|
||||
|
||||
Applied to every project:
|
||||
|
||||
1. `import "@testing-library/jest-dom/vitest"` — registers `toBeInTheDocument`, `toHaveAttribute`, etc.
|
||||
2. `afterEach(() => cleanup())` — explicit RTL cleanup (Vitest doesn't expose `afterEach` globally without `test.globals: true`).
|
||||
3. Polyfills for jsdom/happy-dom gaps: `ResizeObserver`, `IntersectionObserver`, `Element.prototype.scrollIntoView`, `window.matchMedia` (used by `cmdk`, Base UI, `next-themes`).
|
||||
|
||||
## Per-Package Vitest Configs
|
||||
|
||||
All `vitest.config.ts` files reuse the shared factory. Notable variants:
|
||||
|
||||
- **Node default** (`packages/utils/vitest.config.ts`, `packages/api/vitest.config.ts`, `packages/db/vitest.config.ts`, `packages/schema/vitest.config.ts`, `packages/ai/vitest.config.ts`, `packages/email/vitest.config.ts`, `packages/fonts/vitest.config.ts`, `packages/env/vitest.config.ts`, `packages/auth/vitest.config.ts`, `packages/import/vitest.config.ts`, `packages/pdf/vitest.config.ts`, `packages/config/vitest.config.ts`) — `environment: "node"`.
|
||||
- **DOM** (`packages/ui/vitest.config.ts:7`) — `environment: "happy-dom"` for component tests.
|
||||
- **Web app** (`apps/web/vitest.config.ts`) — `environment: "node"` plus Vite plugins to mirror dev: `@tailwindcss/vite`, `@lingui/vite-plugin` (with `linguiTransformerBabelPreset`), and `@rolldown/plugin-babel`. Individual web tests opt into the DOM via the `@vitest-environment happy-dom` file-level comment.
|
||||
|
||||
Per-test environment overrides (declared at the top of the file as `// @vitest-environment happy-dom` or in a `/** @vitest-environment happy-dom */` block):
|
||||
|
||||
- `packages/utils/src/sanitize.test.ts`
|
||||
- `packages/utils/src/file.test.ts`
|
||||
- `apps/web/src/components/resume/preview.browser.test.tsx`
|
||||
- `apps/web/src/components/resume/preview.shared.test.tsx`
|
||||
- `apps/web/src/components/typography/combobox.test.tsx`
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:** Co-located with implementation. `foo.ts` lives next to `foo.test.ts` (or `foo.test.tsx` for React).
|
||||
|
||||
**Naming:**
|
||||
- `<name>.test.ts` — Node/pure logic (e.g. `packages/utils/src/string.test.ts`).
|
||||
- `<name>.test.tsx` — JSX/component tests (e.g. `packages/ui/src/components/button.test.tsx`).
|
||||
- `<name>.node.test.ts` — Node-only modules whose implementation is also `.node.ts` (e.g. `packages/utils/src/url-security.node.test.ts`, `packages/utils/src/monorepo.node.test.ts`).
|
||||
- No `.spec.*` files in the repo today, but the include pattern supports them.
|
||||
|
||||
**Test counts (current):** 127 `*.test.ts` files + 98 `*.test.tsx` files across `packages/` and `apps/web/src/`.
|
||||
|
||||
**Hot spots (where coverage is densest):**
|
||||
|
||||
- `packages/utils/src/*.test.ts` — string, color, html, date, level, locale, sanitize, rate-limit, field, file, network-icons, style, url, url-security.node, monorepo.node, plus `resume/patch.test.ts`.
|
||||
- `packages/ui/src/components/*.test.tsx` — ~30+ component tests (button, dialog, alert, badge, card, combobox, command, popover, scroll-area, sidebar, switch, tabs, textarea, toggle, tooltip, etc.).
|
||||
- `packages/pdf/src/templates/shared/*.test.ts` — columns, section-links, rich-text, metrics, picture, filtering.
|
||||
- `packages/pdf/src/section-title.test.ts` and `packages/pdf/src/hooks/use-register-fonts.test.ts`.
|
||||
- `packages/api/src/{dto,helpers,services}/*.test.ts` — `dto/resume`, `helpers/resume-access-policy`, `services/ai`.
|
||||
- `packages/schema/src/{templates,page}.test.ts` and `packages/schema/src/resume/{data,default}.test.ts`.
|
||||
- `packages/ai/src/{tools,resume}/*.test.ts` — patch-proposal, sanitize, extraction-template.
|
||||
- `packages/import/src/reactive-resume-v4-json.test.ts`, `packages/fonts/src/index.test.ts`.
|
||||
- `apps/web/src/libs/{pwa,locale,theme,error-message}.test.ts`, `apps/web/src/dialogs/store.test.ts`, `apps/web/src/components/resume/preview.{browser,shared}.test.tsx`, `apps/web/src/components/typography/combobox.test.tsx`.
|
||||
|
||||
## Test Structure Patterns
|
||||
|
||||
**Idiomatic skeleton** (from `packages/utils/src/string.test.ts:1-21` and `packages/api/src/helpers/resume-access-policy.test.ts:1-17`):
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { thingUnderTest } from "./thing";
|
||||
|
||||
describe("thingUnderTest", () => {
|
||||
it("returns X for Y", () => {
|
||||
expect(thingUnderTest(input)).toBe(expected);
|
||||
});
|
||||
|
||||
it("returns Z for empty input", () => {
|
||||
expect(thingUnderTest("")).toBe("");
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns observed:**
|
||||
|
||||
- Top-level `describe` per exported function; nested `describe` blocks group behaviors.
|
||||
- One assertion focus per `it` — short, declarative names ("returns X", "throws Y when Z", "does not mutate the input").
|
||||
- Negative cases are first-class — every helper has tests for `null`, empty string, unknown shapes, etc.
|
||||
- `it.each([...] as const)("variant=%s renders without throwing", (variant) => {...})` for matrix tests over discriminated union variants (see `packages/ui/src/components/button.test.tsx:55-79`).
|
||||
- Setup with `beforeEach` / `afterEach` is used only when needed (timers, temp dirs, store reset). Example: `apps/web/src/dialogs/store.test.ts:4-13` resets a Zustand store between tests with `useDialogStore.setState(...)`.
|
||||
- Temp directories use `fs.mkdtempSync` + `realpathSync` and are torn down in `afterEach` (`packages/utils/src/monorepo.node.test.ts:7-17`).
|
||||
- Fake timers via `vi.useFakeTimers()` / `vi.advanceTimersByTime(300)` for animation/transition assertions (`apps/web/src/dialogs/store.test.ts:54-65`).
|
||||
|
||||
## Mocking
|
||||
|
||||
**Library:** Vitest's built-in `vi` (no Jest). Used sparingly — most tests cover pure functions.
|
||||
|
||||
**Patterns:**
|
||||
|
||||
- `vi.fn()` for callback assertions: `expect(onClick).toHaveBeenCalledOnce()` (`packages/ui/src/components/button.test.tsx:32-37`).
|
||||
- `vi.fn().mockResolvedValue(true)` for async handlers (`apps/web/src/dialogs/store.test.ts:86-93`).
|
||||
- `vi.mock("module-path", () => ({ ... }))` for replacing modules. Heaviest example: `apps/web/src/components/resume/preview.browser.test.tsx:25-81` mocks `@react-pdf/renderer`, `@/libs/resume/pdf-document`, `./builder-resume-draft`, and `./pdf-canvas` so the preview component can be exercised without React PDF.
|
||||
- `vi.hoisted(() => ({ ... }))` to share mutable state with hoisted `vi.mock` factories (`apps/web/src/components/resume/preview.browser.test.tsx:8-12`). This is required because `vi.mock` calls are hoisted above imports.
|
||||
- Spies via `vi.spyOn` are rare; mock modules are preferred so the real implementation stays out of scope.
|
||||
|
||||
**Test data / fixtures:**
|
||||
|
||||
- No central `fixtures/` directory. Tests build minimal objects inline or extend canonical defaults from the schema package:
|
||||
- `import { defaultResumeData } from "@reactive-resume/schema/resume/default"` (used by `packages/api/src/helpers/resume-access-policy.test.ts:2`).
|
||||
- `import { sampleResumeData } from "@reactive-resume/schema/resume/sample"` (used by `apps/web/src/components/resume/preview.browser.test.tsx:5`).
|
||||
- Local helper builders are inlined per test file (e.g. the `resumeDataWithPageCount` helper at `apps/web/src/components/resume/preview.browser.test.tsx:14-23`).
|
||||
|
||||
## React Component Testing
|
||||
|
||||
**Render + query** via Testing Library (`packages/ui/src/components/button.test.tsx`):
|
||||
|
||||
```typescript
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
render(<Button onClick={onClick}>Click</Button>);
|
||||
await userEvent.click(screen.getByRole("button"));
|
||||
```
|
||||
|
||||
**Accessibility-first queries:** `getByRole("button", { name: "..." })` is the default; `aria-label` is asserted explicitly (`packages/ui/src/components/button.test.tsx:81-84`).
|
||||
|
||||
**Slot / data-attr conventions:** Components expose `data-slot` for shadcn/Base UI slotting and tests assert it (`button.test.tsx:22-25`).
|
||||
|
||||
**Async UI:** Use `waitFor` from `@testing-library/react` and `await userEvent.*`. The `cleanup()` afterEach in `vitest.setup.ts` ensures DOM doesn't leak between tests despite `isolate: false`.
|
||||
|
||||
## Reporters
|
||||
|
||||
Per-package `package.json` scripts (uniform pattern, e.g. `packages/api/package.json:15-18`):
|
||||
|
||||
- `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:agent` → `vitest run --reporter=agent --reporter=json --outputFile.json=reports/vitest-results.json --passWithNoTests`
|
||||
|
||||
The `agent` reporter is a Vitest 4 feature optimized for LLM-driven runs; `pnpm test:agent` is the canonical script to use when an agent needs structured pass/fail data.
|
||||
|
||||
JUnit + JSON outputs land in `reports/` inside each package (Biome-ignored).
|
||||
|
||||
## CI
|
||||
|
||||
- **`.github/workflows/autofix.yml`** — runs on every PR and push to `main`. It runs `pnpm knip --fix` and `pnpm check`. **It does NOT run `pnpm test`.** Tests are not currently gating CI.
|
||||
- **`.github/workflows/docker-build.yml`** — `workflow_dispatch` only; builds multi-arch images. No test step.
|
||||
- **`.github/workflows/crowdin-sync.yml`** — translation sync only.
|
||||
|
||||
Tests are run locally (`pnpm test`) or by agents via `pnpm test:agent` / `pnpm test:ci`. No PR is currently blocked by a failing test on GitHub Actions.
|
||||
|
||||
## Test Types
|
||||
|
||||
- **Unit tests** — Dominant. Pure functions in `packages/utils`, `packages/schema`, `packages/pdf/src/templates/shared`, `packages/api/src/{helpers,dto}` are exercised in isolation.
|
||||
- **Component tests** — `packages/ui/src/components/*.test.tsx` and a handful in `apps/web/src/components/`. Driven by `@testing-library/react` + `happy-dom` (file-level override) or `packages/ui`'s package-level `environment: "happy-dom"`.
|
||||
- **Integration tests** — None against the live Drizzle client or the running TanStack Start server. `apps/web/src/components/resume/preview.browser.test.tsx` is the closest, mocking React PDF and exercising the preview pipeline end-to-end in happy-dom.
|
||||
- **E2E / browser tests** — Not present. No Playwright, Cypress, or `vitest --browser` config.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async error testing:**
|
||||
|
||||
```typescript
|
||||
expect(() => assertCanView({ userId: "u1", isPublic: false }, null)).toThrow();
|
||||
try {
|
||||
assertCanView({ userId: "u1", isPublic: false }, null);
|
||||
expect.unreachable();
|
||||
} catch (error: unknown) {
|
||||
expect((error as { code?: string }).code).toBe("NOT_FOUND");
|
||||
}
|
||||
```
|
||||
(See `packages/api/src/helpers/resume-access-policy.test.ts:29-44`.)
|
||||
|
||||
**Immutability assertions:**
|
||||
|
||||
```typescript
|
||||
const before = JSON.stringify(resume);
|
||||
redactResumeForViewer(resume, false);
|
||||
expect(JSON.stringify(resume)).toBe(before);
|
||||
```
|
||||
(See `packages/api/src/helpers/resume-access-policy.test.ts:86-93`.)
|
||||
|
||||
**Time-sensitive logic:** UUIDv7 ordering is verified with a `setTimeout` and a string compare instead of mocking time (`packages/utils/src/string.test.ts:15-20`).
|
||||
|
||||
## Coverage Gaps Worth Flagging
|
||||
|
||||
These areas have implementation but no `*.test.*` files alongside them today — agents adding features here should consider adding tests.
|
||||
|
||||
- **`packages/email/src/transport.ts` and templates** — no tests. SMTP transport is untested.
|
||||
- **`packages/env/src/server.ts`** — no tests for env-var schema validation.
|
||||
- **`packages/auth/`** — no tests; Better Auth config and helpers are uncovered.
|
||||
- **`packages/api/src/services/{resume,storage,statistics,auth,flags,resume-events}.ts`** — only `ai.ts` has a service-level test (`ai.test.ts`). Most resume mutation flow logic is exercised only indirectly through `helpers/resume-access-policy.test.ts` and `dto/resume.test.ts`.
|
||||
- **`packages/api/src/routers/*`** — no router-level tests. End-to-end oRPC procedure behavior (auth + rate limit + service composition) is not asserted.
|
||||
- **`packages/api/src/middleware/rate-limit/*`** — no tests.
|
||||
- **`packages/db/src/schema/*`** — no tests (schema correctness is implicit, but no migration roundtrip tests).
|
||||
- **`packages/scripts/`** — no tests.
|
||||
- **`packages/runtime-externals/`** — no tests.
|
||||
- **`apps/web/src/routes/**`** — route handlers (`api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, `uploads/...`, `mcp/...`, `auth/oauth.ts`) and most builder UI under `routes/builder/$resumeId` are uncovered.
|
||||
- **`apps/web/src/dialogs/**`** — only `store.test.ts` covers the dialog store. Individual dialog components (resume create/update/import, two-factor, api-key) are uncovered.
|
||||
- **`packages/pdf/src/templates/{azurill,bronzor,...}`** — individual template renderers have no tests; only the shared primitives in `templates/shared/` are covered.
|
||||
|
||||
When adding tests in any of these areas, follow the colocation rule (`foo.ts` ↔ `foo.test.ts`) and reuse `defaultResumeData` / `sampleResumeData` from `@reactive-resume/schema/resume/*` instead of inventing new fixtures.
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-05-11*
|
||||
@@ -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,71 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
if [ "$LEFTHOOK" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
call_lefthook()
|
||||
{
|
||||
if test -n "$LEFTHOOK_BIN"
|
||||
then
|
||||
"$LEFTHOOK_BIN" "$@"
|
||||
elif lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
lefthook "$@"
|
||||
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
|
||||
else
|
||||
dir="$(git rev-parse --show-toplevel)"
|
||||
osArch=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
|
||||
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
|
||||
then
|
||||
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
|
||||
then
|
||||
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
|
||||
then
|
||||
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/lefthook/bin/index.js"
|
||||
then
|
||||
"$dir/node_modules/lefthook/bin/index.js" "$@"
|
||||
elif go tool lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
go tool lefthook "$@"
|
||||
elif bundle exec lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
bundle exec lefthook "$@"
|
||||
elif yarn lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
yarn lefthook "$@"
|
||||
elif pnpm lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
pnpm lefthook "$@"
|
||||
elif swift package lefthook >/dev/null 2>&1
|
||||
then
|
||||
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
|
||||
elif command -v mint >/dev/null 2>&1
|
||||
then
|
||||
mint run csjones/lefthook-plugin "$@"
|
||||
elif uv run lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
uv run lefthook "$@"
|
||||
elif mise exec -- lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
mise exec -- lefthook "$@"
|
||||
elif devbox run lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
devbox run lefthook "$@"
|
||||
else
|
||||
echo "Can't find lefthook in PATH"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
call_lefthook run "commit-msg" "$@"
|
||||
@@ -1,71 +0,0 @@
|
||||
#!/bin/sh
|
||||
|
||||
if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then
|
||||
set -x
|
||||
fi
|
||||
|
||||
if [ "$LEFTHOOK" = "0" ]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
call_lefthook()
|
||||
{
|
||||
if test -n "$LEFTHOOK_BIN"
|
||||
then
|
||||
"$LEFTHOOK_BIN" "$@"
|
||||
elif lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
lefthook "$@"
|
||||
elif /Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
/Users/amruth/Projects/reactive-resume/node_modules/.pnpm/lefthook-darwin-arm64@2.1.6/node_modules/lefthook-darwin-arm64/bin/lefthook "$@"
|
||||
else
|
||||
dir="$(git rev-parse --show-toplevel)"
|
||||
osArch=$(uname | tr '[:upper:]' '[:lower:]')
|
||||
cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')
|
||||
if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook"
|
||||
then
|
||||
"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook"
|
||||
then
|
||||
"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook"
|
||||
then
|
||||
"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook" "$@"
|
||||
elif test -f "$dir/node_modules/lefthook/bin/index.js"
|
||||
then
|
||||
"$dir/node_modules/lefthook/bin/index.js" "$@"
|
||||
elif go tool lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
go tool lefthook "$@"
|
||||
elif bundle exec lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
bundle exec lefthook "$@"
|
||||
elif yarn lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
yarn lefthook "$@"
|
||||
elif pnpm lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
pnpm lefthook "$@"
|
||||
elif swift package lefthook >/dev/null 2>&1
|
||||
then
|
||||
swift package --build-path .build/lefthook --disable-sandbox lefthook "$@"
|
||||
elif command -v mint >/dev/null 2>&1
|
||||
then
|
||||
mint run csjones/lefthook-plugin "$@"
|
||||
elif uv run lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
uv run lefthook "$@"
|
||||
elif mise exec -- lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
mise exec -- lefthook "$@"
|
||||
elif devbox run lefthook -h >/dev/null 2>&1
|
||||
then
|
||||
devbox run lefthook "$@"
|
||||
else
|
||||
echo "Can't find lefthook in PATH"
|
||||
fi
|
||||
fi
|
||||
}
|
||||
|
||||
call_lefthook run "pre-commit" "$@"
|
||||
Vendored
+1
-1
@@ -1,3 +1,3 @@
|
||||
{
|
||||
"recommendations": ["biomejs.biome", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
||||
"recommendations": ["oxc.oxc-vscode", "bradlc.vscode-tailwindcss", "typescriptteam.native-preview"]
|
||||
}
|
||||
|
||||
Vendored
+8
-6
@@ -1,10 +1,8 @@
|
||||
{
|
||||
"biome.enabled": true,
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.biome": "explicit",
|
||||
"source.organizeImports.biome": "explicit"
|
||||
"source.fixAll.oxc": "explicit"
|
||||
},
|
||||
"editor.defaultFormatter": "biomejs.biome",
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode",
|
||||
"files.readonlyInclude": {
|
||||
"**/locales/**.po": true,
|
||||
"**/routeTree.gen.ts": true,
|
||||
@@ -26,6 +24,10 @@
|
||||
["cva\\(([^)]*)\\)", "[\"'`]([^\"'`]*).*?[\"'`]"],
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true
|
||||
"tailwindCSS.experimental.configFile": "packages/ui/src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "oxc.oxc-vscode"
|
||||
},
|
||||
"editor.formatOnSave": true
|
||||
}
|
||||
|
||||
@@ -1,107 +1,240 @@
|
||||
# AGENTS.md
|
||||
# Reactive Resume: agent instructions
|
||||
|
||||
## Cursor Cloud specific 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/).
|
||||
|
||||
### Overview
|
||||
<!-- caveman-begin -->
|
||||
|
||||
Reactive Resume is a pnpm monorepo (Turborepo) with a single full-stack web app at `apps/web` (TanStack Start / React 19 / Vite) and ~15 internal packages under `packages/`. It runs as a single Node.js process on port 3000.
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
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.
|
||||
Rules:
|
||||
|
||||
### Prerequisites
|
||||
- Drop: articles (a/an/the), filler (just/really/basically), pleasantries, hedging
|
||||
- Fragments OK. Short synonyms. Technical terms exact. Code unchanged.
|
||||
- Pattern: [thing] [action] [reason]. [next step].
|
||||
- Not: "Sure! I'd be happy to help you with that."
|
||||
- Yes: "Bug in auth middleware. Fix:"
|
||||
|
||||
- **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.0.9** is managed via corepack (`corepack enable`).
|
||||
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
|
||||
Stop: "stop caveman" or "normal mode"
|
||||
|
||||
### Codebase map
|
||||
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
|
||||
|
||||
- `apps/web` is the only app. It owns TanStack Start routes, Vite/Nitro config, PWA setup, oRPC client wiring, route-level server handlers, and the resume builder UI.
|
||||
- `packages/api` contains oRPC routers, services, DTOs, storage, resume access policy, statistics, AI services, and rate limiting. The web route `apps/web/src/routes/api/rpc.$.ts` exposes these routers at `/api/rpc`.
|
||||
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The web route `apps/web/src/routes/api/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, and template implementations.
|
||||
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
|
||||
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, `packages/scripts`, `packages/config`, and `packages/runtime-externals` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
|
||||
### Web app conventions
|
||||
<!-- BEGIN:turborepo-agent-rules -->
|
||||
|
||||
- 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-only route handlers use route `server.handlers` blocks, for example `api/rpc.$.ts`, `api/auth.$.ts`, `api/health.ts`, uploads, schema, OpenAPI, MCP, and `.well-known` 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 is split across `apps/web/src/components/resume/preview.tsx`, `preview.browser.tsx`, `pdf-canvas.tsx`, and shared helpers. Keep PDF.js/canvas/browser APIs out of SSR paths.
|
||||
- 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.
|
||||
# This is NOT the Turborepo you know
|
||||
|
||||
### Package and feature boundaries
|
||||
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`.
|
||||
|
||||
- Add new API procedures in `packages/api/src/routers/*` and keep business logic in `packages/api/src/services/*` or helpers. 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 `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}`.
|
||||
- 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.
|
||||
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
|
||||
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.
|
||||
|
||||
### Database
|
||||
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 -->
|
||||
|
||||
PostgreSQL runs via Docker Compose:
|
||||
## Agent skills
|
||||
|
||||
```
|
||||
sudo docker compose -f compose.dev.yml up -d postgres
|
||||
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
||||
- Check `git status --short` before editing. Preserve unrelated changes, including existing edits in this file.
|
||||
- Use scripts and configuration as the source of truth when documentation disagrees with them.
|
||||
|
||||
## Overview
|
||||
|
||||
Reactive Resume is a free, open-source resume builder for creating, importing, exporting, and sharing resumes, cover letters, and job applications. It is a TypeScript pnpm monorepo managed by Turborepo, with two apps: `apps/web` (React 19 SPA with TanStack Router, TanStack Query, Tailwind CSS, and Vite) and `apps/server` (Hono / Node.js). oRPC connects browser workflows to server business logic; Better Auth handles authentication; Drizzle accesses PostgreSQL. Forme renders PDFs in the browser and on the server.
|
||||
|
||||
The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app. On Vercel, the `frontend` service serves static assets through its CDN and the `backend` service runs the same Hono application in a Node.js Function.
|
||||
|
||||
Internal packages are source-consumed through `package.json` export maps pointing at `src` files. Do not assume package-local `dist` output exists unless a package explicitly adds it.
|
||||
|
||||
## Setup
|
||||
|
||||
Prerequisites: **Node.js 24** (`.nvmrc`, root `engines`, and Dockerfile), **pnpm 12.8.1** (root `packageManager`; pnpm self-manages to this version), and **Docker with Docker Compose** for local infrastructure. The Dockerfile's `ARG PNPM_VERSION` chooses its base image, not the project's pnpm version. Start your Docker daemon before running Compose.
|
||||
|
||||
Shared dependency versions live in the default `catalog` in `pnpm-workspace.yaml`. Use `catalog:` in workspace manifests when that shared range applies; keep intentional exact pins and peer dependency ranges explicit.
|
||||
|
||||
Run commands from the workspace root unless stated otherwise:
|
||||
|
||||
```sh
|
||||
pnpm install --frozen-lockfile
|
||||
test -e .env.local || cp .env.example .env.local
|
||||
docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket
|
||||
docker compose -f compose.dev.yml ps
|
||||
```
|
||||
|
||||
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||
|
||||
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. You must `export DATABASE_URL=...` before running migration commands, or set it in your shell profile.
|
||||
|
||||
The web app also runs migrations during Nitro startup via `apps/web/plugins/1.migrate.ts`. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. 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.
|
||||
|
||||
### 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` |
|
||||
| Generate migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:generate` |
|
||||
| Run migrations | `DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres" pnpm db:migrate` |
|
||||
| Dev server | `pnpm dev` (starts on port 3000) |
|
||||
| Web dev server only | `pnpm dev:web` |
|
||||
| Lint/format | `pnpm check` (Biome) |
|
||||
| Tests | `pnpm test` (Vitest) |
|
||||
| Build | `pnpm build` |
|
||||
| Typecheck | `pnpm typecheck` |
|
||||
|
||||
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||
Copy the environment template only when `.env.local` does not already exist. For host-run development, edit these values in `.env.local`; the template uses container hostnames:
|
||||
|
||||
```dotenv
|
||||
APP_URL=http://localhost:3000
|
||||
DATABASE_URL=postgresql://postgres:postgres@localhost:5432/postgres
|
||||
S3_ENDPOINT=http://localhost:8333
|
||||
REDIS_URL=redis://localhost:6379
|
||||
```
|
||||
|
||||
Set `AUTH_SECRET` to a generated secret (`openssl rand -hex 32`). If using saved AI providers or the assistant, also set a separate `ENCRYPTION_SECRET` of at least 32 characters. For database-only development, start just `postgres` and set `STORAGE_BACKEND=local` to avoid the template's S3 defaults.
|
||||
|
||||
## Development workflow
|
||||
|
||||
```sh
|
||||
pnpm dev
|
||||
pnpm dev:web
|
||||
pnpm db:generate
|
||||
pnpm db:migrate
|
||||
pnpm db:studio
|
||||
```
|
||||
|
||||
- `pnpm dev` runs Vite on `PORT` (default `3000`), Hono on `SERVER_PORT` (default `3001`), and the email template preview on `3002`. Vite proxies API requests to Hono. Vite supplies hot reload; `tsx watch` restarts the server.
|
||||
- `pnpm dev:web` starts only Vite; API workflows still need a server. If ports are busy, change `PORT` and `SERVER_PORT` consistently in `.env.local`; keep the email preview's `3002` port free when running all dev tasks.
|
||||
- Server startup applies migrations before initializing auth and serving traffic. `pnpm db:migrate` applies them without starting the app; `pnpm db:studio` opens the database UI.
|
||||
- After adding user-facing strings, use Lingui macros and run `pnpm lingui:extract`. Catalogs live in `apps/web/locales/*.po`; `pnpm pdf:translations` regenerates PDF translations. Root build/check scripts run PDF translation generation automatically.
|
||||
- `pnpm docs:gen` regenerates the OpenAPI spec and semantic CSS reference. Use it when changing those public surfaces.
|
||||
|
||||
## Ownership map
|
||||
|
||||
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 `globalPassThroughEnv` array and applicable test-task `env` arrays in `turbo.json`. Add deployment aliases in `packages/env/src/deployment.ts` when needed. Turborepo strict env mode filters unlisted injected variables from task processes.
|
||||
|
||||
## Environment and database
|
||||
|
||||
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 oxlint --deny-warnings apps/web/src/features/resume
|
||||
pnpm exec oxfmt --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 checks boundaries and affected-package typechecks, then runs all unit suites with `pnpm exec turbo run test:ci --concurrency=1` to avoid CPU contention in PDF/rate-limit suites. Unit/browser and Vercel workflows persist `.turbo/cache`; cached coverage and test reports restore to package-local output directories.
|
||||
- Real-database unit suites use `COVER_LETTER_TEST_DATABASE_URL` and `OAUTH_TEST_DATABASE_URL`; see `.github/workflows/e2e.yml` for isolated database setup. Never point test fixtures at production data.
|
||||
- After changing shared contracts, exports, or imports, check affected consumers and run `pnpm exec turbo boundaries`.
|
||||
|
||||
### Gotchas
|
||||
### Browser tests
|
||||
|
||||
- The dev server (`pnpm dev`) auto-runs migrations on startup via Nitro, so `pnpm db:migrate` is only strictly needed for first-time setup or after pulling new migration files.
|
||||
- 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`.
|
||||
- Oxlint checks code with its native React Compiler and accessibility rules. Oxfmt uses tabs, double quotes, 120-column lines, separated type import groups, and sorted Tailwind classes in `clsx`, `cva`, and `cn`. Use existing file naming and feature-local conventions.
|
||||
- **`pnpm check` modifies files**: it regenerates PDF translations, applies safe Oxlint fixes, runs Oxfmt, then fails on remaining lint errors or warnings. Review the diff; use `pnpm lint` and `pnpm format:check` for non-mutating checks.
|
||||
- Lefthook's pre-commit hook checks conflict markers, applies safe Oxlint fixes, runs Oxfmt, then checks staged files with warnings denied and stages the fixes. The commit-message hook enforces Conventional Commits (`fix:`, `feat:`, `docs:`, etc.).
|
||||
|
||||
## Linting and formatting for coding agents
|
||||
|
||||
- After code changes, run `pnpm exec oxlint --fix <changed paths>`, then `pnpm exec oxfmt <changed paths>`. Safe lint fixes run before formatting and import/Tailwind sorting. Keep side-effect import order intact.
|
||||
- Before finishing, run `pnpm lint:agent` (`oxlint --deny-warnings --format=agent`) and `pnpm format:check`. Fix diagnostics and recheck; do not disable rules merely to make a check pass. Any necessary inline suppression must name its rule and explain why.
|
||||
- Lint rules live in `.oxlintrc.json`; formatting and import groups live in `.oxfmtrc.json`. Generated route trees, build outputs, migrations, OpenAPI JSON, generated schema references, and byte-sensitive PDF CSS fixtures are excluded where appropriate.
|
||||
- `@shadcn/lint` is registered as a JS plugin. Its design-system rules are opt-in: configure them in `.oxlintrc.json` after choosing the policy. It discovers the shared UI exports and Tailwind theme through `apps/web/components.json` and `packages/ui/components.json`. See [available rules](https://github.com/shadcn-ui/lint#rules). Keep UI-specific policies scoped to the web app and UI package.
|
||||
- Async test doubles may return a Promise without awaiting; the test override permits this. Playwright fixture callbacks named `use` are not React hooks, so `rules-of-hooks` is disabled only in the fixture adapter. Path references in declaration shims remain supported. CSS is formatted by Oxfmt; Oxlint checks JavaScript/TypeScript rather than CSS declarations.
|
||||
- Editor setup is checked in under `.vscode/`: install the recommended Oxc extension for lint fixes and formatting on save. This workflow follows the [Oxc coding-agent guide](https://oxc.rs/docs/guide/usage/coding-agents.html). Restart Codex sessions after changing these instructions.
|
||||
|
||||
## Build and deployment
|
||||
|
||||
```sh
|
||||
pnpm build
|
||||
NODE_ENV=production pnpm start
|
||||
docker compose up -d --build
|
||||
```
|
||||
|
||||
- Build outputs: `apps/web/dist` (SPA/assets), `apps/web/dist-prerender` (localized marketing HTML), and `apps/server/dist` (`index.mjs` plus server/deployment chunks). `pnpm start` runs the built server; set `NODE_ENV=production` so it uses `PORT` instead of `SERVER_PORT`. Export runtime variables or provide root `.env`; `.env.local` is not loaded by `start`.
|
||||
- Production Compose loads `.env.example` then `.env`, not `.env.local`. Configure `.env` with container hostnames (`postgres`, `redis`, `seaweedfs`) and production secrets before running it. The Docker image runs as `node`, listens on `3000`, and persists local storage through `/app/data`. Health endpoint: `/api/health`.
|
||||
- `vercel.json` defines Vercel Services (project framework must be `Services`): `frontend` (`apps/web`, static `dist`) and `backend` (`apps/server`, entrypoint `apps/server/vercel.mjs` re-exporting the tsdown build). The backend build runs `pnpm build` for both apps, then `node apps/server/dist/prepare-deployment.mjs`. Top-level rewrites send paths whose last segment has a file extension to `frontend` and everything else, including HTML shells, to `backend`, except the server-owned paths listed first. The Function uses Node 24 and a 300-second budget. `outputDirectory: "."` on `backend` stops the builder from treating `dist/index.mjs` (the Docker entrypoint) as the handler.
|
||||
- Vercel environment normalization accepts `POSTGRES_URL`, direct/unpooled DB aliases, and `KV_URL`. `APP_URL` can be derived from Vercel host variables. Blob is the default when no S3 credentials are set. Preview deployments require isolated resources before enabling `ALLOW_PREVIEW_MIGRATIONS=true`; see `docs/self-hosting/vercel.mdx`.
|
||||
- `.github/workflows/e2e.yml` gates core unit/browser flows; `vercel.yml` builds and checks the serverless artifact on PRs and pushes to `main`. `autofix.yml` runs write-capable `pnpm knip --fix` and `pnpm check`. GitHub runners are the default; `USE_BLACKSMITH=true` switches runners and paired actions.
|
||||
- `docker-build.yml` publishes native AMD64/ARM64 images. `main` publishes nightly aliases; release tags/explicit release dispatch publish stable aliases and can trigger configured production integrations. See `docs/agents/container-publishing.md` before release work.
|
||||
- Deployment smoke tests create/delete accounts and files; run only against a dedicated test installation. Details: `docs/contributing/deployment-checks.mdx`.
|
||||
|
||||
## Security and pull requests
|
||||
|
||||
- Keep credentials and personal resume data out of source, logs, test artifacts, issues, and PRs. Do not commit local environment files or substitute production secrets for test values.
|
||||
- Authenticated procedures use `protectedProcedure`; enforce resource ownership in feature logic. Reuse shared auth resolution for API keys, bearer tokens, and cookies rather than adding a separate auth path.
|
||||
- Keep unsafe OAuth redirect/AI URL flags disabled on public deployments. They relax redirect validation and SSRF protections for trusted self-hosted/test use.
|
||||
- Keep PRs focused. Describe the problem, resulting behavior, and checks actually run; link the relevant GitHub issue. Conventional Commits are enforced for commit messages; no separate PR-title convention is configured.
|
||||
- Before submitting, run applicable typechecks/tests and non-mutating lint checks; run the production build for runtime/bundling changes. Match CI's database/browser prerequisites when reproducing its checks. Report skipped checks and failures instead of claiming they passed.
|
||||
- Never add AI attribution, co-author trailers naming AI tools, or session/chat links to commits or PR descriptions.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- 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,294 @@
|
||||
---
|
||||
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 warm, quiet interface around bright paper. Moss green marks primary actions, selection, and progress. Light and dark themes keep document paper 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 }
|
||||
field-label: { fontFamily: Hanken Grotesk, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||
body: { fontFamily: Hanken Grotesk, fontSize: 15px, lineHeight: 24px, fontWeight: 400 }
|
||||
ui: { fontFamily: Hanken Grotesk, fontSize: 14px, lineHeight: 20px, fontWeight: 400 }
|
||||
small: { fontFamily: Hanken Grotesk, fontSize: 13px, lineHeight: 18px, fontWeight: 400 }
|
||||
caption: { fontFamily: Hanken Grotesk, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||
mono: { fontFamily: JetBrains Mono, fontSize: 12px, lineHeight: 16px, fontWeight: 500 }
|
||||
rounded:
|
||||
sm: 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
|
||||
4xl: 24px
|
||||
full: 999px
|
||||
spacing: [4, 8, 12, 16, 24, 32, 48, 64]
|
||||
motion:
|
||||
quick: 120ms
|
||||
standard: 200ms
|
||||
emphasized: 320ms
|
||||
easing: cubic-bezier(0.2, 0.8, 0.2, 1)
|
||||
exit: 70% of the entering duration
|
||||
movement-easing: cubic-bezier(0.77, 0, 0.175, 1)
|
||||
marketing:
|
||||
typography:
|
||||
display: { fontFamily: Anybody, fontWeight: "300–400", fontStretch: "86%–112%" }
|
||||
write-title: { fontFamily: Anybody, fontSize: "clamp(72px, 9vw, 160px)", lineHeight: 0.9, fontWeight: 300 }
|
||||
numeral:
|
||||
{ fontFamily: Anybody, fontSize: "clamp(56px, 7.5vw, 136px)", fontWeight: 300, fontVariantNumeric: tabular-nums }
|
||||
body: { fontFamily: Newsreader, fontSize: "16–21px", lineHeight: "1.45–1.5", fontWeight: 400 }
|
||||
accent: { fontFamily: Newsreader, fontStyle: italic, color: accent-text }
|
||||
label:
|
||||
{ fontFamily: Martian Mono, fontSize: 11px, fontWeight: 500, letterSpacing: 0.08em, textTransform: uppercase }
|
||||
wordmark: { fontFamily: Anybody, fontSize: 17.5cqw, lineHeight: 0.84, fontWeight: 800, fontStretch: 78% }
|
||||
colors:
|
||||
graphite: { light: "oklch(0.38 0.01 95 / .3)", dark: "oklch(0.9 0.01 95 / .18)" }
|
||||
night-1: "oklch(0.24 0.03 265)"
|
||||
night-2: "oklch(0.15 0.02 265)"
|
||||
night-accent: "oklch(0.8 0.13 150)"
|
||||
star: "oklch(0.72 0.14 80)"
|
||||
receipt: "#FDFCF8"
|
||||
bulb-glass: "oklch(0.95 0.11 92)"
|
||||
bulb-filament: "oklch(0.7 0.16 60)"
|
||||
shadow:
|
||||
paper:
|
||||
light: "0 1px 2px oklch(0.2 0.01 95 / 0.12), 0 40px 80px -30px oklch(0.2 0.01 95 / 0.5), 0 0 0 1px oklch(0.2 0.01 95 / 0.04)"
|
||||
dark: "0 1px 2px oklch(0 0 0 / 0.5), 0 40px 80px -30px oklch(0 0 0 / 0.8)"
|
||||
motion:
|
||||
pull-easing: cubic-bezier(0.3, 1.7, 0.5, 1)
|
||||
doodles: { opacity: { light: 0.72, dark: 0.42 }, darkFilter: "invert(1) brightness(1.1)" }
|
||||
---
|
||||
|
||||
## Overview
|
||||
|
||||
Reactive Resume 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 uses “Desk & Paper”: a warm, quiet interface around a bright resume or letter. Low-contrast surfaces, thin rules, and a moss-green accent keep attention on the document.
|
||||
|
||||
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.
|
||||
This reference covers the current app and homepage. Implementation details live in the source files named below; [REDESIGN_PLAN.md](REDESIGN_PLAN.md) records the redesign milestones and deviations from the original handoff.
|
||||
|
||||
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 guide decisions:
|
||||
|
||||
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.** Keep the live document visible while editing. Selecting a supported block opens its fields.
|
||||
2. **One obvious next step.** Each app view has one primary action. Reserve accent fills for that action, selection, and progress.
|
||||
3. **Nothing is lost.** Edits autosave, reversible changes offer undo, and confirmations are reserved for irreversible actions.
|
||||
4. **Detail on demand.** Make defaults useful; put advanced controls one disclosure deeper.
|
||||
5. **AI proposes, you decide.** Show AI edits as reviewable proposals before applying them.
|
||||
|
||||
## Colors
|
||||
Resume templates retain their Pokémon names, fonts, and palettes. App typography and control styling do not dictate template appearance.
|
||||
|
||||
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.
|
||||
`packages/ui/src/styles/globals.css` defines light tokens on `:root` and dark overrides on `.dark`. OKLCH values are authoritative; the front matter lists approximate sRGB equivalents. Tailwind exposes semantic utilities such as `bg-bg`, `bg-surface`, `border-line`, `text-ink`, and `bg-accent`.
|
||||
|
||||
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` for the app desk; `surface` for panels and cards; `raised` for menus, dialogs, and inputs; `sunken` for wells, tracks, and the page canvas.
|
||||
- **Text:** `ink` for primary text, `ink-2` for secondary text, and `ink-3` for metadata and placeholders. Use no lighter text token, and check contrast on tinted backgrounds.
|
||||
- **Signals:** `danger` for errors and irreversible actions; `warn` for issues to review; `info-soft` and `info-text` for neutral guidance. Success uses `accent-soft` and `accent-text`.
|
||||
- **Overlays:** `hover` and `press` are translucent interaction states; `scrim` and `scrim-sheet` dim the background behind layers.
|
||||
- **Paper:** `--paper` remains white in both themes. Switching the app theme must not invert documents.
|
||||
- **Stages:** `stage-saved`, `stage-applied`, `stage-screening`, `stage-interview`, `stage-offer`, and `stage-closed` identify application stages. Use small dots or stepper bars beside stage names.
|
||||
|
||||
Use semantic tokens in app code. Legacy names such as `background`, `foreground`, `primary`, `muted`, and `sidebar-*` are no longer defined.
|
||||
|
||||
## Typography
|
||||
|
||||
The 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.
|
||||
The front matter records the app type scale. Field labels use the separate `field-label` size.
|
||||
|
||||
- **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.
|
||||
- **Newsreader:** page, dialog, and sheet titles; empty-state headlines; large statistics. Use `font-display`.
|
||||
- **Hanken Grotesk:** functional UI and body text. Use `font-sans` or `font-ui`.
|
||||
- **JetBrains Mono:** shortcuts, URLs, slugs, filenames, counts, and section eyebrows. Use `font-mono`.
|
||||
- Field labels are 12px, medium weight, in `ink-2`, with a 6px gap above the control. Group eyebrows are 12px, semibold, uppercase, in `ink-3`, with 0.02em tracking.
|
||||
- Touch inputs use at least 16px text to prevent iOS zoom.
|
||||
- Fonts are self-hosted through `@fontsource-variable`.
|
||||
|
||||
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.
|
||||
## Iconography
|
||||
|
||||
Font rendering uses `antialiased` (grayscale AA) and `proportional-nums` across the board for clean rendering and properly spaced numerals in dates and phone numbers.
|
||||
App icons use **Material Symbols Rounded**, weight 300, through `Icon` from `@reactive-resume/ui/components/icon`. The self-hosted subset is defined in `packages/ui/src/icons/names.ts`.
|
||||
|
||||
## Layout
|
||||
To add a glyph:
|
||||
|
||||
### Builder (Three-Panel Workspace)
|
||||
1. Add its name to `names.ts`.
|
||||
2. Run `pnpm icons:build` to validate names and rebuild the subset and manifest.
|
||||
|
||||
The core builder uses a resizable three-panel layout powered by `react-resizable-panels`:
|
||||
Use 20px icons on desktop and 24px on touch interfaces. Outline is the default; reserve filled app icons for selected navigation. Marketing illustrations may use filled symbols, such as the GitHub star.
|
||||
|
||||
- **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).
|
||||
Pair icons with text. Back, close, more, undo/redo, history, assistant, and zoom controls may use `IconButton`, which requires an accessible label and supplies a tooltip with an optional shortcut. `Icon` is decorative (`aria-hidden`, `translate="no"`); CSS draws its glyph from `data-icon`, keeping the name out of text content. Directional arrows, chevrons, undo, and redo mirror in RTL layouts.
|
||||
|
||||
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).
|
||||
Icons inside resumes use Phosphor names stored in resume data; they remain separate from app icons.
|
||||
|
||||
### Dashboard
|
||||
## Space, shape, and elevation
|
||||
|
||||
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.
|
||||
- **Spacing:** use the 4px scale in the front matter. Cards typically have 16px padding, panels 16–24px, mobile pages 16px margins, and desktop pages 32–40px margins.
|
||||
- **Radius:** 6px for chips and small buttons; 8px for inputs and controls; 10px for list items; 12px for cards and menus; 16px for dialogs; 18px for mobile sheets. Use `rounded-full` for pills; `rounded-4xl` is 24px where needed.
|
||||
- **Elevation:** `shadow-e1` for cards; `shadow-e2` for menus and popovers; `shadow-e3` for dialogs, sheets, and toasts; `shadow-page` for editor paper.
|
||||
- **Controls:** button heights are 28px (`sm`), 36px (`default`), and 44px (`lg`). Icon button sizes range from 28px to 44px. Inputs grow from 36px to 44px on touch devices; `touch-target` expands smaller controls' hit areas to at least 44×44px.
|
||||
- **Layout variables:** `--editor-bar: 56px`, `--editor-panel: 400px`, `--app-sidebar: 240px`, `--sheet-share: 440px`, `--sheet-detail: 480px`, and `--assistant: 400px`.
|
||||
|
||||
### Landing Page
|
||||
## Motion
|
||||
|
||||
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.
|
||||
| Token | Duration | Use |
|
||||
| --------------------- | -------- | ------------------------------------------------------ |
|
||||
| `duration-quick` | 120ms | Hover, press, toggles, checkboxes, and focus |
|
||||
| `duration-standard` | 200ms | Menus, popovers, expansion, content swaps, and dialogs |
|
||||
| `duration-emphasized` | 320ms | Sheets, toasts, and the assistant column |
|
||||
|
||||
### Responsive Breakpoints
|
||||
- Entering and changing state use `ease-enter` (`cubic-bezier(0.2, 0.8, 0.2, 1)`). Exits take 70% of the entry duration.
|
||||
- Sliding indicators, reordering, and settling use `ease-in-out-strong` (`cubic-bezier(0.77, 0, 0.175, 1)`). Swipe-dismissed bottom sheets use `ease-drawer`.
|
||||
- Keep app motion brief and purposeful. Small entrance fades, status transitions, and the mobile tab indicator's spring are supported. Loading placeholders stay still; document reflow is never animated. Keyboard mode switches are instant.
|
||||
- Reduced motion sets duration tokens to 1ms and collapses CSS transitions and animations. Status spinners continue turning.
|
||||
- `apps/web/src/libs/motion.ts` mirrors CSS timings. `MotionConfig reducedMotion="user"` and `followReducedMotion()` make Motion animations respect the preference.
|
||||
|
||||
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.
|
||||
The homepage has separate motion rules below.
|
||||
|
||||
## Components
|
||||
|
||||
### Buttons
|
||||
Generic primitives live in `packages/ui/src/components`, using Base UI and cmdk for the command palette. Feature-specific UI belongs in its owning `apps/web` feature.
|
||||
|
||||
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`, `secondary`, `ghost`, `danger`, and `link`. `loading` adds a spinner, sets `aria-busy`, and blocks activation. Use a progress label such as “Preparing…”.
|
||||
- **Inputs:** `raised` background and `line-2` border; focus adds an accent border and a 3px `accent-soft` ring. Invalid fields use danger styling. Show errors after blur or submission, with an icon and a specific remedy.
|
||||
- **Switches:** prefer `SwitchRow` so the label is part of the target. Checkboxes are 18px with a 5px radius; radios are 18px with an 8px accent dot.
|
||||
- **Segments and tabs:** use `SegmentedControl` for 2–4 options in a radio group. Use `Tabs` to switch panels; set `TabsList variant="line"` for underline tabs.
|
||||
- **Menus:** 12px radius and 36px items. Size popups for their content and trigger; put destructive items last, after a separator.
|
||||
- **Layers:** menus and popovers provide lightweight choices; sheets hold tasks beside the document and use the bottom variant on mobile; dialogs hold decisions. Use `AlertDialog` for destructive confirmation and name what cancel keeps.
|
||||
- **Toasts:** one visible at a time, bottom center, with `bg-ink` and `text-bg`. The default timeout is six seconds; `timeout: 0` keeps a toast visible. Undo is an optional underlined action.
|
||||
- **Alerts:** `info`, `success`, `warn`, and `error`; only the error variant adds `role="alert"` by default.
|
||||
- **Empty states:** a 22px Newsreader headline, concise 14px body text, and a primary action. Add a secondary action only when useful.
|
||||
|
||||
- **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.
|
||||
Target WCAG 2.2 AA:
|
||||
|
||||
### Cards
|
||||
- Show a 2px accent focus outline with a 2px gap on `:focus-visible`. Inputs use their border and soft ring instead.
|
||||
- Provide at least 24px pointer targets and 44px touch targets. Every drag operation needs keyboard and menu alternatives.
|
||||
- Pair color signals with text; add icons where they clarify status.
|
||||
- Trap focus in modal sheets and dialogs. Escape closes the top layer; closing returns focus to its trigger.
|
||||
- Announce save state and routine feedback politely. Reserve assertive announcements for errors that need immediate attention.
|
||||
|
||||
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 shared `theme` cookie stores `light`, `dark`, or `system` (the default). System mode follows `prefers-color-scheme` live. `ThemeProvider` manages the `.dark` class on `<html>`; an inline script in `apps/web/index.html` applies it before first paint. The homepage uses this same preference.
|
||||
|
||||
## Internationalization
|
||||
|
||||
The app supports 40+ locales including RTL languages (Arabic, Hebrew, Persian, Urdu, Uyghur, Yiddish). i18n is not an afterthought — it shapes layout decisions:
|
||||
- Translate user-facing text and accessible labels through Lingui (`t`, `msg`, or `<Trans>`). Catalogs live in `apps/web/locales`.
|
||||
- UI primitives receive translated labels as props, such as `closeLabel`; they do not depend on Lingui.
|
||||
- Supported locales come from `packages/utils/src/locale.ts`. The root route updates `<html lang>` and `<html dir>` and passes direction to Base UI's `DirectionProvider`.
|
||||
- Prefer logical properties and utilities (`ps`, `pe`, `ms`, `me`, `start`, `end`, `inset-s`, `inset-e`).
|
||||
- Allow 30–50% text expansion; avoid fixed widths for translated labels.
|
||||
|
||||
**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.
|
||||
## Marketing site
|
||||
|
||||
**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.
|
||||
The public homepage in `apps/web/src/features/homepage` shares app colors and themes but has its own typography, illustrations, and motion. `landing.css` defines its fonts, graphite color, paper shadows, and ambient animations. Other illustration colors in the front matter are scene values, not global app tokens.
|
||||
|
||||
**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.
|
||||
### Brand and type
|
||||
|
||||
**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.
|
||||
- **Header:** a 30px logomark from `apps/web/public/icon/{light,dark}.svg`. Keep “Reactive Resume” as visually hidden text inside the home link.
|
||||
- **Footer:** a 140px logo from `apps/web/public/logo/{light,dark}.svg`, followed by community and MIT license copy.
|
||||
- **Wordmark:** Anybody 800, at 17.5cqw and 78% width, with “Reactive” in `ink` and “Resume” in `accent-text`. A 23cqw container crops it through a gradient mask. It rises from 60% translation as the footer enters and is decorative (`aria-hidden`).
|
||||
- **Anybody:** hero and closing headlines, selected scene titles, large numerals, and the wordmark. Use weights 300–400 for main display text; reserve 800 for the wordmark.
|
||||
- **Newsreader:** body copy, italic accents, and serif title treatments in the Design scene. The app's `font-display` still maps to Newsreader; use `font-anybody` explicitly.
|
||||
- **Martian Mono:** 11px uppercase labels at weight 500, with 0.08em tracking. Use `font-martian`.
|
||||
- **Hanken Grotesk:** buttons and mock app UI. All four families are self-hosted.
|
||||
|
||||
**Strings:** All user-facing strings use Lingui macros (`t`, `msg`, `<Trans>`) — never hardcode English text in components. Translation files are `.po` format under `/locale/`.
|
||||
### Controls
|
||||
|
||||
## Do's and Don'ts
|
||||
- Repeat the same primary CTA, “Build your resume,” in the header, hero, and closing section. Buttons are pills in Hanken Grotesk 600, at 38px, 54px, and 56px respectively.
|
||||
- Scene navigation appears from 1240px. Martian Mono labels use `ink` when active and `ink-3` when inactive; a 6px accent dot marks the active scene. The Share night scene uses its own light inks.
|
||||
- The GitHub link appears from 1024px, with the GitHub mark, a filled gold star, and a live compact count in Martian Mono. Format counts with the locale's `Intl.NumberFormat`; include the full count in the accessible name.
|
||||
- The homepage theme control is a fixed 30px pull-cord button at the top end corner. Its height is 92px at rest, 112px on hover, and 124px during a pull. It uses a 450ms spring curve, toggles the shared theme after 170ms, and sways every seven seconds until first activated. The bulb glows in dark mode.
|
||||
|
||||
### Do
|
||||
### Motion
|
||||
|
||||
- **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.
|
||||
Pinned scenes contain a sticky `100svh` stage. Scroll progress is `p = clamp(0, −top / max(1, height − viewportHeight), 1)`; `scroll.ts` writes it to `--p` through one animation-frame-throttled scroll listener. CSS derives continuous motion from progress; React receives only coarse scene and step changes.
|
||||
|
||||
### Don't
|
||||
| Scene | Below 900px | From 900px |
|
||||
| ------ | ----------- | ---------- |
|
||||
| Hero | 190vh | 260vh |
|
||||
| Write | 280vh | 330vh |
|
||||
| Design | 380vh | 440vh |
|
||||
| Check | 260vh | 320vh |
|
||||
| Tailor | 280vh | 330vh |
|
||||
| Share | 240vh | 300vh |
|
||||
|
||||
- **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`).
|
||||
Light mode combines breathing window light (16 seconds), a drifting mullion shadow (90 seconds), and 18 dust motes. Dark mode adds a neutral 620px cursor glow at 6% opacity. These are decorative and must not obscure text.
|
||||
|
||||
Reduced motion fixes scenes at their end state and collapses pinned sections to `100svh`. Disable scroll scrubbing, parallax, ambient movement, cord sway, and count animations. The Languages word rotation has a pause control and stays still with reduced motion.
|
||||
|
||||
### Illustration
|
||||
|
||||
- Ten graphite doodles live in `apps/web/public/doodles/` as WebP: pencil, paperclip, eraser, curve, magnifier, scissors, plane, globe, jar, and note.
|
||||
- Doodles appear through a 110° mask wipe with subtle parallax. Default opacity is 0.72 in light mode and 0.42 in dark, with inversion and a slight brightness increase. The Share plane uses a brighter treatment against the night sky.
|
||||
- Most doodles hide below 900px; pencil and plane remain. Keep them decorative, noninteractive, and clear of readable text.
|
||||
- Construction guides use `graphite` lines with an SVG turbulence filter for a pencil effect.
|
||||
|
||||
### Content and delivery
|
||||
|
||||
- Prerender the homepage and public ATS checker per locale at build time through `prerender.tsx` and `apps/web/vite.config.ts`. The app is a client-rendered SPA; `apps/server/src/static/web.ts` serves the generated HTML and adds canonical, Open Graph, `hreflang`, and structured data. Keep headings and body copy in the initial HTML.
|
||||
- Include title and description metadata and `SoftwareApplication` structured data with a zero-price offer.
|
||||
- Use one `h1`, section headings, landmarks, and a skip link. Animated character treatments expose the complete string once to assistive technology. Nothing relies on hover alone.
|
||||
- Translate copy, accessible labels, and demo resume text through Lingui. Names and addresses may stay literal; the Languages display intentionally preserves native words and language names. Allow text expansion and RTL layouts.
|
||||
|
||||
## Do and don't
|
||||
|
||||
- **Do** make the primary action obvious and reserve accent for actions and state.
|
||||
- **Do** keep text readable and pair color signals with words.
|
||||
- **Do** provide empty, loading, error, and success states; keep loading placeholders at their final size.
|
||||
- **Don't** introduce arbitrary app colors or palette classes such as `amber-600`; use semantic tokens.
|
||||
- **Don't** use Newsreader for small functional app text. Homepage prose follows its separate type rules.
|
||||
- **Don't** confirm reversible actions; offer undo.
|
||||
- **Don't** omit `data-slot` on UI primitives; styles and tests rely on it.
|
||||
|
||||
+26
-18
@@ -1,22 +1,25 @@
|
||||
# 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
|
||||
ARG TURBO_VERSION=2.11.5
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
|
||||
|
||||
ARG NODE_VERSION
|
||||
ARG TURBO_VERSION
|
||||
|
||||
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm dlx turbo@2.9.9 prune web --docker
|
||||
pnpm dlx turbo@${TURBO_VERSION} prune web server --docker
|
||||
|
||||
FROM base AS builder
|
||||
COPY --from=pruner /app/out/json/ ./
|
||||
@@ -25,18 +28,18 @@ 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/.output && pnpm turbo run build --filter=web --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 . .
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm dlx turbo@2.9.9 prune @reactive-resume/runtime-externals --docker
|
||||
pnpm dlx turbo@${TURBO_VERSION} prune server --docker
|
||||
|
||||
FROM base AS runtime-deps
|
||||
COPY --from=runtime-pruner /app/out/json/ ./
|
||||
COPY --from=runtime-pruner /app/out/pnpm-lock.yaml ./pnpm-lock.yaml
|
||||
RUN --mount=type=cache,id=pnpm-store,target=/pnpm/store,sharing=locked \
|
||||
pnpm --filter=@reactive-resume/runtime-externals deploy --prod --legacy /runtime-deps
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS runtime
|
||||
|
||||
@@ -47,7 +50,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 \
|
||||
@@ -55,18 +58,23 @@ ENV NODE_ENV="production" \
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN mkdir -p /app/apps/web /app/data && chown node:node /app/data
|
||||
RUN mkdir -p /app/apps/server /app/apps/web /app/data && chown node:node /app/data
|
||||
|
||||
COPY --from=runtime-deps --chown=node:node /runtime-deps/node_modules ./node_modules
|
||||
COPY --from=builder --chown=node:node /app/apps/web/.output ./apps/web/.output
|
||||
COPY --from=runtime-deps --chown=node:node /app/node_modules ./node_modules
|
||||
COPY --from=pruner --chown=node:node /app/package.json /app/pnpm-lock.yaml /app/pnpm-workspace.yaml ./
|
||||
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
|
||||
|
||||
WORKDIR /app/apps/web
|
||||
WORKDIR /app
|
||||
|
||||
USER node
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
||||
CMD ["node", "-e", "fetch(`http://127.0.0.1:${process.env.PORT ?? 3000}/api/health`).then((r) => { if (!r.ok) process.exit(1); }).catch(() => process.exit(1));"]
|
||||
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
CMD ["node", "apps/server/dist/index.mjs"]
|
||||
|
||||
+16
-11
@@ -1,20 +1,22 @@
|
||||
# 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
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS dev
|
||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS dev
|
||||
|
||||
ARG NODE_VERSION
|
||||
|
||||
RUN pnpm runtime set node ${NODE_VERSION} -g --config.store-dir=/pnpm/runtime-store
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
PNPM_HOME="/pnpm" \
|
||||
PATH="/pnpm:$PATH" \
|
||||
NODE_ENV=development \
|
||||
ENV NODE_ENV=development \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml turbo.json ./
|
||||
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
|
||||
COPY packages/api/package.json ./packages/api/package.json
|
||||
@@ -26,17 +28,20 @@ COPY packages/env/package.json ./packages/env/package.json
|
||||
COPY packages/fonts/package.json ./packages/fonts/package.json
|
||||
COPY packages/import/package.json ./packages/import/package.json
|
||||
COPY packages/pdf/package.json ./packages/pdf/package.json
|
||||
COPY packages/runtime-externals/package.json ./packages/runtime-externals/package.json
|
||||
COPY packages/schema/package.json ./packages/schema/package.json
|
||||
COPY packages/scripts/package.json ./packages/scripts/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 \
|
||||
pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
|
||||
EXPOSE 3000/tcp
|
||||
EXPOSE 3000/tcp 3001/tcp
|
||||
|
||||
CMD ["pnpm", "run", "dev:web"]
|
||||
CMD ["pnpm", "run", "dev"]
|
||||
|
||||
+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,26 +33,26 @@
|
||||
|
||||
---
|
||||
|
||||
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.
|
||||
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
|
||||
- Custom CSS for advanced styling
|
||||
- Structured Style Rules for section and text styling
|
||||
|
||||
**Privacy & Control**
|
||||
|
||||
@@ -61,7 +67,7 @@ Built with privacy as a core principle, Reactive Resume gives you complete owner
|
||||
- Multi-language support
|
||||
- Share resumes via unique links
|
||||
- Import from JSON Resume format
|
||||
- Dark mode support
|
||||
- Dark mode
|
||||
- Passkey and two-factor authentication
|
||||
|
||||
## Templates
|
||||
@@ -143,7 +149,7 @@ The quickest way to run Reactive Resume locally:
|
||||
|
||||
```bash
|
||||
# Clone the repository
|
||||
git clone 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
|
||||
@@ -153,44 +159,48 @@ 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) |
|
||||
| Runtime | Node.js |
|
||||
| Language | TypeScript |
|
||||
| Database | PostgreSQL with Drizzle ORM |
|
||||
| API | ORPC (Type-safe RPC) |
|
||||
| Auth | Better Auth |
|
||||
| Styling | Tailwind CSS |
|
||||
| UI Components | Radix UI |
|
||||
| State Management | Zustand + TanStack Query |
|
||||
| Category | Technology |
|
||||
| ---------------- | -------------------------------- |
|
||||
| Framework | TanStack Router (React 19, Vite) |
|
||||
| Runtime | Node.js |
|
||||
| Language | TypeScript |
|
||||
| Database | PostgreSQL with Drizzle ORM |
|
||||
| API | ORPC (Type-safe RPC) |
|
||||
| Auth | Better Auth |
|
||||
| Styling | Tailwind CSS |
|
||||
| UI Components | Base UI + shadcn-style package |
|
||||
| State Management | Zustand + TanStack Query |
|
||||
|
||||
## 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:
|
||||
|
||||
@@ -199,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">
|
||||
@@ -220,23 +230,28 @@ Reactive Resume is and always will be free and open-source. If it has helped you
|
||||
Other ways to support:
|
||||
|
||||
- Star this repository
|
||||
- Report bugs and suggest features
|
||||
- Report reproducible bugs and suggest actionable features
|
||||
- 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/#amruthpillai/reactive-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/svg?repos=amruthpillai/reactive-resume&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=amruthpillai/reactive-resume&type=date&legend=top-left" />
|
||||
<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`)
|
||||
@@ -244,7 +259,11 @@ 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/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`.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+1625
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,103 @@
|
||||
{
|
||||
"name": "server",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "tsx watch src/index.ts",
|
||||
"build": "tsdown",
|
||||
"start": "node dist/index.mjs",
|
||||
"docs:gen": "tsx src/openapi/generate-spec.ts",
|
||||
"typecheck": "tsgo --noEmit",
|
||||
"test": "vitest run --passWithNoTests",
|
||||
"test:coverage": "vitest run --coverage --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"
|
||||
},
|
||||
"dependencies": {
|
||||
"@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": "catalog:",
|
||||
"@better-auth/drizzle-adapter": "^1.7.6",
|
||||
"@better-auth/infra": "catalog:",
|
||||
"@better-auth/oauth-provider": "catalog:",
|
||||
"@better-auth/passkey": "catalog:",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@formepdf/core": "0.25.0",
|
||||
"@formepdf/react": "0.25.0",
|
||||
"@hono/node-server": "^2.1.3",
|
||||
"@modelcontextprotocol/sdk": "^1.31.0",
|
||||
"@orpc/client": "catalog:",
|
||||
"@orpc/experimental-ratelimit": "^1.15.4",
|
||||
"@orpc/json-schema": "^1.15.4",
|
||||
"@orpc/openapi": "^1.15.4",
|
||||
"@orpc/server": "catalog:",
|
||||
"@orpc/zod": "^1.15.4",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
"@reactive-resume/env": "workspace:*",
|
||||
"@reactive-resume/mcp": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.1",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "catalog:",
|
||||
"@vercel/blob": "^2.8.0",
|
||||
"@vercel/functions": "^3.9.9",
|
||||
"ai": "catalog:",
|
||||
"bcryptjs": "catalog:",
|
||||
"better-auth": "catalog:",
|
||||
"cjk-regex": "^3.5.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"drizzle-orm": "catalog:",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "catalog:",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"fast-png": "^8.0.0",
|
||||
"fflate": "catalog:",
|
||||
"firecrawl": "^4.42.0",
|
||||
"hono": "^4.13.11",
|
||||
"ioredis": "catalog:",
|
||||
"jose": "^6.2.12",
|
||||
"jsonrepair": "catalog:",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"nodemailer": "^10.0.12",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pg": "catalog:",
|
||||
"react": "catalog:",
|
||||
"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": "catalog:",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "catalog:",
|
||||
"@types/pg": "catalog:",
|
||||
"@types/react": "catalog:",
|
||||
"@typescript/native-preview": "catalog:",
|
||||
"tsdown": "^0.23.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "catalog:",
|
||||
"vitest": "catalog:"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
// @boundaries-ignore root release metadata
|
||||
import { version } from "../../../package.json";
|
||||
|
||||
export const appVersion = typeof __APP_VERSION__ === "undefined" ? version : __APP_VERSION__;
|
||||
@@ -0,0 +1,216 @@
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
handleAuth: vi.fn(),
|
||||
handleOAuth: vi.fn(),
|
||||
handleRpc: vi.fn(),
|
||||
handleOpenApi: vi.fn(),
|
||||
handleHealth: vi.fn(),
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handlePublicResumePdf: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
handleOpenIdConfiguration: vi.fn(),
|
||||
handleWellKnownFallback: vi.fn(),
|
||||
handleRobots: vi.fn(),
|
||||
handleSitemap: vi.fn(),
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
handleAuth: mocks.handleAuth,
|
||||
handleOAuth: mocks.handleOAuth,
|
||||
}));
|
||||
|
||||
vi.mock("./health", () => ({
|
||||
handleHealth: mocks.handleHealth,
|
||||
}));
|
||||
|
||||
vi.mock("../rpc/handler", () => ({
|
||||
handleRpc: mocks.handleRpc,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/handler", () => ({
|
||||
handleOpenApi: mocks.handleOpenApi,
|
||||
}));
|
||||
|
||||
vi.mock("../openapi/metadata", () => ({
|
||||
handleMcpServerCard: mocks.handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer: mocks.handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource: mocks.handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration: mocks.handleOpenIdConfiguration,
|
||||
handleWellKnownFallback: mocks.handleWellKnownFallback,
|
||||
}));
|
||||
|
||||
vi.mock("../static/uploads", () => ({
|
||||
handleUpload: mocks.handleUpload,
|
||||
}));
|
||||
|
||||
vi.mock("../static/seo", () => ({
|
||||
handleRobots: mocks.handleRobots,
|
||||
handleSitemap: mocks.handleSitemap,
|
||||
handleLlms: mocks.handleLlms,
|
||||
}));
|
||||
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
handleMcp: mocks.handleMcp,
|
||||
}));
|
||||
|
||||
vi.mock("./resume-pdf", () => ({
|
||||
handleResumePdfDownload: mocks.handleResumePdfDownload,
|
||||
}));
|
||||
|
||||
vi.mock("./public-resume-pdf", () => ({
|
||||
handlePublicResumePdf: mocks.handlePublicResumePdf,
|
||||
}));
|
||||
|
||||
const transportEnv = (remoteAddress: string) =>
|
||||
({
|
||||
incoming: { socket: { remoteAddress } },
|
||||
}) as never;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.handleAuth.mockResolvedValue(new Response("auth"));
|
||||
mocks.handleOAuth.mockResolvedValue(new Response("oauth"));
|
||||
mocks.handleRpc.mockResolvedValue(new Response("rpc"));
|
||||
mocks.handleOpenApi.mockResolvedValue(new Response("openapi"));
|
||||
mocks.handleHealth.mockReturnValue(new Response("health"));
|
||||
mocks.handleUpload.mockResolvedValue(new Response("upload"));
|
||||
mocks.handleMcp.mockResolvedValue(new Response("mcp"));
|
||||
mocks.handleResumePdfDownload.mockResolvedValue(new Response("pdf"));
|
||||
mocks.handlePublicResumePdf.mockResolvedValue(new Response("public-pdf"));
|
||||
mocks.handleMcpServerCard.mockReturnValue(new Response("server-card"));
|
||||
mocks.handleOAuthAuthorizationServer.mockReturnValue(new Response("oauth-authorization-server"));
|
||||
mocks.handleOAuthProtectedResource.mockReturnValue(new Response("oauth-protected-resource"));
|
||||
mocks.handleOpenIdConfiguration.mockReturnValue(new Response("openid-configuration"));
|
||||
mocks.handleWellKnownFallback.mockReturnValue(new Response("well-known"));
|
||||
mocks.handleRobots.mockReturnValue(new Response("robots"));
|
||||
mocks.handleSitemap.mockReturnValue(new Response("sitemap"));
|
||||
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
it("routes /api/auth/oauth to the OAuth bridge before the Better Auth wildcard", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/api/auth/oauth?client_id=test-client");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
await expect(response.text()).resolves.toBe("oauth");
|
||||
expect(mocks.handleOAuth).toHaveBeenCalledWith(request);
|
||||
expect(mocks.handleAuth).not.toHaveBeenCalled();
|
||||
// The first test pays for the cold import of the whole app, which takes seconds under a parallel run.
|
||||
}, 15_000);
|
||||
|
||||
it("uses the transport address for public PDF fallback despite rotated forwarding headers", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const first = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
|
||||
headers: { "x-forwarded-for": "198.51.100.1" },
|
||||
});
|
||||
const rotated = new Request("http://localhost:3001/api/resumes/jane/resume/pdf", {
|
||||
headers: { "x-forwarded-for": "198.51.100.2" },
|
||||
});
|
||||
const env = transportEnv("203.0.113.9");
|
||||
|
||||
const response = await app.fetch(first, env);
|
||||
await app.fetch(rotated, env);
|
||||
|
||||
await expect(response.text()).resolves.toBe("public-pdf");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(1, first, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handlePublicResumePdf).toHaveBeenNthCalledWith(2, rotated, "jane", "resume", "203.0.113.9");
|
||||
expect(mocks.handleResumePdfDownload).not.toHaveBeenCalled();
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("passes the transport address to RPC and OpenAPI and fails closed when it is unavailable", async () => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const trustedRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.1" },
|
||||
});
|
||||
const unknownRpcRequest = new Request("http://localhost:3001/api/rpc", {
|
||||
headers: { "cf-connecting-ip": "198.51.100.2" },
|
||||
});
|
||||
const trustedOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
|
||||
const unknownOpenApiRequest = new Request("http://localhost:3001/api/openapi/resumes/jane/resume");
|
||||
|
||||
await app.fetch(trustedRpcRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownRpcRequest);
|
||||
await app.fetch(trustedOpenApiRequest, transportEnv("203.0.113.9"));
|
||||
await app.fetch(unknownOpenApiRequest);
|
||||
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(1, trustedRpcRequest, "203.0.113.9");
|
||||
expect(mocks.handleRpc).toHaveBeenNthCalledWith(2, unknownRpcRequest, "unknown");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(1, trustedOpenApiRequest, "203.0.113.9");
|
||||
expect(mocks.handleOpenApi).toHaveBeenNthCalledWith(2, unknownOpenApiRequest, "unknown");
|
||||
});
|
||||
|
||||
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/");
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
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");
|
||||
});
|
||||
@@ -0,0 +1,97 @@
|
||||
import type { Http2Bindings, HttpBindings } from "@hono/node-server";
|
||||
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 {
|
||||
handleMcpServerCard,
|
||||
handleOAuthAuthorizationServer,
|
||||
handleOAuthProtectedResource,
|
||||
handleOpenIdConfiguration,
|
||||
handleWellKnownFallback,
|
||||
} from "../openapi/metadata";
|
||||
import { handleRpc } from "../rpc/handler";
|
||||
import { handleSchemaJson } from "../static/schema";
|
||||
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||
import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, serveWebDistStatic } from "../static/web";
|
||||
import { handleAuth, handleOAuth } from "./auth";
|
||||
import { handleHealth } from "./health";
|
||||
import { handlePublicResumePdf } from "./public-resume-pdf";
|
||||
import { handleResumePdfDownload } from "./resume-pdf";
|
||||
|
||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||
|
||||
const getTrustedClient = (context: Context<ServerEnvironment>): string => {
|
||||
try {
|
||||
const address = getConnInfo(context).remote.address?.trim();
|
||||
return address && isIP(address) ? address : "unknown";
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
};
|
||||
|
||||
type AppOptions = {
|
||||
serveStatic?: boolean;
|
||||
trustedClient?: (request: Request) => string;
|
||||
};
|
||||
|
||||
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, 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"), 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));
|
||||
app.get("/uploads/*", (c) => handleUpload(c.req.raw));
|
||||
app.get("/schema.json", () => handleSchemaJson());
|
||||
app.all("/mcp", (c) => handleMcp(c.req.raw));
|
||||
app.all("/mcp/*", (c) => handleMcp(c.req.raw));
|
||||
|
||||
app.get("/.well-known/mcp/server-card.json", () => handleMcpServerCard());
|
||||
app.get("/.well-known/oauth-authorization-server", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/oauth-authorization-server/*", (c) => handleOAuthAuthorizationServer(c.req.raw));
|
||||
app.get("/.well-known/openid-configuration", (c) => handleOpenIdConfiguration(c.req.raw));
|
||||
app.get("/.well-known/oauth-protected-resource", () => handleOAuthProtectedResource());
|
||||
app.get("/.well-known/oauth-protected-resource/*", () => handleOAuthProtectedResource());
|
||||
app.all("/.well-known/*", () => handleWellKnownFallback());
|
||||
|
||||
app.on(["GET", "HEAD"], "/robots.txt", (c) => handleRobots({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
// 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));
|
||||
if (options.serveStatic !== false) app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET", "HEAD"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
|
||||
return app;
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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,
|
||||
APP_URL: "http://localhost:3000",
|
||||
FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI: false,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
oauth2Consent: mocks.consent,
|
||||
oauth2Continue: mocks.continueOAuth,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: {} }));
|
||||
vi.mock("@reactive-resume/db/schema", () => ({ oauthClient: {}, verification: {} }));
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI = false;
|
||||
mocks.handler.mockResolvedValue(new Response("ok"));
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it.each(["203.0.113.9", "unknown"])("uses only the adapter's client address (%s)", async (trustedClient) => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/get-session", {
|
||||
headers: {
|
||||
"cf-connecting-ip": "198.51.100.1",
|
||||
"true-client-ip": "198.51.100.2",
|
||||
"x-forwarded-for": "198.51.100.3, 192.0.2.1",
|
||||
"x-real-ip": "198.51.100.4",
|
||||
},
|
||||
}),
|
||||
trustedClient,
|
||||
);
|
||||
const request = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
expect(request.headers.get("cf-connecting-ip")).toBeNull();
|
||||
expect(request.headers.get("true-client-ip")).toBeNull();
|
||||
expect(request.headers.get("x-forwarded-for")).toBeNull();
|
||||
expect(request.headers.get("x-real-ip")).toBe(trustedClient === "unknown" ? null : trustedClient);
|
||||
});
|
||||
it.for([null, false, 42, "client", [], [{ redirect_uris: [] }]])(
|
||||
"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://192.168.1.10/callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
error: "invalid_redirect_uri",
|
||||
error_description: "redirect_uri is not allowed",
|
||||
});
|
||||
expect(mocks.handler).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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;
|
||||
await handleAuth(
|
||||
new Request("http://localhost:3000/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
);
|
||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
expect((await forwarded.json()).application_type).not.toBe("native");
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOAuth", () => {
|
||||
it("redirects unauthenticated users to the same-origin login route", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce(null);
|
||||
|
||||
const response = await handleOAuth(
|
||||
new Request(
|
||||
"http://localhost:3001/api/auth/oauth?client_id=test-client&redirect_uri=https%3A%2F%2Fexample.com%2Fcallback&state=abc&exp=123&sig=456",
|
||||
),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(302);
|
||||
const location = response.headers.get("Location");
|
||||
expect(location).toMatch(/^\/auth\/login\?/);
|
||||
|
||||
const loginUrl = new URL(location ?? "", "http://localhost:3000");
|
||||
const callbackUrl = new URL(loginUrl.searchParams.get("callbackURL") ?? "", "http://localhost:3000");
|
||||
|
||||
expect(loginUrl.origin).toBe("http://localhost:3000");
|
||||
expect(callbackUrl.pathname).toBe("/api/auth/oauth");
|
||||
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.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();
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { isIP } from "node:net";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
"prompt",
|
||||
"redirect_uri",
|
||||
"client_id",
|
||||
"code_challenge",
|
||||
"code_challenge_method",
|
||||
"response_type",
|
||||
"scope",
|
||||
"state",
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/authorize")) return request;
|
||||
|
||||
const sanitizeValue = (value: string) =>
|
||||
value
|
||||
.replace(/[\r\n\t]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
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);
|
||||
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
if (redirectUri && !URL.canParse(redirectUri)) {
|
||||
try {
|
||||
const decodedRedirectUri = decodeURIComponent(redirectUri);
|
||||
if (URL.canParse(decodedRedirectUri)) {
|
||||
url.searchParams.set("redirect_uri", decodedRedirectUri);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed encoded values and let Better Auth validation handle them.
|
||||
}
|
||||
}
|
||||
|
||||
if (url.toString() === request.url) return 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;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return request;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
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";
|
||||
}
|
||||
|
||||
return new Request(url.toString(), {
|
||||
method: request.method,
|
||||
headers: request.headers,
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function validateDynamicClientRegistrationRequest(request: Request): Promise<Response | undefined> {
|
||||
if (request.method !== "POST") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (!url.pathname.endsWith("/oauth2/register")) return;
|
||||
|
||||
const cloned = request.clone();
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
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 });
|
||||
}
|
||||
|
||||
const oauthTrustedOrigins = [new URL(env.APP_URL).origin.toLowerCase()];
|
||||
|
||||
const redirectUris = Array.isArray(body.redirect_uris) ? body.redirect_uris : [];
|
||||
for (const redirectUri of redirectUris) {
|
||||
if (
|
||||
typeof redirectUri !== "string" ||
|
||||
!isAllowedOAuthRedirectUri(redirectUri, oauthTrustedOrigins, {
|
||||
allowUnsafe: env.FLAG_ALLOW_UNSAFE_OAUTH_REDIRECT_URI,
|
||||
})
|
||||
) {
|
||||
return Response.json(
|
||||
{ error: "invalid_redirect_uri", error_description: "redirect_uri is not allowed" },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const sanitizedRequest = sanitizeOAuthAuthorizeRequest(request);
|
||||
const finalRequest = await defaultPublicClientRegistration(sanitizedRequest);
|
||||
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
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) {
|
||||
// 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 });
|
||||
}
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export function mergeResponseHeaders(response: Response, headers: Headers): Response {
|
||||
if ([...headers].length === 0) return response;
|
||||
|
||||
const nextHeaders = new Headers(response.headers);
|
||||
for (const [key, value] of headers) nextHeaders.append(key, value);
|
||||
|
||||
return new Response(response.body, {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: nextHeaders,
|
||||
});
|
||||
}
|
||||
@@ -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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
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;
|
||||
|
||||
type CheckResult = {
|
||||
status: "healthy" | "unhealthy";
|
||||
latencyMs: number;
|
||||
error?: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
// ponytail: es-toolkit withTimeout takes a fn, not a promise — call site passes check (not check())
|
||||
async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
const startedAt = performance.now();
|
||||
|
||||
try {
|
||||
const data = await withTimeout(check, HEALTHCHECK_TIMEOUT_MS);
|
||||
const latencyMs = Math.round(performance.now() - startedAt);
|
||||
const result = data as { status?: string };
|
||||
if (result.status === "unhealthy") return { ...(data as object), status: "unhealthy", latencyMs };
|
||||
return { ...(data as object), status: "healthy", latencyMs };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
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`);
|
||||
return { status: "healthy" };
|
||||
}
|
||||
|
||||
const checkStorage = () => getStorageService().healthcheck();
|
||||
|
||||
export async function handleHealth() {
|
||||
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: appVersion,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database: publicCheck(database, "Database"),
|
||||
storage: publicCheck(storage, "Storage"),
|
||||
...(redis ? { redis: publicCheck(redis, "Redis") } : {}),
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
console.warn("[Healthcheck]", { route: "/api/health", database, storage, ...(redis ? { redis } : {}) });
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,42 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createPublicResumePdf: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/public-pdf", () => ({
|
||||
createPublicResumePdf: mocks.createPublicResumePdf,
|
||||
}));
|
||||
|
||||
const { handlePublicResumePdf } = await import("./public-resume-pdf");
|
||||
const trustedClient = "203.0.113.9";
|
||||
|
||||
describe("handlePublicResumePdf", () => {
|
||||
beforeEach(() => vi.clearAllMocks());
|
||||
|
||||
it("returns the authorized on-demand PDF without forwarding compatibility metadata", async () => {
|
||||
const body = new File(["%PDF"], "Ada_Lovelace.pdf", { type: "text/plain" });
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body,
|
||||
filename: "Ada_Lovelace.pdf",
|
||||
});
|
||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf?ignored=true", {
|
||||
headers: { "x-forwarded-for": "203.0.113.7" },
|
||||
});
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
expect(response.headers.get("Content-Disposition")).toBe('inline; filename="Ada_Lovelace.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
import { createPublicResumePdf } from "@reactive-resume/api/features/resume/public-pdf";
|
||||
|
||||
const noStoreResponse = (body: string, status: number) =>
|
||||
new Response(body, { status, headers: { "Cache-Control": "private, no-store" } });
|
||||
|
||||
const errorStatus = (error: unknown): number => {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
if (code === "NEED_PASSWORD") return 401;
|
||||
if (code === "NOT_FOUND") return 404;
|
||||
if (code === "RATE_LIMIT_EXCEEDED") return 429;
|
||||
return 500;
|
||||
};
|
||||
|
||||
export async function handlePublicResumePdf(
|
||||
request: Request,
|
||||
username: string,
|
||||
slug: string,
|
||||
trustedClient: string,
|
||||
): Promise<Response> {
|
||||
try {
|
||||
const result = await createPublicResumePdf({
|
||||
username,
|
||||
slug,
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
|
||||
return new Response(result.body, {
|
||||
headers: {
|
||||
"Content-Type": "application/pdf",
|
||||
"Content-Disposition": `inline; filename="${result.filename.replaceAll('"', "")}"`,
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
createResumePdfDownload: vi.fn(),
|
||||
verifyResumePdfDownloadToken: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/export", () => ({
|
||||
createResumePdfDownload: mocks.createResumePdfDownload,
|
||||
verifyResumePdfDownloadToken: mocks.verifyResumePdfDownloadToken,
|
||||
}));
|
||||
|
||||
const { handleResumePdfDownload } = await import("./resume-pdf");
|
||||
|
||||
describe("handleResumePdfDownload", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("renders the PDF when the signed URL token is valid", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Scizor.pdf", { type: "application/pdf" });
|
||||
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="Scizor.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/pdf");
|
||||
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" });
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
let response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "invalid_signature" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=bad"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({ ok: false, reason: "expired" });
|
||||
response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=expired"),
|
||||
"resume-1",
|
||||
);
|
||||
expect(response.status).toBe(410);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
import { createResumePdfDownload, verifyResumePdfDownloadToken } from "@reactive-resume/api/features/resume/export";
|
||||
|
||||
function unauthorizedResponse() {
|
||||
return new Response("Unauthorized", {
|
||||
status: 401,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function expiredResponse() {
|
||||
return new Response("Download link expired", {
|
||||
status: 410,
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function errorStatus(error: unknown) {
|
||||
const code = typeof error === "object" && error && "code" in error ? (error as { code?: unknown }).code : undefined;
|
||||
return code === "NOT_FOUND" ? 404 : 500;
|
||||
}
|
||||
|
||||
export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
const searchParams = new URL(request.url).searchParams;
|
||||
const token = searchParams.get("token");
|
||||
if (!token) return unauthorizedResponse();
|
||||
|
||||
const verification = verifyResumePdfDownloadToken({ resumeId: id, token });
|
||||
if (!verification.ok) return verification.reason === "expired" ? expiredResponse() : 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 download = await createResumePdfDownload({ id, userId: verification.userId });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
"Content-Type": download.body.type || "application/pdf",
|
||||
"Content-Disposition": download.headers["content-disposition"],
|
||||
"Cache-Control": "private, no-store",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[PDF Download]", error);
|
||||
return new Response("Failed to generate resume PDF", {
|
||||
status: errorStatus(error),
|
||||
headers: {
|
||||
"Cache-Control": "private, no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { runStartupChecks } from "./startup/checks";
|
||||
|
||||
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
|
||||
// exceptions on Node's default crash-and-restart, since process state is unsafe after one.)
|
||||
process.on("unhandledRejection", (reason) => {
|
||||
console.error("[unhandledRejection]", reason);
|
||||
});
|
||||
|
||||
const port =
|
||||
process.env.NODE_ENV === "production" ? Number.parseInt(process.env.PORT ?? "3000", 10) : env.SERVER_PORT;
|
||||
|
||||
const app = createApp();
|
||||
|
||||
const server = serve(
|
||||
{
|
||||
fetch: app.fetch,
|
||||
port,
|
||||
},
|
||||
(info) => {
|
||||
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) {
|
||||
main().catch((error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { resolveUserFromRequestHeaders } from "@reactive-resume/api/context";
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
}
|
||||
}
|
||||
|
||||
export async function authenticateRequest(request: Request): Promise<void> {
|
||||
// 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();
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { AuthError, authenticateRequest } from "./auth";
|
||||
import { createMcpServer } from "./server";
|
||||
|
||||
export async function handleMcp(request: Request) {
|
||||
try {
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = createMcpServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
await server.connect(transport);
|
||||
|
||||
return await transport.handleRequest(request);
|
||||
} catch (error) {
|
||||
if (error instanceof AuthError) {
|
||||
return Response.json(
|
||||
{ id: null, jsonrpc: "2.0", error: { code: -32603, message: "Unauthorized" } },
|
||||
{
|
||||
status: 401,
|
||||
headers: {
|
||||
"WWW-Authenticate": `Bearer resource_metadata="${env.APP_URL}/.well-known/oauth-protected-resource"`,
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
console.error("[MCP]", error);
|
||||
|
||||
return Response.json({
|
||||
id: null,
|
||||
jsonrpc: "2.0",
|
||||
error: {
|
||||
code: -32603,
|
||||
message: `Error handling request: ${error instanceof Error ? error.message : String(error)}`,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { RouterClient } from "@orpc/server";
|
||||
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 {
|
||||
buildMcpServerInfo,
|
||||
MCP_TOOL_NAME,
|
||||
registerPrompts,
|
||||
registerResources,
|
||||
registerTools,
|
||||
} from "@reactive-resume/mcp";
|
||||
import { appVersion } from "../app-version";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
return createRouterClient(router, {
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[MCP oRPC]", error);
|
||||
}),
|
||||
],
|
||||
context: () => ({
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders: new Headers(),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
export function createMcpServer(request: Request) {
|
||||
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);
|
||||
registerTools(server, client, request.headers);
|
||||
registerPrompts(server);
|
||||
|
||||
return server;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { StylesheetChange } from "@reactive-resume/api/features/resume/legacy-styles-migration";
|
||||
import { closeSync, openSync, readFileSync, writeSync } from "node:fs";
|
||||
import { parseArgs } from "node:util";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { Pool } from "pg";
|
||||
import { migrateLegacyStyles, restoreLegacyStyles } from "@reactive-resume/api/features/resume/legacy-styles-migration";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
|
||||
const usage = `Converts resumes and letters still styled by the old style editor (legacy style rules) to Semantic CSS.
|
||||
Uses DATABASE_URL. Run it once after deploying the version without the legacy renderer.
|
||||
|
||||
node apps/server/dist/migrate-legacy-styles.mjs
|
||||
Dry run: converts every row that needs it in memory and reports the counts. Writes nothing.
|
||||
|
||||
node apps/server/dist/migrate-legacy-styles.mjs --apply --backup <file>
|
||||
Converts and saves. Every replaced stylesheet is appended to <file> (NDJSON) before its row is written.
|
||||
Safe to run again or after an interruption: converted rows are skipped. Use a new file or the same one.
|
||||
|
||||
node apps/server/dist/migrate-legacy-styles.mjs --restore <file>
|
||||
Puts back the stylesheets recorded in <file>, except on rows whose stylesheet was edited since.
|
||||
`;
|
||||
|
||||
const { values } = parseArgs({
|
||||
options: {
|
||||
apply: { type: "boolean", default: false },
|
||||
backup: { type: "string" },
|
||||
restore: { type: "string" },
|
||||
help: { type: "boolean", default: false },
|
||||
},
|
||||
});
|
||||
|
||||
if (values.help || (values.apply && !values.backup) || (values.restore && (values.apply || values.backup))) {
|
||||
console.info(usage);
|
||||
process.exit(values.help ? 0 : 1);
|
||||
}
|
||||
|
||||
// Opened before connecting, so an unwritable path fails before anything changes.
|
||||
const backup = values.backup ? openSync(values.backup, "a") : undefined;
|
||||
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL, max: 1, connectionTimeoutMillis: 10_000 });
|
||||
const client = await pool.connect();
|
||||
const log = (message: string) => console.info(`[${new Date().toISOString()}] ${message}`);
|
||||
|
||||
try {
|
||||
// Finding the rows is one scan per table, which can outlast the database's default statement timeout.
|
||||
await client.query("SET statement_timeout = 0");
|
||||
const db = drizzle({ client });
|
||||
|
||||
if (values.restore) {
|
||||
const changes = readFileSync(values.restore, "utf8")
|
||||
.split("\n")
|
||||
.filter((line) => line.trim())
|
||||
.map((line) => JSON.parse(line) as StylesheetChange);
|
||||
log(`Restoring ${changes.length} stylesheets from ${values.restore}`);
|
||||
log(`Done: ${JSON.stringify(await restoreLegacyStyles(db, changes))}`);
|
||||
} else {
|
||||
log(values.apply ? `Converting, backing up to ${values.backup}` : "Dry run: nothing will be written");
|
||||
const summary = await migrateLegacyStyles(db, {
|
||||
apply: values.apply,
|
||||
log,
|
||||
...(backup === undefined ? {} : { onChange: (change) => writeSync(backup, `${JSON.stringify(change)}\n`) }),
|
||||
});
|
||||
log(`Done: ${JSON.stringify(summary)}`);
|
||||
}
|
||||
} finally {
|
||||
if (backup !== undefined) closeSync(backup);
|
||||
client.release();
|
||||
await pool.end();
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { readFile, writeFile } from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
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 {
|
||||
version: string;
|
||||
};
|
||||
process.env.APP_URL ??= "https://rxresu.me";
|
||||
process.env.DATABASE_URL ??= "postgresql://localhost/reactive_resume_docs";
|
||||
process.env.AUTH_SECRET ??= "documentation-generation-isolated-process-only";
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
const spec = await generateOpenApiSpec({ appUrl: "https://rxresu.me", version: packageJson.version });
|
||||
await writeFile(target, `${JSON.stringify(spec, null, "\t")}\n`);
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
await generateOpenApiDocumentation(process.argv[2]);
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
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<
|
||||
string,
|
||||
Record<
|
||||
string,
|
||||
{
|
||||
requestBody?: {
|
||||
content?: Record<string, { schema?: unknown }>;
|
||||
};
|
||||
}
|
||||
>
|
||||
>;
|
||||
};
|
||||
|
||||
// Building the spec walks every router and resume JSON schema, which costs seconds. It is
|
||||
// deterministic and every test here only reads it, so generate it once for the whole file —
|
||||
// regenerating per test made the first case time out under a loaded machine.
|
||||
let specPromise: ReturnType<typeof generateOnce> | undefined;
|
||||
|
||||
async function generateOnce() {
|
||||
const { generateOpenApiSpec } = await import("./generator");
|
||||
return generateOpenApiSpec({
|
||||
appUrl: "https://rxresu.me",
|
||||
version: "9.8.7",
|
||||
});
|
||||
}
|
||||
|
||||
function generateSpec() {
|
||||
specPromise ??= generateOnce();
|
||||
return specPromise;
|
||||
}
|
||||
|
||||
function getRequestSchema(spec: GeneratedSpecView, path: string, method: string) {
|
||||
return spec.paths?.[path]?.[method]?.requestBody?.content?.["application/json"]?.schema;
|
||||
}
|
||||
|
||||
function containsImpossibleSchema(value: unknown): boolean {
|
||||
if (Array.isArray(value)) return value.some(containsImpossibleSchema);
|
||||
if (typeof value !== "object" || value === null) return false;
|
||||
const object = value as Record<string, unknown>;
|
||||
const negated = object.not;
|
||||
if (typeof negated === "object" && negated !== null && Object.keys(negated).length === 0) {
|
||||
return true;
|
||||
}
|
||||
return Object.values(object).some(containsImpossibleSchema);
|
||||
}
|
||||
|
||||
function findImpossibleRequestSchemas(spec: GeneratedSpecView) {
|
||||
const impossibleRequests: string[] = [];
|
||||
for (const [path, operations] of Object.entries(spec.paths ?? {})) {
|
||||
for (const [method, operation] of Object.entries(operations)) {
|
||||
for (const [mediaType, content] of Object.entries(operation.requestBody?.content ?? {})) {
|
||||
if (containsImpossibleSchema(content.schema)) {
|
||||
impossibleRequests.push(`${method.toUpperCase()} ${path} (${mediaType})`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return impossibleRequests;
|
||||
}
|
||||
|
||||
describe("generateOpenApiSpec", () => {
|
||||
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();
|
||||
|
||||
expect(spec.components?.schemas?.ResumeData).toEqual(canonicalInputSchema);
|
||||
expect(getRequestSchema(spec, "/resumes/{id}", "put")).toMatchObject({
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("does not publish impossible request schemas", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,130 @@
|
||||
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,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
|
||||
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, {
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
}
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [
|
||||
new ZodToJsonSchemaConverter({
|
||||
interceptors: [
|
||||
({ options, next }) => {
|
||||
const [required, schema] = next();
|
||||
const impossible =
|
||||
Object.keys(schema).length === 1 &&
|
||||
typeof schema.not === "object" &&
|
||||
schema.not !== null &&
|
||||
Object.keys(schema.not).length === 0;
|
||||
return options.strategy === "input" && impossible ? [required, {}] : [required, schema];
|
||||
},
|
||||
],
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
type GenerateOpenApiSpecOptions = {
|
||||
appUrl: string;
|
||||
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/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: writableResumeDataSchema, strategy: "input" },
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
apiKey: {
|
||||
type: "apiKey",
|
||||
name: "x-api-key",
|
||||
in: "header",
|
||||
description: "The API key to authenticate requests.",
|
||||
},
|
||||
},
|
||||
},
|
||||
security: [{ apiKey: [] }],
|
||||
filter: ({ contract }) => !contract["~orpc"].route.tags?.includes("Internal"),
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIHandler } from "@orpc/openapi/fetch";
|
||||
import { onError } from "@orpc/server";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
new BatchHandlerPlugin(),
|
||||
new RequestHeadersPlugin(),
|
||||
new StrictGetMethodPlugin(),
|
||||
new SmartCoercionPlugin({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
}),
|
||||
],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[OpenAPI]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
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 }));
|
||||
}
|
||||
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await openAPIHandler.handle(request, {
|
||||
prefix: "/api/openapi",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
auth: {},
|
||||
env: {
|
||||
APP_URL: "https://rxresu.me",
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@better-auth/oauth-provider", () => ({
|
||||
oauthProviderAuthServerMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
|
||||
oauthProviderOpenIdConfigMetadata: vi.fn(() => vi.fn(() => Response.json({}))),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: mocks.auth,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/mcp/server-card", () => ({
|
||||
buildMcpServerCard: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
vi.mock("../app-version", () => ({
|
||||
appVersion: "test",
|
||||
}));
|
||||
|
||||
describe("handleOAuthProtectedResource", () => {
|
||||
it("advertises the mounted auth issuer as the authorization server", async () => {
|
||||
const { handleOAuthProtectedResource } = await import("./metadata");
|
||||
|
||||
const response = await handleOAuthProtectedResource();
|
||||
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
resource: "https://rxresu.me",
|
||||
authorization_servers: ["https://rxresu.me/api/auth"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
import { oauthProviderAuthServerMetadata, oauthProviderOpenIdConfigMetadata } from "@better-auth/oauth-provider";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
|
||||
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export function handleWellKnownFallback() {
|
||||
return new Response("OK", { status: 200 });
|
||||
}
|
||||
|
||||
export function handleMcpServerCard() {
|
||||
return Response.json(buildMcpServerCard(appVersion), {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=60, stale-while-revalidate=120",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function handleOAuthProtectedResource() {
|
||||
const metadata = {
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [`${env.APP_URL}/api/auth`],
|
||||
};
|
||||
|
||||
return Response.json(metadata, {
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "public, max-age=15, stale-while-revalidate=15, stale-if-error=86400",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
@@ -0,0 +1,31 @@
|
||||
import { onError } from "@orpc/server";
|
||||
import { RPCHandler } from "@orpc/server/fetch";
|
||||
import { BatchHandlerPlugin, RequestHeadersPlugin, StrictGetMethodPlugin } from "@orpc/server/plugins";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "./locale";
|
||||
|
||||
const rpcHandler = new RPCHandler(router, {
|
||||
plugins: [new BatchHandlerPlugin(), new RequestHeadersPlugin(), new StrictGetMethodPlugin()],
|
||||
interceptors: [
|
||||
onError((error) => {
|
||||
console.error("[oRPC Server]", error);
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request, trustedClient: string) {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: {
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
trustedClient,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
return mergeResponseHeaders(response, resHeaders);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import type { Locale } from "@reactive-resume/utils/locale";
|
||||
import { parse } from "hono/utils/cookie";
|
||||
import { defaultLocale, isLocale } from "@reactive-resume/utils/locale";
|
||||
|
||||
export function getRequestLocale(request: Request): Locale {
|
||||
const locale = parse(request.headers.get("cookie") ?? "", "locale").locale;
|
||||
return isLocale(locale) ? locale : defaultLocale;
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import { constants, existsSync } from "node:fs";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
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));
|
||||
}
|
||||
|
||||
function resolveWorkspaceFolder(folderName: string): string {
|
||||
let dir = resolveFromCurrentModule(".");
|
||||
|
||||
while (dir !== path.dirname(dir)) {
|
||||
const candidate = path.join(dir, folderName);
|
||||
if (existsSync(candidate)) return candidate;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
|
||||
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||
}
|
||||
|
||||
export async function runDatabaseMigrations() {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const pool = new Pool({
|
||||
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
|
||||
try {
|
||||
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.STORAGE_BACKEND !== "local") return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
|
||||
try {
|
||||
await fs.mkdir(dataDirectory, { recursive: true });
|
||||
await fs.access(dataDirectory, constants.R_OK | constants.W_OK);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
console.error(
|
||||
`Local storage path is not writable: ${dataDirectory}\n` +
|
||||
` ${message}\n` +
|
||||
"Set LOCAL_STORAGE_PATH to a writable directory or fix permissions on the existing path.",
|
||||
);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function reapStaleAgentRuns() {
|
||||
try {
|
||||
const { reapStaleAgentRunsAtBoot } = await import("@reactive-resume/api/features/agent/runs");
|
||||
await reapStaleAgentRunsAtBoot();
|
||||
} catch (error) {
|
||||
// A reap failure must not block serving traffic; stuck runs also heal lazily on access.
|
||||
console.error("Failed to reap stale agent runs at boot", { error });
|
||||
}
|
||||
}
|
||||
|
||||
export async function runStartupChecks() {
|
||||
await runDatabaseMigrations();
|
||||
await validateLocalStoragePath();
|
||||
await reapStaleAgentRuns();
|
||||
}
|
||||
@@ -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.",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
return Response.json(createResumeDataJsonSchema(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
"Cache-Control": "public, max-age=86400, immutable",
|
||||
"Surrogate-Control": "max-age=86400",
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Robots-Tag": "index, follow",
|
||||
ETag: appVersion,
|
||||
Vary: "Accept",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
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";
|
||||
|
||||
type StaticSeoOptions = {
|
||||
head?: boolean;
|
||||
};
|
||||
|
||||
function appUrl() {
|
||||
return env.APP_URL.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function textResponse(body: string, options: StaticSeoOptions = {}) {
|
||||
return new Response(options.head ? null : body, {
|
||||
headers: { "Content-Type": "text/plain; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleRobots(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"User-agent: *",
|
||||
"Allow: /",
|
||||
"Disallow: /api/rpc",
|
||||
"Disallow: /api/auth",
|
||||
"Disallow: /mcp",
|
||||
"Disallow: /.well-known",
|
||||
"",
|
||||
`Sitemap: ${baseUrl}/sitemap.xml`,
|
||||
`Sitemap: ${DOCS_URL}/sitemap.xml`,
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
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" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
...urls,
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
return new Response(options?.head ? null : body, {
|
||||
headers: { "Content-Type": "application/xml; charset=UTF-8" },
|
||||
});
|
||||
}
|
||||
|
||||
export function handleLlms(options?: StaticSeoOptions) {
|
||||
const baseUrl = appUrl();
|
||||
const body = [
|
||||
"# Reactive Resume",
|
||||
"",
|
||||
`> 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.`,
|
||||
"",
|
||||
"## Product",
|
||||
"",
|
||||
`- [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");
|
||||
|
||||
return textResponse(body, options);
|
||||
}
|
||||
@@ -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,76 +1,52 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { getStorageService } from "@reactive-resume/api/services/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { basename, normalize } from "node:path";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
|
||||
export const Route = createFileRoute("/uploads/$userId/$")({
|
||||
server: { handlers: { GET: handler } },
|
||||
});
|
||||
// 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"]);
|
||||
|
||||
/**
|
||||
* Handler for GET requests to serve uploaded files, supporting ETags, content security, and path validation.
|
||||
* Handles nested paths like:
|
||||
* - /uploads/{userId}/pictures/{timestamp}.jpeg
|
||||
* - /uploads/{userId}/screenshots/{resumeId}/{timestamp}.jpeg
|
||||
* - /uploads/{userId}/pdfs/{resumeId}/{timestamp}.pdf
|
||||
*/
|
||||
export async function handler({ request }: { request: Request }) {
|
||||
export async function handleUpload(request: Request) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
|
||||
if (!userId || !filePath) return new Response("Bad Request", { status: 400 });
|
||||
|
||||
if (!isValidPath(userId) || !isValidPathSegments(filePath)) return new Response("Forbidden", { status: 403 });
|
||||
if (isPrivateUploadPath(filePath)) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const storageService = getStorageService();
|
||||
|
||||
// Build the full storage key: uploads/{userId}/{filePath}
|
||||
const key = `uploads/${userId}/${filePath}`;
|
||||
const storedFile = await storageService.read(key);
|
||||
if (!storedFile) return new Response("Not Found", { status: 404 });
|
||||
|
||||
const filename = filePath.split("/").pop() ?? filePath;
|
||||
const ext = extname(filename).toLowerCase();
|
||||
const contentType = storedFile.contentType ?? inferContentTypeFromExtension(ext);
|
||||
const contentType = storedFile.contentType ?? inferContentType(filename);
|
||||
const etag = createEtag(storedFile);
|
||||
|
||||
if (isNotModified(request.headers, etag)) return makeNotModifiedResponse(etag);
|
||||
|
||||
const shouldForceDownload = [".pdf"].includes(ext);
|
||||
const headers = await buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
const shouldForceDownload = !INLINE_CONTENT_TYPES.has(contentType);
|
||||
|
||||
const buffer = toArrayBuffer(storedFile.data);
|
||||
const headers = new Headers();
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
return new Response(buffer, { headers });
|
||||
}
|
||||
|
||||
function inferContentTypeFromExtension(ext: string): string {
|
||||
switch (ext) {
|
||||
case ".webp":
|
||||
return "image/webp";
|
||||
case ".png":
|
||||
return "image/png";
|
||||
case ".jpg":
|
||||
case ".jpeg":
|
||||
return "image/jpeg";
|
||||
case ".gif":
|
||||
return "image/gif";
|
||||
case ".pdf":
|
||||
return "application/pdf";
|
||||
default:
|
||||
return "application/octet-stream";
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
|
||||
return new Response(toArrayBuffer(storedFile.data), { headers });
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts userId and the remaining file path from the request URL.
|
||||
*/
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
const pathname = new URL(url).pathname;
|
||||
const [, pathAfterUploads] = pathname.split("/uploads/");
|
||||
@@ -87,27 +63,22 @@ function parseRouteParams(url: string): { userId: string | undefined; filePath:
|
||||
return { userId, filePath: filePath || undefined };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that a path segment does not contain directory traversal attempts.
|
||||
*/
|
||||
function isValidPath(segment: string): boolean {
|
||||
const normalized = normalize(segment).replace(/^(\.\.(\/|\\|$))+/, "");
|
||||
|
||||
return normalized === segment;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates all segments in a path for directory traversal attempts.
|
||||
*/
|
||||
function isValidPathSegments(path: string): boolean {
|
||||
const segments = path.split("/");
|
||||
|
||||
return segments.every((segment) => isValidPath(segment));
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks for ETag match for conditional GET requests.
|
||||
*/
|
||||
function isPrivateUploadPath(path: string): boolean {
|
||||
return path.split("/")[0] === "agent";
|
||||
}
|
||||
|
||||
function isNotModified(headers: Headers, etag: string): boolean {
|
||||
const ifNoneMatch = headers.get("If-None-Match");
|
||||
const candidates = ifNoneMatch?.split(",").map((s) => s.trim()) ?? [];
|
||||
@@ -115,9 +86,6 @@ function isNotModified(headers: Headers, etag: string): boolean {
|
||||
return candidates.includes(etag);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a 304 Not Modified response with caching headers.
|
||||
*/
|
||||
function makeNotModifiedResponse(etag: string): Response {
|
||||
return new Response(null, {
|
||||
status: 304,
|
||||
@@ -125,60 +93,12 @@ function makeNotModifiedResponse(etag: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Builds all headers for serving the file, including caching, security, and download headers.
|
||||
*/
|
||||
async function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): Promise<Headers> {
|
||||
const headers = new Headers();
|
||||
|
||||
headers.set("Content-Type", shouldForceDownload ? "application/octet-stream" : contentType);
|
||||
headers.set("Content-Length", storedFile.size.toString());
|
||||
|
||||
if (shouldForceDownload) {
|
||||
headers.set("Content-Disposition", `attachment; filename="${encodeURIComponent(basename(filename))}"`);
|
||||
}
|
||||
|
||||
headers.set("Cache-Control", "public, max-age=31536000, immutable");
|
||||
headers.set("ETag", etag);
|
||||
|
||||
// Security Headers
|
||||
headers.set("X-Content-Type-Options", "nosniff");
|
||||
headers.set("X-Robots-Tag", "noindex, nofollow");
|
||||
headers.set("Cross-Origin-Resource-Policy", "same-site");
|
||||
headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
|
||||
headers.set("X-Frame-Options", "DENY");
|
||||
headers.set("X-Download-Options", "noopen");
|
||||
headers.set("Access-Control-Allow-Origin", env.APP_URL);
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a Uint8Array to ArrayBuffer efficiently.
|
||||
*/
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
: (data.slice().buffer as ArrayBuffer);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates or returns the ETag for a stored file.
|
||||
*/
|
||||
function createEtag(storedFile: { data: Uint8Array; size: number; etag?: string }): string {
|
||||
if (storedFile.etag) {
|
||||
const tag = storedFile.etag.trim();
|
||||
@@ -0,0 +1,198 @@
|
||||
import fs from "node:fs/promises";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
env: { APP_URL: "https://rxresu.me", ROOT_RESUME_ID: undefined as string | undefined },
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
getPublicResumeSocialMeta: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/api/features/resume/social-meta", () => ({
|
||||
getPublicResumeSocialMeta: mocks.getPublicResumeSocialMeta,
|
||||
}));
|
||||
|
||||
vi.mock("node:fs", () => ({
|
||||
existsSync: vi.fn(() => true),
|
||||
}));
|
||||
|
||||
vi.mock("node:fs/promises", () => ({
|
||||
default: {
|
||||
readFile: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: mocks.serveStatic,
|
||||
}));
|
||||
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
const { handleWebApp } = await import("./web");
|
||||
|
||||
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("injects canonical metadata and structured data into tracking-parameter root requests only", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(`
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<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 makes it easy to create, update, and share your resume."
|
||||
>
|
||||
</head>
|
||||
<body><div id="app"></div></body>
|
||||
</html>
|
||||
`);
|
||||
|
||||
const response = await handleWebApp(new Request("http://server.internal/?utm_source=search"));
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/">');
|
||||
expect(html).toContain('<meta property="og:url" content="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>`;
|
||||
|
||||
it("serves an indexable shell rather than a 404", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
|
||||
const response = await handleWebApp(new Request("https://example.com/ats-checker"));
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
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?locale=de-DE"))).text();
|
||||
|
||||
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("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||
name: 'Jane" onload="alert(1)',
|
||||
title: "<script>alert(1)</script>",
|
||||
description: 'Ends with " and & ampersand',
|
||||
template: "azurill",
|
||||
});
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
||||
|
||||
expect(html).not.toContain("<script>alert(1)</script>");
|
||||
expect(html).not.toContain('onload="alert(1)');
|
||||
expect(html).toContain('<meta property="og:title" content="<script>alert(1)</script>">');
|
||||
expect(html).toContain('content="Ends with " and & ampersand"');
|
||||
});
|
||||
|
||||
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/resume"))).text();
|
||||
|
||||
expect(html).toContain("<title>Jane $& $' Doe - Reactive Resume</title>");
|
||||
expect(html).toContain('<meta name="description" content="Costs $$ and $` nothing">');
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("X-Frame-Options")).toBe("DENY");
|
||||
expect(response.headers.get("X-Content-Type-Options")).toBe("nosniff");
|
||||
expect(response.headers.get("Content-Security-Policy-Report-Only")).toContain("frame-ancestors 'none'");
|
||||
});
|
||||
|
||||
it.each(["/auth/login", "/dashboard", "/builder/resume-1", "/agent", "/templates", "/templates/azurill.pdf"])(
|
||||
"serves noindex shell for known app prefix %s",
|
||||
async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
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>',
|
||||
);
|
||||
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/);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,420 @@
|
||||
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 = [
|
||||
// Source layout: apps/server/src/static/web.ts -> apps/web/dist
|
||||
fileURLToPath(new URL("../../../web/dist", import.meta.url)),
|
||||
// Bundled layout: apps/server/dist/index.mjs -> apps/web/dist
|
||||
fileURLToPath(new URL("../../web/dist", import.meta.url)),
|
||||
];
|
||||
const [fallback] = candidates;
|
||||
if (!fallback) throw new Error("Could not resolve web dist path");
|
||||
|
||||
return candidates.find((candidate) => existsSync(candidate)) ?? fallback;
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
* Without an entry here the fallback below returns 404 for the path in production — the dev Vite
|
||||
* server serves the shell for anything, so this failure only ever shows up once deployed.
|
||||
*/
|
||||
const indexableAppPaths = new Set(["/ats-checker"]);
|
||||
const reservedPublicResumeSegments = new Set([
|
||||
"api",
|
||||
"mcp",
|
||||
".well-known",
|
||||
"uploads",
|
||||
"auth",
|
||||
"dashboard",
|
||||
"builder",
|
||||
"agent",
|
||||
"templates",
|
||||
"ats-checker",
|
||||
]);
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
|
||||
function getPathSegments(pathname: string) {
|
||||
return pathname.split("/").filter(Boolean);
|
||||
}
|
||||
|
||||
function isNoindexShellPath(pathname: string): boolean {
|
||||
return noindexShellPrefixes.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`));
|
||||
}
|
||||
|
||||
function isPublicResumePath(pathname: string): boolean {
|
||||
const segments = getPathSegments(pathname);
|
||||
const [firstSegment] = segments;
|
||||
|
||||
return segments.length === 2 && firstSegment !== undefined && !reservedPublicResumeSegments.has(firstSegment);
|
||||
}
|
||||
|
||||
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' 'wasm-unsafe-eval'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||
};
|
||||
|
||||
const githubUrl = "https://github.com/reactive-resume/reactive-resume";
|
||||
// The English copy, for a build without prerendered pages; a prerendered page carries its own locale's title and
|
||||
// description, and the social cards reuse them.
|
||||
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
||||
const ROOT_DESCRIPTION =
|
||||
"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.";
|
||||
|
||||
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: rootUrl,
|
||||
publisher: { "@id": `${origin}/#organization` },
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
url: rootUrl,
|
||||
description: ROOT_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
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="${escapeAttribute(pageUrl)}">
|
||||
${alternates}
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<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="${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>
|
||||
`;
|
||||
}
|
||||
|
||||
/** 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,
|
||||
};
|
||||
}
|
||||
|
||||
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
||||
// HTML unescaped.
|
||||
const escapeAttribute = (value: string) =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'");
|
||||
|
||||
async function createPublicResumeSeoMarkup(pathname: string, origin: string) {
|
||||
const [username, slug] = getPathSegments(pathname);
|
||||
if (!username || !slug) return null;
|
||||
|
||||
// A card render must never take down the page: any lookup failure falls back to the plain shell.
|
||||
const meta = await import("@reactive-resume/api/features/resume/social-meta")
|
||||
.then((module) => module.getPublicResumeSocialMeta({ username, slug }))
|
||||
.catch(() => null);
|
||||
if (!meta) return null;
|
||||
|
||||
const canonicalUrl = `${origin}/${username}/${slug}`;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const pageTitle = escapeAttribute(`${meta.name} - Reactive Resume`);
|
||||
const title = escapeAttribute(meta.title);
|
||||
const description = escapeAttribute(meta.description);
|
||||
|
||||
return {
|
||||
pageTitle,
|
||||
description,
|
||||
markup: `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<meta property="og:type" content="profile">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${title}">
|
||||
<meta property="og:description" content="${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="${title}">
|
||||
<meta name="twitter:description" content="${description}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
`,
|
||||
};
|
||||
}
|
||||
|
||||
export const serveWebDistStatic = serveStatic({
|
||||
root: staticRoot,
|
||||
onFound: (_path, context) => {
|
||||
if (/^\/videos\/.*-v\d+\.(?:mp4|webp)$/.test(context.req.path)) {
|
||||
context.header("Cache-Control", "public, max-age=31536000, immutable");
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
function getFallbackResponseHeaders(pathname: string) {
|
||||
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 };
|
||||
}
|
||||
if (isNoindexShellPath(pathname) || isPublicResumePath(pathname)) {
|
||||
return {
|
||||
"Content-Type": "text/html; charset=UTF-8",
|
||||
"X-Robots-Tag": "noindex, follow",
|
||||
...BASE_SECURITY_HEADERS,
|
||||
};
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
return new Response(options.head ? null : "Not Found", {
|
||||
status: 404,
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
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";
|
||||
const pathname = new URL(request.url).pathname;
|
||||
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
|
||||
return new Response(isHead ? null : "Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ head: isHead, noindex: true });
|
||||
|
||||
if (isHead) return new Response(null, { status: 200, headers });
|
||||
|
||||
const html = await fs.readFile(indexHtmlPath, "utf-8");
|
||||
|
||||
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 },
|
||||
);
|
||||
}
|
||||
|
||||
const prerendered = prerenderedPages[pathname];
|
||||
if (prerendered) {
|
||||
const { locale, requested } = getPageLocale(request);
|
||||
const origin = new URL(env.APP_URL).origin;
|
||||
// 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),
|
||||
});
|
||||
|
||||
// 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. 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}">`,
|
||||
);
|
||||
|
||||
return new Response(
|
||||
withTitle.replace("</head>", () => `${resumeSeo.markup}</head>`),
|
||||
{ headers },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(html, { headers });
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
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 }));
|
||||
},
|
||||
};
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
declare const __APP_VERSION__: string;
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "@reactive-resume/config/tsconfig.base.json",
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "tsdown.config.ts", "vitest.config.ts"],
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ESNext", "DOM"],
|
||||
"types": ["node"],
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { TsdownPlugin } from "tsdown";
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig } from "tsdown";
|
||||
|
||||
const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf-8")) as {
|
||||
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 (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;
|
||||
};
|
||||
|
||||
const aiPromptsDir = resolve(dirname(fileURLToPath(import.meta.url)), "../../packages/ai/src/prompts");
|
||||
|
||||
const promptAssetsPlugin: TsdownPlugin = {
|
||||
name: "prompt-assets",
|
||||
buildStart() {
|
||||
for (const filename of readdirSync(aiPromptsDir)) {
|
||||
if (!filename.endsWith(".md")) continue;
|
||||
|
||||
this.emitFile({
|
||||
type: "asset",
|
||||
fileName: `prompts/${filename}`,
|
||||
source: readFileSync(resolve(aiPromptsDir, filename), "utf-8"),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
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",
|
||||
outDir: "dist",
|
||||
clean: true,
|
||||
shims: true,
|
||||
dts: false,
|
||||
define: { __APP_VERSION__: JSON.stringify(rootPackageJson.version ?? "0.0.0") },
|
||||
// The flagged dynamic imports are deliberate: they defer evaluation of env-dependent
|
||||
// modules so tests can run without env vars, not to split chunks.
|
||||
suppressWarnings: [/dynamic import will not move module into another chunk/],
|
||||
outExtensions: () => ({ js: ".mjs" }),
|
||||
deps: {
|
||||
alwaysBundle: shouldBundle,
|
||||
neverBundle: shouldExternalizeThirdParty,
|
||||
},
|
||||
plugins: [promptAssetsPlugin],
|
||||
});
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:coverage": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:agent": {
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
},
|
||||
"test:ci": {
|
||||
"cache": false,
|
||||
"env": ["$TURBO_EXTENDS$", "OAUTH_TEST_DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Vercel service entrypoint. It must exist before the build, so it re-exports the adapter that tsdown emits.
|
||||
export { default } from "./dist/vercel.mjs";
|
||||
@@ -1,7 +1,8 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
// @boundaries-ignore root shared Vitest config
|
||||
import { createVitestProjectConfig } from "../../vitest.shared.mts";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "@reactive-resume/config",
|
||||
name: "server",
|
||||
dirname: fileURLToPath(new URL(".", import.meta.url)),
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<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" />
|
||||
<meta name="apple-mobile-web-app-title" content="Reactive Resume" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<!-- Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines. -->
|
||||
<meta
|
||||
name="description"
|
||||
content="Free, open-source resume builder. Create, update, and share a professional resume in minutes — no ads, no paywall."
|
||||
/>
|
||||
|
||||
<link rel="icon" href="/favicon.ico" type="image/x-icon" sizes="128x128" />
|
||||
<link rel="icon" href="/favicon.svg" type="image/svg+xml" sizes="256x256 any" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon-180x180.png" type="image/png" sizes="180x180 any" />
|
||||
<link rel="manifest" href="/manifest.webmanifest" crossorigin="use-credentials" />
|
||||
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- 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 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>
|
||||
<style>
|
||||
@keyframes app-spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
#initial-loader {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 24px;
|
||||
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(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;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
</style>
|
||||
<script type="module" data-cfasync="false" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
+7641
-2142
File diff suppressed because it is too large
Load Diff
+7626
-2127
File diff suppressed because it is too large
Load Diff
+7620
-2121
File diff suppressed because it is too large
Load Diff
+7622
-2123
File diff suppressed because it is too large
Load Diff
+7621
-2122
File diff suppressed because it is too large
Load Diff
+7626
-2127
File diff suppressed because it is too large
Load Diff
+7621
-2122
File diff suppressed because it is too large
Load Diff
+7616
-2117
File diff suppressed because it is too large
Load Diff
+7619
-2120
File diff suppressed because it is too large
Load Diff
+7622
-2123
File diff suppressed because it is too large
Load Diff
+7622
-2123
File diff suppressed because it is too large
Load Diff
+7617
-2118
File diff suppressed because it is too large
Load Diff
+7606
-2107
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