Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0b1b0aca11 | ||
|
|
d5c117cf5d | ||
|
|
c380d16feb | ||
|
|
6b03507ca0 | ||
|
|
ff8a8b8ee6 | ||
|
|
d3981125a1 | ||
|
|
b4965d386e | ||
|
|
bda28bd385 | ||
|
|
edfb90c6c7 | ||
|
|
a1e3762b9d | ||
|
|
ea54da12ee | ||
|
|
d552d61cfd | ||
|
|
6332418cb1 | ||
|
|
824c37c227 | ||
|
|
1c116282d7 | ||
|
|
ba197523d2 | ||
|
|
d8902d0d96 | ||
|
|
784860fd9d | ||
|
|
9552618f33 | ||
|
|
f27c8a0629 | ||
|
|
e43e89be67 | ||
|
|
578f55f444 | ||
|
|
033dd6654b | ||
|
|
341dc1cb6a | ||
|
|
160b4d528c | ||
|
|
0081e73721 | ||
|
|
79eb4ed6fe | ||
|
|
3d50a9ee32 | ||
|
|
5e5c853df1 | ||
|
|
c47bf6436d | ||
|
|
86cfe5b940 | ||
|
|
81723667cb | ||
|
|
73f7dd26bd | ||
|
|
cb6c834253 | ||
|
|
85f7616707 | ||
|
|
7bc5719f94 | ||
|
|
dfbbc1d297 | ||
|
|
3c70c6e40b | ||
|
|
ff9500c133 | ||
|
|
5e72daf3dc | ||
|
|
5c5cca0d9e | ||
|
|
840cad55be | ||
|
|
523b6f5c46 | ||
|
|
46ff1b60eb | ||
|
|
935508f355 | ||
|
|
7c1122fc10 | ||
|
|
bf3f82b76e | ||
|
|
41e7d9d419 | ||
|
|
6088025a81 | ||
|
|
fdec832934 | ||
|
|
a3a52681c8 | ||
|
|
4dc1245437 | ||
|
|
ddb30f0bb8 | ||
|
|
067e89cf47 | ||
|
|
34b4909640 | ||
|
|
2df2b8aa4b | ||
|
|
b40dec2cb6 | ||
|
|
ba2d9ee8c2 | ||
|
|
dc35096db7 | ||
|
|
509c838608 | ||
|
|
75f2ae5f10 | ||
|
|
19778b2b94 | ||
|
|
be6cdd580f | ||
|
|
5b4523bd71 | ||
|
|
c54b829250 | ||
|
|
484b7e06ca | ||
|
|
af3e645580 | ||
|
|
cfde86f057 | ||
|
|
8f02925844 | ||
|
|
d20dff9ad9 | ||
|
|
1b376bdff8 | ||
|
|
b42239095d | ||
|
|
13e7fba4a4 | ||
|
|
e6d54458a8 | ||
|
|
802476e851 | ||
|
|
dc16c5b99d | ||
|
|
7f23d3c3e8 | ||
|
|
4dfb5ab7d0 | ||
|
|
6ace2e78f1 | ||
|
|
338049a09d | ||
|
|
cc7b293b92 | ||
|
|
48b59d96fb | ||
|
|
bb06383e45 | ||
|
|
d252d1b7fa | ||
|
|
8ae45e11ef | ||
|
|
08e42a6174 | ||
|
|
be2c994795 | ||
|
|
0574fe538b | ||
|
|
9e1dc7acd1 | ||
|
|
d5b4722e2a | ||
|
|
65d2906426 | ||
|
|
01ccc48a1e | ||
|
|
48f2df527e | ||
|
|
4539f4db97 | ||
|
|
466b6d9cea | ||
|
|
7a988da0b8 | ||
|
|
7179fd2c05 | ||
|
|
b06b46f486 | ||
|
|
a677a11e02 | ||
|
|
ad54a1fba3 | ||
|
|
832aea3c88 | ||
|
|
bb7fe6d419 | ||
|
|
dfdd4818dd | ||
|
|
459f8d990f | ||
|
|
a4cbfebb96 | ||
|
|
158deb0896 | ||
|
|
74bf74dd02 | ||
|
|
84a8f16ec0 | ||
|
|
87a2ec7351 | ||
|
|
b526e56780 | ||
|
|
b4ede4f620 | ||
|
|
568777f81e | ||
|
|
ce37bec2fa | ||
|
|
417dc247a3 | ||
|
|
2593c937d8 | ||
|
|
30837ce44e | ||
|
|
88933fc19b | ||
|
|
6bc5f9014b | ||
|
|
705333a704 | ||
|
|
b19448d53b | ||
|
|
a4af2d2b5a | ||
|
|
bdf14d5423 | ||
|
|
8bb349ce10 | ||
|
|
8ca3219c8e | ||
|
|
dd49ea8251 | ||
|
|
b1971b6048 | ||
|
|
7f69b5922c | ||
|
|
8018e3c7cb | ||
|
|
d0bd476c79 | ||
|
|
4e2d6c0a46 | ||
|
|
cb93188e6c | ||
|
|
c176867c7d | ||
|
|
1784df7e00 | ||
|
|
996c08c32c | ||
|
|
0db1cea614 | ||
|
|
f292af46f6 | ||
|
|
5efac0810e | ||
|
|
51074a1f03 | ||
|
|
c7d009a3a8 | ||
|
|
014ca90161 | ||
|
|
2816fa3fd8 | ||
|
|
cf681c7f7b | ||
|
|
1558804191 | ||
|
|
8ae445bfa5 | ||
|
|
f7ca253f80 | ||
|
|
a1598a2828 | ||
|
|
131e538055 | ||
|
|
2f72e6475c | ||
|
|
01e01193cd | ||
|
|
8e0fe399c2 | ||
|
|
5ec05326c8 | ||
|
|
0783b04a7d | ||
|
|
daff9246e1 | ||
|
|
24d5bfbe7a | ||
|
|
784b68c581 | ||
|
|
5c5ff9960d | ||
|
|
69be26854a | ||
|
|
e9a489eb5f | ||
|
|
b67b8fe3f7 | ||
|
|
f8d3258829 | ||
|
|
dab0f87304 | ||
|
|
6ef6c769a8 | ||
|
|
882fc32586 | ||
|
|
e9dd6c94ab | ||
|
|
340a54835e | ||
|
|
195a9baccb | ||
|
|
71c1e3092a | ||
|
|
aaaab9aa99 | ||
|
|
349bb5e625 | ||
|
|
9719c661b1 | ||
|
|
4533e70b32 | ||
|
|
a142a8ef68 | ||
|
|
a3d977e99c | ||
|
|
1c35e8577d | ||
|
|
30d2cdaaca | ||
|
|
a81f35d9e4 | ||
|
|
59233c46cd | ||
|
|
ab58a27673 | ||
|
|
190fe26051 | ||
|
|
90132850dd | ||
|
|
6731d5683a | ||
|
|
00f9a0af71 | ||
|
|
adda58adb9 | ||
|
|
a9486c9e6c | ||
|
|
2172ce002f | ||
|
|
a5dfbc799f | ||
|
|
f7ddb2a508 | ||
|
|
f24b587038 | ||
|
|
00343617bf | ||
|
|
22248a7914 | ||
|
|
c0296d1233 | ||
|
|
c9b829e96c | ||
|
|
a761d676ad | ||
|
|
6827d37a18 | ||
|
|
6370894e35 | ||
|
|
187e595a64 | ||
|
|
6326b1486a | ||
|
|
df5950917d | ||
|
|
7bcca2a737 | ||
|
|
730486a87c | ||
|
|
3a93c38a79 | ||
|
|
e0a9dbeaf3 | ||
|
|
9a6fbe1766 | ||
|
|
5bf1e3f008 | ||
|
|
c4a01afcc0 | ||
|
|
9f7c016133 | ||
|
|
5416b184ff | ||
|
|
b71c638e86 | ||
|
|
1229172640 | ||
|
|
b0f578a5ef | ||
|
|
e9fed81cff | ||
|
|
e2d3a7760f | ||
|
|
cf72b8be39 | ||
|
|
6972743364 | ||
|
|
8860b30aa1 | ||
|
|
49d3e3c71c | ||
|
|
39d4f4c1fd | ||
|
|
51be45cece | ||
|
|
b65f58e8a1 | ||
|
|
7dd180e4f3 | ||
|
|
85744b5449 | ||
|
|
6006455f02 | ||
|
|
36babc73d4 | ||
|
|
3f0d0f01aa | ||
|
|
3b4c483b40 | ||
|
|
a7be70cbf0 | ||
|
|
44857bdf57 | ||
|
|
d975c604b8 | ||
|
|
88e2a13785 | ||
|
|
5d82d941ec | ||
|
|
f4f28b872a | ||
|
|
8a675638a7 | ||
|
|
e50cb1eb9e | ||
|
|
d1d86bda4f | ||
|
|
183300e62f | ||
|
|
e2406b303e | ||
|
|
dac732907e | ||
|
|
7cd1046809 | ||
|
|
366fcfcbbc | ||
|
|
539bbfdbbc | ||
|
|
16f35fc9cf | ||
|
|
6cf583e93d | ||
|
|
91adbdf8ff | ||
|
|
e78f389a73 | ||
|
|
d62c6ec6e5 | ||
|
|
fd1dc99dd2 | ||
|
|
d04b12b61e | ||
|
|
cff71ab797 | ||
|
|
7a6bb9093e | ||
|
|
d9a2b373da | ||
|
|
03495e0527 | ||
|
|
20cbd2716c | ||
|
|
bc4709d154 | ||
|
|
7ffcadce19 | ||
|
|
83b4fc2c6a | ||
|
|
a495f8874f | ||
|
|
aa5071d022 | ||
|
|
bcd38c06da | ||
|
|
80abffb73b | ||
|
|
3a6691d4af | ||
|
|
e5c5042b9c | ||
|
|
7da2b32a15 | ||
|
|
fe605b5d4f | ||
|
|
3131cf822d | ||
|
|
6f06336a01 | ||
|
|
b71ed2e239 | ||
|
|
519a84e95c | ||
|
|
b55d6ff848 | ||
|
|
87731a20e2 | ||
|
|
fe33bd7c5b | ||
|
|
56c33674b0 | ||
|
|
29121dce59 | ||
|
|
459c0b85bf | ||
|
|
c13e99a578 | ||
|
|
c38566c346 | ||
|
|
80865e1ff0 | ||
|
|
fcee0eb974 | ||
|
|
ebd770ec72 | ||
|
|
90c02acdce | ||
|
|
a7c651b420 | ||
|
|
b1f27ec7a6 | ||
|
|
38a82ab793 | ||
|
|
321eec4196 | ||
|
|
85f507d4d5 | ||
|
|
7995bb4c5d | ||
|
|
234f999fce | ||
|
|
dc68963fd1 | ||
|
|
0259b5425c | ||
|
|
113f6d787c | ||
|
|
d4cc11424d | ||
|
|
528f9ee2bf | ||
|
|
f5e3d0bcb3 | ||
|
|
f80892c9ea | ||
|
|
e17688a989 | ||
|
|
26d4f7c9e8 | ||
|
|
a502ace330 | ||
|
|
ec5294762b | ||
|
|
f73f71f58c | ||
|
|
3d62925af4 | ||
|
|
3e68a97d10 | ||
|
|
26e1639aec | ||
|
|
5e83eb652f | ||
|
|
95ef59ede0 | ||
|
|
a4a41ea421 | ||
|
|
60ccc7f0db | ||
|
|
5b60f3250a | ||
|
|
35269a1ac8 | ||
|
|
b70d559a49 | ||
|
|
895950c3bb | ||
|
|
09760d80b2 | ||
|
|
e5f088e1b7 | ||
|
|
63ea697cdc | ||
|
|
3faa0dd812 | ||
|
|
c157965874 | ||
|
|
d5e1f2c660 | ||
|
|
74a8772409 | ||
|
|
dc70f5fa2d | ||
|
|
868df7793b | ||
|
|
cd8469343d | ||
|
|
c3f4bf092d | ||
|
|
b123c69073 | ||
|
|
025c871c7b | ||
|
|
2dfd2d17b7 | ||
|
|
44e5385c46 | ||
|
|
f05fe63207 | ||
|
|
44fd99100d | ||
|
|
0fe588b355 | ||
|
|
818017f214 | ||
|
|
bee2bfab83 | ||
|
|
0fea23ea7e | ||
|
|
0f9e156e56 | ||
|
|
29bcf809a2 | ||
|
|
9cf79ed196 | ||
|
|
377b47e5d3 | ||
|
|
fc65f77794 | ||
|
|
606afe0bc6 | ||
|
|
e57df42243 | ||
|
|
2d588958ee | ||
|
|
9eaed124c4 | ||
|
|
c45750d2f4 | ||
|
|
9d51ebb734 | ||
|
|
21ecb50636 | ||
|
|
825c34b415 | ||
|
|
8ae7f81de4 | ||
|
|
5e02ab216f | ||
|
|
43218d311c | ||
|
|
b22d704f17 | ||
|
|
dcfa6410e9 | ||
|
|
5f10f21c9f | ||
|
|
999b1867e0 | ||
|
|
edd06b727e | ||
|
|
3325e111ae | ||
|
|
829c8dba84 | ||
|
|
f5e92c4336 | ||
|
|
a9f12c6ecf | ||
|
|
32c183ef82 | ||
|
|
050200261c | ||
|
|
184137d985 | ||
|
|
94747af60a | ||
|
|
fa7071eefa | ||
|
|
89e084e5b8 | ||
|
|
f581a700bc | ||
|
|
19d86c9a64 | ||
|
|
b3c3c5c634 | ||
|
|
3c8bbe010b | ||
|
|
bc1e3647b3 | ||
|
|
76e9dd1379 | ||
|
|
c6410bdf90 | ||
|
|
4a0ca9d918 | ||
|
|
dfb18d8d68 | ||
|
|
5d40be802d | ||
|
|
35c71f4385 | ||
|
|
9a4c3fe971 | ||
|
|
ed1d5da1fc | ||
|
|
87f21407c9 | ||
|
|
6590e8faa0 | ||
|
|
3f5233120d | ||
|
|
5951f8647b | ||
|
|
695b625205 | ||
|
|
6160f17208 | ||
|
|
e1c8deb534 | ||
|
|
c65411c682 | ||
|
|
6c8645bc52 | ||
|
|
c0489890c7 | ||
|
|
ec7844abeb | ||
|
|
6b1214c1ec | ||
|
|
179b410a27 | ||
|
|
c254fc94e5 | ||
|
|
2e6086c46b | ||
|
|
d7e23bd8cc | ||
|
|
0aee40f77e | ||
|
|
ab3c00e7c3 | ||
|
|
20419d10c2 | ||
|
|
8f43b7c2b5 | ||
|
|
15bddadb65 | ||
|
|
5526467f8d | ||
|
|
14973084aa | ||
|
|
419a9cdeb4 | ||
|
|
d070bdde6d | ||
|
|
cbf369032d | ||
|
|
7c6b42811b | ||
|
|
0428e7c40a | ||
|
|
05064014d4 | ||
|
|
2963b6f47e | ||
|
|
baff63c076 | ||
|
|
8e4c0c01d4 | ||
|
|
551db81adc | ||
|
|
0e9997442c | ||
|
|
6b92577873 | ||
|
|
75a6ebd180 | ||
|
|
640e2d35cf | ||
|
|
c4dd08be6e | ||
|
|
d07918ebf6 | ||
|
|
fc9d92ac2a | ||
|
|
bd9cdb5f0f | ||
|
|
01d66238de | ||
|
|
37fcebcd5b | ||
|
|
dc8ce11b12 | ||
|
|
4616ee26b7 | ||
|
|
765f33b23d | ||
|
|
4022cf4212 | ||
|
|
42e2131823 | ||
|
|
143a3f9f67 | ||
|
|
fcadbcc8eb | ||
|
|
304946f6aa | ||
|
|
5c8ca4b67c | ||
|
|
67914b6332 | ||
|
|
ddb97f40cf | ||
|
|
e111928797 | ||
|
|
f2f985aa4d | ||
|
|
4487be0450 | ||
|
|
1dcc3e9c2d | ||
|
|
a5ab24fa4f | ||
|
|
2b3f9ba73d | ||
|
|
b7bc152b8c | ||
|
|
a7d0392971 | ||
|
|
8252eeb64e | ||
|
|
f5a560170d | ||
|
|
35c0423e29 | ||
|
|
10edc3eb3c | ||
|
|
275f8709a3 | ||
|
|
4c6c82f68f | ||
|
|
05a9fb8779 | ||
|
|
0d76008190 | ||
|
|
28b0cb571c | ||
|
|
192c3b8d0c | ||
|
|
32c4557981 | ||
|
|
3d313c12ee | ||
|
|
538dccc8a7 | ||
|
|
6d6b138f12 | ||
|
|
6f095fa809 | ||
|
|
266fceb5fc | ||
|
|
0c401987c3 | ||
|
|
e9ec39e975 | ||
|
|
257648380c | ||
|
|
b7073782fb | ||
|
|
a14845f324 | ||
|
|
f7756e46a9 | ||
|
|
7c2a29aae8 | ||
|
|
aa8cfef0a8 | ||
|
|
af1dff4f93 | ||
|
|
39d1abde52 | ||
|
|
4be7928bbe | ||
|
|
df025d57ca | ||
|
|
bc3966fa1c | ||
|
|
7325a84f0f | ||
|
|
410be9a1a7 | ||
|
|
feec835986 | ||
|
|
d5ada7080e | ||
|
|
61bbe13d06 | ||
|
|
69f429020d | ||
|
|
dfbcf74cc6 | ||
|
|
1f6dd75cc6 | ||
|
|
4b812f05d9 | ||
|
|
7ca772a608 | ||
|
|
88f7d528b4 | ||
|
|
51aae16834 | ||
|
|
81f3a9413c | ||
|
|
a2c7af335a | ||
|
|
3b198c99ef | ||
|
|
ab51e25dd6 | ||
|
|
3b5a292999 | ||
|
|
fd83e0e303 | ||
|
|
fd8fab227d | ||
|
|
1456873e40 | ||
|
|
219e3905d5 | ||
|
|
d78eea8c12 | ||
|
|
293d607dc4 | ||
|
|
d5d783fb01 | ||
|
|
308556b779 | ||
|
|
69caf01ff6 | ||
|
|
68b8497d98 | ||
|
|
46021b9d2a | ||
|
|
e85fc41855 | ||
|
|
7f228ded78 | ||
|
|
4e0856e83c | ||
|
|
90ef1b0720 | ||
|
|
7c16985441 | ||
|
|
57dddd9186 | ||
|
|
1c1d7dc5c0 | ||
|
|
92e7f07e15 | ||
|
|
d855700cbd | ||
|
|
ff43b83b11 | ||
|
|
a13c1a5549 | ||
|
|
8aa2a366ed | ||
|
|
2f326f612d | ||
|
|
826812f8bd | ||
|
|
eeb02a310e | ||
|
|
ce7c2ea2b0 | ||
|
|
dec270037d | ||
|
|
45c2988106 | ||
|
|
3cc183fa90 | ||
|
|
8874cf4bba | ||
|
|
0d4cc760aa | ||
|
|
e759754238 | ||
|
|
2ef01b7e0a | ||
|
|
6b447ebf96 | ||
|
|
e58b2eb91e | ||
|
|
aa78f48dfc | ||
|
|
ebbd3f4b19 | ||
|
|
6328eab2fc | ||
|
|
710dc6c2cb | ||
|
|
ba380922cd | ||
|
|
d851e65718 | ||
|
|
9cb357510d | ||
|
|
3e4d7b3a4c | ||
|
|
2f09549e13 | ||
|
|
31f74531b7 | ||
|
|
e5c0d9e1cd | ||
|
|
2a33de685d | ||
|
|
d1cf5c5acc | ||
|
|
9124e1a83f | ||
|
|
93f8d16a37 | ||
|
|
101b625112 | ||
|
|
910001b4ba | ||
|
|
fd24f27473 | ||
|
|
32a23d2023 | ||
|
|
1d553a40ad | ||
|
|
7bc63fdfc2 | ||
|
|
8de170f5a4 | ||
|
|
c50c45687c | ||
|
|
a4eb114a56 | ||
|
|
83a819d832 | ||
|
|
1b9a246892 | ||
|
|
c965a266bd | ||
|
|
06d99d57dd | ||
|
|
58ba20f16f | ||
|
|
c699123ed6 | ||
|
|
68866e073c | ||
|
|
3eac43429c | ||
|
|
de79e80bed | ||
|
|
fa512452ab | ||
|
|
b979149ae8 | ||
|
|
0e95877106 | ||
|
|
4239e07c53 | ||
|
|
5995fef47b | ||
|
|
e1d04621b2 | ||
|
|
9a1db5f445 | ||
|
|
63c4434ba1 | ||
|
|
9256c498e3 | ||
|
|
33e11fa343 | ||
|
|
a125337fa7 | ||
|
|
66f0654607 | ||
|
|
195f2895a0 | ||
|
|
f7e45efe69 | ||
|
|
86e961ca90 | ||
|
|
09e315bb70 | ||
|
|
7d78eb09a5 | ||
|
|
e4f18f2b38 | ||
|
|
562284445e | ||
|
|
49b142e2c8 | ||
|
|
300c9791cc | ||
|
|
0eb734278a | ||
|
|
5e5439aaab | ||
|
|
d03707bb3e | ||
|
|
0563f08a08 | ||
|
|
d32ba6e888 | ||
|
|
368808416f | ||
|
|
f59eb023b2 | ||
|
|
627367cdc4 | ||
|
|
9822383d9b | ||
|
|
9dd287cf8b | ||
|
|
db46f8f0a8 | ||
|
|
74ee4b8ca7 | ||
|
|
c758c588f8 | ||
|
|
18dc9eb279 | ||
|
|
28fb63cef1 | ||
|
|
cf6cc206e0 | ||
|
|
cf0d2ab15d | ||
|
|
88e5fb034b | ||
|
|
d709957e7f | ||
|
|
5fc149a6f0 | ||
|
|
fc5747bdfa | ||
|
|
a96ed4dd11 | ||
|
|
5fb069980e | ||
|
|
7b679948ba | ||
|
|
5c399dcc2f | ||
|
|
097c02bc48 | ||
|
|
3073a80303 | ||
|
|
3598d5cac9 | ||
|
|
4a1e5658ad | ||
|
|
d5ebb467fc | ||
|
|
5cd288786f | ||
|
|
8dff1d8e50 | ||
|
|
b23da7a78a | ||
|
|
4bfe3f3f70 | ||
|
|
763147beed | ||
|
|
daa7b17733 | ||
|
|
ac7ca69edc | ||
|
|
4bb7e7ed1a | ||
|
|
ef62967afd | ||
|
|
286661ae87 | ||
|
|
c1be5bfba6 | ||
|
|
43a619a8f1 | ||
|
|
df7c162e13 | ||
|
|
085f590cb8 | ||
|
|
85708998e2 | ||
|
|
e6e59b87ac | ||
|
|
f6b5711831 | ||
|
|
eefa22b1a8 | ||
|
|
f96afe7622 | ||
|
|
4fa89b4542 | ||
|
|
49eb46b539 | ||
|
|
5426f9728d | ||
|
|
b0b8339df7 | ||
|
|
f811a70618 | ||
|
|
c8256cf5b2 | ||
|
|
dd1535863a | ||
|
|
04be8387ab | ||
|
|
e8a8cca89b | ||
|
|
6a1beeefdd | ||
|
|
5492c66a32 | ||
|
|
1f387b1356 | ||
|
|
1635b90122 | ||
|
|
262d106ba0 | ||
|
|
95a877c351 | ||
|
|
af5f855f07 | ||
|
|
e37b0fb277 | ||
|
|
64a9d78a18 | ||
|
|
2c9d3e939d | ||
|
|
f467017283 | ||
|
|
1b1488f4ee | ||
|
|
a2ac1c6117 | ||
|
|
f102e09fbe | ||
|
|
ab2efe960c | ||
|
|
1b38f0d153 | ||
|
|
9b6fd0d04d | ||
|
|
86ee5c6a8a | ||
|
|
052f39ec42 | ||
|
|
0625bd39f2 | ||
|
|
50b5330848 | ||
|
|
e4df9eb90a | ||
|
|
ef2dfe5809 | ||
|
|
4c5a9dee7c | ||
|
|
178a38530e | ||
|
|
82bf15d0a9 | ||
|
|
83fd087fcc | ||
|
|
8026305c7b | ||
|
|
b164fa452f | ||
|
|
b6046807f0 | ||
|
|
86d44314a5 | ||
|
|
62334f6d2d | ||
|
|
92cfeef4a2 | ||
|
|
6b2886a46c | ||
|
|
63e3a698c5 | ||
|
|
e7d981f27d | ||
|
|
d5b8e7af0f | ||
|
|
abb58aaec8 | ||
|
|
68914bf59e | ||
|
|
60fa410504 | ||
|
|
94240122f3 | ||
|
|
6e4d05d098 | ||
|
|
9cbbd2626d | ||
|
|
6b1cbb29a9 | ||
|
|
408a0c2c36 | ||
|
|
23f823f59a | ||
|
|
247bebda48 | ||
|
|
bdd801de7c | ||
|
|
934e933bdb | ||
|
|
407ccb5ec7 | ||
|
|
91edf09c77 | ||
|
|
a78c447a0a | ||
|
|
d9697ba0c1 | ||
|
|
f1faac046b | ||
|
|
de646ecbbe | ||
|
|
04e1c199cf | ||
|
|
55bebf3ad9 | ||
|
|
a28a1541d9 | ||
|
|
c96cea4459 | ||
|
|
b1b9a36a22 | ||
|
|
fe04061870 | ||
|
|
5b5271398a | ||
|
|
a58bb02d32 | ||
|
|
fd8e5e0425 | ||
|
|
18758557f1 | ||
|
|
94323596a4 | ||
|
|
131a1569ea | ||
|
|
864ee292a4 | ||
|
|
6dc74e8156 | ||
|
|
3d6dcd61f5 | ||
|
|
8e93bc94cf | ||
|
|
7063fb90e4 | ||
|
|
fb07e45cfa | ||
|
|
1bf98f0949 | ||
|
|
4d8eeee6c1 | ||
|
|
d265b8e3d3 | ||
|
|
746685c155 | ||
|
|
f7d2997e9a | ||
|
|
9e37a4c4f7 | ||
|
|
fb29c8d7f0 | ||
|
|
4aab8ca2b7 | ||
|
|
15e9581e98 | ||
|
|
bc558098fb | ||
|
|
47617a2590 | ||
|
|
024766374f | ||
|
|
18c44ae2f4 | ||
|
|
065992862d | ||
|
|
e0f126abf1 | ||
|
|
875e0e006b | ||
|
|
5c8ff5b764 |
@@ -0,0 +1,3 @@
|
||||
[codespell]
|
||||
skip = .git,*.pdf,*.svg,package-lock.json,*.prisma
|
||||
ignore-words-list = afterall
|
||||
@@ -0,0 +1,7 @@
|
||||
Dockerfile
|
||||
.dockerignore
|
||||
node_modules
|
||||
npm-debug.log
|
||||
README.md
|
||||
.next
|
||||
.git
|
||||
@@ -0,0 +1,19 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@localhost:5432/postgres"
|
||||
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
# NEXTAUTH_SECRET=""
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
NEXTAUTH_SECRET="secret"
|
||||
|
||||
# Langfuse experimental features
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="true"
|
||||
SALT="salt"
|
||||
@@ -1,32 +0,0 @@
|
||||
# Hanzo Cloud Console — environment.
|
||||
# Copy to .env.local and adjust. All values are public (NEXT_PUBLIC_*) since the
|
||||
# console is a browser app that talks to the unified /v1 backend with cookies.
|
||||
|
||||
# The ONE Hanzo API endpoint (the unified /v1 backend). There is no per-service
|
||||
# API host — never llm./kms./platform./cloud./console.hanzo.ai. Leave UNSET in
|
||||
# production: the browser then calls its own origin, so the session cookie stays
|
||||
# first-party and the edge route forwards /v1 through the gateway.
|
||||
# Local backend: http://localhost:14000
|
||||
NEXT_PUBLIC_CLOUD_URL=https://api.hanzo.ai
|
||||
|
||||
# Hanzo PaaS FRONTEND (deep-links only — the Clusters/PaaS *API* is /v1/paas on
|
||||
# NEXT_PUBLIC_CLOUD_URL above, never a second API host).
|
||||
NEXT_PUBLIC_PLATFORM_URL=https://platform.hanzo.ai
|
||||
|
||||
# hanzo.app builder — target of the Templates gallery "Open in builder" deep-link
|
||||
# (fork a starter → customize by prompt in the builder). Default: production.
|
||||
NEXT_PUBLIC_APP_URL=https://hanzo.app
|
||||
|
||||
# Hanzo IAM (OIDC authority). Canonical issuer is https://hanzo.id — tokens are
|
||||
# minted with iss=https://hanzo.id, which the cloud /v1 backend validates against.
|
||||
# iam.hanzo.ai is the legacy zone (iss=https://iam.hanzo.ai) and MUST NOT be used
|
||||
# by the browser, or sign-in drops on iam.hanzo.ai with an issuer mismatch.
|
||||
NEXT_PUBLIC_IAM_URL=https://hanzo.id
|
||||
|
||||
# IAM application console2 authenticates as. console2 is a front-end of the
|
||||
# shared Hanzo Cloud /v1 backend, which exchanges the OIDC code and validates the
|
||||
# token as app `hanzo-cloud` (aud=hanzo-cloud) — so the front-end presents the
|
||||
# same app/client_id. Must match the cloud-api binding, not a console-only app.
|
||||
NEXT_PUBLIC_IAM_APP_NAME=hanzo-cloud
|
||||
NEXT_PUBLIC_IAM_ORG_NAME=hanzo
|
||||
NEXT_PUBLIC_IAM_CLIENT_ID=hanzo-cloud
|
||||
@@ -0,0 +1,16 @@
|
||||
# When adding additional environment variables, the schema in "/src/env.mjs"
|
||||
# should be updated accordingly.
|
||||
# Prisma
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
DATABASE_URL="postgresql://postgres:postgres@db:5432/postgres"
|
||||
# Next Auth
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
NEXTAUTH_SECRET="secret"
|
||||
NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# feature flag to enable experimental features locally
|
||||
LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES="false"
|
||||
SALT="salt"
|
||||
@@ -0,0 +1,46 @@
|
||||
# Assuming deployment on Vercel with Postgres database on Supabase
|
||||
NEXT_PUBLIC_LANGFUSE_CLOUD_REGION="US"
|
||||
NEXTAUTH_COOKIE_DOMAIN=".langfuse.com"
|
||||
|
||||
# Prisma
|
||||
|
||||
# https://www.prisma.io/docs/reference/database-reference/connection-urls#env
|
||||
DIRECT_URL="postgresql://postgres:[pw]@db.[db_id].supabase.co:5432/postgres"
|
||||
DATABASE_URL="postgres://postgres:[pw]@db.[db_id].supabase.co:6543/postgres?pgbouncer=true&connection_limit=1"
|
||||
|
||||
# Next Auth
|
||||
|
||||
# NEXTAUTH_URL does not need to be set when deploying on Vercel
|
||||
# NEXTAUTH_URL="http://localhost:3000"
|
||||
|
||||
# AUTH_REDIRECT_PROXY_URL used to proxy oauth callbacks on e.g. preview deployments. optional.
|
||||
# AUTH_REDIRECT_PROXY_URL="https://example.com/api/auth"
|
||||
|
||||
# You can generate a new secret on the command line with:
|
||||
# openssl rand -base64 32
|
||||
# https://next-auth.js.org/configuration/options#secret
|
||||
NEXTAUTH_SECRET="secret"
|
||||
SALT="salt"
|
||||
# Sentry; set via Vercel integration
|
||||
# NEXT_PUBLIC_SENTRY_DSN=
|
||||
# NEXT_SENTRY_ORG=
|
||||
# NEXT_SENTRY_PROJECT=
|
||||
# SENTRY_AUTH_TOKEN=
|
||||
|
||||
# LANGFUSE_TEAM_SLACK_WEBHOOK=
|
||||
# LANGFUSE_NEW_USER_SIGNUP_WEBHOOK=
|
||||
|
||||
# Posthog (optional for analytics of web ui)
|
||||
# NEXT_PUBLIC_POSTHOG_HOST=
|
||||
# NEXT_PUBLIC_POSTHOG_KEY=
|
||||
|
||||
# Id of demo project to automatically assign new users to
|
||||
# NEXT_PUBLIC_DEMO_PROJECT_ID=
|
||||
|
||||
# Auth, each group is optional
|
||||
AUTH_GOOGLE_CLIENT_ID=
|
||||
AUTH_GOOGLE_CLIENT_SECRET=
|
||||
AUTH_GITHUB_CLIENT_ID=
|
||||
AUTH_GITHUB_CLIENT_SECRET=
|
||||
# AUTH_DOMAINS_WITH_SSO_ENFORCEMENT=domain1.com,domain2.com
|
||||
# AUTH_DISABLE_USERNAME_PASSWORD=true
|
||||
@@ -0,0 +1,35 @@
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require("path");
|
||||
|
||||
/** @type {import("eslint").Linter.Config} */
|
||||
const config = {
|
||||
overrides: [
|
||||
{
|
||||
extends: [
|
||||
"plugin:@typescript-eslint/recommended-requiring-type-checking",
|
||||
],
|
||||
files: ["*.ts", "*.tsx"],
|
||||
parserOptions: {
|
||||
project: path.join(__dirname, "tsconfig.json"),
|
||||
},
|
||||
},
|
||||
],
|
||||
parser: "@typescript-eslint/parser",
|
||||
parserOptions: {
|
||||
project: path.join(__dirname, "tsconfig.json"),
|
||||
},
|
||||
plugins: ["@typescript-eslint"],
|
||||
extends: ["next/core-web-vitals", "plugin:@typescript-eslint/recommended"],
|
||||
rules: {
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"warn",
|
||||
{
|
||||
prefer: "type-imports",
|
||||
fixStyle: "inline-type-imports",
|
||||
},
|
||||
],
|
||||
"@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }],
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = config;
|
||||
@@ -0,0 +1,21 @@
|
||||
name: 🐞 Bug Report
|
||||
description: Create a bug report to help us improve
|
||||
title: "bug: "
|
||||
labels: ["🐞❔ unconfirmed bug"]
|
||||
body:
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the bug
|
||||
description: A clear and concise description of the bug, as well as what you expected to happen when encountering it.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: To reproduce
|
||||
description: Describe how to reproduce your bug. Steps, code snippets, reproduction repos etc.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the bug here, screenshots if applicable.
|
||||
@@ -0,0 +1,36 @@
|
||||
# This template is heavily inspired by the Next.js's template:
|
||||
# See here: https://github.com/vercel/next.js/blob/canary/.github/ISSUE_TEMPLATE/3.feature_request.yml
|
||||
|
||||
name: 🛠 Feature Request
|
||||
description: Create a feature request for the core packages
|
||||
title: "feat: "
|
||||
labels: ["✨ enhancement"]
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Thank you for taking the time to file a feature request. Please fill out this form as completely as possible.
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the feature you'd like to request
|
||||
description: Please describe the feature as clear and concise as possible. Remember to add context as to why you believe this feature is needed.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Describe the solution you'd like to see
|
||||
description: Please describe the solution you would like to see. Adding example usage is a good way to provide context.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Additional information
|
||||
description: Add any other information related to the feature here. If your feature request is related to any issues or discussions, link them here.
|
||||
- type: checkboxes
|
||||
id: contribute
|
||||
attributes:
|
||||
label: Contribute
|
||||
description: Are you willing to contribute to the implementation of this feature?
|
||||
options:
|
||||
- label: Yes, I can implement this and raise a PR
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
## What does this PR do?
|
||||
|
||||
<!-- Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change. -->
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
<!-- Please provide a loom video for visual changes to speed up reviews
|
||||
Loom Video: https://www.loom.com/
|
||||
-->
|
||||
|
||||
## Type of change
|
||||
|
||||
<!-- Please delete bullets that are not relevant. -->
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] Chore (refactoring code, technical debt, workflow improvements)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] Refactor (does not change functionality, e.g. code style improvements, linting)
|
||||
- [ ] This change requires a documentation update
|
||||
|
||||
## Mandatory Tasks
|
||||
|
||||
- [ ] Make sure you have self-reviewed the code. A decent size PR without self-review might be rejected.
|
||||
|
||||
## Checklist
|
||||
|
||||
<!-- Remove bullet points below that don't apply to you -->
|
||||
|
||||
- I haven't read the [contributing guide](https://github.com/calcom/cal.com/blob/main/CONTRIBUTING.md)
|
||||
- My code doesn't follow the style guidelines of this project (`npm run prettier`)
|
||||
- I haven't commented my code, particularly in hard-to-understand areas
|
||||
- I haven't checked if my PR needs changes to the documentation
|
||||
- I haven't checked if my changes generate no new warnings (`npm run lint`)
|
||||
- I haven't added tests that prove my fix is effective or that my feature works
|
||||
- I haven't checked if new and existing unit tests pass locally with my changes
|
||||
@@ -0,0 +1,31 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: npm
|
||||
directory: "/" # Location of package manifests
|
||||
schedule:
|
||||
interval: "daily"
|
||||
rebase-strategy: "disabled" # use dependabot-rebase-stale
|
||||
commit-message:
|
||||
prefix: chore
|
||||
prefix-development: chore
|
||||
include: scope
|
||||
ignore:
|
||||
- dependency-name: "@types/node"
|
||||
- dependency-name: "@trpc/*"
|
||||
groups:
|
||||
prisma:
|
||||
patterns:
|
||||
- "prisma"
|
||||
- "@prisma/*"
|
||||
next:
|
||||
patterns:
|
||||
- "eslint-config-next"
|
||||
- "next"
|
||||
patches:
|
||||
update-types:
|
||||
- "patch"
|
||||
@@ -1,9 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="1280" height="640" viewBox="0 0 1280 640" role="img" aria-label="console2">
|
||||
<rect width="1280" height="640" fill="#0A0A0A"/>
|
||||
<svg x="96" y="215" width="210" height="210" viewBox="0 0 67 67"><path d="M22.21 67V44.6369H0V67H22.21Z" fill="#fff"/><path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z" fill="#fff"/><path d="M22.21 0H0V22.3184H22.21V0Z" fill="#fff"/><path d="M66.7198 0H44.5098V22.3184H66.7198V0Z" fill="#fff"/><path d="M66.7198 67V44.6369H44.5098V67H66.7198Z" fill="#fff"/></svg>
|
||||
<text x="378" y="276" font-family="Inter,system-ui,-apple-system,sans-serif" font-size="78" font-weight="800" letter-spacing="-2" fill="#ffffff">console2</text>
|
||||
|
||||
<rect x="378" y="338" width="806" height="3" rx="1.5" fill="#ffffff" opacity=".9"/>
|
||||
<text x="378" y="390" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">github.com/hanzoai</text>
|
||||
<text x="1184" y="390" text-anchor="end" font-family="Inter,system-ui,sans-serif" font-size="24" font-weight="600" fill="#ffffff" opacity=".5">hanzo.ai</text>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 1.1 KiB |
@@ -0,0 +1,69 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
branches: ["*"]
|
||||
push:
|
||||
branches: ["main", "cloud"]
|
||||
|
||||
# You can leverage Vercel Remote Caching with Turbo to speed up your builds
|
||||
# @link https://turborepo.org/docs/core-concepts/remote-caching#remote-caching-on-vercel-builds
|
||||
env:
|
||||
TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }}
|
||||
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
|
||||
|
||||
jobs:
|
||||
build-lint:
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
SHADOW_DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
DIRECT_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout repo
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Start containers
|
||||
run: docker-compose -f "docker-compose.yml" up -d --build
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v2.2.4
|
||||
|
||||
- name: Setup Node 18
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
|
||||
- name: Get pnpm store directory
|
||||
id: pnpm-cache
|
||||
run: |
|
||||
echo "pnpm_cache_dir=$(pnpm store path)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Setup pnpm cache
|
||||
uses: actions/cache@v3
|
||||
with:
|
||||
path: ${{ steps.pnpm-cache.outputs.pnpm_cache_dir }}
|
||||
key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-pnpm-store-
|
||||
|
||||
- name: Install deps (with cache)
|
||||
run: pnpm install
|
||||
|
||||
# Normally, this would be done as part of the turbo pipeline - however since the Expo app doesn't depend on `@acme/db` it doesn't care.
|
||||
# TODO: Free for all to find a better solution here.
|
||||
- name: Deploy db
|
||||
run: pnpm turbo db:deploy
|
||||
|
||||
- name: Generate Prisma Client
|
||||
run: pnpm turbo db:generate
|
||||
|
||||
- name: Build, lint and type-check
|
||||
run: pnpm turbo build lint type-check
|
||||
env:
|
||||
SKIP_ENV_VALIDATION: true
|
||||
|
||||
# FIXME: Add this back once we have an Expo SDK supporting React 18.2
|
||||
# - name: Check workspaces
|
||||
# run: pnpm manypkg check
|
||||
@@ -0,0 +1,22 @@
|
||||
---
|
||||
name: Codespell
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
codespell:
|
||||
name: Check for spelling errors
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
- name: Codespell
|
||||
uses: codespell-project/actions-codespell@v2
|
||||
@@ -0,0 +1,27 @@
|
||||
name: Dependabot auto-merge
|
||||
on:
|
||||
workflow_dispatch:
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
if: ${{ github.actor == 'dependabot[bot]' }}
|
||||
steps:
|
||||
- name: Dependabot metadata
|
||||
id: metadata
|
||||
uses: dependabot/fetch-metadata@v1
|
||||
with:
|
||||
github-token: "${{ secrets.GITHUB_TOKEN }}"
|
||||
- name: Enable auto-merge for Dependabot PRs
|
||||
if: ${{steps.metadata.outputs.update-type == 'version-update:semver-patch'}}
|
||||
run: gh pr merge --auto --squash "$PR_URL"
|
||||
env:
|
||||
PR_URL: ${{github.event.pull_request.html_url}}
|
||||
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
|
||||
@@ -0,0 +1,18 @@
|
||||
name: Rebase Dependabot stale PRs
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
rebase-dependabot:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- name: "Rebase open Dependabot PR"
|
||||
uses: orange-buffalo/dependabot-auto-rebase@v1
|
||||
with:
|
||||
api-token: ${{ secrets.DEP_REBASE_PAT }}
|
||||
repository: ${{ github.repository }}
|
||||
@@ -0,0 +1,169 @@
|
||||
name: CI/CD
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches:
|
||||
- "main"
|
||||
tags:
|
||||
- "v*"
|
||||
pull_request:
|
||||
branches:
|
||||
- "main"
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
- name: install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
- name: lint
|
||||
run: npm run lint
|
||||
|
||||
tests:
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
matrix:
|
||||
node-version: [18, 20]
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- name: Use Node.js ${{ matrix.node-version }}
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: "npm"
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
|
||||
- name: Run, migrate, seed DB
|
||||
run: |
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
npx --yes prisma migrate reset --force --skip-generate
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Start Langfuse
|
||||
run: (npm start&)
|
||||
|
||||
- name: run tests
|
||||
run: npm run test
|
||||
|
||||
e2e-tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
cache: "npm"
|
||||
|
||||
- name: install dependencies
|
||||
run: |
|
||||
npm ci
|
||||
|
||||
- name: Load default env
|
||||
run: |
|
||||
cp .env.dev.example .env
|
||||
|
||||
- name: Run, migrate, seed DB
|
||||
run: |
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
sleep 5 # Wait for PostgreSQL to accept connections
|
||||
npx --yes prisma migrate reset --force --skip-generate
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Install playwright
|
||||
run: npx playwright install
|
||||
|
||||
- name: Run e2e tests
|
||||
run: npm run test:e2e
|
||||
|
||||
all-ci-passed:
|
||||
# This allows us to have a branch protection rule for tests and deploys with matrix
|
||||
runs-on: ubuntu-latest
|
||||
needs: [lint, tests, e2e-tests]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Successful deploy
|
||||
if: ${{ !(contains(needs.*.result, 'failure')) }}
|
||||
run: exit 0
|
||||
- name: Failing deploy
|
||||
if: ${{ contains(needs.*.result, 'failure') }}
|
||||
run: exit 1
|
||||
|
||||
push-docker-image:
|
||||
needs: all-ci-passed
|
||||
if: github.event_name == 'push' && (github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags/'))
|
||||
environment: "protected branches"
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/postgres
|
||||
NEXTAUTH_SECRET: "secret"
|
||||
SALT: "salt"
|
||||
NEXTAUTH_URL: "http://localhost:3030"
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
permissions:
|
||||
packages: write
|
||||
contents: read
|
||||
|
||||
steps:
|
||||
- name: Setup node
|
||||
uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v3
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v2
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v4
|
||||
with:
|
||||
images: |
|
||||
${{ env.REGISTRY }}/${{ github.repository }}
|
||||
tags: |
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
type=sha
|
||||
type=semver,pattern={{version}}
|
||||
type=semver,pattern={{major}}.{{minor}}
|
||||
|
||||
- name: Build and push Docker image
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
push: true
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
build-args: |
|
||||
DATABASE_URL=${{ env.DATABASE_URL }}
|
||||
NEXTAUTH_SECRET=${{ env.NEXTAUTH_SECRET }}
|
||||
NEXTAUTH_URL=${{ env.NEXTAUTH_URL }}
|
||||
SALT=${{ env.SALT }}
|
||||
@@ -0,0 +1,20 @@
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
# Pattern matched against refs/tags
|
||||
tags:
|
||||
- "v*" # Push events to every tag not containing /
|
||||
|
||||
jobs:
|
||||
release:
|
||||
runs-on: ubuntu-latest
|
||||
environment: "protected branches"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
ref: main # Always checkout main even for tagged releases
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GH_ACCESS_TOKEN }}
|
||||
- name: Push to production
|
||||
run: git push origin +main:production
|
||||
if: github.ref == 'refs/heads/main'
|
||||
+41
-25
@@ -1,32 +1,48 @@
|
||||
node_modules
|
||||
.next/
|
||||
out/
|
||||
dist/
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# pnpm is the one package manager here — pnpm-lock.yaml is the tracked lockfile and
|
||||
# `packageManager` in package.json pins the version corepack installs. A lockfile
|
||||
# from any other manager is a second source of truth that silently drifts.
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
bun.lockb
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.js
|
||||
|
||||
# build artifacts
|
||||
*.tsbuildinfo
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# database
|
||||
/prisma/db.sqlite
|
||||
/prisma/db.sqlite-journal
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
next-env.d.ts
|
||||
|
||||
# env
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# local env files
|
||||
# do not commit any .env files to git, except for the .env.example file. https://create.t3.gg/en/usage/env-variables#using-environment-variables
|
||||
.env
|
||||
.env.local
|
||||
.env*.local
|
||||
|
||||
# editor / os
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
e2e/screenshots/
|
||||
e2e-shots/
|
||||
test-results/
|
||||
playwright-report/
|
||||
.claude/
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# blank-audit generated report
|
||||
e2e/blank-report.json
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
|
||||
/generated/typescript-server
|
||||
|
||||
# openapi spec that is copied during build
|
||||
/public/openapi*.yml
|
||||
@@ -1,25 +0,0 @@
|
||||
# ~7-line canonical caller — all real config lives in /hanzo.yml.
|
||||
# Builds + pushes BOTH console images (the embed artifact cloud go:embeds, and
|
||||
# the Next.js server image admin.hanzo.ai runs); auto-mirrors to registry.hanzo.ai.
|
||||
#
|
||||
# It replaces `.hanzo/workflows/deploy.yml`, which built the server image a
|
||||
# SECOND time by hand and could not: `buildctl-daemonless.sh` is not in the image
|
||||
# this fleet serves for `hanzo-build-linux-amd64` (every label in that pool maps
|
||||
# to catthehacker/ubuntu:act-24.04 — universe:infra/k8s/git-runner/statefulset.yaml),
|
||||
# and its `secrets.GIT_CLONE_TOKEN` exists on neither the repo nor the org. Its
|
||||
# `kubectl patch app` was futile too: cd.hanzo.ai's selfHeal restores the CR from
|
||||
# the universe pin on the next poll. Rollout is a reviewed tag pin in
|
||||
# hanzoai/universe, never a CI side effect.
|
||||
name: CI/CD
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
branches: [main]
|
||||
# A hand-cut v* tag must produce its image, or the tag is a receipt for
|
||||
# nothing — the exact drift the retired build-image.yml existed to prevent.
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
jobs:
|
||||
cicd:
|
||||
uses: hanzoai/ci/.hanzo/workflows/build.yml@v1
|
||||
secrets: inherit
|
||||
Vendored
+10
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"recommendations": [
|
||||
"esbenp.prettier-vscode",
|
||||
"dbaeumer.vscode-eslint",
|
||||
"bradlc.vscode-tailwindcss",
|
||||
"unifiedjs.vscode-mdx",
|
||||
"yoavbls.pretty-ts-errors",
|
||||
"Prisma.prisma"
|
||||
]
|
||||
}
|
||||
Vendored
+28
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Next.js: debug server-side",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug client-side",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000"
|
||||
},
|
||||
{
|
||||
"name": "Next.js: debug full stack",
|
||||
"type": "node-terminal",
|
||||
"request": "launch",
|
||||
"command": "npm run dev",
|
||||
"serverReadyAction": {
|
||||
"pattern": "- Local:.+(https?://.+)",
|
||||
"uriFormat": "%s",
|
||||
"action": "debugWithChrome"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
Vendored
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"editor.codeActionsOnSave": {
|
||||
"source.fixAll.eslint": true
|
||||
},
|
||||
"editor.defaultFormatter": "esbenp.prettier-vscode",
|
||||
"editor.formatOnSave": true,
|
||||
"editor.rulers": [100],
|
||||
"editor.tabSize": 2,
|
||||
|
||||
"eslint.validate": [
|
||||
"javascript",
|
||||
"javascriptreact",
|
||||
"astro",
|
||||
"typescript",
|
||||
"typescriptreact"
|
||||
],
|
||||
|
||||
"eslint.rules.customizations": [{ "rule": "*", "severity": "warn" }],
|
||||
"typescript.tsdk": "node_modules/typescript/lib",
|
||||
"prettier.documentSelectors": [
|
||||
"**/*.{cjs,mjs,ts,tsx,astro,md,mdx,json,yaml,yml}"
|
||||
],
|
||||
"mdx.experimentalLanguageServer": true,
|
||||
"[astro]": {
|
||||
"editor.defaultFormatter": "astro-build.astro-vscode"
|
||||
},
|
||||
"typescript.preferences.importModuleSpecifier": "non-relative",
|
||||
"docwriter.style": "JSDoc",
|
||||
"[prisma]": {
|
||||
"editor.defaultFormatter": "Prisma.prisma"
|
||||
}
|
||||
}
|
||||
+103
@@ -0,0 +1,103 @@
|
||||
# Contributing to Langfuse
|
||||
|
||||
First off, thanks for taking the time to contribute! ❤️
|
||||
|
||||
Langfuse is an open-source observability and analytics solution for LLM-based applications. We welcome contributions through GitHub pull requests. This document outlines our conventions regarding development workflow, commit message formatting, contact points, and other resources. Our goal is to simplify the process and ensure that your contributions are easily accepted.
|
||||
|
||||
We gratefully welcome improvements to documentation as well as to code.
|
||||
|
||||
The maintainers are available on [Discord](https://langfuse.com/discord) in case you have any questions.
|
||||
|
||||
> And if you like the project, but just don't have time to contribute, that's fine. There are other easy ways to support the project and show your appreciation, which we would also be very happy about:
|
||||
>
|
||||
> - Star the project;
|
||||
> - Tweet about it;
|
||||
> - Refer to this project in your project's readme;
|
||||
> - Mention the project at local meetups and tell your friends/colleagues.
|
||||
|
||||
# How to contribute to Langfuse
|
||||
|
||||
## Making a change
|
||||
|
||||
_Before making any significant changes, please [open an issue](https://github.com/langfuse/langfuse/issues)._ Discussing your proposed changes ahead of time will make the contribution process smooth for everyone.
|
||||
|
||||
Once we've discussed your changes and you've got your code ready, make sure that tests are passing and open your pull request.
|
||||
|
||||
## Getting started
|
||||
|
||||
A good first step is to search for open [issues](https://github.com/langfuse/langfuse/issues). Issues are labeled, and some good issues to start with are labeled: [good first issue](https://github.com/langfuse/langfuse/issues?q=is%3Aissue+is%3Aopen+label%3A%22good+first+issue%22).
|
||||
|
||||
## Development Setup
|
||||
|
||||
Requirements
|
||||
|
||||
- Node.js 20 as specified in the [.nvmrc](.nvmrc)
|
||||
- Docker to run the database locally
|
||||
|
||||
**Steps**
|
||||
|
||||
1. Fork the the repository and clone it locally
|
||||
2. Install dependencies
|
||||
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Run the development database
|
||||
|
||||
```bash
|
||||
docker-compose -f docker-compose.dev.yml up -d
|
||||
```
|
||||
|
||||
4. Create an env file
|
||||
|
||||
```bash
|
||||
cp .env.dev.example .env
|
||||
```
|
||||
|
||||
5. Run the migrations
|
||||
|
||||
```bash
|
||||
npm run db:migrate
|
||||
|
||||
# Optional: seed the database
|
||||
# npm run db:seed
|
||||
# npm run db:seed:examples
|
||||
```
|
||||
|
||||
6. Start the development server
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Commit messages
|
||||
|
||||
On the main branch, we adhere to the best practices of [conventional commits](https://www.conventionalcommits.org/en/v1.0.0/). All pull requests and branches are squash-merged to maintain a clean and readable history. This approach ensures the addition of a conventional commit message when merging contributions.
|
||||
|
||||
## CI/CD
|
||||
|
||||
We use GitHub Actions for CI/CD, the configuration is in [`.github/workflows/pipeline.yml`](.github/workflows/pipeline.yml)
|
||||
|
||||
CI on `main` and `pull_request`
|
||||
|
||||
- Check Linting
|
||||
- E2E test of API using Jest
|
||||
- E2E tests of UI using Playwright
|
||||
|
||||
CD on `main`
|
||||
|
||||
- Publish Docker image to GitHub Packages if CI passes
|
||||
|
||||
## Staging environment
|
||||
|
||||
We run a staging environment at [https://staging.langfuse.com](https://staging.langfuse.com) that is automatically deployed on every push to `main` branch.
|
||||
|
||||
The same environment is also used for preview deployments of pull requests. Limitations:
|
||||
|
||||
- SSO is not available as dynamic domains are not supported by most SSO providers
|
||||
- When making changes to the database, migrations to the staging database need to be applied manually by a maintainer. If you want to interactively test database changes in the staging environment, please reach out.
|
||||
|
||||
## License
|
||||
|
||||
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
+90
-46
@@ -1,48 +1,92 @@
|
||||
# console2 — Hanzo Cloud Console (Next.js 15 + @hanzo/gui). MIT OR Apache-2.0.
|
||||
# NEXT_PUBLIC_* are inlined at build time (browser config), so they are build args.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
WORKDIR /app
|
||||
# Exact commit for a deterministic Next build id (next.config.mjs generateBuildId).
|
||||
# The alpine image has no git binary, so CI passes the SHA as a build arg -> ENV,
|
||||
# baked into .next/BUILD_ID so every replica of this image shares ONE build id.
|
||||
ARG SOURCE_COMMIT=""
|
||||
ENV SOURCE_COMMIT=$SOURCE_COMMIT
|
||||
# Copy ALL source FIRST, then install — order matters under Kaniko --single-snapshot:
|
||||
# a `COPY` that FOLLOWS the install in the same stage drops that RUN's freshly
|
||||
# created node_modules (the 'next not found' cause — the install's own `test -f next`
|
||||
# passed, then `COPY . .` wiped node_modules before the build RUN). Putting COPY
|
||||
# before install means node_modules is created by the LAST RUNs and nothing clobbers
|
||||
# it. (Layer-cache for deps is moot here — the on-cluster build runs --cache=false.)
|
||||
COPY . .
|
||||
# public/ may be empty (git doesn't track empty dirs) — ensure it exists for the runner COPY.
|
||||
RUN mkdir -p public
|
||||
# corepack installs the exact pnpm from package.json's `packageManager`, so the
|
||||
# builder and a laptop resolve identically. --frozen-lockfile is the whole reason
|
||||
# this repo is on pnpm: the old `npm install` here could not be `npm ci`, because
|
||||
# @hanzo/gui's react-native tree resolves its platform/optional packages differently
|
||||
# across npm versions and a lockfile written by one npm failed under another. pnpm
|
||||
# records every platform in the lockfile, so the build installs exactly what is
|
||||
# committed and fails loudly instead of quietly resolving something else.
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# ONE brand-agnostic image: brand (IAM org/issuer/app + wordmark) is resolved at
|
||||
# RUNTIME from the request hostname (src/config/index.ts), and /v1 is same-origin
|
||||
# per host. Baking NEXT_PUBLIC_* here would inline a single brand and break that.
|
||||
# Next 15 + @hanzo/gui (large RN dep tree) overflows Node's default heap → OOMKill
|
||||
# (exit 137); cap the heap generously (chat uses 4096).
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=6144
|
||||
RUN pnpm build
|
||||
# Base image
|
||||
FROM node:20-alpine AS base
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
# It's important to update the index before installing packages to ensure you're getting the latest versions.
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk update && apk upgrade --no-cache libcrypto3 libssl3 libc6-compat
|
||||
|
||||
FROM base AS deps
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=4000
|
||||
RUN addgroup -S app && adduser -S app -G app
|
||||
COPY --from=build /app/.next ./.next
|
||||
COPY --from=build /app/public ./public
|
||||
COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./package.json
|
||||
COPY --from=build /app/next.config.mjs ./next.config.mjs
|
||||
# next.config.mjs imports this at load time (build AND standalone runtime); copy it or the server ERR_MODULE_NOT_FOUND-crashes on boot.
|
||||
COPY --from=build /app/src/config/build-id.mjs ./src/config/build-id.mjs
|
||||
USER app
|
||||
EXPOSE 4000
|
||||
CMD ["node", "node_modules/next/dist/bin/next", "start", "-p", "4000"]
|
||||
|
||||
# Install dependencies based on the preferred package manager
|
||||
COPY package.json yarn.lock* package-lock.json* pnpm-lock.yaml* ./
|
||||
RUN \
|
||||
if [ -f yarn.lock ]; then yarn --frozen-lockfile; \
|
||||
elif [ -f package-lock.json ]; then npm ci; \
|
||||
elif [ -f pnpm-lock.yaml ]; then yarn global add pnpm && pnpm i --frozen-lockfile; \
|
||||
else echo "Lockfile not found." && exit 1; \
|
||||
fi
|
||||
|
||||
|
||||
# Rebuild the source code only when needed
|
||||
FROM base AS builder
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
WORKDIR /app
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
# remove middleware.ts if it exists - not needed in self-hosted environments
|
||||
RUN rm -f ./src/middleware.ts
|
||||
|
||||
# Next.js collects completely anonymous telemetry data about general usage.
|
||||
# Learn more here: https://nextjs.org/telemetry
|
||||
# Uncomment the following line in case you want to disable telemetry during the build.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
# Generate prisma client
|
||||
RUN npx prisma generate
|
||||
|
||||
# Build the application
|
||||
RUN npm run build
|
||||
|
||||
# Production image, copy all the files and run next
|
||||
FROM base AS runner
|
||||
ARG DATABASE_URL
|
||||
ARG NEXTAUTH_SECRET
|
||||
ARG NEXTAUTH_URL
|
||||
ARG SALT
|
||||
|
||||
RUN apk add --no-cache dumb-init
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV production
|
||||
# Uncomment the following line in case you want to disable telemetry during runtime.
|
||||
ENV NEXT_TELEMETRY_DISABLED 1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
RUN npm install -g --no-package-lock --no-save prisma
|
||||
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
||||
|
||||
COPY --chown=nextjs:nodejs entrypoint.sh ./entrypoint.sh
|
||||
RUN chmod +x ./entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
|
||||
# Default port to 3000
|
||||
ENV PORT 3000
|
||||
|
||||
# CMD ["node", "server.js"]
|
||||
CMD ["dumb-init", "--", "./entrypoint.sh"]
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
# hanzoai/console — the EMBED artifact.
|
||||
#
|
||||
# Builds the console SPA static export (`pnpm build:embed` → out/) ONCE, as a
|
||||
# versioned immutable image whose rootfs is just the bundle at /dist. hanzoai/cloud
|
||||
# then does `FROM registry.hanzo.ai/hanzoai/console-embed:<ver> AS console` +
|
||||
# `COPY --from=console /dist/ webui/dist/` instead of re-running the install+Next export on
|
||||
# EVERY cloud release (the ~15-min cache-busted long pole). Console changes far less
|
||||
# often than cloud ships, so this moves the build to console's own cadence and turns
|
||||
# a cloud rebuild into a registry pull.
|
||||
#
|
||||
# The Next.js SERVER image (standalone/admin hosts) stays in build-image.yml — this
|
||||
# is a separate, additional artifact, not a replacement.
|
||||
FROM public.ecr.aws/docker/library/node:24-alpine AS build
|
||||
RUN apk add --no-cache git
|
||||
WORKDIR /console
|
||||
# Heap headroom so the full @hanzo/gui static export never OOMs into a stub; telemetry off.
|
||||
ENV NEXT_TELEMETRY_DISABLED=1 NODE_OPTIONS=--max-old-space-size=8192
|
||||
# Bake the console.hanzo.ai analytics property (public per-site id) — the SAME default
|
||||
# cloud baked at build:embed time, so the embedded console keeps tracking identically.
|
||||
# GA4/Pixel stay unset. Public id, not a KMS secret.
|
||||
ARG NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=7dce54ee-41f6-4751-96bf-fe005067c7c7
|
||||
ENV NEXT_PUBLIC_ANALYTICS_WEBSITE_ID=$NEXT_PUBLIC_ANALYTICS_WEBSITE_ID
|
||||
COPY . .
|
||||
RUN corepack enable && pnpm install --frozen-lockfile
|
||||
# FAIL-HARD: the export MUST emit a real bundle (non-empty out/index.html + out/_next/),
|
||||
# never a placeholder shell — same invariant cloud's console stage enforced.
|
||||
RUN pnpm build:embed && [ -s out/index.html ] && [ -d out/_next ] \
|
||||
&& echo ">> embedded REAL console bundle: $(wc -c < out/index.html)-byte index.html, $(du -sh out/_next | cut -f1) _next/"
|
||||
FROM scratch
|
||||
COPY --from=build /console/out/ /dist/
|
||||
@@ -1,14 +1,25 @@
|
||||
Licensed under either of
|
||||
Copyright (c) 2023 Finto Technologies GmbH
|
||||
|
||||
* Apache License, Version 2.0 (LICENSE-APACHE or
|
||||
https://www.apache.org/licenses/LICENSE-2.0)
|
||||
* MIT license (LICENSE-MIT or https://opensource.org/licenses/MIT)
|
||||
Portions of this software are licensed as follows:
|
||||
|
||||
at your option.
|
||||
* All content that resides under the "ee/" directory of this repository, if that directory exists, is licensed under the license defined in "ee/LICENSE".
|
||||
* All third party components incorporated into the Finto Technologies Software are licensed under the original license provided by the owner of the applicable component.
|
||||
* Content outside of the above mentioned directories or restrictions above is available under the "MIT Expat" license as defined below.
|
||||
|
||||
Unless you explicitly state otherwise, any contribution intentionally
|
||||
submitted for inclusion in the work by you, as defined in the Apache-2.0
|
||||
license, shall be dual licensed as above, without any additional terms or
|
||||
conditions.
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
See HIP-0137 (hanzoai/hips) for the standard this follows.
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
-202
@@ -1,202 +0,0 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-33
@@ -1,33 +0,0 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc.
|
||||
|
||||
Portions of this software are derived from upstream code originally licensed
|
||||
under the MIT License, with the following copyright notices retained per its
|
||||
terms:
|
||||
|
||||
Copyright (c) 2020 Nate Wienert
|
||||
Copyright (c) 2015-present, Nicolas Gallagher.
|
||||
Copyright (c) 2015-present, Facebook, Inc.
|
||||
Copyright (c) 2021 Radix
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V.
|
||||
Copyright (c) 2022 WorkOS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,76 +0,0 @@
|
||||
Hanzo Cloud Console (console2)
|
||||
Copyright (c) Hanzo AI, Inc. Licensed MIT OR Apache-2.0 (see LICENSE) per HIP-0137.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Third-party attribution
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Observe surface — Langfuse (MIT License)
|
||||
|
||||
The console's Observe screens (Traces, Trace detail with the span-tree /
|
||||
latency-waterfall, Observations, Sessions, Scores, Score Configs, Datasets,
|
||||
Dataset Items, Dataset Runs / Experiments, and the observability Dashboards /
|
||||
Metrics) reproduce the SCREEN LAYOUT AND USER FLOWS of Langfuse's observability
|
||||
product.
|
||||
|
||||
This is a clean-room reimplementation in our own code (React + @hanzo/gui),
|
||||
wired to the native Hanzo Cloud /v1/evals contract. No Langfuse source code is
|
||||
copied. Only the MIT-licensed layout/flow concepts inform the design; the
|
||||
Langfuse EE / commercial ("ee") code is neither used nor referenced.
|
||||
|
||||
Langfuse — https://github.com/langfuse/langfuse
|
||||
Copyright (c) Langfuse GmbH
|
||||
Licensed under the MIT License.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
------------------------------------------------------------------------
|
||||
Vendored MIT-licensed code
|
||||
------------------------------------------------------------------------
|
||||
|
||||
Portions of this software are derived from upstream MIT-licensed code. Those
|
||||
copyright notices are retained here per the MIT License's terms; they were
|
||||
previously carried in LICENSE, which is reserved for this project's own
|
||||
BSD-3-Clause grant.
|
||||
|
||||
Copyright (c) 2020 Nate Wienert (Tamagui)
|
||||
Copyright (c) 2015-present, Nicolas Gallagher. (react-native-web)
|
||||
Copyright (c) 2015-present, Facebook, Inc. (react-native-web)
|
||||
Copyright (c) 2021 Radix (Radix UI)
|
||||
Copyright (c) 2017 Carmelo Pullara
|
||||
Copyright (c) 2018 Framer B.V. (Framer Motion)
|
||||
Copyright (c) 2022 WorkOS
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -1,53 +1,258 @@
|
||||
<p align="center"><img src=".github/hero.svg" alt="console2" width="880"></p>
|
||||
<div align="center">
|
||||
<a href="https://langfuse.com">
|
||||
<h1>🪢 Langfuse</h1>
|
||||
</a>
|
||||
<h3>
|
||||
Open source observability & analytics for LLM-based applications
|
||||
</h3>
|
||||
<div>
|
||||
<strong>Observability:</strong> Explore and debug complex logs & traces in a visual UI
|
||||
</div>
|
||||
<div>
|
||||
<strong>Analytics:</strong> Measure & improve costs, latency and response quality
|
||||
</div>
|
||||
</br>
|
||||
<div>
|
||||
<a href="https://discord.gg/7NXusRtqYU">
|
||||
<strong>Join the Langfuse Discord »</strong>
|
||||
</a>
|
||||
</br>
|
||||
<a href="https://langfuse.com">
|
||||
<strong>langfuse.com</strong>
|
||||
</a> ·
|
||||
<a href="https://langfuse.com/docs">
|
||||
<strong>Docs</strong>
|
||||
</a> ·
|
||||
<a href="https://github.com/langfuse/langfuse/issues/new?labels=%F0%9F%90%9E%E2%9D%94+unconfirmed+bug&projects=&template=bug_report.yml&title=bug%3A+">
|
||||
<strong>Report Bug</strong>
|
||||
</a> ·
|
||||
<a href="https://github.com/langfuse/langfuse/issues/new?assignees=&labels=%E2%9C%A8+enhancement&projects=&template=feature_request.yml&title=feat%3A+">
|
||||
<strong>Feature Request</strong>
|
||||
</a>
|
||||
</div>
|
||||
</br>
|
||||
<div>
|
||||
<img src="https://img.shields.io/badge/License-MIT-red.svg?style=flat-square" alt="MIT License">
|
||||
<a href="https://discord.gg/7NXusRtqYU"><img src="https://img.shields.io/discord/1111061815649124414?style=flat-square&logo=Discord&logoColor=white&label=Discord&color=%23434EE4" alt="Discord"></a>
|
||||
<a href="https://github.com/langfuse/langfuse"><img src="https://img.shields.io/github/stars/langfuse/langfuse?style=flat-square&logo=GitHub&label=langfuse%2Flangfuse" alt="Github Repo Stars"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/releases"><img src="https://img.shields.io/github/v/release/langfuse/langfuse?include_prereleases&style=flat-square" alt="langfuse releases"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/actions/workflows/pipeline.yml?query=branch:main"><img src="https://img.shields.io/github/actions/workflow/status/langfuse/langfuse/pipeline.yml?style=flat-square&label=All%20tests" alt="CI test status"></a>
|
||||
<a href="https://status.langfuse.com"><img src="https://uptime.betterstack.com/status-badges/v1/monitor/udlc.svg" alt="Uptime Status"/></a>
|
||||
<a href="https://www.ycombinator.com/companies/langfuse"><img src="https://img.shields.io/badge/Y%20Combinator-W23-orange?style=flat-square" alt="Y Combinator W23"></a>
|
||||
<a href="https://github.com/langfuse/langfuse/pkgs/container/langfuse"><img alt="Docker Image" src="https://img.shields.io/badge/docker-langfuse-blue?logo=Docker&logoColor=white&style=flat-square"></a>
|
||||
<a href="https://www.npmjs.com/package/langfuse"><img src="https://img.shields.io/npm/v/langfuse?style=flat-square&label=npm+langfuse" alt="langfuse npm package"></a>
|
||||
<a href="https://pypi.python.org/pypi/langfuse"><img src="https://img.shields.io/pypi/v/langfuse.svg?style=flat-square&label=pypi+langfuse" alt="langfuse Python package on PyPi"></a>
|
||||
</div>
|
||||
</div>
|
||||
</br>
|
||||
</div>
|
||||
</br>
|
||||
|
||||
# Hanzo Cloud Console
|
||||
## What is Langfuse?
|
||||
|
||||
Unified admin console for **Hanzo Cloud** and all Hanzo cloud products. Built on
|
||||
[@hanzo/gui](https://gui.hanzo.ai) (cross-platform UI) over the unified `/v1`
|
||||
backend (`hanzoai/cloud`). Dark theme, OIDC sign-in via Hanzo IAM.
|
||||
Langfuse is an open source observability & analytics solution for LLM-based applications. It is mostly geared towards production usage but some users also use it for local development of their LLM applications.
|
||||
|
||||
Manages: **Providers · Models · Applications · Stores · Chat** — with an
|
||||
extensible product-module registry so every cloud product can be added as a
|
||||
module.
|
||||
Langfuse is focused on applications built on top of LLMs. Many new abstractions and common best practices evolved recently, e.g. agents, chained prompts, embedding-based retrieval, LLM access to REPLs & APIs. These make applications more powerful but also unpredictable for developers as they cannot fully anticipate how changes impact the quality, cost and overall latency of their application. Thus Langfuse helps to monitor and debug these applications.
|
||||
|
||||
## Quick start
|
||||
**Demo (2 min)**
|
||||
|
||||
https://github.com/langfuse/langfuse/assets/2834609/6041347a-b517-4a11-8737-93ef8f8af49f
|
||||
|
||||
_Muted by default, enable sound for voice-over_
|
||||
|
||||
Explore demo project in Langfuse here (free account required): https://langfuse.com/demo
|
||||
|
||||
### Observability
|
||||
|
||||
Langfuse offers an admin UI to explore the ingested data.
|
||||
|
||||
- Nested view of LLM app executions; detailed information along the traces on: latency, cost, scores
|
||||
- Segment execution traces by user feedback, to e.g. identify production issues
|
||||
|
||||
### Analytics
|
||||
|
||||
Reporting on
|
||||
|
||||
- Token usage by model
|
||||
- Volume of traces
|
||||
- Scores/evals
|
||||
|
||||
Broken down by
|
||||
|
||||
- Users
|
||||
- Releases
|
||||
- Prompt/chain versions
|
||||
- Prompt/chain types
|
||||
- Time
|
||||
|
||||
→ Expect releases with more ways to analyze the data over the next weeks.
|
||||
|
||||
## Get started
|
||||
|
||||
### Step 1: Run Server
|
||||
|
||||
#### Langfuse Cloud
|
||||
|
||||
Managed deployment by the Langfuse team, generous free-tier (hobby plan) available, no credit card required.
|
||||
|
||||
Links: [Create account](https://cloud.langfuse.com), [learn more](https://cloud.langfuse.com)
|
||||
|
||||
#### Localhost
|
||||
|
||||
Requirements: docker, docker compose (e.g. using Docker Desktop)
|
||||
|
||||
```bash
|
||||
npm install
|
||||
cp .env.example .env.local
|
||||
# set NEXT_PUBLIC_IAM_CLIENT_ID for live sign-in; defaults point at production.
|
||||
npm run dev # http://localhost:4000
|
||||
# Clone repository
|
||||
git clone https://github.com/langfuse/langfuse.git
|
||||
cd langfuse
|
||||
|
||||
# Run server and database
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
## Scripts
|
||||
#### Self-host (Docker)
|
||||
|
||||
| Script | What |
|
||||
| --- | --- |
|
||||
| `npm run dev` | Dev server on :4000 |
|
||||
| `npm run build` | Production build (type-checks; Gui CSS injected at runtime) |
|
||||
| `npm run start` | Serve the production build |
|
||||
| `npm run typecheck` | `tsc --noEmit` (strict) |
|
||||
[→ Instructions](https://langfuse.com/docs/deployment/self-host)
|
||||
|
||||
## Configuration
|
||||
[](https://railway.app/template/gmbqa_)
|
||||
|
||||
All config is `NEXT_PUBLIC_*` (browser app, cookie auth). See `.env.example`.
|
||||
### Step 2: Data ingestion
|
||||
|
||||
| Var | Default | Meaning |
|
||||
| --- | --- | --- |
|
||||
| `NEXT_PUBLIC_CLOUD_URL` | same origin, else `https://api.hanzo.ai` | The ONE Hanzo API endpoint (unified `/v1` backend). Never a per-service API host. |
|
||||
| `NEXT_PUBLIC_IAM_URL` | `https://iam.hanzo.ai` | Hanzo IAM OIDC authority |
|
||||
| `NEXT_PUBLIC_IAM_APP_NAME` | `hanzo-console` | IAM application (`<org>-<app>`) |
|
||||
| `NEXT_PUBLIC_IAM_ORG_NAME` | `hanzo` | IAM organization |
|
||||
| `NEXT_PUBLIC_IAM_CLIENT_ID` | — | OAuth client id |
|
||||
#### SDKs to instrument application
|
||||
|
||||
## Architecture
|
||||
Fully async, typed SDKs to instrument any LLM application. Currently available for Python & JS/TS.
|
||||
|
||||
See [LLM.md](./LLM.md) for the full design (base choice, /v1 client, auth flow,
|
||||
the product-module registry, and the Providers surface). Endpoint reference in
|
||||
[docs/endpoints.md](./docs/endpoints.md).
|
||||
→ [Guide](https://langfuse.com/docs/guides/sdk-integration) with an example of how the SDK can be used
|
||||
|
||||
| Package | Description | Links |
|
||||
| --------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------- | -------------------------------------------------------------------------------------------------------------- |
|
||||
| [](https://pypi.python.org/pypi/langfuse) | Python | [docs](https://langfuse.com/docs/integrations/sdk/python), [repo](https://github.com/langfuse/langfuse-python) |
|
||||
| [](https://www.npmjs.com/package/langfuse) | JS/TS: Node >= 18, Edge runtimes | [docs](https://langfuse.com/docs/integrations/sdk/typescript), [repo](https://github.com/langfuse/langfuse-js) |
|
||||
| [](https://www.npmjs.com/package/langfuse-node) | JS/TS: Node <18 | [docs](https://langfuse.com/docs/integrations/sdk/typescript), [repo](https://github.com/langfuse/langfuse-js) |
|
||||
|
||||
#### Langchain applications
|
||||
|
||||
The Langfuse callback handler automatically instruments Langchain applications. Currently available for Python and JS/TS.
|
||||
|
||||
**Python**
|
||||
|
||||
```shell
|
||||
pip install langfuse
|
||||
```
|
||||
|
||||
```python
|
||||
# Initialize Langfuse handler
|
||||
from langfuse.callback import CallbackHandler
|
||||
handler = CallbackHandler(PUBLIC_KEY, SECRET_KEY)
|
||||
|
||||
# Setup Langchain
|
||||
from langchain.chains import LLMChain
|
||||
...
|
||||
chain = LLMChain(llm=llm, prompt=prompt)
|
||||
|
||||
# Add Langfuse handler as callback
|
||||
chain.run(input="<user_input", callbacks=[handler])
|
||||
```
|
||||
|
||||
→ [Langchain integration docs for Python](https://langfuse.com/docs/integrations/langchain/python)
|
||||
|
||||
**JS/TS**
|
||||
|
||||
→ [Langchain integration docs for JS/TS](https://langfuse.com/docs/integrations/langchain/typescript)
|
||||
|
||||
#### Add scores/evaluations to traces (optional)
|
||||
|
||||
Quality/evaluation of traces is tracked via scores ([docs](https://langfuse.com/docs/scores)). Scores are related to traces and optionally to observations. Scores can be added via:
|
||||
|
||||
- **Backend SDKs** (see docs above): `{trace, event, span, generation}.score()`
|
||||
- **API** (see docs below): `POST /api/public/scores`
|
||||
- **Client-side using Web SDK**, e.g. to capture user feedback or other user-based quality metrics:
|
||||
|
||||
```sh
|
||||
npm install langfuse
|
||||
```
|
||||
|
||||
```ts
|
||||
// Client-side (browser)
|
||||
|
||||
import { LangfuseWeb } from "langfuse";
|
||||
|
||||
const langfuseWeb = new LangfuseWeb({
|
||||
publicKey: process.env.LANGFUSE_PUBLIC_KEY,
|
||||
});
|
||||
|
||||
// frontend handler (example: React)
|
||||
export function UserFeedbackComponent(props: { traceId: string }) {
|
||||
const handleUserFeedback = async (value: number) => {
|
||||
await langfuseWeb.score({
|
||||
traceId: props.traceId,
|
||||
name: "user_feedback",
|
||||
value,
|
||||
});
|
||||
};
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => handleUserFeedback(1)}>👍</button>
|
||||
<button onClick={() => handleUserFeedback(-1)}>👎</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### API
|
||||
|
||||
[**Api reference**](https://langfuse.com/docs/integrations/api)
|
||||
|
||||
- POST/PATCH routes to ingest data
|
||||
- GET routes to use data in downstream applications (e.g. embedded analytics)
|
||||
|
||||
## Questions / Feedback
|
||||
|
||||
The maintainers are very active in the Langfuse [Discord](https://langfuse.com/discord) and are happy to answer questions or discuss feedback/ideas regarding the future of the project.
|
||||
|
||||
## Contributing to Langfuse
|
||||
|
||||
Join the community [on Discord](https://discord.gg/7NXusRtqYU).
|
||||
|
||||
To contribute, send us a PR, raise a GitHub issue, or email at contributing@langfuse.com
|
||||
|
||||
### Development setup
|
||||
|
||||
See [CONTRIBUTING.md](CONTRIBUTING.md) for details on how to setup a development environment.
|
||||
|
||||
## License
|
||||
|
||||
`MIT OR Apache-2.0` at your option — see [LICENSE](./LICENSE),
|
||||
[LICENSE-MIT](./LICENSE-MIT), [LICENSE-APACHE](./LICENSE-APACHE).
|
||||
Copyright (c) 2026-present, Hanzo AI, Inc. Estate-wide licensing standard: HIP-0137 (`hanzoai/hips`).
|
||||
Langfuse is MIT licensed, except for `ee/` folder. See [LICENSE](LICENSE) and [docs](https://langfuse.com/docs/open-source) for more details.
|
||||
|
||||
## Misc
|
||||
|
||||
### Upgrade Langfuse (localhost)
|
||||
|
||||
```bash
|
||||
# Stop server and db
|
||||
docker compose down
|
||||
|
||||
# Pull latest changes
|
||||
git pull
|
||||
docker-compose pull
|
||||
|
||||
# Run server and db
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
### Run Langfuse in CI for integration tests
|
||||
|
||||
Checkout GitHub Actions workflows of [Python SDK](https://github.com/langfuse/langfuse-python/blob/main/.github/workflows/ci.yml) and [JS/TS SDK](https://github.com/langfuse/langfuse-js/blob/main/.github/workflows/ci.yml).
|
||||
|
||||
### Telemetry
|
||||
|
||||
By default, Langfuse automatically reports basic usage statistics to a centralized server (PostHog).
|
||||
|
||||
This helps us to:
|
||||
|
||||
1. Understand how Langfuse is used and improve the most relevant features.
|
||||
2. Track overall usage for internal and external (e.g. fundraising) reporting.
|
||||
|
||||
None of the data is shared with third parties and does not include any sensitive information. We want to be super transparent about this and you can find the exact data we collect [here](/src/features/telemetry/index.ts).
|
||||
|
||||
You can opt-out by setting `TELEMETRY_ENABLED=false`.
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
|
||||
/**
|
||||
* Catch-all product route. Resolves the module + route from the registry and
|
||||
* renders its component via the shared `ProductRoute` (the ONE renderer, also used
|
||||
* by the dashboard home for the static embed). Adding a product anywhere in the
|
||||
* registry makes its routes live here — no per-product page files.
|
||||
*
|
||||
* `ProductRoute` applies the two honest gates (sub-page stub, admin "managed by
|
||||
* Hanzo" notice), the external-product interstitial, and the per-route error
|
||||
* boundary. See that component.
|
||||
*/
|
||||
export default function ProductPage({ params }: { params: Promise<{ slug: string[] }> }) {
|
||||
const { slug } = use(params)
|
||||
return <ProductRoute slug={slug} />
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { use } from 'react'
|
||||
|
||||
import { ProductInterstitial } from '~/components/products/ProductInterstitial'
|
||||
|
||||
/**
|
||||
* Product discover screen — `/discover/<id>` renders the interstitial for one
|
||||
* catalog entry (docs, OSS source, revenue share, open/get-started). A dedicated
|
||||
* route (more specific than the `[...slug]` product catch-all) so it's shareable.
|
||||
*/
|
||||
export default function DiscoverPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const { id } = use(params)
|
||||
return <ProductInterstitial id={id} />
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Dashboard route error backstop (Next App Router).
|
||||
*
|
||||
* `ProductErrorBoundary` catches throws inside a resolved product module; this
|
||||
* catches anything above it in the dashboard page tree (the resolver itself, a
|
||||
* non-catch-all dashboard page). It renders in the layout's content slot, so the
|
||||
* shell + nav stay mounted — never a white-screened "Application error". Next's
|
||||
* `reset()` re-renders the segment; `notFound()`/`redirect()` are control flow and
|
||||
* do not reach here.
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
import { Button, Card, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { RefreshCw, TriangleAlert } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { reportError } from '~/lib/event'
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function DashboardError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[console] dashboard route error:', error)
|
||||
// A chunk skew self-heals: reload ONCE per window to pull the fresh HTML +
|
||||
// current chunks (same recovery the product boundary does), so a stale-deploy
|
||||
// crash at the segment level auto-recovers instead of stranding a manual card.
|
||||
// A chunk skew is not an app bug, so report only a genuine crash to the ONE stream.
|
||||
if (!chunk) {
|
||||
reportError(error, { digest: error.digest, boundary: 'dashboard' })
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* sessionStorage blocked (private mode) — fall through to the manual card */
|
||||
}
|
||||
}, [error, chunk])
|
||||
|
||||
return (
|
||||
<YStack p="$4">
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" maxWidth={640} bg="$color1">
|
||||
<XStack gap="$2" items="center">
|
||||
<TriangleAlert size={16} />
|
||||
<Text fontSize="$4" fontWeight="700">
|
||||
{chunk ? 'Updating to the latest version' : 'This page hit an unexpected error'}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reload to load the latest.'
|
||||
: 'The rest of the console still works. Try again, or reload the page.'}
|
||||
</Text>
|
||||
<XStack gap="$2">
|
||||
{!chunk ? (
|
||||
<Button size="$2" icon={<RefreshCw size={14} />} onPress={() => reset()}>
|
||||
Try again
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless={!chunk}
|
||||
icon={<RefreshCw size={14} />}
|
||||
onPress={() => { if (typeof window !== 'undefined') window.location.reload() }}
|
||||
>
|
||||
Reload
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
|
||||
import { Preferences } from '~/lib/products/preferences'
|
||||
import { Toast } from '~/components/ui/Toast'
|
||||
import { Entry } from '~/entry/entry'
|
||||
import { Host } from '~/entry/host'
|
||||
|
||||
/**
|
||||
* The console entry, decomplected (see src/entry/). `Preferences` + `Toast` are the
|
||||
* session-tier context: the stage RESOLVER reads the onboarding preference, and the
|
||||
* onboard wizard + every module report through Toast — so they sit above the switch.
|
||||
* `Host` answers the two effects `@hanzo/ui/product`'s state cards ask for (sign in,
|
||||
* add credits), so every card below renders its affordance without being handed one.
|
||||
* `Entry` computes ONE stage value from the session and renders EXACTLY one surface
|
||||
* (sign-in · waitlist · org · onboard · dashboard).
|
||||
*/
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<Preferences>
|
||||
<Toast>
|
||||
<Host>
|
||||
<Entry>{children}</Entry>
|
||||
</Host>
|
||||
</Toast>
|
||||
</Preferences>
|
||||
)
|
||||
}
|
||||
@@ -1,312 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Product catalog — the unified console home. Every Hanzo product, grouped by the
|
||||
* ten canonical categories, with its Google Cloud equivalent. Every product is
|
||||
* open-for-all: each card opens straight into its native in-console surface and
|
||||
* carries a "Learn more" affordance to its docs — there is no enablement gate and
|
||||
* no external bounce. Each card can be pinned to the sidebar (persisted to the
|
||||
* account). Rendered entirely from the catalog registry.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useRouter, usePathname } from 'next/navigation'
|
||||
import { Button, Card, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { Star, Lock, ArrowRight, BookOpen, KeyRound, Boxes, HandCoins, ExternalLink } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { shellFor } from '~/lib/products/shell'
|
||||
import { visibleCatalogByCategory, categorySlug, type CatalogEntry } from '~/lib/products/registry'
|
||||
import { resolveView } from '~/lib/products/match'
|
||||
import { ProductRoute } from '~/components/ProductRoute'
|
||||
import { openProduct } from '~/lib/products/open'
|
||||
import { useFavorites } from '~/lib/products/favorites'
|
||||
import { useIsSuperAdmin } from '~/lib/auth/admin'
|
||||
import { ProductIcon } from '~/components/ui/ProductIcon'
|
||||
import { useProductColors } from '~/lib/products/pins'
|
||||
import { livingOverviewModule } from '~/components/products/overview/living/LivingOverviewModule'
|
||||
import { ResourceOverview } from '~/components/products/overview/ResourceOverview'
|
||||
import { ProductObservability } from '~/components/products/observability/ProductObservability'
|
||||
import { FadeIn, PageHeader, type IconLike } from '@hanzo/ui/product'
|
||||
|
||||
// The home centerpiece is the reusable LivingOverview (count-up KPIs, live
|
||||
// sparklines, streaming activity) — the SAME component every product overview uses.
|
||||
const OverviewDashboard = livingOverviewModule('overview')
|
||||
|
||||
function ProductCard({
|
||||
entry,
|
||||
pinned,
|
||||
onOpen,
|
||||
onToggle,
|
||||
onLearnMore,
|
||||
}: {
|
||||
entry: CatalogEntry
|
||||
pinned: boolean
|
||||
onOpen: () => void
|
||||
onToggle: () => void
|
||||
onLearnMore: () => void
|
||||
}) {
|
||||
const Icon = entry.icon
|
||||
return (
|
||||
<Card borderWidth={1} borderColor="$borderColor" p="$4" gap="$3" width={272}>
|
||||
<XStack justify="space-between" items="flex-start">
|
||||
<XStack gap="$2" items="center" flex={1}>
|
||||
<Icon size={20} />
|
||||
<YStack flex={1}>
|
||||
<Text fontSize="$5" fontWeight="700">
|
||||
{entry.label}
|
||||
</Text>
|
||||
{entry.gcp ? (
|
||||
<Text fontSize="$1" color="$color10">
|
||||
{entry.gcp}
|
||||
</Text>
|
||||
) : null}
|
||||
</YStack>
|
||||
</XStack>
|
||||
<XStack gap="$1" items="center">
|
||||
{entry.admin ? <Lock size={13} opacity={0.45} /> : null}
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
opacity={pinned ? 1 : 0.3}
|
||||
icon={<Star size={15} />}
|
||||
onPress={onToggle}
|
||||
aria-label={pinned ? `Unpin ${entry.label}` : `Pin ${entry.label}`}
|
||||
/>
|
||||
</XStack>
|
||||
</XStack>
|
||||
|
||||
<Text fontSize="$3" color="$color11" minH={40}>
|
||||
{entry.description}
|
||||
</Text>
|
||||
|
||||
<XStack justify="space-between" items="center">
|
||||
<Button
|
||||
size="$2"
|
||||
chromeless
|
||||
icon={<BookOpen size={14} />}
|
||||
onPress={onLearnMore}
|
||||
aria-label={`Learn more about ${entry.label}`}
|
||||
>
|
||||
Learn more
|
||||
</Button>
|
||||
<Button
|
||||
size="$2"
|
||||
bg="$color5"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
onPress={onOpen}
|
||||
iconAfter={<ArrowRight size={14} />}
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Primary action tile — the ONE presentational card for a top-of-home "first action"
|
||||
* (get an API key, deploy an OSS project, earn from your OSS). Prop-driven and pure: a
|
||||
* ProductIcon tile (the shared product-color system; omit `color` for the neutral chip),
|
||||
* a title, a one-line blurb, and a single CTA. Reused for EVERY primary action so the row
|
||||
* stays DRY — add an action by rendering one more tile, never a new card. `external` swaps
|
||||
* the CTA's trailing glyph to the new-tab mark; `dataTour` anchors the first-run tour (the
|
||||
* API-key tile keeps its `api-key` anchor). No data fetch — a tile is cheap on first paint;
|
||||
* anything heavy (e.g. the OSS catalog) lives behind the CTA, loaded only on press.
|
||||
*/
|
||||
function PrimaryActionTile({
|
||||
icon,
|
||||
color,
|
||||
title,
|
||||
description,
|
||||
ctaLabel,
|
||||
external,
|
||||
dataTour,
|
||||
onPress,
|
||||
}: {
|
||||
icon: IconLike
|
||||
color?: string
|
||||
title: string
|
||||
description: string
|
||||
ctaLabel: string
|
||||
external?: boolean
|
||||
dataTour?: string
|
||||
onPress: () => void
|
||||
}) {
|
||||
return (
|
||||
<Card flex={1} minW={280} borderWidth={1} borderColor="$borderColor" bg="$color2" p="$4" gap="$3" data-tour={dataTour}>
|
||||
<XStack items="center" gap="$3">
|
||||
<ProductIcon icon={icon} color={color} size={40} />
|
||||
<Text fontSize="$5" fontWeight="800" flex={1} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11" minH={40}>
|
||||
{description}
|
||||
</Text>
|
||||
<XStack>
|
||||
{/* Neutral, not filled. These three tiles are PEERS — a menu of things you can
|
||||
do, not a call to action — so three white buttons side by side gave the
|
||||
screen three primaries and therefore none. The one filled action on this
|
||||
page is the getting-started card's active step: the thing to do NEXT. */}
|
||||
<Button
|
||||
size="$3"
|
||||
borderWidth={1}
|
||||
borderColor="$borderColor"
|
||||
iconAfter={external ? <ExternalLink size={15} /> : <ArrowRight size={15} />}
|
||||
onPress={onPress}
|
||||
>
|
||||
{ctaLabel}
|
||||
</Button>
|
||||
</XStack>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardHome() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const { toggle, isPinned } = useFavorites()
|
||||
const { colorOf } = useProductColors()
|
||||
const showAdmin = useIsSuperAdmin()
|
||||
const push = (path: string) => router.push(path)
|
||||
const groups = visibleCatalogByCategory(showAdmin)
|
||||
|
||||
useEffect(() => setMounted(true), [])
|
||||
|
||||
// Product-shell face (billing.<brand> / sentry.<brand> / an override): the default
|
||||
// route IS the face's home — redirect the catalog home there so people who only ever
|
||||
// see billing.hanzo.ai land on billing, and sentry.hanzo.ai on Issues. ONE redirect
|
||||
// for every face, driven by the shell descriptor.
|
||||
const shellHome = shellFor(config.shell).home
|
||||
useEffect(() => {
|
||||
if (shellHome) router.replace(`/${shellHome}`)
|
||||
}, [router, shellHome])
|
||||
|
||||
// One-binary STATIC embed: cloud serves THIS page's index.html for EVERY deep
|
||||
// link (a static export can't pre-generate arbitrary product slugs), so a direct
|
||||
// load / refresh — or a client nav that hard-falls-back — of /models, /chat,
|
||||
// /tracker … would otherwise render the home instead of the module. Resolve the
|
||||
// LIVE path client-side and hand any real product route to the shared
|
||||
// ProductRoute. Gated on `mounted` so the first client render matches the
|
||||
// server-exported home ("/") — no hydration mismatch; it then swaps to the
|
||||
// resolved module. On a real Next server this page only renders for "/", so
|
||||
// `segments` is empty and the home always shows; an unknown/non-product deep path
|
||||
// (e.g. /category/*, /discover/*) resolves to notfound here and falls through to
|
||||
// the home rather than a hard 404 in the embed.
|
||||
const segments =
|
||||
mounted && pathname ? pathname.replace(/^\/+|\/+$/g, '').split('/').filter(Boolean) : []
|
||||
if (segments.length > 0 && resolveView(segments).kind !== 'notfound') {
|
||||
return <ProductRoute slug={segments} />
|
||||
}
|
||||
|
||||
if (shellHome) {
|
||||
return (
|
||||
<XStack flex={1} justify="center" items="center" p="$8">
|
||||
<Spinner size="large" color="$color11" />
|
||||
</XStack>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<YStack gap="$7">
|
||||
{/* Primary actions — the first, most prominent things a signed-in user can do:
|
||||
get an API key, deploy an open-source project (the platform template catalog),
|
||||
and earn from their own OSS (the Authors revenue-share). ONE tile primitive,
|
||||
three uses; wraps to stack on narrow viewports. The Deploy tile opens the
|
||||
external OSS catalog on press — no eager fetch, so first paint stays cheap. */}
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
<PrimaryActionTile
|
||||
icon={KeyRound}
|
||||
title="Get your API key"
|
||||
description={`Call ${config.brandName} models from your apps, SDKs, and CLI with a personal key.`}
|
||||
ctaLabel="Get API key"
|
||||
dataTour="api-key"
|
||||
onPress={() => push('/api-keys')}
|
||||
/>
|
||||
<PrimaryActionTile
|
||||
icon={Boxes}
|
||||
color={colorOf('store')}
|
||||
title="Deploy OSS"
|
||||
description="Deploy Postgres, n8n, Grafana, Supabase and more — one-click open-source apps on Hanzo Cloud."
|
||||
ctaLabel="Browse the App Store"
|
||||
onPress={() => push('/store')}
|
||||
/>
|
||||
<PrimaryActionTile
|
||||
icon={HandCoins}
|
||||
color={colorOf('authors')}
|
||||
title="Earn from your OSS"
|
||||
description="Earn 20% of the compute margin your open-source project drives when organizations run it on Hanzo Cloud — paid to your Hanzo wallet."
|
||||
ctaLabel="Start earning"
|
||||
onPress={() => push('/authors')}
|
||||
/>
|
||||
</XStack>
|
||||
<OverviewDashboard params={{}} />
|
||||
|
||||
{/* Observability, front-and-center — the platform's live LLM signals (RED
|
||||
metrics · recent logs · recent traces) on the home, the way Langfuse put
|
||||
its metrics dashboard up top. Reuses the ONE shared ProductObservability
|
||||
panel over the `ai` inference service (honest-empty until o11y emits), and
|
||||
deep-links to the full Observe surface. `data-tour` anchors the first-run
|
||||
tour's Observability step. */}
|
||||
<YStack gap="$3" data-tour="metrics">
|
||||
<XStack
|
||||
self="flex-start"
|
||||
items="center"
|
||||
gap="$2"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.75 }}
|
||||
onPress={() => push('/o11y')}
|
||||
aria-label="Open Observability"
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
Observability
|
||||
</Text>
|
||||
<ArrowRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
<ProductObservability service="ai" label="AI inference" />
|
||||
</YStack>
|
||||
|
||||
<ResourceOverview />
|
||||
<YStack gap="$4">
|
||||
<PageHeader
|
||||
title="Explore products"
|
||||
subtitle={`Open and manage every ${config.brandName} product from one place.`}
|
||||
/>
|
||||
{groups.map((group, i) => (
|
||||
<FadeIn key={group.category} index={i} style={{ width: '100%' }}>
|
||||
<YStack gap="$3">
|
||||
<XStack
|
||||
self="flex-start"
|
||||
items="center"
|
||||
gap="$2"
|
||||
cursor="pointer"
|
||||
hoverStyle={{ opacity: 0.75 }}
|
||||
onPress={() => push(`/category/${categorySlug(group.category)}`)}
|
||||
aria-label={`${group.category} overview`}
|
||||
>
|
||||
<Text fontSize="$5" fontWeight="800" color="$color12">
|
||||
{group.category}
|
||||
</Text>
|
||||
<ArrowRight size={16} opacity={0.5} />
|
||||
</XStack>
|
||||
<XStack flexWrap="wrap" gap="$3">
|
||||
{group.entries.map((entry) => (
|
||||
<ProductCard
|
||||
key={entry.id}
|
||||
entry={entry}
|
||||
pinned={isPinned(entry.id)}
|
||||
onOpen={() => openProduct(entry, push)}
|
||||
onToggle={() => toggle(entry.id)}
|
||||
onLearnMore={() => push(`/discover/${entry.id}`)}
|
||||
/>
|
||||
))}
|
||||
</XStack>
|
||||
</YStack>
|
||||
</FadeIn>
|
||||
))}
|
||||
</YStack>
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
@@ -1,232 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* /accept — the invitee's landing page for a team invite (PUBLIC, no session).
|
||||
*
|
||||
* The org admin shares this link (email/OTP delivery isn't wired on this
|
||||
* deployment). The invitee opens it, sees the org they've been invited to, sets a
|
||||
* password (IAM hashes it server-side — never plaintext), then signs in and lands
|
||||
* in that org with the role the admin assigned. Honest states throughout: an
|
||||
* invalid/expired link, an already-accepted link, and IAM errors are all truthful,
|
||||
* never a fake success.
|
||||
*/
|
||||
import { Suspense, useCallback, useEffect, useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import { Button, Card, Input, Spinner, Text, XStack, YStack } from '@hanzo/gui'
|
||||
import { CheckCircle2, ArrowRight, ShieldAlert, UserPlus } from '@hanzogui/lucide-icons-2'
|
||||
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
type Info =
|
||||
| { phase: 'loading' }
|
||||
| { phase: 'error'; message: string }
|
||||
| { phase: 'accepted'; org: string }
|
||||
| { phase: 'form'; org: string; email: string; displayName: string; role: string }
|
||||
|
||||
function Center({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<YStack flex={1} minH="100vh" items="center" justify="center" p="$4">
|
||||
{children}
|
||||
</YStack>
|
||||
)
|
||||
}
|
||||
|
||||
function AcceptFlow() {
|
||||
const router = useRouter()
|
||||
const params = useSearchParams()
|
||||
const token = params?.get('t') ?? ''
|
||||
|
||||
const [info, setInfo] = useState<Info>({ phase: 'loading' })
|
||||
const [password, setPassword] = useState('')
|
||||
const [name, setName] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
const [done, setDone] = useState<false | string>(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setInfo({ phase: 'error', message: 'This invitation link is missing its token.' })
|
||||
return
|
||||
}
|
||||
let live = true
|
||||
;(async () => {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch(`/console/accept?t=${encodeURIComponent(token)}`, { credentials: 'include' })
|
||||
} catch {
|
||||
if (live) setInfo({ phase: 'error', message: 'Network error — please try again.' })
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as
|
||||
| { org?: string; email?: string; displayName?: string; role?: string; accepted?: boolean; error?: string }
|
||||
| null
|
||||
if (!live) return
|
||||
if (!res.ok || !j?.org) {
|
||||
setInfo({ phase: 'error', message: j?.error || 'This invitation link is invalid or has expired.' })
|
||||
return
|
||||
}
|
||||
if (j.accepted) {
|
||||
setInfo({ phase: 'accepted', org: j.org })
|
||||
return
|
||||
}
|
||||
setInfo({ phase: 'form', org: j.org, email: j.email || '', displayName: j.displayName || '', role: j.role || 'member' })
|
||||
setName(j.displayName || '')
|
||||
})()
|
||||
return () => {
|
||||
live = false
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const submit = useCallback(async () => {
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
setErr(`Use a password of at least ${MIN_PASSWORD} characters.`)
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
setErr(null)
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetch('/console/accept', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ t: token, password, displayName: name.trim() || undefined }),
|
||||
})
|
||||
} catch {
|
||||
setErr('Network error — please try again.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
const j = (await res.json().catch(() => null)) as { ok?: boolean; org?: string; error?: string } | null
|
||||
if (!res.ok || !j?.ok) {
|
||||
setErr(j?.error || `Could not activate your account (HTTP ${res.status}).`)
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
setDone(j.org || (info.phase === 'form' ? info.org : ''))
|
||||
}, [password, name, token, info])
|
||||
|
||||
if (done !== false) {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={40} color="$green10" />
|
||||
<YStack gap="$1" items="center">
|
||||
<Text fontSize="$7" fontWeight="800">You're in</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
Your account for <Text color="$color12" fontWeight="700">{done}</Text> is ready. Sign in to continue.
|
||||
</Text>
|
||||
</YStack>
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
width="100%"
|
||||
iconAfter={<ArrowRight size={16} />}
|
||||
onPress={() => router.push('/signin')}
|
||||
>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'loading') {
|
||||
return (
|
||||
<Center>
|
||||
<Spinner size="large" color="$color11" />
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'error') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<ShieldAlert size={36} color="$red10" />
|
||||
<Text fontSize="$6" fontWeight="800">Invitation unavailable</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">{info.message}</Text>
|
||||
<Button size="$3" onPress={() => router.push('/signin')}>Go to sign in</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
if (info.phase === 'accepted') {
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$3" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1" items="center">
|
||||
<CheckCircle2 size={36} color="$green10" />
|
||||
<Text fontSize="$6" fontWeight="800">Already accepted</Text>
|
||||
<Text fontSize="$3" color="$color11" text="center">
|
||||
This invitation to <Text color="$color12" fontWeight="700">{info.org}</Text> was already used. Sign in to continue.
|
||||
</Text>
|
||||
<Button size="$4" theme="light" iconAfter={<ArrowRight size={16} />} onPress={() => router.push('/signin')}>
|
||||
Sign in
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Center>
|
||||
<Card p="$5" gap="$4" width={440} maxW="92vw" borderWidth={1} borderColor="$borderColor" bg="$color1">
|
||||
<YStack gap="$2">
|
||||
<XStack gap="$2" items="center">
|
||||
<UserPlus size={20} />
|
||||
<Text fontSize="$7" fontWeight="800">Join {info.org}</Text>
|
||||
</XStack>
|
||||
<Text fontSize="$3" color="$color11">
|
||||
You've been invited to <Text color="$color12" fontWeight="700">{info.org}</Text> as a{' '}
|
||||
<Text color="$color12" fontWeight="700">{info.role}</Text>. Set a password to activate{' '}
|
||||
<Text color="$color12">{info.email}</Text> and sign in.
|
||||
</Text>
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Your name</Text>
|
||||
<Input value={name} onChangeText={setName} placeholder="Your name" autoCapitalize="words" />
|
||||
</YStack>
|
||||
|
||||
<YStack gap="$2">
|
||||
<Text fontSize="$2" color="$color11" fontWeight="600">Password</Text>
|
||||
<Input
|
||||
value={password}
|
||||
onChangeText={(v) => {
|
||||
setPassword(v)
|
||||
if (err) setErr(null)
|
||||
}}
|
||||
placeholder={`At least ${MIN_PASSWORD} characters`}
|
||||
// secureTextEntry alone does not mask in this @hanzo/gui build; set the
|
||||
// web input type explicitly (RNW passthrough) — same as SignInForm.
|
||||
secureTextEntry
|
||||
{...{ type: 'password' }}
|
||||
autoComplete="new-password"
|
||||
onSubmitEditing={() => void submit()}
|
||||
/>
|
||||
</YStack>
|
||||
|
||||
{err ? <Text fontSize="$2" color="$red10">{err}</Text> : null}
|
||||
|
||||
<Button
|
||||
size="$4"
|
||||
theme="light"
|
||||
disabled={busy || password.length < MIN_PASSWORD}
|
||||
iconAfter={busy ? <Spinner color="$color1" /> : <ArrowRight size={16} />}
|
||||
onPress={() => void submit()}
|
||||
>
|
||||
{busy ? 'Activating…' : 'Set password & join'}
|
||||
</Button>
|
||||
</Card>
|
||||
</Center>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AcceptPage() {
|
||||
return (
|
||||
<Suspense fallback={<Center><Spinner size="large" color="$color11" /></Center>}>
|
||||
<AcceptFlow />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,134 +0,0 @@
|
||||
/**
|
||||
* Server-gated GLOBAL admin aggregate proxy — the cross-tenant business/platform
|
||||
* reads (`/v1/admin/{overview,usage,orgs,audit,products,finance,compute,providers}`)
|
||||
* AND the few GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`POST /v1/admin/providers/{toggle,primary}` — flip shared-gateway provider
|
||||
* routing that affects every org).
|
||||
*
|
||||
* The admin business board is an ALL-ORGS god view (`?org=all`) over IAM + commerce
|
||||
* + o11y. So — unlike the per-tenant `/v1` proxy, which authorizes on the bearer
|
||||
* `owner` claim and is safe for any authenticated user — this MUST be gated to a
|
||||
* GLOBAL admin BEFORE anything is forwarded: a tenant customer (even one who is
|
||||
* `isAdmin` of their own org) must NOT read another org's revenue/spend/customers,
|
||||
* must NOT trigger the `org=all` aggregate at all, and must NOT flip a shared
|
||||
* provider's enabled/primary state.
|
||||
*
|
||||
* Defense in depth (RED H1 — the cloud-side gate for `/v1/admin/*` is a separate
|
||||
* backend contract we cannot see or test from this repo): `getAdminGate` enforces the
|
||||
* SAME policy the IAM/KMS admin proxies use — a VERIFIED `@<brand.adminDomain>` email
|
||||
* AND an IAM global-admin flag, fail-closed (→ 403) on any miss. Only then does the
|
||||
* shared `forwardWithUserBearer` mint a short-lived user bearer and forward to
|
||||
* cloud-api, applying the usual path-traversal + same-origin-CSRF hardening. On a
|
||||
* mutating method (POST), that CSRF gate (Sec-Fetch-Site ≠ cross-site AND Origin/
|
||||
* Referer host == Host, fail-closed 403 BEFORE resolving the user) means a
|
||||
* cross-site page can never flip a provider on the victim admin's behalf. The
|
||||
* browser holds no cloud credential and cannot reach this endpoint without passing
|
||||
* the gate; the client-side `admin: true` nav gate + `AdminManagedNotice` is UI-only
|
||||
* defense-in-depth, never the boundary.
|
||||
*
|
||||
* Least privilege: only the admin aggregate heads are reachable, NOT `iam`/`kms`
|
||||
* (those keep their own gated proxies with their own tenant-scoping semantics) — this
|
||||
* is not a general cloud-api tunnel. `allowAdminSurface` admits `v1/admin/<head>[/...]`
|
||||
* (the exact forwarded upstream shape), so `providers` covers the GET list and the
|
||||
* `providers/{toggle,primary}` POSTs and nothing else. `next.config.mjs` rewrites
|
||||
* `/v1/admin/<head>[/...]` here for BOTH GET and POST (dropping the `/v1/` into the
|
||||
* internal Next route path); this handler re-adds `v1/` for the upstream cloud call,
|
||||
* and the client calls the clean same-origin `/v1/admin/*` form (unchanged).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { cloudAudience } from '~/config'
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowAdminSurface } from '~/lib/server/admin-aggregate'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env reconciled to an EMPTY string still resolves the service. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
const forbidden = () => NextResponse.json({ status: 'error', msg: 'forbidden' }, { status: 403 })
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
async function handle(req: NextRequest, ctx: Ctx): Promise<NextResponse> {
|
||||
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin (tenant
|
||||
// customer, org-level isAdmin) gets a 403 and never triggers the org=all aggregate.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
|
||||
// The rewrite feeds the tail after `/v1/admin/` (e.g. `overview`, `audit`); rebuild
|
||||
// the FULL cloud path, which is `/v1/admin/<head>` — cloud serves every admin route
|
||||
// under `/v1/admin/*` (the beego `/v1/*` glob in hanzoai/ai + cloud's own
|
||||
// `clients/admin` `app.Get("/v1/admin/…")`), and `forwardWithUserBearer` forwards to
|
||||
// `target/path` VERBATIM (no `/v1` prepend), so the `v1/` MUST be part of the path
|
||||
// here or the request lands on a non-existent bare `/admin/*` and 404s. The rewrite
|
||||
// destination (`app/admin/aggregate/<head>`) is the internal Next route, not the
|
||||
// upstream — it deliberately carries no `v1/`; this handler adds it.
|
||||
// `forwardWithUserBearer` re-validates the exact forwarded path via `allow`
|
||||
// (`allowAdminSurface`, keyed on the `v1/admin/<head>` shape) + `pathIsClean`.
|
||||
const path = `v1/admin/${(await ctx.params).path.join('/')}`.replace(/\/+$/, '')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowAdminSurface,
|
||||
// Scope the minted user bearer to the brand's cloud audience (`<brand>-cloud`).
|
||||
// The operator is a member of the reserved `admin` org, whose OWN app is
|
||||
// `admin-console` — NOT in cloud's audience allowlist — so a default-audience
|
||||
// bearer is rejected (anonymous → 403 on every /v1/admin/*). With the cloud
|
||||
// audience, cloud validates the token and, seeing owner=admin + isAdmin=true,
|
||||
// sets X-User-IsAdmin=true. Host-aware so a lux/zoo admin host scopes to its own
|
||||
// brand cloud audience. (Tenant proxies are unchanged — they omit this.)
|
||||
audience: cloudAudience(req.headers.get('host')),
|
||||
// The AdminApi client unwraps the casibase `{status,msg,data}` envelope, so this
|
||||
// proxy's own 401/404 must speak the same shape (an honest state, never a throw).
|
||||
errorShape: 'casibase',
|
||||
unauthorizedMessage: 'Sign in as an administrator.',
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* POST — the GLOBAL-admin mutations that ride the same god-view gate
|
||||
* (`/v1/admin/providers/{toggle,primary}`, `/v1/admin/caps` create). Identical
|
||||
* path through `getAdminGate` (fail-closed 403) → `forwardWithUserBearer`, which applies
|
||||
* the same-origin CSRF check to this mutating method BEFORE resolving the user, streams
|
||||
* the JSON body through, and re-validates the path against `allowAdminSurface` (so a POST
|
||||
* can only ever reach an allowed head — never `iam`/`kms`, never a traversal).
|
||||
*/
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT — the GLOBAL-admin upserts on the same god-view gate (`PUT /v1/admin/enablement`
|
||||
* flip an item off|beta|ga + grant orgs; `PUT /v1/admin/promos` upsert the single
|
||||
* platform plan promo). Same gate + same CSRF/traversal hardening as POST;
|
||||
* `allowAdminSurface` admits only the declared heads, nothing else.
|
||||
*/
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* PATCH — the GLOBAL-admin partial edits (`PATCH /v1/admin/caps/:id?org=<slug>`,
|
||||
* override an org's usage cap). Same gate + same CSRF/traversal hardening; the `:id`
|
||||
* sub-path passes because `allowAdminSurface` admits `v1/admin/caps[/...]`.
|
||||
*/
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE — the GLOBAL-admin removals (`DELETE /v1/admin/caps/:id?org=<slug>`,
|
||||
* remove an org's usage cap). Same gate + CSRF/traversal hardening as the other
|
||||
* mutating verbs; only an allow-listed head/sub-path is ever reached.
|
||||
*/
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
/**
|
||||
* Server-gated GLOBAL IAM admin proxy — cross-tenant IAM ops (any org).
|
||||
*
|
||||
* The browser holds no IAM credential. It calls this SAME-ORIGIN route with just
|
||||
* its session cookie; the handler enforces the GLOBAL admin gate (`getAdminGate`:
|
||||
* verified @<adminDomain> email AND a global-admin flag), then the shared
|
||||
* `forwardIam` applies the allow-list + tenant scoping (a global admin may act on
|
||||
* any org) and forwards to IAM as the user. A CUSTOMER managing their OWN org uses
|
||||
* `/org/iam` instead — this route is global-only.
|
||||
*
|
||||
* Least privilege: only an explicit allow-list of admin segments is reachable
|
||||
* (GET reads / POST mutations); every owner the request references — including
|
||||
* the mutation BODY owner — is validated by `forwardIam`.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Read segments — reachable via GET only. */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-applications',
|
||||
'get-application',
|
||||
'get-providers',
|
||||
'get-provider',
|
||||
'get-roles',
|
||||
'get-records',
|
||||
// Waitlist approval queue (iam#104) — the Pending-Users board reads this. The
|
||||
// /admin/iam gate is already global-admin-only, matching IAM's own
|
||||
// GetPendingUsers auth (global admin or org admin). REUSED, not rebuilt.
|
||||
'get-pending-users',
|
||||
])
|
||||
|
||||
/**
|
||||
* Mutation segments — reachable via POST only (JSON body forwarded).
|
||||
*
|
||||
* The org-metadata WRITES (`add-organization`/`update-organization`/`delete-organization`)
|
||||
* are the DATA-DRIVEN white-label backbone: a tenant IS an org record, and its BRAND
|
||||
* (logo / favicon / themeData) is a real writable IAM field on that record. This is
|
||||
* how the Tenants board CREATES a tenant and WRITES its brand — no hardcoded brand map.
|
||||
* These are safe on THIS proxy because the gate is already GLOBAL-ADMIN-ONLY and
|
||||
* `forwardIam` pins the org NAME (`orgNameSegments` below) so the write is scoped
|
||||
* (a non-global caller — who can't reach this route anyway — could never retarget
|
||||
* another tenant's org via the id or the body `name`).
|
||||
*/
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
'add-application',
|
||||
'update-application',
|
||||
'delete-application',
|
||||
'add-provider',
|
||||
'update-provider',
|
||||
'delete-provider',
|
||||
'add-role',
|
||||
'update-role',
|
||||
'delete-role',
|
||||
'add-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
// Waitlist approval actions (iam#104) — approve/reject a pending user. Body is
|
||||
// `{id:"owner/name"}`; the global-admin gate + forwardIam's owner scoping apply.
|
||||
'approve-user',
|
||||
'reject-user',
|
||||
])
|
||||
|
||||
/**
|
||||
* Organization objects are owned by IAM's built-in `admin`, and the org
|
||||
* list/get endpoints scope results to the caller's org server-side — so `admin`
|
||||
* is an acceptable owner THERE (never for tenant data like users/roles). The
|
||||
* org-metadata WRITES join it: they operate on the `admin`-owned org record.
|
||||
*/
|
||||
const ORG_ENDPOINTS = new Set([
|
||||
'get-organizations',
|
||||
'get-organization',
|
||||
'add-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
])
|
||||
|
||||
/**
|
||||
* Segments carrying an org NAME to guard — a non-global admin can't read/write
|
||||
* another org's settings via the `admin` metadata owner. (This route's gate is
|
||||
* already global-only, so this is defense-in-depth: it keeps the org-name scoping
|
||||
* identical to the `/org/iam` self-service proxy, one policy for both.)
|
||||
*/
|
||||
const ORG_NAME_SEGMENTS = new Set([
|
||||
'get-organization',
|
||||
'update-organization',
|
||||
'delete-organization',
|
||||
])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(
|
||||
req,
|
||||
{ user: gate.user, isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
{
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_ENDPOINTS,
|
||||
orgNameSegments: ORG_NAME_SEGMENTS,
|
||||
// The gate is already global-only; global admins may write to any org.
|
||||
requireAdminForWrite: false,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
/**
|
||||
* Server-gated KMS admin proxy — the ONLY way the browser reaches Hanzo KMS.
|
||||
*
|
||||
* Same trust boundary as the IAM proxy: the browser sends only its session
|
||||
* cookie, this handler enforces the brand-admin gate, then forwards to kmsd as
|
||||
* the user (short-lived user-bound bearer) so KMS enforces org isolation from the
|
||||
* verified `owner` claim (`canActOnOrg`). Secrets are scoped to the brand org by
|
||||
* default; a global admin may target another org with `?org=`.
|
||||
*
|
||||
* Zero-knowledge discipline: this route NEVER logs a secret value or any request
|
||||
* body, and never derives or stores key material — it is a faithful pass-through
|
||||
* of kmsd's JSON + status code. One resource path (`/admin/kms/secrets`); the
|
||||
* verb + query select the operation:
|
||||
* GET ?path=&name=&env= → reveal one value → GET .../secrets/<path>/<name>?env=
|
||||
* GET ?prefix=&env= → list metadata → GET .../secrets?prefix=&env=
|
||||
* POST {path,name,env,value} → create/upsert → POST .../secrets
|
||||
* PATCH ?path=&name= {value,version,env} → rotate → PATCH .../secrets/<path>/<name>
|
||||
* DELETE ?path=&name=&env= → delete → DELETE .../secrets/<path>/<name>?env=
|
||||
*
|
||||
* kmsd has no list endpoint yet — the list GET returns 404, which the KMS module
|
||||
* renders as an honest "listing requires kmsd ≥ next release" state.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate, adminBearer, kmsBaseUrl, type AdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
const notFound = () => NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
|
||||
/** `<path>/<name>` for the kmsd route, each segment encoded, slashes preserved. */
|
||||
function secretRest(path: string, name: string): string {
|
||||
return [...path.split('/').filter(Boolean), name].map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
/** Org the operator acts on — the brand org, unless a SuperAdmin passes ?org=
|
||||
* (the pure `admin-policy` predicate, tested in admin-policy.test.ts). */
|
||||
function orgFor(gate: AdminGate, req: NextRequest): string {
|
||||
return policyOrgFor(
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.nextUrl.searchParams.get('org'),
|
||||
)
|
||||
}
|
||||
|
||||
async function handle(req: NextRequest, segments: string[]): Promise<NextResponse> {
|
||||
// CSRF: a cross-site page carrying the admin's auto-sent cookie must never be able
|
||||
// to create / rotate / delete a KMS secret. Refuse a cross-origin MUTATION before
|
||||
// the admin gate or any body read (safe GET reveals pass). Defense in depth on top
|
||||
// of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return forbidden()
|
||||
if (segments.length !== 1 || segments[0] !== 'secrets') return notFound()
|
||||
|
||||
const org = orgFor(gate, req)
|
||||
// The org travels on the IDENTITY channel, never the URL: cloud's KMS surface
|
||||
// is /v1/kms/secrets and reads the acted-on org from the validated principal
|
||||
// (X-Org-Id — for a SuperAdmin, the switched-into org; the same one-predicate
|
||||
// switch every other subsystem honors). URL-addressed orgs were removed
|
||||
// server-side because a path that names a tenant is caller-selectable.
|
||||
const base = `${kmsBaseUrl()}/v1/kms/secrets`
|
||||
const q = req.nextUrl.searchParams
|
||||
const name = q.get('name') ?? ''
|
||||
const path = q.get('path') ?? ''
|
||||
const env = q.get('env') ?? ''
|
||||
|
||||
let target: string
|
||||
let body: string | undefined
|
||||
if (req.method === 'GET') {
|
||||
if (name) {
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
const params = new URLSearchParams()
|
||||
const prefix = q.get('prefix')
|
||||
if (prefix) params.set('prefix', prefix)
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}${params.toString() ? `?${params}` : ''}`
|
||||
}
|
||||
} else if (req.method === 'POST') {
|
||||
target = base
|
||||
body = await req.text() // {path,name,env,value} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'PATCH') {
|
||||
if (!name) return notFound()
|
||||
target = `${base}/${secretRest(path, name)}`
|
||||
body = await req.text() // {value,version,env} — forwarded verbatim, never logged
|
||||
} else if (req.method === 'DELETE') {
|
||||
if (!name) return notFound()
|
||||
const params = new URLSearchParams()
|
||||
if (env) params.set('env', env)
|
||||
target = `${base}/${secretRest(path, name)}${params.toString() ? `?${params}` : ''}`
|
||||
} else {
|
||||
return notFound()
|
||||
}
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(gate.user)
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ message: `Could not authorize the request: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = { Authorization: `Bearer ${bearer}`, Accept: 'application/json', 'X-Org-Id': org }
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
init.body = body
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(target, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
// Surface only the transport failure — never the request body/value.
|
||||
return NextResponse.json(
|
||||
{ message: `KMS unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
/**
|
||||
* Server-gated GLOBAL-admin proxy for the commerce SaaS-operations god-view
|
||||
* (`GET /v1/metrics/saas`) — the cross-tenant revenue / subscription / customer
|
||||
* snapshot computed IN commerce (the money system of record). This is the exact
|
||||
* console→commerce pattern commerce's own `api/costs` gate documents: the console's
|
||||
* OWN global-admin gate runs FIRST, then it forwards with the `COMMERCE_SERVICE_TOKEN`
|
||||
* and NO user identity — commerce's `RequirePlatformAdmin` admits that trusted M2M
|
||||
* token (Admin bit, empty Subject) for the fleet god-view.
|
||||
*
|
||||
* Gated fail-closed BEFORE any cross-tenant row is read: `getAdminGate` requires a
|
||||
* VERIFIED `@<brand.adminDomain>` email AND an IAM global-admin flag (the SAME gate
|
||||
* the IAM/KMS/aggregate admin proxies use), → 403 on any miss. A tenant customer —
|
||||
* even one who is `isAdmin` of their OWN org — can never read another org's revenue.
|
||||
* The client-side `admin: true` nav gate + module `OperatorAccessRequired` are
|
||||
* UI-only defense-in-depth; this server gate is the boundary.
|
||||
*
|
||||
* The path is FIXED (`/v1/metrics/saas`) — there is no client-controlled path
|
||||
* segment, so no traversal surface. Only the allow-listed `window`/`limit` query
|
||||
* params are forwarded (validated here), never the raw query string. The commerce
|
||||
* SERVICE token comes from server-only env (never `NEXT_PUBLIC_`, never the browser
|
||||
* bundle); unset → honest 501 (the board shows "not configured", never a fabricated
|
||||
* MRR). The commerce raw JSON is wrapped in the casibase `{status,msg,data}`
|
||||
* envelope the admin client (`originGet`) unwraps.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { commerceBaseUrl, commerceServiceToken } from '~/lib/server/billing-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** The commerce SaaS god-view — the internal `/v1` bundle path (cloud/costs siblings
|
||||
* live here too), reached directly at the in-cluster commerce address. */
|
||||
const METRICS_PATH = '/v1/metrics/saas'
|
||||
|
||||
/** The windows commerce accepts; anything else is dropped (commerce defaults 30d). */
|
||||
const WINDOWS = new Set(['7d', '30d', '90d', 'mtd', 'all'])
|
||||
|
||||
const NO_STORE = 'no-store, must-revalidate'
|
||||
const envelope = (msg: string, status: number) =>
|
||||
NextResponse.json({ status: 'error', msg, data: null }, { status, headers: { 'Cache-Control': NO_STORE } })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
// AUTHORIZE FIRST — global-admin only, fail-closed. A non-global-admin never
|
||||
// triggers the cross-tenant commerce walk.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) return envelope('forbidden', 403)
|
||||
|
||||
const token = commerceServiceToken()
|
||||
if (!token) return envelope('SaaS metrics are not configured (COMMERCE_TOKEN missing).', 501)
|
||||
|
||||
// Forward ONLY the allow-listed, validated params — never the raw query string.
|
||||
const q = new URLSearchParams()
|
||||
const window = (req.nextUrl.searchParams.get('window') ?? '').trim()
|
||||
if (WINDOWS.has(window)) q.set('window', window)
|
||||
const limit = Number(req.nextUrl.searchParams.get('limit'))
|
||||
if (Number.isInteger(limit) && limit > 0 && limit <= 200) q.set('limit', String(limit))
|
||||
|
||||
const url = `${commerceBaseUrl()}${METRICS_PATH}${q.toString() ? `?${q}` : ''}`
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, {
|
||||
method: 'GET',
|
||||
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
||||
cache: 'no-store',
|
||||
signal: req.signal,
|
||||
})
|
||||
if (!res.ok) {
|
||||
// Forward commerce's status class as an honest error; the board shows the
|
||||
// failure state (never a fabricated snapshot).
|
||||
return envelope(`SaaS metrics upstream returned ${res.status}.`, res.status === 403 ? 403 : 502)
|
||||
}
|
||||
const data = await res.json()
|
||||
return NextResponse.json({ status: 'ok', msg: '', data }, { headers: { 'Cache-Control': NO_STORE } })
|
||||
} catch (e) {
|
||||
// Redact the exception (it carries the internal commerce host/port) — log
|
||||
// server-side only; return a generic client message.
|
||||
console.error('saas-metrics proxy: upstream unreachable:', commerceBaseUrl(), e instanceof Error ? e.message : String(e))
|
||||
return envelope('SaaS metrics upstream is unavailable.', 502)
|
||||
}
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Keyless AI proxy — the ONE path the console uses to reach the model gateway.
|
||||
*
|
||||
* `/v1/chat/completions` (and friends) REQUIRE an `Authorization: Bearer` token; a
|
||||
* browser session cookie alone is rejected. Rather than ship the user's durable
|
||||
* `sk-` key to the browser, the console calls its OWN origin at the canonical, prefix-free
|
||||
* `/v1/<aihead>` (the /v1-first law); `next.config.mjs` dispatches those heads to THIS `/ai`
|
||||
* proxy (re-rooting the upstream at `v1/` — invisible to the client). `forwardWithUserBearer`
|
||||
* resolves the user, mints a SHORT-LIVED, user-bound IAM token (shared per-user cache in
|
||||
* identity.ts), and forwards to the gateway with that token. No key in the browser, and
|
||||
* every call is billed to the user's own org. The response STREAMS through, so
|
||||
* `chat/completions` SSE (and the multi-model TTFT measurement) is preserved.
|
||||
*
|
||||
* Least privilege: only the read/inference AI endpoints are proxied (the ALLOWED
|
||||
* allow-list); anything else 404s, so this is not a general gateway tunnel. The RAG
|
||||
* retrieval switch (`X-Retrieval`/`X-Retrieval-Store`) is the ONE client-header
|
||||
* passthrough (allow-listed in `ai-proxy`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { retrievalHeaders } from '~/lib/server/ai-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Gateway the proxied AI calls are forwarded to (gated/priced api.hanzo.ai). */
|
||||
const AI_GATEWAY_URL = trim(process.env.AI_GATEWAY_URL ?? 'https://api.hanzo.ai')
|
||||
|
||||
/** The exact `/v1/<...>` endpoints the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
'v1/models',
|
||||
'v1/pricing/models', // the rich model+provider catalog (context, pricing, specs, tier) for Models/Providers pages
|
||||
'v1/plans', // the subscription tiers + entitlements (rpm/tpm/quota) for the catalog plan badges
|
||||
'v1/chat',
|
||||
'v1/chat/completions',
|
||||
'v1/embeddings',
|
||||
'v1/rerank',
|
||||
'v1/audio/speech', // text-to-speech (JSON in → audio bytes out) for the Playground Audio tab
|
||||
'v1/images/generations', // text-to-image (JSON in → image url/b64 out) for the Playground Image tab
|
||||
'v1/videos/generations', // text-to-video CREATE — async: JSON in → a queued job object out (Sora-style)
|
||||
'v1/ai/connections', // AI Login Manager (ai#79/#80): GET list + POST link a BYO provider key (KMS-sealed server-side)
|
||||
'v1/training/clients', // Interactive Training: GET list clients + POST create a LoRA training client (engine plane)
|
||||
'v1/router/policy', // Router: GET the caller's org policy + PUT upsert it (org-admin gated upstream, self-scoped)
|
||||
'v1/router/stats', // Router: the caller org's routing observability aggregate (RequirePrincipal upstream, self-scoped)
|
||||
'v1/get-training-contribution', // Router: the caller org's training opt-in flag (org-admin gated upstream)
|
||||
'v1/update-training-contribution', // Router: set the caller org's training opt-in flag (org-admin gated upstream)
|
||||
'v1/org/settings', // Routing admin: one org's settings row — GET read, PUT upsert (PATCH-merge), DELETE revert (super-admin gated upstream)
|
||||
'v1/org/settings/list', // Routing admin: per-org settings rows (super-admin gated upstream)
|
||||
])
|
||||
|
||||
/**
|
||||
* Async video poll/download sub-paths: GET `/v1/videos/{id}` and
|
||||
* `/v1/videos/{id}/content`. Video generation is async (create returns a job id
|
||||
* immediately; the client polls the job and then downloads the finished MP4), so
|
||||
* the Playground must reach these two dynamic paths in addition to the exact
|
||||
* CREATE above. The job id is an opaque `video_<uuid>`; the charset is kept
|
||||
* conservative and the pattern is anchored to `v1/videos/`, so this stays a
|
||||
* narrow allow-list (the create POST is still only the exact
|
||||
* `v1/videos/generations`), never a general gateway tunnel. Method is enforced
|
||||
* by the backend (these are GET-only there).
|
||||
*/
|
||||
const VIDEO_JOB_PATH = /^v1\/videos\/[A-Za-z0-9._-]+(?:\/content)?$/
|
||||
|
||||
/**
|
||||
* Per-provider AI-connection sub-path: `/v1/ai/connections/<provider>` — the
|
||||
* disconnect (the AI router maps POST here to the delete). Anchored to the
|
||||
* connections head with a conservative provider charset, so it stays a narrow
|
||||
* allow-list, never a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+$/
|
||||
|
||||
/**
|
||||
* Provider-login OAuth start (ai#85): `/v1/ai/connections/<provider>/authorize`.
|
||||
* GET returns the provider consent URL (`?format=json` → `{ authorizeUrl }`) that
|
||||
* the console redirects the browser to; the OAuth callback is handled server-side
|
||||
* by the backend (KMS-sealed), never through this proxy. Anchored to the
|
||||
* connections head with a conservative provider charset — a narrow allow-list, not
|
||||
* a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_AUTHORIZE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/authorize$/
|
||||
|
||||
/**
|
||||
* Import a connected account's usage: `/v1/ai/connections/<provider>/usage`. GET only —
|
||||
* the org's key is unsealed SERVER-SIDE and the provider's usage/cost API is called there;
|
||||
* the browser only reads the normalized ProviderUsage. Anchored to the connections head
|
||||
* with a conservative provider charset — a narrow allow-list, not a general tunnel.
|
||||
*/
|
||||
const AI_CONNECTION_USAGE_PATH = /^v1\/ai\/connections\/[A-Za-z0-9_-]+\/usage$/
|
||||
|
||||
/**
|
||||
* Interactive-training per-client sub-path: `/v1/training/clients/<id>` and its four
|
||||
* drive actions — `/forward_backward`, `/optim_step`, `/sample`, `/save_weights`. GET
|
||||
* reads a client, DELETE drops it, POST drives the actions. Anchored to the clients
|
||||
* head with a conservative id charset (opaque `client_<...>`) and an exact action set,
|
||||
* so it stays a narrow allow-list, never a general tunnel. The bare `v1/training/clients`
|
||||
* (list/create) is the exact entry above.
|
||||
*/
|
||||
const TRAINING_CLIENT_PATH = /^v1\/training\/clients\/[A-Za-z0-9._-]+(?:\/(?:forward_backward|optim_step|sample|save_weights))?$/
|
||||
|
||||
/** Whether a resolved `/v1/<...>` path is reachable through this proxy. */
|
||||
function isAllowedAiPath(p: string): boolean {
|
||||
return (
|
||||
ALLOWED.has(p) ||
|
||||
VIDEO_JOB_PATH.test(p) ||
|
||||
AI_CONNECTION_PATH.test(p) ||
|
||||
AI_CONNECTION_AUTHORIZE_PATH.test(p) ||
|
||||
AI_CONNECTION_USAGE_PATH.test(p) ||
|
||||
TRAINING_CLIENT_PATH.test(p)
|
||||
)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// The client builds a clean `/v1/<aihead>` and `next.config.mjs` dispatches it here
|
||||
// WITHOUT a nested version (destination `/ai/<aihead>`), so the catch-all captures the
|
||||
// sub-path after `/ai/`. Re-root the upstream at `v1/` — the exact path `isAllowedAiPath`
|
||||
// and the gateway see (`v1/chat/completions`, `v1/images/generations`, `v1/ai/connections`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: AI_GATEWAY_URL,
|
||||
path,
|
||||
allow: isAllowedAiPath,
|
||||
// Forward the RAG retrieval switch when present; the store's org owner is still
|
||||
// resolved server-side from the session (the bearer), never the browser.
|
||||
extraHeaders: retrievalHeaders((h) => req.headers.get(h)),
|
||||
errorShape: 'openai',
|
||||
unauthorizedMessage: 'Sign in to use AI.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
// DELETE drops an interactive-training client (`/v1/training/clients/<id>`); the
|
||||
// same-origin CSRF guard in the bearer proxy gates it like every mutating verb.
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
@@ -1,18 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* IAM OAuth callback route. The exchange logic lives in <AuthCallback/> — the SPA fallback
|
||||
* also routes `/auth/callback` through <Auth/> (which renders the same component), so
|
||||
* both entry points share the ONE handler rather than duplicating the code→token flow.
|
||||
*/
|
||||
import { Suspense } from 'react'
|
||||
|
||||
import { AuthCallback } from '~/components/AuthCallback'
|
||||
|
||||
export default function CallbackPage() {
|
||||
return (
|
||||
<Suspense fallback={null}>
|
||||
<AuthCallback />
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* /auth/session — the console's OWN durable, refreshable OAuth session (BFF).
|
||||
*
|
||||
* POST establish the console session for the SIGNED-IN user (first-party
|
||||
* confidential-client password grant WITH offline_access → access +
|
||||
* rotating refresh token, sealed into the httpOnly cookies).
|
||||
* GET the current account resolved from that session (what the Auth reads
|
||||
* FIRST — durable + silently refreshed, so it survives the casibase
|
||||
* session's own lifetime and never bounces the user mid-task).
|
||||
* DELETE sign out — best-effort revoke the refresh token + clear the cookies.
|
||||
*
|
||||
* SECURITY. POST is GATED: it mints a console session ONLY for a caller who is
|
||||
* ALREADY authenticated (a valid casibase/console session — the full login incl. any
|
||||
* MFA), AND only when the password grant resolves to the SAME principal — so it can
|
||||
* never be driven standalone with a stolen password, and never bypasses MFA (an MFA
|
||||
* account never reaches this call). Tokens live only inside the sealed httpOnly
|
||||
* cookies — never returned to the browser, never logged, never in a URL.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import {
|
||||
accountOf,
|
||||
applyCookies,
|
||||
clearCookies,
|
||||
consoleSession,
|
||||
passwordGrant,
|
||||
readRefreshToken,
|
||||
revokeRefreshToken,
|
||||
sameSubject,
|
||||
sealSession,
|
||||
sessionConfigured,
|
||||
setCookies,
|
||||
SessionError,
|
||||
} from '~/lib/server/session'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** GET — the account + remaining access lifetime from the live console session, or
|
||||
* 401 when there is none (the client then falls back to the casibase session). */
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const sess = consoleSession(req)
|
||||
if (!sess || !sess.claims.name) {
|
||||
return NextResponse.json({ error: 'no session' }, { status: 401 })
|
||||
}
|
||||
return NextResponse.json({ account: accountOf(sess.claims), expiresIn: sess.expiresInSec })
|
||||
}
|
||||
|
||||
/** POST { username, password } — establish the console session for the signed-in user. */
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin login (login-CSRF fixes the victim into an attacker's
|
||||
// session) before touching credentials.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!sessionConfigured()) {
|
||||
// No confidential client wired: the console still runs on the casibase session;
|
||||
// report "not configured" so the client silently skips the console session.
|
||||
return NextResponse.json({ error: 'session not configured' }, { status: 501 })
|
||||
}
|
||||
|
||||
let body: { username?: unknown; password?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'bad request' }, { status: 400 })
|
||||
}
|
||||
const username = typeof body.username === 'string' ? body.username.trim() : ''
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (!username || !password) {
|
||||
return NextResponse.json({ error: 'missing credentials' }, { status: 400 })
|
||||
}
|
||||
|
||||
// GATE: the caller must already be authenticated (they just completed the casibase
|
||||
// login incl. any MFA). This binds the console session to a real, MFA-cleared
|
||||
// session and blocks standalone password abuse.
|
||||
const authed = await resolveUser(req)
|
||||
if (!authed) {
|
||||
return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
}
|
||||
|
||||
let tokens
|
||||
try {
|
||||
tokens = await passwordGrant(username, password)
|
||||
} catch (e) {
|
||||
const status = e instanceof SessionError ? e.status : 502
|
||||
return NextResponse.json({ error: 'grant failed' }, { status })
|
||||
}
|
||||
|
||||
const sealed = sealSession(tokens)
|
||||
// The grant MUST resolve to the same principal as the established session — the
|
||||
// console session is for the already-authenticated user, never a third party.
|
||||
const grantId =
|
||||
sealed && sealed.claims.owner && sealed.claims.name ? `${sealed.claims.owner}/${sealed.claims.name}` : ''
|
||||
if (!sealed || !grantId || !sameSubject(grantId, authed.id)) {
|
||||
return NextResponse.json({ error: 'identity mismatch' }, { status: 401 })
|
||||
}
|
||||
|
||||
const res = NextResponse.json({
|
||||
account: accountOf(sealed.claims),
|
||||
expiresIn: Math.floor(sealed.expiresInMs / 1000),
|
||||
})
|
||||
return applyCookies(res, setCookies(sealed.identity, sealed.refresh))
|
||||
}
|
||||
|
||||
/** DELETE — sign out: best-effort revoke the refresh token, then clear the cookies. */
|
||||
export async function DELETE(req: NextRequest): Promise<NextResponse> {
|
||||
// CSRF: refuse a cross-origin forced sign-out.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const rt = readRefreshToken(req)
|
||||
if (rt) await revokeRefreshToken(rt)
|
||||
return applyCookies(NextResponse.json({ ok: true }), clearCookies())
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
/**
|
||||
* GET /auth/waitlist — the signed-in user's WAITLIST ACCESS + position (BFF).
|
||||
*
|
||||
* THE shared product-access check. The console shell (Waitlist) reads this to
|
||||
* decide whether to render the product or the waitlist status page; hanzo.chat and
|
||||
* hanzo.app gate on the SAME underlying `/v1/waitlist/status` for the same user, so
|
||||
* a user's access + position are identical across every surface.
|
||||
*
|
||||
* Resolves the caller's email from their established session (never trusts a
|
||||
* client-supplied email), then asks the waitlist plugin. FAIL-OPEN: when the waitlist
|
||||
* is unconfigured or unreachable, `waitlistAccess` grants access — the gate is
|
||||
* additive and never locks a signed-in user out of a paid product on a blip.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { waitlistAccess } from '~/lib/server/waitlist'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
// No email on the identity → cannot key a waitlist entry; fail OPEN (don't strand
|
||||
// a valid session behind a gate it can never satisfy).
|
||||
if (!user.email) return NextResponse.json({ hasAccess: true, status: null })
|
||||
|
||||
const { hasAccess, status } = await waitlistAccess(user.email, req.headers.get('host'))
|
||||
return NextResponse.json({ hasAccess, status })
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
/**
|
||||
* /console/accept — the invitee's side of the team-invite flow (UNAUTHENTICATED).
|
||||
*
|
||||
* GET ?t=<token> → validate the sealed invite; report whether the member is
|
||||
* still PENDING (no password) or already ACTIVATED, plus the
|
||||
* org + email to show. Never leaks anything a token-holder
|
||||
* shouldn't already know (the admin put them in the org).
|
||||
* POST { t, password, displayName? } → set the pending member's INITIAL password
|
||||
* (IAM hashes it — never plaintext) and mark them activated.
|
||||
*
|
||||
* The sealed token IS the authorization (it names exactly one `org/name`), so this
|
||||
* needs no session — the invitee has none yet. It refuses once the member already
|
||||
* has a password, so a link can never reset an active member's credential.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { BRANDS } from '~/lib/branding/brands'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { getMember, memberHasPassword, activateMember, mintConfigured } from '~/lib/server/identity'
|
||||
import { readInvite, inviteUserId } from '~/lib/server/invite'
|
||||
import { MIN_PASSWORD } from '~/lib/server/onboarding'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (error: string, status: number) => NextResponse.json({ error }, { status })
|
||||
|
||||
export async function GET(req: NextRequest): Promise<NextResponse> {
|
||||
const inv = readInvite(req.nextUrl.searchParams.get('t'))
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const member = await getMember(inviteUserId(inv))
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
org: inv.org,
|
||||
email: member.email || inv.email,
|
||||
displayName: member.displayName || member.name,
|
||||
role: member.isAdmin ? 'admin' : 'member',
|
||||
accepted: memberHasPassword(member),
|
||||
})
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
if (!mintConfigured()) {
|
||||
return bad('Invite acceptance is not configured on this deployment.', 501)
|
||||
}
|
||||
|
||||
let body: { t?: unknown; password?: unknown; displayName?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const inv = readInvite(typeof body.t === 'string' ? body.t : null)
|
||||
if (!inv) return bad('This invitation link is invalid or has expired.', 400)
|
||||
|
||||
const password = typeof body.password === 'string' ? body.password : ''
|
||||
if (password.length < MIN_PASSWORD) {
|
||||
return bad(`Use a password of at least ${MIN_PASSWORD} characters.`, 400)
|
||||
}
|
||||
if (/\s/.test(password)) return bad('Password cannot contain spaces.', 400)
|
||||
const displayName = typeof body.displayName === 'string' ? body.displayName.trim() : ''
|
||||
|
||||
const id = inviteUserId(inv)
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== inv.org) {
|
||||
return bad('This invitation is no longer valid — the member was removed.', 410)
|
||||
}
|
||||
// Single-use for activation: refuse if the member already has a credential, so a
|
||||
// stale/re-shared link can never reset an active member's password.
|
||||
if (memberHasPassword(member)) {
|
||||
return bad('This invitation was already accepted. Please sign in.', 409)
|
||||
}
|
||||
|
||||
const brand = BRANDS[brandFromHost(req.headers.get('host'))]
|
||||
const signupApplication = `${brand.id}-cloud`
|
||||
|
||||
try {
|
||||
await activateMember(id, { password, displayName: displayName || undefined, signupApplication })
|
||||
} catch (e) {
|
||||
return bad(`Could not activate the account: ${e instanceof Error ? e.message : String(e)}`, 502)
|
||||
}
|
||||
return NextResponse.json({ ok: true, org: inv.org, email: member.email || inv.email })
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
/**
|
||||
* POST /console/invite-link — mint a shareable ACCEPT LINK for a pending member.
|
||||
*
|
||||
* The Team module creates the member row via the `/org/iam` proxy (Dave's own
|
||||
* user bearer, Casbin-scoped to his org) — that path is unchanged. This route then
|
||||
* mints the sealed, TTL-bound invite token so the invitee can set a password and
|
||||
* sign in, WITHOUT any email/OTP (delivery is a link hand-off; IAM `send-invitation`
|
||||
* is a documented stub on this deployment).
|
||||
*
|
||||
* Gate: any authenticated ORG ADMIN, pinned to a member of their OWN org (a global
|
||||
* admin may target any org — same policy as the `/org/iam` proxy). The member must
|
||||
* actually EXIST in that org (verified via the confidential client) — so an admin
|
||||
* can never mint an activation link for someone else's tenant or a phantom user.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate, getMember } from '~/lib/server/identity'
|
||||
import { ownerAllowed, orgWriteAllowed } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { signInvite, acceptLink, type Invite } from '~/lib/server/invite'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const bad = (msg: string, status: number) => NextResponse.json({ error: msg }, { status })
|
||||
|
||||
/** The public origin the invitee will open — from the ingress-set Host header. */
|
||||
function publicOrigin(req: NextRequest): string {
|
||||
const host = req.headers.get('host') ?? req.nextUrl.host
|
||||
const proto = req.headers.get('x-forwarded-proto') ?? (host.startsWith('localhost') ? 'http' : 'https')
|
||||
return `${proto}://${host}`
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return bad('forbidden', 403)
|
||||
// Writes (an invite is one) require org admin — a member can view the roster only.
|
||||
if (!orgWriteAllowed({ isSuperAdmin: gate.isSuperAdmin, isAdmin: gate.user.isAdmin })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
let body: { org?: unknown; name?: unknown; email?: unknown }
|
||||
try {
|
||||
body = (await req.json()) as typeof body
|
||||
} catch {
|
||||
return bad('bad request', 400)
|
||||
}
|
||||
const name = typeof body.name === 'string' ? body.name.trim() : ''
|
||||
const email = typeof body.email === 'string' ? body.email.trim() : ''
|
||||
// The org defaults to the caller's own scope; a SuperAdmin may pass another.
|
||||
const reqOrg = typeof body.org === 'string' && body.org.trim() ? body.org.trim() : gate.orgScope
|
||||
if (!name) return bad('missing member name', 400)
|
||||
|
||||
// Pin the org to the caller's scope (a non-SuperAdmin can only ever mint a link
|
||||
// for their OWN org) — the SAME guard as the /org/iam proxy.
|
||||
if (!ownerAllowed(reqOrg, { isSuperAdmin: gate.isSuperAdmin, orgScope: gate.orgScope, orgMetadataOk: false })) {
|
||||
return bad('forbidden', 403)
|
||||
}
|
||||
|
||||
const id = `${reqOrg}/${name}`
|
||||
const member = await getMember(id)
|
||||
if (!member || member.owner !== reqOrg) return bad('member not found', 404)
|
||||
|
||||
const inv: Invite = { org: reqOrg, name, email: email || member.email || '' }
|
||||
const token = signInvite(inv)
|
||||
return NextResponse.json({ ok: true, org: reqOrg, name, email: inv.email, link: acceptLink(publicOrigin(req), token) })
|
||||
}
|
||||
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* /console/mfa/<action> — console-native two-factor (TOTP) enrollment BFF.
|
||||
*
|
||||
* WHY console-native: the console delegated 2FA to hanzo.id's account page, but the
|
||||
* custom hanzo.id login worker doesn't establish an IAM account session, so a
|
||||
* user who signed in through it lands on an account page that can't manage MFA
|
||||
* (setup returns "Unauthorized operation"). This closes that gap: the user enrolls
|
||||
* 2FA IN the console. We forward each IAM MFA op as the caller's OWN user bearer
|
||||
* (the authz filter authenticates the JWT and Casbin authorizes self-service MFA),
|
||||
* with owner/name PINNED to the resolved session user — so a caller can only ever
|
||||
* manage THEIR OWN 2FA, never another account's.
|
||||
*
|
||||
* Actions (POST): initiate · verify · enable · disable — the standard TOTP flow.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, adminBearer, iamBaseUrl } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const TOTP = 'app' // IAM TotpType
|
||||
|
||||
/**
|
||||
* IAM endpoint + the params each action sends. owner/name are ALWAYS included and
|
||||
* pinned to the resolved session user for TWO reasons: (1) the handler targets that
|
||||
* user, and (2) the IAM authz filter derives the request OBJECT from `owner`/`name`
|
||||
* (query) and grants self-access when it equals the bearer subject — the SAME rule
|
||||
* that lets `get-users?owner=<me>` through. We send these as the QUERY STRING with an
|
||||
* EMPTY body: the authz filter's object-derivation reads a form body as JSON, so a
|
||||
* form-encoded body yields an empty object (→ no self-match → denied); with the
|
||||
* params in the query and no body it reads owner/name and the self grant applies.
|
||||
*/
|
||||
const ACTIONS: Record<string, { path: string; params: (u: { owner: string; name: string }, b: Body) => Record<string, string> }> = {
|
||||
initiate: {
|
||||
path: '/v1/iam/mfa/setup/initiate',
|
||||
params: (u) => ({ owner: u.owner, name: u.name, mfaType: TOTP }),
|
||||
},
|
||||
verify: {
|
||||
path: '/v1/iam/mfa/setup/verify',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, passcode: b.passcode ?? '', secret: b.secret ?? '' }),
|
||||
},
|
||||
enable: {
|
||||
path: '/v1/iam/mfa/setup/enable',
|
||||
params: (u, b) => ({ owner: u.owner, name: u.name, mfaType: TOTP, secret: b.secret ?? '', recoveryCodes: b.recoveryCodes ?? '' }),
|
||||
},
|
||||
disable: {
|
||||
path: '/v1/iam/delete-mfa',
|
||||
params: (u) => ({ owner: u.owner, name: u.name }),
|
||||
},
|
||||
}
|
||||
|
||||
type Body = { passcode?: string; secret?: string; recoveryCodes?: string }
|
||||
|
||||
export async function POST(req: NextRequest, ctx: { params: Promise<{ action: string }> }): Promise<NextResponse> {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
const { action } = await ctx.params
|
||||
const spec = ACTIONS[action]
|
||||
if (!spec) return NextResponse.json({ error: 'unknown action' }, { status: 404 })
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'not authenticated' }, { status: 401 })
|
||||
|
||||
const body = (await req.json().catch(() => ({}))) as Body
|
||||
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Could not authorize the request.' }, { status: 502 })
|
||||
}
|
||||
|
||||
// Params ride the QUERY STRING (see ACTIONS doc) with an EMPTY body so the IAM
|
||||
// authz filter derives owner/name for the self-access grant.
|
||||
const qs = new URLSearchParams(spec.params({ owner: user.owner, name: user.name }, body)).toString()
|
||||
try {
|
||||
const res = await fetchWithTimeout(`${iamBaseUrl()}${spec.path}?${qs}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
cache: 'no-store',
|
||||
})
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch {
|
||||
return NextResponse.json({ status: 'error', msg: 'Identity service is unavailable.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
/* ─────────────────────────────────────────────────────────────────────────────
|
||||
Hanzo Design System tokens — the PUBLISHED @hanzo/design package.
|
||||
|
||||
These were vendored under app/design/ (synced 2026-07-24) only because the
|
||||
package was not yet on npm. It is now (@hanzo/design ≥ 0.4.6), so the console
|
||||
reads the real dependency and can no longer drift a border rework behind the
|
||||
rest of the fleet. The token subpaths are named one by one rather than pulling
|
||||
`@hanzo/design/styles.css`: that entry chains relative `@import url(...)`s that
|
||||
Next's CSS pipeline resolves as modules, not sibling files, so the explicit
|
||||
published subpaths are the resolvable form of the same import.
|
||||
|
||||
Fonts are deliberately NOT imported from the package: the console loads the
|
||||
Geist faces via app/fonts.css, and the `:root` shim below lets the vendored
|
||||
typography roles resolve without a second copy.
|
||||
───────────────────────────────────────────────────────────────────────────── */
|
||||
@import '@hanzo/design/tokens/colors.css';
|
||||
@import '@hanzo/design/tokens/typography.css';
|
||||
@import '@hanzo/design/tokens/spacing.css';
|
||||
@import '@hanzo/design/tokens/radius.css';
|
||||
@import '@hanzo/design/tokens/elevation.css';
|
||||
@import '@hanzo/design/tokens/motion.css';
|
||||
@import '@hanzo/design/tokens/z.css';
|
||||
|
||||
/* Font families — the console loads the Geist faces via app/fonts.css; these vars
|
||||
let the typography roles (--type-*) resolve without re-importing fonts. */
|
||||
:root {
|
||||
--font-sans: 'Geist', 'Geist Sans', ui-sans-serif, system-ui, sans-serif;
|
||||
--font-display: var(--font-sans);
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
@@ -1,35 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* `/docs` → the brand documentation site (docs.hanzo.ai / docs.lux.network / …),
|
||||
* resolved CLIENT-side (task #41, "True 1-binary FE").
|
||||
*
|
||||
* Docs are an EXTERNAL product on their own domain, never an in-app route — so a
|
||||
* typed or bookmarked `<console-host>/docs` must land on the real docs, not the
|
||||
* catch-all not-found. The old app/docs/route.ts issued a server 308; in the
|
||||
* one-binary there is no Next runtime (the static export has no server, and a static
|
||||
* export cannot rewrite), so the redirect is resolved from the per-host brand
|
||||
* (`config.docsUrl`) in the browser — exactly what the sidebar "Docs" link and the
|
||||
* header "?" already open. One way, both topologies (embed + standalone).
|
||||
*
|
||||
* The target is set in an effect (not during render) so there is no SSR/CSR
|
||||
* hydration mismatch on the per-brand host between the build-time default and the
|
||||
* real browser host.
|
||||
*/
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { config } from '~/config'
|
||||
|
||||
export default function DocsRedirect() {
|
||||
const [url, setUrl] = useState('')
|
||||
useEffect(() => {
|
||||
const target = config.docsUrl
|
||||
setUrl(target)
|
||||
window.location.replace(target)
|
||||
}, [])
|
||||
return (
|
||||
<main style={{ padding: 24, fontFamily: 'system-ui, sans-serif' }}>
|
||||
Opening documentation… {url ? <a href={url}>Continue</a> : null}
|
||||
</main>
|
||||
)
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 15 KiB |
@@ -1,30 +0,0 @@
|
||||
/* Canonical Hanzo faces — Geist Sans (UI/body/headings) + Geist Mono (code/data).
|
||||
SELF-HOSTED, because a font we serve ourselves is the only kind that arrives.
|
||||
|
||||
These were `@import url('https://cdn.jsdelivr.net/npm/geist@1.3.1/...')`. The import
|
||||
ORDER was fixed once already (an @import emitted after the reset rules is invalid and
|
||||
dropped), but the fonts still never loaded in production: the browser refuses the
|
||||
cross-origin stylesheet (ERR_BLOCKED_BY_ORB), so `document.fonts.size` was 0 on live
|
||||
console.hanzo.ai and every customer read the whole product in system-ui while every
|
||||
rule in the app asked for Geist. A third-party CDN on our own critical render path is
|
||||
also a dependency we do not control.
|
||||
|
||||
One VARIABLE file per family (56K + 58K) spans weights 100-900, so eighteen static
|
||||
cuts collapse to two requests and any weight the design reaches for already exists —
|
||||
no second place to add a face. `font-display: swap` keeps text readable while they
|
||||
load; `local()` lets an installed copy win with no download at all. */
|
||||
@font-face {
|
||||
font-family: 'Geist';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: local('Geist'), url('/fonts/Geist-Variable.woff2') format('woff2');
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
font-style: normal;
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
src: local('Geist Mono'), url('/fonts/GeistMono-Variable.woff2') format('woff2');
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Top-level recovery boundary (Next App Router `global-error`).
|
||||
*
|
||||
* This REPLACES Next's built-in root fallback — the one that renders the bare,
|
||||
* dead-ended "Application error: a client-side exception has occurred" and leaves
|
||||
* the SPA wedged (no router, so a later in-app nav back to `/` stays dead until a
|
||||
* full reload). It is the OUTERMOST boundary: it catches throws in the root layout
|
||||
* and anything that bubbles past the segment boundaries — including a chunk-load
|
||||
* failure during the very first hydration, which is exactly the "deep-link /
|
||||
* refresh a sub-route → crash" the audit hit (a stale-deploy chunk 404s, falls
|
||||
* through to the app-shell HTML, and the browser throws parsing HTML as JS).
|
||||
*
|
||||
* On a chunk skew it SELF-HEALS: one full reload per window pulls the fresh HTML +
|
||||
* current chunks. The reload is bounded by the SAME sessionStorage key every other
|
||||
* recovery site uses (`CHUNK_RELOAD_AT_KEY`), so a skew that trips several
|
||||
* boundaries at once reloads ONCE, never in a loop. For a genuine (non-chunk)
|
||||
* crash it shows a minimal, self-contained recovery card — it runs with the root
|
||||
* layout torn down, so it owns its own `<html>`/`<body>` and uses inline styles
|
||||
* (no GUI provider is mounted here).
|
||||
*/
|
||||
import { useEffect } from 'react'
|
||||
|
||||
import { reportError } from '~/lib/event'
|
||||
import { isChunkLoadError, shouldReloadForChunk, CHUNK_RELOAD_AT_KEY } from '~/components/errors/boundary-logic'
|
||||
|
||||
export default function GlobalError({ error, reset }: { error: Error & { digest?: string }; reset: () => void }) {
|
||||
const chunk = isChunkLoadError(error)
|
||||
|
||||
useEffect(() => {
|
||||
console.error('[console] global error:', error)
|
||||
// The root layout (and its AnalyticsProvider) is torn down here, so this boundary
|
||||
// reports through the module-singleton `eventClient` — the reason it is shared. A
|
||||
// chunk skew self-heals below and is not reported; only a genuine crash is.
|
||||
if (!chunk) {
|
||||
reportError(error, { digest: error.digest, boundary: 'global' })
|
||||
return
|
||||
}
|
||||
if (typeof window === 'undefined') return
|
||||
try {
|
||||
const raw = window.sessionStorage.getItem(CHUNK_RELOAD_AT_KEY)
|
||||
const last = raw ? Number(raw) : null
|
||||
if (shouldReloadForChunk(Date.now(), last)) {
|
||||
window.sessionStorage.setItem(CHUNK_RELOAD_AT_KEY, String(Date.now()))
|
||||
window.location.reload()
|
||||
}
|
||||
} catch {
|
||||
/* sessionStorage blocked (private mode) — fall through to the manual card */
|
||||
}
|
||||
}, [error, chunk])
|
||||
|
||||
return (
|
||||
<html lang="en" style={{ backgroundColor: '#000', colorScheme: 'dark' }}>
|
||||
<body style={{ margin: 0, fontFamily: 'ui-sans-serif, system-ui, -apple-system, sans-serif', color: '#fff', backgroundColor: '#000' }}>
|
||||
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', padding: 24 }}>
|
||||
<div style={{ maxWidth: 440, width: '100%', border: '1px solid #262626', borderRadius: 12, padding: 24, backgroundColor: '#0a0a0a' }}>
|
||||
<h1 style={{ margin: '0 0 8px', fontSize: 18, fontWeight: 700 }}>
|
||||
{chunk ? 'Updating to the latest version' : 'Something went wrong'}
|
||||
</h1>
|
||||
<p style={{ margin: '0 0 20px', fontSize: 14, lineHeight: 1.5, color: '#a3a3a3' }}>
|
||||
{chunk
|
||||
? 'A newer version of the console just shipped. Reloading to load the latest…'
|
||||
: 'The console hit an unexpected error. Reload to recover, or return home.'}
|
||||
</p>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
{!chunk ? (
|
||||
<button type="button" onClick={() => reset()} style={btn(true)}>
|
||||
Try again
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.reload() }}
|
||||
style={btn(chunk)}
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { if (typeof window !== 'undefined') window.location.assign('/') }}
|
||||
style={btn(false)}
|
||||
>
|
||||
Go home
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
|
||||
/** Inline button style — primary (filled) vs chromeless (bordered). */
|
||||
function btn(primary: boolean): React.CSSProperties {
|
||||
return {
|
||||
appearance: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 13,
|
||||
fontWeight: 600,
|
||||
padding: '8px 14px',
|
||||
borderRadius: 8,
|
||||
border: primary ? '1px solid #fff' : '1px solid #333',
|
||||
backgroundColor: primary ? '#fff' : 'transparent',
|
||||
color: primary ? '#000' : '#e5e5e5',
|
||||
}
|
||||
}
|
||||
-635
@@ -1,635 +0,0 @@
|
||||
|
||||
html,
|
||||
body,
|
||||
#__next {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background-color: var(--background, #000000);
|
||||
color: var(--color, #ededf1);
|
||||
font-family: 'Geist', system-ui, -apple-system, sans-serif;
|
||||
/* The base of the ONE type scale. Without this the body inherits the browser's
|
||||
16px root, and every element that does not name a size token — a Gui <Button>
|
||||
label, a bare <span>, anything the ladder does not reach — renders at a size
|
||||
that belongs to no scale. That was the single largest source of type drift in
|
||||
the console: hundreds of nodes painting the retired 16px base beside a 14px
|
||||
one. `--text-base` is the same 14px the Gui `$3` token resolves to
|
||||
(gui.config.ts), so the inherited size and the named size agree. */
|
||||
font-size: var(--text-base, 0.875rem);
|
||||
/* Calm type rendering — crisp, low-glare, comfortable rhythm for a full workday. */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
line-height: 1.5;
|
||||
/* Geist ships a full real weight range, so a requested 500/600/700 resolves to a
|
||||
genuine cut — never a browser-fabricated faux-bold/oblique. This bans synthesis
|
||||
outright as a floor. Inherited by every element; the ONE place the product sets it. */
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
code,
|
||||
pre,
|
||||
kbd,
|
||||
samp {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
/* Tabular numerals — metrics, prices, contexts and IDs align on a fixed advance
|
||||
width so columns of numbers read cleanly (the dashboard-grade detail). */
|
||||
.hz-tnum {
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
}
|
||||
|
||||
/* Display headline — a tight, UNITLESS line-height for large type.
|
||||
A Gui font-size token ships a line-height tuned for ONE line, so a display
|
||||
headline overprints itself the moment it wraps (which it always does on a
|
||||
phone). This must live in CSS: React Native Web reads a bare numeric
|
||||
`lineHeight` in a style object as PIXELS, so `lineHeight: 1.1` there crushes
|
||||
the text instead of scaling it. Unitless in real CSS is relative to the
|
||||
element's own font-size, so ONE rule holds at every size token and
|
||||
breakpoint. `className` forwards to the DOM node on web, so a Gui <Text>
|
||||
can wear it. */
|
||||
.hz-display {
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
/* Data/numeric face — Geist Mono + tabular figures for metric values, prices, IDs,
|
||||
counts and code-like tokens. The dashboard-grade "numbers are typeset" detail
|
||||
(Linear/Stripe): stat tiles, table numeric cells and monospace identifiers read
|
||||
as precise, column-aligned data — distinct from Geist prose. One class, whole
|
||||
product. `className` forwards to the DOM node on web, so a Gui <Text> can wear it. */
|
||||
.hz-mono {
|
||||
font-family: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-feature-settings: 'tnum' 1;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* Ban faux-bold/oblique EVERYWHERE. Geist ships real weight cuts, so a requested
|
||||
600/700/800/900 must map to a genuine face, never a browser-synthesized smear.
|
||||
Tamagui/RNW inject runtime styles that reset the
|
||||
inherited `font-synthesis` on Text nodes, so a body-level declaration loses —
|
||||
this universal rule (with !important, a true global invariant) wins on every
|
||||
element regardless of insertion order. One place, whole product. */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
font-synthesis: none !important;
|
||||
}
|
||||
|
||||
/* ── Console dark theme — TRUE-BLACK canvas + calm text/borders. The ONE place the
|
||||
console's dark/light palette is set. Overrides the generated @hanzo/gui (Tamagui)
|
||||
theme variables; the `html:root.t_*` selector is one step more specific than the
|
||||
library's runtime `:root.t_*` block so it wins regardless of stylesheet insertion
|
||||
order. DRY: every surface, border and text color in the app reads from these
|
||||
tokens, so this one block sets the whole product.
|
||||
|
||||
Design intent (dark): a TRUE-BLACK #000 canvas (matches hanzo.ai marketing +
|
||||
hanzo.chat OLED) with a Linear/Vercel-caliber surface-depth ladder above it —
|
||||
resting panels #050505, the next surface #0a0a0a, interactive/elevated #171717 →
|
||||
#1f1f1f — so cards read with real depth, never flat voids. Above the surface
|
||||
ladder the CALM neutral scale (color5–12) gives premium, low-glare off-white text
|
||||
and quiet hairline borders (never harsh pure #fff on pure #000). Neutral grey,
|
||||
monochrome-first; text contrast stays WCAG-AA+ on every surface. */
|
||||
html:root.t_dark {
|
||||
--background: #000000;
|
||||
--backgroundStrong: #000000;
|
||||
--backgroundHover: #101010;
|
||||
--backgroundPress: #050505;
|
||||
--backgroundFocus: #171717;
|
||||
|
||||
/* Surface depth ladder over the true-black canvas — panels/cards step up from
|
||||
#050505 so they separate cleanly without heavy borders (Linear-grade depth). */
|
||||
--color1: #050505;
|
||||
--color2: #0a0a0a;
|
||||
--color3: #171717;
|
||||
--color4: #1f1f1f;
|
||||
--color5: hsl(0 0% 16%);
|
||||
--color6: hsl(0 0% 22%);
|
||||
--color7: hsl(0 0% 30%);
|
||||
--color8: hsl(0 0% 42%);
|
||||
--color9: hsl(0 0% 55%);
|
||||
--color10: hsl(0 0% 68%);
|
||||
--color11: hsl(0 0% 83%);
|
||||
--color12: hsl(0 0% 95%);
|
||||
--color: #ededed;
|
||||
|
||||
/* Gentle hairlines — present enough to define, quiet enough to disappear on black. */
|
||||
--borderColor: #1f1f1f;
|
||||
--borderColorHover: #333333;
|
||||
--borderColorPress: hsl(0 0% 13%);
|
||||
--borderColorFocus: hsl(0 0% 23%);
|
||||
|
||||
/* Elevation ladder (Material-inspired: ambient + key light). On the true-black
|
||||
canvas a cast shadow alone is nearly invisible, so each level pairs a deep
|
||||
shadow with a faint top highlight + a hairline ring (set on .hz-paper) so a
|
||||
sheet lifts cleanly off black. Brand-neutral — color stays token-driven. */
|
||||
--hz-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.6), 0 1px 1px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-2: 0 3px 8px rgba(0, 0, 0, 0.62), 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-3: 0 8px 24px rgba(0, 0, 0, 0.64), 0 2px 6px rgba(0, 0, 0, 0.5);
|
||||
--hz-elevation-4: 0 16px 40px rgba(0, 0, 0, 0.68), 0 6px 14px rgba(0, 0, 0, 0.55);
|
||||
--hz-elevation-5: 0 28px 64px rgba(0, 0, 0, 0.72), 0 12px 24px rgba(0, 0, 0, 0.6);
|
||||
--hz-ring: 0 0 0 1px rgba(255, 255, 255, 0.06);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Light theme — the calm parallel: a neutral off-white base (not stark #fff), soft
|
||||
ink text (not pure black), and quiet hairlines. MONOCHROME by construction — every
|
||||
token is a zero-saturation gray (hue-agnostic), the light twin of the dark ladder,
|
||||
so no surface ever reads a blue/cool tint. Lighter touch than dark, since the
|
||||
console defaults to dark, but kept consistent for the theme toggle. */
|
||||
html:root.t_light {
|
||||
--background: hsl(0 0% 99%);
|
||||
--color1: hsl(0 0% 100%);
|
||||
--color2: hsl(0 0% 98%);
|
||||
--color3: hsl(0 0% 95.5%);
|
||||
--color4: hsl(0 0% 92.5%);
|
||||
--color5: hsl(0 0% 89%);
|
||||
--color9: hsl(0 0% 46%);
|
||||
--color10: hsl(0 0% 38%);
|
||||
--color11: hsl(0 0% 22%);
|
||||
--color12: hsl(0 0% 12%);
|
||||
--color: hsl(0 0% 12%);
|
||||
--borderColor: hsl(0 0% 90%);
|
||||
--borderColorHover: hsl(0 0% 82%);
|
||||
|
||||
/* Elevation ladder — light theme: soft NEUTRAL-grey Material shadows (pure black
|
||||
alpha, zero hue) on the off-white base — the calm parallel of the dark ladder. */
|
||||
--hz-elevation-1: 0 1px 2px rgba(0, 0, 0, 0.06), 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
--hz-elevation-2: 0 3px 8px rgba(0, 0, 0, 0.08), 0 1px 3px rgba(0, 0, 0, 0.06);
|
||||
--hz-elevation-3: 0 10px 24px rgba(0, 0, 0, 0.1), 0 3px 8px rgba(0, 0, 0, 0.07);
|
||||
--hz-elevation-4: 0 18px 40px rgba(0, 0, 0, 0.13), 0 6px 14px rgba(0, 0, 0, 0.08);
|
||||
--hz-elevation-5: 0 28px 60px rgba(0, 0, 0, 0.16), 0 12px 24px rgba(0, 0, 0, 0.1);
|
||||
--hz-ring: 0 0 0 1px rgba(0, 0, 0, 0.05);
|
||||
--hz-paper-highlight: inset 0 1px 0 0 rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
|
||||
/* Motion — a single fade-up entrance (matches the hanzo.ai marketing feel:
|
||||
~0.4s ease-out, small upward travel, staggered by the consumer). One place
|
||||
defines it; <FadeIn> applies the class + per-item delay. */
|
||||
@keyframes hz-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-fade-up {
|
||||
animation: hz-fade-up 0.4s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Honor the user's reduced-motion preference — no entrance animation. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-fade-up {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Shell chrome motion — the sidebar collapse (width) and the Linear-style
|
||||
two-level nav slide (transform). One place defines the easing; the shell
|
||||
applies the class. `className` forwards to the underlying DOM node on web, so
|
||||
the browser transitions the Gui-driven inline width/transform. */
|
||||
.hz-collapse {
|
||||
transition: width 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: width;
|
||||
}
|
||||
|
||||
.hz-slide {
|
||||
transition: transform 260ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
/* Backdrop cross-fade behind a SlideOver / dialog. */
|
||||
.hz-fade {
|
||||
transition: opacity 240ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Drag-to-reorder — a pinned row while it is being dragged (pointer DnD). The
|
||||
lifted row gets a subtle lift; siblings ease into place via `.hz-slide`. */
|
||||
.hz-drag-item {
|
||||
touch-action: none;
|
||||
transition: transform 180ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.hz-drag-item[data-dragging='true'] {
|
||||
transition: none;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.28);
|
||||
opacity: 0.96;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-collapse,
|
||||
.hz-slide,
|
||||
.hz-fade,
|
||||
.hz-drag-item {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar category accordion — a collapsible level-1 section. The body animates
|
||||
its HEIGHT via grid-template-rows 0fr↔1fr (no magic max-height — the row
|
||||
resolves to the real content height) plus a short opacity fade; the header
|
||||
chevron rotates ▸→▾. Collapsed content stays in the DOM (so both directions
|
||||
animate) but is `inert` (out of tab order + a11y tree). One place defines the
|
||||
easing; the shell toggles `data-open`. */
|
||||
.hz-acc {
|
||||
display: grid;
|
||||
grid-template-rows: 0fr;
|
||||
transition: grid-template-rows 220ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
.hz-acc[data-open='true'] {
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
.hz-acc-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 180ms ease;
|
||||
}
|
||||
.hz-acc[data-open='true'] .hz-acc-inner {
|
||||
opacity: 1;
|
||||
}
|
||||
.hz-chevron {
|
||||
transition: transform 200ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-acc,
|
||||
.hz-acc-inner,
|
||||
.hz-chevron {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* LivingOverview motion — the "videogame-like" living dashboard. The count-up +
|
||||
live sparkline are driven in JS (rAF, gated by prefers-reduced-motion in the
|
||||
hooks); these are the pure-CSS bits: a loading shimmer, a live-feed pulse, and
|
||||
a brief highlight when a tile's number changes. One place defines the easing. */
|
||||
|
||||
/* Skeleton shimmer — an honest "loading", never fabricated content. */
|
||||
@keyframes hz-shimmer {
|
||||
0% {
|
||||
background-position: -160px 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 160px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.hz-skeleton {
|
||||
background-color: var(--color3);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
var(--color4) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 160px 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: hz-shimmer 1.2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Live-feed pulse — the "Live" dot on a streaming panel. */
|
||||
@keyframes hz-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.45;
|
||||
transform: scale(0.82);
|
||||
}
|
||||
}
|
||||
|
||||
/* Entrance for a freshly-arrived activity row (staggerless — one row at a time). */
|
||||
@keyframes hz-row-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.hz-row-in {
|
||||
animation: hz-row-in 0.35s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-skeleton {
|
||||
animation: none;
|
||||
}
|
||||
.hz-row-in {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* RailwayDeploy — the deployment pipeline. A smooth flowing gradient marches along the
|
||||
active leg of the track (stroke-dashoffset), a soft halo pulses out from the current
|
||||
station, and the status dot breathes. All reduced-motion-guarded (→ static). One place
|
||||
defines the easing; RailwayDeploy applies the classes. */
|
||||
@keyframes hz-rail-flow {
|
||||
to {
|
||||
stroke-dashoffset: -28;
|
||||
}
|
||||
}
|
||||
.hz-rail-flow {
|
||||
stroke-dasharray: 5 9;
|
||||
animation: hz-rail-flow 0.85s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes hz-rail-pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
opacity: 0.34;
|
||||
}
|
||||
70% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
transform: scale(2.1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
.hz-rail-pulse {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: hz-rail-pulse 1.7s ease-out infinite;
|
||||
}
|
||||
|
||||
.hz-rail-dot {
|
||||
animation: hz-pulse 1.5s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-rail-flow,
|
||||
.hz-rail-pulse,
|
||||
.hz-rail-dot {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* DataTable row — the hover fill eases in/out (Tamagui flips the bg instantly;
|
||||
this smooths it to the 140ms ease-out the rest of the product uses). */
|
||||
.hz-row {
|
||||
transition: background-color 140ms ease-out;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-row {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Row-edge pin — the quiet affordance on a search result. It is a REAL, focusable
|
||||
control at all times (opacity, never `display:none`), so the keyboard and a
|
||||
screen reader always reach it; it is simply not drawn until the pointer reaches
|
||||
its row. A pinned or keyboard-selected row opts out of the class entirely, so
|
||||
its pin stays lit — the lit ones are STATE, not chrome.
|
||||
Touch has no hover, so `hover:none` pointers keep it visible: on a phone an
|
||||
invisible control is an absent one.
|
||||
Doubled selector (`:root .hz-pin.hz-pin`, specificity 0,3,0) for the same reason
|
||||
`.hz-paper` is doubled above: Gui injects its compiled style props at `:root ._x-…`
|
||||
(0,2,0), so a plain `.hz-pin` loses and the pin paints at full strength forever. */
|
||||
:root .hz-pin.hz-pin {
|
||||
opacity: 0;
|
||||
transition: opacity 140ms ease-out;
|
||||
}
|
||||
:root .hz-row-pin:hover .hz-pin.hz-pin,
|
||||
:root .hz-pin.hz-pin:focus-within,
|
||||
:root .hz-pin.hz-pin:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
@media (hover: none) {
|
||||
:root .hz-pin.hz-pin {
|
||||
opacity: 0.45;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root .hz-pin.hz-pin {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Touch targets — WCAG 2.5.5 (AAA) / Apple HIG ≥44px ──────────────────────
|
||||
On phones/tablets (<lg) every control inside the mobile nav drawer must be at
|
||||
least 44px tall to tap reliably. Scoped to `.hz-touch-target` (set on the drawer
|
||||
root only), so the dense DESKTOP sidebar — a separate mount at lg+ that never
|
||||
wears this class — keeps its Linear-grade density. A Gui <Button> renders a real
|
||||
<button>, so this one rule reaches every nav row / control within the drawer.
|
||||
One class, every touch surface (DRY). */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-touch-target button,
|
||||
.hz-touch-target [role='button'] {
|
||||
min-height: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chat composer dock — pinned to the viewport bottom on phones/tablets ─────
|
||||
The full-page chat scrolls inside the shell's content scroller; without this the
|
||||
composer sits at the end of a tall welcome/thread and first paints BELOW the fold.
|
||||
Made sticky it rides the bottom edge of the scrollport (the conversation scrolls
|
||||
under it), so the input is always reachable. From lg up the capped, centered
|
||||
column already keeps it in view, so it stays in normal flow. The element carries
|
||||
an opaque background so content scrolls cleanly beneath. */
|
||||
@media (max-width: 1023.98px) {
|
||||
.hz-chat-dock {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: var(--z-raised);
|
||||
/* Clear the iOS home indicator when Safari's bottom bar hides (viewport-fit=cover
|
||||
exposes the inset; 0 on devices without one, so no effect elsewhere). */
|
||||
padding-bottom: env(safe-area-inset-bottom);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Material paper / 3D elevation ─────────────────────────────────────────────
|
||||
A real depth system for the console's overlay surfaces (drawer, command palette,
|
||||
menus, dialog, support bubble). Layered box-shadow (ambient + key light) read
|
||||
from the per-theme --hz-elevation-* tokens (light AND dark aware). Brand-neutral:
|
||||
the shadow is monochrome and color stays token-driven, so lux/zoo/pars theme
|
||||
cleanly. One place defines the ladder; an overlay wears a class. `className`
|
||||
forwards to the DOM node on web, so a Gui surface can wear these.
|
||||
|
||||
Each selector is written `:root .hz-x.hz-x` on purpose. @hanzo/gui (Tamagui)
|
||||
compiles its own shadow props to an atomic rule it injects at RUNTIME as
|
||||
`:root ._bxsh-…` — specificity (0,2,0). A plain `.hz-paper` is (0,1,0) and loses;
|
||||
`.hz-paper.hz-paper` merely TIES, and a tie is settled by stylesheet order, which
|
||||
runtime injection makes nondeterministic. It lost in practice: an overlay wearing
|
||||
`hz-paper` rendered Tamagui's `0 12px 24px rgba(0,0,0,.33)` instead of this ladder,
|
||||
and on the true-black canvas that shadow is nearly invisible — the sheet did not
|
||||
lift off the page. (0,3,0) wins outright, in either order, with no `!important`. */
|
||||
:root .hz-elevation-1.hz-elevation-1 { box-shadow: var(--hz-elevation-1); }
|
||||
:root .hz-elevation-2.hz-elevation-2 { box-shadow: var(--hz-elevation-2); }
|
||||
:root .hz-elevation-3.hz-elevation-3 { box-shadow: var(--hz-elevation-3); }
|
||||
:root .hz-elevation-4.hz-elevation-4 { box-shadow: var(--hz-elevation-4); }
|
||||
:root .hz-elevation-5.hz-elevation-5 { box-shadow: var(--hz-elevation-5); }
|
||||
|
||||
/* Paper = an elevated sheet: hairline ring + top highlight + a mid cast shadow, so
|
||||
a menu/palette/dialog reads as a physical sheet floating above the page. */
|
||||
:root .hz-paper.hz-paper { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-3); }
|
||||
:root .hz-paper-4.hz-paper-4 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-4); }
|
||||
:root .hz-paper-5.hz-paper-5 { box-shadow: var(--hz-ring), var(--hz-paper-highlight), var(--hz-elevation-5); }
|
||||
|
||||
/* Overlay entrance — a fast, physical scale-fade from the origin (menus, palette,
|
||||
dialog, support sheet). 180ms ease-out enter; the overlay's own unmount handles
|
||||
exit. Reduced-motion → snap (no transform). */
|
||||
@keyframes hz-pop-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(6px) scale(0.985);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
.hz-pop-in {
|
||||
animation: hz-pop-in 180ms cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
transform-origin: var(--hz-pop-origin, center);
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* Popover MENU entrance — OPACITY-ONLY (never transform). floating-ui positions an
|
||||
anchored menu with an inline `transform: translate(x,y)`, and a CSS-animation that
|
||||
also drives `transform` (like hz-pop-in) OVERRIDES that inline value for the
|
||||
animation's duration — detaching the menu from its trigger. So anchored menus
|
||||
(SelectMenu / ComboBox Popover.Content) fade in with NO transform, keeping the
|
||||
floating-ui anchor exact. The transform-based hz-pop-in stays for the centered
|
||||
Dialog surfaces (CommandPalette / FloatingChat), which are NOT
|
||||
floating-ui-positioned. Reduced-motion → snap. */
|
||||
@keyframes hz-menu-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.hz-menu-in {
|
||||
animation: hz-menu-in 140ms ease-out both;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
/* Scrim fade — the dimmed backdrop behind a dialog/palette eases in (Tamagui mounts
|
||||
the overlay instantly otherwise). */
|
||||
@keyframes hz-scrim-in {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
.hz-scrim-in {
|
||||
animation: hz-scrim-in 160ms ease-out both;
|
||||
}
|
||||
|
||||
/* Support bubble — a gentle hover lift on the elevated brand-H bubble. */
|
||||
.hz-lift {
|
||||
transition:
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 160ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
will-change: transform;
|
||||
}
|
||||
.hz-lift:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Hover-paper — a subtle elevation lift on hover for a small affordance (the sidebar
|
||||
brand-H container). Only the H wears this, never the whole row. */
|
||||
.hz-hover-paper {
|
||||
transition:
|
||||
box-shadow 160ms ease,
|
||||
transform 160ms cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background-color 140ms ease;
|
||||
}
|
||||
.hz-hover-paper:hover {
|
||||
box-shadow: var(--hz-elevation-2);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.hz-pop-in,
|
||||
.hz-menu-in,
|
||||
.hz-scrim-in {
|
||||
animation: none;
|
||||
}
|
||||
.hz-lift,
|
||||
.hz-hover-paper {
|
||||
transition: none;
|
||||
}
|
||||
.hz-lift:hover,
|
||||
.hz-hover-paper:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── A11y + responsive hardening ─────────────────────────────────────────────
|
||||
Global floors that hold across every product surface. One place, whole app. */
|
||||
|
||||
/* 1. The page body is a hard NO-horizontal-scroll surface. A stray fixed/overwide
|
||||
child (an off-screen drawer mid-transition, a wide table) must clip, never
|
||||
scroll the whole document sideways. `clip` (not `hidden`) does not create a
|
||||
scroll container, so sticky/fixed descendants keep working. */
|
||||
html,
|
||||
body {
|
||||
overflow-x: clip;
|
||||
}
|
||||
|
||||
/* 2. Visible keyboard focus, everywhere. `:focus-visible` fires ONLY for keyboard
|
||||
navigation (never a mouse/touch press), so this paints a crisp ring for
|
||||
tab-through without touching pointer interactions. Tamagui focusStyle handles
|
||||
some controls; this is the global floor so nothing is ever focus-invisible.
|
||||
Colour reads from the theme scale, so it adapts in light and dark. */
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--color9);
|
||||
outline-offset: 2px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 3. Touch tap targets ≥44px (WCAG 2.5.5 / Apple HIG). On a COARSE pointer
|
||||
(phone/tablet) every top-bar control meets the 44×44 minimum; the desktop
|
||||
mouse density is deliberately left unchanged. Scoped to the top bar so table
|
||||
row-actions and inline chips are untouched. */
|
||||
@media (pointer: coarse) {
|
||||
.hz-topbar button {
|
||||
min-height: 44px;
|
||||
min-width: 44px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 4. ONE typeface per screen. The shared `@hanzogui/shell` chrome (HanzoHeader and
|
||||
its Meet-Hanzo / Products menus, HanzoFooter, HanzoAppHeader, …) sets its own
|
||||
SYSTEM font stack as an INLINE style on its root — `fontFamily: CHROME.font`,
|
||||
i.e. `ui-sans-serif, system-ui, -apple-system, "Segoe UI", …`, which contains no
|
||||
Geist — and its subtree inherits it (the shell's own buttons re-declare
|
||||
`font-family: inherit`). So the logged-out console rendered the header wordmark
|
||||
and nav in the platform's system face while the hero and body below correctly
|
||||
rendered Geist: mixed typography on one screen. Geist itself loads fine (see
|
||||
app/fonts.css) — this is a cascade problem, not a loading one.
|
||||
|
||||
An inline declaration can only be beaten by `!important`, and the rule has to
|
||||
reach descendants because of that `inherit`. Every shell root carries
|
||||
`data-hanzo-shell`, so ONE rule covers the whole set. Code-ish elements keep the
|
||||
mono face declared above, so the two font invariants stay orthogonal. */
|
||||
[data-hanzo-shell],
|
||||
[data-hanzo-shell] :not(code, pre, kbd, samp) {
|
||||
font-family: var(--font-sans) !important;
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 67 67" role="img" aria-label="Hanzo">
|
||||
<style>path{fill:#000}@media (prefers-color-scheme:dark){path{fill:#fff}}</style>
|
||||
<path d="M22.21 67V44.6369H0V67H22.21Z"/>
|
||||
<path d="M66.7038 22.3184H22.2534L0.0878906 44.6367H44.4634L66.7038 22.3184Z"/>
|
||||
<path d="M22.21 0H0V22.3184H22.21V0Z"/>
|
||||
<path d="M66.7198 0H44.5098V22.3184H66.7198V0Z"/>
|
||||
<path d="M66.7198 67V44.6369H44.5098V67H66.7198Z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 443 B |
@@ -1,96 +0,0 @@
|
||||
/**
|
||||
* Per-user `sk-` Cloud API key — the SAME-ORIGIN console route (the fix for the
|
||||
* API-keys "sign in to manage API keys" / CORS crack).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/keys`) with just its first-party
|
||||
* session cookie. This handler resolves the signed-in user from that cookie
|
||||
* (`resolveUser`) and mints/reads/revokes the key through IAM as the confidential
|
||||
* `hanzo-console` client (`identity.ts` `mintUserKey`/`getUserKey`/`revokeUserKey`,
|
||||
* over IAM `mint-user-keys`/`get-user`/`revoke-user-keys` — the WORKING key path,
|
||||
* verified live). No credential ever reaches the browser; the `sk-` secret is
|
||||
* returned ONLY by POST (show once).
|
||||
*
|
||||
* Why not `cloud.hanzo.ai/v1/iam/keys` (the old path): that is a DIFFERENT
|
||||
* ORIGIN than console.hanzo.ai, so a browser `fetch` is blocked by CORS ("Failed to
|
||||
* fetch") — and cloud-api's own keys handler 501s ("IAM client unset") on this
|
||||
* deployment anyway. The IAM confidential-client mint the console already uses for
|
||||
* `sk-` keys elsewhere (`app/ai` chat) is the ONE authoritative, same-origin,
|
||||
* always-working path — so the Org-Settings API-keys surface uses it too (DRY: the
|
||||
* exact primitives from `identity.ts`, no new IAM plumbing).
|
||||
*
|
||||
* GET → { hasKey, keyPrefix, createdAt } (no secret)
|
||||
* POST → { accessKey } (mint/rotate; full sk- shown ONCE)
|
||||
* DELETE → { ok: true } (revoke; the old key stops working)
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, mintUserKey, getUserKey, revokeUserKey, mintConfigured } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
|
||||
/** 401 (not signed in) — the honest state the UI shows to sign in. */
|
||||
function unauthorized() {
|
||||
return NextResponse.json({ error: 'Sign in to manage API keys.' }, { status: 401 })
|
||||
}
|
||||
|
||||
/** GET — the user's current key state (existence + public prefix, NEVER the secret). */
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
// Honest, non-leaking: the confidential client isn't wired on this deployment.
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const { accessKey, updatedAt } = await getUserKey(user)
|
||||
return NextResponse.json({
|
||||
hasKey: Boolean(accessKey),
|
||||
keyPrefix: accessKey ? accessKey.slice(0, 11) : '',
|
||||
createdAt: updatedAt || '',
|
||||
})
|
||||
} catch (e) {
|
||||
console.error('keys: could not read key state:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not read the API key state.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** POST — mint (or rotate) the key. Returns the full `sk-` secret ONCE. */
|
||||
export async function POST(req: NextRequest) {
|
||||
// CSRF: minting mutates (and is billable-adjacent) from the auto-sent cookie —
|
||||
// refuse a cross-origin request before any work.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
const accessKey = await mintUserKey(user)
|
||||
return NextResponse.json({ accessKey })
|
||||
} catch (e) {
|
||||
console.error('keys: could not mint key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not create the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
|
||||
/** DELETE — revoke the key (the old key stops working; gateway cache ~5m). */
|
||||
export async function DELETE(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
if (!mintConfigured()) {
|
||||
return NextResponse.json({ error: 'API key management is not configured on this deployment.' }, { status: 501 })
|
||||
}
|
||||
try {
|
||||
await revokeUserKey(user)
|
||||
return NextResponse.json({ ok: true })
|
||||
} catch (e) {
|
||||
console.error('keys: could not revoke key:', msgOf(e))
|
||||
return NextResponse.json({ error: 'Could not revoke the API key.' }, { status: 502 })
|
||||
}
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import './fonts.css'
|
||||
import '@hanzogui/core/reset.css'
|
||||
// Hanzo Design System tokens (vendored from hanzoai/design) — the monochrome
|
||||
// source of truth. Imported BEFORE globals.css so the console's Tamagui theme can
|
||||
// derive its ladder from the design neutral/semantic tokens.
|
||||
import './design/index.css'
|
||||
// The motion/skeleton classes `@hanzo/ui/product` components emit (`skeleton`,
|
||||
// `row`, `tnum`, `fade-up`, `drag`). Console's own markup still names the `hz-`
|
||||
// prefixed twins in globals.css below; these are the package's, and without this
|
||||
// import a DataTable's skeleton, row hover and tabular figures render unstyled.
|
||||
import '@hanzo/ui/styles/motion.css'
|
||||
import './globals.css'
|
||||
|
||||
import type { Metadata, Viewport } from 'next'
|
||||
import type { ReactNode } from 'react'
|
||||
import { headers } from 'next/headers'
|
||||
|
||||
import { Provider } from '~/components/Provider'
|
||||
import { ChunkGuard } from '~/components/ChunkGuard'
|
||||
import { BrandTitle } from '~/components/BrandTitle'
|
||||
import { resolveConfig } from '~/config'
|
||||
|
||||
// The document <title> is SSR metadata, so it must reflect the REQUEST host's
|
||||
// brand (console.lux.cloud -> "Lux Cloud Console"), not the build-time default.
|
||||
// The visible shell resolves the brand client-side from window.location, but the
|
||||
// tab title is server-rendered — without reading the Host header here the browser
|
||||
// tab leaks "Hanzo Cloud Console" on Lux/Zoo hosts, a white-label violation.
|
||||
//
|
||||
// The description is the same metadata read by the same brand, so it resolves the
|
||||
// same way. It did not, and shipped `content="Unified admin console for Hanzo Cloud
|
||||
// and all cloud products."` to console.lux.cloud and console.zoo.cloud — the title
|
||||
// beside it was already correct, which is exactly why nobody noticed. Every
|
||||
// brand-visible string in this function comes from `brandName`; adding a literal
|
||||
// here re-opens the leak.
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const host = (await headers()).get('host') ?? undefined
|
||||
const { brandName } = resolveConfig(host)
|
||||
return {
|
||||
title: `${brandName} Console`,
|
||||
description: `Unified admin console for ${brandName} and all cloud products.`,
|
||||
}
|
||||
}
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: '#000000',
|
||||
// Extend the layout into the display cutout / home-indicator area so the
|
||||
// `env(safe-area-inset-*)` values become non-zero on notched devices — the mobile
|
||||
// drawers + chat composer read them to keep content clear of the notch/indicator.
|
||||
viewportFit: 'cover',
|
||||
}
|
||||
|
||||
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<html lang="en" className="t_dark" style={{ backgroundColor: '#000000', colorScheme: 'dark' }} suppressHydrationWarning>
|
||||
<body style={{ margin: 0 }}>
|
||||
<ChunkGuard />
|
||||
<BrandTitle />
|
||||
<Provider>{children}</Provider>
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
/**
|
||||
* Server-gated SELF-SERVICE org member proxy — a CUSTOMER managing their OWN org.
|
||||
*
|
||||
* The `/admin/iam` proxy is GLOBAL-admin only, so a tenant org owner (e.g.
|
||||
* Dave/maxpower) could never manage their own members through it. This proxy
|
||||
* closes that: it admits ANY authenticated user with an org (`getOrgGate`), then
|
||||
* the shared `forwardIam`:
|
||||
* - scopes every reference (query `owner`, `id` owner, and the mutation BODY
|
||||
* owner) to the caller's OWN org — a global admin may cross, a customer never;
|
||||
* - guards get-organization by org NAME (no reading another org's settings);
|
||||
* - requires an ORG ADMIN for writes (invite / change-role / remove), while any
|
||||
* member may READ the roster.
|
||||
* IAM enforces its own checks on the user-bound bearer too — this is the matching,
|
||||
* fail-closed server gate, not the only one.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getOrgGate } from '~/lib/server/identity'
|
||||
import { forwardIam } from '~/lib/server/iam-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
/** Reads — any member of the org (own org only, unless global). */
|
||||
const GET_SEGMENTS = new Set([
|
||||
'get-users',
|
||||
'get-user',
|
||||
'get-roles',
|
||||
'get-organization',
|
||||
// Projects live under the org (IAM-served); the console host's /v1 sends /v1/iam/*
|
||||
// to the cloud binary → 404, so the Projects page routes here (Bearer → IAM).
|
||||
'get-organization-projects',
|
||||
])
|
||||
|
||||
/** Writes — org admin only, own org only (unless global). */
|
||||
const POST_SEGMENTS = new Set([
|
||||
'add-user',
|
||||
'update-user',
|
||||
'delete-user',
|
||||
// Org branding/settings — org-admin only, pinned to the caller's OWN org by both
|
||||
// the `?id` name AND the body name (below), so a brand admin can't retarget another.
|
||||
'update-organization',
|
||||
// Project CRUD — org-admin only (requireAdminForWrite), pinned to the caller's org.
|
||||
'add-project',
|
||||
'delete-project',
|
||||
])
|
||||
|
||||
/** Org objects are owned by the `admin` metadata org (name guarded separately). */
|
||||
const ORG_META = new Set(['get-organization', 'update-organization'])
|
||||
/** Segments carrying an org NAME to pin to the caller's scope (read id + write body). */
|
||||
const ORG_NAME = new Set(['get-organization', 'update-organization'])
|
||||
/** Segments keyed by `organization` (projects) — pin it to the caller's own org so an
|
||||
* omitted/empty organization can't enumerate/pollute across tenants. */
|
||||
const ORG_PARAM = new Set(['get-organization-projects', 'add-project', 'delete-project'])
|
||||
|
||||
const forbidden = () => NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
|
||||
async function handle(req: NextRequest, path: string[], method: 'GET' | 'POST'): Promise<NextResponse> {
|
||||
const gate = await getOrgGate(req)
|
||||
if (!gate) return forbidden()
|
||||
return forwardIam(req, gate, {
|
||||
segment: path.join('/'),
|
||||
method,
|
||||
allowed: method === 'GET' ? GET_SEGMENTS : POST_SEGMENTS,
|
||||
orgMetaSegments: ORG_META,
|
||||
orgNameSegments: ORG_NAME,
|
||||
orgParamSegments: ORG_PARAM,
|
||||
requireAdminForWrite: true,
|
||||
})
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'GET')
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, (await ctx.params).path, 'POST')
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
/**
|
||||
* Same-origin proxy to the PaaS control plane (Job 3 — embedded PaaS). The
|
||||
* browser calls console2's OWN origin (`/paas/...`); this server-side handler
|
||||
* forwards to the ONE Hanzo API endpoint at `/v1/paas/...`, injecting the
|
||||
* service token from server-only env (sourced via KMS — never `NEXT_PUBLIC_`,
|
||||
* never in the browser bundle). This is the real control-plane API, not an
|
||||
* iframe stub.
|
||||
*
|
||||
* ONE ENDPOINT: there is no per-service API host. `/v1/paas/*` is served by the
|
||||
* unified backend behind `api.hanzo.ai` (same `CLOUD_API_URL` every other server
|
||||
* proxy here uses — in-cluster in prod, the public gateway everywhere else). It
|
||||
* used to aim at `platform.hanzo.ai`, which serves NO `/v1/paas/*` route at all
|
||||
* and 401s every `/v1/*` path uniformly, so the board could never load.
|
||||
*
|
||||
* SECURITY: the forwarded token is a PLATFORM SERVICE token — full control-plane
|
||||
* authority, NOT tenant-scoped. So this route is gated to brand admins exactly
|
||||
* like the IAM/KMS admin proxies: `getAdminGate` resolves the caller from their
|
||||
* own session and requires a verified brand-admin (no gate → 403). Without this,
|
||||
* any authenticated browser could drive the whole control plane through the
|
||||
* service token. The gate is the control, NOT a deploy-time env toggle.
|
||||
*
|
||||
* When `PAAS_SERVICE_TOKEN` is unset the proxy returns an honest 501 so the UI
|
||||
* can show a truthful "not configured" state — it never fabricates apps/deploys.
|
||||
*
|
||||
* SCOPE: the browser stamps the active tenant path (X-Org-Id / X-Project-Id /
|
||||
* X-Environment) on every call. We forward it to the control plane so PaaS
|
||||
* resources scope by org → project → environment like the rest of the console —
|
||||
* but the ORG is re-resolved server-side through the admin policy (`orgFor`): a
|
||||
* global admin's switched org is honored, a brand admin is PINNED to their own,
|
||||
* so the forwarded X-Org-Id is authoritative and never the spoofable claim.
|
||||
* Project + environment are sub-scopes the admin picks WITHIN that org, passed
|
||||
* through verbatim.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { getAdminGate } from '~/lib/server/identity'
|
||||
import { orgFor as policyOrgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const API_URL = (process.env.CLOUD_API_URL ?? 'https://api.hanzo.ai').replace(/\/+$/, '')
|
||||
const TOKEN = process.env.PAAS_SERVICE_TOKEN ?? ''
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// CSRF FIRST — the service token below is control-plane god-mode, so a cross-site
|
||||
// page carrying the admin's auto-sent cookie must never drive a deploy/scale/delete.
|
||||
// Refuse a cross-origin MUTATION before the admin gate or any body read (safe reads
|
||||
// pass). Defense in depth on top of the session cookie's own SameSite attribute.
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
|
||||
// Brand-admin gate — the service token below is control-plane god-mode.
|
||||
const gate = await getAdminGate(req)
|
||||
if (!gate) {
|
||||
return NextResponse.json({ error: 'forbidden' }, { status: 403 })
|
||||
}
|
||||
if (!TOKEN) {
|
||||
return NextResponse.json(
|
||||
{ error: 'PaaS control plane is not configured (PAAS_SERVICE_TOKEN missing).' },
|
||||
{ status: 501 },
|
||||
)
|
||||
}
|
||||
// Resolve the authoritative tenant path. Org: the admin policy honors a
|
||||
// SuperAdmin's switched org (the X-Org-Id the browser sends = currentOrg()) and pins
|
||||
// a brand admin to their own — so we forward the resolved org, never the raw
|
||||
// claim. Project + environment are sub-scopes within that org, forwarded as-is.
|
||||
const org = policyOrgFor(
|
||||
{ isSuperAdmin: gate.user.isSuperAdmin, orgScope: gate.orgScope },
|
||||
req.headers.get('X-Org-Id'),
|
||||
)
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
|
||||
const search = req.nextUrl.search
|
||||
// `/paas/<x>` → `/v1/paas/<x>`. The control plane mounts under `/v1/paas`; this
|
||||
// route prefixed only `/v1`, so every call landed on a path that does not exist
|
||||
// (`/paas/apps` → `/v1/apps` → 404) and the board rendered nothing. The name is
|
||||
// 1:1 on both sides: this proxy is the PaaS plane, so it forwards to the PaaS
|
||||
// plane. It aimed at `/v1/<x>` because that IS where the standalone Node platform
|
||||
// served apps; the plane moved into cloud under `/v1/paas` and the path did not.
|
||||
const url = `${API_URL}/v1/paas/${path.join('/')}${search}`
|
||||
const init: RequestInit = {
|
||||
method: req.method,
|
||||
headers: {
|
||||
Authorization: `Bearer ${TOKEN}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
'X-Org-Id': org,
|
||||
...(projectId ? { 'X-Project-Id': projectId } : {}),
|
||||
...(environment ? { 'X-Environment': environment } : {}),
|
||||
},
|
||||
// Never cache control-plane reads.
|
||||
cache: 'no-store',
|
||||
}
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ error: `PaaS upstream unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
'use client'
|
||||
|
||||
/**
|
||||
* Sign-in route. The whole experience (tenant credential form / admin silent SSO)
|
||||
* lives in the shared `<SignIn/>` component, which `Auth` also renders — so a
|
||||
* direct `/signin` load resolves to the form whether it mounts this route or the
|
||||
* dashboard shell (the deploy serves the SPA shell for every path).
|
||||
*/
|
||||
import { SignIn } from '~/components/SignIn'
|
||||
|
||||
export default function SignInPage() {
|
||||
return <SignIn />
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* /system-status — same-origin BFF for the global status badge.
|
||||
*
|
||||
* status.<brand> (Gatus) serves its JSON at `/api/v1/endpoints/statuses` with NO
|
||||
* CORS header, so the browser can't read it cross-origin. This route fetches it
|
||||
* SERVER-SIDE (no CORS) and returns a small overall summary the badge renders
|
||||
* natively — the console's established BFF pattern (no iframe, no third-party
|
||||
* script). Public health data only; no auth, no secrets.
|
||||
*
|
||||
* Fail-soft by construction: any upstream error (down/slow/garbage) returns
|
||||
* `overall: 'unknown'` with HTTP 200, so the badge shows a neutral state and the
|
||||
* shell never breaks.
|
||||
*/
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { config } from '~/config'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import { summarizeStatuses, type StatusSummary } from '~/lib/status/summary'
|
||||
|
||||
// Health changes minute-to-minute — always evaluate fresh (short CDN cache below).
|
||||
export const dynamic = 'force-dynamic'
|
||||
|
||||
const UNKNOWN: StatusSummary = { overall: 'unknown', total: 0, up: 0, down: [] }
|
||||
|
||||
export async function GET() {
|
||||
const statusUrl = config.statusUrl
|
||||
let summary = UNKNOWN
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${statusUrl}/api/v1/endpoints/statuses`,
|
||||
{ headers: { accept: 'application/json' }, cache: 'no-store' },
|
||||
{ timeoutMs: 4000 },
|
||||
)
|
||||
if (res.ok) summary = summarizeStatuses(await res.json())
|
||||
} catch {
|
||||
// fail-soft → UNKNOWN
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ ...summary, statusUrl, checkedAt: new Date().toISOString() },
|
||||
{ headers: { 'Cache-Control': 'public, max-age=30' } },
|
||||
)
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Same-origin proxy to the durable task engine (hanzoai/tasks `tasksd`, the native
|
||||
* Temporal-style HTTP surface at `/v1/tasks/*`).
|
||||
*
|
||||
* `tasksd` runs `TASKSD_REQUIRE_IDENTITY=true`: it validates an IAM **Bearer JWT**
|
||||
* against the IAM JWKS, unconditionally STRIPS inbound `X-Org-Id`, and mints org +
|
||||
* user from the JWT claims (`owner`→org). So — like the `/ai` proxy — the console
|
||||
* calls its OWN origin (`/tasksd/...`) with just the session cookie;
|
||||
* `forwardWithUserBearer` resolves the signed-in user, mints a short-lived
|
||||
* user-bound IAM token (shared per-user cache), and forwards it as the Bearer. No
|
||||
* key in the browser, and every read is org-scoped by the JWT server-side.
|
||||
*
|
||||
* READ-ONLY: only GET is proxied (the console never mutates workflows here), scoped
|
||||
* to the `v1/tasks/*` subtree. When the engine is unreachable the UI shows an honest
|
||||
* BackendStateCard — never fabricated workflows.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The durable task engine. Public TLS is not live yet → default to the in-cluster
|
||||
* service. The tasks Service exposes REST on :7243 (http port); there is NO :80,
|
||||
* so target :7243 explicitly. Override with TASKS_URL. `|| default` (not `??`) so a
|
||||
* blank env still falls back to the in-cluster service. */
|
||||
const TASKS_URL = trim(process.env.TASKS_URL?.trim() || 'http://tasks.hanzo.svc.cluster.local:7243')
|
||||
|
||||
export async function GET(req: NextRequest, ctx: { params: Promise<{ path: string[] }> }) {
|
||||
const rel = (await ctx.params).path.join('/')
|
||||
return forwardWithUserBearer(req, {
|
||||
target: TASKS_URL,
|
||||
path: `v1/tasks/${rel}`,
|
||||
allow: (p) => p === 'v1/tasks' || p.startsWith('v1/tasks/'),
|
||||
unauthorizedMessage: 'Sign in to view tasks.',
|
||||
})
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Same-origin proxy to the cloud ML/training surface (`/v1/ml/models` and the
|
||||
* fine-tuning broker `/v1/finetune/*`).
|
||||
*
|
||||
* The console's Training page calls its OWN origin (`/training/...`) with just the
|
||||
* first-party session cookie; this server handler resolves the signed-in user from
|
||||
* that cookie, mints a SHORT-LIVED, user-bound IAM Bearer (`adminBearer` — the ONE
|
||||
* per-user cache shared with the `/v1` bearer proxy), and forwards to the cloud
|
||||
* backend's `/v1/...` surface with `Authorization: Bearer <token>` + the active
|
||||
* `X-Org-Id`. Training is a TENANT action — any signed-in org user may run it — so
|
||||
* this is user-scoped (resolveUser), NOT the control-plane admin gate the `/paas`
|
||||
* proxy uses. The cloud backend resolves the org from the token's `owner` claim (and
|
||||
* the X-Org-Id the plain-REST train sub-service reads), so a caller can only ever
|
||||
* touch their own org's jobs. `POST /v1/finetune/jobs` is billing-gated upstream and
|
||||
* returns 402 on an unfunded org — that status flows straight back so the UI can
|
||||
* surface it honestly.
|
||||
*
|
||||
* Why a Bearer and NOT the cookie (the fix for the "Not enabled" 403): cloud-api's
|
||||
* `/v1/*` authorizes on a VALIDATED JWT principal and returns 403 "no validated
|
||||
* principal" for a cookie-only call — the raw casibase session cookie is NOT a
|
||||
* principal it accepts (only the sanitizer's cookie-token names or a Bearer). Minting
|
||||
* the same user-bound token the `/v1` proxy uses is the ONE way a signed-in tenant
|
||||
* reaches this surface; the cookie is deliberately dropped upstream (it can't
|
||||
* authenticate, and a cookie + JWT together risks the public-gateway 431).
|
||||
*
|
||||
* Least privilege: only the explicit ML/training sub-paths are forwarded; anything
|
||||
* else 404s, so this is not a general backend tunnel. No secret ever reaches the
|
||||
* browser — the HuggingFace token (for private repos) is resolved from KMS
|
||||
* server-side inside the broker, never here.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser, adminBearer } from '~/lib/server/identity'
|
||||
import { orgFor } from '~/lib/server/admin-policy'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
const msgOf = (e: unknown) => (e instanceof Error ? e.message : String(e))
|
||||
/** Cloud `/v1` backend (hanzoai/ai) — same target lib/server/identity.ts resolves. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL ?? 'http://cloud.hanzo.svc.cluster.local:8000')
|
||||
|
||||
/** The exact `/v1/<...>` ML/training sub-paths the console is allowed to reach. */
|
||||
const ALLOWED = new Set([
|
||||
// Model serving — the org's deployed kserve InferenceServices.
|
||||
'ml/models',
|
||||
// fine-tuning broker (custom-data runs, HF search) — the ONE training door.
|
||||
'finetune/jobs',
|
||||
'finetune/job',
|
||||
'finetune/cancel',
|
||||
'finetune/deploy',
|
||||
'finetune/presets',
|
||||
'finetune/hf/models',
|
||||
'finetune/hf/datasets',
|
||||
'finetune/hf/repo',
|
||||
])
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const rel = path.join('/')
|
||||
if (!ALLOWED.has(rel)) {
|
||||
return NextResponse.json({ status: 'error', msg: 'Not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
// CSRF: `POST /finetune/jobs` mutates (and bills) from the auto-sent cookie — refuse a
|
||||
// cross-origin one before any work (safe reads pass).
|
||||
const csrf = csrfRefusal(req, 'casibase')
|
||||
if (csrf) return csrf
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Sign in to manage training.' },
|
||||
{ status: 401 },
|
||||
)
|
||||
}
|
||||
|
||||
// Mint a short-lived, user-bound Bearer (the SAME per-user cache the `/v1`
|
||||
// proxy uses). cloud-api's `/v1/*` 403s a cookie-only call ("no validated
|
||||
// principal"); a Bearer is the one credential it accepts. Fail CLOSED with 502 if
|
||||
// the token can't be minted — never fall through to an unauthenticated forward.
|
||||
let bearer: string
|
||||
try {
|
||||
bearer = await adminBearer(user)
|
||||
} catch (e) {
|
||||
// Redact — the exception carries the internal IAM host/port. Log server-side only.
|
||||
console.error('training-proxy: could not mint user bearer:', msgOf(e))
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: 'Could not authorize the request.' },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
|
||||
const url = `${CLOUD_API_URL}/v1/${rel}${req.nextUrl.search}`
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${bearer}`,
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
// Org is SERVER-RESOLVED, not the raw browser header: a SuperAdmin's switched
|
||||
// org (?/X-Org-Id) is honored, a non-SuperAdmin caller is PINNED to their own — so a
|
||||
// brand admin can't drive another tenant's training jobs even if the backend
|
||||
// trusted the forwarded header. For a non-SuperAdmin caller this equals the token
|
||||
// owner (the Bearer's own claim), so header and token agree. Matches the /paas +
|
||||
// /admin/kms orgFor pin. The raw session cookie is NOT forwarded (cloud-api can't
|
||||
// validate it as a principal, and cookie + JWT together risks the gateway 431).
|
||||
'X-Org-Id': orgFor({ isSuperAdmin: user.isSuperAdmin, orgScope: user.owner }, req.headers.get('X-Org-Id')),
|
||||
}
|
||||
const projectId = req.headers.get('X-Project-Id')
|
||||
const environment = req.headers.get('X-Environment')
|
||||
if (projectId) headers['X-Project-Id'] = projectId
|
||||
if (environment) headers['X-Environment'] = environment
|
||||
|
||||
const init: RequestInit = { method: req.method, headers, cache: 'no-store' }
|
||||
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
||||
init.body = await req.text()
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetchWithTimeout(url, init)
|
||||
const text = await res.text()
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { 'Content-Type': res.headers.get('content-type') ?? 'application/json' },
|
||||
})
|
||||
} catch (e) {
|
||||
return NextResponse.json(
|
||||
{ status: 'error', msg: `Fine-tuning backend unreachable: ${e instanceof Error ? e.message : String(e)}` },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy at the console's OWN `/v1/*` — the ONE prefix-free
|
||||
* path the browser uses to reach the unified cloud-api surfaces that authorize on a
|
||||
* Bearer JWT. CTO contract: every cloud API path is `/v1/`-rooted, ZERO prefix (no
|
||||
* `/cloud/`, no `/api/`).
|
||||
*
|
||||
* The browser holds NO credential: it calls `<origin>/v1/<head>/...` with just its
|
||||
* first-party session cookie. This catch-all resolves WHO the caller is from that
|
||||
* cookie (`resolveUser`), mints a SHORT-LIVED, user-bound IAM token (shared per-user
|
||||
* cache in identity.ts — ONE cache across every proxy), and forwards to cloud-api's
|
||||
* `/v1/*` with `Authorization: Bearer <token>`. The backend resolves the ORG from the
|
||||
* token's `owner` claim, so tenancy is server-authoritative — a browser can never
|
||||
* supply its own org — and the raw session cookie NEVER reaches cloud-api (no
|
||||
* cookie-CSRF surface upstream). This is the EXACT transport the `/ai` proxy proved
|
||||
* live; every service proxy shares the ONE `forwardWithUserBearer` implementation.
|
||||
*
|
||||
* DISPATCH: the AI (`models`/`chat`/…), admin-aggregate (`/v1/admin/*`), visor
|
||||
* (`regions`/`sizes`/`gpu-sizes`), billing (`/v1/billing/*`) and commerce
|
||||
* (`/v1/commerce/*`) heads are routed to their OWN backends by `next.config.mjs`
|
||||
* `beforeFiles` rewrites BEFORE they reach this catch-all — so this handler owns
|
||||
* exactly the cloud-api `/v1/<head>` surface.
|
||||
*
|
||||
* Least privilege: only the allow-listed cloud HEADS are reachable
|
||||
* (`allowCloudSurface`); `v1/iam/*`, `v1/admin/*`, etc. 404 here — this is not a
|
||||
* general cloud-api tunnel. The mutating same-origin (CSRF) guard, the path-traversal
|
||||
* rejection, and the bearer mint all live in `forwardWithUserBearer`.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCloudSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud). In-cluster ClusterIP — public egress is CF-403'd.
|
||||
* `|| default` (not `??`) so an env accidentally reconciled to an EMPTY string still falls
|
||||
* back to the in-cluster service (a blank CLOUD_API_URL would otherwise break every cloud page). */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// The `[...path]` catch-all sits UNDER `/v1`, so it captures the segments AFTER
|
||||
// `/v1`. Re-prepend the `v1/` root so the allow-list (matches `v1/<head>`) and the
|
||||
// upstream URL (`CLOUD_API_URL/v1/<head>/...`) both see the cloud-api contract path.
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path,
|
||||
allow: allowCloudSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward the browser-controlled
|
||||
// X-Project-Id/X-Environment sub-scopes — the data/serverless resources are
|
||||
// org-keyed, and forwarding an unvalidated project id is an attack surface
|
||||
// (RED MEDIUM). A project-scoped feature must validate membership first.
|
||||
unauthorizedMessage: 'Sign in to use Hanzo Cloud.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* AI-account credential store — the per-user connect/list/disconnect route.
|
||||
*
|
||||
* GET v1/accounts → { providers: masked[] } (existence + mode, NO secret)
|
||||
* POST v1/accounts/:providerId → seal a pasted API key / OAuth token / cookie header
|
||||
* DELETE v1/accounts/:providerId → drop the sealed credential
|
||||
*
|
||||
* The secret is sealed into an httpOnly cookie server-side (`lib/server/ai-accounts`)
|
||||
* and NEVER echoed back or logged. Every request is session-gated (`resolveUser`); the
|
||||
* two mutating verbs are CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* Namespaced under `/v1/ai-accounts/` so the data plane never shadows the UI tab URLs
|
||||
* (`/ai-accounts`, `/ai-accounts/accounts`) — a route handler always wins over the
|
||||
* catch-all page, so the two live in disjoint path space (same rule as `/v1/billing/`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import {
|
||||
readAccounts,
|
||||
accountsCookie,
|
||||
maskAccounts,
|
||||
type AiAccountsStore,
|
||||
type StoredCredential,
|
||||
} from '~/lib/server/ai-accounts'
|
||||
import { isAiProvider, type ConnectMode } from '~/lib/products/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
const MODES: ConnectMode[] = ['api', 'oauth', 'web']
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI accounts.' }, { status: 401 })
|
||||
const notFound = () => NextResponse.json({ error: 'Not found.' }, { status: 404 })
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
const seg = (await ctx.params).path
|
||||
if (seg[0] !== 'accounts' || seg.length !== 1) return notFound()
|
||||
return NextResponse.json({ providers: maskAccounts(readAccounts(req)) })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
if (!isAiProvider(id)) return NextResponse.json({ error: 'Unknown provider.' }, { status: 400 })
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { mode?: string; secret?: string; baseUrl?: string } | null
|
||||
const mode = body?.mode as ConnectMode
|
||||
const secret = typeof body?.secret === 'string' ? body.secret.trim() : ''
|
||||
if (!MODES.includes(mode) || !secret) {
|
||||
return NextResponse.json({ error: 'A link mode and a non-empty credential are required.' }, { status: 400 })
|
||||
}
|
||||
|
||||
const cred: StoredCredential = {
|
||||
mode,
|
||||
secret, // sealed at rest by accountsCookie; never logged.
|
||||
baseUrl: body?.baseUrl?.trim() || undefined,
|
||||
connectedAt: new Date().toISOString(),
|
||||
}
|
||||
const next: AiAccountsStore = { ...readAccounts(req), [id]: cred }
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(next) }), [accountsCookie(next)])
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const seg = (await ctx.params).path
|
||||
const id = seg[1]
|
||||
if (seg[0] !== 'accounts' || !id) return notFound()
|
||||
|
||||
const store = readAccounts(req)
|
||||
delete store[id]
|
||||
return applyCookies(NextResponse.json({ providers: maskAccounts(store) }), [accountsCookie(store)])
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
/**
|
||||
* AI-accounts ORG routing defaults (READ-ONLY) — the server-driven default the
|
||||
* admin set for the whole org, surfaced so the Routing tab can show
|
||||
* "Organization default: On/Off" and fall back to it when the user has no explicit
|
||||
* override.
|
||||
*
|
||||
* GET v1/routing-defaults → cloud-api `{ status, data: { auto_routing_active,
|
||||
* default_session_routing } }` (streamed through verbatim)
|
||||
*
|
||||
* This is a pure READ. It forwards to cloud-api's org-scoped
|
||||
* `GET /v1/router/defaults` with the caller's short-lived user bearer (org is
|
||||
* the token owner — never browser-supplied), the EXACT same auth pattern as the
|
||||
* `/v1` proxy. It deliberately does NOT touch the org-settings WRITE path: a
|
||||
* customer surface has no clean authenticated path to mint the global-admin write,
|
||||
* and forging one is a confused-deputy escalation (see the long note in
|
||||
* `settings/route.ts`). Reads are fine; writes stay out.
|
||||
*
|
||||
* FAIL-SOFT: an older cloud-api with no such endpoint 404s, which streams straight
|
||||
* through as a 404 the client treats as "no org default" — the tab then honors the
|
||||
* cookie preference alone, exactly as before this endpoint existed.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage` and `/v1/ai-accounts/settings`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The unified cloud backend (hanzoai/cloud) — same in-cluster target as the `/v1` proxy. */
|
||||
const CLOUD_API_URL = trim(process.env.CLOUD_API_URL?.trim() || 'http://cloud-api.hanzo.svc.cluster.local:8000')
|
||||
|
||||
const UPSTREAM_PATH = 'v1/router/defaults'
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return forwardWithUserBearer(req, {
|
||||
target: CLOUD_API_URL,
|
||||
path: UPSTREAM_PATH,
|
||||
allow: (p) => p === UPSTREAM_PATH,
|
||||
errorShape: 'casibase',
|
||||
unauthorizedMessage: 'Sign in to read organization routing defaults.',
|
||||
})
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* AI-accounts NON-SECRET preferences — the org/user settings route.
|
||||
*
|
||||
* GET v1/settings → { settings: { routingEnabled } }
|
||||
* PUT v1/settings → persist { routingEnabled } (sealed), returns the new settings
|
||||
*
|
||||
* The one preference today is `routingEnabled` — the org's `model: "auto"` smart-
|
||||
* routing default that Hanzo surfaces read. Persisted with the SAME sealed-cookie
|
||||
* store as the credential blob (`lib/server/ai-accounts`); there is no secret here,
|
||||
* so the seal is for integrity, not confidentiality. Session-gated; the mutating
|
||||
* verb is CSRF-guarded (auto-sent cookie → refuse cross-origin first).
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path (same rule as `/v1/ai-accounts/usage`).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { csrfRefusal } from '~/lib/server/bearer-proxy'
|
||||
import { applyCookies } from '~/lib/server/session'
|
||||
import { readSettings, settingsCookie, normalizeSettings } from '~/lib/server/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const unauthorized = () => NextResponse.json({ error: 'Sign in to manage AI settings.' }, { status: 401 })
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
return NextResponse.json({ settings: readSettings(req) })
|
||||
}
|
||||
|
||||
export async function PUT(req: NextRequest) {
|
||||
const csrf = csrfRefusal(req)
|
||||
if (csrf) return csrf
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return unauthorized()
|
||||
|
||||
const body = (await req.json().catch(() => null)) as { routingEnabled?: unknown } | null
|
||||
if (typeof body?.routingEnabled !== 'boolean') {
|
||||
return NextResponse.json({ error: 'routingEnabled (boolean) is required.' }, { status: 400 })
|
||||
}
|
||||
const settings = normalizeSettings(body)
|
||||
|
||||
// Cookie-only, deliberately. cloud-api now enforces per-org auto-routing via
|
||||
// `OrgSettings.AutoRouting` (hanzoai/ai), toggled through
|
||||
// `PUT /v1/org/settings`. But that endpoint is `RequireGlobalAdmin`-gated
|
||||
// (like every /v1/*-model-route admin route) and is NOT gateway-exposed — it is
|
||||
// reachable only on the direct api.cloud.hanzo.ai ingress with a global-admin
|
||||
// session. This Routing tab is a CUSTOMER surface: `resolveUser` here is a tenant
|
||||
// user whose minted `hanzo-console` bearer is NOT global-admin, and the console's
|
||||
// only admin proxy (`/admin/aggregate`) fail-closed-403s a non-global-admin. So
|
||||
// there is NO clean authenticated path for a customer to write cloud-side
|
||||
// OrgSettings, and forging one (a console service token asserting admin authority
|
||||
// for a client-supplied org) would be a confused-deputy privilege escalation —
|
||||
// refused per "do not bodge auth". The toggle therefore stays the sealed-cookie
|
||||
// org preference the Hanzo surfaces read; API `model:"auto"` still honors the
|
||||
// GLOBAL router flag. To make this write real, a global-admin must set the org's
|
||||
// AutoRouting via the admin console (the OrgSettings CRUD), OR cloud-api must add a
|
||||
// self-serve, org-scoped (owner-from-JWT, non-global-admin) auto-routing toggle the
|
||||
// `/ai` proxy can reach — at which point wire that call in here.
|
||||
return applyCookies(NextResponse.json({ settings }), [settingsCookie(settings)])
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* Unified AI-account usage — the Overview data plane.
|
||||
*
|
||||
* For each CONNECTED provider it runs the headless `@hanzo/usage` pipeline
|
||||
* server-side over the Node host, decrypting the sealed credential into the
|
||||
* usage-engine settings (`settingsFor`) only in memory for the fetch. It ALSO
|
||||
* merges the org's own Hanzo lane — the REAL commerce usage ledger overview,
|
||||
* fetched through the tested `/billing` proxy (the SAME source the Billing/Overview
|
||||
* dashboards read), so `/ai-accounts` shows Hanzo + every linked provider side by side.
|
||||
*
|
||||
* A static route, so it wins over the sibling `[...path]` catch-all for this exact
|
||||
* path. Session-gated; a secret is never logged or returned.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
import { nodeHost } from '@hanzo/usage/node'
|
||||
import { runPipeline } from '@hanzo/usage'
|
||||
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { readAccounts, descriptorFor, settingsFor } from '~/lib/server/ai-accounts'
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
import { normalizeUsageRecords } from '~/lib/api/aimetrics'
|
||||
import { buildCloudUsageOverview } from '~/lib/api/usage-adapter'
|
||||
import type { CloudUsageOverview } from '~/lib/api/usage'
|
||||
import type { ProviderUsage } from '~/lib/api/ai-accounts'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const msgOf = (e: unknown): string => (e instanceof Error ? e.message : 'Fetch failed.')
|
||||
|
||||
/** The org's own Hanzo Cloud lane: the real commerce ledger overview, null on any miss. */
|
||||
async function hanzoLane(req: NextRequest): Promise<CloudUsageOverview | null> {
|
||||
try {
|
||||
const res = await forwardBilling(req, ['usage'])
|
||||
if (!res.ok) return null
|
||||
const records = normalizeUsageRecords(await res.json())
|
||||
return buildCloudUsageOverview(records, {
|
||||
range: '30d',
|
||||
topModels: 6,
|
||||
activityType: 'all',
|
||||
activityLimit: 8,
|
||||
activityOffset: 0,
|
||||
now: Date.now(),
|
||||
product: null,
|
||||
})
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Run the usage pipeline for one connected provider. */
|
||||
async function providerUsage(id: string, cred: ReturnType<typeof readAccounts>[string]): Promise<ProviderUsage> {
|
||||
const descriptor = descriptorFor(id)
|
||||
if (!descriptor) return { id, ok: false, error: 'Unknown provider.' }
|
||||
const { mode, settings } = settingsFor(cred)
|
||||
try {
|
||||
const outcome = await runPipeline(descriptor, { host: nodeHost, sourceMode: mode, settings })
|
||||
if (outcome.result) return { id, ok: true, usage: outcome.result.usage }
|
||||
return { id, ok: false, error: msgOf(outcome.error) }
|
||||
} catch (e) {
|
||||
return { id, ok: false, error: msgOf(e) }
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const user = await resolveUser(req)
|
||||
if (!user) return NextResponse.json({ error: 'Sign in to view usage.' }, { status: 401 })
|
||||
|
||||
const store = readAccounts(req)
|
||||
const [providers, hanzo] = await Promise.all([
|
||||
Promise.all(Object.entries(store).map(([id, cred]) => providerUsage(id, cred))),
|
||||
hanzoLane(req),
|
||||
])
|
||||
|
||||
return NextResponse.json({ providers, hanzo })
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
/**
|
||||
* Per-tenant billing DATA proxy → commerce. Thin route wrapper: the trust boundary,
|
||||
* tenant scoping, CSRF guard, and binary (PDF) passthrough all live in the tested
|
||||
* `~/lib/server/billing-proxy` (`forwardBilling`) — this file only maps the HTTP verbs.
|
||||
*
|
||||
* Rooted at `/v1/billing/` (the /v1-first law) — this handler lives at
|
||||
* `app/v1/billing/[...path]`, MORE SPECIFIC than the cloud BFF catch-all
|
||||
* `app/v1/[...path]`, so `/v1/billing/*` (data) resolves here while `/v1/<other>/*`
|
||||
* falls through to the catch-all. And `/v1/billing/*` (data) never collides with the
|
||||
* billing UI tab URLs (`/billing/reports`, `/billing/invoices`, …) — they differ at
|
||||
* the FIRST path segment, so the tab slugs fall through to the SPA.
|
||||
*
|
||||
* Verbs: GET (reads: balance/usage/invoices/subscriptions/methods, and the
|
||||
* per-invoice PDF), POST (writes: top-up, alerts, save-a-method, cancel/
|
||||
* reactivate a subscription), PATCH (edit a budget/spend-alert), DELETE (detach a
|
||||
* saved payment method, remove a budget). Each is scoped to the caller's OWN org
|
||||
* server-side; a mutating verb is CSRF-guarded (`forwardBilling`).
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardBilling } from '~/lib/server/billing-proxy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return forwardBilling(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce for the PLATFORM CATALOG admin surface
|
||||
* (`/v1/catalog/entries` + `/v1/catalog/seed`) — the SuperAdmin CMS for the product
|
||||
* + pricing catalog (the 17 infra tiers increment 1 seeded, plus every product
|
||||
* surface docs/pricing/the console read from).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/v1/catalog/...`) with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound
|
||||
* IAM token, and forwards to commerce with that Bearer. Commerce's `requireSuperAdmin`
|
||||
* (owner=="admin", the `IsSuperAdmin()` home-org predicate) is the AUTHORITATIVE gate:
|
||||
* the platform catalog is cross-tenant `system`-namespace data, so an org-level admin
|
||||
* is refused 403 — a tenant can never read cost/margin or edit the catalog. The org is
|
||||
* server-authoritative (the Bearer owner), never browser-supplied.
|
||||
*
|
||||
* This is the ADMIN twin of the tenant `/v1/commerce/*` store proxy: a DISTINCT
|
||||
* least-privilege boundary (`allowCatalogSurface`) that admits ONLY the catalog
|
||||
* entries + seed paths, so it can never tunnel commerce's `/v1/billing`, `/v1/checkout`,
|
||||
* `/_/commerce/tenants`, or the merchant store models. It lives at
|
||||
* `app/v1/catalog/[...path]` — MORE SPECIFIC than the `app/v1/[...path]` cloud BFF
|
||||
* catch-all, so Next resolves `/v1/catalog/*` here (the same precedence as
|
||||
* `app/v1/commerce/[...path]`). The path is `/v1/catalog/*` (the REAL commerce mount),
|
||||
* so the go:embed console (where the BFF is pruned) reaches the SAME path on the cloud
|
||||
* binary's embedded commerce directly.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCatalogSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR wires
|
||||
* `COMMERCE_URL` (public egress is CF-gated). Override per-deploy / for local dev. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/catalog/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/catalog/` (e.g. `entries`, `entries/cloud-dev`,
|
||||
// `seed`). Commerce serves the catalog admin CRUD at `/v1/catalog/*`, so re-root
|
||||
// the upstream path at `v1/catalog/` — the same path `allowCatalogSurface` and
|
||||
// `forwardWithUserBearer` see (`v1/catalog/entries`).
|
||||
const path = `v1/catalog/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowCatalogSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the catalog is platform-global and commerce gates on the
|
||||
// SuperAdmin home-org from the token.
|
||||
unauthorizedMessage: 'Sign in as an administrator to edit the catalog.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce (`commerce.hanzo.svc`) — the store /
|
||||
* merchant admin surface (products / orders / customers / collections / variants /
|
||||
* discounts / store settings). The browser calls this OWN-origin route
|
||||
* (`/v1/commerce/...`) with just its session cookie; `forwardWithUserBearer` resolves
|
||||
* the user, mints a short-lived user-bound IAM token, and forwards to commerce with
|
||||
* that Bearer. Commerce's EdgeAuth validates the JWT and resolves the org from its
|
||||
* `owner` claim (`middleware.TokenRequired` fast-paths IAM auth), so the store is
|
||||
* org-scoped SERVER-SIDE — a merchant only ever sees their OWN org's catalog/orders/
|
||||
* customers. No token reaches the browser, and the org is never browser-supplied.
|
||||
*
|
||||
* This is the TENANT store surface (any signed-in org member acts on their own org's
|
||||
* store), so it is user-scoped (`resolveUser`), NOT the `/paas` god-mode service-token
|
||||
* path. It is also DISTINCT from the `/billing` proxy: money (balance/usage/invoices/
|
||||
* Square) stays on `/billing` with its own per-tenant subject scoping — this proxy
|
||||
* carries only the store catalog/orders/customers. Least privilege on the path:
|
||||
* `allowCommerceSurface` admits only the merchant REST heads (product/order/user/…),
|
||||
* so `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants` are NOT reachable here.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowCommerceSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR already
|
||||
* wires `COMMERCE_URL` (public egress is CF-gated). Override per-deploy with COMMERCE_URL. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/commerce/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/commerce/` (e.g. `product`). Commerce serves its REST
|
||||
// models under `/v1/<model>`, so re-root the upstream path at `v1/` — the same path
|
||||
// `allowCommerceSurface` (v1Head) and `forwardWithUserBearer` see (`v1/product`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowCommerceSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the store is org-keyed and commerce re-scopes on the token.
|
||||
unauthorizedMessage: 'Sign in to manage your store.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
/**
|
||||
* Per-user proxy to the Lux DEX indexer's `dex` subgraph — the Lux Economy /
|
||||
* Markets board's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/v1/economy/overview`) with just the session cookie; this handler resolves the
|
||||
* caller, resolves the BRAND from the request host, and POSTs a FIXED, allowlisted
|
||||
* GraphQL query to the in-cluster graphd per brand-scoped network, returning the
|
||||
* NORMALIZED markets + fills + day-data. No graph host or GraphQL query ever reaches
|
||||
* the browser, and the browser can never compose one.
|
||||
*
|
||||
* Security (mirrors app/nodes/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY path is `overview`, and the ONLY GraphQL query is
|
||||
* the fixed markets+fills+dayData read below — this is not a general GraphQL
|
||||
* tunnel (no client-supplied query, no mutations, no arbitrary entity).
|
||||
*
|
||||
* Honest by construction: an unset/unreachable graph host yields a `not-reporting`
|
||||
* snapshot with the real error — never fabricated markets. The native DEX is a CLOB,
|
||||
* so the query asks only for the fields the `dex` subgraph really exposes.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import {
|
||||
normalizeMarkets,
|
||||
normalizeTrades,
|
||||
normalizeDayData,
|
||||
type EconomySnapshot,
|
||||
type RawMarket,
|
||||
type RawFill,
|
||||
type RawMarketDayData,
|
||||
} from '~/lib/api/economy'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The `dex` subgraph GraphQL endpoint per network — the ONLY endpoint this proxy
|
||||
* will query. graphd serves the DEX subgraph at `<prefix>/graphql` (default prefix
|
||||
* `/v1/graph/cchain/dex`); each is overridable per-deploy. An unset/unreachable host
|
||||
* yields an honest `not-reporting` snapshot — never fake markets. (Cross-cluster
|
||||
* reach depends on network policy; when unreachable the board shows honest empty.)
|
||||
*/
|
||||
const GRAPH_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.DEX_GRAPHQL_MAINNET ?? 'http://graph.lux-mainnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
'lux-testnet': trim(process.env.DEX_GRAPHQL_TESTNET ?? 'http://graph.lux-testnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
'lux-devnet': trim(process.env.DEX_GRAPHQL_DEVNET ?? 'http://graph.lux-devnet.svc:8080/v1/graph/cchain/dex/graphql'),
|
||||
}
|
||||
|
||||
/** Per-query timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.ECONOMY_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/**
|
||||
* The ONE fixed GraphQL query — markets (book summary + accrued 24h aggregates),
|
||||
* recent fills (the trade feed), and day-data (the historical series, empty until a
|
||||
* MarketDayData producer emits). Only fields the `dex` subgraph really exposes.
|
||||
*/
|
||||
const QUERY = `query LuxEconomy {
|
||||
markets(first: 100) {
|
||||
id
|
||||
symbol
|
||||
baseToken
|
||||
quoteToken
|
||||
assetsBound
|
||||
openOrders
|
||||
remaining
|
||||
bestBid
|
||||
bestAsk
|
||||
volume24h
|
||||
tradeCount
|
||||
lastPrice
|
||||
feeTier
|
||||
}
|
||||
fills(first: 40) {
|
||||
id
|
||||
symbol
|
||||
price
|
||||
size
|
||||
side
|
||||
timestamp
|
||||
}
|
||||
marketDayDatas(first: 90) {
|
||||
id
|
||||
date
|
||||
symbol
|
||||
volumeUSD
|
||||
feesUSD
|
||||
tvlUSD
|
||||
}
|
||||
}`
|
||||
|
||||
interface GraphResp {
|
||||
data?: { markets?: RawMarket[]; fills?: RawFill[]; marketDayDatas?: RawMarketDayData[] }
|
||||
errors?: { message?: string }[]
|
||||
}
|
||||
|
||||
/** Query ONE network's `dex` subgraph → normalized snapshot. Honest not-reporting on failure. */
|
||||
async function probe(net: NodeNetworkId): Promise<EconomySnapshot> {
|
||||
const base: EconomySnapshot = { network: net, status: 'not-reporting', markets: [], trades: [], dayData: [] }
|
||||
const host = GRAPH_HOSTS[net]
|
||||
if (!host) {
|
||||
base.error = 'no DEX GraphQL host configured for this network'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
host,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ query: QUERY }),
|
||||
cache: 'no-store',
|
||||
},
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
)
|
||||
if (!res.ok) {
|
||||
base.error = `graphql ${res.status}`
|
||||
return base
|
||||
}
|
||||
const json = (await res.json()) as GraphResp
|
||||
if (json?.errors?.length) {
|
||||
base.error = json.errors[0]?.message ?? 'graphql error'
|
||||
return base
|
||||
}
|
||||
const d = json?.data ?? {}
|
||||
return {
|
||||
network: net,
|
||||
status: 'reporting',
|
||||
markets: normalizeMarkets(d.markets),
|
||||
trades: normalizeTrades(d.fills),
|
||||
dayData: normalizeDayData(d.marketDayDatas),
|
||||
}
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// This handler lives at `app/v1/economy/[...path]`, so the catch-all captures the
|
||||
// sub-path after `/v1/economy/`.
|
||||
if (path.join('/') !== 'overview') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view the market economy.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
|
||||
// Query the brand's networks and return the FIRST that reports markets (the live
|
||||
// economy), else the first reporting network, else the first (honest not-reporting).
|
||||
const snaps = await Promise.all(networks.map(probe))
|
||||
const withMarkets = snaps.find((s) => s.status === 'reporting' && s.markets.length > 0)
|
||||
const reporting = snaps.find((s) => s.status === 'reporting')
|
||||
const chosen = withMarkets ?? reporting ?? snaps[0]
|
||||
if (!chosen) {
|
||||
return NextResponse.json({ network: null, status: 'not-reporting', markets: [], trades: [], dayData: [], error: 'no network in scope' })
|
||||
}
|
||||
return NextResponse.json(chosen)
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,168 +0,0 @@
|
||||
/**
|
||||
* Per-user proxy to the REAL luxd node RPC — the Nodes module's ONE transport.
|
||||
* The browser calls console2's OWN origin (`/v1/nodes/inventory`) with just the
|
||||
* session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and fetches the allowlisted luxd RPC methods server-side for each
|
||||
* network that brand may see, returning NORMALIZED per-node rows. No RPC host or
|
||||
* method ever reaches the browser, and the browser can never choose either.
|
||||
*
|
||||
* Security (mirrors app/bootnode/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401 (the RPC data is public,
|
||||
* but the console surface is authenticated, same as every other module).
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — so cloud.lux.cloud sees only Lux networks,
|
||||
* console.hanzo.ai (hanzo) sees all.
|
||||
* - Least privilege: the ONLY path is `inventory`, and the ONLY luxd methods
|
||||
* called are the four read methods below — this is not a general RPC tunnel.
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import {
|
||||
NODE_NETWORK_META,
|
||||
combineInventory,
|
||||
normalizeChains,
|
||||
parseHeight,
|
||||
type NetworkInventory,
|
||||
type RawBlockchain,
|
||||
type RawPeer,
|
||||
type RawValidator,
|
||||
} from '~/lib/api/nodes'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* Public luxd RPC host per network — the ONLY endpoints this proxy will call.
|
||||
* These are PUBLIC RPC hosts (not secrets); each is overridable per-deploy so a
|
||||
* network can be repointed or disabled without a code change. A host that is
|
||||
* unset/unreachable yields an honest `not-reporting` network — never fake rows.
|
||||
*/
|
||||
const HOSTS: Record<NodeNetworkId, string> = {
|
||||
'lux-mainnet': trim(process.env.LUX_MAINNET_RPC ?? 'https://api.lux.network'),
|
||||
'lux-testnet': trim(process.env.LUX_TESTNET_RPC ?? 'https://api.lux-test.network'),
|
||||
'lux-devnet': trim(process.env.LUX_DEVNET_RPC ?? 'https://api.lux-dev.network'),
|
||||
'pars-mainnet': trim(process.env.PARS_MAINNET_RPC ?? 'https://api.pars.network'),
|
||||
// Zoo has no confirmed public primary-network host yet; the default is the
|
||||
// conventional host (api.<brand>.network) and reports honestly when unreachable.
|
||||
'zoo-mainnet': trim(process.env.ZOO_MAINNET_RPC ?? 'https://api.zoo.network'),
|
||||
}
|
||||
|
||||
/** Per-network probe timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.NODES_RPC_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/** A single allowlisted luxd JSON-RPC call. `path` and `method` are fixed here. */
|
||||
async function rpc<T>(
|
||||
host: string,
|
||||
path: '/v1/bc/P' | '/v1/info',
|
||||
method: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<T> {
|
||||
const res = await fetch(`${host}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', accept: 'application/json' },
|
||||
body: JSON.stringify({ jsonrpc: '2.0', id: 1, method }),
|
||||
cache: 'no-store',
|
||||
signal,
|
||||
})
|
||||
if (!res.ok) throw new Error(`${method} ${res.status}`)
|
||||
const json = (await res.json()) as { result?: T; error?: { message?: string } }
|
||||
if (json?.error) throw new Error(json.error.message ?? `${method} error`)
|
||||
return json.result as T
|
||||
}
|
||||
|
||||
/** Probe ONE network: validators + peers + version + height, normalized. */
|
||||
async function probe(net: NodeNetworkId): Promise<NetworkInventory> {
|
||||
const meta = NODE_NETWORK_META[net]
|
||||
const host = HOSTS[net]
|
||||
const base: NetworkInventory = {
|
||||
id: net,
|
||||
chain: meta.chain,
|
||||
env: meta.env,
|
||||
label: meta.label,
|
||||
status: 'not-reporting',
|
||||
validators: 0,
|
||||
peers: 0,
|
||||
nodes: [],
|
||||
chains: [],
|
||||
}
|
||||
|
||||
const ctrl = new AbortController()
|
||||
const timer = setTimeout(() => ctrl.abort(), TIMEOUT_MS)
|
||||
try {
|
||||
const [valR, peerR, verR, hgtR, chainR] = await Promise.allSettled([
|
||||
rpc<{ validators?: RawValidator[] }>(host, '/v1/bc/P', 'platform.getCurrentValidators', ctrl.signal),
|
||||
rpc<{ numPeers?: string; peers?: RawPeer[] }>(host, '/v1/info', 'info.peers', ctrl.signal),
|
||||
rpc<{ version?: string }>(host, '/v1/info', 'info.getNodeVersion', ctrl.signal),
|
||||
rpc<{ height?: string }>(host, '/v1/bc/P', 'platform.getHeight', ctrl.signal),
|
||||
rpc<{ blockchains?: RawBlockchain[] }>(host, '/v1/bc/P', 'platform.getBlockchains', ctrl.signal),
|
||||
])
|
||||
|
||||
const reachable =
|
||||
valR.status === 'fulfilled' || peerR.status === 'fulfilled' || chainR.status === 'fulfilled'
|
||||
if (!reachable) {
|
||||
const reason =
|
||||
valR.status === 'rejected'
|
||||
? valR.reason
|
||||
: peerR.status === 'rejected'
|
||||
? peerR.reason
|
||||
: chainR.status === 'rejected'
|
||||
? chainR.reason
|
||||
: null
|
||||
base.error = reason instanceof Error ? reason.message : 'unreachable'
|
||||
return base
|
||||
}
|
||||
|
||||
const validators = valR.status === 'fulfilled' ? valR.value?.validators : undefined
|
||||
const peers = peerR.status === 'fulfilled' ? peerR.value?.peers : undefined
|
||||
const nodes = combineInventory(validators, peers, net)
|
||||
|
||||
base.status = 'reporting'
|
||||
base.nodes = nodes
|
||||
base.validators = nodes.filter((n) => n.role === 'validator').length
|
||||
base.peers = nodes.filter((n) => n.role === 'peer').length
|
||||
if (verR.status === 'fulfilled') base.version = verR.value?.version
|
||||
if (hgtR.status === 'fulfilled') base.height = parseHeight(hgtR.value?.height)
|
||||
// Chains are best-effort: a network can report validators/peers yet not answer
|
||||
// getBlockchains — then the chains list is honestly empty (no fabricated chains).
|
||||
if (chainR.status === 'fulfilled') base.chains = normalizeChains(chainR.value?.blockchains)
|
||||
return base
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
} finally {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
// ONE endpoint — the inventory. No arbitrary RPC pass-through. This handler lives at
|
||||
// `app/v1/nodes/[...path]`, so the catch-all captures the sub-path after `/v1/nodes/`.
|
||||
if (path.join('/') !== 'inventory') {
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view node infrastructure.' }, { status: 401 })
|
||||
}
|
||||
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
|
||||
// Optional single-network scope, still gated by the brand's allowed set.
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
|
||||
const inventory = await Promise.all(networks.map(probe))
|
||||
return NextResponse.json({ brand, networks: inventory })
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to commerce for the PLATFORM PLAN admin surface
|
||||
* (`/v1/plans/entries` + `/v1/plans/seed`) — the SuperAdmin CMS for the subscription/DNS
|
||||
* plan authority (`models/plan`, the source of truth `GET /v1/billing/plans` and the
|
||||
* internal-ledger renewal charge derive from).
|
||||
*
|
||||
* The browser calls this OWN-origin route (`/v1/plans/...`) with just its session
|
||||
* cookie; `forwardWithUserBearer` resolves the user, mints a short-lived user-bound IAM
|
||||
* token, and forwards to commerce with that Bearer. Commerce's `requireSuperAdmin`
|
||||
* (owner=="admin") is the AUTHORITATIVE gate: the plan authority is cross-tenant
|
||||
* `system`-namespace PRICING data — a plan's price is the real renewal charge — so an
|
||||
* org-level admin is refused 403. The org is server-authoritative (the Bearer owner).
|
||||
*
|
||||
* The ADMIN twin of the tenant `/v1/commerce/*` store proxy and the sibling
|
||||
* `/v1/catalog/*` proxy: a DISTINCT least-privilege boundary (`allowPlansSurface`) that
|
||||
* admits ONLY the plan entries + seed paths, so it can never tunnel commerce's
|
||||
* `/v1/billing`, `/v1/checkout`, `/_/commerce/tenants`, or the merchant store models. It
|
||||
* lives at `app/v1/plans/[...path]` — MORE SPECIFIC than the `app/v1/[...path]` cloud BFF
|
||||
* catch-all, so Next resolves `/v1/plans/*` here. The path is `/v1/plans/*` (the REAL
|
||||
* commerce mount), so the go:embed console (BFF pruned) reaches the SAME path on the
|
||||
* cloud binary's embedded commerce directly.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowPlansSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Commerce API (commerce.hanzo.ai). In-cluster ClusterIP on :8001; the CR wires
|
||||
* `COMMERCE_URL` (public egress is CF-gated). Override per-deploy / for local dev. */
|
||||
const COMMERCE_URL = trim(process.env.COMMERCE_URL ?? 'http://commerce.hanzo.svc:8001')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/plans/[...path]`, so the catch-all captures ONLY
|
||||
// the sub-path after `/v1/plans/` (e.g. `entries`, `entries/pro`, `seed`). Commerce
|
||||
// serves the plan admin CRUD at `/v1/plans/*`, so re-root the upstream path at
|
||||
// `v1/plans/` — the same path `allowPlansSurface` and `forwardWithUserBearer` see.
|
||||
const path = `v1/plans/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: COMMERCE_URL,
|
||||
path,
|
||||
allow: allowPlansSurface,
|
||||
// Org is authoritative (Bearer owner). Do NOT forward browser X-Project-Id/
|
||||
// X-Environment — the plan authority is platform-global and commerce gates on the
|
||||
// SuperAdmin home-org from the token.
|
||||
unauthorizedMessage: 'Sign in as an administrator to edit plans.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Per-user proxy to the Hanzo Base control plane (base.hanzo.ai) — the embedded
|
||||
* Base module's ONE transport. The browser calls console2's OWN origin
|
||||
* (`/v1/superbase/...`) with just the session cookie; `forwardWithUserBearer`
|
||||
* resolves the user, mints a short-lived user-bound IAM token (shared per-user
|
||||
* cache), and forwards to base.hanzo.ai with that token. No token ever reaches the
|
||||
* browser, and the SAME @hanzo/superbase-dashboard screens render here and standalone.
|
||||
*
|
||||
* NOT the PaaS pattern: PaaS forwards a god-mode SERVICE token and is gated to brand
|
||||
* admins. Base authorizes PER USER itself — the `tenants` collection's
|
||||
* `ListRule = "owner_iam_user = @request.auth.id"` and admin-only mutations are
|
||||
* enforced by Base against the forwarded user identity. So here we forward the
|
||||
* USER's own minted bearer (least privilege, tenant-scoped by Base), and the only
|
||||
* gate is "must be signed in" (resolveUser → 401). A non-admin simply sees their own
|
||||
* tenants and gets Base's 403 on a mutation — honest, not faked.
|
||||
*
|
||||
* Least privilege on the path too: only the Base DATA PLANE is proxied — the
|
||||
* collection schemas (read) and any collection's records (list/get/create/update/
|
||||
* delete), via `allowBaseSurface`. Base's admin/settings/backup/log surfaces 404,
|
||||
* so this stays a data-plane proxy, not a general Base tunnel. Base still authorizes
|
||||
* every read/write per-user and per-collection itself, so a non-admin sees only what
|
||||
* a collection's rules permit and gets Base's own honest 403 on a denied mutation.
|
||||
* (The tenants manager rides this same proxy — records/tenants is one such path.)
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowBaseSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** The Base control plane the proxied calls are forwarded to. */
|
||||
const BASE_URL = trim(process.env.BASE_DASHBOARD_URL ?? 'https://base.hanzo.ai')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/superbase/[...path]`, so the catch-all captures
|
||||
// ONLY the sub-path after `/v1/superbase/` (e.g. `collections/...`). Base serves its
|
||||
// data plane under `/v1/collections`, so re-root the upstream path at `v1/` — the same
|
||||
// path `allowBaseSurface` and `forwardWithUserBearer` see (`v1/collections/...`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: BASE_URL,
|
||||
path,
|
||||
allow: allowBaseSurface,
|
||||
unauthorizedMessage: 'Sign in to manage Base records.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -1,185 +0,0 @@
|
||||
/**
|
||||
* Per-user proxy to the LIVE trading-bot state — the Trading module's ONE
|
||||
* live-data transport (the DEPLOYED FLEET is read separately via the `/v1`
|
||||
* PaaS proxy). The browser calls console2's OWN origin (`/v1/trading/*`) with just
|
||||
* the session cookie; this handler resolves the caller, resolves the BRAND from the
|
||||
* request host, and reads the allowlisted upstreams server-side, per network that
|
||||
* brand may see. No cluster host or RPC method ever reaches the browser, and the
|
||||
* browser can never choose either.
|
||||
*
|
||||
* Security (mirrors app/nodes/[...path]/route.ts):
|
||||
* - Session-gated: an unauthenticated caller gets 401.
|
||||
* - Org/brand-aware: the network set is scoped by `nodeNetworksForBrand(brand)`,
|
||||
* brand resolved from the host — cloud.lux.cloud sees only Lux networks.
|
||||
* - Least privilege: the ONLY paths are `metrics` and `orderbook`; the ONLY
|
||||
* upstreams are the maker's :2112 /metrics scrape and the DEX read endpoint —
|
||||
* this is not a general RPC/HTTP tunnel.
|
||||
*
|
||||
* Two upstreams, one concern each:
|
||||
* 1. METRICS — GETs the in-cluster maker's Prometheus `:2112/metrics` for a
|
||||
* network and parses it to `MakerStatus`. The maker Service host per network is
|
||||
* env-configurable; unset/unreachable → an honest `not-reporting` status.
|
||||
* 2. ORDERBOOK — reads the DEX CLOB `dex_get_orders?market=<poolHex>` for a market
|
||||
* on a network. The DEX read host is env-configurable; the private D-Chain is
|
||||
* not publicly exposed, so an unreachable venue → an honest `not-reporting` book
|
||||
* (never fabricated bids/asks).
|
||||
*/
|
||||
import { type NextRequest, NextResponse } from 'next/server'
|
||||
|
||||
import { brandFromHost } from '~/config'
|
||||
import { resolveUser } from '~/lib/server/identity'
|
||||
import { nodeNetworksForBrand, type NodeNetworkId } from '~/lib/products/brand-scope'
|
||||
import { fetchWithTimeout } from '~/lib/server/fetch-timeout'
|
||||
import {
|
||||
parseMakerMetrics,
|
||||
normalizeBook,
|
||||
type MakerStatus,
|
||||
type OrderBook,
|
||||
type RawBookOrder,
|
||||
} from '~/lib/api/trading'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
|
||||
/**
|
||||
* The maker's Prometheus metrics host per network — the ONLY metrics endpoint this
|
||||
* proxy will scrape. These point at the in-cluster maker Service (`maker-coherence`,
|
||||
* port 2112) per luxd network namespace. Each is overridable per-deploy; an
|
||||
* unset/unreachable host yields an honest `not-reporting` status — never fake rows.
|
||||
* (Cross-cluster reach depends on network policy; when unreachable the console shows
|
||||
* the honest not-reporting state, exactly like the Nodes surface.)
|
||||
*/
|
||||
const MAKER_METRICS_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.MAKER_METRICS_MAINNET ?? 'http://maker-coherence.lux-mainnet.svc:2112'),
|
||||
'lux-testnet': trim(process.env.MAKER_METRICS_TESTNET ?? 'http://maker-coherence.lux-testnet.svc:2112'),
|
||||
'lux-devnet': trim(process.env.MAKER_METRICS_DEVNET ?? 'http://maker-coherence.lux-devnet.svc:2112'),
|
||||
}
|
||||
|
||||
/**
|
||||
* The DEX read host per network — the ONLY DEX endpoint this proxy will query for
|
||||
* the order book (`<host>/dex/dex_get_orders?market=<poolHex>`). The native D-Chain
|
||||
* CLOB is not publicly exposed, so these default to the in-cluster luxd router;
|
||||
* unreachable → an honest `not-reporting` book.
|
||||
*/
|
||||
const DEX_READ_HOSTS: Partial<Record<NodeNetworkId, string>> = {
|
||||
'lux-mainnet': trim(process.env.DEX_READ_MAINNET ?? 'http://luxd-0.luxd-headless.lux-mainnet.svc:9630/v1/bc/D'),
|
||||
'lux-testnet': trim(process.env.DEX_READ_TESTNET ?? 'http://luxd-0.luxd-headless.lux-testnet.svc:9640/v1/bc/D'),
|
||||
'lux-devnet': trim(process.env.DEX_READ_DEVNET ?? 'http://luxd-0.luxd-headless.lux-devnet.svc:9650/v1/bc/D'),
|
||||
}
|
||||
|
||||
/** Per-upstream probe timeout (ms). */
|
||||
const TIMEOUT_MS = Number(process.env.TRADING_TIMEOUT_MS ?? 8000)
|
||||
|
||||
/** Scrape ONE network's maker metrics → MakerStatus. Honest not-reporting on failure. */
|
||||
async function makerStatus(net: NodeNetworkId): Promise<MakerStatus> {
|
||||
const host = MAKER_METRICS_HOSTS[net]
|
||||
const base: MakerStatus = { status: 'not-reporting', symbols: [] }
|
||||
if (!host) {
|
||||
base.error = 'no metrics host configured for this network'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const res = await fetchWithTimeout(
|
||||
`${host}/metrics`,
|
||||
{ headers: { accept: 'text/plain' }, cache: 'no-store' },
|
||||
{ timeoutMs: TIMEOUT_MS },
|
||||
)
|
||||
if (!res.ok) {
|
||||
base.error = `metrics ${res.status}`
|
||||
return base
|
||||
}
|
||||
const text = await res.text()
|
||||
const parsed = parseMakerMetrics(text)
|
||||
return { status: 'reporting', ...parsed }
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
/** A single allowlisted DEX read call. `method` is fixed here (dex_get_orders). */
|
||||
async function dexGetOrders(host: string, poolHex: string): Promise<RawBookOrder[]> {
|
||||
const url = `${host}/dex/dex_get_orders?market=${encodeURIComponent(poolHex)}`
|
||||
const res = await fetchWithTimeout(url, { headers: { accept: 'application/json' }, cache: 'no-store' }, { timeoutMs: TIMEOUT_MS })
|
||||
if (!res.ok) throw new Error(`dex_get_orders ${res.status}`)
|
||||
const json = (await res.json()) as { orders?: RawBookOrder[] }
|
||||
return Array.isArray(json?.orders) ? json.orders : []
|
||||
}
|
||||
|
||||
/**
|
||||
* Read ONE market's order book. A `poolId` (32-byte hex) addresses the book
|
||||
* directly; a `symbol`/`base`/`quote` are echoed for display but the book is only
|
||||
* readable by poolId (the console does not compute keccak client- or server-side to
|
||||
* avoid an eth-crypto dep — the caller supplies the poolId, or the book is honestly
|
||||
* not-reporting). Honest not-reporting when the DEX is unreachable.
|
||||
*/
|
||||
async function orderbook(net: NodeNetworkId, params: URLSearchParams): Promise<OrderBook> {
|
||||
const symbol = params.get('symbol') ?? undefined
|
||||
const poolId = params.get('poolId') ?? undefined
|
||||
const base: OrderBook = { network: net, symbol, poolId, status: 'not-reporting', orders: [] }
|
||||
|
||||
const host = DEX_READ_HOSTS[net]
|
||||
if (!host) {
|
||||
base.error = 'no DEX read host configured for this network'
|
||||
return base
|
||||
}
|
||||
if (!poolId) {
|
||||
// No poolId → the book can't be addressed. Honest, never fabricated.
|
||||
base.error = 'order book is read by poolId; none supplied for this market'
|
||||
return base
|
||||
}
|
||||
try {
|
||||
const raw = await dexGetOrders(host, poolId)
|
||||
return { ...base, status: 'reporting', orders: normalizeBook(raw), error: undefined }
|
||||
} catch (e) {
|
||||
base.error = e instanceof Error ? e.message : String(e)
|
||||
return base
|
||||
}
|
||||
}
|
||||
|
||||
/** Resolve the brand's networks, optionally narrowed to `?network=` (still gated). */
|
||||
function scopedNetworks(req: NextRequest): NodeNetworkId[] {
|
||||
const brand = brandFromHost(req.headers.get('host'))
|
||||
let networks = nodeNetworksForBrand(brand)
|
||||
const only = req.nextUrl.searchParams.get('network') as NodeNetworkId | null
|
||||
if (only) networks = networks.filter((n) => n === only)
|
||||
return networks
|
||||
}
|
||||
|
||||
async function forward(req: NextRequest, path: string[]): Promise<NextResponse> {
|
||||
const route = path.join('/')
|
||||
|
||||
const user = await resolveUser(req)
|
||||
if (!user) {
|
||||
return NextResponse.json({ error: 'Sign in to view trading bots.' }, { status: 401 })
|
||||
}
|
||||
|
||||
if (route === 'metrics') {
|
||||
const networks = scopedNetworks(req)
|
||||
// A single-network scope is the common case (per-bot status); return the first
|
||||
// (the network the caller asked for), or the brand's first if none specified.
|
||||
const net = networks[0]
|
||||
if (!net) return NextResponse.json({ status: 'not-reporting', symbols: [], error: 'no network in scope' })
|
||||
const status = await makerStatus(net)
|
||||
return NextResponse.json(status)
|
||||
}
|
||||
|
||||
if (route === 'orderbook') {
|
||||
const networks = scopedNetworks(req)
|
||||
const net = networks[0]
|
||||
if (!net) {
|
||||
return NextResponse.json({ network: null, status: 'not-reporting', orders: [], error: 'no network in scope' })
|
||||
}
|
||||
const book = await orderbook(net, req.nextUrl.searchParams)
|
||||
return NextResponse.json(book)
|
||||
}
|
||||
|
||||
return NextResponse.json({ error: 'not found' }, { status: 404 })
|
||||
}
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return forward(req, (await ctx.params).path)
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
/**
|
||||
* Same-origin user-bearer proxy to Visor (vm.hanzo.ai) — the compute control plane
|
||||
* (regions / gpus / machines / instances). The browser calls this OWN-origin route
|
||||
* (`/v1/vm/...`) with just its session cookie; `forwardWithUserBearer` resolves the
|
||||
* user, mints a short-lived user-bound IAM token, and forwards to visor with that
|
||||
* Bearer. Visor mints org + user from the JWT claims, so compute is org-scoped
|
||||
* server-side — a caller only ever sees their own org's machines. No token reaches
|
||||
* the browser.
|
||||
*
|
||||
* NOT the `/paas` pattern: `/paas` forwards a god-mode control-plane SERVICE token
|
||||
* and is gated to brand admins. Compute is a TENANT action (any signed-in org user
|
||||
* may list/manage their own machines), so this is user-scoped (resolveUser), and
|
||||
* visor itself authorizes the forwarded user bearer.
|
||||
*
|
||||
* Least privilege on the path: only the visor `v1/*` surface is reachable
|
||||
* (`allowVisorSurface`); anything else 404s.
|
||||
*/
|
||||
import { type NextRequest } from 'next/server'
|
||||
|
||||
import { forwardWithUserBearer } from '~/lib/server/bearer-proxy'
|
||||
import { allowVisorSurface } from '~/lib/server/proxy-allow'
|
||||
|
||||
export const runtime = 'nodejs'
|
||||
|
||||
const trim = (s: string) => s.replace(/\/+$/, '')
|
||||
/** Visor (vm.hanzo.ai). In-cluster ClusterIP on :19000 (its Service has NO :80) — public
|
||||
* egress is CF-403'd. Override with VISOR_URL (the CR sets visor.hanzo.svc:19000).
|
||||
* `|| default` (not `??`): if the env is reconciled to an EMPTY string (observed drift on
|
||||
* the live pod), `??` would keep the blank and every machines/GPUs call would fail —
|
||||
* `|| default` treats blank/whitespace as unset so visor ALWAYS resolves. */
|
||||
const VISOR_URL = trim(process.env.VISOR_URL?.trim() || 'http://visor.hanzo.svc:19000')
|
||||
|
||||
type Ctx = { params: Promise<{ path: string[] }> }
|
||||
|
||||
function handle(req: NextRequest, ctx: Ctx) {
|
||||
return (async () => {
|
||||
// This handler lives under `app/v1/vm/[...path]`, so the catch-all captures ONLY the
|
||||
// sub-path after `/v1/vm/` (e.g. `regions`). Visor serves its compute surface under
|
||||
// `/v1/<x>`, so re-root the upstream path at `v1/` — the same path `allowVisorSurface`
|
||||
// and `forwardWithUserBearer` see (`v1/regions`).
|
||||
const path = `v1/${(await ctx.params).path.join('/')}`
|
||||
return forwardWithUserBearer(req, {
|
||||
target: VISOR_URL,
|
||||
path,
|
||||
allow: allowVisorSurface,
|
||||
// Org is authoritative (Bearer owner). Don't forward browser X-Project-Id/
|
||||
// X-Environment (unvalidated sub-scopes) — RED MEDIUM.
|
||||
unauthorizedMessage: 'Sign in to manage compute.',
|
||||
})
|
||||
})()
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function POST(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PUT(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function PATCH(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
export async function DELETE(req: NextRequest, ctx: Ctx) {
|
||||
return handle(req, ctx)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/styles/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/src/components",
|
||||
"utils": "@/src/utils/tailwind"
|
||||
}
|
||||
}
|
||||
Vendored
-9
@@ -1,9 +0,0 @@
|
||||
// TypeScript 7 reports TS2882 for a side-effect import with no type
|
||||
// declaration ("Cannot find module or type declarations for side-effect import
|
||||
// of './globals.css'"). TS 5.x let these pass silently.
|
||||
//
|
||||
// Next.js resolves stylesheet imports through its own loader pipeline, so these
|
||||
// specifiers never reach the TypeScript module resolver at build time. The
|
||||
// ambient declaration exists to tell the checker they are legitimate, not to
|
||||
// give them a shape — hence no exported members.
|
||||
declare module '*.css';
|
||||
@@ -1,5 +0,0 @@
|
||||
// Side-effect CSS imports (`import './globals.css'`, `import '@hanzogui/core/reset.css'`).
|
||||
// The bundler owns them; TypeScript only needs to know the specifier resolves.
|
||||
// TS7 (tsgo) errors on an unresolvable side-effect import (TS2882) where tsc stayed
|
||||
// silent, so the declaration lives here — one place, every stylesheet.
|
||||
declare module '*.css'
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 70 KiB |
@@ -0,0 +1,41 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
langfuse-server:
|
||||
build:
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- NEXT_PUBLIC_SIGN_UP_DISABLED=${NEXT_PUBLIC_SIGN_UP_DISABLED:-false}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
restart: always
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
driver: local
|
||||
@@ -0,0 +1,19 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
command: ["postgres", "-c", "log_statement=all"]
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
driver: local
|
||||
@@ -0,0 +1,34 @@
|
||||
version: "3.5"
|
||||
|
||||
services:
|
||||
langfuse-server:
|
||||
image: ghcr.io/langfuse/langfuse:latest
|
||||
depends_on:
|
||||
- db
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
- NODE_ENV=production
|
||||
- DATABASE_URL=postgresql://postgres:postgres@db:5432/postgres
|
||||
- NEXTAUTH_SECRET=mysecret
|
||||
- SALT=mysalt
|
||||
- NEXTAUTH_URL=http:localhost:3000
|
||||
- TELEMETRY_ENABLED=${TELEMETRY_ENABLED:-true}
|
||||
- NEXT_PUBLIC_SIGN_UP_DISABLED=${NEXT_PUBLIC_SIGN_UP_DISABLED:-false}
|
||||
- LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES=${LANGFUSE_ENABLE_EXPERIMENTAL_FEATURES:-false}
|
||||
|
||||
db:
|
||||
image: postgres
|
||||
restart: always
|
||||
environment:
|
||||
- POSTGRES_USER=postgres
|
||||
- POSTGRES_PASSWORD=postgres
|
||||
- POSTGRES_DB=postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
volumes:
|
||||
- database_data:/var/lib/postgresql/data
|
||||
|
||||
volumes:
|
||||
database_data:
|
||||
driver: local
|
||||
@@ -1,75 +0,0 @@
|
||||
# Unified `/v1` backend endpoints
|
||||
|
||||
The console talks to the unified Hanzo Cloud backend (`hanzoai/cloud`).
|
||||
Base URL: `${NEXT_PUBLIC_CLOUD_URL}/v1`. All requests send cookie
|
||||
credentials; responses are the envelope `{ status, msg, data, total }` (`total`
|
||||
is the row count on list endpoints; the legacy `data2` count is still accepted
|
||||
as a fallback until every emitter finishes the rename).
|
||||
|
||||
Client modules live in `src/lib/api/`.
|
||||
|
||||
## Account / session — `AccountApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `current()` | `GET /get-account` |
|
||||
| `signin(code, state)` | `POST /signin?code&state` |
|
||||
| `signout()` | `POST /signout` |
|
||||
|
||||
## Providers — `ProviderApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal()` | `GET /get-global-providers` |
|
||||
| `list({ owner, store, p, pageSize, … })` | `GET /get-providers` |
|
||||
| `get(owner, name)` | `GET /get-provider?id=owner/name` |
|
||||
| `add(p)` | `POST /add-provider` |
|
||||
| `update(owner, name, p)` | `POST /update-provider?id=owner/name` |
|
||||
| `remove(p)` | `POST /delete-provider` |
|
||||
| `refreshMcpTools(p)` | `POST /refresh-mcp-tools` |
|
||||
|
||||
## Model routes — `ModelRouteApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `list({ owner, … })` | `GET /get-model-routes` |
|
||||
| `get(owner, modelName)` | `GET /get-model-route?owner&modelName` |
|
||||
| `add(r)` | `POST /add-model-route` |
|
||||
| `update(owner, modelName, r)` | `POST /update-model-route?owner&modelName` |
|
||||
| `remove(r)` | `POST /delete-model-route` |
|
||||
|
||||
## Applications — `ApplicationApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `list({ owner, … })` | `GET /get-applications` |
|
||||
| `get(owner, name)` | `GET /get-application?id=owner/name` |
|
||||
| `add(a)` | `POST /add-application` |
|
||||
| `update(owner, name, a)` | `POST /update-application?id=owner/name` |
|
||||
| `remove(a)` | `POST /delete-application` |
|
||||
| `deploy(a)` | `POST /deploy-application?id=owner/name` |
|
||||
| `undeploy(owner, name)` | `POST /undeploy-application?id=owner/name` |
|
||||
|
||||
## Stores — `StoreApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal()` | `GET /get-global-stores` |
|
||||
| `list(owner)` | `GET /get-stores?owner` |
|
||||
| `get(owner, name)` | `GET /get-store?id=owner/name` |
|
||||
| `names(owner)` | `GET /get-store-names?owner` |
|
||||
| `add(s)` | `POST /add-store` |
|
||||
| `update(owner, name, s)` | `POST /update-store?id=owner/name` |
|
||||
| `remove(s)` | `POST /delete-store` |
|
||||
| `refreshVectors(s)` | `POST /refresh-store-vectors` |
|
||||
|
||||
## Chat — `ChatApi`
|
||||
|
||||
| Method | Endpoint |
|
||||
| --- | --- |
|
||||
| `listGlobal({ … })` | `GET /get-global-chats` |
|
||||
| `list({ user, store, selectedUser, … })` | `GET /get-chats` |
|
||||
| `get(owner, name)` | `GET /get-chat?id=owner/name` |
|
||||
| `add(c)` | `POST /add-chat` |
|
||||
| `update(owner, name, c)` | `POST /update-chat?id=owner/name` |
|
||||
| `remove(c)` | `POST /delete-chat` |
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* Fixture-server gate for the render specs.
|
||||
*
|
||||
* Several render specs (ai-economics, budgets-responsive, gpus-*, provider-billing,
|
||||
* entitlement-sidebar, interactive-training, blank-audit, probe-o11y) assert data
|
||||
* that exists ONLY in a LOCAL fixture server — they default `BASE_URL` to
|
||||
* `http://localhost:4000` and seed exact numbers ("$26k credit / 62% margin /
|
||||
* fable-5 75%"). Run against live prod that server isn't there (ECONNREFUSED) and
|
||||
* the numbers are meaningless anyway, so the spec has nothing real to assert.
|
||||
*
|
||||
* This is NOT a blind skip: it's a reachability gate. Point `BASE_URL` at a running
|
||||
* fixture (`npm run dev` on :4000, or a prod origin that actually serves the seeded
|
||||
* surface) and the spec runs for real. Call `requireFixtureServer()` once at module
|
||||
* top level in a fixture spec; its `beforeAll` probes the target and skips the whole
|
||||
* file only when it's genuinely unreachable.
|
||||
*/
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
/** The origin a fixture render spec targets (its own default is the local dev server). */
|
||||
export const FIXTURE_BASE = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
/** Skip the whole spec file when its fixture server can't be reached. */
|
||||
export function requireFixtureServer(base: string = FIXTURE_BASE): void {
|
||||
test.beforeAll(async ({ request }) => {
|
||||
const reachable = await request
|
||||
.get(base, { timeout: 4000 })
|
||||
.then((r) => r.status() < 500)
|
||||
.catch(() => false)
|
||||
test.skip(
|
||||
!reachable,
|
||||
`fixture server ${base} not reachable — point BASE_URL at a running fixture to exercise these render specs`,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Session priming for render specs — the ONE recipe for the IAM-PKCE auth model.
|
||||
*
|
||||
* Identity is a client-held @hanzo/iam token now (there is NO /auth/session
|
||||
* endpoint): `AccountApi.session()` reads the sessionStorage access token and
|
||||
* projects the OIDC userinfo claims. So a spec authenticates by (1) seeding a
|
||||
* forged unsigned JWT + expiry into sessionStorage (the client only
|
||||
* base64-decodes the payload — no signature check in the browser), and
|
||||
* (2) serving the claims from a mocked userinfo endpoint (discovery is left to
|
||||
* 404 — the SDK synthesizes its endpoints). Registered AFTER a spec's own
|
||||
* catch-all route, these handlers win (Playwright matches routes in reverse
|
||||
* registration order), so legacy `/auth/session` mock branches are simply dead.
|
||||
*
|
||||
* Also seeds the first-run gates that otherwise block interaction: the guided
|
||||
* TOUR overlays the whole page at z=100000 (clicks hang on actionability), the
|
||||
* onboarding wizard is a takeover, and Scope parks on the picker.
|
||||
*
|
||||
* Usage (after the spec registers its own catch-all page.route):
|
||||
* await primeSession(page) // hanzo/z admin (default)
|
||||
* await primeSession(page, { owner: 'maxpower', name: 'dave', isAdmin: false })
|
||||
*/
|
||||
import type { Page, Route } from '@playwright/test'
|
||||
|
||||
export type SessionClaims = {
|
||||
owner: string
|
||||
name: string
|
||||
email?: string
|
||||
displayName?: string
|
||||
isAdmin?: boolean
|
||||
/** The IAM user's property bag — where `hanzo.preferences` rides as a SNAPSHOT. */
|
||||
properties?: Record<string, string>
|
||||
/** When the token was minted (`iat`, seconds). Defaults to now; set it in the past
|
||||
* to reproduce the production case where the snapshot predates a later write. */
|
||||
issuedAt?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* base64URL — what a JWT segment actually is. Plain base64 was close enough while the
|
||||
* payloads were tiny, but `+` and `/` appear as soon as one grows (a `properties` bag
|
||||
* is enough), and a strict decoder rejects the token outright: the SDK reports signed
|
||||
* out and the app sits on its loader forever.
|
||||
*/
|
||||
const b64 = (o: object): string =>
|
||||
Buffer.from(JSON.stringify(o)).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
|
||||
/** The default identity render specs run as — a hanzo-org admin. */
|
||||
export const DEFAULT_CLAIMS: Required<Omit<SessionClaims, 'properties' | 'issuedAt'>> = {
|
||||
owner: 'hanzo',
|
||||
name: 'z',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isAdmin: true,
|
||||
}
|
||||
|
||||
/** An unsigned JWT whose payload carries the claims, an `iat` and a far-future `exp`. */
|
||||
export function forgeToken(claims: SessionClaims): string {
|
||||
const iat = claims.issuedAt ?? Math.floor(Date.now() / 1000)
|
||||
const payload = { ...claims, sub: `${claims.owner}/${claims.name}`, iat, exp: iat + 86_400 }
|
||||
return `${b64({ alg: 'none' })}.${b64(payload)}.x`
|
||||
}
|
||||
|
||||
/** Seed tokens + gate keys and register the IAM endpoint mocks. */
|
||||
export async function primeSession(page: Page, overrides: Partial<SessionClaims> = {}): Promise<void> {
|
||||
const claims: SessionClaims = { ...DEFAULT_CLAIMS, ...overrides }
|
||||
await page.addInitScript(
|
||||
({ org, token }: { org: string; token: string }) => {
|
||||
try {
|
||||
// localStorage, not sessionStorage: the `@hanzo/iam` token store is shared
|
||||
// across tabs (that IS the session), so seeding a per-tab area would leave
|
||||
// the SDK reading an empty store and every primed spec signed out.
|
||||
localStorage.setItem('hanzo_iam_access_token', token)
|
||||
localStorage.setItem('hanzo_iam_expires_at', String(Date.now() + 3600_000))
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1')
|
||||
localStorage.setItem(`hz_onboarding_done:${org}`, '1')
|
||||
localStorage.setItem(`hz_tour_seen:v1:${org}`, '1')
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
},
|
||||
{ org: claims.owner, token: forgeToken(claims) },
|
||||
)
|
||||
// Registered after the spec's catch-all → these win for the IAM endpoints.
|
||||
await page.route('**/.well-known/**', (route: Route) => route.fulfill({ status: 404, body: '' }))
|
||||
await page.route('**/userinfo*', (route: Route) =>
|
||||
route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ ...claims, sub: `${claims.owner}/${claims.name}` }),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
/**
|
||||
* The ONE account control, at the foot of the rail — and the ONE org switch.
|
||||
*
|
||||
* There used to be three account-ish menus: an org switcher at the top of the
|
||||
* sidebar, an account popover at the bottom, and a third in the mobile drawer,
|
||||
* with four ways to sign out between them. They became one control that answered
|
||||
* BOTH "who am I" and "where am I".
|
||||
*
|
||||
* They have now been split again, but by QUESTION rather than by accident: the
|
||||
* account control at the foot answers who you are (identity, team, personal
|
||||
* settings, balance, the way out), and `ContextSwitcher` at the TOP-LEFT answers
|
||||
* where you are (organization + project, together, beside the tenant's mark).
|
||||
* So the cross-tenant reach is asserted against the context switcher below, and
|
||||
* the account menu is asserted to no longer offer a tenant at all.
|
||||
*
|
||||
* Everything is asserted on computed style and geometry. The failure this guards
|
||||
* against is a menu that is present in the DOM and unreadable — a library that
|
||||
* paints with utility class names renders exactly that in this app, because
|
||||
* Tailwind never scanned node_modules. An `expect(locator).toBeVisible()` would
|
||||
* have passed on the broken build.
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import { primeSession } from './_session'
|
||||
|
||||
/** The cross-tenant org list an admin console reaches — none of them memberships. */
|
||||
const ORGS = [
|
||||
{ owner: 'admin', name: 'hanzo', displayName: 'Hanzo' },
|
||||
{ owner: 'admin', name: 'maxpower', displayName: 'Max Power' },
|
||||
{ owner: 'admin', name: 'acme-industrial', displayName: 'Acme Industrial' },
|
||||
]
|
||||
|
||||
/** Every org-scoped request the page made, with the scope it carried. */
|
||||
type Scoped = { url: string; org: string | null }
|
||||
|
||||
/**
|
||||
* The account trigger in the persistent rail.
|
||||
*
|
||||
* The shell mounts the SAME control three times — the rail, the collapsed-rail
|
||||
* hover flyout, and the phone drawer — because `SidebarNav` is one component with
|
||||
* three mounts. All three stay in the DOM (the flyout and drawer are offset, not
|
||||
* unmounted), which predates this change and belongs to the shell lane; the first
|
||||
* in document order is the persistent rail, and every geometry assertion below
|
||||
* checks it really is the one on screen.
|
||||
*/
|
||||
const accountTrigger = (page: Page) => page.getByTestId('nav-user').first()
|
||||
|
||||
/** The trigger inside the phone's account sheet — the last mount in the document. */
|
||||
const drawerTrigger = (page: Page) => page.getByTestId('nav-user').last()
|
||||
|
||||
/** The org + project control at the top-left — the only thing that switches tenant. */
|
||||
const contextTrigger = (page: Page) => page.getByTestId('switcher-context').first()
|
||||
|
||||
async function mountConsole(page: Page, seen: Scoped[]) {
|
||||
// The standalone console reaches the cross-tenant list through its own gated
|
||||
// `/admin/iam` proxy; the go:embed build reaches cloud's `/v1/iam` directly.
|
||||
// Both are covered so the spec does not silently pass on the wrong one.
|
||||
await page.route(/\/(v1|admin\/iam)\//, async (route) => {
|
||||
const url = route.request().url()
|
||||
seen.push({ url, org: route.request().headers()['x-org-id'] ?? null })
|
||||
|
||||
if (url.includes('get-organizations')) {
|
||||
const query = new URL(url).searchParams.get('value') ?? ''
|
||||
const rows = ORGS.filter((o) => o.displayName.toLowerCase().includes(query.toLowerCase()))
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: rows, data2: rows.length }) })
|
||||
}
|
||||
if (url.includes('billing/balance')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ spendableCents: 4250 }) })
|
||||
}
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', data: [] }) })
|
||||
})
|
||||
// The reserved `admin` org IS the super admin — the only identity that reaches
|
||||
// every tenant, which is what an admin console is for.
|
||||
// Record every write to the console's org scope. The switch reloads the page and
|
||||
// the harness re-seeds the scope on load, so the write is observed as it happens.
|
||||
await page.addInitScript(() => {
|
||||
const setItem = Storage.prototype.setItem
|
||||
Storage.prototype.setItem = function (key: string, value: string) {
|
||||
if (key === 'hanzo.console.org') {
|
||||
const log = JSON.parse(sessionStorage.getItem('spec.scope.writes') ?? '[]') as string[]
|
||||
log.push(`${key}=${value}`)
|
||||
setItem.call(sessionStorage, 'spec.scope.writes', JSON.stringify(log))
|
||||
}
|
||||
return setItem.call(this, key, value)
|
||||
}
|
||||
})
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', displayName: 'Z Admin' })
|
||||
await page.goto('/')
|
||||
await page.waitForSelector('[data-testid=nav-user]', { state: 'attached', timeout: 30_000 })
|
||||
}
|
||||
|
||||
const px = (v: string) => Number.parseFloat(v)
|
||||
const rgb = (v: string) => (v.match(/\d+(\.\d+)?/g) ?? []).map(Number)
|
||||
const luminance = ([r, g, b]: number[]) => {
|
||||
const f = (c: number) => { const n = c / 255; return n <= 0.03928 ? n / 12.92 : ((n + 0.055) / 1.055) ** 2.4 }
|
||||
return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b)
|
||||
}
|
||||
const contrast = (a: number[], b: number[]) => {
|
||||
const [hi, lo] = [luminance(a), luminance(b)].sort((m, n) => n - m)
|
||||
return (hi + 0.05) / (lo + 0.05)
|
||||
}
|
||||
|
||||
test.describe('account control', () => {
|
||||
test('sits at the foot of the rail and paints in a shell with no Tailwind', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
|
||||
// The control is at the BOTTOM — below the middle of the sidebar, not above it.
|
||||
const trigger = accountTrigger(page)
|
||||
const box = (await trigger.boundingBox())!
|
||||
const viewport = page.viewportSize()!
|
||||
expect(box.y).toBeGreaterThan(viewport.height / 2)
|
||||
expect(box.x).toBeLessThan(300)
|
||||
|
||||
// Nothing else claims to switch orgs: the top-of-rail switcher is gone.
|
||||
await expect(page.getByLabel('Switch organization')).toHaveCount(0)
|
||||
|
||||
await trigger.click()
|
||||
const menu = page.locator('[role=menu]')
|
||||
await menu.waitFor()
|
||||
|
||||
const paint = await menu.evaluate((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
const r = el.getBoundingClientRect()
|
||||
return {
|
||||
bg: s.backgroundColor,
|
||||
radius: s.borderTopLeftRadius,
|
||||
borderWidth: s.borderTopWidth,
|
||||
z: s.zIndex,
|
||||
font: s.fontFamily,
|
||||
rect: { x: r.x, y: r.y, w: r.width, h: r.height },
|
||||
}
|
||||
})
|
||||
|
||||
// It PAINTS — an opaque surface, not a transparent stack of divs.
|
||||
expect(paint.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
expect(px(paint.radius)).toBeGreaterThanOrEqual(8)
|
||||
expect(px(paint.borderWidth)).toBeGreaterThanOrEqual(1)
|
||||
// …in the app's own typeface, not a system fallback.
|
||||
expect(paint.font).toMatch(/Geist/i)
|
||||
|
||||
// It is FULLY on screen and above the shell.
|
||||
expect(paint.rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(paint.rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(paint.rect.x + paint.rect.w).toBeLessThanOrEqual(viewport.width + 1)
|
||||
expect(paint.rect.y + paint.rect.h).toBeLessThanOrEqual(viewport.height + 1)
|
||||
// Nothing of the shell is painted over it.
|
||||
const onTop = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
const hit = document.elementFromPoint(r.x + r.width / 2, r.y + 12)
|
||||
return el.contains(hit)
|
||||
})
|
||||
expect(onTop).toBe(true)
|
||||
|
||||
// Rows are padded, tall enough to hit, and readable.
|
||||
const rows = await menu.locator('.hz-iam-row').evaluateAll((els) =>
|
||||
els.map((el) => {
|
||||
const s = getComputedStyle(el)
|
||||
return { pl: s.paddingLeft, h: el.getBoundingClientRect().height, color: s.color, text: (el.textContent ?? '').trim() }
|
||||
}),
|
||||
)
|
||||
expect(rows.length).toBeGreaterThanOrEqual(5)
|
||||
for (const row of rows) {
|
||||
expect(px(row.pl), `"${row.text}" padding`).toBeGreaterThanOrEqual(8)
|
||||
expect(row.h, `"${row.text}" height`).toBeGreaterThanOrEqual(24)
|
||||
expect(contrast(rgb(row.color), rgb(paint.bg)), `"${row.text}" contrast`).toBeGreaterThanOrEqual(4.5)
|
||||
}
|
||||
|
||||
// Hover is a real state — the switch-that-rendered-identical class of bug.
|
||||
const first = menu.locator('.hz-iam-row').first()
|
||||
const atRest = await first.evaluate((el) => getComputedStyle(el).backgroundColor)
|
||||
await first.hover()
|
||||
expect(await first.evaluate((el) => getComputedStyle(el).backgroundColor)).not.toBe(atRest)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-desktop.png', animations: 'disabled' })
|
||||
})
|
||||
|
||||
test('nothing in it shouts', async ({ page }) => {
|
||||
await mountConsole(page, [])
|
||||
await accountTrigger(page).click()
|
||||
await page.locator('[role=menu]').waitFor()
|
||||
|
||||
const shouting = await page.locator('[role=menu]').evaluate((el) =>
|
||||
[...el.querySelectorAll('*')].filter((n) => getComputedStyle(n).textTransform === 'uppercase').map((n) => n.textContent ?? ''),
|
||||
)
|
||||
expect(shouting).toEqual([])
|
||||
|
||||
const typedInCaps = await page.locator('[role=menu]').evaluate((el) =>
|
||||
[...el.querySelectorAll('*')]
|
||||
.map((n) => (n.children.length ? '' : (n.textContent ?? '').trim()))
|
||||
.filter((t) => /^[A-Z][A-Z0-9 &/·—-]{3,}$/.test(t)),
|
||||
)
|
||||
expect(typedInCaps).toEqual([])
|
||||
})
|
||||
|
||||
test('the context switcher reaches a tenant the caller is not a member of', async ({ page }) => {
|
||||
const seen: Scoped[] = []
|
||||
await mountConsole(page, seen)
|
||||
// Tenancy is the TOP-LEFT control's job now, not the account menu's.
|
||||
await contextTrigger(page).click()
|
||||
|
||||
// Acme is nobody's membership — it exists only in the cross-tenant list an
|
||||
// admin may search. A memberships-only switcher could not offer it at all.
|
||||
await page.getByLabel('Find an organization').fill('acme')
|
||||
// `radiogroup`/`radio`, not `listbox`/`option`: @hanzo/gui's `role` union is
|
||||
// React Native's a11y set, which carries `option` but NOT `listbox`.
|
||||
const orgList = page.getByRole('radiogroup', { name: 'Organizations' })
|
||||
const acme = orgList.getByRole('radio', { name: 'Acme Industrial' })
|
||||
await acme.waitFor()
|
||||
// Scoped to the ORG group — the same popover also lists projects, and a bare
|
||||
// getByRole('radio') would silently count those too.
|
||||
await expect(orgList.getByRole('radio')).toHaveCount(1)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-find-org.png', animations: 'disabled' })
|
||||
|
||||
// MONEY PATH. Entering a tenant must go through the console's OWN org scope —
|
||||
// the single seam that persists `hanzo.console.org`, reloads, and is read back
|
||||
// as `X-Org-Id` on every call. A switcher that minted its own would bypass the
|
||||
// scoping and its billing attribution without anything visibly breaking, so the
|
||||
// write itself is what is asserted. (The scope key is recorded through a wrapped
|
||||
// setter because the reload re-runs the harness's own seeding.)
|
||||
await acme.click()
|
||||
await page.waitForFunction(
|
||||
() => sessionStorage.getItem('spec.scope.writes')?.includes('acme-industrial') ?? false,
|
||||
)
|
||||
const writes: string[] = JSON.parse(
|
||||
(await page.evaluate(() => sessionStorage.getItem('spec.scope.writes'))) ?? '[]',
|
||||
)
|
||||
expect(writes).toContain('hanzo.console.org=acme-industrial')
|
||||
|
||||
// And every scoped call the page made before that carried the admin's own
|
||||
// scope — the menu never issued a request under someone else's tenant.
|
||||
for (const call of seen.filter((s) => s.org !== null)) expect(call.org).toBe('admin')
|
||||
})
|
||||
|
||||
test('the same control is the account surface on a phone', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 })
|
||||
await mountConsole(page, [])
|
||||
|
||||
// On a phone the rail is a drawer, so the account control lives in the
|
||||
// right-hand account sheet — the SAME component, not a phone-only copy.
|
||||
await page.getByLabel('Account and settings').click()
|
||||
await drawerTrigger(page).click()
|
||||
const menu = page.locator('[role=menu]')
|
||||
await menu.waitFor()
|
||||
|
||||
const rect = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return { x: r.x, y: r.y, w: r.width, h: r.height, bg: getComputedStyle(el).backgroundColor }
|
||||
})
|
||||
expect(rect.bg).not.toBe('rgba(0, 0, 0, 0)')
|
||||
// …and it paints OVER the sheet it was opened from. A sheet pinned at a
|
||||
// literal 1000 swallowed the menu whole: present, measurable, unclickable.
|
||||
const onTop = await menu.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect()
|
||||
return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + 12))
|
||||
})
|
||||
expect(onTop).toBe(true)
|
||||
expect(rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.x + rect.w).toBeLessThanOrEqual(391)
|
||||
expect(rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.y + rect.h).toBeLessThanOrEqual(845)
|
||||
|
||||
// The page itself never scrolls sideways to accommodate it.
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth)
|
||||
expect(overflow).toBeLessThanOrEqual(0)
|
||||
|
||||
await page.screenshot({ path: 'e2e-shots/account-menu-mobile.png', animations: 'disabled' })
|
||||
})
|
||||
})
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Catalog & Pricing admin editor — render + edit-persists proof (increment 2).
|
||||
*
|
||||
* Drives the REAL CatalogModule (client + form + metadata editor) against a
|
||||
* mock of commerce's `/v1/catalog/*` CRUD, seeded with the REAL 17 infra tiers
|
||||
* increment 1 seeds (11 cloud + 3 gpu + 3 datastore). The mock is a live
|
||||
* in-memory store: a PUT mutates it, so a save → re-fetch shows the NEW price —
|
||||
* the exact "edit persists" loop the module drives against commerce (whose CRUD
|
||||
* contract is itself proven by commerce's own passing api/catalog handler tests).
|
||||
*
|
||||
* Proves: the table renders every real tier with its price + spec; opening a
|
||||
* cloud tier shows the editable form (name/price/published/category/metadata);
|
||||
* changing the price + Save issues `PUT /v1/catalog/entries/<slug>` with the new
|
||||
* priceCents; and the table then reflects the persisted price. Screenshots the
|
||||
* table + the open edit form (admin-catalog-editor.png).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-catalog-editor
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** The REAL 17 infra tiers commerce seeds (models/catalogentry/seed/infra-tiers.json),
|
||||
* in the raw `catalog-entry` shape the admin GET /v1/catalog/entries returns. */
|
||||
function seedEntries(): Record<string, unknown>[] {
|
||||
const cloud = (
|
||||
[
|
||||
['cloud-starter', 'Starter', 'Get started for free. Perfect for side projects, bots, and learning.', 500, { id: 'starter', vcpus: 1, memoryGB: 1, diskGB: 20, cpuType: 'shared', maxVMs: 1, priceMonthly: 5, features: ['1 VM', '1 vCPU', '1 GB RAM', '20 GB SSD'], freeTier: true }],
|
||||
['cloud-builder', 'Builder', 'For developers shipping real products.', 1000, { id: 'builder', vcpus: 2, memoryGB: 2, diskGB: 40, cpuType: 'shared', maxVMs: 5, priceMonthly: 10, features: ['Up to 5 VMs', '2 vCPU'] }],
|
||||
['cloud-dev', 'Dev', 'The sweet spot. Full dev environment with room to grow.', 1500, { id: 'dev', vcpus: 2, memoryGB: 8, diskGB: 25, cpuType: 'shared', maxVMs: 25, priceMonthly: 15, features: ['Up to 25 VMs', '2 vCPU', '8 GB RAM'], popular: true }],
|
||||
['cloud-pro', 'Pro', 'Dedicated CPU. Zero noisy neighbors.', 2500, { id: 'pro', vcpus: 2, memoryGB: 8, diskGB: 80, cpuType: 'dedicated', maxVMs: 25, priceMonthly: 25, features: ['2 dedicated vCPU'] }],
|
||||
['cloud-turbo', 'Turbo', '4x the power. Browser automation, CI/CD, and heavy workloads.', 3900, { id: 'turbo', vcpus: 4, memoryGB: 16, diskGB: 160, cpuType: 'shared', maxVMs: 25, priceMonthly: 39, features: ['4 vCPU', '16 GB RAM'] }],
|
||||
['cloud-turbo-dedicated', 'Turbo Dedicated', 'All the power of Turbo with dedicated CPU cores.', 4900, { id: 'turbo-dedicated', vcpus: 4, memoryGB: 16, diskGB: 160, cpuType: 'dedicated', maxVMs: 25, priceMonthly: 49, features: ['4 dedicated vCPU'] }],
|
||||
['cloud-business', 'Business', 'Team-scale compute.', 21900, { id: 'business', vcpus: 8, memoryGB: 32, diskGB: 240, cpuType: 'dedicated', maxVMs: 50, priceMonthly: 219, features: ['8 dedicated vCPU'] }],
|
||||
['cloud-enterprise', 'Enterprise', 'Mission-critical infrastructure.', 42900, { id: 'enterprise', vcpus: 16, memoryGB: 64, diskGB: 360, cpuType: 'dedicated', maxVMs: 100, priceMonthly: 429, features: ['16 dedicated vCPU'] }],
|
||||
['cloud-scale', 'Scale', 'Platform-scale compute.', 84900, { id: 'scale', vcpus: 32, memoryGB: 128, diskGB: 600, cpuType: 'dedicated', maxVMs: 250, priceMonthly: 849, features: ['32 dedicated vCPU'] }],
|
||||
['cloud-mega', 'Mega', 'Maximum single-node power.', 129900, { id: 'mega', vcpus: 48, memoryGB: 192, diskGB: 960, cpuType: 'dedicated', maxVMs: 500, priceMonthly: 1299, features: ['48 dedicated vCPU'] }],
|
||||
['cloud-ultra', 'Ultra', 'Extreme compute. Multi-node clusters.', 399900, { id: 'ultra', vcpus: 96, memoryGB: 384, diskGB: 1920, cpuType: 'dedicated', maxVMs: 1000, priceMonthly: 3999, features: ['96 dedicated vCPU'] }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'cloud', description, priceCents, currency: 'usd', order: i, published: true, metadata }))
|
||||
|
||||
const gpu = (
|
||||
[
|
||||
['gpu-standard', 'GPU Standard', '1x H100 · 80 GB VRAM', 348, { gpu: '1x H100', vram: '80 GB', price: 3.48 }],
|
||||
['gpu-pro', 'GPU Pro', '2x H100 · 160 GB VRAM', 696, { gpu: '2x H100', vram: '160 GB', price: 6.96 }],
|
||||
['gpu-ultra', 'GPU Ultra', '4x H100 · 320 GB VRAM', 1392, { gpu: '4x H100', vram: '320 GB', price: 13.92 }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'gpu', description, priceCents, currency: 'usd', order: 11 + i, published: true, metadata }))
|
||||
|
||||
const datastore = (
|
||||
[
|
||||
['datastore-basic', 'Basic', 'For teams getting started with analytics', 6652, { id: 'basic', replicas: 1, ramGiB: 8, vcpu: 2, storageGB: 1000, priceMonthly: 66.52, priceHourly: 0.0922, support: { level: 'standard' }, features: ['async_inserts', 'http_api'] }],
|
||||
['datastore-scale', 'Scale', 'For production workloads with high availability', 49938, { id: 'scale', replicas: 2, ramGiB: 8, vcpu: 2, storageGB: null, priceMonthly: 499.38, priceHourly: 0.6936, support: { level: 'priority' }, popular: true }],
|
||||
['datastore-enterprise', 'Enterprise', 'For mission-critical deployments at scale', 266940, { id: 'enterprise', replicas: 2, ramGiB: 32, vcpu: 8, storageGB: 5000, priceMonthly: 2669.4, priceHourly: 3.7075, support: { level: 'enterprise', sla: true }, contactSales: true }],
|
||||
] as const
|
||||
).map(([slug, name, description, priceCents, metadata], i) => ({ slug, name, category: 'datastore', description, priceCents, currency: 'usd', order: 14 + i, published: true, metadata }))
|
||||
|
||||
return [...cloud, ...gpu, ...datastore]
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
test('catalog editor renders the infra tiers, edits a price, and persists', async ({ page }) => {
|
||||
// A live in-memory catalog — GET returns it, PUT mutates it (the persistence loop).
|
||||
const store = new Map(seedEntries().map((e) => [e.slug as string, e]))
|
||||
// A holder (not a bare `let`) so TS keeps the union type across the route closure.
|
||||
const cap: { put: { slug: string; body: Record<string, unknown> } | null } = { put: null }
|
||||
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
// Catalog admin CRUD (bare JSON, not the casibase envelope).
|
||||
if (path === '/v1/catalog/entries' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([...store.values()]) })
|
||||
}
|
||||
const m = path.match(/^\/v1\/catalog\/entries\/(.+)$/)
|
||||
if (m && req.method() === 'PUT') {
|
||||
const slug = decodeURIComponent(m[1])
|
||||
const body = JSON.parse(req.postData() || '{}') as Record<string, unknown>
|
||||
cap.put = { slug, body }
|
||||
const updated = { ...(store.get(slug) ?? {}), ...body, slug }
|
||||
store.set(slug, updated)
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(updated) })
|
||||
}
|
||||
|
||||
// Everything else same-origin API → an honest empty envelope (the shell's
|
||||
// non-critical calls); let real assets/documents through.
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
|
||||
// A global admin (reserved `admin` org) — the catalog module is admin-gated.
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
|
||||
await page.goto(`${BASE_URL}/catalog`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The table renders every real tier.
|
||||
await expect(page.getByText('Catalog & Pricing').first()).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.getByText('cloud-dev').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('gpu-standard').first()).toBeVisible()
|
||||
await expect(page.getByText('datastore-enterprise').first()).toBeVisible()
|
||||
// The cloud-dev price is the seeded $15.00 before the edit.
|
||||
await expect(page.getByText('$15.00').first()).toBeVisible()
|
||||
|
||||
// Open the cloud-dev row → the edit form.
|
||||
await page.getByText('cloud-dev').first().click()
|
||||
await expect(page.getByText('Edit Dev').first()).toBeVisible({ timeout: 10_000 })
|
||||
// The spec (metadata) editor shows the real cloud scalars.
|
||||
await expect(page.getByText('Spec (metadata)').first()).toBeVisible()
|
||||
|
||||
// Screenshot the editor (table behind + the open edit form).
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-catalog-editor.png'), fullPage: false })
|
||||
|
||||
// Edit the price: $15 → $18. The price field is uniquely identified by its
|
||||
// placeholder "15" (the metadata priceMonthly value input shows placeholder "value").
|
||||
const priceBox = page.locator('input[placeholder="15"]')
|
||||
await expect(priceBox).toBeVisible({ timeout: 8_000 })
|
||||
await expect(priceBox).toHaveValue('15')
|
||||
await priceBox.fill('18')
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click()
|
||||
|
||||
// The PUT was issued to the correct endpoint with the new priceCents (1800).
|
||||
await expect.poll(() => cap.put?.slug, { timeout: 10_000 }).toBe('cloud-dev')
|
||||
expect(cap.put?.body.priceCents).toBe(1800)
|
||||
// Name/category/metadata survived the round-trip (the form sends the whole entry).
|
||||
expect(cap.put?.body.name).toBe('Dev')
|
||||
expect(cap.put?.body.category).toBe('cloud')
|
||||
expect((cap.put?.body.metadata as Record<string, unknown>)?.vcpus).toBe(2)
|
||||
|
||||
// The store persisted it, so the reloaded table shows the NEW price.
|
||||
await expect(page.getByText('$18.00').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-catalog-editor-persisted.png'), fullPage: false })
|
||||
})
|
||||
@@ -1,355 +0,0 @@
|
||||
/**
|
||||
* Infrastructure admin board — render + interaction proof.
|
||||
*
|
||||
* Drives the REAL InfraModule (client + pure logic + the shared sortable DataTable)
|
||||
* against a mock of `/v1/admin/infra` seeded with the fleet's REAL shape: 58 nodes,
|
||||
* 295 volumes, 8 clusters, 132 detached, and 3 unreferenced/deletable volumes totalling
|
||||
* 500 GiB ≈ $50/mo. Nothing here is fabricated beyond the fixture — the assertions are
|
||||
* about what the board DOES with real numbers.
|
||||
*
|
||||
* Proves: the Overview totals render and the droplet-local-disk note is unmissable;
|
||||
* every tab renders; sorting a column genuinely REORDERS rows (the first row's text
|
||||
* changes); the `unreferenced` filter yields exactly 3; a NON-deletable volume shows no
|
||||
* delete control (it shows its blockedReason instead); and a deletable volume's confirm
|
||||
* states the name, the size in GiB, and the monthly cost being reclaimed.
|
||||
*
|
||||
* Screenshots every tab to e2e-shots/admin-infra-<tab>.png.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-infra
|
||||
*/
|
||||
import { test, expect, type Route } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
// ── the fixture: the fleet's REAL shape ───────────────────────────────────────
|
||||
|
||||
const CLUSTER_NAMES = ['hanzo-k8s', 'lux-k8s', 'zoo-k8s', 'bootnode-k8s', 'pars-k8s', 'ci-arc-k8s', 'edge-k8s', 'staging-k8s']
|
||||
|
||||
/** 8 clusters. `zebra-k8s` is deliberately absent — name sorting is proven on the real set. */
|
||||
const clusters = CLUSTER_NAMES.map((name, i) => ({
|
||||
id: `c-${i + 1}`,
|
||||
name,
|
||||
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
|
||||
version: '1.31.1-do.4',
|
||||
status: 'running',
|
||||
nodePools: 2 + (i % 3),
|
||||
nodes: [12, 10, 8, 7, 6, 6, 5, 4][i],
|
||||
pods: 120 - i * 9,
|
||||
pvs: 40 - i * 3,
|
||||
pvcs: 40 - i * 3,
|
||||
idlePVCs: i === 0 ? 6 : i === 1 ? 3 : 0,
|
||||
scanned: true,
|
||||
scanError: '',
|
||||
monthlyCents: [480000, 320000, 180000, 120000, 74000, 60000, 32000, 18000][i],
|
||||
}))
|
||||
|
||||
/** 58 droplets across the 8 clusters; each carries 160 GiB of LOCAL disk (9,280 GiB total). */
|
||||
const nodes = Array.from({ length: 58 }, (_, i) => ({
|
||||
id: 1000 + i,
|
||||
name: `pool-${String.fromCharCode(97 + (i % 8))}-${i + 1}`,
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
region: ['nyc3', 'sfo3', 'ams3'][i % 3],
|
||||
status: 'active',
|
||||
sizeSlug: i % 5 === 0 ? 's-8vcpu-16gb' : 's-4vcpu-8gb',
|
||||
vcpus: i % 5 === 0 ? 8 : 4,
|
||||
memoryMiB: i % 5 === 0 ? 16384 : 8192,
|
||||
localDiskGiB: 160,
|
||||
monthlyCents: i % 5 === 0 ? 9600 : 4800,
|
||||
createdAt: '2026-01-04T10:00:00Z',
|
||||
privateIp: `10.0.${Math.floor(i / 256)}.${i % 256}`,
|
||||
publicIp: '',
|
||||
tags: ['k8s', `k8s:c-${(i % 8) + 1}`],
|
||||
ready: i !== 57,
|
||||
schedulable: i !== 56,
|
||||
pods: 4 + (i % 17),
|
||||
volumes: i % 3 === 0 ? 2 : 1,
|
||||
}))
|
||||
|
||||
/**
|
||||
* 295 volumes: 163 attached, 129 detached-but-referenced (bound/released), and the 3
|
||||
* UNREFERENCED ones that are genuinely reclaimable (500 GiB ≈ $50/mo).
|
||||
* detachedVolumes = 132 = the 129 bound/released + the 3 unreferenced.
|
||||
*/
|
||||
const volumes = [
|
||||
...Array.from({ length: 163 }, (_, i) => ({
|
||||
id: `v-att-${i}`,
|
||||
name: `pvc-attached-${String(i).padStart(3, '0')}`,
|
||||
region: 'nyc3',
|
||||
sizeGiB: 100,
|
||||
monthlyCents: 1000,
|
||||
state: 'attached',
|
||||
dropletIds: [1000 + (i % 58)],
|
||||
nodeName: nodes[i % 58].name,
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-att-${i}`,
|
||||
pvPhase: 'Bound',
|
||||
pvcNamespace: 'hanzo',
|
||||
pvcName: `data-${i}`,
|
||||
mountedBy: [`pod-${i}`],
|
||||
idle: false,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'Attached to a droplet.',
|
||||
})),
|
||||
...Array.from({ length: 118 }, (_, i) => ({
|
||||
id: `v-bound-${i}`,
|
||||
name: `pvc-bound-${String(i).padStart(3, '0')}`,
|
||||
region: 'sfo3',
|
||||
sizeGiB: 150,
|
||||
monthlyCents: 1500,
|
||||
state: 'bound',
|
||||
dropletIds: [],
|
||||
nodeName: '',
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-bound-${i}`,
|
||||
pvPhase: 'Bound',
|
||||
pvcNamespace: 'hanzo',
|
||||
pvcName: `idle-${i}`,
|
||||
mountedBy: [],
|
||||
idle: true,
|
||||
createdAt: '2026-02-01T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'Bound to PVC hanzo/idle — still claimed.',
|
||||
})),
|
||||
...Array.from({ length: 11 }, (_, i) => ({
|
||||
id: `v-rel-${i}`,
|
||||
name: `pvc-released-${String(i).padStart(3, '0')}`,
|
||||
region: 'ams3',
|
||||
sizeGiB: 120,
|
||||
monthlyCents: 1200,
|
||||
state: 'released',
|
||||
dropletIds: [],
|
||||
nodeName: '',
|
||||
cluster: CLUSTER_NAMES[i % 8],
|
||||
clusterId: `c-${(i % 8) + 1}`,
|
||||
tagCluster: `c-${(i % 8) + 1}`,
|
||||
pv: `pv-rel-${i}`,
|
||||
pvPhase: 'Released',
|
||||
pvcNamespace: '',
|
||||
pvcName: '',
|
||||
mountedBy: [],
|
||||
idle: false,
|
||||
createdAt: '2026-01-15T00:00:00Z',
|
||||
deletable: false,
|
||||
blockedReason: 'PV is Released but not yet reclaimed — retain policy holds the data.',
|
||||
})),
|
||||
// The 3 genuinely reclaimable volumes: 500 GiB total, $50.00/mo total.
|
||||
{
|
||||
id: 'v-orphan-1', name: 'pvc-abandoned-alpha', region: 'nyc3', sizeGiB: 200, monthlyCents: 2000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: 'c-1',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2025-11-02T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
{
|
||||
id: 'v-orphan-2', name: 'pvc-abandoned-bravo', region: 'sfo3', sizeGiB: 200, monthlyCents: 2000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2025-12-11T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
{
|
||||
id: 'v-orphan-3', name: 'pvc-abandoned-charlie', region: 'ams3', sizeGiB: 100, monthlyCents: 1000,
|
||||
state: 'unreferenced', dropletIds: [], nodeName: '', cluster: '', clusterId: '', tagCluster: '',
|
||||
pv: '', pvPhase: '', pvcNamespace: '', pvcName: '', mountedBy: [], idle: false,
|
||||
createdAt: '2026-01-20T00:00:00Z', deletable: true, blockedReason: '',
|
||||
},
|
||||
]
|
||||
|
||||
const loadBalancers = [
|
||||
{ id: 'lb-1', name: 'edge-ingress', region: 'nyc3', status: 'active', ip: '143.198.10.1', sizeUnit: 3, monthlyCents: 3600, droplets: 12, cluster: 'hanzo-k8s' },
|
||||
{ id: 'lb-2', name: 'api-gateway', region: 'sfo3', status: 'active', ip: '143.198.10.2', sizeUnit: 1, monthlyCents: 1200, droplets: 10, cluster: 'lux-k8s' },
|
||||
{ id: 'lb-3', name: 'zoo-edge', region: 'ams3', status: 'new', ip: '', sizeUnit: 1, monthlyCents: 1200, droplets: 0, cluster: 'zoo-k8s' },
|
||||
{ id: 'lb-4', name: 'bootnode-rpc', region: 'nyc3', status: 'active', ip: '143.198.10.4', sizeUnit: 1, monthlyCents: 1200, droplets: 7, cluster: 'bootnode-k8s' },
|
||||
]
|
||||
|
||||
const findings = [
|
||||
{ id: 'f-1', severity: 'critical', kind: 'unreferenced-volume', title: 'Three unreferenced volumes', detail: '500 GiB of block storage is referenced by no PV, PVC or droplet.', resource: 'pvc-abandoned-alpha, pvc-abandoned-bravo, pvc-abandoned-charlie', cluster: '', monthlyCents: 5000 },
|
||||
{ id: 'f-2', severity: 'warn', kind: 'idle-pvc', title: 'Idle PVCs on hanzo-k8s', detail: 'Bound to a PVC but no pod mounts them.', resource: '6 PVCs', cluster: 'hanzo-k8s', monthlyCents: 9000 },
|
||||
{ id: 'f-3', severity: 'warn', kind: 'released-pv', title: 'Released PVs retained', detail: 'Retain reclaim policy is holding the data.', resource: '11 PVs', cluster: 'lux-k8s', monthlyCents: 13200 },
|
||||
{ id: 'f-4', severity: 'info', kind: 'cost-outlier', title: 'hanzo-k8s is 28% of fleet spend', detail: 'Largest single cluster by monthly cost.', resource: 'hanzo-k8s', cluster: 'hanzo-k8s', monthlyCents: 480000 },
|
||||
]
|
||||
|
||||
const snapshot = {
|
||||
at: new Date().toISOString(),
|
||||
complete: true,
|
||||
incompleteReason: '',
|
||||
sources: [
|
||||
{ name: 'digitalocean', ok: true, rows: 359, error: '', at: new Date().toISOString() },
|
||||
{ name: 'hanzo-k8s', ok: true, rows: 40, error: '', at: new Date().toISOString() },
|
||||
],
|
||||
totals: {
|
||||
clusters: 8, nodes: 58, volumes: 295, loadBalancers: 4,
|
||||
volumeGiB: 41200, attachedVolumes: 163, attachedGiB: 16300,
|
||||
detachedVolumes: 132, detachedGiB: 20120,
|
||||
unreferencedVolumes: 3, unreferencedGiB: 500,
|
||||
idlePVCs: 118, localDiskGiB: 9280,
|
||||
},
|
||||
cost: { dropletsMonthly: 1284000, volumesMonthly: 412000, loadBalancersMonthly: 7200, totalMonthly: 1703200, reclaimableMonthly: 5000 },
|
||||
clusters, nodes, volumes, loadBalancers, findings,
|
||||
}
|
||||
|
||||
// ── the spec ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Mock everything; `/v1/admin/infra` answers with the fixture, all else an empty envelope. */
|
||||
async function mockFleet(page: import('@playwright/test').Page) {
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/v1/admin/infra' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: snapshot }) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
}
|
||||
|
||||
/**
|
||||
* Open one tab by URL. This also proves the registry declares the `:tab` route — an
|
||||
* undeclared tab slug 404s (the v8.4.86 class of bug), which a click-only spec hides.
|
||||
* URL navigation is also unambiguous: the sidebar carries its own "Clusters" / "Nodes"
|
||||
* product entries, so a bare button match would be a coin flip.
|
||||
*/
|
||||
async function openTab(page: import('@playwright/test').Page, slug: string, tabLabel: string) {
|
||||
await page.goto(`${BASE_URL}/infra${slug ? `/${slug}` : ''}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByText('Infrastructure').first()).toBeVisible({ timeout: 30_000 })
|
||||
// The module's own tab bar rendered this tab (and it is the selected one).
|
||||
await expect(page.getByRole('button', { name: tabLabel, exact: true }).last()).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
test('infrastructure board renders the fleet, sorts, filters, and gates deletion', async ({ page }) => {
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await mockFleet(page)
|
||||
|
||||
// ── Overview: the totals + the unmissable local-disk note ───────────────────
|
||||
await openTab(page, '', 'Overview')
|
||||
|
||||
// Cost breakdown: total / droplets / block storage / load balancers / reclaimable.
|
||||
await expect(page.getByText('$17,032.00').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('$12,840.00').first()).toBeVisible()
|
||||
await expect(page.getByText('$4,120.00').first()).toBeVisible()
|
||||
await expect(page.getByText('$72.00').first()).toBeVisible()
|
||||
// The reclaimable card: the 3 unreferenced volumes ≈ $50/mo, 500 GiB.
|
||||
await expect(page.getByText('$50.00').first()).toBeVisible()
|
||||
await expect(page.getByText('3 unreferenced · 500 GiB').first()).toBeVisible()
|
||||
// Fleet counts.
|
||||
await expect(page.getByText('8 clusters · 58 nodes').first()).toBeVisible()
|
||||
await expect(page.getByText('295 volumes · 40.2 TiB').first()).toBeVisible()
|
||||
|
||||
// THE distinction: droplet local disk is inside the droplet price, not block storage.
|
||||
await expect(page.getByText('Droplet local disk is included in the droplet price — it is never billed separately')).toBeVisible()
|
||||
await expect(page.getByText(/9,280 GiB of local disk is already inside the droplet number/)).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-overview.png'), fullPage: false })
|
||||
|
||||
// ── Clusters: sorting a column genuinely REORDERS rows ──────────────────────
|
||||
await page.getByRole('button', { name: 'Clusters', exact: true }).first().click()
|
||||
await expect(page.getByText('hanzo-k8s').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
// Default sort is Monthly desc → hanzo-k8s ($4,800.00) is first.
|
||||
const clusterRows = page.locator('.hz-row')
|
||||
await expect(clusterRows.first()).toContainText('hanzo-k8s')
|
||||
const beforeSort = (await clusterRows.first().innerText()).trim()
|
||||
|
||||
// Click the "Cluster" header → sort by name ASC → bootnode-k8s is first (a different row).
|
||||
await page.getByLabel('Sort by Cluster').click()
|
||||
await expect(clusterRows.first()).toContainText('bootnode-k8s', { timeout: 10_000 })
|
||||
const afterAsc = (await clusterRows.first().innerText()).trim()
|
||||
expect(afterAsc).not.toBe(beforeSort) // the first row's text genuinely CHANGED
|
||||
|
||||
// Click it again → DESC → zoo-k8s is first (the reverse end of the same column).
|
||||
await page.getByLabel('Sort by Cluster').click()
|
||||
await expect(clusterRows.first()).toContainText('zoo-k8s', { timeout: 10_000 })
|
||||
expect((await clusterRows.first().innerText()).trim()).not.toBe(afterAsc)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-clusters.png'), fullPage: false })
|
||||
|
||||
// ── Nodes: 58 droplets, sortable, with a cordon control ─────────────────────
|
||||
await page.getByRole('button', { name: 'Nodes', exact: true }).first().click()
|
||||
await expect(page.getByLabel('Sort by Node')).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByRole('button', { name: 'Cordon' }).first()).toBeVisible()
|
||||
|
||||
// Sort by vCPU ascending → a 4-vCPU node leads; descending → an 8-vCPU node leads.
|
||||
const nodeRows = page.locator('.hz-row')
|
||||
await page.getByLabel('Sort by vCPU').click()
|
||||
await expect(nodeRows.first()).toContainText('s-4vcpu-8gb', { timeout: 10_000 })
|
||||
const nodeAsc = (await nodeRows.first().innerText()).trim()
|
||||
await page.getByLabel('Sort by vCPU').click()
|
||||
await expect(nodeRows.first()).toContainText('s-8vcpu-16gb', { timeout: 10_000 })
|
||||
expect((await nodeRows.first().innerText()).trim()).not.toBe(nodeAsc)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-nodes.png'), fullPage: false })
|
||||
|
||||
// ── Volumes: the unreferenced filter yields EXACTLY 3 ───────────────────────
|
||||
await page.getByRole('button', { name: 'Volumes', exact: true }).first().click()
|
||||
await expect(page.getByText('295').first()).toBeVisible({ timeout: 15_000 })
|
||||
|
||||
await page.getByRole('button', { name: 'Unreferenced', exact: true }).click()
|
||||
const volumeRows = page.locator('.hz-row')
|
||||
await expect(volumeRows).toHaveCount(3, { timeout: 10_000 })
|
||||
await expect(page.getByText('pvc-abandoned-alpha')).toBeVisible()
|
||||
await expect(page.getByText('pvc-abandoned-bravo')).toBeVisible()
|
||||
await expect(page.getByText('pvc-abandoned-charlie')).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-volumes.png'), fullPage: false })
|
||||
|
||||
// A DELETABLE volume: the confirm states name + GiB + the monthly cost reclaimed.
|
||||
await page.getByText('pvc-abandoned-alpha').first().click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
|
||||
const confirmText = page.getByText(/Delete volume “pvc-abandoned-alpha”/)
|
||||
await expect(confirmText).toBeVisible()
|
||||
await expect(confirmText).toContainText('200 GiB')
|
||||
await expect(confirmText).toContainText('$20.00/month')
|
||||
await expect(confirmText).toContainText('A snapshot is taken first')
|
||||
await expect(page.getByRole('button', { name: 'Delete pvc-abandoned-alpha' })).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-volume-delete.png'), fullPage: false })
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// A NON-deletable volume: NO delete control anywhere — the blocked reason instead.
|
||||
await page.getByRole('button', { name: 'Attached', exact: true }).click()
|
||||
await expect(page.getByText('pvc-attached-000').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.getByText('pvc-attached-000').first().click()
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('This volume cannot be deleted')).toBeVisible()
|
||||
await expect(page.getByText('Attached to a droplet.').first()).toBeVisible()
|
||||
// The gate, asserted negatively: no delete button, no confirm text, no snapshot toggle.
|
||||
await expect(page.getByRole('button', { name: /^Delete / })).toHaveCount(0)
|
||||
await expect(page.getByText(/Delete volume “/)).toHaveCount(0)
|
||||
await expect(page.getByText('Take a snapshot first')).toHaveCount(0)
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(page.getByRole('dialog')).toBeHidden({ timeout: 10_000 })
|
||||
|
||||
// ── Load balancers ──────────────────────────────────────────────────────────
|
||||
await page.getByRole('button', { name: 'Load balancers', exact: true }).first().click()
|
||||
await expect(page.getByText('edge-ingress').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('$36.00').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-load-balancers.png'), fullPage: false })
|
||||
|
||||
// ── Audit: findings grouped by severity, with cost impact ───────────────────
|
||||
await page.getByRole('button', { name: 'Audit', exact: true }).first().click()
|
||||
await expect(page.getByText('Three unreferenced volumes').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('critical · 1').first()).toBeVisible()
|
||||
await expect(page.getByText('warn · 2').first()).toBeVisible()
|
||||
await expect(page.getByText('info · 1').first()).toBeVisible()
|
||||
// Group cost impact: the two warns sum to $222.00/mo (9000 + 13200 cents).
|
||||
await expect(page.getByText('$222.00/mo').first()).toBeVisible()
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-infra-audit.png'), fullPage: false })
|
||||
})
|
||||
@@ -1,116 +0,0 @@
|
||||
/**
|
||||
* Subscription Plans admin editor — render + edit-persists proof (increment 3a-console).
|
||||
*
|
||||
* Drives the REAL PlansCatalogModule (client + form + metadata editor) against a mock
|
||||
* of commerce's `/v1/plans/*` CRUD, seeded with real-shaped subscription/DNS plans. The
|
||||
* mock is a live in-memory store: a PUT mutates it, so a save → re-fetch shows the NEW
|
||||
* price — the exact "edit persists" loop the module drives against commerce (whose CRUD
|
||||
* + slug-immutable guard is proven by commerce's own api/plan handler tests).
|
||||
*
|
||||
* Proves: the table renders every plan with its monthly/annual price + custom/per-seat
|
||||
* flags; opening a plan shows the editable form (slug locked, name/price/category/
|
||||
* contactSales/popular/metadata) with the LIVE-BILLING warning; changing the price + Save
|
||||
* issues `PUT /v1/plans/entries/<slug>` with the new cents; and the table reflects it.
|
||||
* Screenshots the table + the open edit form (admin-plans-editor.png).
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-plans-editor
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
/** Real-shaped platform plans (the raw `plan` shape the admin GET /v1/plans/entries returns). */
|
||||
function seedPlans(): Record<string, unknown>[] {
|
||||
const base = { sku: '', currency: 'usd', interval: 'month', intervalCount: 1 }
|
||||
return [
|
||||
{ ...base, slug: 'personal-free', name: 'Personal', description: 'For personal projects.', category: 'personal', price: 0, priceAnnual: 0, trialPeriodDays: 0, perSeat: false, contactSales: false, popular: false, metadata: { limits: { requests: 1000 }, features: ['1 project'] } },
|
||||
{ ...base, slug: 'pro', name: 'Pro', description: 'For professionals shipping real products.', category: 'personal', price: 2000, priceAnnual: 1600, trialPeriodDays: 14, perSeat: false, contactSales: false, popular: true, metadata: { limits: { requests: 100000 }, features: ['Unlimited projects', 'Priority support'] } },
|
||||
{ ...base, slug: 'team', name: 'Team', description: 'For teams, billed per seat.', category: 'team', price: 9900, priceAnnual: 7900, trialPeriodDays: 14, perSeat: true, contactSales: false, popular: false, metadata: { seats: 'unlimited' } },
|
||||
{ ...base, slug: 'enterprise', name: 'Enterprise', description: 'Custom deployment at scale.', category: 'enterprise', price: 0, priceAnnual: 0, trialPeriodDays: 0, perSeat: false, contactSales: true, popular: false, metadata: { sla: true } },
|
||||
{ ...base, slug: 'dns-basic', name: 'DNS Basic', description: 'Managed DNS for a domain.', category: 'dns', price: 500, priceAnnual: 400, trialPeriodDays: 0, perSeat: false, contactSales: false, popular: false, metadata: { zones: 1 } },
|
||||
]
|
||||
}
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
test('plans editor renders the plans, edits a price, and persists', async ({ page }) => {
|
||||
const store = new Map(seedPlans().map((p) => [p.slug as string, p]))
|
||||
const cap: { put: { slug: string; body: Record<string, unknown> } | null } = { put: null }
|
||||
|
||||
await page.route('**/*', async (route: Route) => {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/v1/plans/entries' && req.method() === 'GET') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([...store.values()]) })
|
||||
}
|
||||
const m = path.match(/^\/v1\/plans\/entries\/(.+)$/)
|
||||
if (m && req.method() === 'PUT') {
|
||||
const slug = decodeURIComponent(m[1])
|
||||
const body = JSON.parse(req.postData() || '{}') as Record<string, unknown>
|
||||
cap.put = { slug, body }
|
||||
// Commerce pins the path slug (immutable) — mirror that here.
|
||||
const updated = { ...(store.get(slug) ?? {}), ...body, slug }
|
||||
store.set(slug, updated)
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(updated) })
|
||||
}
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
})
|
||||
|
||||
await primeSession(page, { owner: 'admin', name: 'z', email: 'z@hanzo.ai', isAdmin: true })
|
||||
|
||||
await page.goto(`${BASE_URL}/plan-catalog`, { waitUntil: 'domcontentloaded' })
|
||||
|
||||
// The table renders every plan.
|
||||
await expect(page.getByText('Subscription Plans').first()).toBeVisible({ timeout: 25_000 })
|
||||
await expect(page.getByText('pro').first()).toBeVisible({ timeout: 15_000 })
|
||||
await expect(page.getByText('enterprise').first()).toBeVisible()
|
||||
await expect(page.getByText('dns-basic').first()).toBeVisible()
|
||||
// Pro is $20.00/mo before the edit; Enterprise shows the custom price.
|
||||
await expect(page.getByText('$20.00/mo').first()).toBeVisible()
|
||||
await expect(page.getByText('Contact sales').first()).toBeVisible()
|
||||
|
||||
// Open the Pro row → the edit form (with the live-billing warning).
|
||||
await page.getByText('pro', { exact: true }).first().click()
|
||||
await expect(page.getByText('Edit Pro').first()).toBeVisible({ timeout: 10_000 })
|
||||
await expect(page.getByText('Editing the price changes the real renewal charge').first()).toBeVisible()
|
||||
// The slug field is disabled (immutable on edit).
|
||||
await expect(page.locator('input[value="pro"]')).toBeDisabled()
|
||||
|
||||
// Screenshot the editor (table behind + the open edit form).
|
||||
mkdirSync(SHOTS, { recursive: true })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-plans-editor.png'), fullPage: false })
|
||||
|
||||
// Edit the monthly price: $20 → $25 (the price field is uniquely identified by
|
||||
// its placeholder "20"; the annual + metadata inputs carry different placeholders).
|
||||
const priceBox = page.locator('input[placeholder="20"]')
|
||||
await expect(priceBox).toBeVisible({ timeout: 8_000 })
|
||||
await expect(priceBox).toHaveValue('20')
|
||||
await priceBox.fill('25')
|
||||
|
||||
await page.getByRole('button', { name: 'Save changes' }).click()
|
||||
|
||||
// The PUT was issued to the correct endpoint with the new price (2500 cents), the
|
||||
// immutable slug preserved, and the metadata round-tripped type-exactly.
|
||||
await expect.poll(() => cap.put?.slug, { timeout: 10_000 }).toBe('pro')
|
||||
expect(cap.put?.body.price).toBe(2500)
|
||||
expect(cap.put?.body.slug).toBe('pro')
|
||||
expect(cap.put?.body.name).toBe('Pro')
|
||||
expect(cap.put?.body.popular).toBe(true)
|
||||
expect((cap.put?.body.metadata as Record<string, unknown>)?.limits).toEqual({ requests: 100000 })
|
||||
|
||||
// The store persisted it, so the reloaded table shows the NEW price.
|
||||
await expect(page.getByText('$25.00/mo').first()).toBeVisible({ timeout: 10_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'admin-plans-editor-persisted.png'), fullPage: false })
|
||||
})
|
||||
@@ -1,164 +0,0 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai super-admin view audit — monochrome + not-broken + org search.
|
||||
*
|
||||
* Renders every admin-only view as a super-admin (primeSession owner:'admin') against
|
||||
* a LOCAL fixture server with the network mocked, and asserts three things the CTO asked
|
||||
* for: (1) MONOCHROME — no surface has a blue/cool color cast (the hue-220 light-theme
|
||||
* bug); (2) NOT BROKEN — every admin route renders its shell without an error-boundary
|
||||
* crash, and page errors are collected per route; (3) org SEARCH is reachable. One
|
||||
* screenshot per view so breakage is visible.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test admin-views-audit
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots', 'admin-audit')
|
||||
|
||||
/** The super-admin identity (a@hanzo.ai in the reserved `admin` org). */
|
||||
const ADMIN = { owner: 'admin', name: 'a', email: 'a@hanzo.ai', displayName: 'Admin', isAdmin: true }
|
||||
|
||||
/** Every admin-only view (registry `admin:true`) + the two catalog editors. */
|
||||
const ADMIN_VIEWS = [
|
||||
'finance-center', 'provider-billing', 'provider-admin', 'ai-economics', 'iam', 'kms',
|
||||
'audit', 'secrets', 'authz', 'hsm', 'mpc', 'treasury', 'tenants', 'entitlements',
|
||||
'cluster-fleet', 'function-fleet', 'service-mesh', 'gitops', 'status', 'tracker',
|
||||
'routing', 'models', 'platform', 'authors-admin', 'affiliates-admin', 'referrals-admin',
|
||||
'catalog', 'plans',
|
||||
]
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
// Honest-empty for every API — the audit is about RENDER + THEME, not data.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
/** Parse `rgb(r, g, b[, a])` → [r,g,b] or null. */
|
||||
function rgb(v: string): [number, number, number] | null {
|
||||
const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/)
|
||||
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null
|
||||
}
|
||||
|
||||
/** A color is monochrome when R≈G≈B. A blue cast = B meaningfully above R and G. */
|
||||
function blueCast([r, g, b]: [number, number, number]): number {
|
||||
return b - Math.max(r, g)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('every admin view is monochrome — no blue cast in the rendered surfaces', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.goto(`${BASE_URL}/finance-center`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2500) // let the SPA hydrate + the module mount
|
||||
|
||||
// Sample the computed background/border/text colors of every rendered element and
|
||||
// assert none carries a blue cast beyond a small tolerance (anti-aliasing / semantics
|
||||
// like a green "live" dot are allowed — we only flag a systemic BLUE tint).
|
||||
const offenders = await page.evaluate(() => {
|
||||
const bad: { sel: string; prop: string; color: string }[] = []
|
||||
const rgbOf = (v: string) => { const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); return m ? [+m[1], +m[2], +m[3]] as [number, number, number] : null }
|
||||
const els = Array.from(document.querySelectorAll('*')).slice(0, 4000)
|
||||
for (const el of els) {
|
||||
const cs = getComputedStyle(el as Element)
|
||||
for (const prop of ['backgroundColor', 'borderTopColor', 'color'] as const) {
|
||||
const c = rgbOf(cs[prop]); if (!c) continue
|
||||
const [r, g, bl] = c
|
||||
// Ignore near-black/near-white/transparent grays; flag a real blue tint only.
|
||||
if (bl - Math.max(r, g) >= 18 && bl > 60) bad.push({ sel: (el as Element).tagName.toLowerCase(), prop, color: cs[prop] })
|
||||
}
|
||||
}
|
||||
return bad.slice(0, 20)
|
||||
})
|
||||
await page.screenshot({ path: join(SHOTS, 'finance-center.png') })
|
||||
if (offenders.length) console.log('BLUE-CAST offenders:', JSON.stringify(offenders, null, 2))
|
||||
expect(offenders, `blue-cast surfaces found: ${JSON.stringify(offenders)}`).toHaveLength(0)
|
||||
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the LIGHT theme is monochrome — the hue-220 blue-tinge fix', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.addInitScript(() => { try { localStorage.setItem('theme', 'light') } catch { /* private */ } })
|
||||
await page.goto(`${BASE_URL}/finance-center`, { waitUntil: 'domcontentloaded' })
|
||||
// Force the light-theme class regardless of the next-themes storage key — this is the
|
||||
// surface (html:root.t_light) that used to build its scale on hsl(220 …) = blue.
|
||||
await page.evaluate(() => { document.documentElement.classList.add('t_light'); document.documentElement.classList.remove('t_dark') })
|
||||
await page.waitForTimeout(1500)
|
||||
const offenders = await page.evaluate(() => {
|
||||
const bad: { sel: string; prop: string; color: string }[] = []
|
||||
const rgbOf = (v: string) => { const m = v.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/); return m ? [+m[1], +m[2], +m[3]] as [number, number, number] : null }
|
||||
for (const el of Array.from(document.querySelectorAll('*')).slice(0, 4000)) {
|
||||
const cs = getComputedStyle(el as Element)
|
||||
for (const prop of ['backgroundColor', 'borderTopColor', 'color'] as const) {
|
||||
const c = rgbOf(cs[prop]); if (!c) continue
|
||||
const [r, g, bl] = c
|
||||
if (bl - Math.max(r, g) >= 18 && bl > 60) bad.push({ sel: (el as Element).tagName.toLowerCase(), prop, color: cs[prop] })
|
||||
}
|
||||
}
|
||||
return bad.slice(0, 20)
|
||||
})
|
||||
await page.screenshot({ path: join(SHOTS, 'finance-center-light.png') })
|
||||
if (offenders.length) console.log('LIGHT-MODE BLUE-CAST offenders:', JSON.stringify(offenders, null, 2))
|
||||
expect(offenders, `light-mode blue-cast surfaces: ${JSON.stringify(offenders)}`).toHaveLength(0)
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('org search is reachable for a super-admin', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
await page.goto(`${BASE_URL}/`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(2000)
|
||||
// The org switcher/picker must expose a filter input for a super-admin (many orgs).
|
||||
const filter = page.locator('input[placeholder*="rganization" i], input[placeholder*="ilter" i], input[placeholder*="earch" i]')
|
||||
await expect(filter.first(), 'no org search/filter input found for super-admin').toBeVisible({ timeout: 10_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('no admin view crashes — each renders its shell (screenshot per view)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ADMIN)
|
||||
|
||||
const broken: { view: string; reason: string }[] = []
|
||||
for (const view of ADMIN_VIEWS) {
|
||||
const errors: string[] = []
|
||||
const onErr = (e: Error) => errors.push(e.message)
|
||||
page.on('pageerror', onErr)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}/${view}`, { waitUntil: 'domcontentloaded' })
|
||||
await page.waitForTimeout(1500)
|
||||
await page.screenshot({ path: join(SHOTS, `${view}.png`) })
|
||||
// A hard crash = the shared error boundary card, or a JS pageerror.
|
||||
const crashed = await page.locator('text=/Something went wrong|Application error|Unhandled|Cannot read prop/i').first().isVisible().catch(() => false)
|
||||
if (crashed) broken.push({ view, reason: 'error-boundary/crash card' })
|
||||
else if (errors.length) broken.push({ view, reason: `pageerror: ${errors[0]}` })
|
||||
} catch (e) {
|
||||
broken.push({ view, reason: `navigation: ${(e as Error).message}` })
|
||||
} finally {
|
||||
page.off('pageerror', onErr)
|
||||
}
|
||||
}
|
||||
if (broken.length) console.log('BROKEN ADMIN VIEWS:', JSON.stringify(broken, null, 2))
|
||||
expect(broken, `broken admin views: ${JSON.stringify(broken)}`).toHaveLength(0)
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,205 +0,0 @@
|
||||
/**
|
||||
* e2e: admin.hanzo.ai AI Economics board (feat/ai-economics).
|
||||
*
|
||||
* TWO layers, mirroring provider-billing.spec:
|
||||
* (A) FIXTURE render — runs against a LOCAL server (BASE_URL=http://localhost:4000)
|
||||
* with the network mocked: `/auth/session` → a global admin so the admin shell
|
||||
* mounts, and the reads (`/v1/admin/usage/funding`, `/v1/admin/finance`,
|
||||
* `/v1/admin/providers/credit`, `/v1/evals/{datasets,runs,evaluators}`) → a
|
||||
* fixture where fable-5 is exactly 75% of requests and gross margin is 62%.
|
||||
* Proves: the page renders, the model-mix table shows the mocked rows WITH the
|
||||
* request-share %, the margin card shows the mocked grossMarginPct, and the
|
||||
* honest "no traffic is harvested" training-data card renders. Desktop + mobile.
|
||||
* (B) LIVE — the fail-closed gate proof (`/v1/admin/*` → >=401 unauthenticated)
|
||||
* against the same origin; needs no credentials, always runs.
|
||||
*
|
||||
* Run fixture: BASE_URL=http://localhost:4000 npx playwright test ai-economics
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
// These render specs assert LOCAL fixture data; skip cleanly when that server is down.
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
// A SuperAdmin via the isGlobalAdmin/isSuperAdmin CLAIM (what the `admin: true` module
|
||||
// gates on). owner is a normal org so Scope resolves locally instead of demanding a
|
||||
// pick from the (mocked-empty) org list.
|
||||
// owner === the reserved `admin` org IS the SuperAdmin signal the client gate reads
|
||||
// (`isSuperAdminOwner` / IAM `User.IsSuperAdmin` — the isGlobalAdmin/isSuperAdmin claim
|
||||
// fields are NOT read), so the `admin: true` module renders instead of the managed notice.
|
||||
const ACCOUNT = {
|
||||
owner: 'admin',
|
||||
name: 'z',
|
||||
type: 'normal-user',
|
||||
email: 'z@hanzo.ai',
|
||||
displayName: 'Z Admin',
|
||||
isGlobalAdmin: true,
|
||||
isSuperAdmin: true,
|
||||
isAdmin: true,
|
||||
signupApplication: 'hanzo-cloud',
|
||||
}
|
||||
|
||||
/** GET /v1/admin/usage/funding — the model mix: fable-5 = 750/1000 requests (75%),
|
||||
* gpt-5.6 = 200 (20%), ds4-flash = 30, ds4-pro = 20. One row per (provider,model,funding). */
|
||||
const FUNDING = [
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'credit', tokens: 4_800_000, cost_cents: 18_200, requests: 600 },
|
||||
{ provider: 'do-ai', model: 'fable-5', funding: 'paid', tokens: 1_200_000, cost_cents: 6_100, requests: 150 },
|
||||
{ provider: 'openrouter', model: 'gpt-5.6', funding: 'paid', tokens: 900_000, cost_cents: 44_000, requests: 200 },
|
||||
{ provider: 'openrouter', model: 'ds4-flash', funding: 'paid', tokens: 120_000, cost_cents: 900, requests: 30 },
|
||||
{ provider: 'openrouter', model: 'ds4-pro', funding: 'paid', tokens: 80_000, cost_cents: 3_100, requests: 20 },
|
||||
]
|
||||
|
||||
/** GET /v1/admin/finance — the casibase-enveloped finance aggregate; grossMarginPct 62. */
|
||||
const FINANCE = {
|
||||
status: 'ok',
|
||||
msg: '',
|
||||
data: {
|
||||
cost: { configured: true, error: '', period: '2026-07', totalCents: 3_800_000, vendors: [], digitalocean: { configured: true, error: '', creditRemainingCents: 2_418_000, monthToDateSpendCents: 41_200, avgDailyBurnCents: 20_100, accountBalanceCents: -2_418_000, generatedAt: '', history: [] } },
|
||||
revenue: { configured: true, totalRevenueCents: 10_000_000, mrrCents: 820_000, creditsConsumedCents: 120_000 },
|
||||
derived: { grossMarginCents: 6_200_000, grossMarginPct: 62, runwayDays: 120, profitable: true },
|
||||
generatedAt: '2026-07-15T00:00:00Z',
|
||||
},
|
||||
}
|
||||
|
||||
/** GET /v1/admin/providers/credit — the DO grant + a paid-only provider. */
|
||||
const CREDIT = [
|
||||
{ provider: 'do-ai', grant_cents: 2_600_000, burn_cents: 41_200, remaining_cents: 2_418_000, runway_days: 58, has_credit: true, is_paid_only: false },
|
||||
{ provider: 'openrouter', grant_cents: 100_000, burn_cents: 21_000, remaining_cents: 62_500, runway_days: 3, has_credit: true, is_paid_only: false },
|
||||
]
|
||||
|
||||
/** GET /v1/evals/datasets — user-curated registry: 2 datasets, 150 items. */
|
||||
const DATASETS = { data: [
|
||||
{ name: 'router-quality', description: 'router routing quality', items: 120, createdAt: '2026-07-08T00:00:00Z' },
|
||||
{ name: 'safety-redteam', description: 'safety judgments', items: 30, createdAt: '2026-07-02T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/runs — recent LLM-as-judge runs with an average score. */
|
||||
const RUNS = { data: [
|
||||
{ dataset: 'router-quality', runName: 'rq-2026-07-10', model: 'fable-5', judgeModel: 'claude-opus-4.6', items: 120, scored: 120, avgScore: 0.87, createdAt: '2026-07-10T00:00:00Z' },
|
||||
{ dataset: 'safety-redteam', runName: 'sr-2026-07-04', model: 'gpt-5.6', judgeModel: 'claude-opus-4.6', items: 30, scored: 30, avgScore: 0.93, createdAt: '2026-07-04T00:00:00Z' },
|
||||
] }
|
||||
|
||||
/** GET /v1/evals/evaluators. */
|
||||
const EVALUATORS = { data: [{ name: 'quality-judge', model: 'claude-opus-4.6', criteria: 'routing quality', scoreName: 'quality' }] }
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations|auth\/refresh)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const path = url.pathname
|
||||
|
||||
if (path === '/auth/session') {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ account: ACCOUNT, expiresIn: 3600 }) })
|
||||
}
|
||||
if (path.startsWith('/auth/')) {
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ ok: true }) })
|
||||
}
|
||||
// The economics reads. funding/credit are bare arrays (restGet + pluckList); finance
|
||||
// is the casibase envelope (originGet unwraps `data`); evals are `{data:[...]}`.
|
||||
const json = (body: unknown) => route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(body) })
|
||||
if (path === '/v1/admin/usage/funding') return json(FUNDING)
|
||||
if (path === '/v1/admin/finance') return json(FINANCE)
|
||||
if (path === '/v1/admin/providers/credit') return json(CREDIT)
|
||||
if (path === '/v1/evals/datasets') return json(DATASETS)
|
||||
if (path === '/v1/evals/runs') return json(RUNS)
|
||||
if (path === '/v1/evals/evaluators') return json(EVALUATORS)
|
||||
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(path)) return route.continue()
|
||||
// Any other data call → an honest empty-ok envelope so the shell is quiet.
|
||||
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }) })
|
||||
}
|
||||
|
||||
async function openBoard(page: Page) {
|
||||
await page.addInitScript((org) => {
|
||||
try {
|
||||
localStorage.setItem('hanzo.console.org', org)
|
||||
localStorage.setItem('hanzo.console.org.selected', '1') // ENTERED flag — Scope → scoped console
|
||||
localStorage.setItem('hz_onboarding_done:' + org, '1') // skip the first-run wizard
|
||||
localStorage.setItem('hz_admin_banner_dismissed', '1')
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, ACCOUNT.owner)
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, ACCOUNT)
|
||||
await page.goto(`${BASE_URL}/ai-economics`, { waitUntil: 'domcontentloaded' })
|
||||
const content = page.locator('[data-testid="product-content"]').first()
|
||||
await content.waitFor({ state: 'attached', timeout: 20_000 })
|
||||
await expect(content.getByTestId('ai-economics')).toBeVisible({ timeout: 20_000 })
|
||||
await page.waitForTimeout(700)
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
// ─── (A) fixture render ───────────────────────────────────────────────────────
|
||||
test.describe('(A) fixture render — model mix (fable-5 75%) + 62% margin + honest training card', () => {
|
||||
test('renders the model mix, share %, margin, and the honest training-data card (desktop)', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
// Page rendered (not the operator gate).
|
||||
await expect(page.getByText('AI Economics').first()).toBeVisible()
|
||||
await expect(page.locator('text=/SuperAdmin access required|not authorized|access denied/i')).toHaveCount(0)
|
||||
|
||||
// (a) model mix — the mocked rows WITH request-share %.
|
||||
const modelMix = page.getByTestId('model-mix')
|
||||
await expect(modelMix.getByText('Model mix').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('fable-5').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('gpt-5.6').first()).toBeVisible()
|
||||
await expect(modelMix.getByText('75%').first()).toBeVisible() // fable-5 = 750/1000 requests
|
||||
await expect(modelMix.getByText('20%').first()).toBeVisible() // gpt-5.6 = 200/1000
|
||||
|
||||
// (b) profitability — the mocked grossMarginPct.
|
||||
const margin = page.getByTestId('margin-card')
|
||||
await expect(margin.getByText('+62% margin').first()).toBeVisible()
|
||||
|
||||
// (c) training data — the honest "no traffic harvested" collection card + real counts.
|
||||
const training = page.getByTestId('training-collection-card')
|
||||
await expect(training).toBeVisible()
|
||||
await expect(training.getByText(/No traffic is harvested for training/i)).toBeVisible()
|
||||
await expect(page.getByText('Eval datasets').first()).toBeVisible()
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-desktop.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('reflows with no horizontal body scroll at a narrow (mobile) viewport', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await openBoard(page)
|
||||
|
||||
await expect(page.getByTestId('model-mix').getByText('fable-5').first()).toBeVisible()
|
||||
const overflow = await page.evaluate(() => {
|
||||
const el = document.documentElement
|
||||
return { scrollWidth: el.scrollWidth, clientWidth: el.clientWidth }
|
||||
})
|
||||
expect(overflow.scrollWidth, 'no horizontal body scroll at 390px').toBeLessThanOrEqual(overflow.clientWidth + 1)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'ai-economics-mobile.png'), fullPage: true })
|
||||
await ctx.close()
|
||||
})
|
||||
})
|
||||
|
||||
// ─── (B) fail-closed gate — always runs, no credentials ───────────────────────
|
||||
test.describe('(B) admin gate fail-closed', () => {
|
||||
test('/v1/admin/{usage/funding,finance,providers/credit} → fail-closed unauthenticated', async ({ request }) => {
|
||||
for (const p of ['usage/funding', 'finance', 'providers/credit']) {
|
||||
const res = await request.get(`${BASE_URL}/v1/admin/${p}`)
|
||||
// A raw request (no page mocks, no session) NEVER gets data: the console's
|
||||
// getAdminGate is fail-closed. Post-deploy this is the 403 global-admin gate;
|
||||
// before a sibling route deploys it may 404 — both are "not open". Never 200.
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must be fail-closed (>=401)`).toBeGreaterThanOrEqual(401)
|
||||
expect(res.status(), `${BASE_URL}/v1/admin/${p} must not 5xx`).toBeLessThan(500)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,170 +0,0 @@
|
||||
/**
|
||||
* e2e: the assistant's ONE entry point, and the All-products directory you can act in.
|
||||
*
|
||||
* Three claims, each measured in a real browser rather than inferred from source:
|
||||
*
|
||||
* 1. The assistant opens from a FLOATING bottom-right control, not from the header —
|
||||
* asserted on GEOMETRY (the control's box is in the bottom-right quadrant of the
|
||||
* viewport) and on the header carrying no assistant control at all.
|
||||
* 2. Clicking an app in the All-products directory NAVIGATES to that app. This is the
|
||||
* regression that matters: the rows rendered, hovered, and did nothing, so the
|
||||
* directory looked interactive and was not. Asserted on where the browser LANDS.
|
||||
* 3. A pin made in the directory survives a reload EVEN WHEN the identity token
|
||||
* carries an older preferences snapshot — the exact production condition (the
|
||||
* token is minted at sign-in; a pin made after it is not in it).
|
||||
*
|
||||
* Local dev server + mocked network; `primeSession` supplies the IAM-PKCE identity.
|
||||
*
|
||||
* Run: BASE_URL=http://localhost:4000 npx playwright test assistant-fab-and-apps
|
||||
*/
|
||||
import { test, expect, type Route, type Page } from '@playwright/test'
|
||||
import { requireFixtureServer } from './_fixture'
|
||||
import { primeSession } from './_session'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { join } from 'node:path'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'http://localhost:4000'
|
||||
|
||||
requireFixtureServer()
|
||||
const SHOTS = join(process.cwd(), 'e2e-shots')
|
||||
|
||||
const API_RE = /\/(v1|cloud|ai|billing|commerce|telemetry|vm|superbase|admin|paas|integrations)(\/|$|\?)/
|
||||
|
||||
async function mock(route: Route) {
|
||||
const req = route.request()
|
||||
if (req.resourceType() === 'document') return route.continue()
|
||||
const url = new URL(req.url())
|
||||
const sameOrigin = url.origin === new URL(BASE_URL).origin
|
||||
if (sameOrigin && !API_RE.test(url.pathname)) return route.continue()
|
||||
return route.fulfill({
|
||||
status: 200,
|
||||
contentType: 'application/json',
|
||||
body: JSON.stringify({ status: 'ok', msg: '', data: [], data2: 0 }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Sign in and land on `path`, waiting for the signed-in shell to have mounted. */
|
||||
async function boot(page: Page, path = '/', claims?: Parameters<typeof primeSession>[1]) {
|
||||
await page.route('**/*', mock)
|
||||
await primeSession(page, claims)
|
||||
await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
}
|
||||
|
||||
test.beforeAll(() => mkdirSync(SHOTS, { recursive: true }))
|
||||
|
||||
test('the assistant opens from the bottom-right, and the header carries no AI control', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
// Bottom-right quadrant: the whole point of the relocation.
|
||||
expect(box!.x).toBeGreaterThan(1440 / 2)
|
||||
expect(box!.y).toBeGreaterThan(900 / 2)
|
||||
// A comfortable target, not a hairline.
|
||||
expect(box!.width).toBeGreaterThanOrEqual(44)
|
||||
expect(box!.height).toBeGreaterThanOrEqual(44)
|
||||
|
||||
// The topbar itself holds no assistant control any more — it used to carry two
|
||||
// (a brand-H "Chat with Hanzo" and a "Talk to Hanzo" mic) beside the search box.
|
||||
const inTopbar = await page.evaluate(() =>
|
||||
Array.from(document.querySelectorAll('.hz-topbar [aria-label]')).map((n) => n.getAttribute('aria-label') ?? ''),
|
||||
)
|
||||
expect(inTopbar).not.toHaveLength(0) // the topbar was found at all
|
||||
expect(inTopbar.filter((l) => /Hanzo/i.test(l))).toHaveLength(0)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-desktop.png') })
|
||||
|
||||
// It opens the SAME assistant surface.
|
||||
await fab.click()
|
||||
await expect(page.getByText('Assistant').first()).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-open-desktop.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('the assistant control is reachable on a phone and never scrolls the body sideways', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 390, height: 844 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
const fab = page.getByRole('button', { name: 'Ask Hanzo' })
|
||||
const box = await fab.boundingBox()
|
||||
expect(box).not.toBeNull()
|
||||
expect(box!.x + box!.width).toBeLessThanOrEqual(390)
|
||||
expect(box!.y).toBeGreaterThan(844 / 2)
|
||||
|
||||
const [scrollW, clientW] = await page.evaluate(() => [
|
||||
document.documentElement.scrollWidth,
|
||||
document.documentElement.clientWidth,
|
||||
])
|
||||
expect(scrollW).toBe(clientW)
|
||||
|
||||
await page.screenshot({ path: join(SHOTS, 'assistant-fab-mobile.png') })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('clicking an app in All products opens that app', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
await boot(page)
|
||||
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
const row = page.getByRole('button', { name: 'Open Agents' })
|
||||
await expect(row).toBeVisible({ timeout: 15_000 })
|
||||
await page.screenshot({ path: join(SHOTS, 'all-products-desktop.png') })
|
||||
|
||||
await row.click()
|
||||
// Where the browser LANDS is the claim — not that a handler fired.
|
||||
await expect(page).toHaveURL(/\/agents$/, { timeout: 15_000 })
|
||||
await ctx.close()
|
||||
})
|
||||
|
||||
test('a pin made in All products survives a reload under a STALE token snapshot', async ({ browser }) => {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } })
|
||||
const page = await ctx.newPage()
|
||||
|
||||
// The production condition: the identity token was minted an hour ago and carries a
|
||||
// preferences SNAPSHOT from then. Treating that snapshot as authoritative is what
|
||||
// silently threw away every pin made since — the pin reads as pinned, and is gone
|
||||
// after a reload.
|
||||
const snapshot = { pins: [{ id: 'models', group: '' }], pinGroups: [] }
|
||||
await boot(page, '/', {
|
||||
properties: { 'hanzo.preferences': JSON.stringify(snapshot) },
|
||||
issuedAt: Math.floor(Date.now() / 1000) - 3600,
|
||||
})
|
||||
|
||||
const openDirectory = async () => {
|
||||
await page.getByRole('button', { name: 'All products' }).first().click()
|
||||
// "…to sidebar" / "…from sidebar" are the directory's own labels — the home page's
|
||||
// Apps map carries a plain "Pin Agents", so the short form is ambiguous.
|
||||
await expect(page.getByRole('button', { name: /Agents (to|from) sidebar/ })).toBeVisible({ timeout: 15_000 })
|
||||
}
|
||||
|
||||
// The snapshot the token carries is what the sidebar starts from.
|
||||
await openDirectory()
|
||||
await page.getByRole('button', { name: 'Pin Agents to sidebar' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible()
|
||||
|
||||
// Only a write the SERVER acknowledged earns the stamp that out-ranks the snapshot.
|
||||
await expect
|
||||
.poll(() => page.evaluate(() => localStorage.getItem('hanzo.console2.prefs.z.writtenAt')), { timeout: 10_000 })
|
||||
.not.toBeNull()
|
||||
|
||||
await page.reload({ waitUntil: 'domcontentloaded' })
|
||||
await expect(page.getByRole('button', { name: 'Ask Hanzo' })).toBeVisible({ timeout: 60_000 })
|
||||
|
||||
// Still pinned — the hour-old snapshot did not win. Asserted on what the user sees…
|
||||
await openDirectory()
|
||||
await expect(page.getByRole('button', { name: 'Remove Agents from sidebar' })).toBeVisible({ timeout: 15_000 })
|
||||
// …and on what was actually kept (models from the snapshot, agents from the write).
|
||||
const pins = await page.evaluate(() => {
|
||||
const raw = JSON.parse(localStorage.getItem('hanzo.console2.prefs.z') ?? '{}')
|
||||
return (raw.pins ?? []).map((p: { id: string }) => p.id)
|
||||
})
|
||||
expect(pins).toContain('agents')
|
||||
expect(pins).toContain('models')
|
||||
await ctx.close()
|
||||
})
|
||||
@@ -1,98 +0,0 @@
|
||||
/**
|
||||
* e2e: two-tenant BILLING ISOLATION through the `/v1/billing/*` proxy.
|
||||
*
|
||||
* The proxy (app/v1/billing/[...path]/route.ts) resolves the billing subject from the
|
||||
* session server-side and pins the full subject-key set (user/userId/customerId) +
|
||||
* the X-Org-Id header, so a tenant can only ever read its OWN commerce ledger. This
|
||||
* spec proves that end-to-end against the LIVE proxy: two accounts in DIFFERENT orgs
|
||||
* each fetch `/v1/billing/subscriptions` (and `/v1/billing/methods`), and we assert
|
||||
* the two result sets are disjoint — neither tenant can see the other's rows.
|
||||
*
|
||||
* This is the regression guard for the IDOR RED found (the proxy previously pinned
|
||||
* only `?user=` while commerce filters subscriptions on `?userId=`, so subscriptions
|
||||
* were returned across the whole namespace).
|
||||
*
|
||||
* Credentials (env, never in repo). Skips unless BOTH tenants are provided:
|
||||
* TENANT_A_EMAIL / TENANT_A_PASSWORD (org A)
|
||||
* TENANT_B_EMAIL / TENANT_B_PASSWORD (org B, a DIFFERENT org)
|
||||
* BASE_URL default https://console.hanzo.ai
|
||||
*
|
||||
* Run: TENANT_A_EMAIL=.. TENANT_A_PASSWORD=.. TENANT_B_EMAIL=.. TENANT_B_PASSWORD=.. pnpm e2e billing-isolation.spec.ts
|
||||
*/
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.BASE_URL ?? 'https://console.hanzo.ai'
|
||||
const A = { email: process.env.TENANT_A_EMAIL ?? '', password: process.env.TENANT_A_PASSWORD ?? '' }
|
||||
const B = { email: process.env.TENANT_B_EMAIL ?? '', password: process.env.TENANT_B_PASSWORD ?? '' }
|
||||
|
||||
async function signIn(page: Page, email: string, password: string) {
|
||||
await page.goto(`${BASE_URL}/signin`)
|
||||
await page.waitForSelector('input[placeholder="Email"]', { timeout: 20_000 })
|
||||
await page.fill('input[placeholder="Email"]', email)
|
||||
await page.fill('input[placeholder="Password"]', password)
|
||||
await page.click('button:has-text("Sign in")')
|
||||
const base = new URL(BASE_URL).origin
|
||||
await page.waitForURL((url) => url.origin === base && url.pathname === '/', { timeout: 30_000 })
|
||||
await page.waitForLoadState('domcontentloaded')
|
||||
}
|
||||
|
||||
/** Fetch a billing path through the same-origin DATA proxy (`/v1/billing/*`), as the
|
||||
* signed-in browser. (`/billing/<slug>` is a UI tab, served by the SPA — it differs at
|
||||
* the FIRST path segment, so the two never collide.) */
|
||||
async function billing(page: Page, path: string): Promise<{ status: number; ids: string[] }> {
|
||||
return page.evaluate(async (p) => {
|
||||
const res = await fetch(`/v1/billing/${p}`, { credentials: 'include', headers: { Accept: 'application/json' } })
|
||||
let ids: string[] = []
|
||||
try {
|
||||
const body = await res.json()
|
||||
const rows = Array.isArray(body)
|
||||
? body
|
||||
: (body?.subscriptions ?? body?.paymentMethods ?? body?.payment_methods ?? body?.invoices ?? body?.data ?? [])
|
||||
ids = (Array.isArray(rows) ? rows : [])
|
||||
.map((r: { id?: unknown }) => (typeof r?.id === 'string' ? r.id : ''))
|
||||
.filter(Boolean)
|
||||
} catch {
|
||||
/* non-JSON (e.g. 501 not-configured) — ids stays empty */
|
||||
}
|
||||
return { status: res.status, ids }
|
||||
}, path)
|
||||
}
|
||||
|
||||
test.describe('billing is isolated per tenant through the proxy', () => {
|
||||
test.skip(
|
||||
!A.email || !A.password || !B.email || !B.password,
|
||||
'TENANT_A_* / TENANT_B_* not set — skipping two-tenant billing isolation',
|
||||
)
|
||||
|
||||
test('two distinct-org tenants never see each other’s subscriptions or payment methods', async ({ browser }) => {
|
||||
const ctxA = await browser.newContext()
|
||||
const ctxB = await browser.newContext()
|
||||
const pageA = await ctxA.newPage()
|
||||
const pageB = await ctxB.newPage()
|
||||
|
||||
await signIn(pageA, A.email, A.password)
|
||||
await signIn(pageB, B.email, B.password)
|
||||
|
||||
// `invoices` is included because its row ids drive the per-invoice PDF URL
|
||||
// (`/v1/billing/invoices/:id/pdf`) — proving the invoice list is tenant-isolated
|
||||
// proves a user can only ever build a PDF URL for their OWN org's invoices.
|
||||
for (const path of ['subscriptions', 'methods', 'invoices']) {
|
||||
const a = await billing(pageA, path)
|
||||
const b = await billing(pageB, path)
|
||||
|
||||
// A 401 would mean the session broke; a 501 means commerce isn't configured
|
||||
// on this deployment (isolation is vacuously safe — nothing is returned).
|
||||
expect(a.status, `tenant A /${path} not authorized`).not.toBe(401)
|
||||
expect(b.status, `tenant B /${path} not authorized`).not.toBe(401)
|
||||
|
||||
if (a.status === 501 || b.status === 501) continue
|
||||
|
||||
// The core isolation assertion: the two tenants' row-id sets are disjoint.
|
||||
const overlap = a.ids.filter((id) => b.ids.includes(id))
|
||||
expect(overlap, `/${path} leaked ${overlap.length} shared rows across tenants`).toEqual([])
|
||||
}
|
||||
|
||||
await ctxA.close()
|
||||
await ctxB.close()
|
||||
})
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user