mirror of
https://github.com/AmruthPillai/Reactive-Resume.git
synced 2026-09-29 16:24:22 +10:00
Compare commits
605
Commits
v5.1.8
...
2b31d70a8a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,3 @@
|
||||
---
|
||||
exclude_paths:
|
||||
- "migrations/**"
|
||||
+32
-2
@@ -9,11 +9,26 @@ SERVER_PORT="3001"
|
||||
# OpenGraph metadata, and absolute upload URLs.
|
||||
APP_URL="http://localhost:3000"
|
||||
|
||||
# Optional: serve one already-public resume at /. Use the ID from /builder/<id>.
|
||||
# Unset or blank keeps the marketing home. Restart after changes.
|
||||
# ROOT_RESUME_ID=
|
||||
|
||||
# Vercel: APP_URL can be omitted; production uses VERCEL_PROJECT_PRODUCTION_URL.
|
||||
|
||||
# --- Database (PostgreSQL) ---
|
||||
# PostgreSQL connection URL. In Docker Compose, the hostname is usually `postgres`;
|
||||
# when running directly on your machine, `localhost` is typical.
|
||||
DATABASE_URL="postgresql://postgres:postgres@postgres:5432/postgres"
|
||||
|
||||
# Optional direct connection for migrations (Neon: DATABASE_URL_UNPOOLED alias).
|
||||
# DATABASE_MIGRATION_URL=""
|
||||
# DATABASE_POOL_MAX="10"
|
||||
|
||||
# When "true", the server refuses to boot if the live database schema has drifted from
|
||||
# the migration ledger (e.g. a table dropped outside migrations). Default "false" logs
|
||||
# the drift loudly at startup and continues.
|
||||
STRICT_SCHEMA_CHECK="false"
|
||||
|
||||
# --- Authentication ---
|
||||
# Generated using `openssl rand -hex 32`
|
||||
AUTH_SECRET="change-me-to-a-secure-secret-key-in-production"
|
||||
@@ -62,7 +77,15 @@ SMTP_FROM="Reactive Resume <noreply@rxresu.me>"
|
||||
SMTP_SECURE="false"
|
||||
|
||||
# --- Storage (optional) ---
|
||||
# If all S3 keys are disabled, the app uses local filesystem storage instead.
|
||||
# Backend defaults to S3 when all credentials are present, otherwise local.
|
||||
# Vercel defaults to private Blob. Explicit selection: local, s3, blob.
|
||||
# STORAGE_BACKEND="local"
|
||||
# BLOB_READ_WRITE_TOKEN=""
|
||||
# BLOB_STORE_ID=""
|
||||
# DEPLOYMENT_NAMESPACE="default"
|
||||
# Vercel previews need isolated resources before setting ALLOW_PREVIEW_MIGRATIONS=true.
|
||||
|
||||
# If all S3 keys are disabled, Docker uses local filesystem storage instead.
|
||||
# Make sure to mount this directory to a volume or the host filesystem to ensure data integrity.
|
||||
# LOCAL_STORAGE_PATH overrides where local uploads/cache are written.
|
||||
# Defaults to /app/data in the official Docker image; in dev, defaults to <workspace>/data.
|
||||
@@ -77,7 +100,9 @@ S3_BUCKET="reactive-resume"
|
||||
S3_FORCE_PATH_STYLE="true"
|
||||
|
||||
# --- AI Agent Workspace (optional) ---
|
||||
# Required only for the authenticated /agent workspace and saved AI providers.
|
||||
# Required for the authenticated /agent workspace and saved AI providers.
|
||||
# Redis also shares rate limits, resume events, cancellation and view deduplication.
|
||||
# Vercel Upstash KV_URL is accepted as an alias for REDIS_URL.
|
||||
REDIS_URL="redis://redis:6379"
|
||||
ENCRYPTION_SECRET="change-me-to-a-secure-agent-secret-in-production"
|
||||
|
||||
@@ -93,6 +118,11 @@ FLAG_DISABLE_EMAIL_AUTH="false"
|
||||
# This is useful if you are using a machine with limited resources, like a Raspberry Pi.
|
||||
FLAG_DISABLE_IMAGE_PROCESSING="false"
|
||||
|
||||
# This flag disables API rate limiting for authentication endpoints.
|
||||
# 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
|
||||
|
||||
@@ -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,4 @@
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404-arm
|
||||
@@ -0,0 +1,17 @@
|
||||
<!-- 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,26 @@ env:
|
||||
|
||||
jobs:
|
||||
autofix:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- 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.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
@@ -2,6 +2,11 @@ name: Build Docker Image
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release:
|
||||
description: Publish release aliases and redeploy production (false runs a cache-only build, then publishes a canary)
|
||||
type: boolean
|
||||
default: false
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
@@ -13,30 +18,39 @@ concurrency:
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
IMAGE: ${{ github.repository }}
|
||||
GHCR_IMAGE: ghcr.io/${{ github.repository }}
|
||||
DOCKER_IMAGE: docker.io/amruthpillai/reactive-resume
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
|
||||
|
||||
jobs:
|
||||
mode:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
|
||||
outputs:
|
||||
nightly: ${{ steps.mode.outputs.nightly }}
|
||||
release: ${{ steps.mode.outputs.release }}
|
||||
matrix: ${{ steps.mode.outputs.matrix }}
|
||||
canary: ${{ steps.mode.outputs.canary }}
|
||||
|
||||
steps:
|
||||
- name: Determine publishing mode
|
||||
id: mode
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
GIT_REF: ${{ github.ref }}
|
||||
RELEASE: ${{ inputs.release }}
|
||||
run: |
|
||||
if [[ "${{ github.event_name }}" == "push" && "${{ github.ref }}" == "refs/heads/main" ]]; then
|
||||
if [[ "$EVENT_NAME" == "push" && "$GIT_REF" == "refs/heads/main" ]]; then
|
||||
echo "nightly=true" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"}]}' >> "$GITHUB_OUTPUT"
|
||||
echo "canary=false" >> "$GITHUB_OUTPUT"
|
||||
elif [[ "$EVENT_NAME" == "workflow_dispatch" && "$RELEASE" != "true" ]]; then
|
||||
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||
echo "release=false" >> "$GITHUB_OUTPUT"
|
||||
echo "canary=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "nightly=false" >> "$GITHUB_OUTPUT"
|
||||
echo "release=true" >> "$GITHUB_OUTPUT"
|
||||
echo 'matrix={"include":[{"platform":"linux/amd64","runner":"ubuntu-latest","arch":"amd64"},{"platform":"linux/arm64","runner":"ubuntu-24.04-arm","arch":"arm64"}]}' >> "$GITHUB_OUTPUT"
|
||||
echo "canary=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
@@ -44,7 +58,14 @@ jobs:
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix: ${{ fromJSON(needs.mode.outputs.matrix) }}
|
||||
matrix:
|
||||
include:
|
||||
- platform: linux/amd64
|
||||
runner: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
arch: amd64
|
||||
- platform: linux/arm64
|
||||
runner: ${{ vars.CI_RUNNER_ARM64 || 'ubuntu-24.04-arm' }}
|
||||
arch: arm64
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
@@ -59,14 +80,38 @@ jobs:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Get version from package.json
|
||||
id: version
|
||||
run: echo "version=$(jq -r .version package.json)" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- ®istries
|
||||
name: Determine registries
|
||||
id: registries
|
||||
env:
|
||||
DOCKER_USERNAME: ${{ secrets.DOCKER_USERNAME }}
|
||||
DOCKER_PASSWORD: ${{ secrets.DOCKER_PASSWORD }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
dockerhub=false
|
||||
ghcr_image="${GHCR_IMAGE,,}"
|
||||
docker_image="${DOCKER_IMAGE,,}"
|
||||
images="$ghcr_image"
|
||||
if [[ -n "$DOCKER_USERNAME" && -n "$DOCKER_PASSWORD" ]]; then
|
||||
dockerhub=true
|
||||
images="${images}"$'\n'"$docker_image"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "ghcr_image=$ghcr_image"
|
||||
echo "docker_image=$docker_image"
|
||||
echo "images<<EOF"
|
||||
echo "$images"
|
||||
echo "EOF"
|
||||
echo "dockerhub=$dockerhub"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
@@ -83,12 +128,18 @@ 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' }}
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
platforms: ${{ matrix.platform }}
|
||||
outputs: type=cacheonly
|
||||
|
||||
- name: Build and Push by Digest
|
||||
id: build
|
||||
uses: docker/build-push-action@v7
|
||||
@@ -101,8 +152,6 @@ 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: Export digest
|
||||
run: |
|
||||
@@ -123,7 +172,11 @@ jobs:
|
||||
- mode
|
||||
- build
|
||||
timeout-minutes: 30
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || '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
|
||||
@@ -152,7 +205,10 @@ jobs:
|
||||
- name: Setup Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- *registries
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ steps.registries.outputs.dockerhub == 'true' }}
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
@@ -179,11 +235,10 @@ jobs:
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: |
|
||||
ghcr.io/${{ env.IMAGE }}
|
||||
docker.io/${{ env.IMAGE }}
|
||||
images: ${{ steps.registries.outputs.images }}
|
||||
tags: |
|
||||
type=sha,prefix=sha-
|
||||
type=raw,value=canary-${{ github.run_id }}-${{ github.run_attempt }},enable=${{ needs.mode.outputs.canary == 'true' }}
|
||||
type=raw,value=nightly,enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=nightly-{{date 'YYYYMMDDHHmmss' tz='UTC'}},enable=${{ needs.mode.outputs.nightly == 'true' }}
|
||||
type=raw,value=latest,enable=${{ needs.mode.outputs.release == 'true' }}
|
||||
@@ -199,6 +254,8 @@ jobs:
|
||||
|
||||
if [[ "${{ needs.mode.outputs.nightly }}" == "true" ]]; then
|
||||
FINAL_TAG="nightly"
|
||||
elif [[ "${{ needs.mode.outputs.canary }}" == "true" ]]; then
|
||||
FINAL_TAG="canary-${{ github.run_id }}-${{ github.run_attempt }}"
|
||||
else
|
||||
FINAL_TAG="v${{ steps.version.outputs.version }}"
|
||||
fi
|
||||
@@ -211,17 +268,19 @@ jobs:
|
||||
--annotation "index:org.opencontainers.image.vendor=Amruth Pillai" \
|
||||
--annotation "index:org.opencontainers.image.url=https://rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.documentation=https://docs.rxresu.me" \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/amruthpillai/reactive-resume" \
|
||||
--annotation "index:org.opencontainers.image.source=https://github.com/${{ github.repository }}" \
|
||||
--annotation "index:org.opencontainers.image.version=${{ steps.version.outputs.version }}" \
|
||||
$(printf 'ghcr.io/${{ env.IMAGE }}@sha256:%s ' *) \
|
||||
$(printf 'docker.io/${{ env.IMAGE }}@sha256:%s ' *)
|
||||
$(printf '${{ steps.registries.outputs.ghcr_image }}@sha256:%s ' *)
|
||||
|
||||
# Get the digest of the multi-arch manifest
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
GHCR_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
echo "final_tag=$FINAL_TAG" >> "$GITHUB_OUTPUT"
|
||||
echo "ghcr_digest=$GHCR_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
DOCKER_DIGEST=$(docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${FINAL_TAG} --format '{{json .Manifest.Digest}}' | tr -d '"')
|
||||
echo "docker_digest=$DOCKER_DIGEST" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Install Cosign
|
||||
uses: sigstore/cosign-installer@v3
|
||||
@@ -229,18 +288,40 @@ jobs:
|
||||
- name: Sign images with Cosign
|
||||
run: |
|
||||
# Sign GHCR image
|
||||
cosign sign --yes ghcr.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.ghcr_digest }}
|
||||
cosign sign --yes ${{ steps.registries.outputs.ghcr_image }}@${{ steps.manifest.outputs.ghcr_digest }}
|
||||
|
||||
# Sign Docker Hub image
|
||||
cosign sign --yes docker.io/${{ env.IMAGE }}@${{ steps.manifest.outputs.docker_digest }}
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
# Sign Docker Hub image
|
||||
cosign sign --yes ${{ steps.registries.outputs.docker_image }}@${{ steps.manifest.outputs.docker_digest }}
|
||||
fi
|
||||
|
||||
- name: Inspect image
|
||||
run: |
|
||||
docker buildx imagetools inspect ghcr.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
docker buildx imagetools inspect docker.io/${{ env.IMAGE }}:${{ steps.manifest.outputs.final_tag }}
|
||||
docker buildx imagetools inspect ${{ steps.registries.outputs.ghcr_image }}:${{ steps.manifest.outputs.final_tag }}
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
docker buildx imagetools inspect ${{ steps.registries.outputs.docker_image }}:${{ steps.manifest.outputs.final_tag }}
|
||||
fi
|
||||
|
||||
- name: Verify anonymous pulls on both architectures
|
||||
run: |
|
||||
set -euo pipefail
|
||||
registry_config=$(mktemp -d)
|
||||
trap 'rm -rf "$registry_config"' EXIT
|
||||
# Prevent Docker from discovering a system credential helper.
|
||||
printf '%s\n' '{"auths":{"ghcr.io":{},"https://index.docker.io/v1/":{}}}' > "$registry_config/config.json"
|
||||
images=("${{ steps.registries.outputs.ghcr_image }}")
|
||||
if [[ "${{ steps.registries.outputs.dockerhub }}" == "true" ]]; then
|
||||
images+=("${{ steps.registries.outputs.docker_image }}")
|
||||
fi
|
||||
for image in "${images[@]}"; do
|
||||
for platform in linux/amd64 linux/arm64; do
|
||||
docker --config "$registry_config" pull --quiet --platform "$platform" \
|
||||
"$image:${{ steps.manifest.outputs.final_tag }}"
|
||||
done
|
||||
done
|
||||
|
||||
- name: Redeploy Stack
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
if: ${{ needs.mode.outputs.release == 'true' && env.DEPLOY == 'true' }}
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
key: ${{ secrets.SSH_KEY }}
|
||||
@@ -251,7 +332,7 @@ jobs:
|
||||
./manage_stack.sh up reactive_resume
|
||||
|
||||
- name: Purge Cloudflare cache
|
||||
if: ${{ needs.mode.outputs.release == 'true' }}
|
||||
if: ${{ needs.mode.outputs.release == 'true' && env.PURGE_CLOUDFLARE == 'true' }}
|
||||
env:
|
||||
CLOUDFLARE_ZONE_ID: ${{ secrets.CLOUDFLARE_ZONE_ID }}
|
||||
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
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
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || '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
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Install pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: ".nvmrc"
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install Dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- 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
|
||||
|
||||
# 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,34 @@
|
||||
name: Label New Issues
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
label:
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
|
||||
steps:
|
||||
- name: Checkout Repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7
|
||||
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.CI_RUNNER_X64 || '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,99 @@
|
||||
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.CI_RUNNER_X64 || '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:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: pnpm/action-setup@v6
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version-file: .nvmrc
|
||||
cache: pnpm
|
||||
- run: pnpm install --frozen-lockfile
|
||||
# Local project settings avoid authentication and API calls. Forks receive no cloud credentials.
|
||||
- name: Build Vercel artifact against isolated PostgreSQL
|
||||
run: |
|
||||
mkdir -p .vercel
|
||||
node --input-type=module - <<'JS'
|
||||
import { writeFileSync } from 'node:fs';
|
||||
writeFileSync('.vercel/project.json', JSON.stringify({
|
||||
projectId: 'prj_ci', orgId: 'team_ci', projectName: 'reactive-resume-ci',
|
||||
settings: { framework: null, nodeVersion: '24.x', createdAt: 0 }
|
||||
}));
|
||||
JS
|
||||
pnpm dlx --allow-build=esbuild vercel@60.0.1 build --prod --yes --global-config "$RUNNER_TEMP/vercel-offline"
|
||||
- name: Check Lambda module loading and function budget
|
||||
run: |
|
||||
node --no-experimental-require-module --input-type=module - <<'JS'
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
const config = JSON.parse(readFileSync('.vercel/output/functions/api/index.func/.vc-config.json'));
|
||||
assert.equal(config.runtime, 'nodejs24.x');
|
||||
assert.equal(config.maxDuration, 300);
|
||||
const tracedFiles = Object.keys(config.filePathMap ?? {});
|
||||
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/standard-fonts/Helvetica.cjs')));
|
||||
assert.ok(tracedFiles.some((path) => path.endsWith('/pdfkit/js/data/Helvetica.afm')));
|
||||
for (const name of readdirSync('apps/server/dist')) {
|
||||
if (name.endsWith('.mjs') && !['index.mjs', 'prepare-deployment.mjs'].includes(name)) {
|
||||
await import(`./apps/server/dist/${name}`);
|
||||
}
|
||||
}
|
||||
const { default: app } = await import('./apps/server/dist/vercel.mjs');
|
||||
const response = await app.fetch(new Request('http://localhost:3000/api/storage/stage', { method: 'POST', body: '{}' }));
|
||||
assert.equal(response.status, 401);
|
||||
process.exit(0);
|
||||
JS
|
||||
|
||||
live-smoke:
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
runs-on: ${{ vars.CI_RUNNER_X64 || 'ubuntu-latest' }}
|
||||
environment: vercel-smoke
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
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
|
||||
+8
-1
@@ -36,6 +36,8 @@ logs
|
||||
# Testing
|
||||
coverage
|
||||
reports
|
||||
playwright-report
|
||||
test-results
|
||||
|
||||
# Cache
|
||||
tmp
|
||||
@@ -47,12 +49,17 @@ temp
|
||||
.agents
|
||||
.claude
|
||||
.cursor
|
||||
.opencode
|
||||
.codegraph
|
||||
.superpowers
|
||||
.worktrees
|
||||
.migration
|
||||
graphify-out
|
||||
|
||||
# Local Storage Data
|
||||
/data
|
||||
/apps/web/data
|
||||
|
||||
# Git Hooks
|
||||
.vite-hooks/
|
||||
.vite-hooks
|
||||
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
config:
|
||||
default: true
|
||||
MD007: false
|
||||
MD009: false
|
||||
MD010: false
|
||||
MD012: false
|
||||
MD013: false
|
||||
MD001: false
|
||||
MD022: false
|
||||
MD024: false
|
||||
MD025: false
|
||||
MD028: false
|
||||
MD031: false
|
||||
MD032: false
|
||||
MD033: false
|
||||
MD034: false
|
||||
MD036: false
|
||||
MD040: false
|
||||
MD041: false
|
||||
MD046: false
|
||||
MD060: false
|
||||
|
||||
frontMatter: "^---[\\s\\S]*?---"
|
||||
gitignore: true
|
||||
globs:
|
||||
- "**/*.{md,mdx}"
|
||||
ignores:
|
||||
- ".design-sync/**"
|
||||
- "node_modules/**"
|
||||
- ".turbo/**"
|
||||
- "dist/**"
|
||||
-17
@@ -1,17 +0,0 @@
|
||||
// @ts-check
|
||||
|
||||
const betaPackages = ["drizzle-zod"];
|
||||
const rcPackages = ["drizzle-orm", "drizzle-kit"];
|
||||
|
||||
/** @type {import('npm-check-updates').RunOptions} */
|
||||
module.exports = {
|
||||
upgrade: true,
|
||||
workspaces: true,
|
||||
install: "always",
|
||||
packageManager: "pnpm",
|
||||
target: (packageName) => {
|
||||
if (betaPackages.includes(packageName)) return "@beta";
|
||||
if (rcPackages.includes(packageName)) return "@rc";
|
||||
return "latest";
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
# Imported-table raster CI fix
|
||||
|
||||
## Root cause
|
||||
|
||||
Hosted runs `34007560930` (PR #3471) and `34007788443` (PR #3472) failed only in
|
||||
`tests/e2e/specs/imported-table.spec.ts` with `horizontal: 18` instead of Plan 16's
|
||||
`horizontal: 17`; text and vertical checks passed.
|
||||
|
||||
The PDF operator dump from the failed hosted artifact showed 29 table path records matching the Plan 16 contract
|
||||
(17 horizontal, 12 vertical), followed by an unrelated `constructPath` `endPath` bbox:
|
||||
`[0, 19.65, 358.93, 20.65]`. Its stroke color was reported as `#cc00cc` only because the helper retained the
|
||||
last table stroke color. It was a later red section-divider fill/no-paint path, not an extra table border. The old
|
||||
helper classified every thin bbox after the last matching color state, so it counted this false positive.
|
||||
|
||||
The table's explicit width is stable at 300pt, while row height legitimately changes from 30pt to 31pt after the
|
||||
`Beta!` edit. The helper therefore scopes candidate paths by the fixture's 300pt horizontal grid envelope, not by a
|
||||
row-height tolerance. Missing or duplicated paths inside that envelope still change the exact 17/12 contract.
|
||||
|
||||
## Change
|
||||
|
||||
- Added `tests/e2e/fixtures/pdf-borders.ts` with deterministic `countTableBorderGeometry` filtering.
|
||||
- Updated browser/server PDF inspection in `tests/e2e/specs/imported-table.spec.ts` to use the helper.
|
||||
- Added `tests/e2e/fixtures/pdf-borders.test.ts`; regression proves old stale-color counting returns 2 horizontal
|
||||
paths while topology-scoped counting returns 1.
|
||||
|
||||
## Verification
|
||||
|
||||
- Intent skill inventory: 7 packages, 26 skills; no matching local skill for this E2E/PDF helper.
|
||||
- Focused helper regression: 1 file, 1 passed.
|
||||
- Dedicated imported-table E2E: 2 consecutive runs, each 1 passed; both exercise initial, unrelated-edit, and table-edit
|
||||
stages plus browser and server PDF exports.
|
||||
- Production build: 3/3 tasks successful.
|
||||
- Web typecheck via `rtk proxy pnpm --filter web typecheck`: passed (`tsgo --noEmit`).
|
||||
- Turbo boundaries: 1,443 files across 20 packages, no issues.
|
||||
- Targeted Biome: 3 files, no issues.
|
||||
- `git diff --check`: passed.
|
||||
|
||||
The root `pnpm typecheck` wrapper was also tried but invokes an incompatible `tsc` path and reports TS5096 for
|
||||
`allowImportingTsExtensions`; the package's documented `tsgo --noEmit` typecheck passes.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Plan 21 implementation evidence
|
||||
|
||||
## Revision and scope
|
||||
|
||||
- Worktree: `issue-3060-section-heading-visibility`
|
||||
- Base: current `origin/main` at dispatch, `2a4a1583b` (`fix(pdf): restore Gengar skill rating order (#3473)`)
|
||||
- Product decisions applied: Q1 explicit Show heading toggle; Q2 Move-to continuations default visible; Q3 visual omission in preview/PDF/DOCX with accessible outline labels retained.
|
||||
- No `.codegraph/` directory exists in this worktree, so CodeGraph was skipped after the required presence check.
|
||||
- Intent discovery ran before edits; no matching local skill was available for this schema/PDF/DOCX/web change.
|
||||
|
||||
## Implementation
|
||||
|
||||
- Added backward-compatible `showHeading` section data for summary, built-ins, and custom sections. `parseResumeData` normalizes absent legacy values to `true`; explicit `false` survives round trips.
|
||||
- Added heading toggles to built-in/summary and custom section menus. Toggle mutations use `useUpdateResumeData`, preserving undo/autosave/save/reload behavior; legacy absent values are treated as visible. Existing lock fieldset remains authoritative.
|
||||
- Move-to-created custom sections explicitly set `showHeading: true`, independent of source heading state or copied title.
|
||||
- `SectionShell` omits complete heading/icon/decoration output when disabled in both icon and no-icon branches. Empty titles still resolve localized defaults.
|
||||
- DOCX section renderers omit visible heading paragraphs for summary, built-in, and custom sections while retaining content. Screen-reader mirror continues to expose section labels regardless of visual setting.
|
||||
- Added characterization for Semantic CSS `section[id="..."] section-heading { display: none; }`; body remains while heading is omitted.
|
||||
- Updated default/sample fixtures, generated schema references, recovery hashes, and compatibility tests; existing Gengar renderer/order changes remain untouched.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @reactive-resume/schema test`: 9 files, 132 tests passed.
|
||||
- `pnpm --filter @reactive-resume/pdf test`: 81 files, 1059 tests passed.
|
||||
- `pnpm --filter @reactive-resume/docx test`: 9 files, 76 tests passed.
|
||||
- `pnpm --filter web test`: 135 files, 942 tests passed.
|
||||
- `pnpm test`: full Turborepo suite passed (19 successful tasks; 10 cache hits).
|
||||
- Affected typechecks passed: schema, PDF, DOCX, web.
|
||||
- Focused menu, Move-to, accessible-outline, schema, PDF semantic, and DOCX renderer tests passed.
|
||||
- `pnpm exec turbo boundaries`: passed (1108 files, 20 packages).
|
||||
- Read-only `pnpm exec biome check` on 22 changed source/test files: passed; no write-capable `pnpm check` run.
|
||||
- `git diff --check`: passed.
|
||||
- Final diff review completed; local commit follows.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Plan 23 item-pagination execution evidence
|
||||
|
||||
## Scope
|
||||
|
||||
Plan 23 steps 1–3 were evaluated from `origin/main` at `368858a56` (Plan 21 / PR #3477 merged). Widow/orphan UI and authored-page continuation guidance remain deferred from this execution, and Semantic CSS was not changed.
|
||||
|
||||
## Durable diagnostic matrix
|
||||
|
||||
`packages/pdf/src/templates/shared/item-pagination.test.tsx` renders physical PDF pages and checks numbered tokens exactly once for:
|
||||
|
||||
- an item that fits remaining space;
|
||||
- an item that fits a full page but not the remaining space;
|
||||
- an oversized item taller than one page;
|
||||
- a two-line paragraph at a boundary;
|
||||
- nested bullets; and
|
||||
- built-in plus custom items in an Azurill sidebar/main-column overflow fixture.
|
||||
|
||||
The fixture also keeps authored `metadata.layout.pages` separate from renderer-generated physical pages.
|
||||
|
||||
The current deterministic baseline is: fit remainder = 1 physical page; full-page-but-not-remainder = 3 pages with sampled tokens on pages 2/2/3; oversized = 5 pages with sampled tokens on pages 1/3/5; two-line boundary = 2 pages; nested bullets = 1 page; Azurill built-in/custom/sidebar = 5 pages with sampled tokens on pages 1/4/5/5/1. Page numbers here are 1-based; every token still appears exactly once.
|
||||
|
||||
## Concrete blocker
|
||||
|
||||
React PDF's only available item-level keep-together primitive is `View wrap={false}`. A durable renderer fixture with 180 paragraph-like child views shows that a non-wrapping item cannot safely fall back when its content exceeds one page: the renderer omits the oversized tail instead of splitting it. Applying the same prop to shared `SectionItem` would therefore violate the lossless token requirement; no item schema flag or menu control was added.
|
||||
|
||||
Do not estimate item height from HTML length, persist physical pages, alter existing Semantic CSS, or claim #3350 complete. A future implementation needs renderer-supported conditional keep-together behavior or an actual measured two-pass fallback that preserves every token.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/templates/shared/item-pagination.test.tsx`: 7 tests passed.
|
||||
- No production source or schema changes made after the unsafe fallback was reproduced.
|
||||
- Undo/persistence/lock UI coverage is intentionally absent because no item control was shipped; add it only when safe fallback exists.
|
||||
@@ -0,0 +1,157 @@
|
||||
# Plan 27 Phase A diagnostic evidence
|
||||
|
||||
Date: 2026-09-06
|
||||
Issue: [#3377](https://github.com/reactive-resume/reactive-resume/issues/3377)<br>
|
||||
Revision: `2a4a1583b` (`origin/main` at run start)
|
||||
Scope: Phase A, steps 1–2 only. No resolver, runtime behavior, or remote-source behavior changed.
|
||||
|
||||
## Drift and authority
|
||||
|
||||
- Worktree started clean and `HEAD` matched `origin/main`; `git diff origin/main...HEAD` was empty.
|
||||
- Current catalog is `packages/fonts/src/webfontlist.json`. Its web font records point at both Google Fonts static assets and jsDelivr assets; “Google blocked” is not an offline proof.
|
||||
- Browser preview is `apps/web/src/components/typography/font-display.tsx` and calls `FontFace.load()` against each catalog preview URL.
|
||||
- Browser PDF preview/download is `apps/web/src/features/resume/export/pdf-document.tsx` → `@reactive-resume/pdf/browser`; registration is `packages/pdf/src/hooks/use-register-fonts.ts`.
|
||||
- Server PDF is `apps/server/src/http/resume-pdf.ts` → `createResumePdfDownload`; Playwright browser routing cannot observe that process’s outbound font fetches.
|
||||
- The issue is open and unmodified. PR #3455 is the approved planning PR; its plan/decision log grants execution of this bounded diagnostic and manifest evidence.
|
||||
|
||||
## Deterministic fixture
|
||||
|
||||
`tests/e2e/fixtures/offline-fonts.ts` seeds one disposable resume after sample creation. It writes the same text into basics and summary, hides the picture, selects IBM Plex Serif 400/700 for body and heading, and marks the row public for the server-PDF surface.
|
||||
|
||||
The exact markers are versioned as `offline-font-scripts-v1`:
|
||||
|
||||
| Marker | Script or coverage |
|
||||
| --- | --- |
|
||||
| `Latin punctuation • — “quotes” €` | Latin plus General Punctuation and currency |
|
||||
| `简体中文` | Han / Simplified Chinese |
|
||||
| `العربية` | Arabic |
|
||||
| `עברית` | Hebrew |
|
||||
| `ไทย` | Thai |
|
||||
| `Emoji 🚀` | Emoji |
|
||||
|
||||
`tests/e2e/specs/offline-fonts.spec.ts` is opt-in (`OFFLINE_FONT_DIAGNOSTIC=1`) so the normal PR E2E suite does not become network-dependent. Each surface creates a new browser context with persisted auth state, disabled service workers, and no prior browser cache. Every non-same-origin request is aborted and recorded as `{ hostname, path }`; query strings, fragments, headers, bodies, tokens, and full URLs never enter diagnostic output. Reports are attached as JSON and emitted with the same sanitized shape.
|
||||
|
||||
The four surfaces are separate tests:
|
||||
|
||||
1. Font picker preview opens Typography → Font Family and waits for lazy `FontFace` preview loads.
|
||||
2. Builder PDF preview navigates to the builder, captures the active PDF canvas, and measures marker-local raster crops.
|
||||
3. Browser PDF download uses the Export dialog, rasterizes the downloaded PDF, and measures marker-local crops when generation succeeds.
|
||||
4. Server PDF calls the public PDF endpoint and records text-layer marker presence when generation succeeds.
|
||||
|
||||
Builder/browser-PDF reports keep PDF text extraction as a separate `textLayerMarkers` signal; it does not prove visible glyph outlines. Raster evidence attaches a rendered PNG and per-marker crop metrics, failing for blank or tofu-like visible crops. Blocked browser font requests classify browser surfaces as `network-error`. The server report deliberately says `server-outbound-requests-unobservable-from-playwright`; its cold-network gate remains unresolved because server outbound capture and verifiable restart identity require external host-level controls.
|
||||
|
||||
## Run protocol and cold-cache boundary
|
||||
|
||||
Build and database setup follow `tests/e2e/README.md`. Run each surface in a separately restarted production server process so module-level PDF font registration state cannot leak between controls:
|
||||
|
||||
```text
|
||||
OFFLINE_FONT_DIAGNOSTIC=1 OFFLINE_FONT_DIAGNOSTIC_SERVER_RESTARTED=1 \
|
||||
pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --grep "picker preview"
|
||||
```
|
||||
|
||||
Stop and restart the production server before repeating the command with `builder PDF`, `browser PDF`, and `server PDF` grep patterns. The environment used for this change had no built `apps/server/dist` or `apps/web/dist`, no running PostgreSQL instance, and no production server to restart, so the cold E2E matrix was not run. This is an explicit infrastructure blocker, not a pass claim. The test records `serverRestartFlag` only as caller input and labels it non-proof; it does not claim a completed cold-network gate.
|
||||
|
||||
The current Playwright route guard cannot impose host-level egress denial on Node.js running the server. A genuinely cold server test therefore needs a separately restarted server plus host-level egress capture/deny (for example, a controlled network namespace or an approved outbound proxy). Do not infer server network behavior from an empty browser request list.
|
||||
|
||||
## Administrator-hosted manifest proposal
|
||||
|
||||
This is a proposal, not an asset download. It intentionally contains only the primary family and glyph fallbacks required by the fixture and current PDF fallback map, not the full catalog.
|
||||
|
||||
```json
|
||||
{
|
||||
"schemaVersion": "offline-fonts-v1",
|
||||
"mode": "local-only",
|
||||
"assetRoot": "/fonts/offline/v1",
|
||||
"families": {
|
||||
"IBM Plex Serif": {
|
||||
"normal": { "400": "ibm-plex-serif/400.ttf", "700": "ibm-plex-serif/700.ttf" },
|
||||
"italic": { "400": "ibm-plex-serif/400-italic.ttf", "700": "ibm-plex-serif/700-italic.ttf" },
|
||||
"preview": "ibm-plex-serif/preview.ttf"
|
||||
},
|
||||
"IBM Plex Sans": {
|
||||
"normal": { "400": "ibm-plex-sans/400.ttf", "700": "ibm-plex-sans/700.ttf" },
|
||||
"italic": { "400": "ibm-plex-sans/400-italic.ttf", "700": "ibm-plex-sans/700-italic.ttf" },
|
||||
"preview": "ibm-plex-sans/preview.ttf"
|
||||
},
|
||||
"Noto Serif": {
|
||||
"normal": { "400": "noto-serif/400.ttf", "700": "noto-serif/700.ttf" },
|
||||
"italic": { "400": "noto-serif/400-italic.ttf", "700": "noto-serif/700-italic.ttf" },
|
||||
"preview": "noto-serif/preview.ttf"
|
||||
},
|
||||
"Noto Sans": {
|
||||
"normal": { "400": "noto-sans/400.ttf", "700": "noto-sans/700.ttf" },
|
||||
"italic": { "400": "noto-sans/400-italic.ttf", "700": "noto-sans/700-italic.ttf" },
|
||||
"preview": "noto-sans/preview.ttf"
|
||||
},
|
||||
"Noto Sans SC": { "normal": { "400": "noto-sans-sc/400.ttf", "700": "noto-sans-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-sc/preview.ttf" },
|
||||
"Noto Serif SC": { "normal": { "400": "noto-serif-sc/400.ttf", "700": "noto-serif-sc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-sc/preview.ttf" },
|
||||
"Noto Sans TC": { "normal": { "400": "noto-sans-tc/400.ttf", "700": "noto-sans-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-tc/preview.ttf" },
|
||||
"Noto Serif TC": { "normal": { "400": "noto-serif-tc/400.ttf", "700": "noto-serif-tc/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-tc/preview.ttf" },
|
||||
"Noto Sans JP": { "normal": { "400": "noto-sans-jp/400.ttf", "700": "noto-sans-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-jp/preview.ttf" },
|
||||
"Noto Serif JP": { "normal": { "400": "noto-serif-jp/400.ttf", "700": "noto-serif-jp/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-jp/preview.ttf" },
|
||||
"Noto Sans KR": { "normal": { "400": "noto-sans-kr/400.ttf", "700": "noto-sans-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-kr/preview.ttf" },
|
||||
"Noto Serif KR": { "normal": { "400": "noto-serif-kr/400.ttf", "700": "noto-serif-kr/700.ttf" }, "italic": "reuse-normal", "preview": "noto-serif-kr/preview.ttf" },
|
||||
"Noto Sans Arabic": { "normal": { "400": "noto-sans-arabic/400.ttf", "700": "noto-sans-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-arabic/preview.ttf" },
|
||||
"Noto Naskh Arabic": { "normal": { "400": "noto-naskh-arabic/400.ttf", "700": "noto-naskh-arabic/700.ttf" }, "italic": "reuse-normal", "preview": "noto-naskh-arabic/preview.ttf" },
|
||||
"Noto Sans Hebrew": { "normal": { "400": "noto-sans-hebrew/400.ttf", "700": "noto-sans-hebrew/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-hebrew/preview.ttf" },
|
||||
"Noto Sans Thai": { "normal": { "400": "noto-sans-thai/400.ttf", "700": "noto-sans-thai/700.ttf" }, "italic": "reuse-normal", "preview": "noto-sans-thai/preview.ttf" },
|
||||
"Noto Emoji": { "normal": { "400": "noto-emoji/400.ttf", "700": "noto-emoji/700.ttf" }, "italic": "reuse-normal", "preview": "noto-emoji/preview.ttf" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Candidate source, license, script, and size evidence
|
||||
|
||||
Sizes are `Content-Length` bytes from a HEAD request to the exact current catalog assets on 2026-09-06. Responses reported `Content-Encoding: gzip`; these are compressed transfer-size estimates, not a claim about the eventual on-disk representation. Preview paths are aliases to the selected 400 face and add no extra bytes when stored once. Primary sources: [IBM Plex LICENSE.txt](https://github.com/IBM/plex/blob/master/LICENSE.txt), [Noto core LICENSE](https://github.com/notofonts/noto-fonts/blob/main/LICENSE), [Noto CJK Sans LICENSE](https://github.com/notofonts/noto-cjk/blob/main/Sans/LICENSE), and [Noto Emoji font LICENSE](https://github.com/googlefonts/noto-emoji/blob/main/fonts/LICENSE).
|
||||
|
||||
| Family | Style/weights in proposal | Current catalog source version | License | Script/fallback role | Gzip bytes (selected files) | Build owner; runtime owners |
|
||||
| --- | --- | --- | --- | --- | ---: | --- |
|
||||
| IBM Plex Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexserif/v20` | OFL 1.1, Reserved Font Name `Plex` | Primary serif; Latin and punctuation stack | 294,717 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
|
||||
| IBM Plex Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/ibmplexsans/v23` | OFL 1.1, Reserved Font Name `Plex` | Primary sans | 435,469 | `packages/fonts`; `apps/web` FontDisplay; `packages/pdf` registration |
|
||||
| Noto Serif | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notoserif/v33` | OFL 1.1 | Serif punctuation fallback | 1,055,120 | `packages/fonts`; `packages/pdf` fallback registration |
|
||||
| Noto Sans | normal 400/700; italic 400/700 | `fonts.gstatic.com/s/notosans/v42` | OFL 1.1 | Sans punctuation fallback | 1,236,259 | `packages/fonts`; `packages/pdf` fallback registration |
|
||||
| Noto Sans SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanssc/v40` | OFL 1.1 (Noto CJK) | Simplified Han; CJK fallback | 12,766,416 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Serif SC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifsc/v35` | OFL 1.1 (Noto CJK) | Simplified Han serif fallback | 17,350,185 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Sans TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanstc/v39` | OFL 1.1 (Noto CJK) | Traditional Han fallback | 8,628,278 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Serif TC | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoseriftc/v36` | OFL 1.1 (Noto CJK) | Traditional Han serif fallback | 11,804,923 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Sans JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansjp/v56` | OFL 1.1 (Noto CJK) | Kana and Japanese Han fallback | 6,383,035 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Serif JP | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifjp/v33` | OFL 1.1 (Noto CJK) | Kana and Japanese Han serif fallback | 8,685,862 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Sans KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanskr/v39` | OFL 1.1 (Noto CJK) | Hangul and Korean Han fallback | 6,102,888 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Serif KR | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoserifkr/v31` | OFL 1.1 (Noto CJK) | Hangul and Korean Han serif fallback | 11,113,442 | `packages/fonts`; `packages/pdf` CJK fallback |
|
||||
| Noto Sans Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansarabic/v33` | OFL 1.1 | Arabic sans fallback | 178,455 | `packages/fonts`; `packages/pdf` script fallback |
|
||||
| Noto Naskh Arabic | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notonaskharabic/v44` | OFL 1.1 | Arabic serif fallback | 190,924 | `packages/fonts`; `packages/pdf` script fallback |
|
||||
| Noto Sans Hebrew | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosanshebrew/v50` | OFL 1.1 | Hebrew fallback for both serif/sans slots | 55,707 | `packages/fonts`; `packages/pdf` script fallback |
|
||||
| Noto Sans Thai | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notosansthai/v29` | OFL 1.1 | Thai fallback for both serif/sans slots | 55,173 | `packages/fonts`; `packages/pdf` script fallback |
|
||||
| Noto Emoji | normal 400/700; italic reuses normal | `fonts.gstatic.com/s/notoemoji/v62` | OFL 1.1 for font files; assets/tools have separate licenses | Emoji outline fallback; verify renderer support | 1,153,847 | `packages/fonts`; `packages/pdf` script fallback |
|
||||
|
||||
Estimated transfer size for all rows and listed styles: **87,490,700 bytes (~83.44 MiB)**. This confirms why a full-catalog bundle is out of scope. A later implementation should subset by declared glyph requirements or make the administrator choose fallback families; it must not silently fetch another CDN.
|
||||
|
||||
### Source and license obligations
|
||||
|
||||
- Pin an upstream release/commit and retain source attribution plus the complete applicable license with hosted assets. Do not use mutable `@latest` URLs as runtime sources.
|
||||
- IBM Plex’s license has Reserved Font Name `Plex`; modified/subset outputs must follow OFL naming requirements.
|
||||
- Noto core, Noto CJK, and Noto Emoji font files are OFL 1.1, but Noto Emoji documents separate Apache/public-domain treatment for tools and flag image assets. Bundle only font files unless those other assets are intentionally needed and separately attributed.
|
||||
- License checks are build-owner responsibility (`packages/fonts`/tooling); runtime owners (`apps/web` and `packages/pdf`) consume only the validated manifest.
|
||||
|
||||
### Missing-family and missing-asset behavior
|
||||
|
||||
Local mode must resolve only same-origin administrator-hosted manifest paths. If imported resume data names an unavailable family, show an actionable missing-family error naming the family and required local asset; apply a configured local fallback only when the administrator explicitly supplied one. If a required weight/style/fallback asset is absent, fail the affected preview/export with an actionable diagnostic containing family/style/weight and local path. Never retry Google Fonts, jsDelivr, or any other remote URL in local mode.
|
||||
|
||||
Standard PDF families (Helvetica, Courier, Times-Roman) remain file-free. They do not prove that a document containing punctuation, CJK, Arabic, Hebrew, Thai, or emoji is network-free; the script fallback rows remain required.
|
||||
|
||||
## Verification record
|
||||
|
||||
Completed read-only checks before handoff:
|
||||
|
||||
- CodeGraph exploration of font catalog, picker preview, browser PDF, server PDF, and existing fallback tests.
|
||||
- `pnpm dlx @tanstack/intent@latest list`: no matching local intent skill for this work.
|
||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts turbo.json`: passed.
|
||||
- `git diff --check`: passed.
|
||||
- `pnpm --filter @reactive-resume/fonts test`: passed (55 tests).
|
||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts`: passed (35 tests).
|
||||
- Web typography/regression suite: passed (940 tests across 135 files); web and server package typechecks passed.
|
||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list`: passed (4 diagnostic tests collected).
|
||||
- E2E diagnostic execution: blocked by missing build outputs and unavailable PostgreSQL/server; no success claim made.
|
||||
- `pnpm exec turbo boundaries`: passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
|
||||
|
||||
The implementation intentionally stops at diagnostic fixtures and manifest evidence. Shared source resolution, asset hosting, local-mode configuration, and production behavior remain Phase A step 3+ work.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Plan 27A remediation round 2
|
||||
|
||||
Date: 2026-09-06
|
||||
Base: `ae8e2f76f`
|
||||
|
||||
## Focused fixes
|
||||
|
||||
- Removed multilingual markers from the fixture headline. Each marker now exists only in its dedicated summary paragraph.
|
||||
- Added pure marker-location helpers. Marker lookup joins PDF text items, supports markers split across items, rejects duplicate occurrences, and rejects non-whitespace neighbors that could contaminate a local crop.
|
||||
- Raster measurement still scans with antialiasing padding but counts ink only inside the marker box, preventing neighboring glyphs from making blank or tofu-like evidence pass.
|
||||
- Browser PDF download now separates download errors from post-download evidence errors. A received download with failed rasterization is reported as `unresolved-raster-evidence-error` and fails the opt-in test rather than passing as a generic download error.
|
||||
- Added focused pure tests covering duplicate, split, neighboring, blank, and tofu-like cases.
|
||||
- Removed trailing spaces from `plan-27a-remediation.md`.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec vitest run tests/e2e/fixtures/offline-font-markers.test.ts` — 5/5 passed.
|
||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts tests/e2e/fixtures/offline-font-markers.ts tests/e2e/fixtures/offline-font-markers.test.ts` — passed.
|
||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
|
||||
- `git diff --check` — passed after remediation-document whitespace cleanup.
|
||||
|
||||
Full diagnostic E2E remains opt-in and was not run in this focused round. Server outbound request capture and verifiable restart identity remain explicit external host-level blockers; no production resolver changes were made.
|
||||
@@ -0,0 +1,24 @@
|
||||
# Plan 27A remediation
|
||||
|
||||
Date: 2026-09-06
|
||||
Base: `61b58ae9a`
|
||||
Scope: concrete findings from `.orchestration/plan-27a-independent-review.md` only.
|
||||
|
||||
## Remediated findings
|
||||
|
||||
- Builder PDF preview and browser PDF download now produce raster evidence. The fixture stores each multilingual marker in its own summary paragraph, allowing the diagnostic to locate marker-local PDF text boxes and measure only those raster crops. Reports attach a rendered PNG plus per-marker `inkPixels`, trimmed dimensions, and status. Blank and tofu-like crops fail assertions; no whole-page snapshot is used.
|
||||
- PDF text extraction is reported separately as `textLayerMarkers`. It is not described or asserted as proof of visible glyph outlines.
|
||||
- Server PDF output remains text-extraction-only and is explicitly classified as `serverGateStatus: unresolved-external-host-level-blocker`. `serverRestartFlag` is caller input, not restart proof. Browser Playwright routing is not used to infer server egress, and no production resolver or instrumentation behavior was added.
|
||||
- `.orchestration/plan-27a-diagnostic.md` now records a fresh boundaries pass and the corrected `87,490,700 bytes (~83.44 MiB)` arithmetic.
|
||||
- Diagnostic remains opt-in through `OFFLINE_FONT_DIAGNOSTIC=1`; normal CI behavior remains unchanged. Request logs stay sanitized to hostname and pathname.
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec biome check tests/e2e/specs/offline-fonts.spec.ts tests/e2e/fixtures/offline-fonts.ts` — passed.
|
||||
- `git diff --check` — passed.
|
||||
- `pnpm exec playwright test tests/e2e/specs/offline-fonts.spec.ts --list` — 4 tests collected.
|
||||
- `pnpm --filter @reactive-resume/fonts test` — 55/55 passed.
|
||||
- `pnpm --filter @reactive-resume/pdf exec vitest run src/hooks/use-register-fonts.test.ts` — 35/35 passed.
|
||||
- `pnpm exec turbo boundaries` — passed on fresh rerun (Turbo 2.10.12, 1108 files, no issues).
|
||||
|
||||
Full diagnostic E2E remains unrun because this environment lacks production build output, PostgreSQL, and a production server. Server cold-network capture and verifiable restart identity remain external host-level blockers by design; this remediation does not claim that gate is complete.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Issue 3350 remediation evidence
|
||||
|
||||
## Findings addressed
|
||||
|
||||
- `item-pagination.test.tsx` now derives complete numbered-token inventories for each generated fixture and asserts every token exactly once. Sampled token-to-physical-page placement checks remain separate.
|
||||
- Pagination fixtures snapshot `metadata.layout.pages` before rendering and assert authored layout pages are unchanged afterward. Overflow fixtures also assert physical PDF page count exceeds authored page count.
|
||||
- Unsafe `wrap={false}` renderer coverage remains diagnostic-only; no item controls, schema flags, or runtime behavior were added.
|
||||
|
||||
## Verification
|
||||
|
||||
- `rtk proxy pnpm --filter @reactive-resume/pdf exec vitest run src/semantic/pagination.test.tsx src/templates/shared/item-pagination.test.tsx` — 2 files, 11 tests passed.
|
||||
- `rtk proxy pnpm --filter @reactive-resume/pdf typecheck` — passed.
|
||||
- `rtk proxy pnpm exec biome check packages/pdf/src/templates/shared/item-pagination.test.tsx` — passed.
|
||||
- `rtk proxy pnpm exec turbo boundaries` — passed; 1109 files checked.
|
||||
- `rtk git diff --check origin/main...HEAD` — passed.
|
||||
|
||||
Only PDF test coverage and this evidence file changed; production behavior remains untouched.
|
||||
@@ -0,0 +1,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
|
||||
Vendored
+4
-1
@@ -27,5 +27,8 @@
|
||||
["cn\\(([^)]*)\\)", "(?:'|\"|`)([^']*)(?:'|\"|`)"]
|
||||
],
|
||||
"tailwindCSS.experimental.configFile": "src/styles/globals.css",
|
||||
"typescript.experimental.useTsgo": true
|
||||
"typescript.experimental.useTsgo": true,
|
||||
"[json]": {
|
||||
"editor.defaultFormatter": "biomejs.biome"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,157 +1,124 @@
|
||||
# AGENTS.md
|
||||
<!-- intent-skills:start -->
|
||||
## Skill Loading
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
Before editing files for a substantial task:
|
||||
- Run `pnpm dlx @tanstack/intent@latest list` from the workspace root to see available local skills.
|
||||
- If a listed skill matches the task, run `pnpm dlx @tanstack/intent@latest load <package>#<skill>` before changing files.
|
||||
- Use the loaded `SKILL.md` guidance while making the change.
|
||||
- Monorepos: when working across packages, run the skill check from the workspace root and prefer the local skill for the package being changed.
|
||||
- Multiple matches: prefer the most specific local skill for the package or concern you are changing; load additional skills only when the task spans multiple packages or concerns.
|
||||
<!-- intent-skills:end -->
|
||||
|
||||
### Overview
|
||||
<!-- caveman-begin -->
|
||||
Respond terse like smart caveman. All technical substance stay. Only fluff die.
|
||||
|
||||
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (TanStack Start / React 19 / Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000, with `apps/server` mounting the API/auth/MCP/static routes and serving the built web app.
|
||||
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:"
|
||||
|
||||
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.
|
||||
Switch level: /caveman lite|full|ultra|wenyan-lite|wenyan-full|wenyan-ultra
|
||||
Stop: "stop caveman" or "normal mode"
|
||||
|
||||
### Prerequisites
|
||||
Auto-Clarity: drop caveman for security warnings, irreversible actions, user confused. Resume after.
|
||||
|
||||
- **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.1.2** is managed via corepack (`corepack enable`).
|
||||
Boundaries: code/commits/PRs written normal.
|
||||
<!-- caveman-end -->
|
||||
|
||||
### Codebase map
|
||||
## Agent skills
|
||||
|
||||
- `apps/web` owns TanStack Start routes, Vite config, PWA setup, oRPC browser client wiring, web features, and the resume builder UI.
|
||||
- `apps/server` owns the production Hono app, route composition, auth/RPC/MCP/OpenAPI handlers, static uploads, schema JSON, web-dist fallback serving, and startup checks.
|
||||
- `packages/api` contains oRPC routers, DTOs, rate limiting, and feature-owned API modules under `packages/api/src/features/*`. The router export at `@reactive-resume/api/routers` aggregates those feature routers for `/api/rpc`.
|
||||
- `packages/auth` contains Better Auth config, auth helper functions, and exported auth types. The server auth adapter in `apps/server/src/http/auth.ts` delegates to `auth.handler`.
|
||||
- `packages/db` contains the Drizzle client and schema. Migration files live at the repo root in `migrations/`.
|
||||
- `packages/env` defines server environment validation and auto-loads the root `.env` for app/server code.
|
||||
- `packages/schema` contains Zod schemas and typed resume/page/template models.
|
||||
- `packages/pdf` contains the React PDF document, font registration, shared template primitives, template implementations, and browser/server PDF generation adapters. PDF.js viewer UI stays in `apps/web`.
|
||||
- `packages/resume` contains pure resume-domain behavior such as JSON Patch helpers and social-network icon mapping.
|
||||
- `packages/docx` contains DOCX export generation.
|
||||
- `packages/mcp` contains MCP tools, prompts, resources, server-card generation, and tool metadata.
|
||||
- `packages/ui` contains shared Base UI/shadcn-style components and hooks.
|
||||
- `packages/fonts`, `packages/email`, `packages/import`, `packages/ai`, `packages/utils`, and `packages/config` provide focused support surfaces. Prefer their existing exports over adding cross-package shortcuts.
|
||||
- Development-only scripts live in `tooling/`, not under `packages/`, so packages only contain code bundled by the app/runtime.
|
||||
- Issues and specs: GitHub Issues for `reactive-resume/reactive-resume`. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Web app conventions
|
||||
## Overview
|
||||
|
||||
- Routes are file-based under `apps/web/src/routes`. Do not hand-edit `apps/web/src/routeTree.gen.ts`; it is generated by TanStack Router tooling.
|
||||
- Server-owned HTTP behavior lives in `apps/server/src/{http,rpc,mcp,openapi,static,startup}`. Keep API/RPC/auth/MCP/static route wiring in `apps/server`, not in web routes.
|
||||
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context where possible instead of refetching these concerns ad hoc.
|
||||
- The builder shell lives under `apps/web/src/routes/builder/$resumeId`. The nested preview route is client-only (`ssr: false`), while the public resume route `apps/web/src/routes/$username/$slug.tsx` uses `ssr: "data-only"`.
|
||||
- Browser-only resume preview code lives under `apps/web/src/features/resume/preview`, and public resume PDF viewer code lives under `apps/web/src/features/resume/public`. Keep PDF.js/canvas/browser APIs out of SSR paths and out of `packages/pdf`.
|
||||
- The isomorphic oRPC client is in `apps/web/src/libs/orpc/client.ts`; server calls use an in-process router client and browser calls use `/api/rpc` with credentials included.
|
||||
- For React components with explicit props, prefer a named TypeScript props type over inline object annotations in the function signature, especially once the props include more than one field or generics. For example:
|
||||
Reactive Resume is a pnpm monorepo (Turborepo) with two deployable apps: `apps/web` (React 19 SPA with TanStack Router and Vite) and `apps/server` (Hono / Node.js). The production Docker image runs a single Node.js process on port 3000; `apps/server` mounts the API/auth/MCP/static routes and serves the built web app.
|
||||
|
||||
```ts
|
||||
type IntentSelectFieldProps<TValue extends string> = {
|
||||
label: string;
|
||||
id: string;
|
||||
value: TValue | undefined;
|
||||
options: readonly ComboboxOption<TValue>[];
|
||||
onChange: (value: TValue | undefined) => void;
|
||||
};
|
||||
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.
|
||||
|
||||
function IntentSelectField<TValue extends string>(props: IntentSelectFieldProps<TValue>) {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
Prerequisites: **Node.js 24** (pinned in `.nvmrc`; matches Dockerfile `ARG NODE_VERSION=24`), **pnpm 12.3.4** (pinned by `packageManager` in the root `package.json`; pnpm self-manages to it, so any recent pnpm can bootstrap — the Dockerfile's `ARG PNPM_VERSION` only picks the base image) ([install guide](https://pnpm.io/installation)), and **Docker** for PostgreSQL (`sudo dockerd &` if the daemon isn't running).
|
||||
|
||||
### Package and feature boundaries
|
||||
## Ownership map
|
||||
|
||||
- Workspace dependencies must go through package names and package export maps. Do not import another workspace's `src` tree through repository paths, `@reactive-resume/*/src/*`, or TypeScript path aliases.
|
||||
- `turbo boundaries` is the executable package-boundary check. Workspace-level `turbo.json` files declare coarse tags:
|
||||
- `app:web` for the TanStack Start app.
|
||||
- `app:server` and `runtime:server` for the Node/Hono process.
|
||||
- `runtime:server` for server-only packages such as API/auth/db/env/email/MCP.
|
||||
- `runtime:browser` for browser-only shared UI.
|
||||
- `runtime:universal` for environment-neutral domain packages.
|
||||
- `role:domain`, `role:infra`, `role:adapter`, `role:api`, `role:rendering`, and `role:tooling` for package intent.
|
||||
- Browser/server runtime-specific code should live behind explicit export subpaths such as `@reactive-resume/pdf/browser`, `@reactive-resume/pdf/server`, or `@reactive-resume/env/server`. Keep root exports environment-neutral unless the package is intentionally server-only.
|
||||
- Wildcard exports are allowed only for leaf libraries whose public surface is intentionally file-like, currently `@reactive-resume/ui/components/*`, `@reactive-resume/ui/hooks/*`, and schema resume model files. Prefer explicit exports for packages that own runtime behavior.
|
||||
- Add new API procedures and business logic inside the owning `packages/api/src/features/*` module. Keep route wiring, DTO usage, helpers, and services colocated by feature/capability, then expose only intentional public surfaces through `packages/api/package.json`. Prefer `protectedProcedure` from `packages/api/src/context.ts` for authenticated procedures.
|
||||
- Add database columns/tables in `packages/db/src/schema/*`, then generate root-level migrations with `dotenvx run -f .env.local -- pnpm db:generate`.
|
||||
- Add or change resume data shape in `packages/schema/src/resume/*` first, then update API DTOs, importers, PDF rendering, and web forms that consume that shape.
|
||||
- Add or rename templates in all relevant places: `packages/schema/src/templates.ts`, `packages/pdf/src/templates/index.ts`, template source under `packages/pdf/src/templates/<name>/`, and static previews under `apps/web/public/templates/{jpg,pdf}`.
|
||||
- Resume JSON Patch behavior belongs in `@reactive-resume/resume/patch`; do not put resume-domain helpers in `@reactive-resume/utils`.
|
||||
- DOCX export behavior belongs in `@reactive-resume/docx`; do not put DOCX builders in `@reactive-resume/utils`.
|
||||
- Shared PDF section filtering lives in `packages/pdf/src/templates/shared/filtering.ts`. Keep template-specific visual exceptions in the owning template directory unless multiple templates need the same behavior.
|
||||
- `packages/pdf/src/hooks/use-register-fonts.ts` owns React PDF font registration, standard PDF font handling, CJK fallback stacks, and global hyphenation behavior.
|
||||
- PDF generation helpers live behind `@reactive-resume/pdf/browser` and `@reactive-resume/pdf/server`; locale-specific section-title resolution stays in the caller.
|
||||
- MCP implementation belongs in `@reactive-resume/mcp`; app packages must not import MCP implementation from another app's source tree.
|
||||
- `packages/utils` has narrowly exported helpers. If another package needs a utility, add an explicit export path instead of importing private files.
|
||||
Where each concern lives, and where new code for it goes:
|
||||
|
||||
Placement decision tree:
|
||||
| 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 PDF document, font registration, template primitives, 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 |
|
||||
| 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 |
|
||||
|
||||
1. If the change is a web route, route loader, or user-facing web workflow, start in `apps/web/src/routes` or `apps/web/src/features`.
|
||||
2. If the change is a server HTTP route/adapter, startup check, static handler, MCP transport, or OpenAPI/well-known handler, start in `apps/server/src`.
|
||||
3. If it is authenticated API behavior, put the contract and implementation in the owning `packages/api/src/features/*` module.
|
||||
4. If it is pure resume data behavior with no DB, HTTP, DOM, or PDF renderer dependency, put it in `packages/resume`.
|
||||
5. If it renders resume PDFs, put shared React PDF/template code in `packages/pdf`; put PDF.js viewer/canvas UI in `apps/web/src/features/resume`.
|
||||
6. If it creates DOCX exports, put it in `packages/docx`.
|
||||
7. If it exposes MCP tools/prompts/resources, put it in `packages/mcp`.
|
||||
8. If it is a generic UI primitive or hook, put it in `packages/ui`; if it is workflow-specific UI, keep it in the owning web feature.
|
||||
9. If it is a narrow cross-cutting helper, add an explicit `packages/utils` export only after checking that no domain package is a better owner.
|
||||
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`.
|
||||
|
||||
### Database
|
||||
## Web app conventions
|
||||
|
||||
PostgreSQL runs via Docker Compose:
|
||||
- `apps/web/src/router.tsx` initializes router context with `queryClient`, `orpc`, `theme`, `locale`, `session`, and `flags`. Reuse route context instead of refetching these ad hoc.
|
||||
- The web app is a client-rendered SPA. `apps/server` serves `index.html` and injects page metadata (OpenGraph, canonical, JSON-LD) in `apps/server/src/static/web.ts`; there is no React SSR.
|
||||
- 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 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` owns font registration, standard PDF fonts, CJK fallback stacks, and global hyphenation.
|
||||
|
||||
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 `dotenvx run -f .env.local -- pnpm db:generate`.
|
||||
- **New env var**: `packages/env/src/server.ts` **and** the `globalEnv` array in `turbo.json`. Turborepo 2.x strict env mode filters out unlisted vars, so the variable will be `undefined` in child processes at runtime even when correctly set in the OS/container environment.
|
||||
|
||||
## Environment and database
|
||||
|
||||
Copy `.env.example` to `.env.local`. Three required vars: `APP_URL` (default `http://localhost:3000`), `DATABASE_URL` (default `postgresql://postgres:postgres@localhost:5432/postgres`), `AUTH_SECRET` (any non-empty string).
|
||||
|
||||
- **S3/SeaweedFS optional.** If `S3_ACCESS_KEY_ID`, `S3_SECRET_ACCESS_KEY`, and `S3_BUCKET` are all set, the app uses S3-compatible storage. `.env.example` ships SeaweedFS defaults, so either start the `seaweedfs` compose service or comment those vars out to use local filesystem storage under `<workspace>/data`. `LOCAL_STORAGE_PATH` must be absolute when set.
|
||||
- **`REDIS_URL` and `ENCRYPTION_SECRET`** are optional for core resume flows but both required for saved AI providers and the authenticated `/agent` workspace. Host-run dev uses `REDIS_URL=redis://localhost:6379`; the container-run app uses `redis://redis:6379`.
|
||||
- **`drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly** — it does not auto-load `.env`. Run migration commands through `dotenvx`.
|
||||
- The production server auto-runs migrations at startup before serving traffic, so manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||
|
||||
## Commands
|
||||
|
||||
Prefix dev servers and migration commands with `dotenvx run -f .env.local --`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need it; if one fails on a missing env var, rerun it with the prefix.
|
||||
|
||||
```
|
||||
sudo docker compose -f compose.dev.yml up -d postgres
|
||||
sudo docker compose -f compose.dev.yml up -d postgres # DB only
|
||||
sudo docker compose -f compose.dev.yml up -d postgres redis seaweedfs seaweedfs_create_bucket # full infra
|
||||
dotenvx run -f .env.local -- pnpm dev # port 3000 (dev:web for web only)
|
||||
dotenvx run -f .env.local -- pnpm db:generate # db:migrate to apply
|
||||
pnpm check # Biome — WRITE-CAPABLE (--write --unsafe)
|
||||
pnpm test | pnpm typecheck | pnpm build | pnpm exec turbo boundaries
|
||||
```
|
||||
|
||||
The dev default connection string is `postgresql://postgres:postgres@localhost:5432/postgres`.
|
||||
Prefer package filters over repo-wide runs, e.g. `pnpm --filter web typecheck`, `pnpm --filter @reactive-resume/pdf test`. Vitest paths are package-relative under `pnpm --filter <package> test -- <path>`.
|
||||
|
||||
**Important**: `drizzle-kit` (used by `pnpm db:migrate`) reads `DATABASE_URL` from `process.env` directly — it does **not** auto-load the `.env` file. Run migration commands through `dotenvx`, for example `dotenvx run -f .env.local -- pnpm db:migrate`, so `DATABASE_URL` is present in the process environment.
|
||||
## Gotchas
|
||||
|
||||
The production server runs migrations during startup before serving traffic. Manual `pnpm db:migrate` is mainly for first setup, migration debugging, or applying migrations without starting the app.
|
||||
|
||||
### Environment
|
||||
|
||||
Copy `.env.example` to `.env`. 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.
|
||||
|
||||
When running dev servers or migration commands, prefix the command with `dotenvx run -f .env.local --`. For example: `dotenvx run -f .env.local -- pnpm dev`. Tests, typechecks, linters, boundary checks, and `pnpm build` do not need this prefix by default. If one of those commands fails because a specific environment variable is required, rerun it with the `dotenvx run -f .env.local --` prefix.
|
||||
|
||||
### Common commands
|
||||
|
||||
| Task | Command |
|
||||
|------|---------|
|
||||
| Install deps | `pnpm install` |
|
||||
| Start Postgres only | `sudo docker compose -f compose.dev.yml up -d postgres` |
|
||||
| Start Postgres + SeaweedFS | `sudo docker compose -f compose.dev.yml up -d postgres seaweedfs seaweedfs_create_bucket` |
|
||||
| Generate migrations | `dotenvx run -f .env.local -- pnpm db:generate` |
|
||||
| Run migrations | `dotenvx run -f .env.local -- pnpm db:migrate` |
|
||||
| Dev server | `dotenvx run -f .env.local -- pnpm dev` (starts on port 3000) |
|
||||
| Web dev server only | `dotenvx run -f .env.local -- pnpm dev:web` |
|
||||
| Lint/format | `pnpm check` (Biome) |
|
||||
| Boundary check | `pnpm exec turbo boundaries` |
|
||||
| Tests | `pnpm test` (Vitest) |
|
||||
| Build | `pnpm build` |
|
||||
| Typecheck | `pnpm typecheck` |
|
||||
|
||||
For focused validation, prefer package filters before repo-wide commands, for example:
|
||||
|
||||
```
|
||||
pnpm --filter web typecheck
|
||||
pnpm --filter @reactive-resume/pdf test
|
||||
pnpm --filter @reactive-resume/api test
|
||||
pnpm exec turbo boundaries
|
||||
```
|
||||
|
||||
Vitest test paths are package-relative when running through `pnpm --filter <package> test -- <path>`.
|
||||
|
||||
### Gotchas
|
||||
|
||||
- The server startup path auto-runs migrations before serving traffic, so `pnpm db:migrate` is mainly needed for first-time setup, migration debugging, or applying migrations without starting the app.
|
||||
- Email sending requires SMTP config; without it, emails are logged to console. This is fine for dev — the app still functions, but email verification links appear in server logs.
|
||||
- The `lefthook.yml` pre-commit hook runs `biome check` on staged files. Run `pnpm check` before committing to avoid hook failures.
|
||||
- `pnpm check` is write-capable (`biome check --write --unsafe .`). Call that out when using it, and use narrower Biome commands if you need a non-mutating inspection.
|
||||
- Biome uses tabs, double quotes, line width 120, organized import groups, and sorted Tailwind classes for `clsx`, `cva`, and `cn`.
|
||||
- Most packages use `tsgo --noEmit` for typechecking and `vitest run --passWithNoTests` for tests.
|
||||
- There may be unrelated local edits in the worktree. Inspect `git status --short` first and avoid reverting files you did not touch.
|
||||
- **New env vars require a `turbo.json` entry.** Turborepo 2.x runs in strict env mode by default — it filters out env vars that are not listed in `globalEnv` (or task-level `env`/`passThroughEnv`). Any new environment variable added to `packages/env/src/server.ts` must also be added to the `globalEnv` array in `turbo.json`, or the variable will be `undefined` inside child processes at runtime even if it is correctly set in the OS/container environment.
|
||||
- Email sending needs SMTP config; without it emails are logged to console. Dev still works — verification links appear in server logs.
|
||||
- `lefthook.yml` pre-commit runs `biome check` on staged files. Run `pnpm check` before committing.
|
||||
- `pnpm check` is write-capable. Call that out when using it, and use narrower Biome commands for a non-mutating inspection.
|
||||
- Biome: tabs, double quotes, line width 120, organized import groups, sorted Tailwind classes for `clsx`, `cva`, `cn`.
|
||||
- Most packages typecheck with `tsgo --noEmit` and test with `vitest run --passWithNoTests`.
|
||||
- There may be unrelated local edits in the worktree. Check `git status --short` first; do not revert files you did not touch.
|
||||
|
||||
@@ -226,21 +226,24 @@ A custom Tailwind token `--aspect-page: 210 / 297` enforces A4 paper proportions
|
||||
|
||||
## Animation
|
||||
|
||||
Animations use the Motion library (formerly Framer Motion) and follow a consistent choreography pattern:
|
||||
Motion exists to explain a change, confirm an action, or soften a jump. This is a tool people use for hours, so it stays crisp: short, precise, rarely decorative.
|
||||
|
||||
**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.
|
||||
**Frequency decides first.** Keyboard-initiated actions (the command palette, ⌘B sidebar toggle, zoom shortcuts, keyboard-opened menus via Base UI's `data-instant`) do not animate. Things hit tens of times a day (list rows, tooltips after the first, context menus) get opacity-only or no motion. Dialogs, sheets and toasts get a standard transition. Only rare moments (marketing pages, first load) get more.
|
||||
|
||||
**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.
|
||||
**Tokens.** Never hand-type a curve.
|
||||
- CSS: `ease-out-strong` / `var(--ease-out-strong)` (`cubic-bezier(0.23, 1, 0.32, 1)`) for anything entering, exiting or responding; `ease-in-out-strong` for on-screen movement nobody is waiting on (ambient loops, carousels); `ease-drawer` for sheets. Never `ease-in`.
|
||||
- Motion (JS): `EASE_OUT_STRONG` from `apps/web/src/libs/motion.ts`.
|
||||
|
||||
**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.
|
||||
**Durations.** Press feedback 100–160ms, tooltips/popovers/menus 150ms in and 100ms out, dialogs 200ms in and 150ms out, sheets 300ms. App UI stays under 300ms; marketing reveals may run 0.5–0.9s. Exits are faster than entrances.
|
||||
|
||||
**Ambient animations** loop infinitely with `easeInOut` — the scroll indicator bounces gently (`y: [0, 5, 0]` over 1.5s).
|
||||
**Mechanics.**
|
||||
- Popups use interruptible CSS transitions on Base UI's `data-starting-style` / `data-ending-style`, scale from `0.95` (never `0`) and grow from `origin-(--transform-origin)`. Modals stay centred.
|
||||
- Animate `transform`/`translate`/`scale` and `opacity` only. No `transition-all`, no permanent `will-change` (Motion promotes layers while it animates).
|
||||
- Presses use `active:scale-[0.97]`. `Button` already has it; don't wrap it in Motion hover/tap wrappers.
|
||||
- Lists use `AnimatePresence initial={false}` so items animate when added or removed, not every time the list mounts.
|
||||
- Continuous loops (marquees, drifting spotlights) are CSS keyframes, so they run off the main thread.
|
||||
|
||||
**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.
|
||||
**Reduced motion:** `MotionConfig reducedMotion="user"` disables Motion transforms, and CSS transitions and animations collapse to `0.01ms` — except `animate-spin`, which keeps spinning so loading never looks frozen. Values driven by `useSpring`/`useMotionValue` bypass `MotionConfig`, so check `useReducedMotion()` there.
|
||||
|
||||
## Elevation & Depth
|
||||
|
||||
|
||||
+9
-8
@@ -1,17 +1,18 @@
|
||||
# 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
|
||||
|
||||
FROM node:${NODE_VERSION}-slim AS base
|
||||
FROM ghcr.io/pnpm/pnpm:${PNPM_VERSION} AS base
|
||||
|
||||
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" \
|
||||
TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN corepack enable
|
||||
ENV TURBO_TELEMETRY_DISABLED=1
|
||||
|
||||
FROM base AS pruner
|
||||
COPY . .
|
||||
@@ -47,7 +48,7 @@ LABEL org.opencontainers.image.description="A free and open-source resume builde
|
||||
LABEL org.opencontainers.image.vendor="Amruth Pillai"
|
||||
LABEL org.opencontainers.image.url="https://rxresu.me"
|
||||
LABEL org.opencontainers.image.documentation="https://docs.rxresu.me"
|
||||
LABEL org.opencontainers.image.source="https://github.com/amruthpillai/reactive-resume"
|
||||
LABEL org.opencontainers.image.source="https://github.com/reactive-resume/reactive-resume"
|
||||
|
||||
ENV NODE_ENV="production" \
|
||||
PORT=3000 \
|
||||
|
||||
+7
-7
@@ -1,19 +1,19 @@
|
||||
# syntax=docker/dockerfile:1.7
|
||||
|
||||
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 patches ./patches
|
||||
COPY apps/server/package.json ./apps/server/package.json
|
||||
|
||||
+260
@@ -0,0 +1,260 @@
|
||||
# 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 Cloud.
|
||||
|
||||
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 a resume section, not a separate
|
||||
document.
|
||||
|
||||
**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
|
||||
`apps/web/src/routes/builder/$resumeId/-sidebar/right/sections/ats-check.tsx`. 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 strings say "public URL" rather than "public link".
|
||||
|
||||
## 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 --depth=1 https://github.com/amruthpillai/reactive-resume.git
|
||||
git clone --depth=1 https://github.com/reactive-resume/reactive-resume.git reactive-resume
|
||||
cd reactive-resume
|
||||
|
||||
# Start all services
|
||||
@@ -153,15 +159,13 @@ docker compose up -d
|
||||
open http://localhost:3000
|
||||
```
|
||||
|
||||
[](https://app.ona.com/#https://github.com/amruthpillai/reactive-resume)
|
||||
|
||||
For detailed setup instructions, environment configuration, and self-hosting guides, see the [documentation](https://docs.rxresu.me).
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
| ---------------- | ------------------------------- |
|
||||
| Framework | TanStack Start (React 19, Vite) |
|
||||
| Framework | TanStack Router (React 19, Vite) |
|
||||
| Runtime | Node.js |
|
||||
| Language | TypeScript |
|
||||
| Database | PostgreSQL with Drizzle ORM |
|
||||
@@ -173,24 +177,30 @@ For detailed setup instructions, environment configuration, and self-hosting gui
|
||||
|
||||
## Documentation
|
||||
|
||||
Comprehensive guides are available at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
The full documentation lives at [docs.rxresu.me](https://docs.rxresu.me):
|
||||
|
||||
| Guide | Description |
|
||||
| ---------------------------------------------------------------------------- | -------------------------------- |
|
||||
| [Getting Started](https://docs.rxresu.me/getting-started) | First-time setup and basic usage |
|
||||
| [Self-Hosting](https://docs.rxresu.me/self-hosting/docker) | Deploy on your own server |
|
||||
| [Development Setup](https://docs.rxresu.me/contributing/development) | Local development environment |
|
||||
| [Project Architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
|
||||
| [Development setup](https://docs.rxresu.me/contributing/development) | Local development environment |
|
||||
| [Project architecture](https://docs.rxresu.me/contributing/architecture) | Codebase structure and patterns |
|
||||
| [Exporting Your Resume](https://docs.rxresu.me/guides/exporting-your-resume) | PDF and JSON export options |
|
||||
|
||||
## Self-Hosting
|
||||
|
||||
Reactive Resume can be self-hosted using Docker. The stack includes:
|
||||
Reactive Resume supports Docker and Vercel Hobby.
|
||||
|
||||
[](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2Freactive-resume%2Freactive-resume&project-name=reactive-resume&repository-name=reactive-resume&env=AUTH_SECRET%2CENCRYPTION_SECRET&envDescription=Generate+two+independent+secrets+with+openssl+rand+-hex+32.+Keep+these+values+across+deployments.&envLink=https%3A%2F%2Fdocs.rxresu.me%2Fself-hosting%2Fvercel&stores=%5B%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22neon%22%2C%22productSlug%22%3A%22neon%22%7D%2C%7B%22type%22%3A%22integration%22%2C%22protocol%22%3A%22storage%22%2C%22integrationSlug%22%3A%22upstash%22%2C%22productSlug%22%3A%22upstash-kv%22%7D%2C%7B%22type%22%3A%22blob%22%2C%22access%22%3A%22private%22%7D%5D)
|
||||
|
||||
Vercel provisions Neon PostgreSQL, private Blob storage, and Upstash Redis through its deployment wizard. Supply two persistent secrets, then deploy. See the [Vercel guide](docs/self-hosting/vercel.mdx) for setup, limits, and optional SMTP/OAuth configuration.
|
||||
|
||||
For Docker, the stack includes:
|
||||
|
||||
- **PostgreSQL** — Database for storing user data and resumes
|
||||
- **SeaweedFS** (optional) — S3-compatible storage for file uploads
|
||||
|
||||
> **From v5.1.0 onwards** — PDF generation now runs entirely client-side via `@react-pdf/renderer`. New deployments no longer require Browserless, Chromium, or any external print service as a dependency. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
|
||||
> **From v5.1.0 onwards** — PDF generation runs entirely client-side via `@react-pdf/renderer`. New deployments no longer need Browserless, Chromium, or any external print service. The `PRINTER_*` and `BROWSERLESS_*` environment variables are no longer read and can be removed from your `.env`.
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from "../apps/server/dist/vercel.mjs";
|
||||
+66
-48
@@ -7,6 +7,7 @@
|
||||
"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",
|
||||
@@ -17,25 +18,36 @@
|
||||
"#react-pdf-renderer": "@react-pdf/renderer"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^3.0.81",
|
||||
"@ai-sdk/google": "^3.0.80",
|
||||
"@ai-sdk/openai": "^3.0.67",
|
||||
"@ai-sdk/openai-compatible": "^2.0.48",
|
||||
"@aws-sdk/client-s3": "^3.1057.0",
|
||||
"@better-auth/api-key": "^1.6.13",
|
||||
"@better-auth/drizzle-adapter": "^1.6.13",
|
||||
"@better-auth/infra": "^0.2.11",
|
||||
"@better-auth/oauth-provider": "^1.6.13",
|
||||
"@better-auth/passkey": "^1.6.13",
|
||||
"@hono/node-server": "^2.0.4",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@orpc/client": "^1.14.4",
|
||||
"@orpc/experimental-ratelimit": "^1.14.4",
|
||||
"@orpc/json-schema": "^1.14.4",
|
||||
"@orpc/openapi": "^1.14.4",
|
||||
"@orpc/server": "^1.14.4",
|
||||
"@orpc/zod": "^1.14.4",
|
||||
"@react-pdf/renderer": "^4.5.1",
|
||||
"@ai-sdk/anthropic": "^4.0.65",
|
||||
"@ai-sdk/cerebras": "^3.0.57",
|
||||
"@ai-sdk/cohere": "^4.0.50",
|
||||
"@ai-sdk/deepseek": "^3.0.54",
|
||||
"@ai-sdk/fireworks": "^3.0.60",
|
||||
"@ai-sdk/google": "^4.0.82",
|
||||
"@ai-sdk/groq": "^4.0.50",
|
||||
"@ai-sdk/mistral": "^4.0.52",
|
||||
"@ai-sdk/openai": "^4.0.78",
|
||||
"@ai-sdk/openai-compatible": "^3.0.57",
|
||||
"@ai-sdk/perplexity": "^5.0.1",
|
||||
"@ai-sdk/togetherai": "^3.0.58",
|
||||
"@ai-sdk/xai": "^5.0.10",
|
||||
"@aws-sdk/client-s3": "^3.1141.0",
|
||||
"@better-auth/api-key": "^1.7.6",
|
||||
"@better-auth/drizzle-adapter": "^1.7.6",
|
||||
"@better-auth/infra": "^0.4.11",
|
||||
"@better-auth/oauth-provider": "^1.7.6",
|
||||
"@better-auth/passkey": "^1.7.6",
|
||||
"@bramus/specificity": "^2.4.2",
|
||||
"@hono/node-server": "^2.1.1",
|
||||
"@modelcontextprotocol/sdk": "^1.30.1",
|
||||
"@orpc/client": "^1.15.4",
|
||||
"@orpc/experimental-ratelimit": "^1.15.4",
|
||||
"@orpc/json-schema": "^1.15.4",
|
||||
"@orpc/openapi": "^1.15.4",
|
||||
"@orpc/server": "^1.15.4",
|
||||
"@orpc/zod": "^1.15.4",
|
||||
"@react-pdf/hyphenate": "0.1.0",
|
||||
"@react-pdf/renderer": "^4.9.0",
|
||||
"@reactive-resume/api": "workspace:*",
|
||||
"@reactive-resume/auth": "workspace:*",
|
||||
"@reactive-resume/db": "workspace:*",
|
||||
@@ -43,46 +55,52 @@
|
||||
"@reactive-resume/mcp": "workspace:*",
|
||||
"@reactive-resume/schema": "workspace:*",
|
||||
"@reactive-resume/utils": "workspace:*",
|
||||
"@sindresorhus/slugify": "^3.0.0",
|
||||
"@sindresorhus/slugify": "^3.0.1",
|
||||
"@t3-oss/env-core": "^0.13.11",
|
||||
"@uiw/color-convert": "^2.10.3",
|
||||
"ai": "^6.0.193",
|
||||
"@vercel/blob": "^2.8.0",
|
||||
"@vercel/functions": "^3.9.9",
|
||||
"ai": "^7.0.118",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-auth": "1.6.13",
|
||||
"cjk-regex": "^3.4.0",
|
||||
"deepmerge-ts": "^7.1.5",
|
||||
"dompurify": "^3.4.7",
|
||||
"dotenv": "^17.4.2",
|
||||
"drizzle-orm": "1.0.0-rc.3",
|
||||
"better-auth": "1.7.6",
|
||||
"cjk-regex": "^3.5.0",
|
||||
"css-tree": "^3.2.1",
|
||||
"deepmerge-ts": "^8.0.2",
|
||||
"drizzle-orm": "1.0.0-rc.4",
|
||||
"drizzle-zod": "1.0.0-beta.14-a36c63d",
|
||||
"es-toolkit": "^1.47.0",
|
||||
"es-toolkit": "^1.52.0",
|
||||
"fast-json-patch": "^3.1.1",
|
||||
"hono": "^4.12.23",
|
||||
"jsonrepair": "^3.14.0",
|
||||
"node-html-parser": "^7.1.0",
|
||||
"nodemailer": "^8.0.10",
|
||||
"ollama-ai-provider-v2": "^3.5.1",
|
||||
"pg": "^8.21.0",
|
||||
"fast-png": "^8.0.0",
|
||||
"hono": "^4.13.9",
|
||||
"ioredis": "^6.0.0",
|
||||
"jose": "^6.2.12",
|
||||
"jsonrepair": "^3.15.0",
|
||||
"node-html-parser": "^9.0.4",
|
||||
"nodemailer": "^10.0.11",
|
||||
"ollama-ai-provider-v2": "^4.0.1",
|
||||
"pg": "^8.23.0",
|
||||
"phosphor-icons-react-pdf": "^0.1.3",
|
||||
"react": "^19.2.6",
|
||||
"react-email": "^6.5.0",
|
||||
"react": "^19.3.0",
|
||||
"react-email": "^6.11.0",
|
||||
"react-pdf-html": "^2.1.5",
|
||||
"resumable-stream": "^2.2.12",
|
||||
"sharp": "^0.34.5",
|
||||
"resumable-stream": "^2.2.13",
|
||||
"sanitize-html": "^2.17.7",
|
||||
"sharp": "^0.35.5",
|
||||
"tokenx": "^2.1.0",
|
||||
"ts-pattern": "^5.9.0",
|
||||
"unique-names-generator": "^4.7.1",
|
||||
"uuid": "^14.0.0",
|
||||
"zod": "^4.4.3"
|
||||
"uuid": "^14.0.2",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@reactive-resume/config": "workspace:*",
|
||||
"@types/node": "^25.9.1",
|
||||
"@types/pg": "^8.20.0",
|
||||
"@types/react": "^19.2.15",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260527.2",
|
||||
"tsdown": "^0.22.1",
|
||||
"tsx": "^4.22.4",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^4.1.8"
|
||||
"@types/node": "^26.6.3",
|
||||
"@types/pg": "^8.23.1",
|
||||
"@types/react": "^19.3.0",
|
||||
"@typescript/native-preview": "7.0.0-dev.20260707.2",
|
||||
"tsdown": "^0.23.0",
|
||||
"tsx": "^4.23.15",
|
||||
"typescript": "^7.0.2",
|
||||
"vitest": "^5.0.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ const mocks = vi.hoisted(() => ({
|
||||
handleUpload: vi.fn(),
|
||||
handleMcp: vi.fn(),
|
||||
handleResumePdfDownload: vi.fn(),
|
||||
handlePublicResumePdf: vi.fn(),
|
||||
handleMcpServerCard: vi.fn(),
|
||||
handleOAuthAuthorizationServer: vi.fn(),
|
||||
handleOAuthProtectedResource: vi.fn(),
|
||||
@@ -19,7 +20,6 @@ const mocks = vi.hoisted(() => ({
|
||||
handleLlms: vi.fn(),
|
||||
serveWebDistStatic: vi.fn(),
|
||||
handleWebApp: vi.fn(),
|
||||
handleWebAppHead: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./auth", () => ({
|
||||
@@ -60,7 +60,6 @@ vi.mock("../static/seo", () => ({
|
||||
vi.mock("../static/web", () => ({
|
||||
serveWebDistStatic: mocks.serveWebDistStatic,
|
||||
handleWebApp: mocks.handleWebApp,
|
||||
handleWebAppHead: mocks.handleWebAppHead,
|
||||
}));
|
||||
|
||||
vi.mock("../mcp/handler", () => ({
|
||||
@@ -71,6 +70,15 @@ 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"));
|
||||
@@ -81,6 +89,7 @@ beforeEach(() => {
|
||||
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"));
|
||||
@@ -91,7 +100,6 @@ beforeEach(() => {
|
||||
mocks.handleLlms.mockReturnValue(new Response("llms"));
|
||||
mocks.serveWebDistStatic.mockResolvedValue(undefined);
|
||||
mocks.handleWebApp.mockResolvedValue(new Response("web"));
|
||||
mocks.handleWebAppHead.mockReturnValue(new Response(null));
|
||||
});
|
||||
|
||||
describe("createApp", () => {
|
||||
@@ -120,6 +128,51 @@ describe("createApp", () => {
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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.each([
|
||||
["GET", "/robots.txt", "robots", mocks.handleRobots],
|
||||
["HEAD", "/robots.txt", "", mocks.handleRobots],
|
||||
@@ -138,6 +191,31 @@ describe("createApp", () => {
|
||||
expect(handler).toHaveBeenCalledWith({ head: method === "HEAD" });
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebApp).not.toHaveBeenCalled();
|
||||
expect(mocks.handleWebAppHead).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["GET", "HEAD"])("routes %s / to the web app handler so SEO markup is injected", async (method) => {
|
||||
const { createApp } = await import("./app");
|
||||
const app = createApp();
|
||||
const request = new Request("http://localhost:3001/", { method });
|
||||
|
||||
const response = await app.fetch(request);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handleWebApp).toHaveBeenCalledWith(request);
|
||||
expect(mocks.serveWebDistStatic).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
});
|
||||
|
||||
+47
-10
@@ -1,4 +1,9 @@
|
||||
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 { prepareStagedBody, withStagedBody } from "@reactive-resume/api/features/storage/transport";
|
||||
import { handleMcp } from "../mcp/handler";
|
||||
import { handleOpenApi } from "../openapi/handler";
|
||||
import {
|
||||
@@ -12,21 +17,51 @@ import { handleRpc } from "../rpc/handler";
|
||||
import { handleSchemaJson } from "../static/schema";
|
||||
import { handleLlms, handleRobots, handleSitemap } from "../static/seo";
|
||||
import { handleUpload } from "../static/uploads";
|
||||
import { handleWebApp, handleWebAppHead, serveWebDistStatic } from "../static/web";
|
||||
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";
|
||||
|
||||
export function createApp() {
|
||||
const app = new Hono();
|
||||
type ServerEnvironment = { Bindings: HttpBindings | Http2Bindings };
|
||||
|
||||
app.all("/api/rpc", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/rpc/*", (c) => handleRpc(c.req.raw));
|
||||
app.all("/api/openapi", (c) => handleOpenApi(c.req.raw));
|
||||
app.all("/api/openapi/*", (c) => handleOpenApi(c.req.raw));
|
||||
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));
|
||||
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));
|
||||
@@ -46,9 +81,11 @@ export function createApp() {
|
||||
app.on(["GET", "HEAD"], "/sitemap.xml", (c) => handleSitemap({ head: c.req.method === "HEAD" }));
|
||||
app.on(["GET", "HEAD"], "/llms.txt", (c) => handleLlms({ head: c.req.method === "HEAD" }));
|
||||
|
||||
app.use("/*", serveWebDistStatic);
|
||||
app.on(["GET"], "/*", (c) => handleWebApp(c.req.raw));
|
||||
app.on(["HEAD"], "/*", (c) => handleWebAppHead(c.req.raw));
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
getSession: vi.fn(),
|
||||
consent: vi.fn(),
|
||||
continueOAuth: vi.fn(),
|
||||
handler: vi.fn(),
|
||||
env: {
|
||||
SERVER_PORT: 3001,
|
||||
@@ -14,6 +16,8 @@ vi.mock("@reactive-resume/auth/config", () => ({
|
||||
auth: {
|
||||
api: {
|
||||
getSession: mocks.getSession,
|
||||
oauth2Consent: mocks.consent,
|
||||
oauth2Continue: mocks.continueOAuth,
|
||||
},
|
||||
handler: mocks.handler,
|
||||
},
|
||||
@@ -32,13 +36,45 @@ beforeEach(() => {
|
||||
});
|
||||
|
||||
describe("handleAuth", () => {
|
||||
it("rejects untrusted dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
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("registers third-party https callbacks so remote MCP clients can complete DCR", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
|
||||
const response = await handleAuth(
|
||||
new Request("http://localhost:3001/api/auth/oauth2/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ redirect_uris: ["https://evil.example.com/callback"] }),
|
||||
body: JSON.stringify({ redirect_uris: ["https://claude.ai/api/mcp/auth_callback"] }),
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("rejects unsafe dynamic OAuth redirect URIs in safe mode", async () => {
|
||||
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" },
|
||||
}),
|
||||
);
|
||||
@@ -66,6 +102,76 @@ describe("handleAuth", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(mocks.handler).toHaveBeenCalledOnce();
|
||||
});
|
||||
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");
|
||||
});
|
||||
|
||||
it("preserves repeated resource indicators during authorization sanitization", async () => {
|
||||
const { handleAuth } = await import("./auth");
|
||||
await handleAuth(
|
||||
new Request(
|
||||
"http://localhost:3000/api/auth/oauth2/authorize?resource=http%3A%2F%2Flocalhost%3A3000&resource=http%3A%2F%2Flocalhost%3A3000%2Fmcp",
|
||||
),
|
||||
);
|
||||
const forwarded = mocks.handler.mock.calls[0]?.[0] as Request;
|
||||
expect(new URL(forwarded.url).searchParams.getAll("resource")).toEqual([
|
||||
"http://localhost:3000",
|
||||
"http://localhost:3000/mcp",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleOAuth", () => {
|
||||
@@ -91,7 +197,64 @@ describe("handleOAuth", () => {
|
||||
expect(callbackUrl.searchParams.get("client_id")).toBe("test-client");
|
||||
expect(callbackUrl.searchParams.get("redirect_uri")).toBe("https://example.com/callback");
|
||||
expect(callbackUrl.searchParams.get("state")).toBe("abc");
|
||||
expect(callbackUrl.searchParams.has("exp")).toBe(false);
|
||||
expect(callbackUrl.searchParams.has("sig")).toBe(false);
|
||||
expect(callbackUrl.searchParams.get("exp")).toBe("123");
|
||||
expect(callbackUrl.searchParams.get("sig")).toBe("456");
|
||||
});
|
||||
it("continues signed authorization without approving consent on GET", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
mocks.continueOAuth.mockResolvedValueOnce(
|
||||
Response.json({ redirect: true, url: "/auth/consent?client_id=client&sig=signed" }),
|
||||
);
|
||||
const query = "client_id=client&resource=one&resource=two&exp=123&sig=456";
|
||||
const response = await handleOAuth(new Request(`http://localhost:3000/api/auth/oauth?${query}`));
|
||||
expect(mocks.continueOAuth).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ body: { postLogin: true, oauth_query: query } }),
|
||||
);
|
||||
expect(mocks.consent).not.toHaveBeenCalled();
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toBe("/auth/consent?client_id=client&sig=signed");
|
||||
});
|
||||
|
||||
it("preserves provider failures instead of issuing an authorization code", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
mocks.continueOAuth.mockResolvedValueOnce(Response.json({ error: "invalid_signature" }, { status: 400 }));
|
||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=invalid"));
|
||||
expect(response.status).toBe(400);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
await expect(response.json()).resolves.toEqual({ error: "invalid_signature" });
|
||||
});
|
||||
it("preserves provider cookies and cache headers on forced reauthentication", async () => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
const headers = new Headers({ "cache-control": "no-store", "content-length": "123" });
|
||||
headers.append("set-cookie", "oauth_state=state; Path=/; HttpOnly");
|
||||
headers.append("set-cookie", "session=refreshed; Path=/; HttpOnly");
|
||||
mocks.continueOAuth.mockResolvedValueOnce(
|
||||
Response.json({ redirect: true, url: "/api/auth/oauth?prompt=login&sig=signed" }, { headers }),
|
||||
);
|
||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=original"));
|
||||
expect(response.status).toBe(302);
|
||||
expect(response.headers.get("location")).toMatch(/^\/auth\/login\?reauthenticate=true&/);
|
||||
expect(response.headers.getSetCookie()).toEqual(headers.getSetCookie());
|
||||
expect(response.headers.get("cache-control")).toBe("no-store");
|
||||
expect(response.headers.get("content-type")).toBeNull();
|
||||
expect(response.headers.get("content-length")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OAuth provider response validation", () => {
|
||||
it.for([{}, { url: null }, { url: 7 }, { url: "" }, { url: "undefined" }, { url: "javascript:alert(1)" }])(
|
||||
"fails closed for malformed provider response %j",
|
||||
async (body) => {
|
||||
const { handleOAuth } = await import("./auth");
|
||||
mocks.getSession.mockResolvedValueOnce({ user: { id: "owner" } });
|
||||
mocks.continueOAuth.mockResolvedValueOnce(Response.json(body));
|
||||
const response = await handleOAuth(new Request("http://localhost:3000/api/auth/oauth?sig=signed"));
|
||||
expect(response.status).toBe(502);
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(mocks.consent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import crypto from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { APIError } from "better-auth/api";
|
||||
import { auth } from "@reactive-resume/auth/config";
|
||||
import { db } from "@reactive-resume/db/client";
|
||||
import { oauthClient, verification } from "@reactive-resume/db/schema";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { generateId } from "@reactive-resume/utils/string";
|
||||
import { isAllowedOAuthRedirectUri } from "@reactive-resume/utils/url-security.node";
|
||||
|
||||
const oauthAuthorizeSanitizedParams = [
|
||||
@@ -19,8 +15,6 @@ const oauthAuthorizeSanitizedParams = [
|
||||
"resource",
|
||||
] as const;
|
||||
|
||||
const oauthCallbackPassthroughExcludedParams = new Set(["exp", "sig"]);
|
||||
|
||||
function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
if (request.method !== "GET") return request;
|
||||
|
||||
@@ -33,9 +27,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
const sanitizeParam = (key: string) => {
|
||||
const value = url.searchParams.get(key);
|
||||
if (!value) return;
|
||||
url.searchParams.set(key, sanitizeValue(value));
|
||||
const values = url.searchParams.getAll(key);
|
||||
if (!values.length) return;
|
||||
url.searchParams.delete(key);
|
||||
for (const value of values) url.searchParams.append(key, sanitizeValue(value));
|
||||
};
|
||||
|
||||
for (const key of oauthAuthorizeSanitizedParams) sanitizeParam(key);
|
||||
@@ -56,6 +51,10 @@ function sanitizeOAuthAuthorizeRequest(request: Request): Request {
|
||||
return new Request(url.toString(), request);
|
||||
}
|
||||
|
||||
function isRegistrationPayload(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
async function defaultPublicClientRegistration(request: Request): Promise<Request> {
|
||||
if (request.method !== "POST") return request;
|
||||
|
||||
@@ -66,13 +65,29 @@ async function defaultPublicClientRegistration(request: Request): Promise<Reques
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
const payload: unknown = await cloned.json();
|
||||
if (!isRegistrationPayload(payload)) return request;
|
||||
body = payload;
|
||||
} catch {
|
||||
return request;
|
||||
}
|
||||
|
||||
// MCP native clients often omit OIDC application_type. Infer it only for
|
||||
// exact HTTP loopback callbacks; the provider still validates every URI.
|
||||
if (body.application_type === undefined && Array.isArray(body.redirect_uris) && body.redirect_uris.length > 0) {
|
||||
const allLoopback = body.redirect_uris.every(
|
||||
(uri: unknown) =>
|
||||
typeof uri === "string" && /^http:\/\/(?:localhost|127\.0\.0\.1|\[::1\])(?::[0-9]+)?(?:[/?]|$)/i.test(uri),
|
||||
);
|
||||
if (allLoopback) body.application_type = "native";
|
||||
}
|
||||
|
||||
// MCP clients that authenticate with PKCE alone omit the method, and Better Auth
|
||||
// would otherwise register them as `client_secret_basic`. Honor an explicit choice:
|
||||
// forcing it to "none" issues no client secret, so the client's own Basic/post
|
||||
// credentials are rejected at the token endpoint with 401 invalid_client.
|
||||
if (!request.headers.get("authorization")) {
|
||||
body.token_endpoint_auth_method = "none";
|
||||
body.token_endpoint_auth_method ??= "none";
|
||||
}
|
||||
|
||||
return new Request(url.toString(), {
|
||||
@@ -92,7 +107,11 @@ async function validateDynamicClientRegistrationRequest(request: Request): Promi
|
||||
let body: Record<string, unknown>;
|
||||
|
||||
try {
|
||||
body = await cloned.json();
|
||||
const payload: unknown = await cloned.json();
|
||||
if (!isRegistrationPayload(payload)) {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
body = payload;
|
||||
} catch {
|
||||
return Response.json({ message: "Invalid registration payload" }, { status: 400 });
|
||||
}
|
||||
@@ -125,90 +144,68 @@ export async function handleAuth(request: Request) {
|
||||
return auth.handler(finalRequest);
|
||||
}
|
||||
|
||||
function generateCode() {
|
||||
return crypto.randomBytes(32).toString("base64url");
|
||||
}
|
||||
|
||||
function hashCode(code: string) {
|
||||
return crypto.createHash("sha256").update(code).digest("base64url");
|
||||
}
|
||||
|
||||
export async function handleOAuth(request: Request) {
|
||||
try {
|
||||
return await resumeOAuth(request);
|
||||
} catch (error) {
|
||||
// Before-hooks can throw even when the provider is called with asResponse.
|
||||
if (error instanceof APIError) return Response.json(error.body, { status: error.statusCode });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function resumeOAuth(request: Request) {
|
||||
const session = await auth.api.getSession({ headers: request.headers });
|
||||
const url = new URL(request.url);
|
||||
|
||||
if (session?.user) {
|
||||
const clientId = url.searchParams.get("client_id");
|
||||
const redirectUri = url.searchParams.get("redirect_uri");
|
||||
const state = url.searchParams.get("state");
|
||||
const scope = url.searchParams.get("scope");
|
||||
const codeChallenge = url.searchParams.get("code_challenge");
|
||||
const codeChallengeMethod = url.searchParams.get("code_challenge_method");
|
||||
|
||||
if (!clientId || !redirectUri) {
|
||||
return Response.json({ error: "missing client_id or redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const [client] = await db.select().from(oauthClient).where(eq(oauthClient.clientId, clientId)).limit(1);
|
||||
|
||||
if (!client) {
|
||||
return Response.json({ error: "invalid client" }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!client.redirectUris.includes(redirectUri)) {
|
||||
return Response.json({ error: "invalid redirect_uri" }, { status: 400 });
|
||||
}
|
||||
|
||||
const code = generateCode();
|
||||
const hashedCode = hashCode(code);
|
||||
const now = new Date();
|
||||
const expiresAt = new Date(now.getTime() + 600_000);
|
||||
|
||||
await db.insert(verification).values({
|
||||
id: generateId(),
|
||||
identifier: hashedCode,
|
||||
value: JSON.stringify({
|
||||
type: "authorization_code",
|
||||
query: {
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
scope,
|
||||
state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: codeChallengeMethod,
|
||||
},
|
||||
userId: session.user.id,
|
||||
sessionId: session.session.id,
|
||||
authTime: new Date(session.session.createdAt).getTime(),
|
||||
}),
|
||||
expiresAt,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
const callbackUrl = new URL(redirectUri);
|
||||
callbackUrl.searchParams.set("code", code);
|
||||
if (state) callbackUrl.searchParams.set("state", state);
|
||||
callbackUrl.searchParams.set("iss", `${env.APP_URL}/api/auth`);
|
||||
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: callbackUrl.toString() },
|
||||
// Resume authorization without granting consent. The provider decides whether
|
||||
// the user must sign in, explicitly approve a client, or reuse an existing grant.
|
||||
// Its signed query must survive the login round trip byte-for-byte.
|
||||
const response = await auth.api.oauth2Continue({
|
||||
asResponse: true,
|
||||
request,
|
||||
headers: request.headers,
|
||||
body: { postLogin: true, oauth_query: url.search.slice(1) },
|
||||
});
|
||||
if (!(response instanceof Response)) throw new Error("OAuth provider did not return a response");
|
||||
if (!response.ok) return response;
|
||||
const result: unknown = await response.json().catch(() => null);
|
||||
if (
|
||||
!result ||
|
||||
typeof result !== "object" ||
|
||||
!("url" in result) ||
|
||||
typeof result.url !== "string" ||
|
||||
!result.url ||
|
||||
!(result.url.startsWith("/") || URL.canParse(result.url)) ||
|
||||
!URL.canParse(result.url, env.APP_URL)
|
||||
)
|
||||
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
|
||||
const headers = new Headers(response.headers);
|
||||
headers.delete("content-type");
|
||||
headers.delete("content-length");
|
||||
const target = new URL(result.url, env.APP_URL);
|
||||
if (["javascript:", "data:", "vbscript:", "file:", "blob:"].includes(target.protocol)) {
|
||||
return Response.json({ error: "invalid_provider_response" }, { status: 502 });
|
||||
}
|
||||
if (target.origin === new URL(env.APP_URL).origin && target.pathname === "/api/auth/oauth") {
|
||||
return redirectToOAuthLogin(target, true, headers);
|
||||
}
|
||||
headers.set("Location", result.url);
|
||||
return new Response(null, { status: 302, headers });
|
||||
}
|
||||
|
||||
const loginUrl = new URL("/auth/login", env.APP_URL);
|
||||
const oauthParams = new URLSearchParams();
|
||||
for (const [key, value] of url.searchParams) {
|
||||
if (!oauthCallbackPassthroughExcludedParams.has(key)) {
|
||||
oauthParams.set(key, value);
|
||||
}
|
||||
}
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth?${oauthParams.toString()}`);
|
||||
return redirectToOAuthLogin(url);
|
||||
}
|
||||
|
||||
function redirectToOAuthLogin(url: URL, reauthenticate = false, headers = new Headers()) {
|
||||
const prompt = new Set(url.searchParams.get("prompt")?.split(" ") ?? []);
|
||||
const loginUrl = new URL(prompt.has("create") ? "/auth/register" : "/auth/login", env.APP_URL);
|
||||
if (reauthenticate) loginUrl.searchParams.set("reauthenticate", "true");
|
||||
loginUrl.searchParams.set("callbackURL", `/api/auth/oauth${url.search}`);
|
||||
headers.set("Location", `${loginUrl.pathname}${loginUrl.search}`);
|
||||
return new Response(null, {
|
||||
status: 302,
|
||||
headers: { Location: `${loginUrl.pathname}${loginUrl.search}` },
|
||||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { execute, healthcheck } = vi.hoisted(() => ({ execute: vi.fn(), healthcheck: vi.fn() }));
|
||||
|
||||
vi.mock("@reactive-resume/db/client", () => ({ db: { execute } }));
|
||||
vi.mock("@reactive-resume/api/features/storage", () => ({ getStorageService: () => ({ healthcheck }) }));
|
||||
vi.mock("../app-version", () => ({ appVersion: "9.8.7" }));
|
||||
|
||||
import { handleHealth } from "./health";
|
||||
|
||||
describe("health version reporting", () => {
|
||||
beforeEach(() => {
|
||||
execute.mockResolvedValue([]);
|
||||
healthcheck.mockResolvedValue({ status: "healthy" });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("reports the built application version when launched directly by Node", async () => {
|
||||
vi.stubEnv("npm_package_version", undefined);
|
||||
|
||||
const response = await handleHealth();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.json()).toMatchObject({ service: "reactive-resume", version: "9.8.7", status: "healthy" });
|
||||
});
|
||||
|
||||
it("ignores a package manager's workspace package version", async () => {
|
||||
vi.stubEnv("npm_package_version", "0.0.0");
|
||||
|
||||
expect(await (await handleHealth()).json()).toMatchObject({ version: "9.8.7" });
|
||||
});
|
||||
|
||||
it("keeps the version available when a dependency is unhealthy", async () => {
|
||||
vi.stubEnv("npm_package_version", undefined);
|
||||
execute.mockRejectedValueOnce(new Error("Database unavailable"));
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
|
||||
const response = await handleHealth();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(await response.json()).toMatchObject({ version: "9.8.7", status: "unhealthy" });
|
||||
});
|
||||
it.each(["database", "storage"])("keeps thrown %s error details in server logs only", async (dependency) => {
|
||||
const detail = "Connection failed for private-user at internal.example:5432";
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
(dependency === "database" ? execute : healthcheck).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 }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
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;
|
||||
|
||||
@@ -11,30 +14,12 @@ type CheckResult = {
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
function getErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) return error.message;
|
||||
return "Unknown error";
|
||||
}
|
||||
|
||||
async function withTimeout<T>(promise: Promise<T>, timeoutMs: number): Promise<T> {
|
||||
let timeoutId: NodeJS.Timeout | undefined;
|
||||
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timeoutId = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timeoutId) clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// 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 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 };
|
||||
@@ -42,48 +27,53 @@ async function runCheck(check: () => Promise<object>): Promise<CheckResult> {
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: getErrorMessage(error),
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
latencyMs: Math.round(performance.now() - startedAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkDatabase() {
|
||||
try {
|
||||
await db.execute(sql`SELECT 1`);
|
||||
return { status: "healthy" };
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
async function checkStorage() {
|
||||
try {
|
||||
const storageService = getStorageService();
|
||||
return await storageService.healthcheck();
|
||||
} catch (error) {
|
||||
return {
|
||||
status: "unhealthy",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
// 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 [database, storage] = await Promise.all([runCheck(checkDatabase), runCheck(checkStorage)]);
|
||||
const status = [database, storage].some((check) => check.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
const redisClient = getRedis();
|
||||
const [database, storage, redis] = await Promise.all([
|
||||
runCheck(checkDatabase),
|
||||
runCheck(checkStorage),
|
||||
redisClient
|
||||
? runCheck(async () => {
|
||||
await redisClient.ping();
|
||||
return { status: "healthy" };
|
||||
})
|
||||
: undefined,
|
||||
]);
|
||||
const status = [database, storage, redis].some((check) => check?.status === "unhealthy") ? "unhealthy" : "healthy";
|
||||
|
||||
const checks = {
|
||||
service: "reactive-resume",
|
||||
version: process.env.npm_package_version,
|
||||
version: appVersion,
|
||||
status,
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: `${process.uptime().toFixed(2)}s`,
|
||||
database,
|
||||
storage,
|
||||
database: publicCheck(database, "Database"),
|
||||
storage: publicCheck(storage, "Storage"),
|
||||
...(redis ? { redis: publicCheck(redis, "Redis") } : {}),
|
||||
};
|
||||
|
||||
if (status === "unhealthy") {
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
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("exchanges a code for a confidential client that registered client_secret_basic", async () => {
|
||||
if (!databaseURL) return;
|
||||
process.env.DATABASE_URL = databaseURL;
|
||||
process.env.APP_URL = "http://localhost:33920";
|
||||
process.env.AUTH_SECRET = "oauth-integration-test-secret-only";
|
||||
const { handleAuth, handleOAuth } = await import("./auth");
|
||||
const origin = process.env.APP_URL;
|
||||
const redirectURI = "http://127.0.0.1:33921/callback";
|
||||
const request = (path: string, body: object, cookie = "") =>
|
||||
new Request(`${origin}/api/auth/${path}`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", origin, cookie },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
const registration = await handleAuth(
|
||||
request("oauth2/register", {
|
||||
client_name: "Confidential MCP client",
|
||||
redirect_uris: [redirectURI],
|
||||
token_endpoint_auth_method: "client_secret_basic",
|
||||
}),
|
||||
);
|
||||
expect(registration.status, await registration.clone().text()).toBe(201);
|
||||
const client = await registration.json();
|
||||
// Downgrading this to a public client leaves the client without a secret, and its
|
||||
// Basic-authenticated token exchange then fails with 401 invalid_client.
|
||||
expect(client.token_endpoint_auth_method).toBe("client_secret_basic");
|
||||
expect(client.client_secret).toBeTruthy();
|
||||
|
||||
const verifier = randomBytes(32).toString("base64url");
|
||||
const query = new URLSearchParams({
|
||||
client_id: client.client_id,
|
||||
redirect_uri: redirectURI,
|
||||
response_type: "code",
|
||||
scope: "openid profile offline_access",
|
||||
code_challenge: createHash("sha256").update(verifier).digest("base64url"),
|
||||
code_challenge_method: "S256",
|
||||
resource: `${origin}/mcp`,
|
||||
state: "opaque-state",
|
||||
});
|
||||
const authorize = await handleAuth(new Request(`${origin}/api/auth/oauth2/authorize?${query}`));
|
||||
const login = await handleOAuth(new Request(new URL(authorize.headers.get("location") ?? "", origin)));
|
||||
const callbackURL = new URL(login.headers.get("location") ?? "", origin).searchParams.get("callbackURL");
|
||||
|
||||
const unique = randomBytes(6).toString("hex");
|
||||
const signup = await handleAuth(
|
||||
request("sign-up/email", {
|
||||
name: "Confidential Test",
|
||||
email: `confidential-${unique}@example.com`,
|
||||
username: `confidential-${unique}`,
|
||||
password: "password123",
|
||||
}),
|
||||
);
|
||||
expect(signup.status, await signup.clone().text()).toBe(200);
|
||||
const cookie = signup.headers
|
||||
.getSetCookie()
|
||||
.map((value) => value.split(";", 1)[0])
|
||||
.join("; ");
|
||||
const callback = await handleOAuth(new Request(`${origin}${callbackURL}`, { headers: { cookie } }));
|
||||
const oauth_query = new URL(callback.headers.get("location") ?? "", origin).search.slice(1);
|
||||
const accepted = await handleAuth(request("oauth2/consent", { accept: true, oauth_query }, cookie));
|
||||
expect(accepted.status, await accepted.clone().text()).toBe(200);
|
||||
const code = new URL((await accepted.json()).url).searchParams.get("code");
|
||||
|
||||
const tokenResponse = await handleAuth(
|
||||
new Request(`${origin}/api/auth/oauth2/token`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
authorization: `Basic ${Buffer.from(`${client.client_id}:${client.client_secret}`).toString("base64")}`,
|
||||
},
|
||||
body: new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: code ?? "",
|
||||
redirect_uri: redirectURI,
|
||||
code_verifier: verifier,
|
||||
resource: `${origin}/mcp`,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(tokenResponse.status, await tokenResponse.clone().text()).toBe(200);
|
||||
await expect(tokenResponse.json()).resolves.toMatchObject({ token_type: "Bearer" });
|
||||
}, 30_000);
|
||||
it.each(["login", "max-age", "create"])(
|
||||
"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,79 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps password and private responses uncacheable", async () => {
|
||||
mocks.createPublicResumePdf.mockResolvedValueOnce({
|
||||
body: new File(["%PDF"], "resume.pdf", { type: "application/pdf" }),
|
||||
filename: "resume.pdf",
|
||||
});
|
||||
const request = new Request("https://example.com/api/resumes/jane/resume/pdf");
|
||||
|
||||
const response = await handlePublicResumePdf(request, "jane", "resume", trustedClient);
|
||||
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(mocks.createPublicResumePdf).toHaveBeenCalledWith({
|
||||
username: "jane",
|
||||
slug: "resume",
|
||||
requestHeaders: request.headers,
|
||||
trustedClient,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ code: "NEED_PASSWORD" }, 401],
|
||||
[{ code: "NOT_FOUND" }, 404],
|
||||
[{ code: "RATE_LIMIT_EXCEEDED" }, 429],
|
||||
[{ code: "INTERNAL_SERVER_ERROR" }, 500],
|
||||
])("maps controlled API errors without caching the response", async (error, status) => {
|
||||
mocks.createPublicResumePdf.mockRejectedValueOnce(error);
|
||||
|
||||
const response = await handlePublicResumePdf(
|
||||
new Request("https://example.com/api/resumes/jane/resume/pdf"),
|
||||
"jane",
|
||||
"resume",
|
||||
trustedClient,
|
||||
);
|
||||
|
||||
expect(response.status).toBe(status);
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
});
|
||||
@@ -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 = "unknown",
|
||||
): 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@ describe("handleResumePdfDownload", () => {
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
@@ -40,7 +41,89 @@ describe("handleResumePdfDownload", () => {
|
||||
expect(response.headers.get("Content-Disposition")).toBe('attachment; filename="Scizor.pdf"');
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
expect(await response.text()).toBe("%PDF");
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1" });
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({ id: "resume-1", userId: "user-1", target: "resume" });
|
||||
});
|
||||
|
||||
it("passes the cover letter target through to PDF rendering", async () => {
|
||||
const pdf = new File([new Uint8Array([37, 80, 68, 70])], "Cover Letter.pdf", { type: "application/pdf" });
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
|
||||
body: pdf,
|
||||
});
|
||||
|
||||
await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "cover-letter",
|
||||
});
|
||||
});
|
||||
|
||||
it("defaults a legacy token without a target to resume", async () => {
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
mocks.createResumePdfDownload.mockResolvedValueOnce({
|
||||
headers: { "content-disposition": 'attachment; filename="Cover Letter.pdf"' },
|
||||
body: new File([], "Cover Letter.pdf", { type: "application/pdf" }),
|
||||
});
|
||||
|
||||
await handleResumePdfDownload(new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy"), "resume-1");
|
||||
|
||||
expect(mocks.createResumePdfDownload).toHaveBeenCalledWith({
|
||||
id: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects a cover-letter target for a legacy token without one", async () => {
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=legacy&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects a target that differs from the signed token", async () => {
|
||||
mocks.verifyResumePdfDownloadToken.mockReturnValueOnce({
|
||||
ok: true,
|
||||
resumeId: "resume-1",
|
||||
userId: "user-1",
|
||||
target: "resume",
|
||||
expiresAt: "2026-06-01T10:10:00.000Z",
|
||||
});
|
||||
|
||||
const response = await handleResumePdfDownload(
|
||||
new Request("https://example.com/api/resumes/resume-1/pdf?token=signed&target=cover-letter"),
|
||||
"resume-1",
|
||||
);
|
||||
|
||||
expect(response.status).toBe(401);
|
||||
expect(mocks.createResumePdfDownload).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects missing, invalid, and expired tokens before rendering", async () => {
|
||||
|
||||
@@ -24,14 +24,23 @@ function errorStatus(error: unknown) {
|
||||
}
|
||||
|
||||
export async function handleResumePdfDownload(request: Request, id: string) {
|
||||
const token = new URL(request.url).searchParams.get("token");
|
||||
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();
|
||||
const queryTarget = searchParams.get("target");
|
||||
if (
|
||||
verification.target
|
||||
? queryTarget !== null && queryTarget !== verification.target
|
||||
: queryTarget && queryTarget !== "resume"
|
||||
)
|
||||
return unauthorizedResponse();
|
||||
|
||||
try {
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId });
|
||||
const target = verification.target ?? "resume";
|
||||
const download = await createResumePdfDownload({ id, userId: verification.userId, target });
|
||||
|
||||
return new Response(download.body, {
|
||||
headers: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const events = vi.hoisted(() => [] as string[]);
|
||||
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: vi.fn() };
|
||||
},
|
||||
};
|
||||
});
|
||||
vi.mock("@reactive-resume/auth/config", () => ({
|
||||
initializeAuth: async () => {
|
||||
await Promise.resolve();
|
||||
events.push("auth ready");
|
||||
},
|
||||
}));
|
||||
vi.mock("@hono/node-server", () => ({
|
||||
serve: () => {
|
||||
events.push("server listening");
|
||||
},
|
||||
}));
|
||||
vi.mock("@reactive-resume/env/server", () => ({ env: { SERVER_PORT: 3001 } }));
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
describe("server startup", () => {
|
||||
it("finishes migrations before importing auth and seeding OAuth resources", async () => {
|
||||
vi.spyOn(process, "on").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"]);
|
||||
});
|
||||
});
|
||||
@@ -1,14 +1,24 @@
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { serve } from "@hono/node-server";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { createApp } from "./http/app";
|
||||
import { runStartupChecks } from "./startup/checks";
|
||||
|
||||
export { createApp } from "./http/app";
|
||||
|
||||
async function main() {
|
||||
export async function main() {
|
||||
await runStartupChecks();
|
||||
|
||||
// Load and initialize auth only after migrations have created the provider tables.
|
||||
const { createApp } = await import("./http/app");
|
||||
const { initializeAuth } = await import("@reactive-resume/auth/config");
|
||||
await initializeAuth();
|
||||
|
||||
// Safety net: Node 24 crashes the whole process on an unhandled rejection. One request's
|
||||
// stray promise must not take the server down for everyone, so log and keep serving.
|
||||
// Registered after startup checks so a broken startup still fails loudly. (Left uncaught
|
||||
// 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;
|
||||
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
import { auth, verifyOAuthToken } from "@reactive-resume/auth/config";
|
||||
|
||||
const OAUTH_WARN_THROTTLE_MS = 60_000;
|
||||
let lastOAuthWarnAt = 0;
|
||||
|
||||
function warnOAuthThrottled(message: string, detail?: unknown): void {
|
||||
const now = Date.now();
|
||||
if (now - lastOAuthWarnAt < OAUTH_WARN_THROTTLE_MS) return;
|
||||
lastOAuthWarnAt = now;
|
||||
|
||||
if (detail !== undefined) {
|
||||
console.warn(message, detail);
|
||||
return;
|
||||
}
|
||||
|
||||
console.warn(message);
|
||||
}
|
||||
|
||||
export class AuthError extends Error {
|
||||
constructor() {
|
||||
super("Unauthorized");
|
||||
@@ -13,8 +29,9 @@ export async function authenticateRequest(request: Request): Promise<void> {
|
||||
try {
|
||||
const payload = await verifyOAuthToken(authHeader.slice(7));
|
||||
if (payload?.sub) return;
|
||||
} catch {
|
||||
// Invalid or expired token; fall through to API key auth.
|
||||
warnOAuthThrottled("[MCP] OAuth token verified but missing `sub` claim");
|
||||
} catch (error) {
|
||||
warnOAuthThrottled("[MCP] OAuth token verification failed:", error);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ export async function handleMcp(request: Request) {
|
||||
try {
|
||||
await authenticateRequest(request);
|
||||
|
||||
const server = await createMcpServer(request);
|
||||
const server = createMcpServer(request);
|
||||
const transport = new WebStandardStreamableHTTPServerTransport({
|
||||
enableJsonResponse: true,
|
||||
});
|
||||
|
||||
@@ -3,7 +3,13 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
||||
import { onError } from "@orpc/client";
|
||||
import { createRouterClient } from "@orpc/server";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { MCP_TOOL_NAME, registerPrompts, registerResources, registerTools } from "@reactive-resume/mcp";
|
||||
import {
|
||||
buildMcpServerInfo,
|
||||
MCP_TOOL_NAME,
|
||||
registerPrompts,
|
||||
registerResources,
|
||||
registerTools,
|
||||
} from "@reactive-resume/mcp";
|
||||
import { appVersion } from "../app-version";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
@@ -22,42 +28,20 @@ function createRequestClient(request: Request): RouterClient<typeof router> {
|
||||
});
|
||||
}
|
||||
|
||||
export async function createMcpServer(request: Request) {
|
||||
const server = new McpServer(
|
||||
{
|
||||
name: "reactive-resume",
|
||||
version: appVersion,
|
||||
title: "Reactive Resume",
|
||||
websiteUrl: "https://rxresu.me",
|
||||
description:
|
||||
"Reactive Resume is a free and open-source resume builder. Use this MCP server to interact with your resume using an LLM of your choice.",
|
||||
icons: [
|
||||
{
|
||||
src: "https://rxresu.me/icon/light.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "light",
|
||||
},
|
||||
{
|
||||
src: "https://rxresu.me/icon/dark.svg",
|
||||
mimeType: "image/svg+xml",
|
||||
theme: "dark",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
instructions: [
|
||||
"You are connected to Reactive Resume over MCP.",
|
||||
"Authenticate with OAuth (recommended) or an API key (`x-api-key`).",
|
||||
`Discover resume IDs with \`${MCP_TOOL_NAME.listResumes}\` (not \`resources/list\`).`,
|
||||
`List distinct tags with \`${MCP_TOOL_NAME.listResumeTags}\`.`,
|
||||
`Read schema at \`resume://_meta/schema\`; read resume JSON via \`resume://{id}\` or \`${MCP_TOOL_NAME.getResume}\`.`,
|
||||
`Apply body edits with JSON Patch through \`${MCP_TOOL_NAME.patchResume}\`.`,
|
||||
`Change name, slug, tags, or public visibility with \`${MCP_TOOL_NAME.updateResume}\` (returns canonical share URL; anonymous access only when \`isPublic\` is true; passwords are managed in the web app only).`,
|
||||
`Create short-lived authenticated PDF download URLs with \`${MCP_TOOL_NAME.downloadResumePdf}\`.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`; read saved AI analysis with \`${MCP_TOOL_NAME.getResumeAnalysis}\`.`,
|
||||
].join(" "),
|
||||
},
|
||||
);
|
||||
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}\`; set target to \`cover-letter\` to export a visible cover letter separately.`,
|
||||
`Import full ResumeData JSON with \`${MCP_TOOL_NAME.importResume}\`.`,
|
||||
].join(" "),
|
||||
});
|
||||
|
||||
const client = createRequestClient(request);
|
||||
registerResources(server, client);
|
||||
|
||||
@@ -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,256 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { writableResumeDataSchema } from "@reactive-resume/schema/resume/write";
|
||||
|
||||
// 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,
|
||||
{
|
||||
tags?: string[];
|
||||
operationId?: string;
|
||||
summary?: string;
|
||||
description?: string;
|
||||
responses?: Record<string, { description?: 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("documents all cover-letter procedures with REST metadata", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const expected = [
|
||||
["get", "/cover-letters", "listCoverLetters", "List cover letters", "200"],
|
||||
["get", "/cover-letters/{id}", "getCoverLetter", "Get cover letter by ID", "200"],
|
||||
["post", "/cover-letters", "createCoverLetter", "Create a cover letter", "200"],
|
||||
["put", "/cover-letters/{id}", "updateCoverLetter", "Update a cover letter", "200"],
|
||||
["post", "/cover-letters/{id}/refresh-style", "refreshCoverLetterStyle", "Refresh cover letter style", "200"],
|
||||
["post", "/cover-letters/{id}/duplicate", "duplicateCoverLetter", "Duplicate a cover letter", "200"],
|
||||
["delete", "/cover-letters/{id}", "deleteCoverLetter", "Delete a cover letter", "200"],
|
||||
["post", "/cover-letters/from-resume", "copyEmbeddedCoverLetter", "Copy an embedded cover letter", "200"],
|
||||
["get", "/cover-letters/{id}/export", "exportCoverLetter", "Export a cover letter", "200"],
|
||||
["post", "/cover-letters/import", "importCoverLetter", "Import a cover letter", "200"],
|
||||
] as const;
|
||||
|
||||
for (const [method, path, operationId, summary, successStatus] of expected) {
|
||||
const operation = spec.paths?.[path]?.[method];
|
||||
expect(operation).toMatchObject({
|
||||
tags: ["Cover Letters"],
|
||||
operationId,
|
||||
summary,
|
||||
description: expect.any(String),
|
||||
responses: { [successStatus]: { description: expect.any(String) } },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps published cover-letter operations in sync with the runtime spec", async () => {
|
||||
const published = JSON.parse(
|
||||
await readFile(new URL("../../../../docs/spec.json", import.meta.url), "utf8"),
|
||||
) as GeneratedSpecView;
|
||||
const runtime = await generateSpec();
|
||||
const coverLetterPaths = (spec: GeneratedSpecView) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(spec.paths ?? {}).filter(
|
||||
([path]) => path.startsWith("/cover-letters") || path.startsWith("/coverLetters/"),
|
||||
),
|
||||
);
|
||||
|
||||
const publishedPaths = coverLetterPaths(published);
|
||||
const runtimePaths = coverLetterPaths(runtime as GeneratedSpecView);
|
||||
expect(Object.keys(publishedPaths).sort()).toEqual(Object.keys(runtimePaths).sort());
|
||||
expect(publishedPaths).toEqual(runtimePaths);
|
||||
});
|
||||
|
||||
it("uses caller-provided application URL and version", async () => {
|
||||
const spec = await generateSpec();
|
||||
|
||||
expect(spec.info).toMatchObject({
|
||||
title: "Reactive Resume",
|
||||
version: "9.8.7",
|
||||
});
|
||||
expect(spec.servers).toEqual([{ url: "https://rxresu.me/api/openapi" }]);
|
||||
expect(spec.externalDocs).toEqual({
|
||||
url: "https://docs.rxresu.me",
|
||||
description: "Reactive Resume Documentation",
|
||||
});
|
||||
}, 15_000);
|
||||
|
||||
it("documents the public health endpoint at its actual URL", async () => {
|
||||
const spec = await generateSpec();
|
||||
const health = spec.paths?.["/api/health"]?.get;
|
||||
|
||||
expect(health).toMatchObject({
|
||||
operationId: "getHealth",
|
||||
security: [],
|
||||
servers: [{ url: "https://rxresu.me" }],
|
||||
});
|
||||
for (const status of ["200", "503"]) {
|
||||
expect(health?.responses?.[status]).toMatchObject({
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
required: expect.arrayContaining(["service", "version", "status"]),
|
||||
properties: { version: { type: "string" } },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it("uses the canonical input-side ResumeData schema in update requests", async () => {
|
||||
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" },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts legacy input with omitted picture fit in the published request schema", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(spec.components?.schemas?.ResumeData).toMatchObject({
|
||||
properties: {
|
||||
picture: { required: expect.not.arrayContaining(["fit"]) },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const schema = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
|
||||
it("enforces the same submitted bounds as the published request schema", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
const published = z.fromJSONSchema(spec.components?.schemas?.ResumeData as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
for (const marginX of [0, 100, -1, 500]) {
|
||||
const data = structuredClone(defaultResumeData);
|
||||
data.metadata.page.marginX = marginX;
|
||||
const expected = marginX === 0 || marginX === 100;
|
||||
expect(published.safeParse(data).success).toBe(expected);
|
||||
expect(writableResumeDataSchema.safeParse(data).success).toBe(expected);
|
||||
}
|
||||
});
|
||||
|
||||
it("does not publish impossible request schemas", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks every request body media type for impossible schemas", () => {
|
||||
const spec: GeneratedSpecView = {
|
||||
paths: {
|
||||
"/documents": {
|
||||
post: {
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": { schema: { type: "object" } },
|
||||
"multipart/form-data": { schema: { not: {} } },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(findImpossibleRequestSchemas(spec)).toEqual(["POST /documents (multipart/form-data)"]);
|
||||
});
|
||||
|
||||
it("documents imported data as an accepted ResumeData input", async () => {
|
||||
const spec = (await generateSpec()) as GeneratedSpecView;
|
||||
|
||||
expect(getRequestSchema(spec, "/resumes/import", "post")).toEqual({
|
||||
type: "object",
|
||||
properties: {
|
||||
data: { $ref: "#/components/schemas/ResumeData" },
|
||||
},
|
||||
required: ["data"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
@@ -1,24 +1,13 @@
|
||||
import { SmartCoercionPlugin } from "@orpc/json-schema";
|
||||
import { OpenAPIGenerator } from "@orpc/openapi";
|
||||
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 { downloadResumePdfProcedure } from "@reactive-resume/api/features/resume/export";
|
||||
import router from "@reactive-resume/api/routers";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { appVersion } from "../app-version";
|
||||
import { mergeResponseHeaders } from "../http/headers";
|
||||
import { getRequestLocale } from "../rpc/locale";
|
||||
|
||||
const openAPIRouter = {
|
||||
...router,
|
||||
resume: {
|
||||
...router.resume,
|
||||
downloadPdf: downloadResumePdfProcedure,
|
||||
},
|
||||
};
|
||||
import { generateOpenApiSpec, openAPIRouter } from "./generator";
|
||||
|
||||
const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
plugins: [
|
||||
@@ -36,46 +25,15 @@ const openAPIHandler = new OpenAPIHandler(openAPIRouter, {
|
||||
],
|
||||
});
|
||||
|
||||
const openAPIGenerator = new OpenAPIGenerator({
|
||||
schemaConverters: [new ZodToJsonSchemaConverter()],
|
||||
});
|
||||
|
||||
export async function handleOpenApi(request: Request) {
|
||||
export async function handleOpenApi(request: Request, trustedClient = "unknown") {
|
||||
if (request.method === "GET" && (request.url.endsWith("/spec.json") || request.url.endsWith("/spec"))) {
|
||||
const spec = await openAPIGenerator.generate(openAPIRouter, {
|
||||
info: {
|
||||
title: "Reactive Resume",
|
||||
version: appVersion,
|
||||
description: "Reactive Resume API",
|
||||
license: { name: "MIT", url: "https://github.com/amruthpillai/reactive-resume/blob/main/LICENSE" },
|
||||
contact: { name: "Amruth Pillai", email: "hello@amruthpillai.com", url: "https://amruthpillai.com" },
|
||||
},
|
||||
servers: [{ url: `${env.APP_URL}/api/openapi` }],
|
||||
externalDocs: { url: "https://docs.rxresu.me", description: "Reactive Resume Documentation" },
|
||||
commonSchemas: {
|
||||
ResumeData: { schema: resumeDataSchema },
|
||||
},
|
||||
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"),
|
||||
});
|
||||
|
||||
return Response.json(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 },
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders, trustedClient },
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,8 @@ import { env } from "@reactive-resume/env/server";
|
||||
import { buildMcpServerCard } from "@reactive-resume/mcp/server-card";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
const oauthAuthorizationServerHandler = oauthProviderAuthServerMetadata(auth);
|
||||
const openIdConfigurationHandler = oauthProviderOpenIdConfigMetadata(auth);
|
||||
export const handleOAuthAuthorizationServer = oauthProviderAuthServerMetadata(auth);
|
||||
export const handleOpenIdConfiguration = oauthProviderOpenIdConfigMetadata(auth);
|
||||
|
||||
export function handleWellKnownFallback() {
|
||||
return new Response("OK", { status: 200 });
|
||||
@@ -20,19 +20,11 @@ export function handleMcpServerCard() {
|
||||
});
|
||||
}
|
||||
|
||||
export function handleOAuthAuthorizationServer(request: Request) {
|
||||
return oauthAuthorizationServerHandler(request);
|
||||
}
|
||||
|
||||
export function handleOpenIdConfiguration(request: Request) {
|
||||
return openIdConfigurationHandler(request);
|
||||
}
|
||||
|
||||
export async function handleOAuthProtectedResource() {
|
||||
export function handleOAuthProtectedResource() {
|
||||
const metadata = {
|
||||
resource: env.APP_URL,
|
||||
bearer_methods_supported: ["header"],
|
||||
authorization_servers: [env.APP_URL, `${env.APP_URL}/api/auth`],
|
||||
authorization_servers: [`${env.APP_URL}/api/auth`],
|
||||
};
|
||||
|
||||
return Response.json(metadata, {
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { initializeAuth } from "@reactive-resume/auth/config";
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { runDatabaseMigrations } from "./startup/checks";
|
||||
|
||||
if (process.env.VERCEL_ENV === "preview" && process.env.ALLOW_PREVIEW_MIGRATIONS !== "true") {
|
||||
throw new Error(
|
||||
"Preview deployment needs an isolated database. Set ALLOW_PREVIEW_MIGRATIONS=true only after connecting one.",
|
||||
);
|
||||
}
|
||||
|
||||
if (process.env.VERCEL === "1") {
|
||||
if (env.STORAGE_BACKEND !== "blob")
|
||||
throw new Error("Vercel requires private Blob storage for direct uploads. Docker supports local, S3, and Blob.");
|
||||
if (!env.REDIS_URL || !env.ENCRYPTION_SECRET) throw new Error("Vercel requires Redis and ENCRYPTION_SECRET.");
|
||||
}
|
||||
await runDatabaseMigrations();
|
||||
|
||||
await initializeAuth();
|
||||
await getPool().end();
|
||||
@@ -14,11 +14,16 @@ const rpcHandler = new RPCHandler(router, {
|
||||
],
|
||||
});
|
||||
|
||||
export async function handleRpc(request: Request) {
|
||||
export async function handleRpc(request: Request, trustedClient = "unknown") {
|
||||
const resHeaders = new Headers();
|
||||
const { response } = await rpcHandler.handle(request, {
|
||||
prefix: "/api/rpc",
|
||||
context: { locale: getRequestLocale(request), reqHeaders: request.headers, resHeaders },
|
||||
context: {
|
||||
locale: getRequestLocale(request),
|
||||
reqHeaders: request.headers,
|
||||
resHeaders,
|
||||
trustedClient,
|
||||
},
|
||||
});
|
||||
|
||||
if (!response) return new Response("NOT_FOUND", { status: 404 });
|
||||
|
||||
@@ -7,6 +7,7 @@ import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import { Pool } from "pg";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getLocalDataDirectory } from "@reactive-resume/utils/monorepo.node";
|
||||
import { verifyMigratedSchema } from "./schema-check";
|
||||
|
||||
function resolveFromCurrentModule(relativePath: string) {
|
||||
return fileURLToPath(new URL(relativePath, import.meta.url));
|
||||
@@ -24,25 +25,54 @@ function resolveWorkspaceFolder(folderName: string): string {
|
||||
throw new Error(`Could not locate ${folderName} folder relative to ${resolveFromCurrentModule(".")}`);
|
||||
}
|
||||
|
||||
async function runDatabaseMigrations() {
|
||||
export async function runDatabaseMigrations() {
|
||||
console.info("Running database migrations...");
|
||||
|
||||
const pool = new Pool({ connectionString: env.DATABASE_URL });
|
||||
const db = drizzle({ client: pool });
|
||||
const pool = new Pool({
|
||||
connectionString: env.DATABASE_MIGRATION_URL ?? env.DATABASE_URL,
|
||||
max: 1,
|
||||
connectionTimeoutMillis: 10_000,
|
||||
});
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
const client = await pool.connect();
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_lock(721830451)");
|
||||
const db = drizzle({ client });
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: resolveWorkspaceFolder("migrations") });
|
||||
console.info("Database migrations completed");
|
||||
} catch (error) {
|
||||
console.error("Database migrations failed", { error });
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Post-migration verification is not a migration failure, so it gets its own log
|
||||
// message. A drifted schema still lets the server boot; STRICT_SCHEMA_CHECK=true
|
||||
// makes the drift fatal instead.
|
||||
try {
|
||||
await verifyMigratedSchema(client);
|
||||
} catch (error) {
|
||||
console.error("Database schema verification failed", { error });
|
||||
if (env.STRICT_SCHEMA_CHECK) throw error;
|
||||
console.error(
|
||||
"Continuing with a drifted database schema; set STRICT_SCHEMA_CHECK=true to refuse startup instead.",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
await client.query("SELECT pg_advisory_unlock(721830451)");
|
||||
} finally {
|
||||
client.release();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
|
||||
async function validateLocalStoragePath() {
|
||||
if (env.S3_ACCESS_KEY_ID && env.S3_SECRET_ACCESS_KEY && env.S3_BUCKET) return;
|
||||
if (env.STORAGE_BACKEND !== "local") return;
|
||||
|
||||
const dataDirectory = getLocalDataDirectory(env.LOCAL_STORAGE_PATH);
|
||||
console.info(`Validating local storage path: ${dataDirectory}`);
|
||||
@@ -61,7 +91,18 @@ async function validateLocalStoragePath() {
|
||||
}
|
||||
}
|
||||
|
||||
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,50 @@
|
||||
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("passes when the catalog reports nothing missing", async () => {
|
||||
const queryable = { query: async () => ({ rows: [] }) };
|
||||
await expect(verifyMigratedSchema(queryable)).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("fails with the table name when every column of a table is missing", async () => {
|
||||
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"');
|
||||
});
|
||||
|
||||
it("fails with the qualified column name when only some columns are missing", async () => {
|
||||
const queryable = { query: async () => ({ rows: [{ table_name: "user", column_name: "role" }] }) };
|
||||
await expect(verifyMigratedSchema(queryable)).rejects.toThrow('"user"."role"');
|
||||
});
|
||||
|
||||
it("passes the expected table and column lists to the catalog query", async () => {
|
||||
let captured: unknown[] | undefined;
|
||||
const queryable = {
|
||||
query: (_text: string, values?: unknown[]) => {
|
||||
captured = values;
|
||||
return Promise.resolve({ rows: [] });
|
||||
},
|
||||
};
|
||||
|
||||
await verifyMigratedSchema(queryable);
|
||||
|
||||
const [tables, columns] = captured as [string[], string[]];
|
||||
// The query relies on $1/$2 being index-aligned, so each table name must pair
|
||||
// with its own column name at the same index.
|
||||
const pairs = tables.map((table, index) => `${table}.${columns[index]}`);
|
||||
expect(pairs).toContain("ai_providers.user_id");
|
||||
});
|
||||
});
|
||||
@@ -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,29 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import z from "zod";
|
||||
import { defaultResumeData } from "@reactive-resume/schema/resume/default";
|
||||
import { handleSchemaJson } from "./schema";
|
||||
|
||||
describe("handleSchemaJson", () => {
|
||||
it("publishes the custom-section type and item correlation", async () => {
|
||||
const response = handleSchemaJson();
|
||||
const schema = z.fromJSONSchema((await response.json()) as Parameters<typeof z.fromJSONSchema>[0]);
|
||||
const mismatched = {
|
||||
...defaultResumeData,
|
||||
customSections: [
|
||||
{
|
||||
id: "custom-experience",
|
||||
type: "experience",
|
||||
title: "Experience",
|
||||
icon: "",
|
||||
columns: 1,
|
||||
hidden: false,
|
||||
keepTogether: false,
|
||||
startOnNewPage: false,
|
||||
items: [{ id: "summary-item", hidden: false, content: "<p>Not an experience item</p>" }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
expect(schema.safeParse(mismatched).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,11 +1,8 @@
|
||||
import z from "zod";
|
||||
import { resumeDataSchema } from "@reactive-resume/schema/resume/data";
|
||||
import { createResumeDataJsonSchema } from "@reactive-resume/schema/resume/json-schema";
|
||||
import { appVersion } from "../app-version";
|
||||
|
||||
export function handleSchemaJson() {
|
||||
const resumeDataJSONSchema = z.toJSONSchema(resumeDataSchema);
|
||||
|
||||
return Response.json(resumeDataJSONSchema, {
|
||||
return Response.json(createResumeDataJsonSchema(), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"Content-Type": "application/schema+json; charset=utf-8",
|
||||
|
||||
@@ -33,6 +33,7 @@ describe("SEO static endpoints", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/xml; charset=UTF-8");
|
||||
expect(text).toContain("<loc>https://app.example.com/</loc>");
|
||||
expect(text).toContain("<loc>https://app.example.com/ats-checker</loc>");
|
||||
expect(text).not.toContain("docs.rxresu.me");
|
||||
expect(text).not.toContain("/auth");
|
||||
expect(text).not.toContain("/dashboard");
|
||||
|
||||
@@ -42,6 +42,9 @@ export function handleSitemap(options?: StaticSeoOptions) {
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/</loc>`,
|
||||
" </url>",
|
||||
" <url>",
|
||||
` <loc>${baseUrl}/ats-checker</loc>`,
|
||||
" </url>",
|
||||
"</urlset>",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
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: "",
|
||||
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();
|
||||
}
|
||||
if (request.method === "DELETE") {
|
||||
objects.delete(path);
|
||||
response.writeHead(204);
|
||||
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("keeps the ACL-disabled storage health check healthy", async () => {
|
||||
expect(await storage.healthcheck()).toMatchObject({ status: "healthy", type: "s3" });
|
||||
expect(requests.map(({ method }) => method)).toEqual(["PUT", "DELETE"]);
|
||||
expect(objects.size).toBe(0);
|
||||
});
|
||||
|
||||
it("stores images without ACLs and serves them through the signed application proxy", async () => {
|
||||
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);
|
||||
});
|
||||
@@ -33,6 +33,8 @@ describe("handleUpload", () => {
|
||||
expect(response.status).toBe(200);
|
||||
expect(readMock).toHaveBeenCalledWith("uploads/user-1/pictures/photo.jpeg");
|
||||
expect(response.headers.get("Content-Type")).toBe("image/jpeg");
|
||||
expect(response.headers.get("Cross-Origin-Resource-Policy")).toBe("same-site");
|
||||
expect(response.headers.get("Access-Control-Allow-Origin")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not serve private agent attachment keys through the public uploads route", async () => {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { basename, extname, normalize } from "node:path";
|
||||
import { getStorageService } from "@reactive-resume/api/features/storage";
|
||||
import { env } from "@reactive-resume/env/server";
|
||||
import { getStorageService, inferContentType } from "@reactive-resume/api/features/storage";
|
||||
|
||||
export async function handleUpload(request: Request) {
|
||||
const { userId, filePath } = parseRouteParams(request.url);
|
||||
@@ -18,41 +17,31 @@ export async function handleUpload(request: Request) {
|
||||
|
||||
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 = buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
});
|
||||
|
||||
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 });
|
||||
}
|
||||
|
||||
function parseRouteParams(url: string): { userId: string | undefined; filePath: string | undefined } {
|
||||
@@ -101,43 +90,6 @@ function makeNotModifiedResponse(etag: string): Response {
|
||||
});
|
||||
}
|
||||
|
||||
type BuildResponseHeaderArgs = {
|
||||
filename: string;
|
||||
storedFile: { size: number };
|
||||
contentType: string;
|
||||
etag: string;
|
||||
shouldForceDownload: boolean;
|
||||
};
|
||||
|
||||
function buildResponseHeaders({
|
||||
filename,
|
||||
storedFile,
|
||||
contentType,
|
||||
etag,
|
||||
shouldForceDownload,
|
||||
}: BuildResponseHeaderArgs): 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);
|
||||
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;
|
||||
}
|
||||
|
||||
function toArrayBuffer(data: Uint8Array): ArrayBuffer {
|
||||
return data.byteOffset === 0 && data.byteLength === data.buffer.byteLength
|
||||
? (data.buffer as ArrayBuffer)
|
||||
|
||||
@@ -1,6 +1,16 @@
|
||||
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((_options?: unknown) => 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),
|
||||
}));
|
||||
@@ -12,15 +22,32 @@ vi.mock("node:fs/promises", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("@hono/node-server/serve-static", () => ({
|
||||
serveStatic: vi.fn(() => vi.fn()),
|
||||
serveStatic: mocks.serveStatic,
|
||||
}));
|
||||
|
||||
const { handleWebApp, handleWebAppHead } = await import("./web");
|
||||
vi.mock("@reactive-resume/env/server", () => ({
|
||||
env: mocks.env,
|
||||
}));
|
||||
|
||||
type StaticOptions = {
|
||||
onFound?: (
|
||||
path: string,
|
||||
context: {
|
||||
req: { path: string };
|
||||
header: (name: string, value: string) => void;
|
||||
},
|
||||
) => void | Promise<void>;
|
||||
};
|
||||
|
||||
const { handleWebApp } = await import("./web");
|
||||
const staticOptions = mocks.serveStatic.mock.calls[0]?.[0] as StaticOptions | undefined;
|
||||
|
||||
describe("web app fallback classification", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mocks.env.ROOT_RESUME_ID = undefined;
|
||||
vi.mocked(fs.readFile).mockResolvedValue("<html>app</html>");
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue(null);
|
||||
});
|
||||
|
||||
it("serves the shell for the root app route without noindex", async () => {
|
||||
@@ -32,22 +59,180 @@ describe("web app fallback classification", () => {
|
||||
expect(await response.text()).toBe("<html>app</html>");
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/auth/login",
|
||||
"/dashboard",
|
||||
"/builder/resume-1",
|
||||
"/agent",
|
||||
"/templates",
|
||||
"/templates/azurill.pdf",
|
||||
])("serves noindex shell for known app prefix %s", async (pathname) => {
|
||||
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('<link rel="preload" href="/videos/timelapse-v1.webp" as="image" fetchpriority="high">');
|
||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
expect(html).toContain('id="reactive-resume-structured-data"');
|
||||
expect(html).toContain('"@type":["SoftwareApplication","WebApplication"]');
|
||||
expect(html).toContain('"url":"https://rxresu.me/"');
|
||||
expect(html).not.toContain("utm_source");
|
||||
|
||||
const dashboardResponse = await handleWebApp(new Request("https://example.com/dashboard"));
|
||||
expect(await dashboardResponse.text()).not.toContain('rel="canonical"');
|
||||
});
|
||||
|
||||
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("replaces the shell metadata with the checker's own", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/ats-checker"))).text();
|
||||
|
||||
expect(html).toContain("<title>ATS Checker - Reactive Resume</title>");
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/ats-checker">');
|
||||
expect(html).toContain('<meta property="og:url" content="https://rxresu.me/ats-checker">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/ats-checker.png">');
|
||||
expect(html).toContain('id="ats-checker-structured-data"');
|
||||
expect(html).not.toContain("Marketing copy.");
|
||||
});
|
||||
|
||||
it("answers HEAD without a body", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/ats-checker", { method: "HEAD" }));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(await response.text()).toBe("");
|
||||
});
|
||||
|
||||
it("does not treat the checker path as a public resume owner", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/ats-checker/anything"));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(mocks.getPublicResumeSocialMeta).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("public resume social cards", () => {
|
||||
const shell = `<html><head><title>Reactive Resume — A free and open-source resume builder</title><meta name="description" content="Marketing copy."></head><body></body></html>`;
|
||||
|
||||
it("injects resume-specific social metadata and replaces the shell title", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockResolvedValue({
|
||||
name: "Jane Doe",
|
||||
title: "Jane Doe — Staff Engineer",
|
||||
description: "Builds resilient distributed systems.",
|
||||
template: "azurill",
|
||||
});
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/resume"))).text();
|
||||
|
||||
expect(mocks.getPublicResumeSocialMeta).toHaveBeenCalledWith({ username: "jane", slug: "resume" });
|
||||
expect(html).toContain("<title>Jane Doe - Reactive Resume</title>");
|
||||
expect(html).toContain('<meta name="description" content="Builds resilient distributed systems.">');
|
||||
expect(html).not.toContain("Marketing copy.");
|
||||
expect(html).toContain('<link rel="canonical" href="https://rxresu.me/jane/resume">');
|
||||
expect(html).toContain('<meta property="og:type" content="profile">');
|
||||
expect(html).toContain('<meta property="og:title" content="Jane Doe — Staff Engineer">');
|
||||
expect(html).toContain('<meta property="og:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
expect(html).toContain('<meta name="twitter:card" content="summary_large_image">');
|
||||
expect(html).toContain('<meta name="twitter:image" content="https://rxresu.me/opengraph/banner.jpg">');
|
||||
});
|
||||
|
||||
it("escapes user-authored values so resume content cannot break out of the attribute", async () => {
|
||||
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("serves the plain shell when the resume is not publicly shareable", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
|
||||
const html = await (await handleWebApp(new Request("https://example.com/jane/private"))).text();
|
||||
|
||||
expect(html).toBe(shell);
|
||||
});
|
||||
|
||||
it("serves the plain shell when the lookup fails", async () => {
|
||||
vi.mocked(fs.readFile).mockResolvedValue(shell);
|
||||
mocks.getPublicResumeSocialMeta.mockRejectedValue(new Error("database unavailable"));
|
||||
|
||||
const response = await handleWebApp(new Request("https://example.com/jane/resume"));
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
await expect(response.text()).resolves.toBe(shell);
|
||||
});
|
||||
});
|
||||
|
||||
it("caches versioned homepage media immutably", async () => {
|
||||
const headers = new Headers();
|
||||
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse-v1.mp4" },
|
||||
header: (name, value) => headers.set(name, value),
|
||||
});
|
||||
|
||||
expect(headers.get("Cache-Control")).toBe("public, max-age=31536000, immutable");
|
||||
|
||||
const unversionedHeaders = new Headers();
|
||||
await staticOptions?.onFound?.("", {
|
||||
req: { path: "/videos/timelapse.mp4" },
|
||||
header: (name, value) => unversionedHeaders.set(name, value),
|
||||
});
|
||||
|
||||
expect(unversionedHeaders.get("Cache-Control")).toBeNull();
|
||||
});
|
||||
|
||||
it.each(["/", "/alice/resume"])("sets framing and report-only CSP security headers on %s", async (pathname) => {
|
||||
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>");
|
||||
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>");
|
||||
},
|
||||
);
|
||||
|
||||
it("serves noindex shell for public resume shaped routes", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/alice/resume"));
|
||||
|
||||
@@ -66,19 +251,18 @@ describe("web app fallback classification", () => {
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
"/api/foo",
|
||||
"/mcp/foo",
|
||||
"/uploads/foo",
|
||||
])("does not treat reserved two-segment path %s as a public resume", async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
it.each(["/api/foo", "/mcp/foo", "/uploads/foo"])(
|
||||
"does not treat reserved two-segment path %s as a public resume",
|
||||
async (pathname) => {
|
||||
const response = await handleWebApp(new Request(`https://example.com${pathname}`));
|
||||
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(response.status).toBe(404);
|
||||
expect(response.headers.get("Content-Type")).toBe("text/plain; charset=UTF-8");
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, nofollow");
|
||||
expect(await response.text()).toBe("Not Found");
|
||||
expect(fs.readFile).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("returns plain 404 for missing asset-looking paths", async () => {
|
||||
const response = await handleWebApp(new Request("https://example.com/assets/missing.css"));
|
||||
@@ -90,8 +274,10 @@ describe("web app fallback classification", () => {
|
||||
});
|
||||
|
||||
it("mirrors fallback status and headers for HEAD without a body", async () => {
|
||||
const knownResponse = handleWebAppHead(new Request("https://example.com/dashboard"));
|
||||
const unknownResponse = handleWebAppHead(new Request("https://example.com/unknown/extra/path"));
|
||||
const knownResponse = await handleWebApp(new Request("https://example.com/dashboard", { method: "HEAD" }));
|
||||
const unknownResponse = await handleWebApp(
|
||||
new Request("https://example.com/unknown/extra/path", { method: "HEAD" }),
|
||||
);
|
||||
|
||||
expect(knownResponse.status).toBe(200);
|
||||
expect(knownResponse.headers.get("Content-Type")).toBe("text/html; charset=UTF-8");
|
||||
@@ -104,3 +290,28 @@ describe("web app fallback classification", () => {
|
||||
expect(await unknownResponse.text()).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("configured root shell", () => {
|
||||
it.each(["GET", "HEAD"])("serves no-store noindex headers for %s", async (method) => {
|
||||
mocks.env.ROOT_RESUME_ID = "private-or-missing-id";
|
||||
const response = await handleWebApp(new Request("https://attacker.example/", { method }));
|
||||
expect(response.headers.get("X-Robots-Tag")).toBe("noindex, follow");
|
||||
expect(response.headers.get("Cache-Control")).toBe("private, no-store");
|
||||
});
|
||||
it("uses configured canonical root without leaking ID or marketing metadata", async () => {
|
||||
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/);
|
||||
});
|
||||
});
|
||||
|
||||
+266
-15
@@ -2,6 +2,7 @@ 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";
|
||||
|
||||
function resolveWebDistPath() {
|
||||
const candidates = [
|
||||
@@ -19,6 +20,13 @@ function resolveWebDistPath() {
|
||||
const staticRoot = resolveWebDistPath();
|
||||
const indexHtmlPath = `${staticRoot}/index.html`;
|
||||
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",
|
||||
@@ -29,10 +37,9 @@ const reservedPublicResumeSegments = new Set([
|
||||
"builder",
|
||||
"agent",
|
||||
"templates",
|
||||
"ats-checker",
|
||||
]);
|
||||
|
||||
export const serveWebDistStatic = serveStatic({ root: staticRoot });
|
||||
|
||||
function isAssetPath(pathname: string): boolean {
|
||||
return pathname.split("/").pop()?.includes(".") ?? false;
|
||||
}
|
||||
@@ -52,12 +59,221 @@ function isPublicResumePath(pathname: string): boolean {
|
||||
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",
|
||||
"Content-Security-Policy-Report-Only":
|
||||
"default-src 'self'; img-src 'self' data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none'",
|
||||
};
|
||||
|
||||
const ROOT_TITLE = "Reactive Resume — A free and open-source resume builder";
|
||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
||||
const ROOT_DESCRIPTION =
|
||||
"Free, open-source resume builder. Create, update, and share your resume, with no ads and no paywall.";
|
||||
const ROOT_POSTER_PATH = "/videos/timelapse-v1.webp";
|
||||
const ROOT_FAQ_ITEMS = [
|
||||
{
|
||||
question: "Is Reactive Resume really free?",
|
||||
answer:
|
||||
"Yes. Reactive Resume is free to use, with no hidden costs, premium tiers, or subscription fees. It's open source, and it will stay free.",
|
||||
},
|
||||
{
|
||||
question: "How is my data protected?",
|
||||
answer:
|
||||
"Your data is stored securely and never shared with third parties. If you want full control over it, you can self-host Reactive Resume on your own servers.",
|
||||
},
|
||||
{
|
||||
question: "Can I export my resume to PDF?",
|
||||
answer: "Yes. One click exports your resume to PDF, with your formatting and styling intact.",
|
||||
},
|
||||
{
|
||||
question: "Is Reactive Resume available in multiple languages?",
|
||||
answer:
|
||||
"Yes. Pick your language on the settings page, or with the language switcher in the top right corner. If your language is missing, or the existing translation could be better, you can contribute to the translations on Crowdin.",
|
||||
},
|
||||
{
|
||||
question: "What makes Reactive Resume different from other resume builders?",
|
||||
answer:
|
||||
"Reactive Resume is open source, private, and free. It shows no ads, doesn't track what you do, and doesn't lock features behind a paywall.",
|
||||
},
|
||||
{
|
||||
question: "How do I share my resume?",
|
||||
answer: "Share it with a public URL, put a password on that URL, or download the PDF and send it yourself.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function createRootSeoMarkup(canonicalUrl: string) {
|
||||
const origin = new URL(canonicalUrl).origin;
|
||||
const imageUrl = `${origin}/opengraph/banner.jpg`;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@graph": [
|
||||
{
|
||||
"@type": "WebSite",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
},
|
||||
{
|
||||
"@type": ["SoftwareApplication", "WebApplication"],
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
description: ROOT_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: {
|
||||
"@type": "Offer",
|
||||
price: "0",
|
||||
priceCurrency: "USD",
|
||||
},
|
||||
codeRepository: "https://github.com/reactive-resume/reactive-resume",
|
||||
},
|
||||
{
|
||||
"@type": "Project",
|
||||
name: "Reactive Resume",
|
||||
url: canonicalUrl,
|
||||
sameAs: ["https://github.com/reactive-resume/reactive-resume"],
|
||||
},
|
||||
{
|
||||
"@type": "FAQPage",
|
||||
mainEntity: ROOT_FAQ_ITEMS.map((item) => ({
|
||||
"@type": "Question",
|
||||
name: item.question,
|
||||
acceptedAnswer: {
|
||||
"@type": "Answer",
|
||||
text: item.answer,
|
||||
},
|
||||
})),
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<link rel="preload" href="${ROOT_POSTER_PATH}" as="image" fetchpriority="high">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${ROOT_TITLE}">
|
||||
<meta property="og:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${ROOT_TITLE}">
|
||||
<meta name="twitter:description" content="${ROOT_DESCRIPTION}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
<script id="reactive-resume-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
||||
`;
|
||||
}
|
||||
|
||||
const ATS_CHECKER_TITLE = "ATS Checker - Reactive Resume";
|
||||
// Keep under ~120 characters so Google's mobile SERP snippet is not truncated at 3 lines.
|
||||
const ATS_CHECKER_DESCRIPTION =
|
||||
"Check whether software can read your resume PDF. Runs entirely in your browser, so your file is never uploaded.";
|
||||
|
||||
function createAtsCheckerSeoMarkup(origin: string) {
|
||||
const canonicalUrl = `${origin}/ats-checker`;
|
||||
const imageUrl = `${origin}/opengraph/ats-checker.png`;
|
||||
const structuredData = {
|
||||
"@context": "https://schema.org",
|
||||
"@type": "WebApplication",
|
||||
name: "ATS Checker",
|
||||
url: canonicalUrl,
|
||||
description: ATS_CHECKER_DESCRIPTION,
|
||||
applicationCategory: "BusinessApplication",
|
||||
operatingSystem: "Web",
|
||||
isAccessibleForFree: true,
|
||||
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
|
||||
isPartOf: { "@type": "WebSite", name: "Reactive Resume", url: `${origin}/` },
|
||||
};
|
||||
|
||||
return `
|
||||
<link rel="canonical" href="${canonicalUrl}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:site_name" content="Reactive Resume">
|
||||
<meta property="og:title" content="${ATS_CHECKER_TITLE}">
|
||||
<meta property="og:description" content="${ATS_CHECKER_DESCRIPTION}">
|
||||
<meta property="og:url" content="${canonicalUrl}">
|
||||
<meta property="og:image" content="${imageUrl}">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="${ATS_CHECKER_TITLE}">
|
||||
<meta name="twitter:description" content="${ATS_CHECKER_DESCRIPTION}">
|
||||
<meta name="twitter:image" content="${imageUrl}">
|
||||
<script id="ats-checker-structured-data" type="application/ld+json">${JSON.stringify(structuredData)}</script>
|
||||
`;
|
||||
}
|
||||
|
||||
// Resume names, headlines, and summaries are user-authored, so they must never reach the served
|
||||
// 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 === "/") return { "Content-Type": "text/html; charset=UTF-8" };
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -74,23 +290,58 @@ function notFoundResponse(options: { head?: boolean; noindex?: boolean } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
// 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("Not Found", { status: 404 });
|
||||
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) {
|
||||
return new Response(isHead ? null : "Not Found", { status: 404 });
|
||||
}
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ noindex: true });
|
||||
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");
|
||||
const canonicalUrl = new URL("/", env.APP_URL).toString();
|
||||
|
||||
if (pathname === "/" && env.ROOT_RESUME_ID?.trim()) {
|
||||
// Root configuration never discloses a target in the HTML shell. The public API
|
||||
// gates data and browser metadata; shell requests must not count extra views.
|
||||
const shell = html
|
||||
.replace(/<title>[^<]*<\/title>/, "<title>Reactive Resume</title>")
|
||||
.replace(/<meta\s+name="description"[^>]*>/, '<meta name="description" content="">');
|
||||
const markup = `<link rel="canonical" href="${escapeAttribute(canonicalUrl)}" data-root-resume-shell><meta name="robots" content="noindex, follow" data-root-resume-shell>`;
|
||||
return new Response(shell.replace("</head>", `${markup}</head>`), { headers });
|
||||
}
|
||||
|
||||
if (pathname === "/") {
|
||||
return new Response(html.replace("</head>", `${createRootSeoMarkup(canonicalUrl)}</head>`), { headers });
|
||||
}
|
||||
|
||||
if (pathname === "/ats-checker") {
|
||||
const origin = new URL(env.APP_URL).origin;
|
||||
const withTitle = html
|
||||
.replace(/<title>[^<]*<\/title>/, `<title>${ATS_CHECKER_TITLE}</title>`)
|
||||
.replace(/<meta\s+name="description"[^>]*>/, `<meta name="description" content="${ATS_CHECKER_DESCRIPTION}">`);
|
||||
|
||||
return new Response(withTitle.replace("</head>", `${createAtsCheckerSeoMarkup(origin)}</head>`), { headers });
|
||||
}
|
||||
|
||||
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.
|
||||
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 });
|
||||
}
|
||||
|
||||
export function handleWebAppHead(request: Request) {
|
||||
const pathname = new URL(request.url).pathname;
|
||||
if (!isNoindexShellPath(pathname) && isAssetPath(pathname)) return new Response(null, { status: 404 });
|
||||
|
||||
const headers = getFallbackResponseHeaders(pathname);
|
||||
if (!headers) return notFoundResponse({ head: true, noindex: true });
|
||||
|
||||
return new Response(null, { status: 200, headers });
|
||||
}
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
ipAddress: vi.fn<(request: Request) => string | undefined>(),
|
||||
waitUntil: vi.fn(),
|
||||
initializeAuth: vi.fn(),
|
||||
attachDatabasePool: vi.fn(),
|
||||
configureAgentStreamLifetime: vi.fn(),
|
||||
pool: {},
|
||||
getPool: vi.fn(),
|
||||
createApp:
|
||||
vi.fn<
|
||||
(options: { serveStatic: boolean; trustedClient: (request: Request) => string }) => {
|
||||
fetch: (request: Request) => Promise<Response>;
|
||||
}
|
||||
>(),
|
||||
handle: vi.fn<(request: Request) => Promise<Response>>(),
|
||||
}));
|
||||
|
||||
vi.mock("@vercel/functions", () => ({
|
||||
ipAddress: mocks.ipAddress,
|
||||
waitUntil: mocks.waitUntil,
|
||||
attachDatabasePool: mocks.attachDatabasePool,
|
||||
}));
|
||||
vi.mock("@reactive-resume/api/features/agent/streams", () => ({
|
||||
configureAgentStreamLifetime: mocks.configureAgentStreamLifetime,
|
||||
}));
|
||||
vi.mock("@reactive-resume/auth/config", () => ({ initializeAuth: mocks.initializeAuth }));
|
||||
vi.mock("@reactive-resume/db/client", () => ({ getPool: mocks.getPool }));
|
||||
vi.mock("./http/app", () => ({ createApp: mocks.createApp }));
|
||||
|
||||
function spoofedRequest() {
|
||||
return new Request("https://resume.test/api/rpc?batch=1", {
|
||||
method: "POST",
|
||||
body: "original RPC body",
|
||||
headers: {
|
||||
...Object.fromEntries(TRUSTED_IP_HEADERS.map((header) => [header, "192.0.2.66"])),
|
||||
"x-forwarded-for": "192.0.2.66, 192.0.2.77",
|
||||
cookie: "session=original",
|
||||
authorization: "Bearer original",
|
||||
"content-type": "application/json",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
vi.clearAllMocks();
|
||||
mocks.getPool.mockReturnValue(mocks.pool);
|
||||
mocks.handle.mockResolvedValue(new Response("handled"));
|
||||
mocks.createApp.mockReturnValue({ fetch: mocks.handle });
|
||||
});
|
||||
|
||||
describe("Vercel adapter", () => {
|
||||
it("registers platform lifetime hooks and disables filesystem static serving", async () => {
|
||||
await import("./vercel");
|
||||
expect(mocks.configureAgentStreamLifetime).toHaveBeenCalledExactlyOnceWith(mocks.waitUntil);
|
||||
expect(mocks.attachDatabasePool).toHaveBeenCalledExactlyOnceWith(mocks.pool);
|
||||
expect(mocks.createApp).toHaveBeenCalledExactlyOnceWith({
|
||||
serveStatic: false,
|
||||
trustedClient: expect.any(Function),
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["203.0.113.9", "2001:db8::9"])("replaces all spoofed IP headers with platform IP %s", async (ip) => {
|
||||
mocks.ipAddress.mockReturnValue(ip);
|
||||
const { default: adapter } = await import("./vercel");
|
||||
const request = spoofedRequest();
|
||||
expect(await (await adapter.fetch(request)).text()).toBe("handled");
|
||||
expect(mocks.ipAddress).toHaveBeenCalledExactlyOnceWith(request);
|
||||
const forwarded = mocks.handle.mock.calls[0]?.[0];
|
||||
if (!forwarded) throw new Error("Expected forwarded request");
|
||||
for (const header of TRUSTED_IP_HEADERS) {
|
||||
const expected = ["x-real-ip", "x-forwarded-for"].includes(header.toLowerCase()) ? ip : null;
|
||||
expect(forwarded.headers.get(header)).toBe(expected);
|
||||
}
|
||||
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe(ip);
|
||||
expect(forwarded.url).toBe(request.url);
|
||||
expect(forwarded.method).toBe("POST");
|
||||
expect(await forwarded.text()).toBe("original RPC body");
|
||||
expect(forwarded.headers.get("cookie")).toBe("session=original");
|
||||
expect(forwarded.headers.get("authorization")).toBe("Bearer original");
|
||||
expect(forwarded.headers.get("content-type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it.each([undefined, "", "invalid-ip", "203.0.113.9, 192.0.2.66"])(
|
||||
"clears attacker headers when platform IP is missing or invalid: %s",
|
||||
async (ip) => {
|
||||
mocks.ipAddress.mockReturnValue(ip);
|
||||
const { default: adapter } = await import("./vercel");
|
||||
await adapter.fetch(spoofedRequest());
|
||||
const forwarded = mocks.handle.mock.calls[0]?.[0];
|
||||
if (!forwarded) throw new Error("Expected forwarded request");
|
||||
for (const header of TRUSTED_IP_HEADERS) expect(forwarded.headers.has(header)).toBe(false);
|
||||
expect(mocks.createApp.mock.calls[0]?.[0].trustedClient(forwarded)).toBe("unknown");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isIP } from "node:net";
|
||||
import { attachDatabasePool, ipAddress, waitUntil } from "@vercel/functions";
|
||||
import { configureAgentStreamLifetime } from "@reactive-resume/api/features/agent/streams";
|
||||
import { initializeAuth } from "@reactive-resume/auth/config";
|
||||
import { getPool } from "@reactive-resume/db/client";
|
||||
import { TRUSTED_IP_HEADERS } from "@reactive-resume/utils/rate-limit";
|
||||
import { createApp } from "./http/app";
|
||||
|
||||
configureAgentStreamLifetime(waitUntil);
|
||||
attachDatabasePool(getPool());
|
||||
const app = createApp({
|
||||
serveStatic: false,
|
||||
trustedClient: (request) => request.headers.get("x-real-ip") ?? "unknown",
|
||||
});
|
||||
|
||||
export default {
|
||||
async fetch(request: Request) {
|
||||
await initializeAuth();
|
||||
const ip = ipAddress(request);
|
||||
const headers = new Headers(request.headers);
|
||||
for (const name of TRUSTED_IP_HEADERS) headers.delete(name);
|
||||
headers.delete("x-real-ip");
|
||||
if (ip && isIP(ip)) {
|
||||
headers.set("x-real-ip", ip);
|
||||
headers.set("x-forwarded-for", ip);
|
||||
}
|
||||
return app.fetch(new Request(request, { headers }));
|
||||
},
|
||||
};
|
||||
@@ -8,8 +8,35 @@ const rootPackageJson = JSON.parse(readFileSync(new URL("../../package.json", im
|
||||
version?: string;
|
||||
};
|
||||
|
||||
// Lambda disables require(ESM) and uses stricter CJS export detection than standalone Node.
|
||||
const bundledInteropPackages = new Set([
|
||||
"@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 shouldExternalizeThirdParty = (id: string) => {
|
||||
if (id.startsWith("@reactive-resume/")) return false;
|
||||
const packageName = id
|
||||
.split("/")
|
||||
.slice(0, id.startsWith("@") ? 2 : 1)
|
||||
.join("/");
|
||||
if (id.startsWith("@reactive-resume/") || bundledInteropPackages.has(packageName)) return false;
|
||||
if (id.startsWith("@/") || id.startsWith(".") || id.startsWith("/") || id.startsWith("\0")) return false;
|
||||
|
||||
return true;
|
||||
@@ -33,7 +60,9 @@ const promptAssetsPlugin: TsdownPlugin = {
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
entry: { index: "src/index.ts" },
|
||||
entry: { index: "src/index.ts", vercel: "src/vercel.ts", "prepare-deployment": "src/prepare-deployment.ts" },
|
||||
// Keep import.meta.url-based asset lookup adjacent to the entrypoints.
|
||||
outputOptions: { chunkFileNames: "[name]-[hash].mjs" },
|
||||
format: "esm",
|
||||
platform: "node",
|
||||
target: "node24",
|
||||
@@ -42,9 +71,12 @@ export default defineConfig({
|
||||
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: [/^@reactive-resume\//],
|
||||
alwaysBundle: [/^@reactive-resume\//, ...bundledInteropPackages],
|
||||
neverBundle: shouldExternalizeThirdParty,
|
||||
},
|
||||
plugins: [promptAssetsPlugin],
|
||||
|
||||
+10
-1
@@ -1,4 +1,13 @@
|
||||
{
|
||||
"extends": ["//"],
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"]
|
||||
"tags": ["app:server", "runtime:server", "role:adapter"],
|
||||
"tasks": {
|
||||
"test": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:coverage": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:agent": { "env": ["OAUTH_TEST_DATABASE_URL"] },
|
||||
"test:ci": {
|
||||
"cache": false,
|
||||
"env": ["OAUTH_TEST_DATABASE_URL"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fileURLToPath } from "node:url";
|
||||
// @boundaries-ignore root shared Vitest config
|
||||
import { createVitestProjectConfig } from "../../vitest.shared";
|
||||
import { createVitestProjectConfig } from "../../vitest.shared.mts";
|
||||
|
||||
export default createVitestProjectConfig({
|
||||
name: "server",
|
||||
|
||||
+40
-3
@@ -9,17 +9,54 @@
|
||||
<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" />
|
||||
<meta name="description" content="Reactive Resume is a free and open-source resume builder that simplifies the process of creating, updating, and sharing your resume.">
|
||||
<!-- 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</title>
|
||||
<title>Reactive Resume — A free and open-source resume builder</title>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Keep #app empty: main.tsx only mounts React when rootElement has no children. -->
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
<!-- Branded first paint; hidden once React populates #app (higher-specificity rule below). -->
|
||||
<div id="initial-loader">
|
||||
<img src="/icon/dark.svg" width="48" height="48" alt="Reactive Resume" />
|
||||
<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: #09090b;
|
||||
}
|
||||
#app:not(:empty) ~ #initial-loader { display: none; }
|
||||
.initial-loader__spinner {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 2px solid rgba(250, 250, 250, 0.2);
|
||||
border-top-color: #fafafa;
|
||||
border-radius: 9999px;
|
||||
animation: app-spin 0.7s linear infinite;
|
||||
}
|
||||
.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>
|
||||
|
||||
+4261
-862
File diff suppressed because it is too large
Load Diff
+4213
-814
File diff suppressed because it is too large
Load Diff
+4194
-795
File diff suppressed because it is too large
Load Diff
+4191
-792
File diff suppressed because it is too large
Load Diff
+4187
-788
File diff suppressed because it is too large
Load Diff
+4192
-793
File diff suppressed because it is too large
Load Diff
+4187
-788
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
+4189
-790
File diff suppressed because it is too large
Load Diff
+4180
-781
File diff suppressed because it is too large
Load Diff
+4190
-791
File diff suppressed because it is too large
Load Diff
+4187
-788
File diff suppressed because it is too large
Load Diff
+4178
-779
File diff suppressed because it is too large
Load Diff
+4163
-765
File diff suppressed because it is too large
Load Diff
+4198
-799
File diff suppressed because it is too large
Load Diff
+4193
-794
File diff suppressed because it is too large
Load Diff
+4192
-793
File diff suppressed because it is too large
Load Diff
+4192
-793
File diff suppressed because it is too large
Load Diff
+4189
-790
File diff suppressed because it is too large
Load Diff
+4194
-795
File diff suppressed because it is too large
Load Diff
+4191
-792
File diff suppressed because it is too large
Load Diff
+4191
-792
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