Compare commits
1239 Commits
bugfix/sea
...
pe/clawhub
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45b4c970b | ||
|
|
4d3b7dedba | ||
|
|
1aab139775 | ||
|
|
88756d5997 | ||
|
|
8c86d6f570 | ||
|
|
86898837fb | ||
|
|
d7c774996e | ||
|
|
b8e5486f63 | ||
|
|
571a85f539 | ||
|
|
0f938fbabd | ||
|
|
5e7797df72 | ||
|
|
0b058d10bf | ||
|
|
0749f16499 | ||
|
|
52c3b649e0 | ||
|
|
03328f7523 | ||
|
|
5019f2a78a | ||
|
|
e4a68d2a76 | ||
|
|
bde371360d | ||
|
|
e62935762b | ||
|
|
ac08267403 | ||
|
|
9fe7532e27 | ||
|
|
3618d296af | ||
|
|
cab18339e6 | ||
|
|
5b1cfb4574 | ||
|
|
2bd6ed9198 | ||
|
|
4f4d7dd563 | ||
|
|
d5d58a9dbc | ||
|
|
7a2733947e | ||
|
|
4592e66879 | ||
|
|
06500ea4ca | ||
|
|
d89e2ce1a1 | ||
|
|
c4d1fcdbc6 | ||
|
|
e8deec13a2 | ||
|
|
678935d014 | ||
|
|
bb592363a7 | ||
|
|
e3b59fce38 | ||
|
|
1da5c53ce9 | ||
|
|
bfb16ceddf | ||
|
|
f7fbd6bde4 | ||
|
|
bec9362361 | ||
|
|
c4688b3526 | ||
|
|
8d5eb14919 | ||
|
|
5aa4d13560 | ||
|
|
a046cff693 | ||
|
|
57308e6059 | ||
|
|
32011a1f9a | ||
|
|
b735a529c2 | ||
|
|
6925ec761c | ||
|
|
521fd2796a | ||
|
|
2b00f0b37e | ||
|
|
88b6a941ec | ||
|
|
0c7607bd64 | ||
|
|
bb6ef2ba44 | ||
|
|
df61771b7e | ||
|
|
9605bb3d8e | ||
|
|
33176522da | ||
|
|
102f47174d | ||
|
|
cd9995c676 | ||
|
|
6679f36a2f | ||
|
|
19993f93ed | ||
|
|
9028a7402a | ||
|
|
0a32b9857d | ||
|
|
0a5b648f78 | ||
|
|
ebe77f7f63 | ||
|
|
caac39ce29 | ||
|
|
d24422a005 | ||
|
|
1c62c5fff0 | ||
|
|
395862fadf | ||
|
|
ba7a108af1 | ||
|
|
6c3f911e8e | ||
|
|
facf20ceb6 | ||
|
|
0690891781 | ||
|
|
0df30649ca | ||
|
|
bbdde7fd53 | ||
|
|
3d6f3b49a5 | ||
|
|
0b842636dc | ||
|
|
2d2d791e9f | ||
|
|
96e3d7ebd4 | ||
|
|
768a50149e | ||
|
|
199e6a0cdf | ||
|
|
59fc54ff64 | ||
|
|
343781a668 | ||
|
|
62b10f829d | ||
|
|
eb3113c1f3 | ||
|
|
887e81eb85 | ||
|
|
d6cfc891f0 | ||
|
|
cf5778d7d5 | ||
|
|
e76b72cdb1 | ||
|
|
f53b49041a | ||
|
|
21abd07672 | ||
|
|
6085ee4852 | ||
|
|
05653453ea | ||
|
|
86f8aa88af | ||
|
|
2d42c3d57a | ||
|
|
cf5a6f6e8b | ||
|
|
46354c9967 | ||
|
|
063ee210a7 | ||
|
|
f84c894e4e | ||
|
|
f8141bc517 | ||
|
|
ca4899078d | ||
|
|
51d4633df0 | ||
|
|
6139dcd052 | ||
|
|
ab48c07b98 | ||
|
|
0a49b75e2f | ||
|
|
5e9c61a185 | ||
|
|
8234c92dcf | ||
|
|
9edff6fd38 | ||
|
|
2ebcdd4ed0 | ||
|
|
f5183cae9b | ||
|
|
4196789c6d | ||
|
|
4c69f2af2e | ||
|
|
4c52dc23c1 | ||
|
|
8916167505 | ||
|
|
f4f2da7fe7 | ||
|
|
01529aaaf1 | ||
|
|
05efb81669 | ||
|
|
ca0d0bd1bd | ||
|
|
f82e07fd3a | ||
|
|
f2a61c9d94 | ||
|
|
3c09df3b77 | ||
|
|
6d4cf0cfe7 | ||
|
|
e4aa4c7459 | ||
|
|
3aff30b955 | ||
|
|
c9a225aef7 | ||
|
|
0fe234e68d | ||
|
|
56743ce3d8 | ||
|
|
1fdfbcd51f | ||
|
|
bf1e112d5a | ||
|
|
7266f4f927 | ||
|
|
77927830f3 | ||
|
|
e599d23f69 | ||
|
|
4c8738f1ef | ||
|
|
e01c7a9f31 | ||
|
|
c9a5b8508d | ||
|
|
cb320fe2ab | ||
|
|
773df44f17 | ||
|
|
402ddddbd7 | ||
|
|
238f3f6b14 | ||
|
|
539bf60e97 | ||
|
|
6527ab6a9f | ||
|
|
63164eb762 | ||
|
|
669e14b92c | ||
|
|
28da510571 | ||
|
|
6e5578ee6d | ||
|
|
68017740e7 | ||
|
|
ff68eeb5d1 | ||
|
|
276760d703 | ||
|
|
1b33c949f1 | ||
|
|
417537a13f | ||
|
|
6e15ed65e0 | ||
|
|
58dcd55076 | ||
|
|
c9ad1305ff | ||
|
|
964fc0fa87 | ||
|
|
bc234c7d89 | ||
|
|
87a286fe1f | ||
|
|
00970bbee9 | ||
|
|
7979ff4249 | ||
|
|
f3c4cbb99a | ||
|
|
bed2d4b1b0 | ||
|
|
81759fd857 | ||
|
|
f3cf886ce5 | ||
|
|
2176dbf4c2 | ||
|
|
80e8b599f9 | ||
|
|
7d636b771b | ||
|
|
e94cc91b8e | ||
|
|
0774d0fe92 | ||
|
|
1261062585 | ||
|
|
88d0cc7888 | ||
|
|
87848016ff | ||
|
|
86e58d6031 | ||
|
|
48e66714ac | ||
|
|
0c705e159f | ||
|
|
5409df4123 | ||
|
|
ac15e5adea | ||
|
|
880d9e0572 | ||
|
|
63dfbd8876 | ||
|
|
4a7b7b7024 | ||
|
|
34e26093ab | ||
|
|
601d29b0e9 | ||
|
|
bff959c8f0 | ||
|
|
34a2c657b6 | ||
|
|
fc6555fa1c | ||
|
|
e7ad7c628d | ||
|
|
7c61d55833 | ||
|
|
89becd866a | ||
|
|
eada4d5dcb | ||
|
|
7f15dcc225 | ||
|
|
bb945c740e | ||
|
|
dfc0d540d8 | ||
|
|
cd37acadbb | ||
|
|
c9fe6db34d | ||
|
|
5fe321a43f | ||
|
|
9e15c5a6fa | ||
|
|
026b911d58 | ||
|
|
08326f7718 | ||
|
|
881514f444 | ||
|
|
3f17fd55e5 | ||
|
|
3f2153e678 | ||
|
|
9ea3ed896f | ||
|
|
7ea5fc085c | ||
|
|
1306ab6640 | ||
|
|
f7c5ae5a16 | ||
|
|
631b357a10 | ||
|
|
42bc312151 | ||
|
|
12c72366f6 | ||
|
|
27d7d4afa4 | ||
|
|
cb3852ef16 | ||
|
|
50768641f9 | ||
|
|
651e54ed7c | ||
|
|
3bbbd858d4 | ||
|
|
9a8607038e | ||
|
|
2bac472615 | ||
|
|
b27072312b | ||
|
|
6bebc0f572 | ||
|
|
efa349c856 | ||
|
|
21f2cfbd9c | ||
|
|
b96af7391c | ||
|
|
cfc4ba9b6a | ||
|
|
9b27c1a1d3 | ||
|
|
9e09581c05 | ||
|
|
97c409d56b | ||
|
|
6c93d2096e | ||
|
|
65d02e57b0 | ||
|
|
ae83b2188c | ||
|
|
d97942b996 | ||
|
|
a3125daf78 | ||
|
|
23eec67163 | ||
|
|
c3c885ec10 | ||
|
|
04492fe196 | ||
|
|
292f15dbae | ||
|
|
6bf8d4b7b7 | ||
|
|
b60514b3fe | ||
|
|
45b9c0e51d | ||
|
|
e3cf29a2bc | ||
|
|
3deff6efd1 | ||
|
|
c4950b8034 | ||
|
|
b043065ee5 | ||
|
|
94d358e25b | ||
|
|
b8f04b5bc4 | ||
|
|
248a3f25e3 | ||
|
|
8fb4d01e65 | ||
|
|
26744ba4ef | ||
|
|
f1481c4d4e | ||
|
|
ec2308c96d | ||
|
|
beb5c27d9e | ||
|
|
18ae25b4c2 | ||
|
|
1208e86b5f | ||
|
|
cd34538f16 | ||
|
|
85db1c60ad | ||
|
|
8aa7a58a40 | ||
|
|
67739a4a9f | ||
|
|
fc74a2f6cd | ||
|
|
52078abd85 | ||
|
|
933fb94bcf | ||
|
|
0db5ef6224 | ||
|
|
43d50b8947 | ||
|
|
3fea99b8a6 | ||
|
|
b4cfe33659 | ||
|
|
e60bff87e8 | ||
|
|
b3c42ddba2 | ||
|
|
adbf4347e7 | ||
|
|
6595e13a10 | ||
|
|
2a7b0f0a6f | ||
|
|
ed596ba24d | ||
|
|
2d054fe9ed | ||
|
|
fa9ab8d620 | ||
|
|
7f220c2108 | ||
|
|
9b5c9541f8 | ||
|
|
e324fcaae2 | ||
|
|
57be656406 | ||
|
|
8cab60d64a | ||
|
|
d4d69d42be | ||
|
|
1461d0f175 | ||
|
|
827fd92c7d | ||
|
|
cf20e10338 | ||
|
|
79eddc0223 | ||
|
|
33334c5afa | ||
|
|
477aae7c95 | ||
|
|
a535da6dfb | ||
|
|
50ee17ce7d | ||
|
|
0079d3f09a | ||
|
|
bcfe66d7d5 | ||
|
|
f3a1d7fc32 | ||
|
|
201713c9ed | ||
|
|
2a5638e05b | ||
|
|
82a85ad21e | ||
|
|
f3c060c360 | ||
|
|
8d5e7b2d4d | ||
|
|
2325c21108 | ||
|
|
ad53229985 | ||
|
|
886a38cb8b | ||
|
|
1a9d80a43d | ||
|
|
0a9f969775 | ||
|
|
9917881331 | ||
|
|
eec9702fa3 | ||
|
|
4a09eafe42 | ||
|
|
076b938724 | ||
|
|
33b921af29 | ||
|
|
8230c1e365 | ||
|
|
3bfdbfc004 | ||
|
|
b48b95b0c1 | ||
|
|
9ebf7d7bde | ||
|
|
55dc372ecf | ||
|
|
667c69a28b | ||
|
|
e8b2aa558c | ||
|
|
415c8e182e | ||
|
|
52831cbc2b | ||
|
|
05c6409c96 | ||
|
|
c8585875bd | ||
|
|
4a9ae92d54 | ||
|
|
8ac5881b4f | ||
|
|
ef340c047b | ||
|
|
1e6f9bd44c | ||
|
|
0150b384a7 | ||
|
|
3989cd8126 | ||
|
|
b90a43adcb | ||
|
|
94102e28f5 | ||
|
|
463e9b3fa7 | ||
|
|
09820d0d1c | ||
|
|
faead0e25c | ||
|
|
0a64b977cb | ||
|
|
1e81388560 | ||
|
|
3ab5762dca | ||
|
|
e2d187b3d5 | ||
|
|
afdac4a6a3 | ||
|
|
5dfcd896e9 | ||
|
|
d91c4804ce | ||
|
|
59e28c7831 | ||
|
|
a0713e1833 | ||
|
|
ca19f31816 | ||
|
|
87da4ec65a | ||
|
|
d9b419b21b | ||
|
|
bb94325679 | ||
|
|
88f8ca2d29 | ||
|
|
54ed3c58a1 | ||
|
|
ea35420eed | ||
|
|
2520da134c | ||
|
|
0b2de12e04 | ||
|
|
a8326517ad | ||
|
|
7bef2a0b65 | ||
|
|
22bb94cee2 | ||
|
|
b3c42b661b | ||
|
|
f72179f37d | ||
|
|
1d79f78426 | ||
|
|
6209fe3fff | ||
|
|
a7d1701f5a | ||
|
|
52da4954f6 | ||
|
|
0ee5958f7a | ||
|
|
5d01b99adb | ||
|
|
5dc834c27e | ||
|
|
82b9a69dad | ||
|
|
064804e2d3 | ||
|
|
6c0163f9f2 | ||
|
|
04a862d2b2 | ||
|
|
a7fc4bbae2 | ||
|
|
4701c555f3 | ||
|
|
c1f167721b | ||
|
|
1a94744484 | ||
|
|
9a5cfeee85 | ||
|
|
e69b7d4501 | ||
|
|
ecf09b868a | ||
|
|
4d16472f5b | ||
|
|
2e5ffdc565 | ||
|
|
4e13e729fb | ||
|
|
d17e100cca | ||
|
|
c732b38569 | ||
|
|
81ca04662c | ||
|
|
ce69ab6a38 | ||
|
|
a28d94c345 | ||
|
|
3701733797 | ||
|
|
b8ba595d06 | ||
|
|
ef2846b2e4 | ||
|
|
232e429dee | ||
|
|
932155cb8f | ||
|
|
d855d09ab0 | ||
|
|
16e87c147d | ||
|
|
cbe22e70b9 | ||
|
|
837331c967 | ||
|
|
6ce443496d | ||
|
|
8fd4f3b051 | ||
|
|
a2153909da | ||
|
|
75e1b4633e | ||
|
|
4cda4a1fa4 | ||
|
|
5fce3ca2f4 | ||
|
|
87bca06939 | ||
|
|
bf25b38c39 | ||
|
|
f20dd624a5 | ||
|
|
8752e4bb7e | ||
|
|
bc06c472e4 | ||
|
|
09fa7daa7b | ||
|
|
e21ca80a7d | ||
|
|
456f4db74d | ||
|
|
7014a53fdf | ||
|
|
c6c4481ffd | ||
|
|
02b7d10af8 | ||
|
|
c5a6d2700f | ||
|
|
205db67e99 | ||
|
|
f406c5bf16 | ||
|
|
add0d13bef | ||
|
|
7c5b8b2a20 | ||
|
|
d3ed4434b9 | ||
|
|
7f7d6676c3 | ||
|
|
aff186bbcf | ||
|
|
1c430cc11d | ||
|
|
e2cb7dfe4e | ||
|
|
fb3bcbafb1 | ||
|
|
e6c3d6ff28 | ||
|
|
57970579cf | ||
|
|
2ddc52c0b0 | ||
|
|
e63031c452 | ||
|
|
e3c772d90b | ||
|
|
73e26e51c1 | ||
|
|
e7a1e9937b | ||
|
|
d5776f8499 | ||
|
|
280352d959 | ||
|
|
bd375f6a93 | ||
|
|
422f6d4e08 | ||
|
|
dd111cacee | ||
|
|
ffa83db48a | ||
|
|
96c7ab1aaa | ||
|
|
743fa3abba | ||
|
|
6c079e93c2 | ||
|
|
23a109c037 | ||
|
|
f28c1745f9 | ||
|
|
4fe275eb50 | ||
|
|
c0d2ac7ac0 | ||
|
|
d6d4028660 | ||
|
|
82ae30d940 | ||
|
|
b53813a5a7 | ||
|
|
87469792d5 | ||
|
|
9e7407cd84 | ||
|
|
70fd9436cf | ||
|
|
5e7584e032 | ||
|
|
ea0824878d | ||
|
|
da74b2a382 | ||
|
|
4787be4eb1 | ||
|
|
89246f1927 | ||
|
|
f4ddccbead | ||
|
|
3cafcbf873 | ||
|
|
13064a7897 | ||
|
|
194c22f4dd | ||
|
|
a693b945fa | ||
|
|
9bef672541 | ||
|
|
9551cac37b | ||
|
|
eb4138fbb3 | ||
|
|
5fbead624b | ||
|
|
35094177e6 | ||
|
|
faa5c9f2b5 | ||
|
|
c3314c2d01 | ||
|
|
7dfa19157c | ||
|
|
44acf86ac1 | ||
|
|
a0ebc1b50a | ||
|
|
88dbb69a23 | ||
|
|
df9acd27e4 | ||
|
|
dbd5d4042c | ||
|
|
ebe82b7e18 | ||
|
|
4c566268a9 | ||
|
|
e54fc1939a | ||
|
|
530e39eedc | ||
|
|
8b87c31a99 | ||
|
|
f7bc8b6349 | ||
|
|
5b8f09167a | ||
|
|
17fbd13bc9 | ||
|
|
aab7dc9ba4 | ||
|
|
dde8796790 | ||
|
|
acc6d292de | ||
|
|
2236ed7be1 | ||
|
|
f6fb7ccfc0 | ||
|
|
b73758c7c8 | ||
|
|
fbc07c5617 | ||
|
|
80e5aec577 | ||
|
|
f869b31ad6 | ||
|
|
9a853f2fcc | ||
|
|
b4a7540157 | ||
|
|
0ea1127a2b | ||
|
|
aeab23a6d6 | ||
|
|
15bc4440cc | ||
|
|
11a20f5755 | ||
|
|
05f8674628 | ||
|
|
d2b2252770 | ||
|
|
a2387253ec | ||
|
|
411260767b | ||
|
|
731d0ce0c5 | ||
|
|
361f2affde | ||
|
|
a17f7bb07e | ||
|
|
7980788ea6 | ||
|
|
2d03b827d3 | ||
|
|
835094ea2c | ||
|
|
7bd7e4c99e | ||
|
|
1722a48055 | ||
|
|
29178898bb | ||
|
|
b16861f422 | ||
|
|
6896e61fa1 | ||
|
|
cf137aa592 | ||
|
|
a28d014d4f | ||
|
|
b2038fc931 | ||
|
|
4a72b543b2 | ||
|
|
54e99c8cc2 | ||
|
|
5826001795 | ||
|
|
0708a43fde | ||
|
|
9a45c371fc | ||
|
|
311a123fbe | ||
|
|
59e93862ed | ||
|
|
8fcd53f899 | ||
|
|
ba2c73e180 | ||
|
|
298cbdd6db | ||
|
|
fa87dc3509 | ||
|
|
f636b31fca | ||
|
|
b255b5865f | ||
|
|
5003c1bec8 | ||
|
|
a8a6242f87 | ||
|
|
39c0fa2531 | ||
|
|
9df6fe37c5 | ||
|
|
d5d806516d | ||
|
|
67c74e10ea | ||
|
|
3501af6bb4 | ||
|
|
051b1dafcd | ||
|
|
383844cacf | ||
|
|
b7923edbfd | ||
|
|
655c914c77 | ||
|
|
b15eeab93f | ||
|
|
a39f07427e | ||
|
|
1a42207879 | ||
|
|
9bd3a63edf | ||
|
|
be4c51ed1b | ||
|
|
8711de4441 | ||
|
|
f4db0ee32b | ||
|
|
f1cf715b89 | ||
|
|
57ed1f62a4 | ||
|
|
bde7529fbe | ||
|
|
5892718a23 | ||
|
|
922eecdbd9 | ||
|
|
e53a4433ae | ||
|
|
68870a1dcc | ||
|
|
c6aaf27886 | ||
|
|
f71db92fc3 | ||
|
|
07a1da4285 | ||
|
|
cb9d854e92 | ||
|
|
74ec5191e0 | ||
|
|
cf98936105 | ||
|
|
dd9c42be28 | ||
|
|
e8a4e094e7 | ||
|
|
068dd78a05 | ||
|
|
2f5e17f56e | ||
|
|
5302bf8598 | ||
|
|
749c89e77b | ||
|
|
fd08d74071 | ||
|
|
67c8e188e3 | ||
|
|
bad07ff95c | ||
|
|
a7547eee51 | ||
|
|
fddbb35b40 | ||
|
|
ba313e2d0a | ||
|
|
f6f31ac78d | ||
|
|
f32500b1b4 | ||
|
|
5bc6402b5e | ||
|
|
9721461aca | ||
|
|
91f80c264a | ||
|
|
712302eb30 | ||
|
|
f044164bae | ||
|
|
997da8857a | ||
|
|
1a31e07332 | ||
|
|
cb761a73a9 | ||
|
|
d4ad4ea489 | ||
|
|
d7c9126c4b | ||
|
|
5dc6720d9e | ||
|
|
087f2c75b0 | ||
|
|
03c42fa947 | ||
|
|
572110b1d5 | ||
|
|
812276f1b7 | ||
|
|
b9676f674c | ||
|
|
32d601aab7 | ||
|
|
4aae925e76 | ||
|
|
e41fd677bb | ||
|
|
31520ef02a | ||
|
|
e829cb1ae6 | ||
|
|
24cc59a425 | ||
|
|
522fa22026 | ||
|
|
7e76336ae4 | ||
|
|
e41d5d6314 | ||
|
|
c62ab8bc96 | ||
|
|
6df3a3b9ef | ||
|
|
7452cf6f69 | ||
|
|
8ded548da2 | ||
|
|
4ff2d1a0cd | ||
|
|
b4e8a26eb4 | ||
|
|
7ff601bcb8 | ||
|
|
70af109cb2 | ||
|
|
1504708208 | ||
|
|
4af2bd50a7 | ||
|
|
88fe310ff1 | ||
|
|
0a31b31f1b | ||
|
|
079e390da6 | ||
|
|
15da02c70a | ||
|
|
8b289618ba | ||
|
|
4ae0406948 | ||
|
|
78d0a637fa | ||
|
|
65bc5d3335 | ||
|
|
8592272720 | ||
|
|
15f5769cda | ||
|
|
bb2a05e501 | ||
|
|
af29ca7f9d | ||
|
|
13caea7cfd | ||
|
|
86259eef42 | ||
|
|
bf2ecab92e | ||
|
|
70516bd0f5 | ||
|
|
66fca9a286 | ||
|
|
a03fe3bbd0 | ||
|
|
d3d9298389 | ||
|
|
2fd0aa01d5 | ||
|
|
5aad36dab0 | ||
|
|
1bab92313f | ||
|
|
f77993d614 | ||
|
|
3a28344c53 | ||
|
|
ea49143fe0 | ||
|
|
bd38fef5c5 | ||
|
|
b7a015523d | ||
|
|
d36f98faa1 | ||
|
|
eeb0ecd932 | ||
|
|
51beceeb20 | ||
|
|
a8a687eba2 | ||
|
|
b9341202c7 | ||
|
|
d701c2a33b | ||
|
|
79d17a91ed | ||
|
|
2f15202a68 | ||
|
|
342a2b1ca4 | ||
|
|
75915cd2b5 | ||
|
|
5395d9d159 | ||
|
|
87c236e8ce | ||
|
|
d8db9b99a2 | ||
|
|
d8e1f0daa1 | ||
|
|
8a350d953c | ||
|
|
3fb3150ee2 | ||
|
|
7dbc0fc3bb | ||
|
|
fc4f8644eb | ||
|
|
dda6d55fbf | ||
|
|
e014759b40 | ||
|
|
2186c41c48 | ||
|
|
c30c182478 | ||
|
|
3080567964 | ||
|
|
f304541561 | ||
|
|
91224ada13 | ||
|
|
d5fbaeef81 | ||
|
|
3706018b72 | ||
|
|
62e616f635 | ||
|
|
9013d324c8 | ||
|
|
c1363ec8d0 | ||
|
|
7e09196f92 | ||
|
|
807043b4b0 | ||
|
|
972fe35935 | ||
|
|
16ee540f5d | ||
|
|
f541882d55 | ||
|
|
95bc156747 | ||
|
|
bf7422022f | ||
|
|
230e5b91f8 | ||
|
|
70dcf21e37 | ||
|
|
932a1fb30c | ||
|
|
44fe60b701 | ||
|
|
1c057ca9b9 | ||
|
|
9f793d1336 | ||
|
|
aa9295bea9 | ||
|
|
370eea4977 | ||
|
|
59e6819020 | ||
|
|
52b633f4e9 | ||
|
|
6b1e6ca1c9 | ||
|
|
6c112eccb7 | ||
|
|
f9e9effcdd | ||
|
|
b5d2d0fefa | ||
|
|
48d0fc91f3 | ||
|
|
fefb2340a8 | ||
|
|
22287558b9 | ||
|
|
f6ce8f9e1e | ||
|
|
b5cdee50a9 | ||
|
|
b5fdee1c13 | ||
|
|
2530beaf51 | ||
|
|
c627b202f7 | ||
|
|
5cbeb54c8e | ||
|
|
eb8136b7fb | ||
|
|
a166c95eb0 | ||
|
|
86dc196e9b | ||
|
|
649c14a43f | ||
|
|
7cbf0434b0 | ||
|
|
76944f0e32 | ||
|
|
6ead1da08b | ||
|
|
22413f4ff8 | ||
|
|
3326c235da | ||
|
|
2b97c13191 | ||
|
|
17977c1248 | ||
|
|
aea0127663 | ||
|
|
9ea750852f | ||
|
|
801cc550ab | ||
|
|
522512c9d2 | ||
|
|
1ceb5d2bad | ||
|
|
c7b3b2a30e | ||
|
|
f1e3629a99 | ||
|
|
931843aeac | ||
|
|
1a56fc5427 | ||
|
|
03a19f6497 | ||
|
|
f9087fc6e0 | ||
|
|
5429c28667 | ||
|
|
c234009b7c | ||
|
|
962171a590 | ||
|
|
852b54a722 | ||
|
|
dff83a52bf | ||
|
|
ec8601c0eb | ||
|
|
ee6a5ab037 | ||
|
|
4814ce39d6 | ||
|
|
ccace09242 | ||
|
|
8f9c9ffba2 | ||
|
|
7460c084a5 | ||
|
|
e7ef691013 | ||
|
|
da76bf7554 | ||
|
|
3959a7ea85 | ||
|
|
08da7eb477 | ||
|
|
958a37fa6b | ||
|
|
91d963cf13 | ||
|
|
31191b7771 | ||
|
|
371367f01d | ||
|
|
2ace8f9ab4 | ||
|
|
914fa895c5 | ||
|
|
93e398e7a5 | ||
|
|
043f029f2c | ||
|
|
de90f80d72 | ||
|
|
cc9de82034 | ||
|
|
05924a36e1 | ||
|
|
ed9dcc39b7 | ||
|
|
1511aa2082 | ||
|
|
f88f3ef925 | ||
|
|
adce8ac1b5 | ||
|
|
ddafee7e0e | ||
|
|
877f37d052 | ||
|
|
5210966a19 | ||
|
|
4ccfb9d3f5 | ||
|
|
fc7d4dadca | ||
|
|
fb86e790b8 | ||
|
|
deb592d4ce | ||
|
|
8030c554e8 | ||
|
|
472e1e875d | ||
|
|
ba9cdde703 | ||
|
|
8d5a64b599 | ||
|
|
351dfd48c3 | ||
|
|
0e2a7e1bba | ||
|
|
54c9d57a22 | ||
|
|
343836a026 | ||
|
|
ac86be7e14 | ||
|
|
6ef8843f1c | ||
|
|
33a7cec1af | ||
|
|
81e734b1fc | ||
|
|
b3dd6c1219 | ||
|
|
ecf71e8664 | ||
|
|
1d170cf634 | ||
|
|
c19917e9a4 | ||
|
|
9922c22291 | ||
|
|
f6c0735238 | ||
|
|
1d2a822f2a | ||
|
|
92e640a46b | ||
|
|
618cb3141c | ||
|
|
49d0e0246a | ||
|
|
0a4e542ba9 | ||
|
|
0721c57fae | ||
|
|
57cec34d53 | ||
|
|
c20f836d71 | ||
|
|
390f52ade0 | ||
|
|
1f30223b54 | ||
|
|
b7d273daea | ||
|
|
8226e418f4 | ||
|
|
48849dec89 | ||
|
|
864b24fe03 | ||
|
|
26e6a435f2 | ||
|
|
f8104fd759 | ||
|
|
ef2cd7a1f4 | ||
|
|
ee2d06e622 | ||
|
|
69e68e696e | ||
|
|
64b32d88c0 | ||
|
|
a0d0ec0e1e | ||
|
|
da578cfcad | ||
|
|
14e4ab59cb | ||
|
|
7c48828d69 | ||
|
|
674ca01a3b | ||
|
|
f0b6335966 | ||
|
|
165f132613 | ||
|
|
da0448923b | ||
|
|
cca8b4421b | ||
|
|
07df1bcec3 | ||
|
|
0c5dc63bf5 | ||
|
|
a9699684ce | ||
|
|
1bc3ba98ed | ||
|
|
08284d8f3d | ||
|
|
1848fcb819 | ||
|
|
6224c05e82 | ||
|
|
68fc915444 | ||
|
|
7cd68cc3b5 | ||
|
|
e37ccd95d1 | ||
|
|
871e89e0b7 | ||
|
|
78077028fe | ||
|
|
5d0f5155cf | ||
|
|
946ca4febd | ||
|
|
593e05f72e | ||
|
|
faaedb17ea | ||
|
|
0ab23862a9 | ||
|
|
7b03a44a9c | ||
|
|
50715d6467 | ||
|
|
0bb513c19a | ||
|
|
536c252310 | ||
|
|
d1b2ca0a29 | ||
|
|
9730ba65bc | ||
|
|
247580c456 | ||
|
|
114cca2049 | ||
|
|
928a65a29c | ||
|
|
1a88a06948 | ||
|
|
44638b73d4 | ||
|
|
db15896a6b | ||
|
|
bf160445dd | ||
|
|
d2956bc64b | ||
|
|
2486159e96 | ||
|
|
b461dcb2bd | ||
|
|
1f474b68ce | ||
|
|
e6ec1ec060 | ||
|
|
1b038d55a2 | ||
|
|
f4fd8fe6f1 | ||
|
|
e9f731b57f | ||
|
|
e2fb0355df | ||
|
|
4057431f63 | ||
|
|
0dcfa0be81 | ||
|
|
3348e87cb7 | ||
|
|
b32a499774 | ||
|
|
576588b70d | ||
|
|
d725a381d7 | ||
|
|
83f5e07f9d | ||
|
|
a9556ee3f0 | ||
|
|
041d8b4b92 | ||
|
|
2058a53c1d | ||
|
|
123fecf04a | ||
|
|
b321025921 | ||
|
|
c1115b1491 | ||
|
|
cd6403fec8 | ||
|
|
b7528760b5 | ||
|
|
d47c774f8c | ||
|
|
8bf9414387 | ||
|
|
0c6c71d167 | ||
|
|
e3b80a848c | ||
|
|
9b33abc0ea | ||
|
|
d68facae8e | ||
|
|
73dfb7b2ba | ||
|
|
e689a33a09 | ||
|
|
d68bcc43dd | ||
|
|
9861da02d5 | ||
|
|
94c805d0f5 | ||
|
|
487ecb3890 | ||
|
|
869c45b5c0 | ||
|
|
5433c66200 | ||
|
|
c74419d834 | ||
|
|
e2c48d893c | ||
|
|
b360de5291 | ||
|
|
ed2ecab0a2 | ||
|
|
872045681b | ||
|
|
2528c1c35a | ||
|
|
e93f9411f3 | ||
|
|
4049a3b58a | ||
|
|
6318a74adf | ||
|
|
e7101f155e | ||
|
|
0f1c7536ba | ||
|
|
dc89ab643e | ||
|
|
0a400437ff | ||
|
|
023a01f411 | ||
|
|
2c42bf9900 | ||
|
|
523b65e443 | ||
|
|
e07198ad41 | ||
|
|
be6761526a | ||
|
|
72b6c5ede6 | ||
|
|
8dddcea5c4 | ||
|
|
0e8c00a8eb | ||
|
|
0aa702fa70 | ||
|
|
4d72506b1c | ||
|
|
2be9b67e74 | ||
|
|
c617ef124a | ||
|
|
114e480388 | ||
|
|
e31a8e9d32 | ||
|
|
460ad3c13d | ||
|
|
2687d671a0 | ||
|
|
deb216e3b4 | ||
|
|
e122569d2c | ||
|
|
65649bc032 | ||
|
|
cf59b41790 | ||
|
|
09054bb053 | ||
|
|
ea0b14dca5 | ||
|
|
4a80758357 | ||
|
|
efd0f50d56 | ||
|
|
a45c5b91f3 | ||
|
|
fc22bfb2a1 | ||
|
|
2152879cd7 | ||
|
|
f8d57f7c70 | ||
|
|
a3fe5cbc43 | ||
|
|
a90608d240 | ||
|
|
8d938e3b44 | ||
|
|
7b7e2b3dc4 | ||
|
|
8f2c86a878 | ||
|
|
06a528c5d9 | ||
|
|
bb1636f255 | ||
|
|
619489a93b | ||
|
|
0f0086591c | ||
|
|
531dcc8d26 | ||
|
|
b1c710e1ea | ||
|
|
a4a9fc62bc | ||
|
|
e58fbc8d1f | ||
|
|
b4b4f266a8 | ||
|
|
f9d35cc5e6 | ||
|
|
1892f72a13 | ||
|
|
fdbc184e0a | ||
|
|
18cbfc6788 | ||
|
|
c821b0ffc4 | ||
|
|
9833a5038d | ||
|
|
40a89e02d5 | ||
|
|
bc06dbffd0 | ||
|
|
f5fa23e0c1 | ||
|
|
e8c3947b21 | ||
|
|
ef26ee0d1f | ||
|
|
3d006ec663 | ||
|
|
52590e84dd | ||
|
|
e3a1c95851 | ||
|
|
db4540743f | ||
|
|
0cb0963c2b | ||
|
|
beae065794 | ||
|
|
46c5637dae | ||
|
|
2217a327e7 | ||
|
|
45d8f0d217 | ||
|
|
add5d83014 | ||
|
|
9751199231 | ||
|
|
883221f8ec | ||
|
|
3e45d67e0d | ||
|
|
df346aeea9 | ||
|
|
4317369480 | ||
|
|
e04d16bdae | ||
|
|
cb66d8d6f3 | ||
|
|
14a2fa80f6 | ||
|
|
ee788b7af3 | ||
|
|
3956ca7e55 | ||
|
|
bc37ec7156 | ||
|
|
f07408eb81 | ||
|
|
cdf5baef7f | ||
|
|
65b154f36c | ||
|
|
6ea7a0792d | ||
|
|
ed961e459f | ||
|
|
8b5f242f73 | ||
|
|
311bf1a88a | ||
|
|
8343f0bb23 | ||
|
|
3b73a09d36 | ||
|
|
412249d2d1 | ||
|
|
480125d859 | ||
|
|
0ab1d1e051 | ||
|
|
85374fa44b | ||
|
|
c319e46c8b | ||
|
|
f17087d1f4 | ||
|
|
da4469e1e0 | ||
|
|
42a4648475 | ||
|
|
edc8ec274b | ||
|
|
15b1a05fee | ||
|
|
1e216c03c6 | ||
|
|
0a0b2e6cb1 | ||
|
|
ac6770acff | ||
|
|
bfc87e5932 | ||
|
|
3e1bd19a45 | ||
|
|
d71b747d1c | ||
|
|
4d211fcf73 | ||
|
|
bc5ab8f3e1 | ||
|
|
bb528ea4b9 | ||
|
|
464a04c1e5 | ||
|
|
f4f8e7276f | ||
|
|
3ccf2e05f5 | ||
|
|
0ee2872f5b | ||
|
|
5112d1b215 | ||
|
|
275a170f15 | ||
|
|
17aa24baf9 | ||
|
|
f01476757a | ||
|
|
fe011d00fd | ||
|
|
30ae099825 | ||
|
|
812641342d | ||
|
|
88848c224c | ||
|
|
c107adabac | ||
|
|
38c4a673da | ||
|
|
89933951f5 | ||
|
|
e3523093b1 | ||
|
|
1faf3ee5ed | ||
|
|
286c76a05f | ||
|
|
5745b5a096 | ||
|
|
a060ae3b15 | ||
|
|
77982c5d8e | ||
|
|
4cb84df36a | ||
|
|
d82c8f66c2 | ||
|
|
43fd834d23 | ||
|
|
9b2fc48a55 | ||
|
|
a4dad5dc9d | ||
|
|
84830a268a | ||
|
|
37ef3eb7c5 | ||
|
|
7f987fcc26 | ||
|
|
a0ea45c9a6 | ||
|
|
1f5a782ecd | ||
|
|
c300d4b447 | ||
|
|
c3a6cd7356 | ||
|
|
b75e25c4d6 | ||
|
|
54383665d8 | ||
|
|
697cc1a08f | ||
|
|
652beef9c1 | ||
|
|
146df7b166 | ||
|
|
8f23eb5ee8 | ||
|
|
30b263c27c | ||
|
|
10b704278a | ||
|
|
a289f9cbd9 | ||
|
|
97d68a1be5 | ||
|
|
57e0d39cdc | ||
|
|
1c033868e7 | ||
|
|
4532366009 | ||
|
|
8e9fa44fc2 | ||
|
|
11a66ea148 | ||
|
|
f94e20d4c3 | ||
|
|
71c74f61e2 | ||
|
|
e2592684ed | ||
|
|
79c9381201 | ||
|
|
6a5712fdb6 | ||
|
|
a85faf76ac | ||
|
|
c0a04210e9 | ||
|
|
5adb334cb2 | ||
|
|
182ec8741f | ||
|
|
bafd17b00a | ||
|
|
802ee58054 | ||
|
|
0e83ba00b9 | ||
|
|
3326a5c838 | ||
|
|
f05dd556db | ||
|
|
964893a622 | ||
|
|
9a804b951f | ||
|
|
d699087786 | ||
|
|
26b42727fe | ||
|
|
8756b78a4e | ||
|
|
e9c771d55d | ||
|
|
a58f0166fa | ||
|
|
4328d4d700 | ||
|
|
28ee2618c1 | ||
|
|
a4b850ec33 | ||
|
|
7e0b21f7c8 | ||
|
|
71c6705ab1 | ||
|
|
a57769771f | ||
|
|
6a2c131a8a | ||
|
|
67ac157545 | ||
|
|
ef36cfd698 | ||
|
|
e0637ad6aa | ||
|
|
09a21a07ff | ||
|
|
65a14dcef3 | ||
|
|
97c12b2327 | ||
|
|
a290c81a75 | ||
|
|
cc5d5cfee5 | ||
|
|
03cd710abc | ||
|
|
287f639fbc | ||
|
|
aecf66981c | ||
|
|
db8090f287 | ||
|
|
ae8614fa98 | ||
|
|
e7f78ea5a3 | ||
|
|
b9355f7a0c | ||
|
|
9530676f8a | ||
|
|
ef23520d22 | ||
|
|
e67a6e6400 | ||
|
|
e6871b86e1 | ||
|
|
75937e8b53 | ||
|
|
d2919791d1 | ||
|
|
9019cd8462 | ||
|
|
5e58bd459e | ||
|
|
3badf0668f | ||
|
|
c719297d70 | ||
|
|
e19cd23be2 | ||
|
|
9266fb7c20 | ||
|
|
99645d2c27 | ||
|
|
a1ad7fac85 | ||
|
|
ebd2f12cc4 | ||
|
|
e2ee7b164c | ||
|
|
36ed062739 | ||
|
|
d12d6e3926 | ||
|
|
1851a9c01f | ||
|
|
bbeb0be343 | ||
|
|
9c22fb7e54 | ||
|
|
ef2403179b | ||
|
|
df178d4bfc | ||
|
|
318cdd33c5 | ||
|
|
9087b037dd | ||
|
|
6991569a1c | ||
|
|
32bc600be4 | ||
|
|
a52a37d08c | ||
|
|
dd58dd0815 | ||
|
|
37a35c955a | ||
|
|
ddddb431c2 | ||
|
|
78c27579a3 | ||
|
|
9aebc35d86 | ||
|
|
fc63f47ffa | ||
|
|
0b83ea6ff3 | ||
|
|
191b5763ec | ||
|
|
5397a8e5e0 | ||
|
|
7949402888 | ||
|
|
2e3920d41a | ||
|
|
dd4fc823f6 | ||
|
|
135b9ea9b0 | ||
|
|
17a106cefe | ||
|
|
496da99392 | ||
|
|
107486adfb | ||
|
|
ef9e7f0e57 | ||
|
|
ecc8ad3833 | ||
|
|
93c2b23b72 | ||
|
|
2e492a5b87 | ||
|
|
91c87d1322 | ||
|
|
91b8f160f8 | ||
|
|
32f3ce45e9 | ||
|
|
19bfe48a67 | ||
|
|
19951fccf7 | ||
|
|
7dcada9122 | ||
|
|
dab307cb6d | ||
|
|
e96eb4781c | ||
|
|
f64b098fcc | ||
|
|
243432e04e | ||
|
|
9f7b9b92cd | ||
|
|
3402f0e735 | ||
|
|
3e39651074 | ||
|
|
741301848f | ||
|
|
b593dedba1 | ||
|
|
e104b8030e | ||
|
|
e74e879e12 | ||
|
|
1f1d93ded9 | ||
|
|
9c31462f15 | ||
|
|
f53be5c8d2 | ||
|
|
03164fe8b1 | ||
|
|
81b53f0b20 | ||
|
|
75474075b1 | ||
|
|
5946a08267 | ||
|
|
b997f5e749 | ||
|
|
27f300bda5 | ||
|
|
557f11985d | ||
|
|
40f9ba5697 | ||
|
|
26a353efc7 | ||
|
|
990d3d730d | ||
|
|
75f7a93fe8 | ||
|
|
bc51ab4b5f | ||
|
|
82fce7f34f | ||
|
|
5f4fa02b42 | ||
|
|
cfef87059b | ||
|
|
b948b216c6 | ||
|
|
f156b909e7 | ||
|
|
0b46210f61 | ||
|
|
03eee4b6de | ||
|
|
09ffaba89b | ||
|
|
b39f203b12 | ||
|
|
4002bb615d | ||
|
|
583e391d83 | ||
|
|
55ae6e1eb7 | ||
|
|
b4fe685a46 | ||
|
|
a93de9cf92 | ||
|
|
74bf5946e1 | ||
|
|
04c99aafc2 | ||
|
|
ee81325cb3 | ||
|
|
bdc6348b1a | ||
|
|
68abaa3642 | ||
|
|
0c3acda26e | ||
|
|
650090d298 | ||
|
|
70220f9487 | ||
|
|
aad1fbe2c4 | ||
|
|
2d7914859c | ||
|
|
ba6a99a65f | ||
|
|
4ac63c2373 | ||
|
|
8d0f32443a | ||
|
|
9e75090a39 | ||
|
|
5cff71104c | ||
|
|
5ef8526874 | ||
|
|
46faafc413 | ||
|
|
0710b99ac4 | ||
|
|
9ebe1b6da8 | ||
|
|
98dd49c651 | ||
|
|
a115bb0d65 | ||
|
|
f1625cd5ff | ||
|
|
010ca1677f | ||
|
|
62d62f976c | ||
|
|
24ad575a4e | ||
|
|
1cd6a22284 | ||
|
|
343e065292 | ||
|
|
2849a864e0 | ||
|
|
ae0338e469 | ||
|
|
ca1ef737cb | ||
|
|
589e46353b | ||
|
|
7edbc03494 | ||
|
|
f359071d96 | ||
|
|
57bae9859e | ||
|
|
96e9ffdcdc | ||
|
|
eb4601141e | ||
|
|
39686b3b8d | ||
|
|
a24d3e9809 | ||
|
|
405c74a4ef | ||
|
|
ee828046b8 | ||
|
|
789082bc00 | ||
|
|
3e4c2450cd | ||
|
|
ea2f51d2ba | ||
|
|
7b2bdbd08f | ||
|
|
f654dc9325 | ||
|
|
d78c105570 | ||
|
|
a32498ea7d | ||
|
|
5d6ee7adf3 | ||
|
|
ffa25f47da | ||
|
|
f866dc05d1 | ||
|
|
69436fd79b | ||
|
|
f185ca6f55 | ||
|
|
f3fc8d62b6 | ||
|
|
71f94a774f | ||
|
|
07349e6107 | ||
|
|
5ef00e8c6a | ||
|
|
c5e5e657dd | ||
|
|
69e1e5c507 | ||
|
|
481f1b9188 | ||
|
|
d4f5832554 | ||
|
|
ca3275ba92 | ||
|
|
d577add8a0 | ||
|
|
90bd065f7e | ||
|
|
219f05b160 | ||
|
|
c8091ee8a8 | ||
|
|
63f367e1d6 | ||
|
|
e701d7b713 | ||
|
|
79bd1f1d6c | ||
|
|
ac51cc0236 | ||
|
|
b9c23dc00e | ||
|
|
f4e96995bc | ||
|
|
33165db598 | ||
|
|
4c10e4847b | ||
|
|
8d68b55333 | ||
|
|
251de1f540 | ||
|
|
123f60fa93 | ||
|
|
643faf71c8 | ||
|
|
eb9a67f2af | ||
|
|
1ae0498595 | ||
|
|
f1a5254755 | ||
|
|
de2542e391 | ||
|
|
a2c46fbb5d | ||
|
|
f51e0a087d | ||
|
|
d9108b0948 | ||
|
|
d7a017e1c3 | ||
|
|
fffdf82540 | ||
|
|
a16e624766 | ||
|
|
decce1d35c | ||
|
|
468832af3f |
347
.agents/skills/blacksmith-testbox/SKILL.md
Normal file
347
.agents/skills/blacksmith-testbox/SKILL.md
Normal file
@ -0,0 +1,347 @@
|
||||
---
|
||||
name: blacksmith-testbox
|
||||
description: Run Blacksmith Testbox for ClawHub CI-parity checks, hosted services, broad Bun gates, or builds local cannot reproduce without hurting developer machines.
|
||||
---
|
||||
|
||||
# Blacksmith Testbox
|
||||
|
||||
## Scope
|
||||
|
||||
Use Testbox when you need remote CI parity, injected secrets, hosted services,
|
||||
or an OS/runtime image that your local machine cannot provide cheaply.
|
||||
|
||||
Do not default to Testbox for every local test/build loop. If the repo has
|
||||
documented local commands for normal iteration, use those first so you keep
|
||||
warm caches, local build state, and fast feedback.
|
||||
|
||||
Testbox is the expensive path. Reach for it deliberately.
|
||||
|
||||
ClawHub maintainers can opt into Testbox-first validation by setting
|
||||
`CLAWHUB_TESTBOX=1` in their environment or standing agent rules. This mode is
|
||||
maintainers-only and requires Blacksmith access.
|
||||
|
||||
When `CLAWHUB_TESTBOX=1` is set in ClawHub:
|
||||
|
||||
- Pre-warm a Testbox early for longer, wider, or uncertain work.
|
||||
- Prefer Testbox for broad Bun gates, e2e, Convex-ish deploy parity, package
|
||||
proof, and expensive validation.
|
||||
- Reuse the same Testbox ID for every run command in the same task/session.
|
||||
- Use local commands only when the task explicitly sets
|
||||
`CLAWHUB_LOCAL_CHECK_MODE=throttled|full`, or when the user asks for local
|
||||
proof.
|
||||
|
||||
## Install The CLI
|
||||
|
||||
If `blacksmith` is not installed, install it:
|
||||
|
||||
```bash
|
||||
curl -fsSL https://get.blacksmith.sh | sh
|
||||
```
|
||||
|
||||
For the canary channel:
|
||||
|
||||
```bash
|
||||
BLACKSMITH_CHANNEL=canary sh -c 'curl -fsSL https://get.blacksmith.sh | sh'
|
||||
```
|
||||
|
||||
Then authenticate:
|
||||
|
||||
```bash
|
||||
blacksmith auth login
|
||||
```
|
||||
|
||||
## Agent-Triggered Browser Auth
|
||||
|
||||
When an agent needs to ensure the user is authenticated before running Testbox
|
||||
commands, use browser-based auth with non-interactive mode. This opens the
|
||||
browser for the user to sign in; the agent does not interact with the browser.
|
||||
|
||||
`--organization` is required with `--non-interactive`:
|
||||
|
||||
```bash
|
||||
blacksmith auth login --non-interactive --organization <org-slug>
|
||||
```
|
||||
|
||||
The org slug can come from `BLACKSMITH_ORG` or the `--org` global flag. Do not
|
||||
use `--api-token` for this browser flow; that is for headless/token auth.
|
||||
|
||||
## Decide First: Local Or Testbox
|
||||
|
||||
Before warming anything up, check the repo's own instructions.
|
||||
|
||||
Prefer local commands when:
|
||||
|
||||
- the repo documents a supported local test/build workflow
|
||||
- you are iterating on unit tests, lint, typecheck, formatting, or other
|
||||
local-only validation
|
||||
- the value comes from warm local caches and fast repeat runs
|
||||
- the command does not need remote secrets, hosted services, or CI-only images
|
||||
|
||||
Prefer Testbox when:
|
||||
|
||||
- `CLAWHUB_TESTBOX=1` is set by the user, agent environment, or standing rules
|
||||
- the repo explicitly requires CI-parity or remote validation
|
||||
- the command needs secrets, service containers, or provisioned infra
|
||||
- you are reproducing CI-only failures
|
||||
- you need the exact workflow image/job environment from GitHub Actions
|
||||
|
||||
For ClawHub specifically, normal local iteration stays local unless maintainer
|
||||
Testbox mode is enabled with `CLAWHUB_TESTBOX=1`:
|
||||
|
||||
- `bun run format:check`
|
||||
- `bun run lint`
|
||||
- `bun run test`
|
||||
- `bun run coverage`
|
||||
- `bunx tsc --noEmit`
|
||||
- `bun run build`
|
||||
|
||||
If `CLAWHUB_TESTBOX=1` is enabled, run those same repo commands inside the warm
|
||||
Testbox. If the user wants laptop-friendly local proof for one command, use the
|
||||
explicit escape hatch `CLAWHUB_LOCAL_CHECK_MODE=throttled`.
|
||||
|
||||
In `.codex` worktrees without a `node_modules` symlink, do not run
|
||||
`bun install` just to validate locally. Use syntax checks or Testbox.
|
||||
|
||||
## Setup: Warmup Before Coding
|
||||
|
||||
If you decided Testbox is warranted, warm one up early. This returns an ID
|
||||
instantly and boots the CI environment in the background while you work:
|
||||
|
||||
```bash
|
||||
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
|
||||
# -> tbx_01jkz5b3t9...
|
||||
```
|
||||
|
||||
Save this ID in the current session. You need it for every `run` command.
|
||||
Treat `blacksmith testbox list` as diagnostics, not a reusable work queue.
|
||||
Listed boxes can be visible at the org/repo level while still being unusable or
|
||||
stale for the current local agent lane.
|
||||
|
||||
For ClawHub maintainer Testbox mode, claim the ID in the current checkout:
|
||||
|
||||
```bash
|
||||
bun run testbox:claim -- --id <ID>
|
||||
```
|
||||
|
||||
Warmup dispatches `.github/workflows/ci-check-testbox.yml`, which provisions a
|
||||
VM with Bun, Node, dependency install/cache, and a clean checkout of the repo at
|
||||
the chosen ref.
|
||||
|
||||
Bootstrap note: GitHub only exposes `workflow_dispatch` workflows through the
|
||||
Actions API after the workflow file exists on the default branch. If a brand-new
|
||||
Testbox workflow exists only on a feature branch, `blacksmith testbox warmup
|
||||
ci-check-testbox.yml --ref <branch>` can return a GitHub 404 even though the
|
||||
file exists on that branch. Land the workflow bootstrap first, then dispatch
|
||||
branch refs normally.
|
||||
|
||||
Options:
|
||||
|
||||
```text
|
||||
--ref <branch|tag> Git ref to dispatch against
|
||||
--job <name> Specific job within the workflow, if it has multiple
|
||||
--idle-timeout <min> Idle timeout in minutes
|
||||
```
|
||||
|
||||
## Critical: Always Run From The Repo Root
|
||||
|
||||
Always invoke `blacksmith testbox` commands from the root of the git
|
||||
repository. The CLI syncs the current working directory to the testbox using
|
||||
rsync with `--delete`. If you run from a subdirectory, rsync mirrors only that
|
||||
subdirectory and can delete everything else on the testbox.
|
||||
|
||||
Correct:
|
||||
|
||||
```bash
|
||||
blacksmith testbox run --id <ID> "bun run test"
|
||||
blacksmith testbox run --id <ID> "cd packages/clawhub && bun run verify"
|
||||
```
|
||||
|
||||
Wrong:
|
||||
|
||||
```bash
|
||||
cd packages/clawhub && blacksmith testbox run --id <ID> "bun run verify"
|
||||
```
|
||||
|
||||
If your shell is in a subdirectory, move back first:
|
||||
|
||||
```bash
|
||||
cd "$(git rev-parse --show-toplevel)"
|
||||
```
|
||||
|
||||
## Running Commands
|
||||
|
||||
Raw Blacksmith form:
|
||||
|
||||
```bash
|
||||
blacksmith testbox run --id <ID> "<command>"
|
||||
```
|
||||
|
||||
The `run` command waits for the testbox to become ready if it is still booting,
|
||||
so you can call `run` immediately after warmup.
|
||||
|
||||
In ClawHub, prefer the guarded runner wrapper so stale/reused ids fail before
|
||||
the Blacksmith CLI spends time syncing or emits a confusing missing-key error:
|
||||
|
||||
```bash
|
||||
bun run testbox:run -- --id <ID> -- bun run lint
|
||||
bun run testbox:run -- --id <ID> -- bun run test
|
||||
bun run testbox:run -- --id <ID> -- bun run build
|
||||
```
|
||||
|
||||
The wrapper refuses to run when the local per-Testbox key is missing or when
|
||||
the id was not claimed by this ClawHub checkout with:
|
||||
|
||||
```bash
|
||||
bun run testbox:claim -- --id <ID>
|
||||
```
|
||||
|
||||
Treat that as the expected remediation, not as a GitHub account or normal
|
||||
SSH-key problem. A local key alone is not enough; a ready box may still carry
|
||||
stale rsync state from another lane.
|
||||
|
||||
If the agent crashes, the remote box relies on Blacksmith's idle timeout. The
|
||||
local ClawHub claim marker is not deleted automatically, so the wrapper treats
|
||||
claims older than 12 hours as stale. Override only for intentional long-running
|
||||
work with:
|
||||
|
||||
```bash
|
||||
CLAWHUB_TESTBOX_CLAIM_TTL_MINUTES=<minutes>
|
||||
```
|
||||
|
||||
Before spending a broad gate on a manually assembled command, run:
|
||||
|
||||
```bash
|
||||
bun run testbox:sanity -- --id <ID>
|
||||
```
|
||||
|
||||
## Downloading Files From A Testbox
|
||||
|
||||
Use the `download` command to retrieve files or directories from a running
|
||||
testbox to your local machine. This is useful for fetching build artifacts,
|
||||
test results, coverage reports, or any output generated on the testbox.
|
||||
|
||||
```bash
|
||||
blacksmith testbox download --id <ID> <remote-path> [local-path]
|
||||
```
|
||||
|
||||
The remote path is relative to the testbox working directory. If no local path
|
||||
is specified, the file is saved to the current directory using the same base
|
||||
name.
|
||||
|
||||
Examples:
|
||||
|
||||
```bash
|
||||
blacksmith testbox download --id <ID> coverage/lcov-report/ ./coverage/
|
||||
blacksmith testbox download --id <ID> test-results/ ./test-results/
|
||||
blacksmith testbox download --id <ID> dist/ ./dist/
|
||||
```
|
||||
|
||||
## How File Sync Works
|
||||
|
||||
Understanding this model is critical for using Testbox correctly.
|
||||
|
||||
When you call `run`, the CLI performs a delta sync of your local changes to the
|
||||
remote testbox before executing your command:
|
||||
|
||||
1. The testbox VM starts from a clean checkout at the warmup ref. The workflow
|
||||
setup steps run during warmup and populate dependency directories on the
|
||||
remote VM.
|
||||
2. On each `run`, the CLI uses git to detect which files changed locally since
|
||||
the last sync. It syncs only tracked files and untracked non-ignored files.
|
||||
3. `.gitignore`'d directories are never synced. `node_modules/`, `.bun/`,
|
||||
`.vite/`, `dist/`, `.output/`, `.nitro/`, and coverage outputs stay local.
|
||||
The testbox uses its own copies populated by the warmup workflow.
|
||||
4. If nothing has changed since the last sync, the sync is skipped.
|
||||
|
||||
Why this matters:
|
||||
|
||||
- If you modify `package.json` or `bun.lock`, re-run install on the testbox:
|
||||
|
||||
```bash
|
||||
bun run testbox:run -- --id <ID> -- bun install --frozen-lockfile
|
||||
```
|
||||
|
||||
- If tests depend on generated/build output, re-run the build on the testbox.
|
||||
- New untracked files sync as long as they are not gitignored.
|
||||
- Deleted files are also deleted on the remote testbox.
|
||||
|
||||
## Critical: Do Not Ban Local Tests
|
||||
|
||||
Do not assume local validation is forbidden. Many repos intentionally invest in
|
||||
fast, warm local loops, and forcing every run through Testbox destroys that
|
||||
advantage.
|
||||
|
||||
Use Testbox for checks that actually need it: remote parity, secrets, services,
|
||||
CI-only runners, expensive broad gates, or reproducibility against the workflow
|
||||
image.
|
||||
|
||||
ClawHub maintainer exception: if `CLAWHUB_TESTBOX=1` is set by the user or
|
||||
agent environment, treat Testbox as the normal validation path for this repo.
|
||||
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled|full` as the explicit local escape
|
||||
hatch.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Decide whether the repo's local loop is the right default. For ClawHub,
|
||||
`CLAWHUB_TESTBOX=1` makes Testbox the maintainer default.
|
||||
2. If Testbox is warranted, warm up early:
|
||||
`blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90`.
|
||||
3. Save the ID, then claim it:
|
||||
`bun run testbox:claim -- --id <ID>`.
|
||||
4. Write code while the testbox boots in the background.
|
||||
5. Run sanity before broad checks:
|
||||
`bun run testbox:sanity -- --id <ID>`.
|
||||
6. Run the remote command:
|
||||
`bun run testbox:run -- --id <ID> -- bun run lint`.
|
||||
7. If tests fail, fix code and re-run against the same warm box.
|
||||
8. If dependency manifests changed, run install in the box before testing.
|
||||
9. If you need artifacts, download them with `blacksmith testbox download`.
|
||||
10. Stop the box when done if it is no longer needed:
|
||||
`blacksmith testbox stop --id <ID>`.
|
||||
|
||||
## ClawHub Broad Gate
|
||||
|
||||
For a broad ClawHub proof in maintainer Testbox mode, use the repo package
|
||||
manager and keep the commands explicit:
|
||||
|
||||
```bash
|
||||
bun run testbox:run -- --id <ID> -- bun run format:check
|
||||
bun run testbox:run -- --id <ID> -- bun run lint
|
||||
bun run testbox:run -- --id <ID> -- bun run test
|
||||
bun run testbox:run -- --id <ID> -- bunx tsc --noEmit
|
||||
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/schema/tsconfig.json --noEmit
|
||||
bun run testbox:run -- --id <ID> -- bunx tsc -p packages/clawhub/tsconfig.json --noEmit
|
||||
bun run testbox:run -- --id <ID> -- bun run build
|
||||
```
|
||||
|
||||
For e2e:
|
||||
|
||||
```bash
|
||||
bun run testbox:run -- --id <ID> -- bun run test:e2e
|
||||
bun run testbox:run -- --id <ID> -- bun run test:pw
|
||||
```
|
||||
|
||||
## Waiting For Readiness
|
||||
|
||||
The `run` command automatically waits for the testbox, so explicit waiting is
|
||||
usually unnecessary. If you do need to check readiness separately, use
|
||||
`--wait`. Do not use a sleep-and-recheck loop.
|
||||
|
||||
```bash
|
||||
blacksmith testbox status --id <ID> --wait --wait-timeout 5m
|
||||
```
|
||||
|
||||
## Managing Testboxes
|
||||
|
||||
```bash
|
||||
blacksmith testbox status --id <ID>
|
||||
blacksmith testbox list
|
||||
blacksmith testbox stop --id <ID>
|
||||
```
|
||||
|
||||
Testboxes automatically shut down after being idle. For ClawHub maintainer
|
||||
work, use 90 minutes for long-running sessions:
|
||||
|
||||
```bash
|
||||
blacksmith testbox warmup ci-check-testbox.yml --idle-timeout 90
|
||||
```
|
||||
288
.agents/skills/convex-create-component/SKILL.md
Normal file
288
.agents/skills/convex-create-component/SKILL.md
Normal file
@ -0,0 +1,288 @@
|
||||
---
|
||||
name: convex-create-component
|
||||
description: Builds reusable Convex components with isolated tables and app-facing APIs. Use for new components, reusable backend modules, integrations, or component boundary work.
|
||||
---
|
||||
|
||||
# Convex Create Component
|
||||
|
||||
Create reusable Convex components with clear boundaries and a small app-facing API.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Creating a new Convex component in an existing app
|
||||
- Extracting reusable backend logic into a component
|
||||
- Building a third-party integration that should own its own tables and workflows
|
||||
- Packaging Convex functionality for reuse across multiple apps
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- One-off business logic that belongs in the main app
|
||||
- Thin utilities that do not need Convex tables or functions
|
||||
- App-level orchestration that should stay in `convex/`
|
||||
- Cases where a normal TypeScript library is enough
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Ask the user what they are building and what the end goal is. If the repo already makes the answer obvious, say so and confirm before proceeding.
|
||||
2. Choose the shape using the decision tree below and read the matching reference file.
|
||||
3. Decide whether a component is justified. Prefer normal app code or a regular library if the feature does not need isolated tables, backend functions, or reusable persistent state.
|
||||
4. Make a short plan for:
|
||||
- what tables the component owns
|
||||
- what public functions it exposes
|
||||
- what data must be passed in from the app (auth, env vars, parent IDs)
|
||||
- what stays in the app as wrappers or HTTP mounts
|
||||
5. Create the component structure with `convex.config.ts`, `schema.ts`, and function files.
|
||||
6. Implement functions using the component's own `./_generated/server` imports, not the app's generated files.
|
||||
7. Wire the component into the app with `app.use(...)`. If the app does not already have `convex/convex.config.ts`, create it.
|
||||
8. Call the component from the app through `components.<name>` using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction`.
|
||||
9. If React clients, HTTP callers, or public APIs need access, create wrapper functions in the app instead of exposing component functions directly.
|
||||
10. Run `npx convex dev` and fix codegen, type, or boundary issues before finishing.
|
||||
|
||||
## Choose the Shape
|
||||
|
||||
Ask the user, then pick one path:
|
||||
|
||||
| Goal | Shape | Reference |
|
||||
| ------------------------------------------------- | ---------------- | ----------------------------------- |
|
||||
| Component for this app only | Local | `references/local-components.md` |
|
||||
| Publish or share across apps | Packaged | `references/packaged-components.md` |
|
||||
| User explicitly needs local + shared library code | Hybrid | `references/hybrid-components.md` |
|
||||
| Not sure | Default to local | `references/local-components.md` |
|
||||
|
||||
Read exactly one reference file before proceeding.
|
||||
|
||||
## Default Approach
|
||||
|
||||
Unless the user explicitly wants an npm package, default to a local component:
|
||||
|
||||
- Put it under `convex/components/<componentName>/`
|
||||
- Define it with `defineComponent(...)` in its own `convex.config.ts`
|
||||
- Install it from the app's `convex/convex.config.ts` with `app.use(...)`
|
||||
- Let `npx convex dev` generate the component's own `_generated/` files
|
||||
|
||||
## Component Skeleton
|
||||
|
||||
A minimal local component with a table and two functions, plus the app wiring.
|
||||
|
||||
```ts
|
||||
// convex/components/notifications/convex.config.ts
|
||||
import { defineComponent } from "convex/server";
|
||||
|
||||
export default defineComponent("notifications");
|
||||
```
|
||||
|
||||
```ts
|
||||
// convex/components/notifications/schema.ts
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
notifications: defineTable({
|
||||
userId: v.string(),
|
||||
message: v.string(),
|
||||
read: v.boolean(),
|
||||
}).index("by_user", ["userId"]),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// convex/components/notifications/lib.ts
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server.js";
|
||||
|
||||
export const send = mutation({
|
||||
args: { userId: v.string(), message: v.string() },
|
||||
returns: v.id("notifications"),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.insert("notifications", {
|
||||
userId: args.userId,
|
||||
message: args.message,
|
||||
read: false,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const listUnread = query({
|
||||
args: { userId: v.string() },
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id("notifications"),
|
||||
_creationTime: v.number(),
|
||||
userId: v.string(),
|
||||
message: v.string(),
|
||||
read: v.boolean(),
|
||||
}),
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("notifications")
|
||||
.withIndex("by_user", (q) => q.eq("userId", args.userId))
|
||||
.filter((q) => q.eq(q.field("read"), false))
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// convex/convex.config.ts
|
||||
import { defineApp } from "convex/server";
|
||||
import notifications from "./components/notifications/convex.config.js";
|
||||
|
||||
const app = defineApp();
|
||||
app.use(notifications);
|
||||
|
||||
export default app;
|
||||
```
|
||||
|
||||
```ts
|
||||
// convex/notifications.ts (app-side wrapper)
|
||||
import { v } from "convex/values";
|
||||
import { mutation, query } from "./_generated/server";
|
||||
import { components } from "./_generated/api";
|
||||
import { getAuthUserId } from "@convex-dev/auth/server";
|
||||
|
||||
export const sendNotification = mutation({
|
||||
args: { message: v.string() },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Not authenticated");
|
||||
|
||||
await ctx.runMutation(components.notifications.lib.send, {
|
||||
userId,
|
||||
message: args.message,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
export const myUnread = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Not authenticated");
|
||||
|
||||
return await ctx.runQuery(components.notifications.lib.listUnread, {
|
||||
userId,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note the reference path shape: a function in `convex/components/notifications/lib.ts` is called as `components.notifications.lib.send` from the app.
|
||||
|
||||
## Critical Rules
|
||||
|
||||
- Keep authentication in the app, because `ctx.auth` is not available inside components.
|
||||
- Keep environment access in the app, because component functions cannot read `process.env`.
|
||||
- Pass parent app IDs across the boundary as strings, because `Id` types become plain strings in the app-facing `ComponentApi`.
|
||||
- Do not use `v.id("parentTable")` for app-owned tables inside component args or schema, because the component has no access to the app's table namespace.
|
||||
- Import `query`, `mutation`, and `action` from the component's own `./_generated/server`, not the app's generated files.
|
||||
- Do not expose component functions directly to clients. Create app wrappers when client access is needed, because components are internal and need auth/env wiring the app provides.
|
||||
- If the component defines HTTP handlers, mount the routes in the app's `convex/http.ts`, because components cannot register their own HTTP routes.
|
||||
- If the component needs pagination, use `paginator` from `convex-helpers` instead of built-in `.paginate()`, because `.paginate()` does not work across the component boundary.
|
||||
- Add `args` and `returns` validators to all public component functions, because the component boundary requires explicit type contracts.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Authentication and environment access
|
||||
|
||||
```ts
|
||||
// Bad: component code cannot rely on app auth or env
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
const apiKey = process.env.OPENAI_API_KEY;
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: the app resolves auth and env, then passes explicit values
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Not authenticated");
|
||||
|
||||
await ctx.runAction(components.translator.translate, {
|
||||
userId,
|
||||
apiKey: process.env.OPENAI_API_KEY,
|
||||
text: args.text,
|
||||
});
|
||||
```
|
||||
|
||||
### Client-facing API
|
||||
|
||||
```ts
|
||||
// Bad: assuming a component function is directly callable by clients
|
||||
export const send = components.notifications.send;
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: re-export through an app mutation or query
|
||||
export const sendNotification = mutation({
|
||||
args: { message: v.string() },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
if (!userId) throw new Error("Not authenticated");
|
||||
|
||||
await ctx.runMutation(components.notifications.lib.send, {
|
||||
userId,
|
||||
message: args.message,
|
||||
});
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### IDs across the boundary
|
||||
|
||||
```ts
|
||||
// Bad: parent app table IDs are not valid component validators
|
||||
args: {
|
||||
userId: v.id("users");
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: treat parent-owned IDs as strings at the boundary
|
||||
args: {
|
||||
userId: v.string();
|
||||
}
|
||||
```
|
||||
|
||||
### Advanced Patterns
|
||||
|
||||
For additional patterns including function handles for callbacks, deriving validators from schema, static configuration with a globals table, and class-based client wrappers, see `references/advanced-patterns.md`.
|
||||
|
||||
## Validation
|
||||
|
||||
Try validation in this order:
|
||||
|
||||
1. `npx convex codegen --component-dir convex/components/<name>`
|
||||
2. `npx convex codegen`
|
||||
3. `npx convex dev`
|
||||
|
||||
Important:
|
||||
|
||||
- Fresh repos may fail these commands until `CONVEX_DEPLOYMENT` is configured.
|
||||
- Until codegen runs, component-local `./_generated/*` imports and app-side `components.<name>...` references will not typecheck.
|
||||
- If validation blocks on Convex login or deployment setup, stop and ask the user for that exact step instead of guessing.
|
||||
|
||||
## Reference Files
|
||||
|
||||
Read exactly one of these after the user confirms the goal:
|
||||
|
||||
- `references/local-components.md`
|
||||
- `references/packaged-components.md`
|
||||
- `references/hybrid-components.md`
|
||||
|
||||
Official docs: [Authoring Components](https://docs.convex.dev/components/authoring)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Asked the user what they want to build and confirmed the shape
|
||||
- [ ] Read the matching reference file
|
||||
- [ ] Confirmed a component is the right abstraction
|
||||
- [ ] Planned tables, public API, boundaries, and app wrappers
|
||||
- [ ] Component lives under `convex/components/<name>/` (or package layout if publishing)
|
||||
- [ ] Component imports from its own `./_generated/server`
|
||||
- [ ] Auth, env access, and HTTP routes stay in the app
|
||||
- [ ] Parent app IDs cross the boundary as `v.string()`
|
||||
- [ ] Public functions have `args` and `returns` validators
|
||||
- [ ] Ran `npx convex dev` and fixed codegen or type issues
|
||||
10
.agents/skills/convex-create-component/agents/openai.yaml
Normal file
10
.agents/skills/convex-create-component/agents/openai.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Convex Create Component"
|
||||
short_description: "Design and build reusable Convex components with clear boundaries."
|
||||
icon_small: "./assets/icon.svg"
|
||||
icon_large: "./assets/icon.svg"
|
||||
brand_color: "#14B8A6"
|
||||
default_prompt: "Help me create a Convex component for this feature. First check that a component is actually justified, then design the tables, API surface, and app-facing wrappers before implementing it."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
3
.agents/skills/convex-create-component/assets/icon.svg
Normal file
3
.agents/skills/convex-create-component/assets/icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="m21 7.5-2.25-1.313M21 7.5v2.25m0-2.25-2.25 1.313M3 7.5l2.25-1.313M3 7.5l2.25 1.313M3 7.5v2.25m9 3 2.25-1.313M12 12.75l-2.25-1.313M12 12.75V15m0 6.75 2.25-1.313M12 21.75V19.5m0 2.25-2.25-1.313m0-16.875L12 2.25l2.25 1.313M21 14.25v2.25l-2.25 1.313m-13.5 0L3 16.5v-2.25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 485 B |
@ -0,0 +1,134 @@
|
||||
# Advanced Component Patterns
|
||||
|
||||
Additional patterns for Convex components that go beyond the basics covered in the main skill file.
|
||||
|
||||
## Function Handles for callbacks
|
||||
|
||||
When the app needs to pass a callback function to the component, use function handles. This is common for components that run app-defined logic on a schedule or in a workflow.
|
||||
|
||||
```ts
|
||||
// App side: create a handle and pass it to the component
|
||||
import { createFunctionHandle } from "convex/server";
|
||||
|
||||
export const startJob = mutation({
|
||||
handler: async (ctx) => {
|
||||
const handle = await createFunctionHandle(internal.myModule.processItem);
|
||||
await ctx.runMutation(components.workpool.enqueue, {
|
||||
callback: handle,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Component side: accept and invoke the handle
|
||||
import { v } from "convex/values";
|
||||
import type { FunctionHandle } from "convex/server";
|
||||
import { mutation } from "./_generated/server.js";
|
||||
|
||||
export const enqueue = mutation({
|
||||
args: { callback: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const handle = args.callback as FunctionHandle<"mutation">;
|
||||
await ctx.scheduler.runAfter(0, handle, {});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Deriving validators from schema
|
||||
|
||||
Instead of manually repeating field types in return validators, extend the schema validator:
|
||||
|
||||
```ts
|
||||
import { v } from "convex/values";
|
||||
import schema from "./schema.js";
|
||||
|
||||
const notificationDoc = schema.tables.notifications.validator.extend({
|
||||
_id: v.id("notifications"),
|
||||
_creationTime: v.number(),
|
||||
});
|
||||
|
||||
export const getLatest = query({
|
||||
args: {},
|
||||
returns: v.nullable(notificationDoc),
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("notifications").order("desc").first();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Static configuration with a globals table
|
||||
|
||||
A common pattern for component configuration is a single-document "globals" table:
|
||||
|
||||
```ts
|
||||
// schema.ts
|
||||
export default defineSchema({
|
||||
globals: defineTable({
|
||||
maxRetries: v.number(),
|
||||
webhookUrl: v.optional(v.string()),
|
||||
}),
|
||||
// ... other tables
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// lib.ts
|
||||
export const configure = mutation({
|
||||
args: { maxRetries: v.number(), webhookUrl: v.optional(v.string()) },
|
||||
returns: v.null(),
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db.query("globals").first();
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, args);
|
||||
} else {
|
||||
await ctx.db.insert("globals", args);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Class-based client wrappers
|
||||
|
||||
For components with many functions or configuration options, a class-based client provides a cleaner API. This pattern is common in published components.
|
||||
|
||||
```ts
|
||||
// src/client/index.ts
|
||||
import type { GenericMutationCtx, GenericDataModel } from "convex/server";
|
||||
import type { ComponentApi } from "../component/_generated/component.js";
|
||||
|
||||
type MutationCtx = Pick<GenericMutationCtx<GenericDataModel>, "runMutation">;
|
||||
|
||||
export class Notifications {
|
||||
constructor(
|
||||
private component: ComponentApi,
|
||||
private options?: { defaultChannel?: string },
|
||||
) {}
|
||||
|
||||
async send(ctx: MutationCtx, args: { userId: string; message: string }) {
|
||||
return await ctx.runMutation(this.component.lib.send, {
|
||||
...args,
|
||||
channel: this.options?.defaultChannel ?? "default",
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// App usage
|
||||
import { Notifications } from "@convex-dev/notifications";
|
||||
import { components } from "./_generated/api";
|
||||
|
||||
const notifications = new Notifications(components.notifications, {
|
||||
defaultChannel: "alerts",
|
||||
});
|
||||
|
||||
export const send = mutation({
|
||||
args: { message: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const userId = await getAuthUserId(ctx);
|
||||
await notifications.send(ctx, { userId, message: args.message });
|
||||
},
|
||||
});
|
||||
```
|
||||
@ -0,0 +1,37 @@
|
||||
# Hybrid Convex Components
|
||||
|
||||
Read this file only when the user explicitly wants a hybrid setup.
|
||||
|
||||
## What This Means
|
||||
|
||||
A hybrid component combines a local Convex component with shared library code.
|
||||
|
||||
This can help when:
|
||||
|
||||
- the user wants a local install but also shared package logic
|
||||
- the component needs extension points or override hooks
|
||||
- some logic should live in normal TypeScript code outside the component boundary
|
||||
|
||||
## Default Advice
|
||||
|
||||
Treat hybrid as an advanced option, not the default.
|
||||
|
||||
Before choosing it, ask:
|
||||
|
||||
- Why is a plain local component not enough?
|
||||
- Why is a packaged component not enough?
|
||||
- What exactly needs to stay overridable or shared?
|
||||
|
||||
If the answer is vague, fall back to local or packaged.
|
||||
|
||||
## Risks
|
||||
|
||||
- More moving parts
|
||||
- Harder upgrades and backwards compatibility
|
||||
- Easier to blur the component boundary
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] User explicitly needs hybrid behavior
|
||||
- [ ] Local-only and packaged-only options were considered first
|
||||
- [ ] The extension points are clearly defined before coding
|
||||
@ -0,0 +1,38 @@
|
||||
# Local Convex Components
|
||||
|
||||
Read this file when the component should live inside the current app and does not need to be published as an npm package.
|
||||
|
||||
## When to Choose This
|
||||
|
||||
- The user wants the simplest path
|
||||
- The component only needs to work in this repo
|
||||
- The goal is extracting app logic into a cleaner boundary
|
||||
|
||||
## Default Layout
|
||||
|
||||
Use this structure unless the repo already has a clear alternative pattern:
|
||||
|
||||
```text
|
||||
convex/
|
||||
convex.config.ts
|
||||
components/
|
||||
<name>/
|
||||
convex.config.ts
|
||||
schema.ts
|
||||
<feature>.ts
|
||||
```
|
||||
|
||||
## Workflow Notes
|
||||
|
||||
- Define the component with `defineComponent("<name>")`
|
||||
- Install it from the app with `defineApp()` and `app.use(...)`
|
||||
- Keep auth, env access, public API wrappers, and HTTP route mounting in the app
|
||||
- Let the component own isolated tables and reusable backend workflows
|
||||
- Add app wrappers if clients need to call into the component
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Component is inside `convex/components/<name>/`
|
||||
- [ ] App installs it with `app.use(...)`
|
||||
- [ ] Component owns only its own tables
|
||||
- [ ] App wrappers handle client-facing calls when needed
|
||||
@ -0,0 +1,51 @@
|
||||
# Packaged Convex Components
|
||||
|
||||
Read this file when the user wants a reusable npm package or a component shared across multiple apps.
|
||||
|
||||
## When to Choose This
|
||||
|
||||
- The user wants to publish the component
|
||||
- The user wants a stable reusable package boundary
|
||||
- The component will be shared across multiple apps or teams
|
||||
|
||||
## Default Approach
|
||||
|
||||
- Prefer starting from `npx create-convex@latest --component` when possible
|
||||
- Keep the official authoring docs as the source of truth for package layout and exports
|
||||
- Validate the bundled package through an example app, not just the source files
|
||||
|
||||
## Build Flow
|
||||
|
||||
When building a packaged component, make sure the bundled output exists before the example app tries to consume it.
|
||||
|
||||
Recommended order:
|
||||
|
||||
1. `npx convex codegen --component-dir ./path/to/component`
|
||||
2. Run the package build command
|
||||
3. Run `npx convex dev --typecheck-components` in the example app
|
||||
|
||||
Do not assume normal app codegen is enough for packaged component workflows.
|
||||
|
||||
## Package Exports
|
||||
|
||||
If publishing to npm, make sure the package exposes the entry points apps need:
|
||||
|
||||
- package root for client helpers, types, or classes
|
||||
- `./convex.config.js` for installing the component
|
||||
- `./_generated/component.js` for the app-facing `ComponentApi` type
|
||||
- `./test` for testing helpers when applicable
|
||||
|
||||
## Testing
|
||||
|
||||
- Use `convex-test` for component logic
|
||||
- Register the component schema and modules with the test instance
|
||||
- Test app-side wrapper code from an example app that installs the package
|
||||
- Export a small helper from `./test` if consumers need easy test registration
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Packaging is actually required
|
||||
- [ ] Build order avoids bundle and codegen races
|
||||
- [ ] Package exports include install and typing entry points
|
||||
- [ ] Example app exercises the packaged component
|
||||
- [ ] Core behavior is covered by tests
|
||||
149
.agents/skills/convex-migration-helper/SKILL.md
Normal file
149
.agents/skills/convex-migration-helper/SKILL.md
Normal file
@ -0,0 +1,149 @@
|
||||
---
|
||||
name: convex-migration-helper
|
||||
description: Plans Convex schema and data migrations with widen-migrate-narrow and @convex-dev/migrations. Use for breaking schema changes, backfills, table reshaping, or zero-downtime rollouts.
|
||||
---
|
||||
|
||||
# Convex Migration Helper
|
||||
|
||||
Safely migrate Convex schemas and data when making breaking changes.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Adding new required fields to existing tables
|
||||
- Changing field types or structure
|
||||
- Splitting or merging tables
|
||||
- Renaming or deleting fields
|
||||
- Migrating from nested to relational data
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- Greenfield schema with no existing data in production or dev
|
||||
- Adding optional fields that do not need backfilling
|
||||
- Adding new tables with no existing data to migrate
|
||||
- Adding or removing indexes with no correctness concern
|
||||
- Questions about Convex schema design without a migration need
|
||||
|
||||
## Key Concepts
|
||||
|
||||
### Schema Validation Drives the Workflow
|
||||
|
||||
Convex will not let you deploy a schema that does not match the data at rest. This is the fundamental constraint that shapes every migration:
|
||||
|
||||
- You cannot add a required field if existing documents don't have it
|
||||
- You cannot change a field's type if existing documents have the old type
|
||||
- You cannot remove a field from the schema if existing documents still have it
|
||||
|
||||
This means migrations follow a predictable pattern: **widen the schema, migrate the data, narrow the schema**.
|
||||
|
||||
### Online Migrations
|
||||
|
||||
Convex migrations run online, meaning the app continues serving requests while data is updated asynchronously in batches. During the migration window, your code must handle both old and new data formats.
|
||||
|
||||
### Prefer New Fields Over Changing Types
|
||||
|
||||
When changing the shape of data, create a new field rather than modifying an existing one. This makes the transition safer and easier to roll back.
|
||||
|
||||
### Don't Delete Data
|
||||
|
||||
Unless you are certain, prefer deprecating fields over deleting them. Mark the field as `v.optional` and add a code comment explaining it is deprecated and why it existed.
|
||||
|
||||
## Safe Changes (No Migration Needed)
|
||||
|
||||
### Adding Optional Field
|
||||
|
||||
```typescript
|
||||
// Before
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
});
|
||||
|
||||
// After - safe, new field is optional
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
bio: v.optional(v.string()),
|
||||
});
|
||||
```
|
||||
|
||||
### Adding New Table
|
||||
|
||||
```typescript
|
||||
posts: defineTable({
|
||||
userId: v.id("users"),
|
||||
title: v.string(),
|
||||
}).index("by_user", ["userId"]);
|
||||
```
|
||||
|
||||
### Adding Index
|
||||
|
||||
```typescript
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
}).index("by_email", ["email"]);
|
||||
```
|
||||
|
||||
## Breaking Changes: The Deployment Workflow
|
||||
|
||||
Every breaking migration follows the same multi-deploy pattern:
|
||||
|
||||
**Deploy 1 - Widen the schema:**
|
||||
|
||||
1. Update schema to allow both old and new formats (e.g., add optional new field)
|
||||
2. Update code to handle both formats when reading
|
||||
3. Update code to write the new format for new documents
|
||||
4. Deploy
|
||||
|
||||
**Between deploys - Migrate data:**
|
||||
|
||||
5. Run migration to backfill existing documents
|
||||
6. Verify all documents are migrated
|
||||
|
||||
**Deploy 2 - Narrow the schema:**
|
||||
|
||||
7. Update schema to require the new format only
|
||||
8. Remove code that handles the old format
|
||||
9. Deploy
|
||||
|
||||
## Using the Migrations Component
|
||||
|
||||
For any non-trivial migration, use the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component. It handles batching, cursor-based pagination, state tracking, resume from failure, dry runs, and progress monitoring.
|
||||
|
||||
See `references/migrations-component.md` for installation, setup, defining and running migrations, dry runs, status monitoring, and configuration options.
|
||||
|
||||
## Common Migration Patterns
|
||||
|
||||
See `references/migration-patterns.md` for complete patterns with code examples covering:
|
||||
|
||||
- Adding a required field
|
||||
- Deleting a field
|
||||
- Changing a field type
|
||||
- Splitting nested data into a separate table
|
||||
- Cleaning up orphaned documents
|
||||
- Zero-downtime strategies (dual write, dual read)
|
||||
- Small table shortcut (single internalMutation without the component)
|
||||
- Verifying a migration is complete
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Making a field required before migrating data**: Convex rejects the deploy because existing documents lack the field. Always widen the schema first.
|
||||
2. **Using `.collect()` on large tables**: Hits transaction limits or causes timeouts. Use the migrations component for proper batched pagination. `.collect()` is only safe for tables you know are small.
|
||||
3. **Not writing the new format before migrating**: Documents created during the migration window will be missed, leaving unmigrated data after the migration "completes."
|
||||
4. **Skipping the dry run**: Use `dryRun: true` to validate migration logic before committing changes to production data. Catches bugs before they touch real documents.
|
||||
5. **Deleting fields prematurely**: Prefer deprecating with `v.optional` and a comment. Only delete after you are confident the data is no longer needed and no code references it.
|
||||
6. **Using crons for migration batches**: The migrations component handles batching via recursive scheduling internally. Crons require manual cleanup and an extra deploy to remove.
|
||||
|
||||
## Migration Checklist
|
||||
|
||||
- [ ] Identify the breaking change and plan the multi-deploy workflow
|
||||
- [ ] Update schema to allow both old and new formats
|
||||
- [ ] Update code to handle both formats when reading
|
||||
- [ ] Update code to write the new format for new documents
|
||||
- [ ] Deploy widened schema and updated code
|
||||
- [ ] Define migration using the `@convex-dev/migrations` component
|
||||
- [ ] Test with `dryRun: true`
|
||||
- [ ] Run migration and monitor status
|
||||
- [ ] Verify all documents are migrated
|
||||
- [ ] Update schema to require new format only
|
||||
- [ ] Clean up code that handled old format
|
||||
- [ ] Deploy final schema and code
|
||||
- [ ] Remove migration code once confirmed stable
|
||||
10
.agents/skills/convex-migration-helper/agents/openai.yaml
Normal file
10
.agents/skills/convex-migration-helper/agents/openai.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Convex Migration Helper"
|
||||
short_description: "Plan and run safe Convex schema and data migrations."
|
||||
icon_small: "./assets/icon.svg"
|
||||
icon_large: "./assets/icon.svg"
|
||||
brand_color: "#8B5CF6"
|
||||
default_prompt: "Help me plan and execute this Convex migration safely. Start by identifying the schema change, the existing data shape, and the widen-migrate-narrow path before making edits."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
3
.agents/skills/convex-migration-helper/assets/icon.svg
Normal file
3
.agents/skills/convex-migration-helper/assets/icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 386 B |
@ -0,0 +1,231 @@
|
||||
# Migration Patterns Reference
|
||||
|
||||
Common migration patterns, zero-downtime strategies, and verification techniques for Convex schema and data migrations.
|
||||
|
||||
## Adding a Required Field
|
||||
|
||||
```typescript
|
||||
// Deploy 1: Schema allows both states
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
role: v.optional(v.union(v.literal("user"), v.literal("admin"))),
|
||||
});
|
||||
|
||||
// Migration: backfill the field
|
||||
export const addDefaultRole = migrations.define({
|
||||
table: "users",
|
||||
migrateOne: async (ctx, user) => {
|
||||
if (user.role === undefined) {
|
||||
await ctx.db.patch(user._id, { role: "user" });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Deploy 2: After migration completes, make it required
|
||||
users: defineTable({
|
||||
name: v.string(),
|
||||
role: v.union(v.literal("user"), v.literal("admin")),
|
||||
});
|
||||
```
|
||||
|
||||
## Deleting a Field
|
||||
|
||||
Mark the field optional first, migrate data to remove it, then remove from schema:
|
||||
|
||||
```typescript
|
||||
// Deploy 1: Make optional
|
||||
// isPro: v.boolean() --> isPro: v.optional(v.boolean())
|
||||
|
||||
// Migration
|
||||
export const removeIsPro = migrations.define({
|
||||
table: "teams",
|
||||
migrateOne: async (ctx, team) => {
|
||||
if (team.isPro !== undefined) {
|
||||
await ctx.db.patch(team._id, { isPro: undefined });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Deploy 2: Remove isPro from schema entirely
|
||||
```
|
||||
|
||||
## Changing a Field Type
|
||||
|
||||
Prefer creating a new field. You can combine adding and deleting in one migration:
|
||||
|
||||
```typescript
|
||||
// Deploy 1: Add new field, keep old field optional
|
||||
// isPro: v.boolean() --> isPro: v.optional(v.boolean()), plan: v.optional(...)
|
||||
|
||||
// Migration: convert old field to new field
|
||||
export const convertToEnum = migrations.define({
|
||||
table: "teams",
|
||||
migrateOne: async (ctx, team) => {
|
||||
if (team.plan === undefined) {
|
||||
await ctx.db.patch(team._id, {
|
||||
plan: team.isPro ? "pro" : "basic",
|
||||
isPro: undefined,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
// Deploy 2: Remove isPro from schema, make plan required
|
||||
```
|
||||
|
||||
## Splitting Nested Data Into a Separate Table
|
||||
|
||||
```typescript
|
||||
export const extractPreferences = migrations.define({
|
||||
table: "users",
|
||||
migrateOne: async (ctx, user) => {
|
||||
if (user.preferences === undefined) return;
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("userPreferences")
|
||||
.withIndex("by_user", (q) => q.eq("userId", user._id))
|
||||
.first();
|
||||
|
||||
if (!existing) {
|
||||
await ctx.db.insert("userPreferences", {
|
||||
userId: user._id,
|
||||
...user.preferences,
|
||||
});
|
||||
}
|
||||
|
||||
await ctx.db.patch(user._id, { preferences: undefined });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Make sure your code is already writing to the new `userPreferences` table for new users before running this migration, so you don't miss documents created during the migration window.
|
||||
|
||||
## Cleaning Up Orphaned Documents
|
||||
|
||||
```typescript
|
||||
export const deleteOrphanedEmbeddings = migrations.define({
|
||||
table: "embeddings",
|
||||
migrateOne: async (ctx, doc) => {
|
||||
const chunk = await ctx.db
|
||||
.query("chunks")
|
||||
.withIndex("by_embedding", (q) => q.eq("embeddingId", doc._id))
|
||||
.first();
|
||||
|
||||
if (!chunk) {
|
||||
await ctx.db.delete(doc._id);
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Zero-Downtime Strategies
|
||||
|
||||
During the migration window, your app must handle both old and new data formats. There are two main strategies.
|
||||
|
||||
### Dual Write (Preferred)
|
||||
|
||||
Write to both old and new structures. Read from the old structure until migration is complete.
|
||||
|
||||
1. Deploy code that writes both formats, reads old format
|
||||
2. Run migration on existing data
|
||||
3. Deploy code that reads new format, still writes both
|
||||
4. Deploy code that only reads and writes new format
|
||||
|
||||
This is preferred because you can safely roll back at any point, the old format is always up to date.
|
||||
|
||||
```typescript
|
||||
// Bad: only writing to new structure before migration is done
|
||||
export const createTeam = mutation({
|
||||
args: { name: v.string(), isPro: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("teams", {
|
||||
name: args.name,
|
||||
plan: args.isPro ? "pro" : "basic",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Good: writing to both structures during migration
|
||||
export const createTeam = mutation({
|
||||
args: { name: v.string(), isPro: v.boolean() },
|
||||
handler: async (ctx, args) => {
|
||||
const plan = args.isPro ? "pro" : "basic";
|
||||
await ctx.db.insert("teams", {
|
||||
name: args.name,
|
||||
isPro: args.isPro,
|
||||
plan,
|
||||
});
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Dual Read
|
||||
|
||||
Read both formats. Write only the new format.
|
||||
|
||||
1. Deploy code that reads both formats (preferring new), writes only new format
|
||||
2. Run migration on existing data
|
||||
3. Deploy code that reads and writes only new format
|
||||
|
||||
This avoids duplicating writes, which is useful when having two copies of data could cause inconsistencies. The downside is that rolling back to before step 1 is harder, since new documents only have the new format.
|
||||
|
||||
```typescript
|
||||
// Good: reading both formats, preferring new
|
||||
function getTeamPlan(team: Doc<"teams">): "basic" | "pro" {
|
||||
if (team.plan !== undefined) return team.plan;
|
||||
return team.isPro ? "pro" : "basic";
|
||||
}
|
||||
```
|
||||
|
||||
## Small Table Shortcut
|
||||
|
||||
For small tables (a few thousand documents at most), you can migrate in a single `internalMutation` without the component:
|
||||
|
||||
```typescript
|
||||
import { internalMutation } from "./_generated/server";
|
||||
|
||||
export const backfillSmallTable = internalMutation({
|
||||
handler: async (ctx) => {
|
||||
const docs = await ctx.db.query("smallConfig").collect();
|
||||
for (const doc of docs) {
|
||||
if (doc.newField === undefined) {
|
||||
await ctx.db.patch(doc._id, { newField: "default" });
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```bash
|
||||
npx convex run migrations:backfillSmallTable
|
||||
```
|
||||
|
||||
Only use `.collect()` when you are certain the table is small. For anything larger, use the migrations component.
|
||||
|
||||
## Verifying a Migration
|
||||
|
||||
Query to check remaining unmigrated documents:
|
||||
|
||||
```typescript
|
||||
import { query } from "./_generated/server";
|
||||
|
||||
export const verifyMigration = query({
|
||||
handler: async (ctx) => {
|
||||
const remaining = await ctx.db
|
||||
.query("users")
|
||||
.filter((q) => q.eq(q.field("role"), undefined))
|
||||
.take(10);
|
||||
|
||||
return {
|
||||
complete: remaining.length === 0,
|
||||
sampleRemaining: remaining.map((u) => u._id),
|
||||
};
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Or use the component's built-in status monitoring:
|
||||
|
||||
```bash
|
||||
npx convex run --component migrations lib:getStatus --watch
|
||||
```
|
||||
@ -0,0 +1,169 @@
|
||||
# Migrations Component Reference
|
||||
|
||||
Complete guide to the [`@convex-dev/migrations`](https://www.convex.dev/components/migrations) component for batched, resumable Convex data migrations.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install @convex-dev/migrations
|
||||
```
|
||||
|
||||
## Setup
|
||||
|
||||
```typescript
|
||||
// convex/convex.config.ts
|
||||
import { defineApp } from "convex/server";
|
||||
import migrations from "@convex-dev/migrations/convex.config.js";
|
||||
|
||||
const app = defineApp();
|
||||
app.use(migrations);
|
||||
export default app;
|
||||
```
|
||||
|
||||
```typescript
|
||||
// convex/migrations.ts
|
||||
import { Migrations } from "@convex-dev/migrations";
|
||||
import { components } from "./_generated/api.js";
|
||||
import { DataModel } from "./_generated/dataModel.js";
|
||||
|
||||
export const migrations = new Migrations<DataModel>(components.migrations);
|
||||
export const run = migrations.runner();
|
||||
```
|
||||
|
||||
The `DataModel` type parameter is optional but provides type safety for migration definitions.
|
||||
|
||||
## Define a Migration
|
||||
|
||||
The `migrateOne` function processes a single document. The component handles batching and pagination automatically.
|
||||
|
||||
```typescript
|
||||
// convex/migrations.ts
|
||||
export const addDefaultRole = migrations.define({
|
||||
table: "users",
|
||||
migrateOne: async (ctx, user) => {
|
||||
if (user.role === undefined) {
|
||||
await ctx.db.patch(user._id, { role: "user" });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Shorthand: if you return an object, it is applied as a patch automatically.
|
||||
|
||||
```typescript
|
||||
export const clearDeprecatedField = migrations.define({
|
||||
table: "users",
|
||||
migrateOne: () => ({ legacyField: undefined }),
|
||||
});
|
||||
```
|
||||
|
||||
## Run a Migration
|
||||
|
||||
From the CLI:
|
||||
|
||||
```bash
|
||||
# Define a one-off runner in convex/migrations.ts:
|
||||
# export const runIt = migrations.runner(internal.migrations.addDefaultRole);
|
||||
npx convex run migrations:runIt
|
||||
|
||||
# Or use the general-purpose runner
|
||||
npx convex run migrations:run '{"fn": "migrations:addDefaultRole"}'
|
||||
```
|
||||
|
||||
Programmatically from another Convex function:
|
||||
|
||||
```typescript
|
||||
await migrations.runOne(ctx, internal.migrations.addDefaultRole);
|
||||
```
|
||||
|
||||
## Run Multiple Migrations in Order
|
||||
|
||||
```typescript
|
||||
export const runAll = migrations.runner([
|
||||
internal.migrations.addDefaultRole,
|
||||
internal.migrations.clearDeprecatedField,
|
||||
internal.migrations.normalizeEmails,
|
||||
]);
|
||||
```
|
||||
|
||||
```bash
|
||||
npx convex run migrations:runAll
|
||||
```
|
||||
|
||||
If one fails, it stops and will not continue to the next. Call it again to retry from where it left off. Completed migrations are skipped automatically.
|
||||
|
||||
## Dry Run
|
||||
|
||||
Test a migration before committing changes:
|
||||
|
||||
```bash
|
||||
npx convex run migrations:runIt '{"dryRun": true}'
|
||||
```
|
||||
|
||||
This runs one batch and then rolls back, so you can see what it would do without changing any data.
|
||||
|
||||
## Check Migration Status
|
||||
|
||||
```bash
|
||||
npx convex run --component migrations lib:getStatus --watch
|
||||
```
|
||||
|
||||
## Cancel a Running Migration
|
||||
|
||||
```bash
|
||||
npx convex run --component migrations lib:cancel '{"name": "migrations:addDefaultRole"}'
|
||||
```
|
||||
|
||||
Or programmatically:
|
||||
|
||||
```typescript
|
||||
await migrations.cancel(ctx, internal.migrations.addDefaultRole);
|
||||
```
|
||||
|
||||
## Run Migrations on Deploy
|
||||
|
||||
Chain migration execution after deploying:
|
||||
|
||||
```bash
|
||||
npx convex deploy --cmd 'npm run build' && npx convex run migrations:runAll --prod
|
||||
```
|
||||
|
||||
## Configuration Options
|
||||
|
||||
### Custom Batch Size
|
||||
|
||||
If documents are large or the table has heavy write traffic, reduce the batch size to avoid transaction limits or OCC conflicts:
|
||||
|
||||
```typescript
|
||||
export const migrateHeavyTable = migrations.define({
|
||||
table: "largeDocuments",
|
||||
batchSize: 10,
|
||||
migrateOne: async (ctx, doc) => {
|
||||
// migration logic
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Migrate a Subset Using an Index
|
||||
|
||||
Process only matching documents instead of the full table:
|
||||
|
||||
```typescript
|
||||
export const fixEmptyNames = migrations.define({
|
||||
table: "users",
|
||||
customRange: (query) => query.withIndex("by_name", (q) => q.eq("name", "")),
|
||||
migrateOne: () => ({ name: "<unknown>" }),
|
||||
});
|
||||
```
|
||||
|
||||
### Parallelize Within a Batch
|
||||
|
||||
By default each document in a batch is processed serially. Enable parallel processing if your migration logic does not depend on ordering:
|
||||
|
||||
```typescript
|
||||
export const clearField = migrations.define({
|
||||
table: "myTable",
|
||||
parallelize: true,
|
||||
migrateOne: () => ({ optionalField: undefined }),
|
||||
});
|
||||
```
|
||||
143
.agents/skills/convex-performance-audit/SKILL.md
Normal file
143
.agents/skills/convex-performance-audit/SKILL.md
Normal file
@ -0,0 +1,143 @@
|
||||
---
|
||||
name: convex-performance-audit
|
||||
description: Audits Convex performance for reads, subscriptions, write contention, and function limits. Use for slow features, insights findings, OCC conflicts, or read amplification.
|
||||
---
|
||||
|
||||
# Convex Performance Audit
|
||||
|
||||
Diagnose and fix performance problems in Convex applications, one problem class at a time.
|
||||
|
||||
## When to Use
|
||||
|
||||
- A Convex page or feature feels slow or expensive
|
||||
- `npx convex insights --details` reports high bytes read, documents read, or OCC conflicts
|
||||
- Low-freshness read paths are using reactivity where point-in-time reads would do
|
||||
- OCC conflict errors or excessive mutation retries
|
||||
- High subscription count or slow UI updates
|
||||
- Functions approaching execution or transaction limits
|
||||
- The same performance pattern needs fixing across sibling functions
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- Initial Convex setup, auth setup, or component extraction
|
||||
- Pure schema migrations with no performance goal
|
||||
- One-off micro-optimizations without a user-visible or deployment-visible problem
|
||||
|
||||
## Guardrails
|
||||
|
||||
- Prefer simpler code when scale is small, traffic is modest, or the available signals are weak
|
||||
- Do not recommend digest tables, document splitting, fetch-strategy changes, or migration-heavy rollouts unless there is a measured signal, a clearly unbounded path, or a known hot read/write path
|
||||
- In Convex, a simple scan on a small table is often acceptable. Do not invent structural work just because a pattern is not ideal at large scale
|
||||
|
||||
## First Step: Gather Signals
|
||||
|
||||
Start with the strongest signal available:
|
||||
|
||||
1. If deployment Health insights are already available from the user or the current context, treat them as a first-class source of performance signals.
|
||||
2. If CLI insights are available, run `npx convex insights --details`. Use `--prod`, `--preview-name`, or `--deployment-name` when needed.
|
||||
- If the local repo's Convex CLI is too old to support `insights`, try `npx -y convex@latest insights --details` before giving up.
|
||||
3. If the repo already uses `convex-doctor`, you may treat its findings as hints. Do not require it, and do not treat it as the source of truth.
|
||||
4. If runtime signals are unavailable, audit from code anyway, but keep the guardrails above in mind. Lack of insights is not proof of health, but it is also not proof that a large refactor is warranted.
|
||||
|
||||
## Signal Routing
|
||||
|
||||
After gathering signals, identify the problem class and read the matching reference file.
|
||||
|
||||
| Signal | Reference |
|
||||
| -------------------------------------------------------------- | ----------------------------------------- |
|
||||
| High bytes or documents read, JS filtering, unnecessary joins | `references/hot-path-rules.md` |
|
||||
| OCC conflict errors, write contention, mutation retries | `references/occ-conflicts.md` |
|
||||
| High subscription count, slow UI updates, excessive re-renders | `references/subscription-cost.md` |
|
||||
| Function timeouts, transaction size errors, large payloads | `references/function-budget.md` |
|
||||
| General "it's slow" with no specific signal | Start with `references/hot-path-rules.md` |
|
||||
|
||||
Multiple problem classes can overlap. Read the most relevant reference first, then check the others if symptoms remain.
|
||||
|
||||
## Escalate Larger Fixes
|
||||
|
||||
If the likely fix is invasive, cross-cutting, or migration-heavy, stop and present options before editing.
|
||||
|
||||
Examples:
|
||||
|
||||
- introducing digest or summary tables across multiple flows
|
||||
- splitting documents to isolate frequently-updated fields
|
||||
- reworking pagination or fetch strategy across several screens
|
||||
- switching to a new index or denormalized field that needs migration-safe rollout
|
||||
|
||||
When correctness depends on handling old and new states during a rollout, consult `skills/convex-migration-helper/SKILL.md` for the migration workflow.
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Scope the problem
|
||||
|
||||
Pick one concrete user flow from the actual project. Look at the codebase, client pages, and API surface to find the flow that matches the symptom.
|
||||
|
||||
Write down:
|
||||
|
||||
- entrypoint functions
|
||||
- client callsites using `useQuery`, `usePaginatedQuery`, or `useMutation`
|
||||
- tables read
|
||||
- tables written
|
||||
- whether the path is high-read, high-write, or both
|
||||
|
||||
### 2. Trace the full read and write set
|
||||
|
||||
For each function in the path:
|
||||
|
||||
1. Trace every `ctx.db.get()` and `ctx.db.query()`
|
||||
2. Trace every `ctx.db.patch()`, `ctx.db.replace()`, and `ctx.db.insert()`
|
||||
3. Note foreign-key lookups, JS-side filtering, and full-document reads
|
||||
4. Identify all sibling functions touching the same tables
|
||||
5. Identify reactive stats, aggregates, or widgets rendered on the same page
|
||||
|
||||
In Convex, every extra read increases transaction work, and every write can invalidate reactive subscribers. Treat read amplification and invalidation amplification as first-class problems.
|
||||
|
||||
### 3. Apply fixes from the relevant reference
|
||||
|
||||
Read the reference file matching your problem class. Each reference includes specific patterns, code examples, and a recommended fix order.
|
||||
|
||||
Do not stop at the single function named by an insight. Trace sibling readers and writers touching the same tables.
|
||||
|
||||
### 4. Fix sibling functions together
|
||||
|
||||
When one function touching a table has a performance bug, audit sibling functions for the same pattern.
|
||||
|
||||
After finding one problem, inspect both sibling readers and sibling writers for the same table family, including companion digest or summary tables.
|
||||
|
||||
Examples:
|
||||
|
||||
- If one list query switches from full docs to a digest table, inspect the other list queries for that table
|
||||
- If one mutation isolates a frequently-updated field or splits a hot document, inspect the other writers to the same table
|
||||
- If one read path needs a migration-safe rollout for an unbackfilled field, inspect sibling reads for the same rollout risk
|
||||
|
||||
Do not leave one path fixed and another path on the old pattern unless there is a clear product reason.
|
||||
|
||||
### 5. Verify before finishing
|
||||
|
||||
Confirm all of these:
|
||||
|
||||
1. Results are the same as before, no dropped records
|
||||
2. Eliminated reads or writes are no longer in the path where expected
|
||||
3. Fallback behavior works when denormalized or indexed fields are missing
|
||||
4. Frequently-updated fields are isolated from widely-read documents where needed
|
||||
5. Every relevant sibling reader and writer was inspected, not just the original function
|
||||
|
||||
## Reference Files
|
||||
|
||||
- `references/hot-path-rules.md` - Read amplification, invalidation, denormalization, indexes, digest tables
|
||||
- `references/occ-conflicts.md` - Write contention, OCC resolution, hot document splitting
|
||||
- `references/subscription-cost.md` - Reactive query cost, subscription granularity, point-in-time reads
|
||||
- `references/function-budget.md` - Execution limits, transaction size, large documents, payload size
|
||||
|
||||
Also check the official [Convex Best Practices](https://docs.convex.dev/understanding/best-practices/) page for additional patterns covering argument validation, access control, and code organization that may surface during the audit.
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Gathered signals from insights, dashboard, or code audit
|
||||
- [ ] Identified the problem class and read the matching reference
|
||||
- [ ] Scoped one concrete user flow or function path
|
||||
- [ ] Traced every read and write in that path
|
||||
- [ ] Identified sibling functions touching the same tables
|
||||
- [ ] Applied fixes from the reference, following the recommended fix order
|
||||
- [ ] Fixed sibling functions consistently
|
||||
- [ ] Verified behavior and confirmed no regressions
|
||||
10
.agents/skills/convex-performance-audit/agents/openai.yaml
Normal file
10
.agents/skills/convex-performance-audit/agents/openai.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Convex Performance Audit"
|
||||
short_description: "Audit slow Convex reads, subscriptions, OCC conflicts, and limits."
|
||||
icon_small: "./assets/icon.svg"
|
||||
icon_large: "./assets/icon.svg"
|
||||
brand_color: "#EF4444"
|
||||
default_prompt: "Audit this Convex app for performance issues. Start with the strongest signal available, identify the problem class, and suggest the smallest high-impact fix before proposing bigger structural changes."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
3
.agents/skills/convex-performance-audit/assets/icon.svg
Normal file
3
.agents/skills/convex-performance-audit/assets/icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M8.25 3v1.5M4.5 8.25H3m18 0h-1.5M4.5 12H3m18 0h-1.5m-15 3.75H3m18 0h-1.5M8.25 19.5V21M12 3v1.5m0 15V21m3.75-18v1.5m0 15V21m-9-1.5h10.5a2.25 2.25 0 0 0 2.25-2.25V6.75a2.25 2.25 0 0 0-2.25-2.25H6.75A2.25 2.25 0 0 0 4.5 6.75v10.5a2.25 2.25 0 0 0 2.25 2.25Zm.75-12h9v9h-9v-9Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 490 B |
@ -0,0 +1,232 @@
|
||||
# Function Budget
|
||||
|
||||
Use these rules when functions are hitting execution limits, transaction size errors, or returning excessively large payloads to the client.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Convex functions run inside transactions with budgets for time, reads, and writes. Staying well within these limits is not just about avoiding errors, it reduces latency and contention.
|
||||
|
||||
## Limits to Know
|
||||
|
||||
These are the current values from the [Convex limits docs](https://docs.convex.dev/production/state/limits). Check that page for the latest numbers.
|
||||
|
||||
| Resource | Limit |
|
||||
| --------------------------------- | ----------------------------------------------------- |
|
||||
| Query/mutation execution time | 1 second (user code only, excludes DB operations) |
|
||||
| Action execution time | 10 minutes |
|
||||
| Data read per transaction | 16 MiB |
|
||||
| Data written per transaction | 16 MiB |
|
||||
| Documents scanned per transaction | 32,000 (includes documents filtered out by `.filter`) |
|
||||
| Index ranges read per transaction | 4,096 (each `db.get` and `db.query` call) |
|
||||
| Documents written per transaction | 16,000 |
|
||||
| Individual document size | 1 MiB |
|
||||
| Function return value size | 16 MiB |
|
||||
|
||||
## Symptoms
|
||||
|
||||
- "Function execution took too long" errors
|
||||
- "Transaction too large" or read/write set size errors
|
||||
- Slow queries that read many documents
|
||||
- Client receiving large payloads that slow down page load
|
||||
- `npx convex insights --details` showing high bytes read
|
||||
|
||||
## Common Causes
|
||||
|
||||
### Unbounded collection
|
||||
|
||||
A query that calls `.collect()` on a table without a reasonable limit. As the table grows, the query reads more and more documents.
|
||||
|
||||
### Large document reads on hot paths
|
||||
|
||||
Reading documents with large fields (rich text, embedded media references, long arrays) when only a small subset of the data is needed for the current view.
|
||||
|
||||
### Mutation doing too much work
|
||||
|
||||
A single mutation that updates hundreds of documents, backfills data, or rebuilds derived state in one transaction.
|
||||
|
||||
### Returning too much data to the client
|
||||
|
||||
A query returning full documents when the client only needs a few fields.
|
||||
|
||||
## Fix Order
|
||||
|
||||
### 1. Bound your reads
|
||||
|
||||
Never `.collect()` without a limit on a table that can grow unbounded.
|
||||
|
||||
```ts
|
||||
// Bad: unbounded read, breaks as the table grows
|
||||
const messages = await ctx.db.query("messages").collect();
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: paginate or limit
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_channel", (q) => q.eq("channelId", channelId))
|
||||
.order("desc")
|
||||
.take(50);
|
||||
```
|
||||
|
||||
### 2. Read smaller shapes
|
||||
|
||||
If the list page only needs title, author, and date, do not read full documents with rich content fields.
|
||||
|
||||
Use digest or summary tables for hot list pages. See `hot-path-rules.md` for the digest table pattern.
|
||||
|
||||
### 3. Break large mutations into batches
|
||||
|
||||
If a mutation needs to update hundreds of documents, split it into a self-scheduling chain.
|
||||
|
||||
```ts
|
||||
// Bad: one mutation updating every row
|
||||
export const backfillAll = internalMutation({
|
||||
handler: async (ctx) => {
|
||||
const docs = await ctx.db.query("items").collect();
|
||||
for (const doc of docs) {
|
||||
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: cursor-based batch processing
|
||||
export const backfillBatch = internalMutation({
|
||||
args: { cursor: v.optional(v.string()), batchSize: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const batchSize = args.batchSize ?? 100;
|
||||
const result = await ctx.db
|
||||
.query("items")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
for (const doc of result.page) {
|
||||
if (doc.newField === undefined) {
|
||||
await ctx.db.patch(doc._id, { newField: computeValue(doc) });
|
||||
}
|
||||
}
|
||||
|
||||
if (!result.isDone) {
|
||||
await ctx.scheduler.runAfter(0, internal.items.backfillBatch, {
|
||||
cursor: result.continueCursor,
|
||||
batchSize,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Move heavy work to actions
|
||||
|
||||
Queries and mutations run inside Convex's transactional runtime with strict budgets. If you need to do CPU-intensive computation, call external APIs, or process large files, use an action instead.
|
||||
|
||||
Actions run outside the transaction and can call mutations to write results back.
|
||||
|
||||
```ts
|
||||
// Bad: heavy computation inside a mutation
|
||||
export const processUpload = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const result = expensiveComputation(args.data);
|
||||
await ctx.db.insert("results", result);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: action for heavy work, mutation for the write
|
||||
export const processUpload = action({
|
||||
handler: async (ctx, args) => {
|
||||
const result = expensiveComputation(args.data);
|
||||
await ctx.runMutation(internal.results.store, { result });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Trim return values
|
||||
|
||||
Only return what the client needs. If a query fetches full documents but the component only renders a few fields, map the results before returning.
|
||||
|
||||
```ts
|
||||
// Bad: returns full documents including large content fields
|
||||
export const list = query({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("articles").take(20);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: project to only the fields the client needs
|
||||
export const list = query({
|
||||
handler: async (ctx) => {
|
||||
const articles = await ctx.db.query("articles").take(20);
|
||||
return articles.map((a) => ({
|
||||
_id: a._id,
|
||||
title: a.title,
|
||||
author: a.author,
|
||||
createdAt: a._creationTime,
|
||||
}));
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Replace `ctx.runQuery` and `ctx.runMutation` with helper functions
|
||||
|
||||
Inside queries and mutations, `ctx.runQuery` and `ctx.runMutation` have overhead compared to calling a plain TypeScript helper function. They run in the same transaction but pay extra per-call cost.
|
||||
|
||||
```ts
|
||||
// Bad: unnecessary overhead from ctx.runQuery inside a mutation
|
||||
export const createProject = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await ctx.runQuery(api.users.getCurrentUser);
|
||||
await ctx.db.insert("projects", { ...args, ownerId: user._id });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: plain helper function, no extra overhead
|
||||
export const createProject = mutation({
|
||||
handler: async (ctx, args) => {
|
||||
const user = await getCurrentUser(ctx);
|
||||
await ctx.db.insert("projects", { ...args, ownerId: user._id });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Exception: components require `ctx.runQuery`/`ctx.runMutation`. Use them there, but prefer helpers everywhere else.
|
||||
|
||||
### 7. Avoid unnecessary `runAction` calls
|
||||
|
||||
`runAction` from within an action creates a separate function invocation with its own memory and CPU budget. The parent action just sits idle waiting. Replace with a plain TypeScript function call unless you need a different runtime (e.g. calling Node.js code from the Convex runtime).
|
||||
|
||||
```ts
|
||||
// Bad: runAction overhead for no reason
|
||||
export const processItems = action({
|
||||
handler: async (ctx, args) => {
|
||||
for (const item of args.items) {
|
||||
await ctx.runAction(internal.items.processOne, { item });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: plain function call
|
||||
export const processItems = action({
|
||||
handler: async (ctx, args) => {
|
||||
for (const item of args.items) {
|
||||
await processOneItem(ctx, { item });
|
||||
}
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
1. No function execution or transaction size errors
|
||||
2. `npx convex insights --details` shows reduced bytes read
|
||||
3. Large mutations are batched and self-scheduling
|
||||
4. Client payloads are reasonably sized for the UI they serve
|
||||
5. `ctx.runQuery`/`ctx.runMutation` in queries and mutations replaced with helpers where possible
|
||||
6. Sibling functions with similar patterns were checked
|
||||
@ -0,0 +1,368 @@
|
||||
# Hot Path Rules
|
||||
|
||||
Use these rules when the top-level workflow points to read amplification, denormalization, index rollout, reactive query cost, or invalidation-heavy writes.
|
||||
|
||||
## Contents
|
||||
|
||||
- Core Principle
|
||||
- Consistency Rule
|
||||
- 1. Push Filters To Storage (indexes, migration rule, redundant indexes)
|
||||
- 2. Minimize Data Sources (denormalization, fallback rule)
|
||||
- 3. Minimize Row Size (digest tables)
|
||||
- 4. Skip No-Op Writes
|
||||
- 5. Match Consistency To Read Patterns (high-read/low-write, high-read/high-write)
|
||||
- Convex-Specific Notes (reactive queries, point-in-time reads, triggers, aggregates, backfills)
|
||||
- Verification
|
||||
|
||||
## Core Principle
|
||||
|
||||
Every byte read or written multiplies with concurrency.
|
||||
|
||||
Think:
|
||||
|
||||
`cost x calls_per_second x 86400`
|
||||
|
||||
In Convex, every write can also fan out into reactive invalidation, replication work, and downstream sync.
|
||||
|
||||
## Consistency Rule
|
||||
|
||||
If you fix a hot-path pattern for one function, audit sibling functions touching the same tables for the same pattern.
|
||||
|
||||
Do this especially for:
|
||||
|
||||
- multiple list queries over the same table
|
||||
- multiple writers to the same table
|
||||
- public browse and search queries over the same records
|
||||
- helper functions reused by more than one endpoint
|
||||
|
||||
## 1. Push Filters To Storage
|
||||
|
||||
Both JavaScript `.filter()` and the Convex query `.filter()` method after a DB scan mean you already paid for the read. The Convex `.filter()` method has the same performance as filtering in JS, it does not push the predicate to the storage layer. Only `.withIndex()` and `.withSearchIndex()` actually reduce the documents scanned.
|
||||
|
||||
Prefer:
|
||||
|
||||
- `withIndex(...)`
|
||||
- `.withSearchIndex(...)` for text search
|
||||
- narrower tables
|
||||
- summary tables
|
||||
|
||||
before accepting a scan-plus-filter pattern.
|
||||
|
||||
```ts
|
||||
// Bad: scans then filters in JavaScript
|
||||
export const listOpen = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const tasks = await ctx.db.query("tasks").collect();
|
||||
return tasks.filter((task) => task.status === "open");
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Also bad: Convex .filter() does not push to storage either
|
||||
export const listOpen = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db
|
||||
.query("tasks")
|
||||
.filter((q) => q.eq(q.field("status"), "open"))
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: use an index so storage does the filtering
|
||||
export const listOpen = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db
|
||||
.query("tasks")
|
||||
.withIndex("by_status", (q) => q.eq("status", "open"))
|
||||
.collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Migration rule for indexes
|
||||
|
||||
New indexes on partially backfilled fields can create correctness bugs during rollout.
|
||||
|
||||
Important Convex detail:
|
||||
|
||||
`undefined !== false`
|
||||
|
||||
If an older document is missing a field entirely, it will not match a compound index entry that expects `false`.
|
||||
|
||||
Do not trust old comments saying a field is "not backfilled" or "already backfilled". Verify.
|
||||
|
||||
If correctness depends on handling old and new states during rollout, do not improvise a partial-backfill workaround in the hot path. Use a migration-safe rollout and consult `skills/convex-migration-helper/SKILL.md`.
|
||||
|
||||
```ts
|
||||
// Bad: optional booleans can miss older rows where the field is undefined
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_archived_and_updated", (q) => q.eq("isArchived", false))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: switch hot-path reads only after the rollout is migration-safe
|
||||
// See the migration helper skill for dual-read / backfill / cutover patterns.
|
||||
```
|
||||
|
||||
### Check for redundant indexes
|
||||
|
||||
Indexes like `by_foo` and `by_foo_and_bar` are usually redundant. You only need `by_foo_and_bar`, since you can query it with just the `foo` condition and omit `bar`. Extra indexes add storage cost and write overhead on every insert, patch, and delete.
|
||||
|
||||
```ts
|
||||
// Bad: two indexes where one would do
|
||||
defineTable({ team: v.id("teams"), user: v.id("users") })
|
||||
.index("by_team", ["team"])
|
||||
.index("by_team_and_user", ["team", "user"]);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: single compound index serves both query patterns
|
||||
defineTable({ team: v.id("teams"), user: v.id("users") }).index("by_team_and_user", [
|
||||
"team",
|
||||
"user",
|
||||
]);
|
||||
```
|
||||
|
||||
Exception: `.index("by_foo", ["foo"])` is really an index on `foo` + `_creationTime`, while `.index("by_foo_and_bar", ["foo", "bar"])` is on `foo` + `bar` + `_creationTime`. If you need results sorted by `foo` then `_creationTime`, you need the single-field index because the compound one would sort by `bar` first.
|
||||
|
||||
## 2. Minimize Data Sources
|
||||
|
||||
Trace every read.
|
||||
|
||||
If a function resolves a foreign key for a tiny display field and a denormalized copy already exists, prefer the denormalized field on the hot path.
|
||||
|
||||
### When to denormalize
|
||||
|
||||
Denormalize when all of these are true:
|
||||
|
||||
- the path is hot
|
||||
- the joined document is much larger than the field you need
|
||||
- many readers are paying that join cost repeatedly
|
||||
|
||||
Useful mental model:
|
||||
|
||||
`join_cost = rows_per_page x foreign_doc_size x pages_per_second`
|
||||
|
||||
Small-table joins are often fine. Large-document joins for tiny fields on hot list pages are usually not.
|
||||
|
||||
### Fallback rule
|
||||
|
||||
Denormalized data is an optimization. Live data is the correctness path.
|
||||
|
||||
Rules:
|
||||
|
||||
- If the denormalized field is missing or null, fall back to the live read
|
||||
- Do not show placeholders instead of falling back
|
||||
- In lookup maps, only include fully populated entries
|
||||
|
||||
```ts
|
||||
// Bad: missing denormalized data becomes a placeholder and blocks correctness
|
||||
const ownerName = project.ownerName ?? "Unknown owner";
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: denormalized data is an optimization, not the only source of truth
|
||||
const ownerName = project.ownerName ?? (await ctx.db.get(project.ownerId))?.name ?? null;
|
||||
```
|
||||
|
||||
Bad lookup map pattern:
|
||||
|
||||
```ts
|
||||
const ownersById = {
|
||||
[project.ownerId]: { ownerName: null },
|
||||
};
|
||||
```
|
||||
|
||||
That blocks fallback because the map says "I have data" when it does not.
|
||||
|
||||
Good lookup map pattern:
|
||||
|
||||
```ts
|
||||
const ownersById =
|
||||
project.ownerName !== undefined && project.ownerName !== null
|
||||
? { [project.ownerId]: { ownerName: project.ownerName } }
|
||||
: {};
|
||||
```
|
||||
|
||||
### No denormalized copy yet
|
||||
|
||||
Prefer adding fields to an existing summary, companion, or digest table instead of bloating the primary hot-path table.
|
||||
|
||||
If introducing the new field or table requires a staged rollout, backfill, or old/new-shape handling, use the migration helper skill for the rollout plan.
|
||||
|
||||
Rollout order:
|
||||
|
||||
1. Update schema
|
||||
2. Update write path
|
||||
3. Backfill
|
||||
4. Switch read path
|
||||
|
||||
## 3. Minimize Row Size
|
||||
|
||||
Hot list pages should read the smallest document shape that still answers the UI.
|
||||
|
||||
Prefer summary or digest tables over full source tables when:
|
||||
|
||||
- the list page only needs a subset of fields
|
||||
- source documents are large
|
||||
- the query is high volume
|
||||
|
||||
An 800 byte summary row is materially cheaper than a 3 KB full document on a hot page.
|
||||
|
||||
Digest tables are a tradeoff, not a default:
|
||||
|
||||
- Worth it when the path is clearly hot, the source rows are much larger than the UI needs, or many readers are repeatedly paying the same join and payload cost
|
||||
- Probably not worth it when an indexed read on the source table is already cheap enough, the table is still small, or the extra write and migration complexity would dominate the benefit
|
||||
|
||||
```ts
|
||||
// Bad: list page reads source docs, then joins owner data per row
|
||||
const projects = await ctx.db
|
||||
.query("projects")
|
||||
.withIndex("by_public", (q) => q.eq("isPublic", true))
|
||||
.collect();
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: list page reads the smaller digest shape first
|
||||
const projects = await ctx.db
|
||||
.query("projectDigests")
|
||||
.withIndex("by_public_and_updated", (q) => q.eq("isPublic", true))
|
||||
.order("desc")
|
||||
.take(20);
|
||||
```
|
||||
|
||||
## 4. Isolate Frequently-Updated Fields
|
||||
|
||||
Convex already no-ops unchanged writes. The invalidation problem here is real writes hitting documents that many queries subscribe to.
|
||||
|
||||
Move high-churn fields like `lastSeen`, counters, presence, or ephemeral status off widely-read documents when most readers do not need them.
|
||||
|
||||
Apply this across sibling writers too. Splitting one write path does not help much if three other mutations still update the same widely-read document.
|
||||
|
||||
```ts
|
||||
// Bad: every presence heartbeat invalidates subscribers to the whole profile
|
||||
await ctx.db.patch(user._id, {
|
||||
name: args.name,
|
||||
avatarUrl: args.avatarUrl,
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: keep profile reads stable, move heartbeat updates to a separate document
|
||||
await ctx.db.patch(user._id, {
|
||||
name: args.name,
|
||||
avatarUrl: args.avatarUrl,
|
||||
});
|
||||
|
||||
await ctx.db.patch(presence._id, {
|
||||
lastSeen: Date.now(),
|
||||
});
|
||||
```
|
||||
|
||||
## 5. Match Consistency To Read Patterns
|
||||
|
||||
Choose read strategy based on traffic shape.
|
||||
|
||||
### High-read, low-write
|
||||
|
||||
Examples:
|
||||
|
||||
- public browse pages
|
||||
- search results
|
||||
- landing pages
|
||||
- directory listings
|
||||
|
||||
Prefer:
|
||||
|
||||
- point-in-time reads where appropriate
|
||||
- explicit refresh
|
||||
- local state for pagination
|
||||
- caching where appropriate
|
||||
|
||||
Do not treat subscriptions as automatically wrong here. Prefer point-in-time reads only when the product does not need live freshness and the reactive cost is material. See `subscription-cost.md` for detailed patterns.
|
||||
|
||||
### High-read, high-write
|
||||
|
||||
Examples:
|
||||
|
||||
- collaborative editors
|
||||
- live dashboards
|
||||
- presence-heavy views
|
||||
|
||||
Reactive queries may be worth the ongoing cost.
|
||||
|
||||
## Convex-Specific Notes
|
||||
|
||||
### Reactive queries
|
||||
|
||||
Every `ctx.db.get()` and `ctx.db.query()` contributes to the invalidation set for the query.
|
||||
|
||||
On the client:
|
||||
|
||||
- `useQuery` creates a live subscription
|
||||
- `usePaginatedQuery` creates a live subscription per page
|
||||
|
||||
For low-freshness flows, consider a point-in-time read instead of a live subscription only when the product does not need updates pushed automatically.
|
||||
|
||||
### Point-in-time reads
|
||||
|
||||
Framework helpers, server-rendered fetches, or one-shot client reads can avoid ongoing subscription cost when live updates are not useful.
|
||||
|
||||
Use them for:
|
||||
|
||||
- aggregate snapshots
|
||||
- reports
|
||||
- low-churn listings
|
||||
- pages where explicit refresh is fine
|
||||
|
||||
### Triggers and fan-out
|
||||
|
||||
Triggers fire on every write, including writes that did not materially change the document.
|
||||
|
||||
When a write exists only to keep derived state in sync:
|
||||
|
||||
- diff before patching
|
||||
- move expensive non-blocking work to `ctx.scheduler.runAfter` when appropriate
|
||||
|
||||
### Aggregates
|
||||
|
||||
Reactive global counts invalidate frequently on busy tables.
|
||||
|
||||
Prefer:
|
||||
|
||||
- one-shot aggregate fetches
|
||||
- periodic recomputation
|
||||
- precomputed summary rows
|
||||
|
||||
for global stats that do not need live updates every second.
|
||||
|
||||
### Backfills
|
||||
|
||||
For larger backfills, use cursor-based, self-scheduling `internalMutation` jobs or the migrations component.
|
||||
|
||||
Deploy code that can handle both states before running the backfill.
|
||||
|
||||
During the gap:
|
||||
|
||||
- writes should populate the new shape
|
||||
- reads should fall back safely
|
||||
|
||||
## Verification
|
||||
|
||||
Before closing the audit, confirm:
|
||||
|
||||
1. Same results as before, no dropped records
|
||||
2. The removed table or lookup is no longer in the hot-path read set
|
||||
3. Tests or validation cover fallback behavior
|
||||
4. Migration safety is preserved while fields or indexes are unbackfilled
|
||||
5. Sibling functions were fixed consistently
|
||||
@ -0,0 +1,114 @@
|
||||
# OCC Conflict Resolution
|
||||
|
||||
Use these rules when insights, logs, or dashboard health show OCC (Optimistic Concurrency Control) conflicts, mutation retries, or write contention on hot tables.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Convex uses optimistic concurrency control. When two transactions read or write overlapping data, one succeeds and the other retries automatically. High contention means wasted work and increased latency.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- OCC conflict errors in deployment logs or health page
|
||||
- Mutations retrying multiple times before succeeding
|
||||
- User-visible latency spikes on write-heavy pages
|
||||
- `npx convex insights --details` showing high conflict rates
|
||||
|
||||
## Common Causes
|
||||
|
||||
### Hot documents
|
||||
|
||||
Multiple mutations writing to the same document concurrently. Classic examples: a global counter, a shared settings row, or a "last updated" timestamp on a parent record.
|
||||
|
||||
### Broad read sets causing false conflicts
|
||||
|
||||
A query that scans a large table range creates a broad read set. If any write touches that range, the query's transaction conflicts even if the specific document the query cared about was not modified.
|
||||
|
||||
### Fan-out from triggers or cascading writes
|
||||
|
||||
A single user action triggers multiple mutations that all touch related documents. Each mutation competes with the others.
|
||||
|
||||
Database triggers (e.g. from `convex-helpers`) run inside the same transaction as the mutation that caused them. If a trigger does heavy work, reads extra tables, or writes to many documents, it extends the transaction's read/write set and increases the window for conflicts. Keep trigger logic minimal, or move expensive derived work to a scheduled function.
|
||||
|
||||
### Write-then-read chains
|
||||
|
||||
A mutation writes a document, then a reactive query re-reads it, then another mutation writes it again. Under load, these chains stack up.
|
||||
|
||||
## Fix Order
|
||||
|
||||
### 1. Reduce read set size
|
||||
|
||||
Narrower reads mean fewer false conflicts.
|
||||
|
||||
```ts
|
||||
// Bad: broad scan creates a wide conflict surface
|
||||
const allTasks = await ctx.db.query("tasks").collect();
|
||||
const mine = allTasks.filter((t) => t.ownerId === userId);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: indexed query touches only relevant documents
|
||||
const mine = await ctx.db
|
||||
.query("tasks")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerId", userId))
|
||||
.collect();
|
||||
```
|
||||
|
||||
### 2. Split hot documents
|
||||
|
||||
When many writers target the same document, split the contention point.
|
||||
|
||||
```ts
|
||||
// Bad: every vote increments the same counter document
|
||||
const counter = await ctx.db.get(pollCounterId);
|
||||
await ctx.db.patch(pollCounterId, { count: counter!.count + 1 });
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: shard the counter across multiple documents, aggregate on read
|
||||
const shardIndex = Math.floor(Math.random() * SHARD_COUNT);
|
||||
const shardId = shardIds[shardIndex];
|
||||
const shard = await ctx.db.get(shardId);
|
||||
await ctx.db.patch(shardId, { count: shard!.count + 1 });
|
||||
```
|
||||
|
||||
Aggregate the shards in a query or scheduled job when you need the total.
|
||||
|
||||
### 3. Move non-critical work to scheduled functions
|
||||
|
||||
If a mutation does primary work plus secondary bookkeeping (analytics, non-critical notifications, cache warming), the bookkeeping extends the transaction's lifetime and read/write set.
|
||||
|
||||
```ts
|
||||
// Bad: canonical write and derived work happen in the same transaction
|
||||
await ctx.db.patch(userId, { name: args.name });
|
||||
await ctx.db.insert("userUpdateAnalytics", {
|
||||
userId,
|
||||
kind: "name_changed",
|
||||
name: args.name,
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: keep the primary write small, defer the analytics work
|
||||
await ctx.db.patch(userId, { name: args.name });
|
||||
await ctx.scheduler.runAfter(0, internal.users.recordNameChangeAnalytics, {
|
||||
userId,
|
||||
name: args.name,
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Combine competing writes
|
||||
|
||||
If two mutations must update the same document atomically, consider whether they can be combined into a single mutation call from the client, reducing round trips and conflict windows.
|
||||
|
||||
Do not introduce artificial locks or queues unless the above steps have been tried first.
|
||||
|
||||
## Related: Invalidation Scope
|
||||
|
||||
Splitting hot documents also reduces subscription invalidation, not just OCC contention. If a document is written frequently and read by many queries, those queries re-run on every write even when the fields they care about have not changed. See `subscription-cost.md` section 4 ("Isolate frequently-updated fields") for that pattern.
|
||||
|
||||
## Verification
|
||||
|
||||
1. OCC conflict rate has dropped in insights or dashboard
|
||||
2. Mutation latency is lower and more consistent
|
||||
3. No data correctness regressions from splitting or scheduling changes
|
||||
4. Sibling writers to the same hot documents were fixed consistently
|
||||
@ -0,0 +1,249 @@
|
||||
# Subscription Cost
|
||||
|
||||
Use these rules when the problem is too many reactive subscriptions, queries invalidating too frequently, or React components re-rendering excessively due to Convex state changes.
|
||||
|
||||
## Core Principle
|
||||
|
||||
Every `useQuery` and `usePaginatedQuery` call creates a live subscription. The server tracks the query's read set and re-executes the query whenever any document in that read set changes. Subscription cost scales with:
|
||||
|
||||
`subscriptions x invalidation_frequency x query_cost`
|
||||
|
||||
Subscriptions are not inherently bad. Convex reactivity is often the right default. The goal is to reduce unnecessary invalidation work, not to eliminate subscriptions on principle.
|
||||
|
||||
## Symptoms
|
||||
|
||||
- Dashboard shows high active subscription count
|
||||
- UI feels sluggish or laggy despite fast individual queries
|
||||
- React profiling shows frequent re-renders from Convex state
|
||||
- Pages with many components each running their own `useQuery`
|
||||
- Paginated lists where every loaded page stays subscribed
|
||||
|
||||
## Common Causes
|
||||
|
||||
### Reactive queries on low-freshness flows
|
||||
|
||||
Some user flows are read-heavy and do not need live updates every time the underlying data changes. In those cases, ongoing subscriptions may cost more than they are worth.
|
||||
|
||||
### Overly broad queries
|
||||
|
||||
A query that returns a large result set invalidates whenever any document in that set changes. The broader the query, the more frequent the invalidation.
|
||||
|
||||
### Too many subscriptions per page
|
||||
|
||||
A page with 20 list items, each running its own `useQuery` to fetch related data, creates 20+ subscriptions per visitor.
|
||||
|
||||
### Paginated queries keeping all pages live
|
||||
|
||||
`usePaginatedQuery` with `loadMore` keeps every loaded page subscribed. On a page where a user has scrolled through 10 pages, all 10 stay reactive.
|
||||
|
||||
### Frequently-updated fields on widely-read documents
|
||||
|
||||
A document that many queries touch gets a frequently-updated field (like `lastSeen`, `lastActiveAt`, or a counter). Every write to that field invalidates every subscription that reads the document, even if those subscriptions never use the field. This is different from OCC conflicts (see `occ-conflicts.md`), which are write-vs-write contention. This is write-vs-subscription: the write succeeds fine, but it forces hundreds of queries to re-run for no reason.
|
||||
|
||||
## Fix Order
|
||||
|
||||
### 1. Use point-in-time reads when live updates are not valuable
|
||||
|
||||
Keep `useQuery` and `usePaginatedQuery` by default when the product benefits from fresh live data.
|
||||
|
||||
Consider a point-in-time read instead when all of these are true:
|
||||
|
||||
- the flow is high-read
|
||||
- the underlying data changes less often than users need to see
|
||||
- explicit refresh, periodic refresh, or a fresh read on navigation is acceptable
|
||||
|
||||
Possible implementations depend on environment:
|
||||
|
||||
- a server-rendered fetch
|
||||
- a framework helper like `fetchQuery`
|
||||
- a point-in-time client read such as `ConvexHttpClient.query()`
|
||||
|
||||
```ts
|
||||
// Reactive by default when fresh live data matters
|
||||
function TeamPresence() {
|
||||
const presence = useQuery(api.teams.livePresence, { teamId });
|
||||
return <PresenceList users={presence} />;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// Point-in-time read when explicit refresh is acceptable
|
||||
import { ConvexHttpClient } from "convex/browser";
|
||||
|
||||
const client = new ConvexHttpClient(import.meta.env.VITE_CONVEX_URL);
|
||||
|
||||
function SnapshotView() {
|
||||
const [items, setItems] = useState<Item[]>([]);
|
||||
|
||||
useEffect(() => {
|
||||
client.query(api.items.snapshot).then(setItems);
|
||||
}, []);
|
||||
|
||||
return <ItemGrid items={items} />;
|
||||
}
|
||||
```
|
||||
|
||||
Good candidates for point-in-time reads:
|
||||
|
||||
- aggregate snapshots
|
||||
- reports
|
||||
- low-churn listings
|
||||
- flows where explicit refresh is already acceptable
|
||||
|
||||
Keep reactive for:
|
||||
|
||||
- collaborative editing
|
||||
- live dashboards
|
||||
- presence-heavy views
|
||||
- any surface where users expect fresh changes to appear automatically
|
||||
|
||||
### 2. Batch related data into fewer queries
|
||||
|
||||
Instead of N components each fetching their own related data, fetch it in a single query.
|
||||
|
||||
```ts
|
||||
// Bad: each card fetches its own author
|
||||
function ProjectCard({ project }: { project: Project }) {
|
||||
const author = useQuery(api.users.get, { id: project.authorId });
|
||||
return <Card title={project.name} author={author?.name} />;
|
||||
}
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: parent query returns projects with author names included
|
||||
function ProjectList() {
|
||||
const projects = useQuery(api.projects.listWithAuthors);
|
||||
return projects?.map((p) => (
|
||||
<Card key={p._id} title={p.name} author={p.authorName} />
|
||||
));
|
||||
}
|
||||
```
|
||||
|
||||
This can use denormalized fields or server-side joins in the query handler. Either way, it is one subscription instead of N.
|
||||
|
||||
This is not automatically better. If the combined query becomes much broader and invalidates much more often, several narrower subscriptions may be the better tradeoff. Optimize for total invalidation cost, not raw subscription count.
|
||||
|
||||
### 3. Use skip to avoid unnecessary subscriptions
|
||||
|
||||
The `"skip"` value prevents a subscription from being created when the arguments are not ready.
|
||||
|
||||
```ts
|
||||
// Bad: subscribes with undefined args, wastes a subscription slot
|
||||
const profile = useQuery(api.users.getProfile, { userId: selectedId! });
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: skip when there is nothing to fetch
|
||||
const profile = useQuery(api.users.getProfile, selectedId ? { userId: selectedId } : "skip");
|
||||
```
|
||||
|
||||
### 4. Isolate frequently-updated fields into separate documents
|
||||
|
||||
If a document is widely read but has a field that changes often, move that field to a separate document. Queries that do not need the field will no longer be invalidated by its writes.
|
||||
|
||||
```ts
|
||||
// Bad: lastSeen lives on the user doc, every heartbeat invalidates
|
||||
// every query that reads this user
|
||||
const users = defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
lastSeen: v.number(),
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: lastSeen lives in a separate heartbeat doc
|
||||
const users = defineTable({
|
||||
name: v.string(),
|
||||
email: v.string(),
|
||||
heartbeatId: v.id("heartbeats"),
|
||||
});
|
||||
|
||||
const heartbeats = defineTable({
|
||||
lastSeen: v.number(),
|
||||
});
|
||||
```
|
||||
|
||||
Queries that only need `name` and `email` no longer re-run on every heartbeat. Queries that actually need online status fetch the heartbeat document explicitly.
|
||||
|
||||
For an even further optimization, if you only need a coarse online/offline boolean rather than the exact `lastSeen` timestamp, add a separate presence document with an `isOnline` flag. Update it immediately when a user comes online, and use a cron to batch-mark users offline when their heartbeat goes stale. This way the presence query only invalidates when online status actually changes, not on every heartbeat.
|
||||
|
||||
### 5. Use the aggregate component for counts and sums
|
||||
|
||||
Reactive global counts (`SELECT COUNT(*)` equivalent) invalidate on every insert or delete to the table. The [`@convex-dev/aggregate`](https://www.npmjs.com/package/@convex-dev/aggregate) component maintains denormalized COUNT, SUM, and MAX values efficiently so you do not need a reactive query scanning the full table.
|
||||
|
||||
Use it for leaderboards, totals, "X items" badges, or any stat that would otherwise require scanning many rows reactively.
|
||||
|
||||
If the aggregate component is not appropriate, prefer point-in-time reads for global stats, or precomputed summary rows updated by a cron or trigger, over reactive queries that scan large tables.
|
||||
|
||||
### 6. Narrow query read sets
|
||||
|
||||
Queries that return less data and touch fewer documents invalidate less often.
|
||||
|
||||
```ts
|
||||
// Bad: returns all fields, invalidates on any field change
|
||||
export const list = query({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("projects").collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: use a digest table with only the fields the list needs
|
||||
export const listDigests = query({
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("projectDigests").collect();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Writes to fields not in the digest table do not invalidate the digest query.
|
||||
|
||||
### 7. Remove `Date.now()` from queries
|
||||
|
||||
Using `Date.now()` inside a query defeats Convex's query cache. The cache is invalidated frequently to avoid showing stale time-dependent results, which increases database work even when the underlying data has not changed.
|
||||
|
||||
```ts
|
||||
// Bad: Date.now() defeats query caching and causes frequent re-evaluation
|
||||
const releasedPosts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_released_at", (q) => q.lte("releasedAt", Date.now()))
|
||||
.take(100);
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: use a boolean field updated by a scheduled function
|
||||
const releasedPosts = await ctx.db
|
||||
.query("posts")
|
||||
.withIndex("by_is_released", (q) => q.eq("isReleased", true))
|
||||
.take(100);
|
||||
```
|
||||
|
||||
If the query must compare against a time value, pass it as an explicit argument from the client and round it to a coarse interval (e.g. the most recent minute) so requests within that window share the same cache entry.
|
||||
|
||||
### 8. Consider pagination strategy
|
||||
|
||||
For long lists where users scroll through many pages:
|
||||
|
||||
- If the data does not need live updates, use point-in-time fetching with manual "load more"
|
||||
- If it does need live updates, accept the subscription cost but limit the number of loaded pages
|
||||
- Consider whether older pages can be unloaded as the user scrolls forward
|
||||
|
||||
### 9. Separate backend cost from UI churn
|
||||
|
||||
If the main problem is loading flash or UI churn when query arguments change, stabilizing the reactive UI behavior may be better than replacing reactivity altogether.
|
||||
|
||||
Treat this as a UX problem first when:
|
||||
|
||||
- the underlying query is already reasonably cheap
|
||||
- the complaint is flicker, loading flashes, or re-render churn
|
||||
- live updates are still desirable once fresh data arrives
|
||||
|
||||
## Verification
|
||||
|
||||
1. Subscription count in dashboard is lower for the affected pages
|
||||
2. UI responsiveness has improved
|
||||
3. React profiling shows fewer unnecessary re-renders
|
||||
4. Surfaces that do not need live updates are not paying for persistent subscriptions unnecessarily
|
||||
5. Sibling pages with similar patterns were updated consistently
|
||||
341
.agents/skills/convex-quickstart/SKILL.md
Normal file
341
.agents/skills/convex-quickstart/SKILL.md
Normal file
@ -0,0 +1,341 @@
|
||||
---
|
||||
name: convex-quickstart
|
||||
description: Creates or adds Convex to an app. Use for new Convex projects, npm create convex@latest, frontend setup, env vars, or the first npx convex dev run.
|
||||
---
|
||||
|
||||
# Convex Quickstart
|
||||
|
||||
Set up a working Convex project as fast as possible.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Starting a brand new project with Convex
|
||||
- Adding Convex to an existing React, Next.js, Vue, Svelte, or other app
|
||||
- Scaffolding a Convex app for prototyping
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- The project already has Convex installed and `convex/` exists - just start building
|
||||
- You only need to add auth to an existing Convex app - use the `convex-setup-auth` skill
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine the starting point: new project or existing app
|
||||
2. If new project, pick a template and scaffold with `npm create convex@latest`
|
||||
3. If existing app, install `convex` and wire up the provider
|
||||
4. Run `npx convex dev` to connect a deployment and start the dev loop
|
||||
5. Verify the setup works
|
||||
|
||||
## Path 1: New Project (Recommended)
|
||||
|
||||
Use the official scaffolding tool. It creates a complete project with the frontend framework, Convex backend, and all config wired together.
|
||||
|
||||
### Pick a template
|
||||
|
||||
| Template | Stack |
|
||||
| -------------------------- | ----------------------------------------- |
|
||||
| `react-vite-shadcn` | React + Vite + Tailwind + shadcn/ui |
|
||||
| `nextjs-shadcn` | Next.js App Router + Tailwind + shadcn/ui |
|
||||
| `react-vite-clerk-shadcn` | React + Vite + Clerk auth + shadcn/ui |
|
||||
| `nextjs-clerk` | Next.js + Clerk auth |
|
||||
| `nextjs-convexauth-shadcn` | Next.js + Convex Auth + shadcn/ui |
|
||||
| `nextjs-lucia-shadcn` | Next.js + Lucia auth + shadcn/ui |
|
||||
| `bare` | Convex backend only, no frontend |
|
||||
|
||||
If the user has not specified a preference, default to `react-vite-shadcn` for simple apps or `nextjs-shadcn` for apps that need SSR or API routes.
|
||||
|
||||
You can also use any GitHub repo as a template:
|
||||
|
||||
```bash
|
||||
npm create convex@latest my-app -- -t owner/repo
|
||||
npm create convex@latest my-app -- -t owner/repo#branch
|
||||
```
|
||||
|
||||
### Scaffold the project
|
||||
|
||||
Always pass the project name and template flag to avoid interactive prompts:
|
||||
|
||||
```bash
|
||||
npm create convex@latest my-app -- -t react-vite-shadcn
|
||||
cd my-app
|
||||
npm install
|
||||
```
|
||||
|
||||
The scaffolding tool creates files but does not run `npm install`, so you must run it yourself.
|
||||
|
||||
To scaffold in the current directory (if it is empty):
|
||||
|
||||
```bash
|
||||
npm create convex@latest . -- -t react-vite-shadcn
|
||||
npm install
|
||||
```
|
||||
|
||||
### Start the dev loop
|
||||
|
||||
`npx convex dev` is a long-running watcher process that syncs backend code to a Convex deployment on every save. It also requires authentication on first run (browser-based OAuth). Both of these make it unsuitable for an agent to run directly.
|
||||
|
||||
**Ask the user to run this themselves:**
|
||||
|
||||
Tell the user to run `npx convex dev` in their terminal. On first run it will prompt them to log in or develop anonymously. Once running, it will:
|
||||
|
||||
- Create a Convex project and dev deployment
|
||||
- Write the deployment URL to `.env.local`
|
||||
- Create the `convex/` directory with generated types
|
||||
- Watch for changes and sync continuously
|
||||
|
||||
The user should keep `npx convex dev` running in the background while you work on code. The watcher will automatically pick up any files you create or edit in `convex/`.
|
||||
|
||||
**Exception - cloud or headless agents:** Environments that cannot open a browser for interactive login should use Agent Mode (see below) to run anonymously without user interaction.
|
||||
|
||||
### Start the frontend
|
||||
|
||||
The user should also run the frontend dev server in a separate terminal:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Vite apps serve on `http://localhost:5173`, Next.js on `http://localhost:3000`.
|
||||
|
||||
### What you get
|
||||
|
||||
After scaffolding, the project structure looks like:
|
||||
|
||||
```
|
||||
my-app/
|
||||
convex/ # Backend functions and schema
|
||||
_generated/ # Auto-generated types (check this into git)
|
||||
schema.ts # Database schema (if template includes one)
|
||||
src/ # Frontend code (or app/ for Next.js)
|
||||
package.json
|
||||
.env.local # CONVEX_URL / VITE_CONVEX_URL / NEXT_PUBLIC_CONVEX_URL
|
||||
```
|
||||
|
||||
The template already has:
|
||||
|
||||
- `ConvexProvider` wired into the app root
|
||||
- Correct env var names for the framework
|
||||
- Tailwind and shadcn/ui ready (for shadcn templates)
|
||||
- Auth provider configured (for auth templates)
|
||||
|
||||
Proceed to adding schema, functions, and UI.
|
||||
|
||||
## Path 2: Add Convex to an Existing App
|
||||
|
||||
Use this when the user already has a frontend project and wants to add Convex as the backend.
|
||||
|
||||
### Install
|
||||
|
||||
```bash
|
||||
npm install convex
|
||||
```
|
||||
|
||||
### Initialize and start dev loop
|
||||
|
||||
Ask the user to run `npx convex dev` in their terminal. This handles login, creates the `convex/` directory, writes the deployment URL to `.env.local`, and starts the file watcher. See the notes in Path 1 about why the agent should not run this directly.
|
||||
|
||||
### Wire up the provider
|
||||
|
||||
The Convex client must wrap the app at the root. The setup varies by framework.
|
||||
|
||||
Create the `ConvexReactClient` at module scope, not inside a component:
|
||||
|
||||
```tsx
|
||||
// Bad: re-creates the client on every render
|
||||
function App() {
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
|
||||
return <ConvexProvider client={convex}>...</ConvexProvider>;
|
||||
}
|
||||
|
||||
// Good: created once at module scope
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
|
||||
function App() {
|
||||
return <ConvexProvider client={convex}>...</ConvexProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
#### React (Vite)
|
||||
|
||||
```tsx
|
||||
// src/main.tsx
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||
import App from "./App";
|
||||
|
||||
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL as string);
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ConvexProvider client={convex}>
|
||||
<App />
|
||||
</ConvexProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
```
|
||||
|
||||
#### Next.js (App Router)
|
||||
|
||||
```tsx
|
||||
// app/ConvexClientProvider.tsx
|
||||
"use client";
|
||||
|
||||
import { ConvexProvider, ConvexReactClient } from "convex/react";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
|
||||
|
||||
export function ConvexClientProvider({ children }: { children: ReactNode }) {
|
||||
return <ConvexProvider client={convex}>{children}</ConvexProvider>;
|
||||
}
|
||||
```
|
||||
|
||||
```tsx
|
||||
// app/layout.tsx
|
||||
import { ConvexClientProvider } from "./ConvexClientProvider";
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body>
|
||||
<ConvexClientProvider>{children}</ConvexClientProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Other frameworks
|
||||
|
||||
For Vue, Svelte, React Native, TanStack Start, Remix, and others, follow the matching quickstart guide:
|
||||
|
||||
- [Vue](https://docs.convex.dev/quickstart/vue)
|
||||
- [Svelte](https://docs.convex.dev/quickstart/svelte)
|
||||
- [React Native](https://docs.convex.dev/quickstart/react-native)
|
||||
- [TanStack Start](https://docs.convex.dev/quickstart/tanstack-start)
|
||||
- [Remix](https://docs.convex.dev/quickstart/remix)
|
||||
- [Node.js (no frontend)](https://docs.convex.dev/quickstart/nodejs)
|
||||
|
||||
### Environment variables
|
||||
|
||||
The env var name depends on the framework:
|
||||
|
||||
| Framework | Variable |
|
||||
| ------------ | ------------------------ |
|
||||
| Vite | `VITE_CONVEX_URL` |
|
||||
| Next.js | `NEXT_PUBLIC_CONVEX_URL` |
|
||||
| Remix | `CONVEX_URL` |
|
||||
| React Native | `EXPO_PUBLIC_CONVEX_URL` |
|
||||
|
||||
`npx convex dev` writes the correct variable to `.env.local` automatically.
|
||||
|
||||
## Agent Mode (Cloud and Headless Agents)
|
||||
|
||||
When running in a cloud or headless agent environment where interactive browser login is not possible, set `CONVEX_AGENT_MODE=anonymous` to use a local anonymous deployment.
|
||||
|
||||
Add `CONVEX_AGENT_MODE=anonymous` to `.env.local`, or set it inline:
|
||||
|
||||
```bash
|
||||
CONVEX_AGENT_MODE=anonymous npx convex dev
|
||||
```
|
||||
|
||||
This runs a local Convex backend on the VM without requiring authentication, and avoids conflicting with the user's personal dev deployment.
|
||||
|
||||
## Verify the Setup
|
||||
|
||||
After setup, confirm everything is working:
|
||||
|
||||
1. The user confirms `npx convex dev` is running without errors
|
||||
2. The `convex/_generated/` directory exists and has `api.ts` and `server.ts`
|
||||
3. `.env.local` contains the deployment URL
|
||||
|
||||
## Writing Your First Function
|
||||
|
||||
Once the project is set up, create a schema and a query to verify the full loop works.
|
||||
|
||||
`convex/schema.ts`:
|
||||
|
||||
```ts
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
tasks: defineTable({
|
||||
text: v.string(),
|
||||
completed: v.boolean(),
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
`convex/tasks.ts`:
|
||||
|
||||
```ts
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export const list = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
return await ctx.db.query("tasks").collect();
|
||||
},
|
||||
});
|
||||
|
||||
export const create = mutation({
|
||||
args: { text: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.insert("tasks", { text: args.text, completed: false });
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Use in a React component (adjust the import path based on your file location relative to `convex/`):
|
||||
|
||||
```tsx
|
||||
import { useQuery, useMutation } from "convex/react";
|
||||
import { api } from "../convex/_generated/api";
|
||||
|
||||
function Tasks() {
|
||||
const tasks = useQuery(api.tasks.list);
|
||||
const create = useMutation(api.tasks.create);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<button onClick={() => create({ text: "New task" })}>Add</button>
|
||||
{tasks?.map((t) => (
|
||||
<div key={t._id}>{t.text}</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Development vs Production
|
||||
|
||||
Always use `npx convex dev` during development. It runs against your personal dev deployment and syncs code on save.
|
||||
|
||||
When ready to ship, deploy to production:
|
||||
|
||||
```bash
|
||||
npx convex deploy
|
||||
```
|
||||
|
||||
This pushes to the production deployment, which is separate from dev. Do not use `deploy` during development.
|
||||
|
||||
## Next Steps
|
||||
|
||||
- Add authentication: use the `convex-setup-auth` skill
|
||||
- Design your schema: see [Schema docs](https://docs.convex.dev/database/schemas)
|
||||
- Build components: use the `convex-create-component` skill
|
||||
- Plan a migration: use the `convex-migration-helper` skill
|
||||
- Add file storage: see [File Storage docs](https://docs.convex.dev/file-storage)
|
||||
- Set up cron jobs: see [Scheduling docs](https://docs.convex.dev/scheduling)
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Determined starting point: new project or existing app
|
||||
- [ ] If new project: scaffolded with `npm create convex@latest` using appropriate template
|
||||
- [ ] If existing app: installed `convex` and wired up the provider
|
||||
- [ ] User has `npx convex dev` running and connected to a deployment
|
||||
- [ ] `convex/_generated/` directory exists with types
|
||||
- [ ] `.env.local` has the deployment URL
|
||||
- [ ] Verified a basic query/mutation round-trip works
|
||||
10
.agents/skills/convex-quickstart/agents/openai.yaml
Normal file
10
.agents/skills/convex-quickstart/agents/openai.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Convex Quickstart"
|
||||
short_description: "Start a new Convex app or add Convex to an existing frontend."
|
||||
icon_small: "./assets/icon.svg"
|
||||
icon_large: "./assets/icon.svg"
|
||||
brand_color: "#F97316"
|
||||
default_prompt: "Set up Convex for this project as fast as possible. First decide whether this is a new app or an existing app, then scaffold or integrate Convex and verify the setup works."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
4
.agents/skills/convex-quickstart/assets/icon.svg
Normal file
4
.agents/skills/convex-quickstart/assets/icon.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z"/>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M15.91 11.672a.375.375 0 0 1 0 .656l-5.603 3.113a.375.375 0 0 1-.557-.328V8.887c0-.286.307-.466.557-.327l5.603 3.112Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 435 B |
148
.agents/skills/convex-setup-auth/SKILL.md
Normal file
148
.agents/skills/convex-setup-auth/SKILL.md
Normal file
@ -0,0 +1,148 @@
|
||||
---
|
||||
name: convex-setup-auth
|
||||
description: Sets up Convex auth, identity mapping, and access control. Use for login, auth providers, users tables, protected functions, or roles in a Convex app.
|
||||
---
|
||||
|
||||
# Convex Authentication Setup
|
||||
|
||||
Implement secure authentication in Convex with user management and access control.
|
||||
|
||||
## When to Use
|
||||
|
||||
- Setting up authentication for the first time
|
||||
- Implementing user management (users table, identity mapping)
|
||||
- Creating authentication helper functions
|
||||
- Setting up auth providers (Convex Auth, Clerk, WorkOS AuthKit, Auth0, custom JWT)
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- Auth for a non-Convex backend
|
||||
- Pure OAuth/OIDC documentation without a Convex implementation
|
||||
- Debugging unrelated bugs that happen to surface near auth code
|
||||
- The auth provider is already fully configured and the user only needs a one-line fix
|
||||
|
||||
## First Step: Choose the Auth Provider
|
||||
|
||||
Convex supports multiple authentication approaches. Do not assume a provider.
|
||||
|
||||
Before writing setup code:
|
||||
|
||||
1. Ask the user which auth solution they want, unless the repository already makes it obvious
|
||||
2. If the repo already uses a provider, continue with that provider unless the user wants to switch
|
||||
3. If the user has not chosen a provider and the repo does not make it obvious, ask before proceeding
|
||||
|
||||
Common options:
|
||||
|
||||
- [Convex Auth](https://docs.convex.dev/auth/convex-auth) - good default when the user wants auth handled directly in Convex
|
||||
- [Clerk](https://docs.convex.dev/auth/clerk) - use when the app already uses Clerk or the user wants Clerk's hosted auth features
|
||||
- [WorkOS AuthKit](https://docs.convex.dev/auth/authkit/) - use when the app already uses WorkOS or the user wants AuthKit specifically
|
||||
- [Auth0](https://docs.convex.dev/auth/auth0) - use when the app already uses Auth0
|
||||
- Custom JWT provider - use when integrating an existing auth system not covered above
|
||||
|
||||
Look for signals in the repo before asking:
|
||||
|
||||
- Dependencies such as `@clerk/*`, `@workos-inc/*`, `@auth0/*`, or Convex Auth packages
|
||||
- Existing files such as `convex/auth.config.ts`, auth middleware, provider wrappers, or login components
|
||||
- Environment variables that clearly point at a provider
|
||||
|
||||
## After Choosing a Provider
|
||||
|
||||
Read the provider's official guide and the matching local reference file:
|
||||
|
||||
- Convex Auth: [official docs](https://docs.convex.dev/auth/convex-auth), then `references/convex-auth.md`
|
||||
- Clerk: [official docs](https://docs.convex.dev/auth/clerk), then `references/clerk.md`
|
||||
- WorkOS AuthKit: [official docs](https://docs.convex.dev/auth/authkit/), then `references/workos-authkit.md`
|
||||
- Auth0: [official docs](https://docs.convex.dev/auth/auth0), then `references/auth0.md`
|
||||
|
||||
The local reference files contain the concrete workflow, expected files and env vars, gotchas, and validation checks.
|
||||
|
||||
Use those sources for:
|
||||
|
||||
- package installation
|
||||
- client provider wiring
|
||||
- environment variables
|
||||
- `convex/auth.config.ts` setup
|
||||
- login and logout UI patterns
|
||||
- framework-specific setup for React, Vite, or Next.js
|
||||
|
||||
For shared auth behavior, use the official Convex docs as the source of truth:
|
||||
|
||||
- [Auth in Functions](https://docs.convex.dev/auth/functions-auth) for `ctx.auth.getUserIdentity()`
|
||||
- [Storing Users in the Convex Database](https://docs.convex.dev/auth/database-auth) for optional app-level user storage
|
||||
- [Authentication](https://docs.convex.dev/auth) for general auth and authorization guidance
|
||||
- [Convex Auth Authorization](https://labs.convex.dev/auth/authz) when the provider is Convex Auth
|
||||
|
||||
Prefer official docs over recalled steps, because provider CLIs and Convex Auth internals change between versions. Inventing setup from memory risks outdated patterns.
|
||||
For third-party providers, only add app-level user storage if the app actually needs user documents in Convex. Not every app needs a `users` table.
|
||||
For Convex Auth, follow the Convex Auth docs and built-in auth tables rather than adding a parallel `users` table plus `storeUser` flow, because Convex Auth already manages user records internally.
|
||||
After running provider initialization commands, verify generated files and complete the post-init wiring steps the provider reference calls out. Initialization commands rarely finish the entire integration.
|
||||
|
||||
## Core Pattern: Protecting Backend Functions
|
||||
|
||||
The most common auth task is checking identity in Convex functions.
|
||||
|
||||
```ts
|
||||
// Bad: trusting a client-provided userId
|
||||
export const getMyProfile = query({
|
||||
args: { userId: v.id("users") },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db.get(args.userId);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Good: verifying identity server-side
|
||||
export const getMyProfile = query({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const identity = await ctx.auth.getUserIdentity();
|
||||
if (!identity) throw new Error("Not authenticated");
|
||||
|
||||
return await ctx.db
|
||||
.query("users")
|
||||
.withIndex("by_tokenIdentifier", (q) => q.eq("tokenIdentifier", identity.tokenIdentifier))
|
||||
.unique();
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Determine the provider, either by asking the user or inferring from the repo
|
||||
2. Ask whether the user wants local-only setup or production-ready setup now
|
||||
3. Read the matching provider reference file
|
||||
4. Follow the official provider docs for current setup details
|
||||
5. Follow the official Convex docs for shared backend auth behavior, user storage, and authorization patterns
|
||||
6. Only add app-level user storage if the docs and app requirements call for it
|
||||
7. Add authorization checks for ownership, roles, or team access only where the app needs them
|
||||
8. Verify login state, protected queries, environment variables, and production configuration if requested
|
||||
|
||||
If the flow blocks on interactive provider or deployment setup, ask the user explicitly for the exact human step needed, then continue after they complete it.
|
||||
For UI-facing auth flows, offer to validate the real sign-up or sign-in flow after setup is done.
|
||||
If the environment has browser automation tools, you can use them.
|
||||
If it does not, give the user a short manual validation checklist instead.
|
||||
|
||||
## Reference Files
|
||||
|
||||
### Provider References
|
||||
|
||||
- `references/convex-auth.md`
|
||||
- `references/clerk.md`
|
||||
- `references/workos-authkit.md`
|
||||
- `references/auth0.md`
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Chosen the correct auth provider before writing setup code
|
||||
- [ ] Read the relevant provider reference file
|
||||
- [ ] Asked whether the user wants local-only setup or production-ready setup
|
||||
- [ ] Used the official provider docs for provider-specific wiring
|
||||
- [ ] Used the official Convex docs for shared auth behavior and authorization patterns
|
||||
- [ ] Only added app-level user storage if the app actually needs it
|
||||
- [ ] Did not invent a cross-provider `users` table or `storeUser` flow for Convex Auth
|
||||
- [ ] Added authentication checks in protected backend functions
|
||||
- [ ] Added authorization checks where the app actually needs them
|
||||
- [ ] Clear error messages ("Not authenticated", "Unauthorized")
|
||||
- [ ] Client auth provider configured for the chosen provider
|
||||
- [ ] If requested, production auth setup is covered too
|
||||
10
.agents/skills/convex-setup-auth/agents/openai.yaml
Normal file
10
.agents/skills/convex-setup-auth/agents/openai.yaml
Normal file
@ -0,0 +1,10 @@
|
||||
interface:
|
||||
display_name: "Convex Setup Auth"
|
||||
short_description: "Set up Convex auth, user identity mapping, and access control."
|
||||
icon_small: "./assets/icon.svg"
|
||||
icon_large: "./assets/icon.svg"
|
||||
brand_color: "#2563EB"
|
||||
default_prompt: "Set up authentication for this Convex app. Figure out the provider first, then wire up the user model, identity mapping, and access control with the smallest solid implementation."
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
3
.agents/skills/convex-setup-auth/assets/icon.svg
Normal file
3
.agents/skills/convex-setup-auth/assets/icon.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" aria-hidden="true" data-slot="icon">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 1 0-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 0 0 2.25-2.25v-6.75a2.25 2.25 0 0 0-2.25-2.25H6.75a2.25 2.25 0 0 0-2.25 2.25v6.75a2.25 2.25 0 0 0 2.25 2.25Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 394 B |
116
.agents/skills/convex-setup-auth/references/auth0.md
Normal file
116
.agents/skills/convex-setup-auth/references/auth0.md
Normal file
@ -0,0 +1,116 @@
|
||||
# Auth0
|
||||
|
||||
Official docs:
|
||||
|
||||
- https://docs.convex.dev/auth/auth0
|
||||
- https://auth0.github.io/auth0-cli/
|
||||
- https://auth0.github.io/auth0-cli/auth0_apps_create.html
|
||||
|
||||
Use this when the app already uses Auth0 or the user wants Auth0 specifically.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the user wants Auth0
|
||||
2. Determine the app framework and whether Auth0 is already partly set up
|
||||
3. Ask whether the user wants local-only setup or production-ready setup now
|
||||
4. Read the official Convex and Auth0 guides before making changes
|
||||
5. Ask whether they want the fastest setup path by installing the Auth0 CLI
|
||||
6. If they agree, install the Auth0 CLI and do as much of the Auth0 app setup as possible through the CLI
|
||||
7. If they do not want the CLI path, use the Auth0 dashboard path instead
|
||||
8. Complete the relevant Auth0 frontend quickstart if the app does not already have Auth0 wired up
|
||||
9. Configure `convex/auth.config.ts` with the Auth0 domain and client ID
|
||||
10. Set environment variables for local and production environments
|
||||
11. Wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
|
||||
12. Gate Convex-backed UI with Convex auth state
|
||||
13. Try to verify Convex reports the user as authenticated after Auth0 login
|
||||
14. If the refresh-token path fails, stop improvising and send the user back to the official docs
|
||||
15. If the user wants production-ready setup, make sure the production Auth0 tenant and env vars are also covered
|
||||
|
||||
## What To Do
|
||||
|
||||
- Read the official Convex and Auth0 guide before writing setup code
|
||||
- Prefer the Auth0 CLI path for mechanical setup if the user is willing to install it, but do not present it as a fully validated end-to-end path yet
|
||||
- Ask the user directly: "The fastest path is to install the Auth0 CLI so I can do more of this for you. If you want, I can install it and then only ask you to log in when needed. Would you like me to do that?"
|
||||
- Make sure the app has already completed the relevant Auth0 quickstart for its frontend
|
||||
- Use the official examples for `Auth0Provider` and `ConvexProviderWithAuth0`
|
||||
- If the Auth0 login or refresh flow starts failing in a way that is not clearly explained by the docs, say that plainly and fall back to the official docs instead of pretending the flow is validated
|
||||
|
||||
## Key Setup Areas
|
||||
|
||||
- install the Auth0 SDK for the app's framework
|
||||
- configure `convex/auth.config.ts` with the Auth0 domain and client ID
|
||||
- set environment variables for local and production environments
|
||||
- wrap the app with `Auth0Provider` and `ConvexProviderWithAuth0`
|
||||
- use Convex auth state when gating Convex-backed UI
|
||||
|
||||
## Files and Env Vars To Expect
|
||||
|
||||
- `convex/auth.config.ts`
|
||||
- frontend app entry or provider wrapper
|
||||
- Auth0 CLI install docs: `https://auth0.github.io/auth0-cli/`
|
||||
- Auth0 environment variables commonly include:
|
||||
- `AUTH0_DOMAIN`
|
||||
- `AUTH0_CLIENT_ID`
|
||||
- `VITE_AUTH0_DOMAIN`
|
||||
- `VITE_AUTH0_CLIENT_ID`
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
1. Start by reading `https://docs.convex.dev/auth/auth0` and the relevant Auth0 quickstart for the app's framework
|
||||
2. Ask whether the user wants the Auth0 CLI path
|
||||
3. If yes, install Auth0 CLI and have the user authenticate it with `auth0 login`
|
||||
4. Use `auth0 apps create` with SPA settings, callback URL, logout URL, and web origins if creating a new app
|
||||
5. If not using the CLI path, complete the relevant Auth0 frontend quickstart and create the Auth0 app in the dashboard
|
||||
6. Get the Auth0 domain and client ID from the CLI output or the Auth0 dashboard
|
||||
7. Install the Auth0 SDK for the app's framework
|
||||
8. Create or update `convex/auth.config.ts` with the Auth0 domain and client ID
|
||||
9. Set frontend and backend environment variables
|
||||
10. Wrap the app in `Auth0Provider`
|
||||
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithAuth0`
|
||||
12. Run the normal Convex dev or deploy flow after backend config changes
|
||||
13. Try the official provider config shown in the Convex docs
|
||||
14. If login works but Convex auth or token refresh fails in a way you cannot clearly resolve, stop and tell the user to follow the official docs manually for now
|
||||
15. Only claim success if the user can sign in and Convex recognizes the authenticated session
|
||||
16. If the user wants production-ready setup, configure the production Auth0 tenant values and production environment variables too
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The Convex docs assume the Auth0 side is already set up, so do not skip the Auth0 quickstart if the app is starting from scratch
|
||||
- The Auth0 CLI is often the fastest path for a fresh setup, but it still requires the user to authenticate the CLI to their Auth0 tenant
|
||||
- If the user agrees to install the Auth0 CLI, do the mechanical setup yourself instead of bouncing them through the dashboard
|
||||
- If login succeeds but Convex still reports unauthenticated, double-check `convex/auth.config.ts` and whether the backend config was synced
|
||||
- We were able to automate Auth0 app creation and Convex config wiring, but we did not fully validate the refresh-token path end to end
|
||||
- In validation, the documented `useRefreshTokens={true}` and `cacheLocation="localstorage"` setup hit refresh-token failures, so do not present that path as settled
|
||||
- If you hit Auth0 errors like `Unknown or invalid refresh token`, do not keep inventing fixes indefinitely, send the user back to the official docs and explain that this path is still under investigation
|
||||
- Keep dev and prod tenants separate if the project uses different Auth0 environments
|
||||
- Do not confuse "Auth0 login works" with "Convex can validate the Auth0 token". Both need to work.
|
||||
- If the repo already uses Auth0, preserve existing redirect and tenant configuration unless the user asked to change it.
|
||||
- Do not assume the local Auth0 tenant settings match production. Verify the production domain, client ID, and callback URLs separately.
|
||||
- For local dev, make sure the Auth0 app settings match the app's real local port for callback URLs, logout URLs, and web origins
|
||||
|
||||
## Production
|
||||
|
||||
- Ask whether the user wants dev-only setup or production-ready setup
|
||||
- If the answer is production-ready, make sure the production Auth0 tenant values, callback URLs, and Convex deployment config are all covered
|
||||
- Verify production environment variables and redirect settings before calling the task complete
|
||||
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
|
||||
|
||||
## Validation
|
||||
|
||||
- Verify the user can complete the Auth0 login flow
|
||||
- Verify Convex-authenticated UI renders only after Convex auth state is ready
|
||||
- Verify protected Convex queries succeed after login
|
||||
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
|
||||
- Verify the Auth0 app settings match the real local callback and logout URLs during development
|
||||
- If the Auth0 refresh-token path fails, mark the setup as not fully validated and direct the user to the official docs instead of claiming the skill completed successfully
|
||||
- If production-ready setup was requested, verify the production Auth0 configuration is also covered
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Confirm the user wants Auth0
|
||||
- [ ] Ask whether the user wants local-only setup or production-ready setup
|
||||
- [ ] Complete the relevant Auth0 frontend setup
|
||||
- [ ] Configure `convex/auth.config.ts`
|
||||
- [ ] Set environment variables
|
||||
- [ ] Verify Convex authenticated state after login, or explicitly tell the user this path is still under investigation and send them to the official docs
|
||||
- [ ] If requested, configure the production deployment too
|
||||
113
.agents/skills/convex-setup-auth/references/clerk.md
Normal file
113
.agents/skills/convex-setup-auth/references/clerk.md
Normal file
@ -0,0 +1,113 @@
|
||||
# Clerk
|
||||
|
||||
Official docs:
|
||||
|
||||
- https://docs.convex.dev/auth/clerk
|
||||
- https://clerk.com/docs/guides/development/integrations/databases/convex
|
||||
|
||||
Use this when the app already uses Clerk or the user wants Clerk's hosted auth features.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the user wants Clerk
|
||||
2. Make sure the user has a Clerk account and a Clerk application
|
||||
3. Determine the app framework:
|
||||
- React
|
||||
- Next.js
|
||||
- TanStack Start
|
||||
4. Ask whether the user wants local-only setup or production-ready setup now
|
||||
5. Gather the Clerk keys and the Clerk Frontend API URL
|
||||
6. Follow the correct framework section in the official docs
|
||||
7. Complete the backend and client wiring
|
||||
8. Verify Convex reports the user as authenticated after login
|
||||
9. If the user wants production-ready setup, make sure the production Clerk config is also covered
|
||||
|
||||
## What To Do
|
||||
|
||||
- Read the official Convex and Clerk guide before writing setup code
|
||||
- If the user does not already have Clerk set up, send them to `https://dashboard.clerk.com/sign-up` to create an account and `https://dashboard.clerk.com/apps/new` to create an application
|
||||
- Send the user to `https://dashboard.clerk.com/apps/setup/convex` if the Convex integration is not already active
|
||||
- Match the guide to the app's framework, usually React, Next.js, or TanStack Start
|
||||
- Use the official examples for `ConvexProviderWithClerk`, `ClerkProvider`, and `useAuth`
|
||||
|
||||
## Key Setup Areas
|
||||
|
||||
- install the Clerk SDK for the framework in use
|
||||
- configure `convex/auth.config.ts` with the Clerk issuer domain
|
||||
- set the required Clerk environment variables
|
||||
- wrap the app with `ClerkProvider` and `ConvexProviderWithClerk`
|
||||
- use Convex auth-aware UI patterns such as `Authenticated`, `Unauthenticated`, and `AuthLoading`
|
||||
|
||||
## Files and Env Vars To Expect
|
||||
|
||||
- `convex/auth.config.ts`
|
||||
- React or Vite client entry such as `src/main.tsx`
|
||||
- Next.js client wrapper for Convex if using App Router
|
||||
- Clerk account sign-up page: `https://dashboard.clerk.com/sign-up`
|
||||
- Clerk app creation page: `https://dashboard.clerk.com/apps/new`
|
||||
- Clerk Convex integration page: `https://dashboard.clerk.com/apps/setup/convex`
|
||||
- Clerk API keys page: `https://dashboard.clerk.com/last-active?path=api-keys`
|
||||
- Clerk environment variables:
|
||||
- `CLERK_JWT_ISSUER_DOMAIN` for Convex backend validation in the Convex docs
|
||||
- `CLERK_FRONTEND_API_URL` in the Clerk docs
|
||||
- `VITE_CLERK_PUBLISHABLE_KEY` for Vite apps
|
||||
- `NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY` for Next.js apps
|
||||
- `CLERK_SECRET_KEY` for Next.js server-side Clerk setup where required
|
||||
|
||||
`CLERK_JWT_ISSUER_DOMAIN` and `CLERK_FRONTEND_API_URL` refer to the same Clerk Frontend API URL value. Do not treat them as two different URLs.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
1. If needed, create a Clerk account at `https://dashboard.clerk.com/sign-up`
|
||||
2. If needed, create a Clerk application at `https://dashboard.clerk.com/apps/new`
|
||||
3. Open `https://dashboard.clerk.com/last-active?path=api-keys` and copy the publishable key, plus the secret key for Next.js where needed
|
||||
4. Open `https://dashboard.clerk.com/apps/setup/convex`
|
||||
5. Activate the Convex integration in Clerk if it is not already active
|
||||
6. Copy the Clerk Frontend API URL shown there
|
||||
7. Install the Clerk package for the app's framework
|
||||
8. Create or update `convex/auth.config.ts` so Convex validates Clerk tokens
|
||||
9. Set the publishable key in the frontend environment
|
||||
10. Set the issuer domain or Frontend API URL so Convex can validate the JWT
|
||||
11. Replace plain `ConvexProvider` wiring with `ConvexProviderWithClerk`
|
||||
12. Wrap the app in `ClerkProvider`
|
||||
13. Use Convex auth helpers for authenticated rendering
|
||||
14. Run the normal Convex dev or deploy flow after updating backend auth config
|
||||
15. If the user wants production-ready setup, configure the production Clerk values and production issuer domain too
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Prefer `useConvexAuth()` over raw Clerk auth state when deciding whether Convex-authenticated UI can render
|
||||
- For Next.js, keep server and client boundaries in mind when creating the Convex provider wrapper
|
||||
- After changing `convex/auth.config.ts`, run the normal Convex dev or deploy flow so the backend picks up the new config
|
||||
- Do not stop at "Clerk login works". The important check is that Convex also sees the session and can authenticate requests.
|
||||
- If the repo already uses Clerk, preserve its existing auth flow unless the user asked to change it.
|
||||
- Do not assume the same Clerk values work for both dev and production. Check the production issuer domain and publishable key separately.
|
||||
- The Convex setup page is where you get the Clerk Frontend API URL for Convex. Keep using the Clerk API keys page for the publishable key and the secret key.
|
||||
- If Convex says no auth provider matched the token, first confirm the Clerk Convex integration was activated at `https://dashboard.clerk.com/apps/setup/convex`
|
||||
- After activating the Clerk Convex integration, sign out completely and sign back in before retesting. An old Clerk session can keep using a token that Convex rejects.
|
||||
|
||||
## Production
|
||||
|
||||
- Ask whether the user wants dev-only setup or production-ready setup
|
||||
- If the answer is production-ready, make sure production Clerk keys and issuer configuration are included
|
||||
- Verify production redirect URLs and any production Clerk domain values before calling the task complete
|
||||
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
|
||||
|
||||
## Validation
|
||||
|
||||
- Verify the user can sign in with Clerk
|
||||
- If the Clerk integration was just activated, verify after a full Clerk sign-out and fresh sign-in
|
||||
- Verify `useConvexAuth()` reaches the authenticated state after Clerk login
|
||||
- Verify protected Convex queries run successfully inside authenticated UI
|
||||
- Verify `ctx.auth.getUserIdentity()` is non-null in protected backend functions
|
||||
- If production-ready setup was requested, verify the production Clerk configuration is also covered
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Confirm the user wants Clerk
|
||||
- [ ] Ask whether the user wants local-only setup or production-ready setup
|
||||
- [ ] Follow the correct framework section in the official guide
|
||||
- [ ] Set Clerk environment variables
|
||||
- [ ] Configure `convex/auth.config.ts`
|
||||
- [ ] Verify Convex authenticated state after login
|
||||
- [ ] If requested, configure the production deployment too
|
||||
143
.agents/skills/convex-setup-auth/references/convex-auth.md
Normal file
143
.agents/skills/convex-setup-auth/references/convex-auth.md
Normal file
@ -0,0 +1,143 @@
|
||||
# Convex Auth
|
||||
|
||||
Official docs: https://docs.convex.dev/auth/convex-auth
|
||||
Setup guide: https://labs.convex.dev/auth/setup
|
||||
|
||||
Use this when the user wants auth handled directly in Convex rather than through a third-party provider.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the user wants Convex Auth specifically
|
||||
2. Determine which sign-in methods the app needs:
|
||||
- magic links or OTPs
|
||||
- OAuth providers
|
||||
- passwords and password reset
|
||||
3. Ask whether the user wants local-only setup or production-ready setup now
|
||||
4. Read the Convex Auth setup guide before writing code
|
||||
5. Make sure the project has a configured Convex deployment:
|
||||
- run `npx convex dev` first if `CONVEX_DEPLOYMENT` is not set
|
||||
- if CLI configuration requires interactive human input, stop and ask the user to complete that step before continuing
|
||||
6. Install the auth packages:
|
||||
- `npm install @convex-dev/auth @auth/core@0.37.0`
|
||||
7. Run the initialization command:
|
||||
- `npx @convex-dev/auth`
|
||||
8. Confirm the initializer created:
|
||||
- `convex/auth.config.ts`
|
||||
- `convex/auth.ts`
|
||||
- `convex/http.ts`
|
||||
9. Add the required `authTables` to `convex/schema.ts`
|
||||
10. Replace plain `ConvexProvider` wiring with `ConvexAuthProvider`
|
||||
11. Configure at least one auth method in `convex/auth.ts`
|
||||
12. Run `npx convex dev --once` or the normal dev flow to push the updated schema and generated code
|
||||
13. Verify the client can sign in successfully
|
||||
14. Verify Convex receives authenticated identity in backend functions
|
||||
15. If the user wants production-ready setup, make sure the same auth setup is configured for the production deployment as well
|
||||
16. Only add a `users` table and `storeUser` flow if the app needs app-level user records inside Convex
|
||||
|
||||
## What This Reference Is For
|
||||
|
||||
- choosing Convex Auth as the default provider for a new Convex app
|
||||
- understanding whether the app wants magic links, OTPs, OAuth, or passwords
|
||||
- keeping the setup provider-specific while using the official Convex Auth docs for identity and authorization behavior
|
||||
|
||||
## What To Do
|
||||
|
||||
- Read the Convex Auth setup guide before writing setup code
|
||||
- Follow the setup flow from the docs rather than recreating it from memory
|
||||
- If the app is new, consider starting from the official starter flow instead of hand-wiring everything
|
||||
- Treat `npx @convex-dev/auth` as a required initialization step for existing apps, not an optional extra
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
1. Install `@convex-dev/auth` and `@auth/core@0.37.0`
|
||||
2. Run `npx convex dev` if the project does not already have a configured deployment
|
||||
3. If `npx convex dev` blocks on interactive setup, ask the user explicitly to finish configuring the Convex deployment
|
||||
4. Run `npx @convex-dev/auth`
|
||||
5. Confirm the generated auth setup is present before continuing:
|
||||
- `convex/auth.config.ts`
|
||||
- `convex/auth.ts`
|
||||
- `convex/http.ts`
|
||||
6. Add `authTables` to `convex/schema.ts`
|
||||
7. Replace `ConvexProvider` with `ConvexAuthProvider` in the app entry
|
||||
8. Configure the selected auth methods in `convex/auth.ts`
|
||||
9. Run `npx convex dev --once` or the normal dev flow so the updated schema and auth files are pushed
|
||||
10. Verify login locally
|
||||
11. If the user wants production-ready setup, repeat the required auth configuration against the production deployment
|
||||
|
||||
## Expected Files and Decisions
|
||||
|
||||
- `convex/schema.ts`
|
||||
- frontend app entry such as `src/main.tsx` or the framework-equivalent provider file
|
||||
- generated Convex Auth setup produced by `npx @convex-dev/auth`
|
||||
- an existing configured Convex deployment, or the ability to create one with `npx convex dev`
|
||||
- `convex/auth.ts` starts with `providers: []` until the app configures actual sign-in methods
|
||||
|
||||
- Decide whether the user is creating a new app or adding auth to an existing app
|
||||
- For a new app, prefer the official starter flow instead of rebuilding setup by hand
|
||||
- Decide which auth methods the app needs:
|
||||
- magic links or OTPs
|
||||
- OAuth providers
|
||||
- passwords
|
||||
- Decide whether the user wants local-only setup or production-ready setup now
|
||||
- Decide whether the app actually needs a `users` table inside Convex, or whether provider identity alone is enough
|
||||
|
||||
## Gotchas
|
||||
|
||||
- Do not assume a specific sign-in method. Ask which methods the app needs before wiring UI and backend behavior.
|
||||
- `npx @convex-dev/auth` is important because it initializes the auth setup, including the key material. Do not skip it when adding Convex Auth to an existing project.
|
||||
- `npx @convex-dev/auth` will fail if the project does not already have a configured `CONVEX_DEPLOYMENT`.
|
||||
- `npx convex dev` may require interactive setup for deployment creation or project selection. If that happens, ask the user explicitly for that human step instead of guessing.
|
||||
- `npx @convex-dev/auth` does not finish the whole integration by itself. You still need to add `authTables`, swap in `ConvexAuthProvider`, and configure at least one auth method.
|
||||
- A project can still build even if `convex/auth.ts` still has `providers: []`, so do not treat a successful build as proof that sign-in is fully configured.
|
||||
- Convex Auth does not mean every app needs a `users` table. If the app only needs authentication gates, `ctx.auth.getUserIdentity()` may be enough.
|
||||
- If the app is greenfield, starting from the official starter flow is usually better than partially recreating it by hand.
|
||||
- Do not stop at local dev setup if the user expects production-ready auth. The production deployment needs the auth setup too.
|
||||
- Keep provider-specific setup and Convex Auth authorization behavior in the official docs instead of inventing shared patterns from memory.
|
||||
|
||||
## Production
|
||||
|
||||
- Ask whether the user wants dev-only setup or production-ready setup
|
||||
- If the answer is production-ready, make sure the auth configuration is applied to the production deployment, not just the dev deployment
|
||||
- Verify production-specific redirect URLs, auth method configuration, and deployment settings before calling the task complete
|
||||
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
|
||||
|
||||
## Human Handoff
|
||||
|
||||
If `npx convex dev` or deployment setup requires human input:
|
||||
|
||||
- stop and explain exactly what the user needs to do
|
||||
- say why that step is required
|
||||
- resume the auth setup immediately after the user confirms it is done
|
||||
|
||||
## Validation
|
||||
|
||||
- Verify the user can complete a sign-in flow
|
||||
- Offer to validate sign up, sign out, and sign back in with the configured auth method
|
||||
- If browser automation is available in the environment, you can do this directly
|
||||
- If browser automation is not available, give the user a short manual validation checklist instead
|
||||
- Verify `ctx.auth.getUserIdentity()` returns an identity in protected backend functions
|
||||
- Verify protected UI only renders after Convex-authenticated state is ready
|
||||
- Verify environment variables and redirect settings match the current app environment
|
||||
- Verify `convex/auth.ts` no longer has an empty `providers: []` configuration once the app is meant to support real sign-in
|
||||
- Run `npx convex dev --once` or the normal dev flow after setup changes and confirm Convex codegen and push succeed
|
||||
- If production-ready setup was requested, verify the production deployment is also configured correctly
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Confirm the user wants Convex Auth specifically
|
||||
- [ ] Ask whether the user wants local-only setup or production-ready setup
|
||||
- [ ] Ensure a Convex deployment is configured before running auth initialization
|
||||
- [ ] Install `@convex-dev/auth` and `@auth/core@0.37.0`
|
||||
- [ ] Run `npx convex dev` first if needed
|
||||
- [ ] Run `npx @convex-dev/auth`
|
||||
- [ ] Confirm `convex/auth.config.ts`, `convex/auth.ts`, and `convex/http.ts` were created
|
||||
- [ ] Follow the setup guide for package install and wiring
|
||||
- [ ] Add `authTables` to `convex/schema.ts`
|
||||
- [ ] Replace `ConvexProvider` with `ConvexAuthProvider`
|
||||
- [ ] Configure at least one auth method in `convex/auth.ts`
|
||||
- [ ] Run `npx convex dev --once` or the normal dev flow after setup changes
|
||||
- [ ] Confirm which sign-in methods the app needs
|
||||
- [ ] Verify the client can sign in and the backend receives authenticated identity
|
||||
- [ ] Offer end-to-end validation of sign up, sign out, and sign back in
|
||||
- [ ] If requested, configure the production deployment too
|
||||
- [ ] Only add extra `users` table sync if the app needs app-level user records
|
||||
114
.agents/skills/convex-setup-auth/references/workos-authkit.md
Normal file
114
.agents/skills/convex-setup-auth/references/workos-authkit.md
Normal file
@ -0,0 +1,114 @@
|
||||
# WorkOS AuthKit
|
||||
|
||||
Official docs:
|
||||
|
||||
- https://docs.convex.dev/auth/authkit/
|
||||
- https://docs.convex.dev/auth/authkit/add-to-app
|
||||
- https://docs.convex.dev/auth/authkit/auto-provision
|
||||
|
||||
Use this when the app already uses WorkOS or the user wants AuthKit specifically.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Confirm the user wants WorkOS AuthKit
|
||||
2. Determine whether they want:
|
||||
- a Convex-managed WorkOS team
|
||||
- an existing WorkOS team
|
||||
3. Ask whether the user wants local-only setup or production-ready setup now
|
||||
4. Read the official Convex and WorkOS AuthKit guide
|
||||
5. Create or update `convex.json` for the app's framework and real local port
|
||||
6. Follow the correct branch of the setup flow based on that choice
|
||||
7. Configure the required WorkOS environment variables
|
||||
8. Configure `convex/auth.config.ts` for WorkOS-issued JWTs
|
||||
9. Wire the client provider and callback flow
|
||||
10. Verify authenticated requests reach Convex
|
||||
11. If the user wants production-ready setup, make sure the production WorkOS configuration is covered too
|
||||
12. Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
|
||||
|
||||
## What To Do
|
||||
|
||||
- Read the official Convex and WorkOS AuthKit guide before writing setup code
|
||||
- Determine whether the user wants a Convex-managed WorkOS team or an existing WorkOS team
|
||||
- Treat `convex.json` as a first-class part of the AuthKit setup, not an optional extra
|
||||
- Follow the current setup flow from the docs instead of relying on older examples
|
||||
|
||||
## Key Setup Areas
|
||||
|
||||
- package installation for the app's framework
|
||||
- `convex.json` with the `authKit` section for dev, and preview or prod if needed
|
||||
- environment variables such as `WORKOS_CLIENT_ID`, `WORKOS_API_KEY`, and redirect configuration
|
||||
- `convex/auth.config.ts` wiring for WorkOS-issued JWTs
|
||||
- client provider setup and token flow into Convex
|
||||
- login callback and redirect configuration
|
||||
|
||||
## Files and Env Vars To Expect
|
||||
|
||||
- `convex.json`
|
||||
- `convex/auth.config.ts`
|
||||
- frontend auth provider wiring
|
||||
- callback or redirect route setup where the framework requires it
|
||||
- WorkOS environment variables commonly include:
|
||||
- `WORKOS_CLIENT_ID`
|
||||
- `WORKOS_API_KEY`
|
||||
- `WORKOS_COOKIE_PASSWORD`
|
||||
- `VITE_WORKOS_CLIENT_ID`
|
||||
- `VITE_WORKOS_REDIRECT_URI`
|
||||
- `NEXT_PUBLIC_WORKOS_REDIRECT_URI`
|
||||
|
||||
For a managed WorkOS team, `convex dev` can provision the AuthKit environment and write local env vars such as `VITE_WORKOS_CLIENT_ID` and `VITE_WORKOS_REDIRECT_URI` into `.env.local` for Vite apps.
|
||||
|
||||
## Concrete Steps
|
||||
|
||||
1. Choose Convex-managed or existing WorkOS team
|
||||
2. Create or update `convex.json` with the `authKit` section for the framework in use
|
||||
3. Make sure the dev `redirectUris`, `appHomepageUrl`, `corsOrigins`, and local redirect env vars match the app's actual local port
|
||||
4. For a managed WorkOS team, run `npx convex dev` and follow the interactive onboarding flow
|
||||
5. For an existing WorkOS team, get `WORKOS_CLIENT_ID` and `WORKOS_API_KEY` from the WorkOS dashboard and set them with `npx convex env set`
|
||||
6. Create or update `convex/auth.config.ts` for WorkOS JWT validation
|
||||
7. Run the normal Convex dev or deploy flow so backend config is synced
|
||||
8. Wire the WorkOS client provider in the app
|
||||
9. Configure callback and redirect handling
|
||||
10. Verify the user can sign in and return to the app
|
||||
11. Verify Convex sees the authenticated user after login
|
||||
12. If the user wants production-ready setup, configure the production client ID, API key, redirect URI, and deployment settings too
|
||||
|
||||
## Gotchas
|
||||
|
||||
- The docs split setup between Convex-managed and existing WorkOS teams, so ask which path the user wants if it is not obvious
|
||||
- Keep dev and prod WorkOS configuration separate where the docs call for different client IDs or API keys
|
||||
- Only add `storeUser` or a `users` table if the app needs first-class user rows inside Convex
|
||||
- Do not mix dev and prod WorkOS credentials or redirect URIs
|
||||
- If the repo already contains WorkOS setup, preserve the current tenant model unless the user wants to change it
|
||||
- For managed WorkOS setup, `convex dev` is interactive the first time. In non-interactive terminals, stop and ask the user to complete the onboarding prompts.
|
||||
- `convex.json` is not optional for the managed AuthKit flow. It drives redirect URI, homepage URL, CORS configuration, and local env var generation.
|
||||
- If the frontend starts on a different port than the one in `convex.json`, the hosted WorkOS sign-in flow will point to the wrong callback URL. Update `convex.json`, update the local redirect env var, and run `npx convex dev` again.
|
||||
- Vite can fall off `5173` if other apps are already running. Do not assume the default port still matches the generated AuthKit config.
|
||||
- A successful WorkOS sign-in should redirect back to the local callback route and then reach a Convex-authenticated state. Do not stop at "the hosted WorkOS page loaded."
|
||||
|
||||
## Production
|
||||
|
||||
- Ask whether the user wants dev-only setup or production-ready setup
|
||||
- If the answer is production-ready, make sure the production WorkOS client ID, API key, redirect URI, and Convex deployment config are all covered
|
||||
- Verify the production redirect and callback settings before calling the task complete
|
||||
- Do not silently write a notes file into the repo by default. If the user wants rollout or handoff docs, create one explicitly.
|
||||
|
||||
## Validation
|
||||
|
||||
- Verify the user can complete the login flow and return to the app
|
||||
- Verify the callback URL matches the real frontend port in local dev
|
||||
- Verify Convex receives authenticated requests after login
|
||||
- Verify `convex.json` matches the framework and chosen WorkOS setup path
|
||||
- Verify `convex/auth.config.ts` matches the chosen WorkOS setup path
|
||||
- Verify environment variables differ correctly between local and production where needed
|
||||
- If production-ready setup was requested, verify the production WorkOS configuration is also covered
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] Confirm the user wants WorkOS AuthKit
|
||||
- [ ] Ask whether the user wants local-only setup or production-ready setup
|
||||
- [ ] Choose Convex-managed or existing WorkOS team
|
||||
- [ ] Create or update `convex.json`
|
||||
- [ ] Configure WorkOS environment variables
|
||||
- [ ] Configure `convex/auth.config.ts`
|
||||
- [ ] Verify authenticated requests reach Convex after login
|
||||
- [ ] If requested, configure the production deployment too
|
||||
47
.agents/skills/convex/SKILL.md
Normal file
47
.agents/skills/convex/SKILL.md
Normal file
@ -0,0 +1,47 @@
|
||||
---
|
||||
name: convex
|
||||
description: Routes general Convex requests to the right project skill. Use when the user asks which Convex skill to use or gives an underspecified Convex app task.
|
||||
---
|
||||
|
||||
# Convex
|
||||
|
||||
Use this as the routing skill for Convex work in this repo.
|
||||
|
||||
If a more specific Convex skill clearly matches the request, use that instead.
|
||||
|
||||
## Start Here
|
||||
|
||||
If the project does not already have Convex AI guidance installed, or the existing guidance looks stale, strongly recommend installing it first.
|
||||
|
||||
Preferred:
|
||||
|
||||
```bash
|
||||
npx convex ai-files install
|
||||
```
|
||||
|
||||
This installs or refreshes the managed Convex AI files. It is the recommended starting point for getting the official Convex guidelines in place and following the current Convex AI setup described in the docs:
|
||||
|
||||
- [Convex AI docs](https://docs.convex.dev/ai)
|
||||
|
||||
Simple fallback:
|
||||
|
||||
- [convex_rules.txt](https://convex.link/convex_rules.txt)
|
||||
|
||||
Prefer `npx convex ai-files install` over copying rules by hand when possible.
|
||||
|
||||
## Route to the Right Skill
|
||||
|
||||
After that, use the most specific Convex skill for the task:
|
||||
|
||||
- New project or adding Convex to an app: `convex-quickstart`
|
||||
- Authentication setup: `convex-setup-auth`
|
||||
- Building a reusable Convex component: `convex-create-component`
|
||||
- Planning or running a migration: `convex-migration-helper`
|
||||
- Investigating performance issues: `convex-performance-audit`
|
||||
|
||||
If one of those clearly matches the user's goal, switch to it instead of staying in this skill.
|
||||
|
||||
## When Not to Use
|
||||
|
||||
- The user has already named a more specific Convex workflow
|
||||
- Another Convex skill obviously fits the request better
|
||||
21
.codex/environments/environment.toml
Normal file
21
.codex/environments/environment.toml
Normal file
@ -0,0 +1,21 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "ClawHub"
|
||||
|
||||
[setup]
|
||||
script = "bun install"
|
||||
|
||||
[[actions]]
|
||||
name = "Run"
|
||||
icon = "run"
|
||||
command = "bun run dev:worktree"
|
||||
|
||||
[[actions]]
|
||||
name = "Convex Dev"
|
||||
icon = "tool"
|
||||
command = "bun run setup:worktree -- --quiet && bunx convex dev --typecheck=disable"
|
||||
|
||||
[[actions]]
|
||||
name = "Seed Dev DB"
|
||||
icon = "tool"
|
||||
command = "bun run seed:dev"
|
||||
15
.editorconfig
Normal file
15
.editorconfig
Normal file
@ -0,0 +1,15 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
||||
[Makefile]
|
||||
indent_style = tab
|
||||
12
.gitattributes
vendored
Normal file
12
.gitattributes
vendored
Normal file
@ -0,0 +1,12 @@
|
||||
* text=auto eol=lf
|
||||
|
||||
*.avif binary
|
||||
*.gif binary
|
||||
*.ico binary
|
||||
*.jpg binary
|
||||
*.jpeg binary
|
||||
*.png binary
|
||||
*.webp binary
|
||||
|
||||
*.woff binary
|
||||
*.woff2 binary
|
||||
118
.github/CODEOWNERS
vendored
Normal file
118
.github/CODEOWNERS
vendored
Normal file
@ -0,0 +1,118 @@
|
||||
# Protect the ownership rules themselves.
|
||||
/.github/CODEOWNERS @openclaw/openclaw-secops
|
||||
|
||||
# WARNING: GitHub CODEOWNERS uses last-match-wins semantics.
|
||||
# If you add overlapping rules below the secops block, include @openclaw/openclaw-secops
|
||||
# on those entries too or you can silently remove required secops review.
|
||||
# Security-sensitive code, config, workflows, and docs require secops review.
|
||||
/.github/actions/ @openclaw/openclaw-secops
|
||||
/.github/actionlint.yaml @openclaw/openclaw-secops
|
||||
/.github/codeql/ @openclaw/openclaw-secops
|
||||
/.github/dependabot.yml @openclaw/openclaw-secops
|
||||
/.github/workflows/ @openclaw/openclaw-secops
|
||||
/scripts/check-staged-secrets.mjs @openclaw/openclaw-secops
|
||||
/scripts/clawhub-cli-npm-publish.sh @openclaw/openclaw-secops
|
||||
/scripts/clawhub-cli-npm-release-check.mjs @openclaw/openclaw-secops
|
||||
/scripts/github/clawhub-rescan-auto-response.mjs @openclaw/openclaw-secops
|
||||
|
||||
# Backend auth, API, publish, upload, moderation, and scan enforcement.
|
||||
/convex/schema.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/auth.config.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/auth.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/commentModeration.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/http.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/httpApi.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/httpApiV1/ @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/packagePublishTokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/publishers.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/rateLimits.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/rescanRequests.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/skills.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/skillTransfers.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/tokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/uploads.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/vt.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/webhooks.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/access.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/apiTokenAuth.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/commentScamPrompt.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/githubActionsOidc.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/httpHeaders.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/httpRateLimit.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/manualOverrides.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/moderation.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/moderationEngine.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/moderationReasonCodes.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/packageRegistry.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/packageSecurity.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/publishers.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/publishLimits.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/reporting.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/securityPrompt.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/skillCapabilityTags.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/skillPublish.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/skillSafety.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/staticPublishScan.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/tokens.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/lib/webhooks.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/model/packages/rescans.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/model/rescans/policy.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/convex/model/skills/rescans.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
|
||||
# Frontend auth, admin, publish, upload, and security-review surfaces.
|
||||
/src/lib/packageApi.ts @openclaw/openclaw-secops @BunsDev
|
||||
/src/lib/packageUpload.ts @openclaw/openclaw-secops @BunsDev
|
||||
/src/lib/roles.ts @openclaw/openclaw-secops @BunsDev
|
||||
/src/lib/uploadFiles.ts @openclaw/openclaw-secops @BunsDev
|
||||
/src/lib/uploadUtils.ts @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/admin.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/cli/auth.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/packages/new.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/plugins/publish.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/publish-plugin.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/publish-skill.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/skills/publish.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/upload.tsx @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/upload/ @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/$owner/$slug/security/ @openclaw/openclaw-secops @BunsDev
|
||||
/src/routes/plugins/$name/security/ @openclaw/openclaw-secops @BunsDev
|
||||
|
||||
# CLI auth, admin, publishing, ownership, and package-contract surfaces.
|
||||
/packages/clawhub/src/browserAuth.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/http.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/adminHelp.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/authToken.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/clawdbotConfig.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/auth.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/delete.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/github.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/moderation.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/ownership.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/publish.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/rescan.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/transfer.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/commands/sync.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/cli/scanSkills.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/openclawContract.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/routes.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/schemas.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/clawhub/src/schema/textFiles.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/schema/src/openclawContract.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/schema/src/packages.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/schema/src/routes.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/schema/src/schemas.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/packages/schema/src/textFiles.ts @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
|
||||
# Security, auth, API, webhook, and deployment documentation.
|
||||
/docs/acceptable-usage.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/api.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/auth.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/deploy.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/http-api.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/security.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/docs/webhook.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/specs/github-import.md @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
/public/api/v1/openapi.json @openclaw/openclaw-secops @Patrick-Erichsen
|
||||
104
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
104
.github/ISSUE_TEMPLATE/rfc.yml
vendored
Normal file
@ -0,0 +1,104 @@
|
||||
name: RFC
|
||||
description: Propose a ClawHub policy, product, trust, or interface decision for feedback.
|
||||
title: "RFC: "
|
||||
labels:
|
||||
- "type: rfc"
|
||||
- "status: review"
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Use RFCs for decisions that need visible feedback before they become policy, product behavior, or public API contract. Accepted repo RFC files live under `rfcs/`, not `docs/`, so draft/decision records do not publish to the docs site. Keep sensitive enforcement details, private reports, exploit specifics, and scanner thresholds out of the public issue.
|
||||
- type: dropdown
|
||||
id: area
|
||||
attributes:
|
||||
label: Area
|
||||
description: Pick the primary area this RFC affects.
|
||||
options:
|
||||
- Moderation / policy
|
||||
- Security / trust
|
||||
- Product / UX
|
||||
- API / CLI
|
||||
- Documentation
|
||||
- Other
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Context
|
||||
description: What problem, decision, or ambiguity does this RFC address?
|
||||
placeholder: |
|
||||
ClawHub needs a clearer policy for...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: goals
|
||||
attributes:
|
||||
label: Goals
|
||||
description: What should this RFC achieve?
|
||||
placeholder: |
|
||||
- Make enforcement expectations understandable to users.
|
||||
- Give moderators a consistent decision boundary.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: non_goals
|
||||
attributes:
|
||||
label: Non-goals
|
||||
description: What is intentionally out of scope?
|
||||
placeholder: |
|
||||
- This RFC does not expose internal scanner thresholds.
|
||||
- This RFC does not decide implementation details for every moderation tool.
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposal
|
||||
description: Describe the proposed policy, behavior, or decision.
|
||||
placeholder: |
|
||||
ClawHub should...
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
id: examples
|
||||
attributes:
|
||||
label: Examples
|
||||
description: Give concrete allowed, not allowed, or edge-case examples.
|
||||
placeholder: |
|
||||
Allowed:
|
||||
- Defensive security review with explicit scope and evidence.
|
||||
|
||||
Not allowed:
|
||||
- Account takeover, evasion, or non-consensual surveillance workflows.
|
||||
|
||||
Edge cases:
|
||||
- ...
|
||||
- type: textarea
|
||||
id: user_impact
|
||||
attributes:
|
||||
label: User impact
|
||||
description: How does this affect authors, users, moderators, API consumers, or external contributors?
|
||||
placeholder: |
|
||||
Authors will...
|
||||
Users will...
|
||||
Moderators will...
|
||||
- type: textarea
|
||||
id: open_questions
|
||||
attributes:
|
||||
label: Open questions
|
||||
description: What feedback would be most useful before a decision?
|
||||
placeholder: |
|
||||
- Should appeals be handled in-product, through GitHub, or both?
|
||||
- What examples would make this clearer?
|
||||
validations:
|
||||
required: true
|
||||
- type: input
|
||||
id: feedback_deadline
|
||||
attributes:
|
||||
label: Feedback deadline
|
||||
description: Use an absolute date. Normal RFCs should stay open for 7-14 days unless urgent.
|
||||
placeholder: "YYYY-MM-DD"
|
||||
validations:
|
||||
required: true
|
||||
10
.github/actionlint.yaml
vendored
Normal file
10
.github/actionlint.yaml
vendored
Normal file
@ -0,0 +1,10 @@
|
||||
# actionlint configuration
|
||||
# https://github.com/rhysd/actionlint/blob/main/docs/config.md
|
||||
|
||||
self-hosted-runner:
|
||||
labels:
|
||||
# Blacksmith CI runners
|
||||
- blacksmith-4vcpu-ubuntu-2404
|
||||
- blacksmith-8vcpu-ubuntu-2404
|
||||
- blacksmith-16vcpu-ubuntu-2404
|
||||
- blacksmith-32vcpu-ubuntu-2404
|
||||
13
.github/actions/setup-bun/action.yml
vendored
Normal file
13
.github/actions/setup-bun/action.yml
vendored
Normal file
@ -0,0 +1,13 @@
|
||||
name: Setup Bun
|
||||
description: Install the pinned Bun runtime and workspace dependencies.
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
run: bun install --frozen-lockfile
|
||||
16
.github/codeql/codeql-actions-security.yml
vendored
Normal file
16
.github/codeql/codeql-actions-security.yml
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
name: clawhub-codeql-actions-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
|
||||
paths:
|
||||
- .github/workflows
|
||||
76
.github/codeql/codeql-backend-api-security.yml
vendored
Normal file
76
.github/codeql/codeql-backend-api-security.yml
vendored
Normal file
@ -0,0 +1,76 @@
|
||||
name: clawhub-codeql-backend-api-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- convex/auth.config.ts
|
||||
- convex/auth.ts
|
||||
- convex/commentModeration.ts
|
||||
- convex/http.ts
|
||||
- convex/httpApi.ts
|
||||
- convex/httpApiV1
|
||||
- convex/packagePublishTokens.ts
|
||||
- convex/packages.ts
|
||||
- convex/publishers.ts
|
||||
- convex/rateLimits.ts
|
||||
- convex/rescanRequests.ts
|
||||
- convex/skills.ts
|
||||
- convex/skillTransfers.ts
|
||||
- convex/tokens.ts
|
||||
- convex/uploads.ts
|
||||
- convex/vt.ts
|
||||
- convex/webhooks.ts
|
||||
- convex/lib/access.ts
|
||||
- convex/lib/apiTokenAuth.ts
|
||||
- convex/lib/commentScamPrompt.ts
|
||||
- convex/lib/githubActionsOidc.ts
|
||||
- convex/lib/httpHeaders.ts
|
||||
- convex/lib/httpRateLimit.ts
|
||||
- convex/lib/httpUtils.ts
|
||||
- convex/lib/manualOverrides.ts
|
||||
- convex/lib/moderation.ts
|
||||
- convex/lib/moderationEngine.ts
|
||||
- convex/lib/moderationReasonCodes.ts
|
||||
- convex/lib/packageRegistry.ts
|
||||
- convex/lib/packageSecurity.ts
|
||||
- convex/lib/publishers.ts
|
||||
- convex/lib/publishLimits.ts
|
||||
- convex/lib/reporting.ts
|
||||
- convex/lib/securityPrompt.ts
|
||||
- convex/lib/skillPublish.ts
|
||||
- convex/lib/skillSafety.ts
|
||||
- convex/lib/staticPublishScan.ts
|
||||
- convex/lib/tokens.ts
|
||||
- convex/lib/webhooks.ts
|
||||
- convex/model/packages/rescans.ts
|
||||
- convex/model/rescans/policy.ts
|
||||
- convex/model/skills/rescans.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/dist"
|
||||
- "**/dist/**"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
- "convex/_generated/**"
|
||||
59
.github/codeql/codeql-cli-package-security.yml
vendored
Normal file
59
.github/codeql/codeql-cli-package-security.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: clawhub-codeql-cli-package-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- packages/clawhub/src/browserAuth.ts
|
||||
- packages/clawhub/src/http.ts
|
||||
- packages/clawhub/src/cli/adminHelp.ts
|
||||
- packages/clawhub/src/cli/authToken.ts
|
||||
- packages/clawhub/src/cli/clawdbotConfig.ts
|
||||
- packages/clawhub/src/cli/commands/auth.ts
|
||||
- packages/clawhub/src/cli/commands/delete.ts
|
||||
- packages/clawhub/src/cli/commands/github.ts
|
||||
- packages/clawhub/src/cli/commands/moderation.ts
|
||||
- packages/clawhub/src/cli/commands/ownership.ts
|
||||
- packages/clawhub/src/cli/commands/packages.ts
|
||||
- packages/clawhub/src/cli/commands/publish.ts
|
||||
- packages/clawhub/src/cli/commands/rescan.ts
|
||||
- packages/clawhub/src/cli/commands/sync.ts
|
||||
- packages/clawhub/src/cli/commands/transfer.ts
|
||||
- packages/clawhub/src/cli/scanSkills.ts
|
||||
- packages/clawhub/src/schema/openclawContract.ts
|
||||
- packages/clawhub/src/schema/packages.ts
|
||||
- packages/clawhub/src/schema/routes.ts
|
||||
- packages/clawhub/src/schema/schemas.ts
|
||||
- packages/clawhub/src/schema/textFiles.ts
|
||||
- packages/schema/src/openclawContract.ts
|
||||
- packages/schema/src/packages.ts
|
||||
- packages/schema/src/routes.ts
|
||||
- packages/schema/src/schemas.ts
|
||||
- packages/schema/src/textFiles.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/dist"
|
||||
- "**/dist/**"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
59
.github/codeql/codeql-frontend-publish-security.yml
vendored
Normal file
59
.github/codeql/codeql-frontend-publish-security.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: clawhub-codeql-frontend-publish-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- src/components/DetailSecuritySummary.tsx
|
||||
- src/components/MarkdownPreview.tsx
|
||||
- src/components/PackageSourceChooser.tsx
|
||||
- src/components/SecurityScannerPage.tsx
|
||||
- src/components/SkillSecurityScanResults.tsx
|
||||
- src/lib/authErrorMessage.ts
|
||||
- src/lib/packageApi.ts
|
||||
- src/lib/packageUpload.ts
|
||||
- src/lib/pluginPublishPrefill.ts
|
||||
- src/lib/rehypeProxyImages.ts
|
||||
- src/lib/roles.ts
|
||||
- src/lib/uploadFiles.ts
|
||||
- src/lib/uploadUtils.ts
|
||||
- src/lib/useAuthError.ts
|
||||
- src/lib/useAuthStatus.ts
|
||||
- src/routes/admin.tsx
|
||||
- src/routes/cli/auth.tsx
|
||||
- src/routes/packages/new.tsx
|
||||
- src/routes/plugins/publish.tsx
|
||||
- src/routes/publish-plugin.tsx
|
||||
- src/routes/publish-skill.tsx
|
||||
- src/routes/skills/publish.tsx
|
||||
- src/routes/upload.tsx
|
||||
- src/routes/upload
|
||||
- src/routes/$owner/$slug/security
|
||||
- src/routes/plugins/$name/security
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/dist"
|
||||
- "**/dist/**"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
39
.github/codeql/codeql-repository-automation-security.yml
vendored
Normal file
39
.github/codeql/codeql-repository-automation-security.yml
vendored
Normal file
@ -0,0 +1,39 @@
|
||||
name: clawhub-codeql-repository-automation-security
|
||||
|
||||
disable-default-queries: true
|
||||
|
||||
queries:
|
||||
- uses: security-extended
|
||||
|
||||
query-filters:
|
||||
- include:
|
||||
precision:
|
||||
- high
|
||||
- very-high
|
||||
tags contain: security
|
||||
security-severity: /([7-9]|10)\.(\d)+/
|
||||
|
||||
paths:
|
||||
- scripts/check-staged-secrets.mjs
|
||||
- scripts/clawhub-cli-npm-release-check.mjs
|
||||
- scripts/github
|
||||
- scripts/verify-convex-contract.ts
|
||||
- scripts/copy-og-assets.ts
|
||||
- scripts/check-peer-deps.ts
|
||||
|
||||
paths-ignore:
|
||||
- "**/node_modules"
|
||||
- "**/coverage"
|
||||
- "**/dist"
|
||||
- "**/dist/**"
|
||||
- "**/*.generated.ts"
|
||||
- "**/*.bundle.js"
|
||||
- "**/*.test.ts"
|
||||
- "**/*.test.tsx"
|
||||
- "**/*.e2e.test.ts"
|
||||
- "**/*.e2e.test.tsx"
|
||||
- "**/*test-support*"
|
||||
- "**/*test-helper*"
|
||||
- "**/*mock*"
|
||||
- "**/*fixture*"
|
||||
- "**/*bench*"
|
||||
41
.github/dependabot.yml
vendored
Normal file
41
.github/dependabot.yml
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "bun"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "America/Los_Angeles"
|
||||
open-pull-requests-limit: 10
|
||||
ignore:
|
||||
- dependency-name: "@auth/core"
|
||||
update-types:
|
||||
- "version-update:semver-minor"
|
||||
- "version-update:semver-major"
|
||||
- dependency-name: "undici"
|
||||
update-types:
|
||||
- "version-update:semver-major"
|
||||
groups:
|
||||
production-minor-and-patch:
|
||||
dependency-type: "production"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
development-minor-and-patch:
|
||||
dependency-type: "development"
|
||||
update-types:
|
||||
- "minor"
|
||||
- "patch"
|
||||
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
day: "monday"
|
||||
time: "09:00"
|
||||
timezone: "America/Los_Angeles"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
32
.github/pull_request_template.md
vendored
Normal file
32
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,32 @@
|
||||
## Summary
|
||||
|
||||
- What changed:
|
||||
- Why:
|
||||
|
||||
## Linked Issue
|
||||
|
||||
- Closes #
|
||||
- Related #
|
||||
|
||||
## Screenshots
|
||||
|
||||
For website/UI changes, attach screenshots or recordings from the real app. Include mobile/narrow views when layout changes.
|
||||
|
||||
- [ ] Screenshots/recordings attached, or `N/A`
|
||||
|
||||
## Security / Trust Impact
|
||||
|
||||
- [ ] No security/trust impact
|
||||
- [ ] Security/trust impact explained
|
||||
|
||||
## Data / Deploy Impact
|
||||
|
||||
- [ ] No data/deploy impact
|
||||
- [ ] Data/deploy impact explained
|
||||
|
||||
## Verification
|
||||
|
||||
- [ ] `bun run format:check`
|
||||
- [ ] `bun run lint`
|
||||
- [ ] `bun run test`
|
||||
- [ ] Other:
|
||||
59
.github/workflows/auto-response.yml
vendored
Normal file
59
.github/workflows/auto-response.yml
vendored
Normal file
@ -0,0 +1,59 @@
|
||||
name: Auto response
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
issue_comment:
|
||||
types: [created]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] trusted base checkout only; no untrusted PR code execution
|
||||
types: [opened, edited, synchronize, reopened, labeled]
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.event.issue.number || github.ref || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request_target' }}
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
auto-response:
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
|
||||
- name: Run Barnacle auto-response
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
|
||||
script: |
|
||||
const { pathToFileURL } = require("node:url");
|
||||
const moduleUrl = pathToFileURL(
|
||||
`${process.env.GITHUB_WORKSPACE}/scripts/github/barnacle-auto-response.mjs`,
|
||||
);
|
||||
const { runBarnacleAutoResponse } = await import(moduleUrl.href);
|
||||
|
||||
await runBarnacleAutoResponse({ github, context, core });
|
||||
80
.github/workflows/ci-check-testbox.yml
vendored
Normal file
80
.github/workflows/ci-check-testbox.yml
vendored
Normal file
@ -0,0 +1,80 @@
|
||||
name: Blacksmith Testbox
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.10"
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: "check"
|
||||
runs-on: blacksmith-8vcpu-ubuntu-2404
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 50
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Restore Bun install cache
|
||||
id: bun-cache
|
||||
uses: actions/cache/restore@v5
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ runner.os }}-bun-${{ env.BUN_VERSION }}-${{ hashFiles('bun.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-bun-${{ env.BUN_VERSION }}-
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Save Bun install cache
|
||||
if: steps.bun-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/cache/save@v5
|
||||
continue-on-error: true
|
||||
with:
|
||||
path: ~/.bun/install/cache
|
||||
key: ${{ steps.bun-cache.outputs.cache-primary-key }}
|
||||
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git fetch --no-tags --depth=50 origin "+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
bun_bin="$(command -v bun)"
|
||||
sudo ln -sf "$bun_bin" /usr/local/bin/bun
|
||||
|
||||
if command -v bunx >/dev/null 2>&1; then
|
||||
sudo ln -sf "$(command -v bunx)" /usr/local/bin/bunx
|
||||
fi
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: always()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
109
.github/workflows/ci.yml
vendored
109
.github/workflows/ci.yml
vendored
@ -4,37 +4,106 @@ on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
VITE_CONVEX_URL: https://example.invalid
|
||||
|
||||
jobs:
|
||||
build:
|
||||
static:
|
||||
name: static
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- uses: oven-sh/setup-bun@v2
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
bun-version: 1.3.6
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
- name: Peer deps
|
||||
run: bun run check:peers
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Lint
|
||||
run: bun run lint
|
||||
- name: Static checks
|
||||
run: bun run ci:static
|
||||
|
||||
- name: Test
|
||||
run: bun run test
|
||||
unit:
|
||||
name: unit
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Coverage
|
||||
run: bun run coverage
|
||||
run: bun run ci:unit
|
||||
|
||||
- name: Typecheck packages
|
||||
run: |
|
||||
bunx tsc -p packages/schema/tsconfig.json --noEmit
|
||||
bunx tsc -p packages/clawdhub/tsconfig.json --noEmit
|
||||
packages:
|
||||
name: packages
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
- name: Build
|
||||
run: bun run build
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Package checks
|
||||
run: bun run ci:packages
|
||||
|
||||
types-build:
|
||||
name: types-build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Typecheck and build
|
||||
run: bun run ci:types-build
|
||||
|
||||
e2e-http:
|
||||
name: e2e-http
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: HTTP e2e
|
||||
run: bun run ci:e2e-http
|
||||
|
||||
playwright-smoke:
|
||||
name: playwright-smoke
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
|
||||
- name: Install Playwright browsers
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: Browser e2e
|
||||
run: bun run ci:playwright-smoke
|
||||
|
||||
- name: Upload Playwright report
|
||||
if: ${{ !cancelled() }}
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: playwright-report
|
||||
path: playwright-report/
|
||||
if-no-files-found: ignore
|
||||
|
||||
317
.github/workflows/clawhub-cli-npm-release.yml
vendored
Normal file
317
.github/workflows/clawhub-cli-npm-release.yml
vendored
Normal file
@ -0,0 +1,317 @@
|
||||
name: ClawHub CLI NPM Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Release tag to publish, for example v0.10.0
|
||||
required: true
|
||||
type: string
|
||||
preflight_only:
|
||||
description: Run validation/build only and skip the gated publish job
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
preflight_run_id:
|
||||
description: Existing successful preflight workflow run id to promote without rebuilding
|
||||
required: false
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: clawhub-cli-npm-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
BUN_VERSION: "1.3.10"
|
||||
|
||||
jobs:
|
||||
preflight_clawhub_cli_npm:
|
||||
if: ${{ inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Forbid preflight artifact promotion on validation-only runs
|
||||
if: ${{ inputs.preflight_run_id != '' }}
|
||||
run: |
|
||||
echo "preflight_run_id is only valid for real publish runs."
|
||||
exit 1
|
||||
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Resolve CLI package directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -d "packages/clawhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawhub" >> "$GITHUB_ENV"
|
||||
elif [[ -d "packages/clawdhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawdhub" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "Unable to find clawhub CLI package directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Ensure version is not already published
|
||||
env:
|
||||
PREFLIGHT_ONLY: ${{ inputs.preflight_only }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
|
||||
if npm view "clawhub@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
if [[ "${PREFLIGHT_ONLY}" == "true" ]]; then
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm; continuing because preflight_only=true."
|
||||
exit 0
|
||||
fi
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing clawhub@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
export RELEASE_SHA
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
node scripts/clawhub-cli-npm-release-check.mjs
|
||||
|
||||
- name: Verify CLI package
|
||||
run: bun run --cwd "$PACKAGE_DIR" verify
|
||||
|
||||
- name: Pack prepared npm tarball
|
||||
id: packed_tarball
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
pushd "$PACKAGE_DIR" >/dev/null
|
||||
PACK_JSON="$(npm pack --json --ignore-scripts)"
|
||||
echo "$PACK_JSON"
|
||||
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) process.exit(1); process.stdout.write(first.filename); });')"
|
||||
popd >/dev/null
|
||||
if [[ -z "${PACK_PATH}" || ! -f "${PACKAGE_DIR}/${PACK_PATH}" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
fi
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/clawhub-cli-npm-preflight"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
cp "${PACKAGE_DIR}/${PACK_PATH}" "$ARTIFACT_DIR/"
|
||||
printf '%s\n' "$RELEASE_TAG" > "$ARTIFACT_DIR/release-tag.txt"
|
||||
printf '%s\n' "$RELEASE_SHA" > "$ARTIFACT_DIR/release-sha.txt"
|
||||
printf '%s\n' "$PACKAGE_VERSION" > "$ARTIFACT_DIR/package-version.txt"
|
||||
echo "dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Upload prepared npm publish bundle
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clawhub-cli-npm-preflight-${{ inputs.tag }}
|
||||
path: ${{ steps.packed_tarball.outputs.dir }}
|
||||
if-no-files-found: error
|
||||
|
||||
validate_publish_request:
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Require main workflow ref for publish
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Real publish runs must be dispatched from main. Use preflight_only=true for branch validation."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Require preflight artifact promotion on real publish
|
||||
env:
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${PREFLIGHT_RUN_ID}" ]]; then
|
||||
echo "Real publish requires preflight_run_id from a successful npm preflight run." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
publish_clawhub_cli_npm:
|
||||
needs: [validate_publish_request]
|
||||
if: ${{ !inputs.preflight_only }}
|
||||
runs-on: ubuntu-latest
|
||||
environment: npm-release
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
id-token: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
|
||||
- name: Resolve CLI package directory
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -d "packages/clawhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawhub" >> "$GITHUB_ENV"
|
||||
elif [[ -d "packages/clawdhub" ]]; then
|
||||
echo "PACKAGE_DIR=packages/clawdhub" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "Unable to find clawhub CLI package directory." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Ensure version is not already published
|
||||
run: |
|
||||
set -euo pipefail
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
|
||||
if npm view "clawhub@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
|
||||
echo "clawhub@${PACKAGE_VERSION} is already published on npm."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Publishing clawhub@${PACKAGE_VERSION}"
|
||||
|
||||
- name: Verify preflight run metadata
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
PREFLIGHT_RUN_ID: ${{ inputs.preflight_run_id }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RUN_JSON="$(gh run view "$PREFLIGHT_RUN_ID" --repo "$GITHUB_REPOSITORY" --json workflowName,headBranch,event,conclusion,url)"
|
||||
# shellcheck disable=SC2016
|
||||
printf '%s' "$RUN_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const run = JSON.parse(Buffer.concat(chunks).toString("utf8")); const checks = [["workflowName", "ClawHub CLI NPM Release"], ["headBranch", "main"], ["event", "workflow_dispatch"], ["conclusion", "success"]]; for (const [key, expected] of checks) { if (run[key] !== expected) { console.error(`Referenced npm preflight run ${process.env.PREFLIGHT_RUN_ID} must have ${key}=${expected}, got ${run[key] ?? "<missing>"}.`); process.exit(1); } } console.log(`Using npm preflight run ${process.env.PREFLIGHT_RUN_ID}: ${run.url}`); });'
|
||||
|
||||
- name: Download prepared npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: clawhub-cli-npm-preflight-${{ inputs.tag }}
|
||||
path: preflight-tarball
|
||||
repository: ${{ github.repository }}
|
||||
run-id: ${{ inputs.preflight_run_id }}
|
||||
github-token: ${{ github.token }}
|
||||
|
||||
- name: Validate release tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_MAIN_REF: origin/main
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
export RELEASE_SHA
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
node scripts/clawhub-cli-npm-release-check.mjs
|
||||
|
||||
- name: Verify prepared tarball provenance
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
EXPECTED_RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
EXPECTED_PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`./${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
TAG_FILE="preflight-tarball/release-tag.txt"
|
||||
SHA_FILE="preflight-tarball/release-sha.txt"
|
||||
VERSION_FILE="preflight-tarball/package-version.txt"
|
||||
if [[ ! -f "$TAG_FILE" || ! -f "$SHA_FILE" || ! -f "$VERSION_FILE" ]]; then
|
||||
echo "Prepared preflight metadata is missing." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
ARTIFACT_RELEASE_TAG="$(tr -d '\r\n' < "$TAG_FILE")"
|
||||
ARTIFACT_RELEASE_SHA="$(tr -d '\r\n' < "$SHA_FILE")"
|
||||
ARTIFACT_PACKAGE_VERSION="$(tr -d '\r\n' < "$VERSION_FILE")"
|
||||
if [[ "$ARTIFACT_RELEASE_TAG" != "$RELEASE_TAG" ]]; then
|
||||
echo "Prepared preflight tag mismatch: expected $RELEASE_TAG, got $ARTIFACT_RELEASE_TAG" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_RELEASE_SHA" != "$EXPECTED_RELEASE_SHA" ]]; then
|
||||
echo "Prepared preflight SHA mismatch: expected $EXPECTED_RELEASE_SHA, got $ARTIFACT_RELEASE_SHA" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$ARTIFACT_PACKAGE_VERSION" != "$EXPECTED_PACKAGE_VERSION" ]]; then
|
||||
echo "Prepared preflight package version mismatch: expected $EXPECTED_PACKAGE_VERSION, got $ARTIFACT_PACKAGE_VERSION" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve publish tarball
|
||||
id: publish_tarball
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL_PATH="$(find preflight-tarball -type f -name '*.tgz' -print | sort | tail -n 1)"
|
||||
if [[ -z "$TARBALL_PATH" ]]; then
|
||||
echo "Prepared preflight tarball not found." >&2
|
||||
ls -la preflight-tarball >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
echo "path=$TARBALL_PATH" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
publish_target="${{ steps.publish_tarball.outputs.path }}"
|
||||
if [[ -n "${publish_target}" ]]; then
|
||||
publish_target="./${publish_target}"
|
||||
fi
|
||||
bash scripts/clawhub-cli-npm-publish.sh --publish "${publish_target}"
|
||||
168
.github/workflows/clawhub-mod-release.yml
vendored
Normal file
168
.github/workflows/clawhub-mod-release.yml
vendored
Normal file
@ -0,0 +1,168 @@
|
||||
name: ClawHub Moderator CLI Release
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
tag:
|
||||
description: Moderator release tag, for example clawhub-mod-v0.1.0
|
||||
required: true
|
||||
type: string
|
||||
|
||||
concurrency:
|
||||
group: clawhub-mod-release-${{ inputs.tag }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
NODE_VERSION: "24.x"
|
||||
BUN_VERSION: "1.3.10"
|
||||
PACKAGE_DIR: packages/clawhub-mod
|
||||
|
||||
jobs:
|
||||
release_clawhub_mod_cli:
|
||||
runs-on: ubuntu-latest
|
||||
environment: moderator-release
|
||||
permissions:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Require main workflow ref
|
||||
env:
|
||||
WORKFLOW_REF: ${{ github.ref }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${WORKFLOW_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Moderator release runs must be dispatched from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Validate tag and package metadata
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
if ! git merge-base --is-ancestor "$RELEASE_SHA" origin/main; then
|
||||
echo "Moderator release tag must point at a commit reachable from main." >&2
|
||||
exit 1
|
||||
fi
|
||||
node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const tag = process.env.RELEASE_TAG;
|
||||
const pkg = JSON.parse(readFileSync(`${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
const version = String(pkg.version ?? "").trim();
|
||||
const expectedTag = `clawhub-mod-v${version}`;
|
||||
if (!/^clawhub-mod-v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(tag)) {
|
||||
throw new Error(`Moderator release tag must look like clawhub-mod-vX.Y.Z, got ${tag}`);
|
||||
}
|
||||
if (tag !== expectedTag) {
|
||||
throw new Error(`Moderator release tag ${tag} must match ${expectedTag} from package.json`);
|
||||
}
|
||||
if (pkg.private !== true) {
|
||||
throw new Error("@openclaw/clawhub-mod must remain private: true");
|
||||
}
|
||||
if (pkg.name !== "@openclaw/clawhub-mod") {
|
||||
throw new Error(`Unexpected package name ${pkg.name}`);
|
||||
}
|
||||
console.log(`Preparing ${pkg.name}@${version} from ${tag}`);
|
||||
EOF
|
||||
|
||||
- name: Verify moderator CLI package
|
||||
run: bun run --cwd "$PACKAGE_DIR" verify
|
||||
|
||||
- name: Pack moderator CLI tarball
|
||||
id: pack
|
||||
env:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
ARTIFACT_DIR="$RUNNER_TEMP/clawhub-mod-release"
|
||||
rm -rf "$ARTIFACT_DIR"
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
|
||||
pushd "$PACKAGE_DIR" >/dev/null
|
||||
PACK_JSON="$(npm pack --json --ignore-scripts)"
|
||||
echo "$PACK_JSON"
|
||||
PACK_PATH="$(printf '%s\n' "$PACK_JSON" | node --input-type=module -e 'const chunks=[]; process.stdin.on("data", (chunk) => chunks.push(chunk)); process.stdin.on("end", () => { const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8")); const first = Array.isArray(parsed) ? parsed[0] : null; if (!first || typeof first.filename !== "string" || !first.filename) process.exit(1); process.stdout.write(first.filename); });')"
|
||||
popd >/dev/null
|
||||
|
||||
if [[ -z "${PACK_PATH}" || ! -f "${PACKAGE_DIR}/${PACK_PATH}" ]]; then
|
||||
echo "npm pack did not produce a tarball file." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cp "${PACKAGE_DIR}/${PACK_PATH}" "$ARTIFACT_DIR/"
|
||||
cp scripts/install-clawhub-mod.sh "$ARTIFACT_DIR/"
|
||||
(cd "$ARTIFACT_DIR" && shasum -a 256 ./* > SHA256SUMS.txt)
|
||||
|
||||
PACKAGE_VERSION="$(node --input-type=module <<'EOF'
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const pkg = JSON.parse(readFileSync(`${process.env.PACKAGE_DIR}/package.json`, "utf8"));
|
||||
process.stdout.write(String(pkg.version ?? "").trim());
|
||||
EOF
|
||||
)"
|
||||
RELEASE_SHA="$(git rev-parse HEAD)"
|
||||
NOTES_FILE="$ARTIFACT_DIR/release-notes.md"
|
||||
cat > "$NOTES_FILE" <<EOF
|
||||
Moderator-only ClawHub operator CLI release.
|
||||
|
||||
- Package: @openclaw/clawhub-mod@${PACKAGE_VERSION}
|
||||
- Commit: ${RELEASE_SHA}
|
||||
- Install/upgrade:
|
||||
|
||||
\`\`\`bash
|
||||
bash scripts/install-clawhub-mod.sh --tag ${RELEASE_TAG}
|
||||
\`\`\`
|
||||
|
||||
This release is intentionally distributed as a GitHub Release asset, not npm.
|
||||
EOF
|
||||
|
||||
echo "artifact_dir=$ARTIFACT_DIR" >> "$GITHUB_OUTPUT"
|
||||
echo "notes_file=$NOTES_FILE" >> "$GITHUB_OUTPUT"
|
||||
echo "package_version=$PACKAGE_VERSION" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Create or update draft GitHub release
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
RELEASE_TITLE: ClawHub Moderator CLI ${{ steps.pack.outputs.package_version }}
|
||||
ARTIFACT_DIR: ${{ steps.pack.outputs.artifact_dir }}
|
||||
NOTES_FILE: ${{ steps.pack.outputs.notes_file }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
IS_DRAFT="$(gh release view "$RELEASE_TAG" --repo "$GITHUB_REPOSITORY" --json isDraft --jq .isDraft)"
|
||||
if [[ "$IS_DRAFT" != "true" ]]; then
|
||||
echo "Refusing to mutate non-draft moderator release ${RELEASE_TAG}." >&2
|
||||
exit 1
|
||||
fi
|
||||
gh release upload "$RELEASE_TAG" "$ARTIFACT_DIR"/* --repo "$GITHUB_REPOSITORY" --clobber
|
||||
else
|
||||
gh release create "$RELEASE_TAG" "$ARTIFACT_DIR"/* \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--draft \
|
||||
--verify-tag \
|
||||
--title "$RELEASE_TITLE" \
|
||||
--notes-file "$NOTES_FILE"
|
||||
fi
|
||||
57
.github/workflows/clawhub-rescan-guidance.yml
vendored
Normal file
57
.github/workflows/clawhub-rescan-guidance.yml
vendored
Normal file
@ -0,0 +1,57 @@
|
||||
name: ClawHub Rescan Guidance
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
issue:
|
||||
description: "Issue number to check"
|
||||
required: true
|
||||
type: string
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: clawhub-rescan-guidance-${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
rescan-guidance:
|
||||
runs-on: ubuntu-latest
|
||||
if: "${{ github.event_name == 'workflow_dispatch' || github.event.label.name == 'r: rescan-guidance' }}"
|
||||
env:
|
||||
CLAWHUB_RESCAN_GUIDANCE_APPLY: "1"
|
||||
ISSUE_NUMBER: ${{ github.event.issue.number || github.event.inputs.issue }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
persist-credentials: false
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
|
||||
- name: Comment when rescan guidance label is present
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
|
||||
run: |
|
||||
node scripts/github/clawhub-rescan-auto-response.mjs \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--issue "$ISSUE_NUMBER" \
|
||||
--comment-for-labeled-issue \
|
||||
--apply
|
||||
62
.github/workflows/clawsweeper-dispatch.yml
vendored
Normal file
62
.github/workflows/clawsweeper-dispatch.yml
vendored
Normal file
@ -0,0 +1,62 @@
|
||||
name: ClawSweeper Dispatch
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, reopened, edited, labeled, unlabeled]
|
||||
pull_request_target: # zizmor: ignore[dangerous-triggers] maintainer-owned external dispatch; no checkout or untrusted PR code execution
|
||||
types: [opened, reopened, synchronize, ready_for_review, edited, labeled, unlabeled]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: clawsweeper-dispatch-${{ github.repository }}-${{ github.event.issue.number || github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review' }}
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
if: ${{ !(endsWith(github.actor, '[bot]') && (github.event.action == 'labeled' || github.event.action == 'unlabeled')) }}
|
||||
env:
|
||||
HAS_CLAWSWEEPER_APP_PRIVATE_KEY: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY != '' }}
|
||||
CLAWSWEEPER_APP_CLIENT_ID: Iv23liOECG0slfuhz093
|
||||
SUPERSEDES_IN_PROGRESS: ${{ (github.event.action == 'edited' || github.event.action == 'synchronize' || github.event.action == 'ready_for_review') && 'true' || 'false' }}
|
||||
steps:
|
||||
- name: Debounce bursty metadata events
|
||||
if: ${{ github.event.action == 'labeled' || github.event.action == 'unlabeled' }}
|
||||
run: sleep 20
|
||||
|
||||
- name: Create ClawSweeper dispatch token
|
||||
id: token
|
||||
if: ${{ env.HAS_CLAWSWEEPER_APP_PRIVATE_KEY == 'true' }}
|
||||
uses: actions/create-github-app-token@1b10c78c7865c340bc4f6099eb2f838309f1e8c3 # v3.1.1
|
||||
with:
|
||||
client-id: ${{ env.CLAWSWEEPER_APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.CLAWSWEEPER_APP_PRIVATE_KEY }}
|
||||
owner: openclaw
|
||||
repositories: clawsweeper
|
||||
|
||||
- name: Dispatch exact ClawSweeper review
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.token.outputs.token || secrets.OPENCLAW_GH_TOKEN }}
|
||||
TARGET_REPO: ${{ github.repository }}
|
||||
ITEM_NUMBER: ${{ github.event.issue.number || github.event.pull_request.number }}
|
||||
ITEM_KIND: ${{ github.event_name == 'pull_request_target' && 'pull_request' || 'issue' }}
|
||||
SOURCE_EVENT: ${{ github.event_name }}
|
||||
SOURCE_ACTION: ${{ github.event.action }}
|
||||
run: |
|
||||
if [ -z "$GH_TOKEN" ]; then
|
||||
echo "::notice::Skipping ClawSweeper dispatch because no dispatch credential is configured."
|
||||
exit 0
|
||||
fi
|
||||
payload="$(jq -nc \
|
||||
--arg target_repo "$TARGET_REPO" \
|
||||
--argjson item_number "$ITEM_NUMBER" \
|
||||
--arg item_kind "$ITEM_KIND" \
|
||||
--arg source_event "$SOURCE_EVENT" \
|
||||
--arg source_action "$SOURCE_ACTION" \
|
||||
--argjson supersedes_in_progress "$SUPERSEDES_IN_PROGRESS" \
|
||||
'{event_type:"clawsweeper_item",client_payload:{target_repo:$target_repo,item_number:$item_number,item_kind:$item_kind,source_event:$source_event,source_action:$source_action,supersedes_in_progress:$supersedes_in_progress}}')"
|
||||
gh api repos/openclaw/clawsweeper/dispatches \
|
||||
--method POST \
|
||||
--input - <<< "$payload"
|
||||
100
.github/workflows/codeql-light.yml
vendored
Normal file
100
.github/workflows/codeql-light.yml
vendored
Normal file
@ -0,0 +1,100 @@
|
||||
name: CodeQL Light
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
profile:
|
||||
description: CodeQL light profile to run
|
||||
required: false
|
||||
default: all
|
||||
type: choice
|
||||
options:
|
||||
- all
|
||||
- backend-api
|
||||
- frontend-publish
|
||||
- cli-package
|
||||
- repository-automation
|
||||
- actions
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "convex/**"
|
||||
- "packages/clawhub/**"
|
||||
- "packages/schema/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "convex/**"
|
||||
- "packages/clawhub/**"
|
||||
- "packages/schema/**"
|
||||
- "scripts/**"
|
||||
- "src/**"
|
||||
- "bun.lock"
|
||||
- "package.json"
|
||||
schedule:
|
||||
- cron: "17 7 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-light-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
security-events: write
|
||||
|
||||
jobs:
|
||||
analyze:
|
||||
name: Analyze (${{ matrix.category }})
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
category: backend-api
|
||||
config_file: ./.github/codeql/codeql-backend-api-security.yml
|
||||
- language: javascript-typescript
|
||||
category: frontend-publish
|
||||
config_file: ./.github/codeql/codeql-frontend-publish-security.yml
|
||||
- language: javascript-typescript
|
||||
category: cli-package
|
||||
config_file: ./.github/codeql/codeql-cli-package-security.yml
|
||||
- language: javascript-typescript
|
||||
category: repository-automation
|
||||
config_file: ./.github/codeql/codeql-repository-automation-security.yml
|
||||
- language: actions
|
||||
category: actions
|
||||
config_file: ./.github/codeql/codeql-actions-security.yml
|
||||
steps:
|
||||
- name: Checkout
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||
with:
|
||||
submodules: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
config-file: ${{ matrix.config_file }}
|
||||
|
||||
- name: Analyze
|
||||
if: ${{ github.event_name != 'workflow_dispatch' || inputs.profile == 'all' || inputs.profile == matrix.category }}
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4
|
||||
with:
|
||||
category: "/codeql-light/${{ matrix.category }}"
|
||||
187
.github/workflows/deploy-staging.yml
vendored
Normal file
187
.github/workflows/deploy-staging.yml
vendored
Normal file
@ -0,0 +1,187 @@
|
||||
name: Deploy Staging
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reset_seed:
|
||||
description: "Reset seeded staging fixtures before smoke tests"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
allow_deleting_large_indexes:
|
||||
description: "Allow Convex to delete large indexes"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: deploy-staging
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
deploy-staging:
|
||||
name: deploy-staging
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
environment:
|
||||
name: Staging
|
||||
url: https://staging.hub.openclaw.ai
|
||||
env:
|
||||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||||
PLAYWRIGHT_BASE_URL: https://staging.hub.openclaw.ai
|
||||
RESET_STAGING_SEED: ${{ github.event_name == 'workflow_dispatch' && inputs.reset_seed || false }}
|
||||
STAGING_CONVEX_SITE_URL: ${{ vars.STAGING_CONVEX_SITE_URL }}
|
||||
STAGING_CONVEX_URL: ${{ vars.STAGING_CONVEX_URL }}
|
||||
STAGING_SITE_URL: https://staging.hub.openclaw.ai
|
||||
VERCEL_CLI_VERSION: 52.0.0
|
||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
||||
VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }}
|
||||
ALLOW_DELETING_LARGE_INDEXES: ${{ github.event_name == 'workflow_dispatch' && inputs.allow_deleting_large_indexes || false }}
|
||||
|
||||
steps:
|
||||
- name: Check staging deploy configuration
|
||||
id: config
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
|
||||
for name in \
|
||||
CONVEX_DEPLOY_KEY \
|
||||
STAGING_CONVEX_SITE_URL \
|
||||
STAGING_CONVEX_URL \
|
||||
VERCEL_ORG_ID \
|
||||
VERCEL_PROJECT_ID \
|
||||
VERCEL_TOKEN
|
||||
do
|
||||
if [[ -z "${!name}" ]]; then
|
||||
missing+=("$name")
|
||||
fi
|
||||
done
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "configured=false" >> "$GITHUB_OUTPUT"
|
||||
printf '::notice::Skipping staging deploy; missing Staging environment values: %s\n' "${missing[*]}"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "configured=true" >> "$GITHUB_OUTPUT"
|
||||
echo "Deploying staging site: $STAGING_SITE_URL"
|
||||
echo "Using staging Convex site URL: $STAGING_CONVEX_SITE_URL"
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
|
||||
- uses: ./.github/actions/setup-bun
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
|
||||
- name: Stamp Convex staging metadata
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
bunx convex env set APP_BUILD_SHA "$GITHUB_SHA"
|
||||
bunx convex env set APP_DEPLOYED_AT "$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
bunx convex env set SITE_URL "$STAGING_SITE_URL"
|
||||
bunx convex env set VITE_SITE_URL "$STAGING_SITE_URL"
|
||||
bunx convex env set CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
|
||||
|
||||
- name: Deploy Convex staging backend
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$ALLOW_DELETING_LARGE_INDEXES" == "true" ]]; then
|
||||
bunx convex deploy --typecheck=disable --yes --allow-deleting-large-indexes
|
||||
else
|
||||
bun run convex:deploy
|
||||
fi
|
||||
|
||||
- name: Verify Convex staging contract
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: bun run verify:convex-contract
|
||||
|
||||
- name: Seed staging fixtures
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "$RESET_STAGING_SEED" == "true" ]]; then
|
||||
bunx convex run --no-push devSeed:seedNixSkills '{"reset":true}'
|
||||
else
|
||||
bunx convex run --no-push devSeed:seedNixSkills
|
||||
fi
|
||||
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
|
||||
|
||||
- name: Prepare staging deploy config
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
bun run deploy:prepare-config -- \
|
||||
--target staging \
|
||||
--site-url "$STAGING_SITE_URL" \
|
||||
--convex-site-url "$STAGING_CONVEX_SITE_URL"
|
||||
|
||||
- name: Pull Vercel staging project settings
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
bunx "vercel@$VERCEL_CLI_VERSION" pull \
|
||||
--yes \
|
||||
--environment=staging \
|
||||
--token "$VERCEL_TOKEN"
|
||||
|
||||
- name: Sync Vercel staging public env
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
set_vercel_env() {
|
||||
local name="$1"
|
||||
local value="$2"
|
||||
bunx "vercel@$VERCEL_CLI_VERSION" env add "$name" staging \
|
||||
--force \
|
||||
--yes \
|
||||
--value "$value" \
|
||||
--token "$VERCEL_TOKEN"
|
||||
}
|
||||
|
||||
set_vercel_env VITE_CONVEX_URL "$STAGING_CONVEX_URL"
|
||||
set_vercel_env VITE_CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
|
||||
set_vercel_env CONVEX_SITE_URL "$STAGING_CONVEX_SITE_URL"
|
||||
set_vercel_env SITE_URL "$STAGING_SITE_URL"
|
||||
set_vercel_env VITE_SITE_URL "$STAGING_SITE_URL"
|
||||
set_vercel_env VITE_APP_BUILD_SHA "$GITHUB_SHA"
|
||||
|
||||
- name: Deploy Vercel staging frontend
|
||||
id: vercel
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deployment_url="$(bunx "vercel@$VERCEL_CLI_VERSION" deploy \
|
||||
--target=staging \
|
||||
--yes \
|
||||
--token "$VERCEL_TOKEN")"
|
||||
echo "deployment_url=$deployment_url" >> "$GITHUB_OUTPUT"
|
||||
echo "Vercel staging deployment: $deployment_url"
|
||||
|
||||
- name: Smoke test staging HTTP
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
env:
|
||||
CLAWHUB_E2E_SITE: https://staging.hub.openclaw.ai
|
||||
CLAWHUB_E2E_SKILL_OWNER: local
|
||||
CLAWHUB_E2E_SKILL_SLUG: padel
|
||||
run: bun run test:e2e:prod-http
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: bunx playwright install --with-deps chromium
|
||||
|
||||
- name: Smoke test staging UI
|
||||
if: steps.config.outputs.configured == 'true'
|
||||
run: bunx playwright test --workers=1 --project=chromium e2e/menu-smoke.pw.test.ts
|
||||
|
||||
- name: Report unconfigured staging
|
||||
if: steps.config.outputs.configured != 'true'
|
||||
run: |
|
||||
echo "Staging deploy is wired in the repo but not configured yet."
|
||||
echo "Add the Staging environment secrets and variables documented in docs/deploy.md."
|
||||
250
.github/workflows/deploy.yml
vendored
Normal file
250
.github/workflows/deploy.yml
vendored
Normal file
@ -0,0 +1,250 @@
|
||||
name: Deploy
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
target:
|
||||
description: "What to deploy"
|
||||
required: true
|
||||
default: full
|
||||
type: choice
|
||||
options:
|
||||
- full
|
||||
- backend
|
||||
- frontend
|
||||
allow_deleting_large_indexes:
|
||||
description: "Allow Convex to delete large indexes"
|
||||
required: true
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
concurrency:
|
||||
group: deploy-production
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
statuses: read
|
||||
|
||||
jobs:
|
||||
validate-deploy-request:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
deploy_backend: ${{ steps.mode.outputs.deploy_backend }}
|
||||
deploy_frontend: ${{ steps.mode.outputs.deploy_frontend }}
|
||||
run_smoke: ${{ steps.mode.outputs.run_smoke }}
|
||||
target: ${{ steps.mode.outputs.target }}
|
||||
steps:
|
||||
- name: Require main ref for production deploy
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${GITHUB_REF}" != "refs/heads/main" ]]; then
|
||||
echo "Production deploys must run from main."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Resolve deploy mode
|
||||
id: mode
|
||||
run: |
|
||||
set -euo pipefail
|
||||
target="${{ inputs.target }}"
|
||||
case "$target" in
|
||||
full)
|
||||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
backend)
|
||||
echo "deploy_backend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=false" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
frontend)
|
||||
echo "deploy_backend=false" >> "$GITHUB_OUTPUT"
|
||||
echo "deploy_frontend=true" >> "$GITHUB_OUTPUT"
|
||||
echo "run_smoke=true" >> "$GITHUB_OUTPUT"
|
||||
;;
|
||||
*)
|
||||
echo "Unsupported deploy target: $target" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
echo "target=$target" >> "$GITHUB_OUTPUT"
|
||||
|
||||
deploy-production:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
needs: validate-deploy-request
|
||||
environment:
|
||||
name: Production
|
||||
url: https://clawhub.ai
|
||||
env:
|
||||
CONVEX_DEPLOY_KEY: ${{ secrets.CONVEX_DEPLOY_KEY }}
|
||||
PLAYWRIGHT_AUTH_STORAGE_STATE_JSON: ${{ secrets.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON }}
|
||||
PLAYWRIGHT_BASE_URL: https://clawhub.ai
|
||||
steps:
|
||||
- name: Check deploy configuration
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=()
|
||||
|
||||
if [[ "${{ needs.validate-deploy-request.outputs.deploy_backend }}" == "true" && -z "$CONVEX_DEPLOY_KEY" ]]; then
|
||||
missing+=("CONVEX_DEPLOY_KEY")
|
||||
fi
|
||||
|
||||
if (( ${#missing[@]} > 0 )); then
|
||||
echo "::error::Missing required production environment secrets: ${missing[*]}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Deploy target: ${{ needs.validate-deploy-request.outputs.target }}"
|
||||
echo "Allow deleting large Convex indexes: ${{ inputs.allow_deleting_large_indexes }}"
|
||||
|
||||
if [[ -z "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" ]]; then
|
||||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE_JSON not set; authenticated smoke will be skipped."
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Install
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Stamp Convex build SHA
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bunx convex env set APP_BUILD_SHA "${GITHUB_SHA}" --prod
|
||||
|
||||
- name: Stamp Convex deploy time
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bunx convex env set APP_DEPLOYED_AT "$(date -u +"%Y-%m-%dT%H:%M:%SZ")" --prod
|
||||
|
||||
- name: Deploy Convex
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ "${{ inputs.allow_deleting_large_indexes }}" == "true" ]]; then
|
||||
bunx convex deploy --typecheck=disable --yes --allow-deleting-large-indexes
|
||||
else
|
||||
bun run convex:deploy
|
||||
fi
|
||||
|
||||
- name: Verify Convex contract
|
||||
if: needs.validate-deploy-request.outputs.deploy_backend == 'true'
|
||||
run: bun run verify:convex-contract -- --prod
|
||||
|
||||
- name: Wait for Vercel production deployment
|
||||
id: vercel
|
||||
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
VERCEL_STATUS_CONTEXT: Vercel – clawhub
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for attempt in {1..90}; do
|
||||
if ! status_json="$(gh api "repos/$GITHUB_REPOSITORY/commits/$GITHUB_SHA/status" \
|
||||
--jq '.statuses[] | select(.context == env.VERCEL_STATUS_CONTEXT) | {state, target_url, description} | @base64' \
|
||||
2>/dev/null | head -n1)"; then
|
||||
echo "GitHub status check failed for $GITHUB_SHA on attempt $attempt; retrying..."
|
||||
sleep 10
|
||||
continue
|
||||
fi
|
||||
|
||||
if [[ -z "$status_json" ]]; then
|
||||
state=""
|
||||
target_url=""
|
||||
else
|
||||
state="$(printf '%s' "$status_json" | base64 -d | jq -r '.state // ""')"
|
||||
target_url="$(printf '%s' "$status_json" | base64 -d | jq -r '.target_url // ""')"
|
||||
fi
|
||||
|
||||
case "$state" in
|
||||
success)
|
||||
echo "Vercel production deployment ready for $GITHUB_SHA"
|
||||
echo "deployment_url=$target_url" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
;;
|
||||
failure|error)
|
||||
echo "::error::Vercel production deployment failed for $GITHUB_SHA"
|
||||
exit 1
|
||||
;;
|
||||
pending)
|
||||
echo "Vercel deployment pending for $GITHUB_SHA on attempt $attempt; waiting..."
|
||||
;;
|
||||
*)
|
||||
echo "Vercel status for $GITHUB_SHA not published yet on attempt $attempt; waiting..."
|
||||
;;
|
||||
esac
|
||||
|
||||
sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Timed out waiting for Vercel production deployment for $GITHUB_SHA"
|
||||
exit 1
|
||||
|
||||
- name: Install Playwright browser
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||||
run: bunx playwright install --with-deps chromium webkit
|
||||
|
||||
- name: Smoke test production HTTP
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true'
|
||||
run: bun run test:e2e:prod-http
|
||||
|
||||
- name: Write authenticated storage state
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true' && env.PLAYWRIGHT_AUTH_STORAGE_STATE_JSON != ''
|
||||
run: |
|
||||
echo "$PLAYWRIGHT_AUTH_STORAGE_STATE_JSON" > "$RUNNER_TEMP/playwright-auth.json"
|
||||
echo "PLAYWRIGHT_AUTH_STORAGE_STATE=$RUNNER_TEMP/playwright-auth.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Smoke test production UI
|
||||
if: needs.validate-deploy-request.outputs.run_smoke == 'true' && needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||||
run: bunx playwright test --workers=1 e2e/menu-smoke.pw.test.ts e2e/publish-entry-workflows.pw.test.ts e2e/upload-auth-smoke.pw.test.ts
|
||||
|
||||
- name: Tag production frontend deployment
|
||||
if: needs.validate-deploy-request.outputs.deploy_frontend == 'true'
|
||||
env:
|
||||
DEPLOY_TARGET: ${{ needs.validate-deploy-request.outputs.target }}
|
||||
DEPLOYMENT_URL: ${{ steps.vercel.outputs.deployment_url }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
deployed_at="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
||||
tag_name="deploy/prod/$(date -u +"%Y%m%d-%H%M%SZ")-${GITHUB_SHA::7}"
|
||||
version_prefix="prod/v$(date -u +"%Y.%m.%d")."
|
||||
run_url="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}"
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
next_version=1
|
||||
while IFS= read -r existing_tag; do
|
||||
existing_tag="${existing_tag#refs/tags/}"
|
||||
existing_tag="${existing_tag%\^\{\}}"
|
||||
suffix="${existing_tag##*.}"
|
||||
if [[ "$existing_tag" == "$version_prefix"* && "$suffix" =~ ^[0-9]+$ && "$suffix" -ge "$next_version" ]]; then
|
||||
next_version=$((suffix + 1))
|
||||
fi
|
||||
done < <(git ls-remote --tags origin "refs/tags/${version_prefix}*" | awk '{print $2}' | sort -u)
|
||||
version_tag="${version_prefix}${next_version}"
|
||||
|
||||
git tag -a "$tag_name" "$GITHUB_SHA" \
|
||||
-m "Production frontend deploy $tag_name" \
|
||||
-m "SHA: $GITHUB_SHA" \
|
||||
-m "Version: $version_tag" \
|
||||
-m "Deployed at: $deployed_at" \
|
||||
-m "Target: $DEPLOY_TARGET" \
|
||||
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
|
||||
-m "Run: $run_url"
|
||||
git tag -a "$version_tag" "$GITHUB_SHA" \
|
||||
-m "Production frontend deploy $version_tag" \
|
||||
-m "SHA: $GITHUB_SHA" \
|
||||
-m "Timestamp tag: $tag_name" \
|
||||
-m "Deployed at: $deployed_at" \
|
||||
-m "Target: $DEPLOY_TARGET" \
|
||||
-m "Vercel: ${DEPLOYMENT_URL:-unknown}" \
|
||||
-m "Run: $run_url"
|
||||
git push origin "refs/tags/$tag_name" "refs/tags/$version_tag"
|
||||
36
.github/workflows/openclaw-docs-sync-dispatch.yml
vendored
Normal file
36
.github/workflows/openclaw-docs-sync-dispatch.yml
vendored
Normal file
@ -0,0 +1,36 @@
|
||||
name: OpenClaw Docs Sync Dispatch
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- docs/**
|
||||
- .github/workflows/openclaw-docs-sync-dispatch.yml
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
dispatch-openclaw-docs-sync:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Dispatch OpenClaw docs sync
|
||||
env:
|
||||
OPENCLAW_DOCS_SYNC_TOKEN: ${{ secrets.OPENCLAW_DOCS_SYNC_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if [ -z "${OPENCLAW_DOCS_SYNC_TOKEN:-}" ]; then
|
||||
echo "::error::OPENCLAW_DOCS_SYNC_TOKEN is required to dispatch openclaw/openclaw docs sync."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
curl --fail-with-body --silent --show-error \
|
||||
--request POST \
|
||||
--header "Authorization: Bearer ${OPENCLAW_DOCS_SYNC_TOKEN}" \
|
||||
--header "Accept: application/vnd.github+json" \
|
||||
--header "X-GitHub-Api-Version: 2022-11-28" \
|
||||
https://api.github.com/repos/openclaw/openclaw/actions/workflows/docs-sync-publish.yml/dispatches \
|
||||
--data '{"ref":"main"}'
|
||||
354
.github/workflows/package-publish.yml
vendored
Normal file
354
.github/workflows/package-publish.yml
vendored
Normal file
@ -0,0 +1,354 @@
|
||||
name: Package Publish
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
source:
|
||||
description: Package source to publish. Usually owner/repo, owner/repo@ref, or a GitHub URL.
|
||||
required: false
|
||||
type: string
|
||||
default: ""
|
||||
ref:
|
||||
description: Optional ref to append to the source when source is not already pinned.
|
||||
required: false
|
||||
type: string
|
||||
dry_run:
|
||||
description: Preview only. When true, no publish mutation is performed.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
json:
|
||||
description: Emit structured JSON output.
|
||||
required: false
|
||||
type: boolean
|
||||
default: true
|
||||
registry:
|
||||
description: ClawHub registry URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
site:
|
||||
description: ClawHub site URL.
|
||||
required: false
|
||||
type: string
|
||||
default: https://clawhub.ai
|
||||
owner:
|
||||
description: Optional owner handle override for org/shared publishing.
|
||||
required: false
|
||||
type: string
|
||||
version:
|
||||
description: Optional package version override.
|
||||
required: false
|
||||
type: string
|
||||
tags:
|
||||
description: Optional comma-separated tags override.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
source_repo:
|
||||
description: Optional source repo override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
source_commit:
|
||||
description: Optional source commit override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
source_ref:
|
||||
description: Optional source ref override for local-folder publishes.
|
||||
required: false
|
||||
type: string
|
||||
source_path:
|
||||
description: Optional source path inside the repository for monorepo package publishes.
|
||||
required: false
|
||||
type: string
|
||||
clawhub_version:
|
||||
description: Legacy npm CLI version input. Kept for compatibility; the workflow now runs the checked-out source.
|
||||
required: false
|
||||
type: string
|
||||
default: latest
|
||||
secrets:
|
||||
clawhub_token:
|
||||
required: false
|
||||
outputs:
|
||||
publish_json:
|
||||
description: Structured JSON output from clawhub package publish.
|
||||
value: ${{ jobs.publish.outputs.publish_json }}
|
||||
release_id:
|
||||
description: Published release id when dry_run is false.
|
||||
value: ${{ jobs.publish.outputs.release_id }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
publish:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
permissions:
|
||||
contents: read
|
||||
id-token: write
|
||||
outputs:
|
||||
publish_json: ${{ steps.capture.outputs.publish_json }}
|
||||
release_id: ${{ steps.capture.outputs.release_id }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.sha }}
|
||||
|
||||
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: 1.3.10
|
||||
|
||||
- name: Resolve ClawHub workflow source
|
||||
id: clawhub_source
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
request_token = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_TOKEN", "").strip()
|
||||
request_url = os.environ.get("ACTIONS_ID_TOKEN_REQUEST_URL", "").strip()
|
||||
if not request_token or not request_url:
|
||||
raise SystemExit("GitHub OIDC token request env vars are missing; id-token: write is required.")
|
||||
|
||||
audience = "clawhub-workflow-source"
|
||||
joiner = "&" if "?" in request_url else "?"
|
||||
token_url = f"{request_url}{joiner}audience={audience}"
|
||||
request = Request(
|
||||
token_url,
|
||||
headers={"Authorization": f"Bearer {request_token}"},
|
||||
)
|
||||
with urlopen(request) as response:
|
||||
payload = json.load(response)
|
||||
|
||||
token = str(payload.get("value", "")).strip()
|
||||
if not token:
|
||||
raise SystemExit("GitHub OIDC token response did not include a token value.")
|
||||
|
||||
try:
|
||||
encoded_payload = token.split(".")[1]
|
||||
except IndexError as exc:
|
||||
raise SystemExit("GitHub OIDC token was not a valid JWT.") from exc
|
||||
padding = "=" * (-len(encoded_payload) % 4)
|
||||
claims = json.loads(
|
||||
base64.urlsafe_b64decode(encoded_payload + padding).decode("utf-8")
|
||||
)
|
||||
|
||||
workflow_ref = str(claims.get("job_workflow_ref", "")).strip()
|
||||
workflow_sha = str(claims.get("job_workflow_sha", "")).strip()
|
||||
repo, marker, _ = workflow_ref.partition("/.github/workflows/")
|
||||
if not marker or not repo or not workflow_sha:
|
||||
raise SystemExit(
|
||||
"Unable to resolve reusable workflow source from GitHub OIDC claims: "
|
||||
f"job_workflow_ref={workflow_ref!r} job_workflow_sha={workflow_sha!r}"
|
||||
)
|
||||
|
||||
output_path = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with output_path.open("a", encoding="utf-8") as fh:
|
||||
fh.write(f"repository={repo}\n")
|
||||
fh.write(f"ref={workflow_sha}\n")
|
||||
PY
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
repository: ${{ steps.clawhub_source.outputs.repository }}
|
||||
ref: ${{ steps.clawhub_source.outputs.ref }}
|
||||
path: clawhub-source
|
||||
|
||||
- name: Install ClawHub CLI dependencies
|
||||
working-directory: clawhub-source
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Validate publish mode inputs
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
JSON_MODE: ${{ inputs.json }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
run: |
|
||||
if [[ "$JSON_MODE" != "true" ]]; then
|
||||
echo "::warning::This reusable workflow always emits JSON output; forcing --json for downstream parsing."
|
||||
fi
|
||||
if [[ "$DRY_RUN" == "true" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ -n "$CLAWHUB_TOKEN" ]]; then
|
||||
exit 0
|
||||
fi
|
||||
if [[ "$GITHUB_EVENT_NAME" == "workflow_dispatch" && -n "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" && -n "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ]]; then
|
||||
echo "No ClawHub token provided; publish will rely on GitHub OIDC trusted publishing."
|
||||
exit 0
|
||||
fi
|
||||
echo "::error::Real publishes need secrets.clawhub_token, or GitHub OIDC on workflow_dispatch runs (permissions.id-token=write)."
|
||||
exit 1
|
||||
|
||||
- name: Write ClawHub config
|
||||
env:
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
CLAWHUB_REGISTRY: ${{ inputs.registry }}
|
||||
run: |
|
||||
if [[ -z "$CLAWHUB_TOKEN" ]]; then
|
||||
echo "No ClawHub token provided, skipping config file creation."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-config.json"
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"registry": os.environ["CLAWHUB_REGISTRY"],
|
||||
"token": os.environ["CLAWHUB_TOKEN"],
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
+ "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
print(path)
|
||||
PY
|
||||
echo "CLAWHUB_CONFIG_PATH=$RUNNER_TEMP/clawhub-config.json" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Resolve publish command
|
||||
env:
|
||||
INPUT_SOURCE: ${{ inputs.source }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
INPUT_DRY_RUN: ${{ inputs.dry_run }}
|
||||
INPUT_OWNER: ${{ inputs.owner }}
|
||||
INPUT_VERSION: ${{ inputs.version }}
|
||||
INPUT_TAGS: ${{ inputs.tags }}
|
||||
INPUT_SOURCE_REPO: ${{ inputs.source_repo }}
|
||||
INPUT_SOURCE_COMMIT: ${{ inputs.source_commit }}
|
||||
INPUT_SOURCE_REF: ${{ inputs.source_ref }}
|
||||
INPUT_SOURCE_PATH: ${{ inputs.source_path }}
|
||||
INPUT_SITE: ${{ inputs.site }}
|
||||
INPUT_REGISTRY: ${{ inputs.registry }}
|
||||
CLAWHUB_TOKEN: ${{ secrets.clawhub_token }}
|
||||
GITHUB_EVENT_NAME: ${{ github.event_name }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF: ${{ github.ref }}
|
||||
GITHUB_SHA: ${{ github.sha }}
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
import shlex
|
||||
from pathlib import Path
|
||||
|
||||
source = os.environ["INPUT_SOURCE"].strip()
|
||||
if not source:
|
||||
source = os.environ["GITHUB_REPOSITORY"]
|
||||
source_is_current_repo = source == os.environ["GITHUB_REPOSITORY"]
|
||||
ref = os.environ["INPUT_REF"].strip()
|
||||
if not ref and source_is_current_repo:
|
||||
ref = os.environ["GITHUB_SHA"].strip()
|
||||
is_local_source = source.startswith(".") or source.startswith("/") or Path(source).exists()
|
||||
if ref and "@" not in source and not source.startswith("http") and not is_local_source:
|
||||
source = f"{source}@{ref}"
|
||||
|
||||
cli_entry = (
|
||||
Path(os.environ["GITHUB_WORKSPACE"])
|
||||
/ "clawhub-source"
|
||||
/ "packages"
|
||||
/ "clawhub"
|
||||
/ "src"
|
||||
/ "cli.ts"
|
||||
)
|
||||
if not cli_entry.exists():
|
||||
raise SystemExit(f"Missing ClawHub CLI entrypoint at {cli_entry}")
|
||||
|
||||
cmd = [
|
||||
"bun",
|
||||
str(cli_entry),
|
||||
"package",
|
||||
"publish",
|
||||
source,
|
||||
"--site",
|
||||
os.environ["INPUT_SITE"],
|
||||
"--registry",
|
||||
os.environ["INPUT_REGISTRY"],
|
||||
]
|
||||
|
||||
if os.environ["INPUT_DRY_RUN"] == "true":
|
||||
cmd.append("--dry-run")
|
||||
cmd.append("--json")
|
||||
|
||||
owner = os.environ["INPUT_OWNER"].strip()
|
||||
version = os.environ["INPUT_VERSION"].strip()
|
||||
tags = os.environ["INPUT_TAGS"].strip()
|
||||
if owner:
|
||||
cmd += ["--owner", owner]
|
||||
if version:
|
||||
cmd += ["--version", version]
|
||||
if tags:
|
||||
cmd += ["--tags", tags]
|
||||
source_repo = os.environ["INPUT_SOURCE_REPO"].strip()
|
||||
source_commit = os.environ["INPUT_SOURCE_COMMIT"].strip()
|
||||
source_ref = os.environ["INPUT_SOURCE_REF"].strip()
|
||||
source_path = os.environ["INPUT_SOURCE_PATH"].strip()
|
||||
if source_repo:
|
||||
cmd += ["--source-repo", source_repo]
|
||||
if source_commit:
|
||||
cmd += ["--source-commit", source_commit]
|
||||
if source_ref:
|
||||
cmd += ["--source-ref", source_ref]
|
||||
elif source_is_current_repo:
|
||||
github_ref = os.environ["GITHUB_REF"].strip()
|
||||
if github_ref:
|
||||
cmd += ["--source-ref", github_ref]
|
||||
if source_path:
|
||||
cmd += ["--source-path", source_path]
|
||||
if os.environ["INPUT_DRY_RUN"] != "true" and os.environ["CLAWHUB_TOKEN"].strip():
|
||||
cmd += [
|
||||
"--manual-override-reason",
|
||||
f"GitHub Actions {os.environ['GITHUB_EVENT_NAME'].strip()} publish via CLAWHUB_TOKEN",
|
||||
]
|
||||
|
||||
path = Path(os.environ["RUNNER_TEMP"]) / "clawhub-package-publish-command.sh"
|
||||
shell_line = " ".join(shlex.quote(part) for part in cmd)
|
||||
path.write_text("#!/usr/bin/env bash\nset -euo pipefail\n" + shell_line + "\n", encoding="utf-8")
|
||||
path.chmod(0o755)
|
||||
print(shell_line)
|
||||
PY
|
||||
|
||||
- name: Run package publish
|
||||
run: |
|
||||
set -euo pipefail
|
||||
"$RUNNER_TEMP/clawhub-package-publish-command.sh" | tee "$RUNNER_TEMP/package-publish.json"
|
||||
|
||||
- name: Capture workflow outputs
|
||||
id: capture
|
||||
run: |
|
||||
python3 - <<'PY'
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
output_path = Path(os.environ["RUNNER_TEMP"]) / "package-publish.json"
|
||||
raw = output_path.read_text(encoding="utf-8").strip()
|
||||
parsed = json.loads(raw)
|
||||
|
||||
github_output = Path(os.environ["GITHUB_OUTPUT"])
|
||||
with github_output.open("a", encoding="utf-8") as fh:
|
||||
fh.write("publish_json<<__CLAWHUB_JSON__\n")
|
||||
fh.write(json.dumps(parsed, indent=2))
|
||||
fh.write("\n__CLAWHUB_JSON__\n")
|
||||
release_id = str(parsed.get("releaseId", "") or "")
|
||||
fh.write(f"release_id={release_id}\n")
|
||||
PY
|
||||
|
||||
- name: Upload publish JSON artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: clawhub-package-publish-json
|
||||
path: ${{ runner.temp }}/package-publish.json
|
||||
if-no-files-found: error
|
||||
66
.github/workflows/secret-scan.yml
vendored
Normal file
66
.github/workflows/secret-scan.yml
vendored
Normal file
@ -0,0 +1,66 @@
|
||||
name: "Security Gate: Secret Scanning"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
trufflehog:
|
||||
name: Scan for Verified Secrets
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read # Required to scan the code in the PR
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0 # necessary to support the scoping requirements below
|
||||
|
||||
- name: Resolve scan range
|
||||
id: scan_range
|
||||
env:
|
||||
EVENT_NAME: ${{ github.event_name }}
|
||||
PR_BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
PUSH_BASE_SHA: ${{ github.event.before }}
|
||||
PUSH_HEAD_SHA: ${{ github.sha }}
|
||||
DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
zero_sha="0000000000000000000000000000000000000000"
|
||||
|
||||
if [[ "$EVENT_NAME" == "pull_request" ]]; then
|
||||
base="$PR_BASE_SHA"
|
||||
head="$PR_HEAD_SHA"
|
||||
else
|
||||
base="$PUSH_BASE_SHA"
|
||||
head="$PUSH_HEAD_SHA"
|
||||
if [[ -z "$base" || "$base" == "$zero_sha" ]]; then
|
||||
base="origin/$DEFAULT_BRANCH"
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "base=$base" >> "$GITHUB_OUTPUT"
|
||||
echo "head=$head" >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: TruffleHog OSS
|
||||
id: trufflehog
|
||||
# Use a concrete released ref that resolves in upstream action registry.
|
||||
# v3 (major tag) is not published by trufflesecurity/trufflehog.
|
||||
uses: trufflesecurity/trufflehog@v3.95.2
|
||||
with:
|
||||
path: ./
|
||||
base: ${{ steps.scan_range.outputs.base }}
|
||||
head: ${{ steps.scan_range.outputs.head }}
|
||||
extra_args: --only-verified --debug
|
||||
|
||||
- name: Notify on Failure
|
||||
if: steps.trufflehog.outcome == 'failure'
|
||||
run: |
|
||||
echo "::error::Verified secrets found! This PR contains live credentials that must be rotated immediately."
|
||||
echo "::notice::If these secrets are already in the commit history, they cannot be removed via a simple removal commit/push. A repository owner can contact GitHub Support to purge the cached data: https://support.github.com/contact/private-information"
|
||||
exit 1
|
||||
171
.github/workflows/stale.yml
vendored
Normal file
171
.github/workflows/stale.yml
vendored
Normal file
@ -0,0 +1,171 @@
|
||||
name: Stale
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "17 3 * * *"
|
||||
workflow_dispatch:
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
permissions: {}
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Mark stale unassigned issues and pull requests
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ github.token }}
|
||||
days-before-issue-stale: 14
|
||||
days-before-issue-close: 7
|
||||
days-before-pr-stale: 7
|
||||
days-before-pr-close: 5
|
||||
stale-issue-label: stale
|
||||
stale-pr-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 1000
|
||||
ascending: true
|
||||
exempt-all-assignees: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale due to inactivity.
|
||||
Please add updated ClawHub details or it will be closed.
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale due to inactivity.
|
||||
Please update it or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this still affects ClawHub, reopen or file a new issue with the current URL, skill/package name, and fresh reproduction details.
|
||||
close-issue-reason: not_planned
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If this PR should be revived, reopen it with current context and a fresh validation plan.
|
||||
|
||||
- name: Mark stale assigned issues
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ github.token }}
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 10
|
||||
days-before-pr-stale: -1
|
||||
days-before-pr-close: -1
|
||||
stale-issue-label: stale
|
||||
exempt-issue-labels: enhancement,maintainer,pinned,security,no-stale,bad-barnacle
|
||||
operations-per-run: 1000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
remove-stale-when-updated: true
|
||||
stale-issue-message: |
|
||||
This assigned issue has been automatically marked as stale after 30 days of inactivity.
|
||||
Please add an update or it will be closed.
|
||||
close-issue-message: |
|
||||
Closing due to inactivity.
|
||||
If this still affects ClawHub, reopen or file a new issue with current evidence.
|
||||
close-issue-reason: not_planned
|
||||
|
||||
- name: Mark stale assigned pull requests
|
||||
uses: actions/stale@v10
|
||||
with:
|
||||
repo-token: ${{ github.token }}
|
||||
days-before-issue-stale: -1
|
||||
days-before-issue-close: -1
|
||||
days-before-pr-stale: 27
|
||||
days-before-pr-close: 5
|
||||
stale-pr-label: stale
|
||||
exempt-pr-labels: maintainer,no-stale,bad-barnacle
|
||||
operations-per-run: 1000
|
||||
ascending: true
|
||||
include-only-assigned: true
|
||||
ignore-pr-updates: true
|
||||
remove-stale-when-updated: true
|
||||
stale-pr-message: |
|
||||
This assigned pull request has been automatically marked as stale after being open for 27 days.
|
||||
Please add an update or it will be closed.
|
||||
close-pr-message: |
|
||||
Closing due to inactivity.
|
||||
If this PR should be revived, reopen it with current context and a fresh validation plan.
|
||||
|
||||
lock-closed-issues:
|
||||
permissions:
|
||||
issues: write
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Lock closed issues after 48h of no comments
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
github-token: ${{ github.token }}
|
||||
script: |
|
||||
const lockAfterHours = 48;
|
||||
const lockAfterMs = lockAfterHours * 60 * 60 * 1000;
|
||||
const cutoffMs = Date.now() - lockAfterMs;
|
||||
const { owner, repo } = context.repo;
|
||||
|
||||
let locked = 0;
|
||||
let inspected = 0;
|
||||
let page = 1;
|
||||
|
||||
while (true) {
|
||||
const { data: issues } = await github.rest.issues.listForRepo({
|
||||
owner,
|
||||
repo,
|
||||
state: "closed",
|
||||
sort: "updated",
|
||||
direction: "desc",
|
||||
per_page: 100,
|
||||
page,
|
||||
});
|
||||
|
||||
if (issues.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
for (const issue of issues) {
|
||||
if (issue.pull_request || issue.locked || !issue.closed_at) {
|
||||
continue;
|
||||
}
|
||||
|
||||
inspected += 1;
|
||||
const closedAtMs = Date.parse(issue.closed_at);
|
||||
if (!Number.isFinite(closedAtMs) || closedAtMs > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let lastCommentMs = 0;
|
||||
if (issue.comments > 0) {
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
per_page: 1,
|
||||
page: 1,
|
||||
sort: "created",
|
||||
direction: "desc",
|
||||
});
|
||||
if (comments.length > 0) {
|
||||
lastCommentMs = Date.parse(comments[0].created_at);
|
||||
}
|
||||
}
|
||||
|
||||
if (Math.max(closedAtMs, lastCommentMs || 0) > cutoffMs) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await github.rest.issues.lock({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: issue.number,
|
||||
lock_reason: "resolved",
|
||||
});
|
||||
locked += 1;
|
||||
}
|
||||
|
||||
page += 1;
|
||||
}
|
||||
|
||||
core.info(`Inspected ${inspected} closed issues; locked ${locked}.`);
|
||||
121
.github/workflows/update-convex-ai-files.yml
vendored
Normal file
121
.github/workflows/update-convex-ai-files.yml
vendored
Normal file
@ -0,0 +1,121 @@
|
||||
name: Update Convex AI Files
|
||||
|
||||
on:
|
||||
schedule:
|
||||
# Midnight Pacific during daylight saving time. GitHub cron uses UTC.
|
||||
- cron: "0 7 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: update-convex-ai-files
|
||||
cancel-in-progress: false
|
||||
|
||||
permissions: {}
|
||||
|
||||
env:
|
||||
BUN_VERSION: "1.3.10"
|
||||
UPDATE_BRANCH: automation/update-convex-ai-files
|
||||
|
||||
jobs:
|
||||
update:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Setup Bun
|
||||
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6
|
||||
with:
|
||||
bun-version: ${{ env.BUN_VERSION }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
with:
|
||||
app-id: "2729701"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/create-github-app-token@v3
|
||||
id: app-token-fallback
|
||||
continue-on-error: true
|
||||
if: steps.app-token.outcome == 'failure'
|
||||
with:
|
||||
app-id: "2971289"
|
||||
private-key: ${{ secrets.GH_APP_PRIVATE_KEY_FALLBACK }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: bun install --frozen-lockfile
|
||||
|
||||
- name: Update Convex AI files
|
||||
run: |
|
||||
"$(bun pm bin)/convex" ai-files update
|
||||
|
||||
- name: Check Convex AI files status
|
||||
run: |
|
||||
"$(bun pm bin)/convex" ai-files status
|
||||
|
||||
- name: Detect changes
|
||||
id: changes
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "changed=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "changed=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Commit and push update branch
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
git checkout -B "$UPDATE_BRANCH"
|
||||
git add AGENTS.md CLAUDE.md .agents/skills convex/_generated/ai/guidelines.md convex/_generated/ai/ai-files.state.json
|
||||
git commit -m "chore: update Convex AI files"
|
||||
git push --force-with-lease origin "$UPDATE_BRANCH"
|
||||
|
||||
- name: Open or update pull request
|
||||
if: steps.changes.outputs.changed == 'true'
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token || github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
body_file="$(mktemp)"
|
||||
{
|
||||
printf '%s\n' '## Summary'
|
||||
printf '\n'
|
||||
printf '%s\n' '- refresh Convex-managed AI guidance files'
|
||||
printf '%s\n' '- keep AGENTS.md / CLAUDE.md Convex sections in sync when Convex updates them'
|
||||
printf '%s\n' '- update repo-local Convex developer skills under .agents/skills'
|
||||
printf '\n'
|
||||
printf '%s\n' '## Validation'
|
||||
printf '\n'
|
||||
# shellcheck disable=SC2016
|
||||
printf '%s\n' '- `$(bun pm bin)/convex ai-files status`'
|
||||
} > "$body_file"
|
||||
|
||||
if gh pr view "$UPDATE_BRANCH" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
gh pr edit "$UPDATE_BRANCH" \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--title "[automation] Update Convex AI files" \
|
||||
--body-file "$body_file"
|
||||
else
|
||||
gh pr create \
|
||||
--repo "$GITHUB_REPOSITORY" \
|
||||
--base main \
|
||||
--head "$UPDATE_BRANCH" \
|
||||
--title "[automation] Update Convex AI files" \
|
||||
--body-file "$body_file"
|
||||
fi
|
||||
23
.gitignore
vendored
23
.gitignore
vendored
@ -2,6 +2,7 @@ node_modules
|
||||
.DS_Store
|
||||
.bun-build
|
||||
*.bun-build
|
||||
.data/
|
||||
bin/docs-list
|
||||
dist
|
||||
dist-ssr
|
||||
@ -10,7 +11,9 @@ dist-ssr
|
||||
*.local
|
||||
.vercel
|
||||
count.txt
|
||||
.env
|
||||
.env*
|
||||
!.env.local.example
|
||||
!.env.example
|
||||
.nitro
|
||||
.tanstack
|
||||
.wrangler
|
||||
@ -21,6 +24,24 @@ todos.json
|
||||
.vscode
|
||||
.env*.local
|
||||
coverage
|
||||
eval/cache/
|
||||
eval/results/
|
||||
playwright-report
|
||||
test-results
|
||||
.playwright
|
||||
convex/_generated/*
|
||||
!convex/_generated/ai/
|
||||
convex/_generated/ai/*
|
||||
!convex/_generated/ai/guidelines.md
|
||||
!convex/_generated/ai/ai-files.state.json
|
||||
skills-lock.json
|
||||
*/skills/*
|
||||
!.agents/skills/
|
||||
!.agents/skills/convex*/
|
||||
!.agents/skills/convex*/**
|
||||
!.agents/skills/blacksmith-testbox/
|
||||
!.agents/skills/blacksmith-testbox/**
|
||||
skills/*
|
||||
.codex/*
|
||||
!.codex/environments/
|
||||
!.codex/environments/environment.toml
|
||||
|
||||
20
.oxfmtrc.jsonc
Normal file
20
.oxfmtrc.jsonc
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||
"experimentalSortImports": {
|
||||
"newlinesBetween": false,
|
||||
},
|
||||
"experimentalSortPackageJson": {
|
||||
"sortScripts": true,
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".output/",
|
||||
".tanstack/",
|
||||
"convex/_generated/",
|
||||
"coverage/",
|
||||
"dist/",
|
||||
"node_modules/",
|
||||
"public/",
|
||||
"src/routeTree.gen.ts",
|
||||
"test-results/",
|
||||
],
|
||||
}
|
||||
@ -1,3 +1,38 @@
|
||||
{
|
||||
"ignorePatterns": ["node_modules", "dist", "coverage", "convex/_generated", ".tanstack", "public"]
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["unicorn", "typescript", "oxc"],
|
||||
"categories": {
|
||||
"correctness": "error",
|
||||
"perf": "error",
|
||||
"suspicious": "error"
|
||||
},
|
||||
"rules": {
|
||||
"curly": "off",
|
||||
"eslint-plugin-unicorn/prefer-array-find": "off",
|
||||
"eslint-plugin-unicorn/no-array-sort": "off",
|
||||
"eslint/no-await-in-loop": "off",
|
||||
"eslint/no-underscore-dangle": "off",
|
||||
"eslint/no-new": "off",
|
||||
"oxc/no-accumulating-spread": "off",
|
||||
"oxc/no-async-endpoint-handlers": "off",
|
||||
"oxc/no-map-spread": "off",
|
||||
"typescript/no-explicit-any": "error",
|
||||
"typescript/no-extraneous-class": "off",
|
||||
"typescript/no-unnecessary-boolean-literal-compare": "off",
|
||||
"typescript/no-unnecessary-type-assertion": "off",
|
||||
"typescript/no-unsafe-type-assertion": "off",
|
||||
"unicorn/consistent-function-scoping": "off",
|
||||
"unicorn/require-post-message-target-origin": "off"
|
||||
},
|
||||
"ignorePatterns": [
|
||||
".output/",
|
||||
".tanstack/",
|
||||
"convex/_generated/",
|
||||
"coverage/",
|
||||
"dist/",
|
||||
"node_modules/",
|
||||
"public/",
|
||||
"src/routeTree.gen.ts",
|
||||
"test-results/"
|
||||
]
|
||||
}
|
||||
|
||||
90
AGENTS.md
90
AGENTS.md
@ -1,44 +1,130 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
|
||||
- `src/` — TanStack Start app code (routes, components, styles).
|
||||
- `convex/` — Convex backend (schema, queries/mutations/actions, HTTP routes).
|
||||
- `convex/_generated/` — generated Convex API/types; committed for builds.
|
||||
- `docs/` — product/spec docs (see `docs/spec.md`).
|
||||
- `docs/` — publishable public/operator docs for the ClawHub docs tab.
|
||||
- `specs/` — product specs, plans, regression notes, design history (see `specs/spec.md`).
|
||||
- `public/` — static assets.
|
||||
|
||||
## Durable Intent & Specs
|
||||
|
||||
- Use `specs/` to persist system/subsystem intent, invariants, and design rationale that future agents should preserve.
|
||||
- Keep intended behavior for security-sensitive flows there, especially moderation, upload gating, scanner outcomes, appeals, bans, ownership, package installability, and API trust boundaries.
|
||||
- If code changes reveal or change how a subsystem is supposed to work, update the relevant spec or add a focused spec note instead of burying the intent only in PR text or public docs.
|
||||
- Keep `docs/` user/operator-facing: explain current behavior and commands there, but put internal “why this must work this way” context in `specs/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
|
||||
- `bun run dev` — local app server at `http://localhost:3000`.
|
||||
- `bun run build` — production build (Vite + Nitro).
|
||||
- `bun run preview` — preview built app.
|
||||
- `bunx convex dev` — Convex dev deployment + function watcher.
|
||||
- `bunx convex codegen` — regenerate `convex/_generated`.
|
||||
- `bun run format:check` — formatting check.
|
||||
- `bun run lint` — Biome + oxlint (type-aware).
|
||||
- `bun run test` — Vitest (unit tests).
|
||||
- `bun run coverage` — coverage run; keep global >= 80%.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
|
||||
- TypeScript strict; ESM.
|
||||
- Indentation: 2 spaces, single quotes (Biome).
|
||||
- Lint/format: Biome + oxlint (type-aware).
|
||||
- Convex function names: verb-first (`getBySlug`, `publishVersion`).
|
||||
|
||||
## Testing Guidelines
|
||||
|
||||
- Framework: Vitest 4 + jsdom.
|
||||
- Tests live in `src/**` and `convex/lib/**`.
|
||||
- Coverage threshold: 80% global (lines/functions/branches/statements).
|
||||
- Example: `convex/lib/skills.test.ts`.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
- Commit messages: Conventional Commits (`feat:`, `fix:`, `chore:`, `docs:`…).
|
||||
- Keep changes scoped; avoid repo-wide search/replace.
|
||||
- Before commit/PR handoff, run `bun run format:check` and `bun run lint`; include commands run in the PR summary.
|
||||
- PRs: include summary + test commands run. Add screenshots for UI changes.
|
||||
- Before merging any PR, verify TypeScript cleanly with `bunx tsc -p packages/schema/tsconfig.json --noEmit` and `bunx tsc -p packages/clawhub/tsconfig.json --noEmit`; if Convex code changed, also run the repo typecheck path used by deploy so `bunx convex deploy` will not fail on `tsc`.
|
||||
- GitHub comments: for multiline `gh` comments/close messages, use `--body-file`, `--input`, or stdin/heredoc with real newlines; never pass literal `\\n` in shell strings.
|
||||
- Reject PRs that add skills into source code/repo content directly (for example under `skills/` or seed-only additions intended as published skills). Skills must be uploaded/published via CLI.
|
||||
- Repo-local Convex developer skills under `.agents/skills/convex*/` are allowed when they support working on this codebase; keep top-level `skills/` reserved for installed/published skill content and ignored by git.
|
||||
|
||||
## Production Release
|
||||
|
||||
- Production deploys are manual-only. Merging to `main` does **not** deploy.
|
||||
- To release production, start the GitHub Actions `Deploy` workflow from `main`:
|
||||
`gh workflow run deploy.yml --repo openclaw/clawhub --ref main`
|
||||
- The workflow supports `full`, `backend`, and `frontend` targets.
|
||||
- `frontend` currently means: wait for the Vercel production deploy for the selected `main` SHA, then run production smoke checks. It does not call `vercel deploy` directly yet.
|
||||
- The workflow uses the GitHub `Production` environment for deploy secrets, but it does not require a separate approval step.
|
||||
- Prod deploy secrets live on the `Production` environment, not as ordinary repo secrets. Required: `CONVEX_DEPLOY_KEY`. Optional: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON`.
|
||||
- CLI npm releases are also manual-only and tag-based. Stable tags only: `vX.Y.Z`. Start `ClawHub CLI NPM Release` from `main`, first with `preflight_only=true`, then rerun it with the same tag and the successful `preflight_run_id`.
|
||||
- Real CLI publishes wait at the GitHub `npm-release` environment and use npm trusted publishing. Required npm trusted publisher settings: repository `openclaw/clawhub`, workflow `clawhub-cli-npm-release.yml`, environment `npm-release`.
|
||||
|
||||
## Git Notes
|
||||
|
||||
- If `git branch -d/-D <branch>` is policy-blocked, delete the local ref directly: `git update-ref -d refs/heads/<branch>`.
|
||||
|
||||
## URL Quick Reference
|
||||
|
||||
- Canonical site: `https://clawhub.ai` (prefer this over legacy domains).
|
||||
- Skill page URL format: `https://clawhub.ai/<owner>/<slug>` (owner handle preferred; falls back to owner id).
|
||||
- Skill API detail URL: `https://clawhub.ai/api/v1/skills/<slug>`.
|
||||
- Skill file URL: `https://clawhub.ai/api/v1/skills/<slug>/file?path=SKILL.md`.
|
||||
- For “full URL?” requests, return the canonical page URL first, then API URL if useful.
|
||||
|
||||
## Configuration & Security
|
||||
|
||||
- Local env: `.env.local` (never commit secrets).
|
||||
- Convex env holds JWT keys; Vercel only needs `VITE_CONVEX_URL` + `VITE_CONVEX_SITE_URL`.
|
||||
- OAuth: GitHub OAuth App credentials required for login.
|
||||
|
||||
## Convex Ops (Gotchas)
|
||||
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy --prod` (prod).
|
||||
|
||||
- New Convex functions must be pushed before `convex run`: use `bunx convex dev --once` (dev) or `bunx convex deploy` (prod).
|
||||
- For non-interactive prod deploys, use `bunx convex deploy -y` to skip confirmation.
|
||||
- If `bunx convex run --env-file .env.local ...` returns `401 MissingAccessToken` despite `bunx convex login`, workaround: omit `--env-file` and use `--deployment-name <name>` / `--prod`.
|
||||
|
||||
## Convex Query & Bandwidth Rules
|
||||
|
||||
- **Always use `.withIndex()` instead of `.filter()` for fields that can be indexed.** `.filter()` causes full table scans — every doc is read and billed. Even a single `.filter()` on a 16K-row table reads ~16 MB per call.
|
||||
- **Convex reads entire documents** — no field projections. If you only need a few fields from large docs (~6 KB+), denormalize a lightweight summary onto the parent doc or use a lookup table (see `embeddingSkillMap`, `skill.latestVersionSummary`, `skill.badges` for examples).
|
||||
- **Denormalization pattern**: persist computed fields so they can be indexed. Every mutation that updates source fields must also update the denormalized field. Always write a cursor-based backfill for new fields (see `backfillIsSuspiciousInternal`, `backfillLatestVersionSummaryInternal`, `backfillDenormalizedBadgesInternal` for examples).
|
||||
- **Cron jobs must never scan entire tables.** Use indexed queries with equality filters. Use cursor-based pagination for large datasets. Prefer incremental/delta tracking over full recounts.
|
||||
- **32K document limit per query.** Split `.collect()` calls by a partition field (e.g., one day at a time instead of a 7-day range). See `rebuildTrendingLeaderboardAction` in `convex/leaderboards.ts` for an example.
|
||||
- **Common mistakes**: `.filter().collect()` without an index; `ctx.db.get()` on large docs in a loop for list views; while loops that paginate the whole table to find filtered results.
|
||||
- **Before writing or reviewing Convex queries, check deployment health.** Run `bunx convex insights` to check for OCC conflicts, `bytesReadLimit`, and `documentsReadLimit` errors. Run `bunx convex logs --failure` to see individual error messages and stack traces. This helps identify which functions are causing bandwidth issues so you can prioritize fixes.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
|
||||
## Stat Field Migration Rules
|
||||
|
||||
The `skills` table maintains two parallel sets of stat fields as part of an in-progress field migration:
|
||||
|
||||
| Legacy (nested, `@deprecated`) | Top-level (source of truth, indexable) |
|
||||
| ------------------------------ | -------------------------------------- |
|
||||
| `stats.downloads` | `statsDownloads` |
|
||||
| `stats.stars` | `statsStars` |
|
||||
| `stats.installsCurrent` | `statsInstallsCurrent` |
|
||||
| `stats.installsAllTime` | `statsInstallsAllTime` |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- **Always use `readCanonicalStat(skill, field)` (`convex/lib/skillStats.ts`) to read** any of the four migrated fields. It prefers the top-level field and falls back to the nested field for pre-migration documents. Never access `skill.stats.downloads` / `.stars` / `.installsCurrent` / `.installsAllTime` directly.
|
||||
- **Always use `applySkillStatDeltas()` to write** stat deltas. It writes both the top-level and nested fields in the same patch to keep them in sync.
|
||||
- **Both sets of fields must be written together** in any patch that touches stat values (see the return shape of `applySkillStatDeltas`).
|
||||
- **Nested-only reads are acceptable only for** `stats.comments` and `stats.versions` — no top-level field exists for these yet.
|
||||
- The four legacy nested fields are marked `@deprecated` in `statsValidator` (schema.ts). Any IDE access to `skill.stats.downloads` etc. will show a strikethrough warning — treat this as a signal to use `readCanonicalStat()` instead.
|
||||
- When adding new stat fields, follow the same dual-write pattern and add a cursor-based backfill mutation (see `backfillSkillStatFieldsInternal` for an example).
|
||||
|
||||
379
CHANGELOG.md
379
CHANGELOG.md
@ -1,8 +1,357 @@
|
||||
# Changelog
|
||||
|
||||
## 0.12.3 - 2026-05-06
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI/API: allow skill publishes to target an org/user publisher with `--owner` / `ownerHandle`, and keep root `SKILL.md` publishable even when broad ignore rules match Markdown files (thanks @deepujain).
|
||||
- Packages: expose owned plugin/package soft-delete in the CLI and dashboard, keep moderator takedown access, and remove deleted packages from package search surfaces (thanks @Patrick-Erichsen).
|
||||
- Packages: support monorepo package publishes, infer package owners from scoped names, and keep dry-run publishes metadata-only.
|
||||
- Packages: validate code-plugin runtime entries against extracted files, allow admin plugin release publishes, and raise trusted-publish/admin API rate limits for legitimate publish bursts.
|
||||
- API/Search: return lean skill list payloads, route package search through digest indexes, decode scoped package paths, and bound fallback scans to reduce production read pressure.
|
||||
- Web: restore skill downloads and search paging, canonicalize scoped plugin paths, and improve mobile layout responsiveness.
|
||||
- Security: add scanner checks for confirmation bypasses and Python file upload exfiltration while reducing generic false-positive package tags.
|
||||
|
||||
## 0.12.2 - 2026-05-02
|
||||
|
||||
### Fixes
|
||||
|
||||
- CLI: publish code plugins as clawpacks and allow legacy package downloads to keep older install flows working.
|
||||
- API: resolve scoped package routes and accept scoped npm packuments.
|
||||
- Schema: allow nullable package SHA values in package responses and refresh generated schema artifacts.
|
||||
|
||||
## 0.12.1 - 2026-05-02
|
||||
|
||||
### Added
|
||||
|
||||
- Packages: add clawpack parsing, uploads, mirror artifact routes, artifact downloads, release moderation, reports, appeals, and official migration management across API, dashboard, and CLI.
|
||||
- Security: add ClawScan security surfaces, owner rescan guidance, scanner-specific report pages, security dataset snapshots, and redacted skill-content exports.
|
||||
- CLI: add unban support, moderation diagnostics in `inspect`, manual skill-directory listing, package environment filters, and package migration-status commands.
|
||||
- Web: add skills/plugins search typeahead, featured plugin curation, plugin management tools, skill upload shortcuts, and dashboard pagination.
|
||||
|
||||
### Fixes
|
||||
|
||||
- API: raise public read rate limits to reduce false-positive 429s from browser pages and production smoke tests (thanks @steipete).
|
||||
- CLI/moderation: allow `delete`, `hide`, `undelete`, and `unhide` to record moderation reasons in skill notes and audit logs for legal or policy reviews (thanks @steipete).
|
||||
- Packages: make package publish retries idempotent, constrain catalog queries, keep package list queries single-page, count package archive downloads, and keep beta plugin packages off `latest`.
|
||||
- Search: add soul lexical fallback, non-suspicious digest indexes, normalized skill prefix recall, and more stable relevance recall windows.
|
||||
- Security: broaden static scanner coverage for unsafe credential, subprocess, browser-file, provider-secret, and remote-recipe patterns while hardening prompt-boundary handling.
|
||||
- Deploy/CI: harden production smoke checks, expand PR validation coverage, add dead-code gates, and stabilize CodeQL light coverage.
|
||||
- Dependencies: pin `undici` on the Node 20-compatible line after reverting the incompatible v8 update.
|
||||
|
||||
## 0.12.0 - 2026-04-28
|
||||
|
||||
### Added
|
||||
|
||||
- Security: add owner rescan requests, owner flagged inventory, scanner-specific security pages, and in-progress scan states.
|
||||
- UI: adopt shadcn-managed primitives and polish the rescan/security surfaces for mobile.
|
||||
|
||||
### Fixes
|
||||
|
||||
- Moderation: calibrate VirusTotal Code Insight suspicious verdicts so uncorroborated AI-only findings do not keep otherwise clean skills quarantined (#1830, #1841) (thanks @deepujain).
|
||||
- Security: flag exposed secrets in skill docs and normalize VirusTotal engine stats before caching.
|
||||
- Packages: constrain plugin catalog queries and avoid catalog/package-list query limits.
|
||||
- Auth: tolerate stale auth state when reading star status.
|
||||
- CI: harden and debounce ClawSweeper dispatch workflows and fix production smoke coverage.
|
||||
|
||||
## 0.11.0 - 2026-04-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Docs: clarify that ClawHub does not support paid skills, per-skill pricing, or paywalled releases (#1752, #1844) (thanks @deepujain).
|
||||
- API docs: clarify how third-party directories can reuse public ClawHub catalog endpoints while respecting rate limits and canonical links (#1825, #1845) (thanks @deepujain).
|
||||
- Packages docs: document the required fields for code-plugin package publish flows (#1802) (thanks @deepujain).
|
||||
- Search: add CJK tokenization support (Chinese/Japanese/Korean) with Intl.Segmenter plus fallback behavior to improve skill query matching (#1596) (thanks @pq-dong).
|
||||
- Stats: centralize migrated skill stat fallback reads through `readCanonicalStat()` and add schema/agent guardrails to discourage direct legacy nested-field access (#1709) (thanks @momothemage).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Packages: use the configured `GITHUB_TOKEN` for trusted-publisher repository identity lookups to avoid anonymous GitHub API rate limits during publish setup (#1820, #1846) (thanks @deepujain).
|
||||
- Packages: keep package search fallback scans bounded, stop scanning after the requested result limit, and keep direct plugin-name matches scoped to the requested package family (OpenClaw #64025).
|
||||
- Moderation: stop flagging declared env vars sent to their intended API while preserving broad env scraping and exfiltration findings (#1803) (thanks @deepujain).
|
||||
- Moderation: stop treating generic webhook integration docs as suspicious unless they include explicit Discord or Slack webhook endpoints (#1716) (thanks @langningchen-openclaw).
|
||||
- Search: increase initial vector candidate pools and align CLI search's default limit with the web UI so high-scoring matches are not missed at small limits (#1375, #1429) (thanks @tjefferson).
|
||||
- Search: fall back to lexical skill search when embedding generation fails instead of returning empty skill results (#1291) (thanks @goulonghui).
|
||||
- Search: rank exact slug matches above longer slugs that merely contain all query tokens (#1130) (thanks @QuinnH496).
|
||||
- Search: widen lexical fallback coverage and scan recently created skills so newly published skills can be found before embeddings rank well (#1185, #1200) (thanks @thirumaleshp).
|
||||
- Search: preserve vector scores across candidate expansion and require all query tokens to match exact-token filters so relevant skills are not crowded out (#1759, #1762) (thanks @LinPower).
|
||||
- Stats maintenance: keep skill stat migration fields synchronized by treating top-level stat fields as canonical during backfill/reconcile fallback reads (#1704) (thanks @momothemage).
|
||||
- Skill install: render OpenClaw CLI commands with the bare slug that the current CLI accepts (#1807).
|
||||
- Skills: keep historical tags out of public skill detail surfaces while preserving manager visibility (#1804) (thanks @deepujain).
|
||||
- Skills moderation: keep hash-based scanner callbacks from overwriting skill-level moderation for older versions (#1805) (thanks @deepujain).
|
||||
- Skills: prevent backport publishes from clobbering `latest` state and guard malformed persisted latest semver values during publish comparisons (#1832) (thanks @momothemage).
|
||||
|
||||
## 0.10.0 - 2026-04-05
|
||||
|
||||
### Added
|
||||
|
||||
- Design system: introduce a shared UI component library (`src/components/ui/`) built on Radix UI primitives — Button, Card, Badge, Tabs, Dialog, Input, Textarea, Label, Select, Avatar, Separator, Tooltip, ScrollArea, Sheet, Skeleton, and Table — following the shadcn/ui pattern with `cn()` + Tailwind utilities.
|
||||
- Design system: `Button` supports `asChild` via Radix Slot for polymorphic rendering (e.g., wrapping `<Link>` without extra DOM).
|
||||
- Layout: add `Container` component with `narrow` / `default` / `wide` size presets and `Breadcrumb` component for hierarchical navigation.
|
||||
- Loading: add skeleton loading states (`SkillCardSkeleton`, `SkillDetailSkeleton`, `DashboardSkeleton`) replacing text-based "Loading..." indicators with animated placeholders.
|
||||
- Errors: add `ErrorBoundary` with `resetKey` prop that auto-resets on route changes, wired into the root layout.
|
||||
- Errors: surface fallback messages from Convex API error payloads in mutation/action error toasts.
|
||||
- UX: add `EmptyState` component with icon, headline, description, and optional CTA action used across dashboard, stars, profile, and publish pages.
|
||||
- UX: add confirmation dialogs for destructive skill ownership actions (transfer, abandon).
|
||||
- Markdown: add `MarkdownPreview` component with `react-markdown`, `remark-gfm`, and `react-syntax-highlighter` for rich rendering of skill/plugin READMEs with syntax-highlighted code blocks, GFM tables, and task lists.
|
||||
- Markdown: render tables with the new `Table` UI primitive for consistent styling across skill docs.
|
||||
- Navigation: replace DropdownMenu-based mobile nav with a slide-out `Sheet` panel.
|
||||
- Validation: add Zod schemas (`src/lib/schemas.ts`) for publish-skill, settings, report, and org forms.
|
||||
- Management: restore capability-tags UI (crypto, requires-wallet, can-make-purchases, etc.) that was silently removed during the initial refactor.
|
||||
- Management: add `.catch()` error handling with toast feedback on `setSoftDeleted` calls; prompt for hide/restore reasons.
|
||||
|
||||
### Changed
|
||||
|
||||
- CSS: migrate from a monolithic 5,161-line `styles.css` to Tailwind utilities on components, pruning CSS to ~1,000 lines (81% reduction). Dark mode now uses Tailwind `dark:` variants via a `@variant dark` directive bridging existing CSS custom properties.
|
||||
- Tailwind: add `@theme` block mapping all CSS design tokens (`--bg`, `--surface`, `--ink`, `--accent`, `--line`, `--radius-*`, etc.) into first-class Tailwind utilities.
|
||||
- Pages: modernize all route pages (home, skills browse, skill detail, dashboard, settings, publish-skill, publish-plugin, import, about, CLI auth, stars, souls, user profile, org profile, management, plugins browse, plugin detail) from CSS class selectors to Tailwind + UI primitives.
|
||||
- Skills browse: widen container to `wide` (1400px) for better use of screen space on desktop; same for plugins browse.
|
||||
- Skills browse: replace text-based filter toggles with pill chips and modernize toolbar layout.
|
||||
- Skill detail: migrate tab controls from CSS-styled buttons to Radix `Tabs` primitive with proper `role="tab"` accessibility.
|
||||
- Skill detail: replace inline CSS class-based install card with `SkillInstallCard` using Card + Button primitives.
|
||||
- Header/Footer: migrate from CSS classes to Tailwind utilities with responsive Sheet-based mobile navigation.
|
||||
- Dashboard: replace CSS table layout with `Table` UI primitive; add metric cards and skeleton loading.
|
||||
- Settings: modernize form inputs with `Input`/`Textarea`/`Label` primitives and structured layout.
|
||||
- Publish: use `Dialog` primitive for modals; inline validation indicators; modernized file list display.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Auth: `EmptyState` "Sign in" button on publish page now triggers GitHub OAuth via `useAuthActions` instead of linking to non-existent `/signin` route.
|
||||
- API: fix plugins page dev-mode `{"error":"Only HTML requests are supported here"}` by routing SSR and localhost API fetches directly to the Convex site URL instead of through TanStack Start's request pipeline.
|
||||
- API: fix CORS error when `credentials: "include"` conflicts with `Access-Control-Allow-Origin: *` by making credentials conditional on same-origin requests.
|
||||
- API: fix SSR `packageApiUrl` to always use `VITE_CONVEX_SITE_URL` directly, avoiding `getRequestUrl()` failures when SSR request context is unavailable.
|
||||
- Management: restore `setSoftDeleted` reason parameter for hide/restore actions.
|
||||
- Tests: rename `settings.test.tsx` to `-settings.test.tsx` to exclude from TanStack Router's file-based route discovery.
|
||||
- Tests: add `@convex-dev/auth/react` mock for `useAuthActions` in upload route tests.
|
||||
- Tests: update skill detail tests for Radix tab roles (`role="tab"` instead of `role="button"`), skeleton loading classes (`animate-pulse`), and capability tag data.
|
||||
- Tests: update skills index tests for refreshed UI copy (placeholder text, empty state wording, loading indicator patterns).
|
||||
- Tests: update SkillDiffCard tests for Tailwind active-tab class (`shadow-sm` replacing `.is-active`).
|
||||
- Tests: update packages publish route tests for Tailwind border classes.
|
||||
- Tests: update packageApi tests for conditional credentials and SSR URL resolution.
|
||||
|
||||
## 0.9.0 - 2026-03-23
|
||||
|
||||
### Added
|
||||
|
||||
- Packages/Plugins: add a first-class OpenClaw package registry across the web app, CLI, and HTTP API. ClawHub now supports package browse/search/detail/version/file/download flows plus `clawhub package explore`, `clawhub package inspect`, and `clawhub package publish` for `skill`, `code-plugin`, and `bundle-plugin` packages. (#1093)
|
||||
- Packages/Install: package downloads now ship install-ready archives with a `package/` root, support nested files like `dist/index.js`, and work directly with OpenClaw plugin install flows.
|
||||
- Skills/Web: server-render public skill pages and OG assets for faster first loads, cleaner sharing previews, and better cache behavior.
|
||||
|
||||
### Changed
|
||||
|
||||
- Browse/Search: rebuild public browse/search around denormalized digests, one-shot HTTP fetches, and deterministic cursors so the homepage and `/skills` are faster, more cacheable, and less likely to hit stale-tab or pagination dead ends.
|
||||
- Search: default skill search to relevance, keep load-more retryable after fetch failures, and tighten package/skill catalog query paths to reduce inconsistent results under load.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Packages/Auth: authenticated owners can now list, search, inspect, download, and read files from their own private packages instead of private packages being direct-URL-only. (#1093)
|
||||
- Packages/API: stabilize package latest-version pointers, cursor pagination, publish outputs, fallback release resolution, and app-origin auth handling so package publish/search/install flows stay reliable.
|
||||
- Visibility/API: prevent skills owned by deleted/banned users from showing up in public detail pages, browse/search results, or version API routes.
|
||||
- Skills/API: sanitize public skill and soul version/file reads so hidden or invalid version data does not leak through direct API access.
|
||||
- Skills/Web: keep Monaco compare layout toggles reliable while defaulting narrow screens to inline mode (#828) (thanks @geoffrey-xiao).
|
||||
|
||||
## 0.8.0 - 2026-03-13
|
||||
|
||||
### Added
|
||||
|
||||
- Skills/Web: show skill owner avatar + handle on skill cards, lists, and detail pages (#312) (thanks @ianalloway).
|
||||
- Skills/Web: add file viewer for skill version files on detail page (#44) (thanks @regenrek).
|
||||
- CLI: add `uninstall` command for skills (#241) (thanks @superlowburn).
|
||||
- Skills/API/CLI: add ownership transfer workflow with request/list/accept/reject/cancel flows.
|
||||
- Skills/Web/API: surface platform/architecture labels and security evaluation results in v1 + inspect views (#499, #362).
|
||||
- API: add structured skill moderation responses plus `GET /api/v1/skills/{slug}/moderation` with redacted public evidence and full owner/staff detail (#334) (thanks @ArthurzKV).
|
||||
- Moderation: persist structured moderation snapshots (static scan + VT/LLM merged verdict, reason codes, and evidence) on skills and versions (#333) (thanks @ArthurzKV).
|
||||
- API: add scan security verification endpoint and non-suspicious filters (#820).
|
||||
- Users: add `trustedPublisher` flag and admin mutations to bypass pending-scan auto-hide for trusted publishers (#298) (thanks @autogame-17).
|
||||
- Moderation: add comment reporting with per-user active report caps, unique reporter/target enforcement, and auto-hide on the 4th unique report.
|
||||
- Moderation: add AI-driven comment scam backfill (`commentModeration:*`) with persisted verdict/confidence/explainer metadata and strict auto-ban for `certain_scam` + `high` confidence.
|
||||
- Admin: add manual unban for banned users (clears `deletedAt` + `banReason`, audit log entry). Revoked API tokens stay revoked.
|
||||
- Admin: bulk restore skills from GitHub backup; reclaim squatted slugs via v1 endpoints + internal tooling (#298) (thanks @autogame-17).
|
||||
- Moderation/Admin: add manual override audit tools for suspicious-skill review.
|
||||
- CI/Security: add TruffleHog pull-request scanning for verified leaked credentials (#505) (thanks @akses0).
|
||||
|
||||
### Changed
|
||||
|
||||
- Skills: make published skill licensing explicit and fixed to MIT-0; require publish consent, surface no-attribution messaging in web/CLI/API, and remove per-skill license metadata.
|
||||
- Skill metadata: support env vars, dependency declarations, author, and links in parsed manifest metadata + install UI (#360) (thanks @mahsumaktas).
|
||||
- Rate limiting: apply authenticated quotas by user bucket (vs shared IP), emit delay-based reset headers, and improve CLI 429 guidance/retries (#412) (thanks @lc0rp).
|
||||
- Skills: reserve deleted slugs for prior owners (90-day cooldown) to prevent squatting; add admin reclaim flow (#298) (thanks @autogame-17).
|
||||
- Moderation: ban flow soft-deletes owned skills (reversible) and removes them from vector search (#298) (thanks @autogame-17).
|
||||
- Security/docs: document comment reporting/auto-hide behavior alongside existing skill reporting rules.
|
||||
- Security/moderation: add bounded explainable auto-ban reasons for scam comments and protect moderator/admin accounts from automated bans.
|
||||
- Moderation: banning users now also soft-deletes their authored comments (skill + soul), including legacy cleanup on re-ban.
|
||||
- Quality gate: language-aware word counting (`Intl.Segmenter`) and new `cjkChars` signal to reduce false rejects for non-Latin docs.
|
||||
- Jobs: run skill stat event processing every 5 minutes (was 15).
|
||||
- Deploy: add frontend/backend drift detection plus hardened production smoke/deploy checks.
|
||||
- API performance: batch resolve skill/soul tags in v1 list/get endpoints (fewer action->query round-trips) (#112) (thanks @mkrokosz).
|
||||
- LLM helpers: centralize OpenAI Responses text extraction for changelog/summary/eval flows (#502) (thanks @ianalloway).
|
||||
- Search/listing performance: cut embedding hydration and badge read bandwidth via `embeddingSkillMap` + denormalized skill badges; shift stat-doc sync to low-frequency cron (#441) (thanks @sethconvex).
|
||||
- Search/listing performance: move public browse/search hydration onto `skillSearchDigest`, add non-suspicious index paths, and split trending rebuilds to stay under Convex document limits.
|
||||
|
||||
### Fixed
|
||||
|
||||
- API: accept legacy CLI publish payloads during the v1 migration (#815).
|
||||
- Auth/UI: surface OAuth callback failures in the web UI instead of swallowing them (#688).
|
||||
- Skills: allow ownership healing when the previous owner was deleted/banned, and sanitize owner data in public payloads (#689, #793).
|
||||
- CLI: validate explicit `install --force --version` targets before removing an existing local skill, preventing data loss when the requested version does not exist (#825) (thanks @jonathandeamer).
|
||||
- Skills/Web: debounce search URL updates on `/skills` to keep typing responsive, and cancel stale pending navigations on external query changes (#587) (thanks @neeravmakwana).
|
||||
- Upload: keep folder-picking enabled after page refresh by reapplying `webkitdirectory`/`directory` on the file input ref (#551) (thanks @MunemHashmi).
|
||||
- CLI publish: use a longer multipart upload timeout and normalize abort rejections into proper Errors (#550) (thanks @MunemHashmi).
|
||||
- CLI: forward optional auth tokens for `search` and `explore` against authenticated registries (#608) (thanks @artdaal).
|
||||
- CLI: respect `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` env vars for outbound registry requests, with troubleshooting docs (#363) (thanks @kerrypotter).
|
||||
- CLI: preserve registry base paths when composing API URLs for search/inspect/moderation commands (#486) (thanks @Liknox).
|
||||
- CLI: show manual URL guidance when automatic browser opening is unavailable; add regression tests for opener errors (#163) (thanks @aronchick).
|
||||
- API/CLI: expose skill security status in version inspect output, with schema wiring and CLI regression coverage (#362) (thanks @abutbul).
|
||||
- Moderation: remove over-broad keyword flags for common auth/payment/crypto terms so legitimate skills stop tripping regex prefilters (#273) (thanks @superlowburn).
|
||||
- Skills hard-delete: delete `commentReports` rows during moderation cleanup to avoid orphaned report records.
|
||||
- Comments: hide entries authored by deleted/deactivated users in `comments:listBySkill`.
|
||||
- Admin API: `POST /api/v1/users/reclaim` now performs non-destructive root-slug owner transfer
|
||||
(preserves existing skill versions/stats/metadata) and clears active slug reservations.
|
||||
- VirusTotal: use shared AV-engine fallback verdict mapping for pending/backfill flows and keep undetected-only results pending (#591) (thanks @Shuai-DaiDai).
|
||||
- Skills/listing: keep non-suspicious browse pagination on one cursor family during `isSuspicious` backfill, and re-sync stale `latestVersionSummary` metadata fields (#572) (thanks @sethconvex).
|
||||
- PWA: update `manifest.json` branding so installed apps show the correct ClawHub name (#569) (thanks @Glucksberg).
|
||||
- Search/tests: cover soft-deleted skill filtering in vector hydration and lexical exact-slug fallback (#552) (thanks @MunemHashmi).
|
||||
- Docs/dev: fix local setup instructions for Node support, Convex env vars, frontend port, and post-seed stats refresh (#584) (thanks @jack-piplabs).
|
||||
- Docs/CLI: fix `explore` flag list indentation so `--limit` renders correctly in the command reference (#601) (thanks @gandli).
|
||||
- Skill metadata: parse top-level `requires.*`, `primaryEnv`, and homepage fallbacks for security review accuracy (#548) (thanks @MunemHashmi).
|
||||
- Users: sync handle on ensure when GitHub login changes (#293) (thanks @christianhpoe).
|
||||
- Users/Auth: throttle GitHub profile sync on login; also sync avatar when it changes (#312) (thanks @ianalloway).
|
||||
- Upload gate: fetch GitHub account age by immutable account ID (prevents username swaps) (#116) (thanks @mkrokosz).
|
||||
- VT fallback: activate only VT-pending hidden skills when scans are unavailable/stale; keep quality/scanner-blocked skills hidden (#300) (thanks @superlowburn).
|
||||
- API: return proper status codes for delete/undelete errors (#35) (thanks @sergical).
|
||||
- API: for owners, return clearer status/messages for hidden/soft-deleted skills instead of a generic 404.
|
||||
- Web: allow copying OpenClaw scan summary text (thanks @borisolver, #322).
|
||||
- HTTP/CORS: add preflight handler + include CORS headers on API/download errors; CLI: include auth token for owner-visible installs/updates (#146) (thanks @Grenghis-Khan).
|
||||
- CLI: clarify `logout` only removes the local token; token remains valid until revoked in the web UI (#166) (thanks @aronchick).
|
||||
- CLI: validate skill slugs used for filesystem operations (prevents path traversal) (#241) (thanks @superlowburn).
|
||||
- Skills: keep global sorting across pagination on `/skills` (thanks @CodeBBakGoSu, #98).
|
||||
- Skills: allow updating skill description/summary from frontmatter on subsequent publishes (#312) (thanks @ianalloway).
|
||||
- Skills/Web: prevent filtered pagination dead-ends and loading-state flicker on `/skills`; move highlighted browse filtering into server list query (#339) (thanks @Marvae).
|
||||
- Web: align `/skills` total count with public visibility and format header count (thanks @rknoche6, #76).
|
||||
- Skills/Web: centralize public visibility checks and keep `globalStats` skill counts in sync incrementally; remove duplicate `/skills` default-sort fallback and share browse test mocks (thanks @rknoche6, #76).
|
||||
- Moderation: clear stale `flagged.suspicious` flags when VirusTotal rescans improve to clean verdicts (#418) (thanks @Phineas1500).
|
||||
- API tests: lock `Retry-After` behavior to relative-delay semantics for v1 search 429s (#421) (thanks @apoorvdarshan).
|
||||
- CLI tests: assert 5xx HTTP responses still perform retry attempts before surfacing final error (#457) (thanks @YonghaoZhao722).
|
||||
- GitHub import: improve storage/publish failure errors with actionable context; add regression tests for error formatting (#512) (thanks @vassiliylakhonin).
|
||||
|
||||
## 0.7.0 - 2026-02-16
|
||||
|
||||
Reconstructed from the `clawhub@0.7.0` npm publish timestamp (`2026-02-16T05:02:25Z`) and the repo version bump commit (`e352309`).
|
||||
|
||||
### Added
|
||||
|
||||
- Skills/Web: show owner avatars/handles across cards, lists, and detail pages (#312) (thanks @ianalloway).
|
||||
- Skills/Web: add version file viewer on skill detail pages (#44) (thanks @regenrek).
|
||||
- CLI: add `uninstall` for installed skills (#241) (thanks @superlowburn).
|
||||
- Skills/Web: add non-suspicious browse filter, downloads-first browse defaults, and popular non-suspicious homepage sections.
|
||||
- Web: compact-format skill and soul stats, plus split page models for skills/detail rendering.
|
||||
- Skills: auto-generate missing summaries and add a resumable/self-scheduling summary backfill job.
|
||||
- Moderation/Admin: add anti-spam publish caps, trust-tier quality checks, empty-skill cleanup tooling, and stronger moderator UX.
|
||||
|
||||
### Changed
|
||||
|
||||
- HTTP/CLI: centralize CORS handling and allow tokenized owner-visible reads through the CLI (#296, #297).
|
||||
- API performance: batch resolve tags in v1 list/get flows to cut action-to-query round-trips (#112) (thanks @mkrokosz).
|
||||
- Quality gate: add language-aware word counting and tighten spam/quarantine handling around publish flows.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skills/Web: fix initial sort wiring, keep global ordering across pagination, prevent pagination dead-ends/flicker, and harden cursor recovery (#92, #98, #339).
|
||||
- CLI: normalize abort/timeout errors, secure config-file permissions, clarify logout semantics, and prefer `$HOME` for path resolution (#164, #166, #283, #286, #299).
|
||||
- API: return correct delete/undelete status codes and clearer soft-delete/owner-visible error responses (#35) (thanks @sergical).
|
||||
- Upload/Auth: gate publish ownership by immutable GitHub account ID and handle duplicate auth-user records safely.
|
||||
- Downloads/Search: harden download dedupe/rate limiting, improve SSR host awareness, and fix homepage/search regressions under legacy data.
|
||||
|
||||
## 0.6.1 - 2026-02-13
|
||||
|
||||
### Added
|
||||
|
||||
- Security: add LLM-based security evaluation during skill publish.
|
||||
- Parsing: recognize `metadata.openclaw` frontmatter and evaluate all skill files for requirements.
|
||||
|
||||
### Changed
|
||||
|
||||
- Performance: lazy-load Monaco diff viewer on demand (thanks @alexjcm, #212).
|
||||
- Search: improve recall/ranking with lexical fallback and relevance prioritization.
|
||||
- Moderation UX: collapse OpenClaw analysis by default; update spacing and default reasoning model.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Skills: fix initial `/skills` sort wiring so first page respects selected sort/direction (thanks @bpk9, #92).
|
||||
- Search/UI: add embedding request timeout and align `/skills` toolbar + list width (thanks @GhadiSaab, #53).
|
||||
- Upload gate: handle GitHub API rate limits and optional authenticated lookup token (thanks @superlowburn, #246).
|
||||
- HTTP: remove `allowH2` from Undici agent to prevent `fetch failed` on Node.js 22+ (#245).
|
||||
- Tests: add root `undici` dev dependency for Node E2E imports (thanks @tanujbhaud, #255).
|
||||
- Downloads: add download rate limiting + per-IP/day dedupe + scheduled dedupe pruning; preserve moderation gating and deterministic zips (thanks @regenrek, #43).
|
||||
- VirusTotal: fix scan sync race conditions and retry behavior in scan/backfill paths.
|
||||
- Metadata: tolerate trailing commas in JSON metadata.
|
||||
- Auth: allow soft-deleted users to re-authenticate on fresh login, while keeping banned users blocked (thanks @tanujbhaud, #177).
|
||||
- Web: prevent horizontal overflow from long code blocks in skill pages (thanks @bewithgaurav, #183).
|
||||
|
||||
## 0.6.0 - 2026-02-10
|
||||
|
||||
### Added
|
||||
|
||||
- CLI/API: add `set-role` to change user roles (admin only).
|
||||
- Security: quarantine skill publishes with VirusTotal scans + UI (thanks @aleph8, #130).
|
||||
- Testing: add tests for badges, skillZip, uploadFiles expandDroppedItems, and ark schema error truncation.
|
||||
- Moderation: add ban reasons to API/CLI and show in management UI.
|
||||
|
||||
### Changed
|
||||
|
||||
- Coverage: track `convex/lib/skillZip.ts` in coverage reports.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web: show pending-scan skills to owners without 404 (thanks @orlyjamie, #136).
|
||||
- Users: backfill empty handles from name/email in ensure (thanks @adlai88, #158).
|
||||
- Web: update footer branding to OpenClaw (thanks @jontsai, #122).
|
||||
- Auth: restore soft-deleted users on reauth, block banned users (thanks @mkrokosz, #106).
|
||||
|
||||
## 0.5.0 - 2026-02-02
|
||||
|
||||
### Added
|
||||
|
||||
- Admin: ban users and delete owned skills from management console.
|
||||
- Moderation: auto-hide skills after 4 unique reports; per-user report cap; moderators can ban users.
|
||||
- Uploads: require GitHub accounts to be at least 7 days old for skill + soul publish/import.
|
||||
- CLI: add `inspect` to fetch skill metadata/files without installing.
|
||||
- CLI: add moderation commands for hide/unhide/delete and ban users.
|
||||
- Management: add filters for reported skills and users.
|
||||
|
||||
### Changed
|
||||
|
||||
- Deps: update dependencies to latest available versions.
|
||||
- Reporting: require reasons, show them in management console, warn about abuse bans.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Bans: batch hard-delete cleanup to avoid Convex read limits on large skills.
|
||||
|
||||
## 0.4.0 - 2026-01-30
|
||||
|
||||
### Added
|
||||
|
||||
- Web: show published skills on user profiles (thanks @njoylab, #20).
|
||||
- CLI: include ClawHub + Moltbot fallback skill roots for sync scans.
|
||||
- CLI: support OpenClaw configuration files (`OPENCLAW_CONFIG_PATH` / `OPENCLAW_STATE_DIR`).
|
||||
|
||||
### Changed
|
||||
|
||||
- Brand: rebrand to ClawHub and publish CLI as `clawhub` (legacy `clawdhub` supported).
|
||||
- Domain: default site/registry now `https://clawhub.ai`; `.well-known/clawhub.json` preferred.
|
||||
- Theme: persist theme under `clawhub-theme` (legacy key still read).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Registry: drop missing skills during search hydration (thanks @aaronn, #28).
|
||||
- CLI: use path-based skill metadata lookup for updates (thanks @daveonkels, #22).
|
||||
- Search: keep highlighted-only filtering and clamp vector candidates to Convex limits (thanks @aaronn, #30).
|
||||
|
||||
## 0.3.0 - 2026-01-19
|
||||
|
||||
### Added
|
||||
|
||||
- CLI: add `explore` command for latest updates, with limit clamping + tests/docs (thanks @jdrhyne, #14).
|
||||
- CLI: `explore --json` output + new sorts (`installs`, `installsAllTime`, `trending`) and limit up to 200.
|
||||
- API: `/api/v1/skills` supports installs + trending sorts (7-day installs).
|
||||
@ -10,39 +359,44 @@
|
||||
- Registry: trending leaderboard + daily stats backfill for installs-based sorts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web: keep search mode navigation and state in sync (thanks @NACC96, #12).
|
||||
|
||||
## 0.2.0 - 2026-01-13
|
||||
|
||||
### Added
|
||||
|
||||
- Web: dynamic OG image cards for skills (name, description, version).
|
||||
- CLI: auto-scan Clawdbot skill roots (per-agent workspaces, shared skills, extraDirs).
|
||||
- Web: import skills from public GitHub URLs (auto-detect `SKILL.md`, smart file selection, provenance).
|
||||
- Web/API: SoulHub (SOUL.md registry) with v1 endpoints and first-run auto-seed.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web: stabilize skill OG image generation on server runtimes.
|
||||
- Web: prevent skill OG text overflow outside the card.
|
||||
- Registry: make SoulHub auto-seed idempotent and non-user-owned.
|
||||
- Registry: keep GitHub backup state + publish backups intact (thanks @joshp123, #1).
|
||||
- CLI/Registry: restore fork lineage on sync + clamp bulk list queries (thanks @joshp123, #1).
|
||||
- CLI: default workdir falls back to Clawdbot workspace (override with `--workdir` / `CLAWDHUB_WORKDIR`).
|
||||
- CLI: default workdir falls back to Clawdbot workspace (override with `--workdir` / `CLAWHUB_WORKDIR`).
|
||||
|
||||
## 0.0.6 - 2026-01-07
|
||||
|
||||
|
||||
### Added
|
||||
|
||||
- API: v1 public REST endpoints with rate limits, raw file fetch, and OpenAPI spec.
|
||||
- Docs: `docs/api.md` and `DEPRECATIONS.md` for the v1 cutover plan.
|
||||
|
||||
### Changed
|
||||
|
||||
- CLI: publish now uses single multipart `POST /api/v1/skills`.
|
||||
- Registry: legacy `/api/*` + `/api/cli/*` marked for deprecation (kept for now).
|
||||
|
||||
## 0.0.5 - 2026-01-06
|
||||
|
||||
### Added
|
||||
- Telemetry: track installs via `clawdhub sync` (logged-in only), per root, with 120-day staleness.
|
||||
|
||||
- Telemetry: track installs via `clawhub sync` (logged-in only), per root, with 120-day staleness.
|
||||
- Skills: show current + all-time installs; sort by installs.
|
||||
- Profile: private "Installed" tab with JSON export + delete telemetry controls.
|
||||
- Docs: add `docs/telemetry.md` (what we track + how to opt out).
|
||||
@ -50,30 +404,36 @@
|
||||
- Web: dashboard for managing your published skills (thanks @dbhurley!).
|
||||
|
||||
### Changed
|
||||
- CLI: telemetry opt-out via `CLAWDHUB_DISABLE_TELEMETRY=1`.
|
||||
|
||||
- CLI: telemetry opt-out via `CLAWHUB_DISABLE_TELEMETRY=1`.
|
||||
- Web: move theme picker into mobile menu.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web: handle shorthand hex colors in diff theme (thanks @dbhurley!).
|
||||
|
||||
## 0.0.5 - 2026-01-06
|
||||
|
||||
### Added
|
||||
|
||||
- Maintenance: admin backfill to re-parse `SKILL.md` and repair stored summaries/parsed metadata.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI sync: ignore plural `skills.md` docs files when scanning for skills.
|
||||
- Registry: parse YAML frontmatter (incl multiline `description`) and accept YAML `metadata` objects.
|
||||
|
||||
## 0.0.4 - 2026-01-05
|
||||
|
||||
### Added
|
||||
|
||||
- Web: `/skills` list view with sorting (newest/downloads/stars/name) + quick filter.
|
||||
- Web: admin/moderator highlight toggle on skill detail.
|
||||
- Web: canonical skill URLs as `/<owner>/<slug>` (legacy `/skills/<slug>` redirects).
|
||||
- Web: upload auto-generates a changelog via OpenAI when left blank (marked as auto-generated).
|
||||
|
||||
### Fixed
|
||||
|
||||
- Web: skill detail shows a loading state instead of flashing "Skill not found".
|
||||
- Web: user profile shows avatar + loading state (no "User not found" flash).
|
||||
- Web: improved mobile responsiveness (nav menu, skill detail layout, install command overflow).
|
||||
@ -82,31 +442,37 @@
|
||||
- CLI: ignore legacy `auth.clawdhub.com` registry and prefer site discovery.
|
||||
|
||||
### Changed
|
||||
|
||||
- Web: homepage search now expands into full search mode with live results + highlighted toggle.
|
||||
- CLI: sync no longer prompts for changelog; registry auto-generates when blank.
|
||||
|
||||
## 0.0.3 - 2026-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- CLI sync: concurrency flag to limit registry checks.
|
||||
- Home: install command switcher (npm/pnpm/bun).
|
||||
|
||||
### Changed
|
||||
|
||||
- CLI sync: default `--concurrency` is now 4 (was 8).
|
||||
- CLI sync: replace boxed notes with plain output for long lists.
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI sync: wrap note output to avoid terminal overflow; cap list lengths.
|
||||
- CLI sync: label fallback scans as fallback locations.
|
||||
- CLI package: bundle schema internally (no external `clawdhub-schema` publish).
|
||||
- Repo: mark `clawdhub-schema` as private to prevent publishing.
|
||||
- CLI package: bundle schema internally (no external `clawhub-schema` publish).
|
||||
- Repo: mark `clawhub-schema` as private to prevent publishing.
|
||||
|
||||
## 0.0.2 - 2026-01-04
|
||||
|
||||
### Added
|
||||
|
||||
- CLI: delete/undelete commands for soft-deleted skills (owner/admin).
|
||||
|
||||
### Fixed
|
||||
|
||||
- CLI sync: dedupe duplicate slugs across scan roots; skip duplicates to avoid double-publish errors.
|
||||
- CLI sync: show parsing progress while hashing local skills.
|
||||
- CLI sync: prompt only actionable skills; preselect all by default; list synced separately; condensed synced summary when nothing to sync.
|
||||
@ -119,6 +485,7 @@
|
||||
## 0.0.1 - 2026-01-04
|
||||
|
||||
### Features
|
||||
|
||||
- CLI auth: login/logout/whoami; browser loopback auth; token storage; site/registry discovery; config overrides.
|
||||
- CLI workflow: search, install, update (single/all), list, publish, sync (scan workdir + legacy roots), dry-run, version bumping, tags.
|
||||
- Registry/API: skills + versions with semver; tags (latest + custom); changelog per version; SKILL.md frontmatter parsing; text-only validation; zip download; hash resolve; stats (downloads/stars/versions/comments).
|
||||
|
||||
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@ -0,0 +1,57 @@
|
||||
# ClawHub — Project Rules
|
||||
|
||||
## Convex Performance Rules
|
||||
|
||||
- For public listing/browse pages, use `ConvexHttpClient.query()` (one-shot fetch),
|
||||
not `useQuery`/`usePaginatedQuery` (reactive subscription). Reserve reactive
|
||||
queries for data the user needs to see update in real time.
|
||||
- Denormalize hot read paths into a single lightweight "digest" table. Every
|
||||
`ctx.db.get()` join adds a table to the reactive invalidation scope.
|
||||
- When a `skillSearchDigest` row is available, use `digestToOwnerInfo(digest)`
|
||||
to resolve owner data. NEVER call `ctx.db.get(ownerUserId)` when digest
|
||||
owner fields (`ownerHandle`, `ownerName`, `ownerDisplayName`, `ownerImage`)
|
||||
are already present. Reading from `users` adds the entire table to the
|
||||
reactive read set and wastes bandwidth.
|
||||
- Use `convex-helpers` Triggers to sync denormalized tables automatically.
|
||||
Always add change detection — skip the write if no fields actually changed.
|
||||
- Use compound indexes instead of JS filtering. If you're filtering docs after
|
||||
the query, you're scanning documents you'll throw away.
|
||||
- For search results scored by computed values (vector + lexical + popularity),
|
||||
fetch all results once and paginate client-side. Don't re-run the full search
|
||||
pipeline on "load more."
|
||||
- Backfills on reactively-subscribed tables need `delayMs` between batches.
|
||||
- Mutations that read >8 MB should use the Action → Query → Mutation pattern
|
||||
to split reads across transactions.
|
||||
|
||||
## Convex Conventions
|
||||
|
||||
- All mutations import from `convex/functions.ts` (not `convex/_generated/server`)
|
||||
to get trigger wrapping. Type imports still come from `convex/_generated/server`.
|
||||
- NEVER use `--typecheck=disable` on `npx convex deploy`.
|
||||
- Use `npx convex dev --once` to push functions once (not long-running watcher).
|
||||
|
||||
## Production Release
|
||||
|
||||
- Production deploys are manual-only. Merging to `main` does **not** deploy.
|
||||
- Start the GitHub Actions `Deploy` workflow from `main` with `gh workflow run deploy.yml --repo openclaw/clawhub --ref main`.
|
||||
- The workflow supports `full`, `backend`, and `frontend` targets.
|
||||
- `frontend` currently waits for the Vercel production deploy on the selected `main` SHA and then runs smoke checks. It does not trigger Vercel directly yet.
|
||||
- The workflow uses the `Production` environment for deploy secrets, but it does not wait for a separate approval.
|
||||
- Required prod secret: `CONVEX_DEPLOY_KEY` on the `Production` environment. Optional smoke secret: `PLAYWRIGHT_AUTH_STORAGE_STATE_JSON`.
|
||||
- CLI npm releases are manual-only and tag-based through `ClawHub CLI NPM Release`. Stable tags only: `vX.Y.Z`. Run a `preflight_only=true` pass first, then rerun with the same tag plus `preflight_run_id` for the real publish.
|
||||
- Real CLI publishes wait at `npm-release` and rely on npm trusted publishing for `openclaw/clawhub` + `clawhub-cli-npm-release.yml` + `npm-release`.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests use `._handler` to call mutation handlers directly with mock `db` objects.
|
||||
- Mock `db` objects MUST include `normalizeId: vi.fn()` for trigger wrapper compatibility.
|
||||
|
||||
<!-- convex-ai-start -->
|
||||
|
||||
This project uses [Convex](https://convex.dev) as its backend.
|
||||
|
||||
When working on Convex code, **always read `convex/_generated/ai/guidelines.md` first** for important guidelines on how to correctly use Convex APIs and patterns. The file contains rules that override what you may have learned about Convex from training data.
|
||||
|
||||
Convex agent skills for common tasks can be installed by running `npx convex ai-files install`.
|
||||
|
||||
<!-- convex-ai-end -->
|
||||
219
CONTRIBUTING.md
Normal file
219
CONTRIBUTING.md
Normal file
@ -0,0 +1,219 @@
|
||||
# Contributing to ClawHub
|
||||
|
||||
Welcome! ClawHub is the public skill registry for [OpenClaw](https://github.com/openclaw/openclaw). We appreciate bug fixes, documentation improvements, and feature contributions.
|
||||
|
||||
- **Questions?** Ask in [#clawhub on Discord](https://discord.gg/clawd).
|
||||
- **Bug fixes** — PRs are welcome.
|
||||
- **New features or architectural changes** — please start with a Discord conversation in #clawhub first so we can align on scope.
|
||||
|
||||
## Local Development Setup
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- [Bun](https://bun.sh/) (Convex CLI runs via `bunx`, no global install needed)
|
||||
- [Node.js](https://nodejs.org/) v18, 20, 22, or 24 (required by the local Convex backend; v25+ is not yet supported)
|
||||
|
||||
### Install and configure
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.local.example .env.local
|
||||
```
|
||||
|
||||
Edit `.env.local` with the following values for **local Convex**:
|
||||
|
||||
```bash
|
||||
# Frontend
|
||||
VITE_CONVEX_URL=http://127.0.0.1:3210
|
||||
VITE_CONVEX_SITE_URL=http://127.0.0.1:3210
|
||||
SITE_URL=http://localhost:3000
|
||||
|
||||
# Deployment used by `bunx convex dev`
|
||||
CONVEX_DEPLOYMENT=anonymous:anonymous-clawhub
|
||||
```
|
||||
|
||||
### GitHub OAuth App (for login)
|
||||
|
||||
1. Go to [github.com/settings/developers](https://github.com/settings/developers) and create a new OAuth App.
|
||||
2. Set **Homepage URL** to `http://localhost:3000`.
|
||||
3. Set **Authorization callback URL** to `http://127.0.0.1:3210/api/auth/callback/github`.
|
||||
4. Copy the Client ID and generate a Client Secret.
|
||||
|
||||
### Run the Convex backend
|
||||
|
||||
Start the local Convex backend first — other setup steps depend on it:
|
||||
|
||||
```bash
|
||||
bunx convex dev --typecheck=disable
|
||||
```
|
||||
|
||||
### Set backend environment variables
|
||||
|
||||
The Convex backend has its own env var store separate from `.env.local`. With the backend running, open a new terminal and set the required variables:
|
||||
|
||||
```bash
|
||||
bunx convex env set AUTH_GITHUB_ID <your-client-id>
|
||||
bunx convex env set AUTH_GITHUB_SECRET <your-client-secret>
|
||||
bunx convex env set SITE_URL http://localhost:3000
|
||||
```
|
||||
|
||||
### JWT keys (for Convex Auth)
|
||||
|
||||
With the backend still running, generate the signing keys:
|
||||
|
||||
```bash
|
||||
bunx @convex-dev/auth
|
||||
```
|
||||
|
||||
This sets `JWT_PRIVATE_KEY` and `JWKS` on the Convex backend and outputs values you can also save to `.env.local` for reference.
|
||||
|
||||
### Run the frontend
|
||||
|
||||
```bash
|
||||
bun run dev -- --port 3000
|
||||
```
|
||||
|
||||
Change the port if 3000 is already in use, and update `SITE_URL` in both `.env.local` and the Convex backend (`bunx convex env set SITE_URL ...`) to match.
|
||||
|
||||
### Seed the database
|
||||
|
||||
Populate sample data so the UI isn't empty:
|
||||
|
||||
```bash
|
||||
# 3 sample skills (padel, gohome, xuezh)
|
||||
bunx convex run --no-push devSeed:seedNixSkills
|
||||
|
||||
# 50 extra skills for pagination testing (optional)
|
||||
bunx convex run --no-push devSeedExtra:seedExtraSkillsInternal
|
||||
|
||||
# Refresh the cached skills count (required after seeding)
|
||||
bunx convex run --no-push statsMaintenance:updateGlobalStatsAction
|
||||
```
|
||||
|
||||
To reset and re-seed:
|
||||
|
||||
```bash
|
||||
bunx convex run --no-push devSeed:seedNixSkills '{"reset": true}'
|
||||
```
|
||||
|
||||
### Optional environment variables
|
||||
|
||||
These features degrade gracefully without their keys:
|
||||
|
||||
| Variable | Purpose |
|
||||
| ------------------------------------------------------------------------- | --------------------------------------------------------- |
|
||||
| `OPENAI_API_KEY` | Embeddings and vector search (falls back to zero vectors) |
|
||||
| `VT_API_KEY` | VirusTotal malware scanning |
|
||||
| `DISCORD_WEBHOOK_URL` | Discord notifications |
|
||||
| `GITHUB_APP_ID` / `GITHUB_APP_PRIVATE_KEY` / `GITHUB_APP_INSTALLATION_ID` | GitHub backup sync |
|
||||
|
||||
## CLI Development
|
||||
|
||||
The CLI source lives in [`packages/clawhub/`](packages/clawhub/). Both `clawhub` and `clawdhub` are registered as bin aliases.
|
||||
|
||||
To test the CLI against your local instance:
|
||||
|
||||
```bash
|
||||
CLAWHUB_REGISTRY=http://127.0.0.1:3210 CLAWHUB_SITE=http://localhost:3000 clawhub search "padel"
|
||||
```
|
||||
|
||||
Use the package-local verification contract when working on the CLI:
|
||||
|
||||
```bash
|
||||
bun run --cwd packages/clawhub test
|
||||
bun run --cwd packages/clawhub verify:build
|
||||
bun run --cwd packages/clawhub test:artifact
|
||||
bun run --cwd packages/clawhub verify
|
||||
```
|
||||
|
||||
`bun test packages/clawhub/` is not the supported workflow. Source tests and built-artifact smoke tests are intentionally split.
|
||||
|
||||
Manual smoke tests are documented in [`specs/manual-testing.md`](specs/manual-testing.md).
|
||||
|
||||
## Skill & Soul Publishing
|
||||
|
||||
- Skill format reference: [`docs/skill-format.md`](docs/skill-format.md)
|
||||
- Soul format reference: [`docs/soul-format.md`](docs/soul-format.md)
|
||||
- End-to-end walkthrough (search, install, publish, sync): [`docs/quickstart.md`](docs/quickstart.md)
|
||||
|
||||
Quick publish:
|
||||
|
||||
```bash
|
||||
clawhub publish <path-to-skill-directory>
|
||||
```
|
||||
|
||||
## Before Submitting a PR
|
||||
|
||||
```bash
|
||||
bun run format:check # oxfmt
|
||||
bun run lint # oxlint
|
||||
bun run deadcode:ci # Knip files/deps/exports
|
||||
bun run test # Vitest (80% coverage threshold)
|
||||
bun run build # Vite + Nitro
|
||||
bun run --cwd packages/clawhub verify
|
||||
```
|
||||
|
||||
These are the same checks that run in CI (`.github/workflows/ci.yml`).
|
||||
|
||||
### Blacksmith Testbox checks
|
||||
|
||||
Maintainers with Blacksmith access can run the same checks in a warmed Testbox
|
||||
instead of spending local CPU:
|
||||
|
||||
```bash
|
||||
export CLAWHUB_TESTBOX=1
|
||||
blacksmith testbox warmup ci-check-testbox.yml --ref main --idle-timeout 90
|
||||
bun run testbox:claim -- --id <tbx_id>
|
||||
bun run testbox:sanity -- --id <tbx_id>
|
||||
bun run testbox:run -- --id <tbx_id> -- bun run lint
|
||||
bun run testbox:run -- --id <tbx_id> -- bun run test
|
||||
bun run testbox:run -- --id <tbx_id> -- bun run build
|
||||
```
|
||||
|
||||
Use the `tbx_...` id from the current warmup output. The wrapper refuses ids
|
||||
that are missing the local SSH key or were claimed by a different checkout.
|
||||
Use `CLAWHUB_LOCAL_CHECK_MODE=throttled` or `CLAWHUB_LOCAL_CHECK_MODE=full` as
|
||||
the explicit local escape hatch when you intentionally want laptop-side proof.
|
||||
If Blacksmith auth/org access is missing, report that instead of falling back
|
||||
to a broad local gate that can bog down a dev machine.
|
||||
For the initial bootstrap only, the Testbox workflow must land on `main` before
|
||||
`blacksmith testbox warmup ci-check-testbox.yml --ref <branch>` can dispatch it.
|
||||
|
||||
**PR guidelines:**
|
||||
|
||||
- Keep PRs focused — one concern per PR.
|
||||
- Use [Conventional Commits](https://www.conventionalcommits.org/): `feat:`, `fix:`, `chore:`, `docs:`, etc.
|
||||
- Include test commands and screenshots for UI changes.
|
||||
- Write a clear description of what changed and why.
|
||||
|
||||
## AI-Generated Code
|
||||
|
||||
AI-assisted contributions are welcome. When submitting AI-generated or AI-assisted code:
|
||||
|
||||
- Note it in the PR description.
|
||||
- Describe the level of testing you applied.
|
||||
- Include prompts if useful for reviewers.
|
||||
- Confirm that you understand and can maintain the code.
|
||||
|
||||
## Security Reporting
|
||||
|
||||
Report vulnerabilities to **security@openclaw.ai** with:
|
||||
|
||||
- Severity assessment
|
||||
- Technical reproduction steps
|
||||
- Suggested remediation
|
||||
|
||||
See [`docs/security.md`](docs/security.md) for moderation and upload gating details.
|
||||
|
||||
## Reading Order for New Contributors
|
||||
|
||||
1. This file (local setup)
|
||||
2. [`docs/clawhub.md`](docs/clawhub.md) — public registry overview
|
||||
3. [`docs/quickstart.md`](docs/quickstart.md) — end-to-end workflows
|
||||
4. [`docs/architecture.md`](docs/architecture.md) — system design
|
||||
5. [`docs/skill-format.md`](docs/skill-format.md) — skill structure
|
||||
6. [`docs/cli.md`](docs/cli.md) — CLI reference
|
||||
7. [`docs/http-api.md`](docs/http-api.md) — HTTP endpoints
|
||||
8. [`docs/auth.md`](docs/auth.md) — authentication
|
||||
9. [`docs/deploy.md`](docs/deploy.md) — deployment
|
||||
10. [`docs/troubleshooting.md`](docs/troubleshooting.md) — common issues
|
||||
362
DESIGN.md
Normal file
362
DESIGN.md
Normal file
@ -0,0 +1,362 @@
|
||||
# ClawHub Design System
|
||||
|
||||
This document outlines the design rules, patterns, and guidelines for the ClawHub platform to ensure consistency, accessibility, and maintainability across all components.
|
||||
|
||||
---
|
||||
|
||||
## Color System
|
||||
|
||||
### Brand Palette (OpenClaw)
|
||||
|
||||
ClawHub uses a strict **3-5 color palette** based on the OpenClaw brand:
|
||||
|
||||
| Token | Light Mode | Dark Mode | Usage |
|
||||
| --------------- | ---------- | --------- | ----------------------------------------------- |
|
||||
| `--accent` | `#dc2626` | `#dc2626` | Primary actions, interactive elements, emphasis |
|
||||
| `--accent-deep` | `#b91c1c` | `#ef4444` | Hover states, secondary emphasis |
|
||||
| `--ink` | `#0a0a0a` | `#fafafa` | Primary text |
|
||||
| `--ink-soft` | `#525252` | `#a1a1a1` | Secondary text, descriptions |
|
||||
| `--surface` | `#ffffff` | `#121212` | Card backgrounds, elevated surfaces |
|
||||
| `--bg` | `#fafafa` | `#0a0a0a` | Page background |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Never exceed 5 colors** without explicit design approval
|
||||
2. **Never use purple/violet prominently** unless explicitly requested
|
||||
3. **Always override text color** when changing background color to ensure contrast
|
||||
4. **Use semantic tokens** (`--accent`, `--ink`, `--surface`) instead of raw colors
|
||||
|
||||
---
|
||||
|
||||
## Typography
|
||||
|
||||
### Font Stack
|
||||
|
||||
```css
|
||||
--font-sans: "Geist", system-ui, sans-serif;
|
||||
--font-mono: "Geist Mono", monospace;
|
||||
--font-display: "Geist", system-ui, sans-serif;
|
||||
```
|
||||
|
||||
### Scale
|
||||
|
||||
| Token | Size | Usage |
|
||||
| ----------- | --------------- | ------------------------ |
|
||||
| `--fs-xs` | 0.75rem (12px) | Labels, badges, metadata |
|
||||
| `--fs-sm` | 0.875rem (14px) | Body text, descriptions |
|
||||
| `--fs-base` | 1rem (16px) | Default body text |
|
||||
| `--fs-md` | 1.125rem (18px) | Subheadings |
|
||||
| `--fs-lg` | 1.25rem (20px) | Section titles |
|
||||
| `--fs-xl` | 1.5rem (24px) | Page headings |
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Maximum 2 font families** per page
|
||||
2. **Line height 1.4-1.6** for body text (use `leading-relaxed`)
|
||||
3. **Never use decorative fonts** for body text
|
||||
4. **Minimum font size: 14px** for readability
|
||||
5. Use `text-balance` or `text-pretty` for titles
|
||||
|
||||
---
|
||||
|
||||
## Layout
|
||||
|
||||
### Method Priority
|
||||
|
||||
Use this hierarchy for layout decisions:
|
||||
|
||||
1. **Flexbox** - Default for most layouts
|
||||
2. **CSS Grid** - Only for complex 2D layouts (cards, galleries)
|
||||
3. **Never use floats** or absolute positioning unless absolutely necessary
|
||||
|
||||
### Spacing Scale
|
||||
|
||||
```css
|
||||
--space-1: 0.25rem /* 4px */ --space-2: 0.5rem /* 8px */ --space-3: 0.75rem /* 12px */
|
||||
--space-4: 1rem /* 16px */ --space-5: 1.5rem /* 24px */ --space-6: 2rem /* 32px */;
|
||||
```
|
||||
|
||||
### Grid Patterns
|
||||
|
||||
#### Auto-fit Grid (Recommended for Cards)
|
||||
|
||||
```css
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
```
|
||||
|
||||
- Automatically adjusts columns based on container width
|
||||
- Prevents orphan items on partial rows
|
||||
- Maintains consistent card widths
|
||||
|
||||
#### Fixed Grid (When exact columns needed)
|
||||
|
||||
```css
|
||||
/* 3-column at desktop, 2 at tablet, 1 at mobile */
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
|
||||
@media (max-width: 860px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 520px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
```
|
||||
|
||||
### Container Widths
|
||||
|
||||
| Size | Max Width | Usage |
|
||||
| ------- | ----------------------- | ----------------------- |
|
||||
| Default | `--page-max` (1200px) | Standard pages |
|
||||
| Narrow | `--page-narrow` (720px) | Reading content, forms |
|
||||
| Wide | Full width | Dashboards, data tables |
|
||||
|
||||
---
|
||||
|
||||
## Components
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
padding: var(--space-4);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-md);
|
||||
background: var(--surface);
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Always use `display: flex; flex-direction: column;` for consistent height
|
||||
- Add `flex: 1` to content area for equal-height cards in grids
|
||||
- Include hover state with `border-color` and subtle `box-shadow`
|
||||
|
||||
### Buttons
|
||||
|
||||
| Variant | Usage |
|
||||
| ------------- | ------------------------------------- |
|
||||
| `primary` | Main actions (Submit, Save, Download) |
|
||||
| `secondary` | Alternative actions |
|
||||
| `ghost` | Tertiary actions, navigation |
|
||||
| `destructive` | Delete, remove, dangerous actions |
|
||||
|
||||
**Rules:**
|
||||
|
||||
- Always include visible focus state
|
||||
- Minimum touch target: 44x44px on mobile
|
||||
- Include `aria-label` when icon-only
|
||||
|
||||
### Form Controls
|
||||
|
||||
- Labels above inputs (not inline)
|
||||
- Error states use `--status-error-fg`
|
||||
- Focus rings use `--accent` with 0.2 opacity
|
||||
- Minimum input height: 40px
|
||||
|
||||
---
|
||||
|
||||
## Responsive Breakpoints
|
||||
|
||||
```css
|
||||
/* Mobile first - base styles for mobile */
|
||||
|
||||
@media (min-width: 520px) {
|
||||
/* Small tablets, large phones */
|
||||
}
|
||||
|
||||
@media (min-width: 640px) {
|
||||
/* Tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 860px) {
|
||||
/* Small desktops, landscape tablets */
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
/* Desktops */
|
||||
}
|
||||
|
||||
@media (min-width: 1280px) {
|
||||
/* Large desktops */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Mobile-first approach** - Base styles target mobile
|
||||
2. **Progressive enhancement** - Add complexity as viewport increases
|
||||
3. **Test intermediate breakpoints** - Avoid jarring layout jumps
|
||||
4. **Never hide critical content** on mobile
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
### Color Contrast
|
||||
|
||||
- Normal text: Minimum 4.5:1 ratio
|
||||
- Large text (18px+): Minimum 3:1 ratio
|
||||
- Interactive elements: Minimum 3:1 ratio
|
||||
|
||||
### Focus States
|
||||
|
||||
```css
|
||||
:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
```
|
||||
|
||||
### Screen Readers
|
||||
|
||||
- Use `sr-only` class for visually hidden but accessible text
|
||||
- Always include `alt` text for images (empty `alt=""` for decorative)
|
||||
- Use semantic HTML elements (`main`, `nav`, `article`, `section`)
|
||||
- Proper heading hierarchy (h1 > h2 > h3, no skipping)
|
||||
|
||||
### Motion
|
||||
|
||||
```css
|
||||
/* Respect user preference */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
* {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Animation
|
||||
|
||||
### Timing
|
||||
|
||||
```css
|
||||
--transition-fast: 150ms;
|
||||
--transition-base: 200ms;
|
||||
--transition-slow: 300ms;
|
||||
```
|
||||
|
||||
### Easing
|
||||
|
||||
- Use `ease` or `ease-out` for most transitions
|
||||
- Use `ease-in-out` for enter/exit animations
|
||||
- Never use `linear` except for continuous animations
|
||||
|
||||
### Rules
|
||||
|
||||
1. **Subtle by default** - Avoid flashy animations
|
||||
2. **Purpose-driven** - Animation should provide feedback
|
||||
3. **Respect preferences** - Support `prefers-reduced-motion`
|
||||
4. **Performance** - Use `transform` and `opacity` only
|
||||
|
||||
---
|
||||
|
||||
## Icons
|
||||
|
||||
### Usage
|
||||
|
||||
- Use Lucide icons consistently
|
||||
- Standard sizes: 14px, 16px, 20px, 24px
|
||||
- Include `aria-hidden="true"` for decorative icons
|
||||
- Never use emojis as icons
|
||||
|
||||
### Placement
|
||||
|
||||
- Left of labels in buttons and navigation
|
||||
- Right of labels for external links or dropdowns
|
||||
- Centered when used alone with `aria-label`
|
||||
|
||||
---
|
||||
|
||||
## Dark Mode
|
||||
|
||||
### Implementation
|
||||
|
||||
```css
|
||||
[data-theme="dark"] {
|
||||
/* Dark mode overrides */
|
||||
}
|
||||
```
|
||||
|
||||
### Rules
|
||||
|
||||
1. Never use pure white (`#ffffff`) on dark backgrounds
|
||||
2. Reduce shadow intensity in dark mode
|
||||
3. Adjust image brightness if needed
|
||||
4. Test contrast ratios in both modes
|
||||
|
||||
---
|
||||
|
||||
## Performance
|
||||
|
||||
### CSS
|
||||
|
||||
1. Use CSS custom properties for theming
|
||||
2. Avoid deeply nested selectors (max 3 levels)
|
||||
3. Use `will-change` sparingly
|
||||
4. Prefer `transform` over `top/left` for animations
|
||||
|
||||
### Images
|
||||
|
||||
1. Always specify `width` and `height` attributes
|
||||
2. Use `loading="lazy"` for below-fold images
|
||||
3. Use appropriate formats (WebP with fallbacks)
|
||||
4. Include placeholder or skeleton states
|
||||
|
||||
---
|
||||
|
||||
## Code Style
|
||||
|
||||
### CSS Class Naming
|
||||
|
||||
```css
|
||||
/* Component */
|
||||
.component-name {
|
||||
}
|
||||
|
||||
/* Component modifier */
|
||||
.component-name.variant {
|
||||
}
|
||||
|
||||
/* Component child */
|
||||
.component-name-child {
|
||||
}
|
||||
|
||||
/* State */
|
||||
.component-name.is-active {
|
||||
}
|
||||
.component-name[data-state="open"] {
|
||||
}
|
||||
```
|
||||
|
||||
### File Organization
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
ui/ # Primitive components (Button, Input, Card)
|
||||
layout/ # Layout components (Container, Header)
|
||||
styles.css # Global styles and design tokens
|
||||
lib/
|
||||
theme.ts # Theme utilities
|
||||
preferences.ts # User preference management
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Checklist
|
||||
|
||||
Before shipping any UI changes, verify:
|
||||
|
||||
- [ ] Color contrast meets WCAG AA standards
|
||||
- [ ] Focus states are visible
|
||||
- [ ] Layout works at all breakpoints
|
||||
- [ ] Animations respect `prefers-reduced-motion`
|
||||
- [ ] Text is readable at default browser zoom
|
||||
- [ ] Interactive elements have 44px minimum touch target
|
||||
- [ ] Semantic HTML is used appropriately
|
||||
- [ ] Dark mode has been tested
|
||||
147
README.md
147
README.md
@ -1,33 +1,48 @@
|
||||
# ClawdHub
|
||||
<p align="center">
|
||||
<img src="public/clawd-logo.png" alt="ClawHub" width="120">
|
||||
</p>
|
||||
|
||||
<h1 align="center">ClawHub</h1>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/clawdbot/clawdhub/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/clawdbot/clawdhub/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://github.com/openclaw/clawhub/actions/workflows/ci.yml?branch=main"><img src="https://img.shields.io/github/actions/workflow/status/openclaw/clawhub/ci.yml?branch=main&style=for-the-badge" alt="CI status"></a>
|
||||
<a href="https://discord.gg/clawd"><img src="https://img.shields.io/discord/1456350064065904867?label=Discord&logo=discord&logoColor=white&color=5865F2&style=for-the-badge" alt="Discord"></a>
|
||||
<a href="LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg?style=for-the-badge" alt="MIT License"></a>
|
||||
</p>
|
||||
|
||||
ClawdHub is the **public skill registry for Clawdbot**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
It’s designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
ClawHub is the **public skill registry for OpenClaw**: publish, version, and search text-based agent skills (a `SKILL.md` plus supporting files).
|
||||
It's designed for fast browsing + a CLI-friendly API, with moderation hooks and vector search.
|
||||
It also now exposes a native **OpenClaw package catalog** for code plugins and bundle plugins.
|
||||
|
||||
onlycrabs.ai is the **SOUL.md registry**: publish and share system lore the same way you publish skills.
|
||||
|
||||
Live: `https://clawdhub.com`
|
||||
onlycrabs.ai: `https://onlycrabs.ai`
|
||||
<p align="center">
|
||||
<a href="https://clawhub.ai">ClawHub</a> ·
|
||||
<a href="https://onlycrabs.ai">onlycrabs.ai</a> ·
|
||||
<a href="VISION.md">Vision</a> ·
|
||||
<a href="docs/clawhub.md">Docs</a> ·
|
||||
<a href="CONTRIBUTING.md">Contributing</a> ·
|
||||
<a href="https://discord.gg/clawd">Discord</a>
|
||||
</p>
|
||||
|
||||
## What you can do
|
||||
## What you can do with it
|
||||
|
||||
- Browse skills + render their `SKILL.md`.
|
||||
- Publish new skill versions with changelogs + tags (including `latest`).
|
||||
- Rename an owned skill without breaking old links or installs.
|
||||
- Merge duplicate owned skills into one canonical slug.
|
||||
- Browse souls + render their `SOUL.md`.
|
||||
- Publish new soul versions with changelogs + tags.
|
||||
- Search via embeddings (vector index) instead of brittle keywords.
|
||||
- Star + comment; admins/mods can curate and approve skills.
|
||||
- Browse OpenClaw packages with family/trust/capability metadata.
|
||||
- Publish native code plugins and bundle plugins through `/packages` APIs and CLI flows.
|
||||
|
||||
## onlycrabs.ai (SOUL.md registry)
|
||||
|
||||
- Entry point is host-based: `onlycrabs.ai`.
|
||||
- On the onlycrabs.ai host, the home page and nav default to souls.
|
||||
- On ClawdHub, souls live under `/souls`.
|
||||
- On ClawHub, souls live under `/souls`.
|
||||
- Soul bundles only accept `SOUL.md` for now (no extra files).
|
||||
|
||||
## How it works (high level)
|
||||
@ -35,50 +50,76 @@ onlycrabs.ai: `https://onlycrabs.ai`
|
||||
- Web app: TanStack Start (React, Vite/Nitro).
|
||||
- Backend: Convex (DB + file storage + HTTP actions) + Convex Auth (GitHub OAuth).
|
||||
- Search: OpenAI embeddings (`text-embedding-3-small`) + Convex vector search.
|
||||
- API schema + routes: `packages/schema` (`clawdhub-schema`).
|
||||
- API schema + routes: `packages/schema` (`clawhub-schema`).
|
||||
|
||||
## CLI
|
||||
|
||||
Common CLI flows:
|
||||
|
||||
- Auth: `clawhub login`, `clawhub whoami`
|
||||
- Discover: `clawhub search ...`, `clawhub explore`
|
||||
- Browse unified catalog (skills + plugins): `clawhub package explore`, `clawhub package inspect <name>`
|
||||
- Manage local installs: `clawhub install <slug>`, `clawhub uninstall <slug>`, `clawhub list`, `clawhub update --all`
|
||||
- Inspect without installing: `clawhub inspect <slug>`
|
||||
- Publish/sync skills: `clawhub skill publish <path>`, `clawhub sync`
|
||||
- Publish plugins: `clawhub package publish <source>`
|
||||
- Code-plugin manifests must include `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion`; see [`docs/cli.md`](docs/cli.md) for a minimal example.
|
||||
- Canonicalize owned skills: `clawhub skill rename <slug> <new-slug>`, `clawhub skill merge <source> <target>`
|
||||
|
||||
Docs: [`docs/quickstart.md`](docs/quickstart.md), [`docs/cli.md`](docs/cli.md).
|
||||
|
||||
### Removal permissions
|
||||
|
||||
- `clawhub uninstall <slug>` only removes a local install on your machine.
|
||||
- Uploaded registry skills use soft-delete/restore (`clawhub delete <slug>` / `clawhub undelete <slug>` or API equivalents).
|
||||
- Soft-delete/restore is allowed for the skill owner, moderators, and admins.
|
||||
- Hard delete is admin-only (management tools / ban flows).
|
||||
- Owner rename keeps the old slug as a redirect alias.
|
||||
- Owner merge hides the source listing and redirects the old slug to the canonical target.
|
||||
|
||||
## Telemetry
|
||||
|
||||
ClawdHub tracks minimal **install telemetry** (to compute install counts) when you run `clawdhub sync` while logged in.
|
||||
ClawHub tracks minimal **install telemetry** (to compute install counts) when you run `clawhub sync` while logged in.
|
||||
Disable via:
|
||||
|
||||
```bash
|
||||
export CLAWDHUB_DISABLE_TELEMETRY=1
|
||||
export CLAWHUB_DISABLE_TELEMETRY=1
|
||||
```
|
||||
|
||||
Details: `docs/telemetry.md`.
|
||||
Details: [`docs/telemetry.md`](docs/telemetry.md).
|
||||
|
||||
## Repo layout
|
||||
|
||||
- `src/` — TanStack Start app (routes, components, styles).
|
||||
- `convex/` — schema + queries/mutations/actions + HTTP API routes.
|
||||
- `packages/schema/` — shared API types/routes for the CLI and app.
|
||||
- `docs/spec.md` — product + implementation spec (good first read).
|
||||
- [`docs/`](docs/README.md) — publishable ClawHub public/operator docs for users, publishers, API clients, and deploy operators.
|
||||
- [`specs/`](specs/README.md) — product specs, plans, regression notes, and design history.
|
||||
- [`specs/spec.md`](specs/spec.md) — product + implementation spec (good first read for maintainers).
|
||||
|
||||
## Local dev
|
||||
|
||||
Prereqs: Bun + Convex CLI.
|
||||
Prereqs: [Bun](https://bun.sh/) (Convex runs via `bunx`, no global install needed).
|
||||
|
||||
```bash
|
||||
bun install
|
||||
cp .env.local.example .env.local
|
||||
# edit .env.local — see CONTRIBUTING.md for local Convex values
|
||||
|
||||
# terminal A: web app
|
||||
# terminal A: local Convex backend
|
||||
bunx convex dev
|
||||
|
||||
# terminal B: web app (port 3000)
|
||||
bun run dev
|
||||
|
||||
# terminal B: Convex dev deployment
|
||||
bunx convex dev
|
||||
# detached/Codex worktree preview
|
||||
bun run dev:worktree
|
||||
|
||||
# seed sample data
|
||||
bun run seed:dev
|
||||
```
|
||||
|
||||
## Auth (GitHub OAuth) setup
|
||||
|
||||
Create a GitHub OAuth App, set `AUTH_GITHUB_ID` / `AUTH_GITHUB_SECRET`, then:
|
||||
|
||||
```bash
|
||||
bunx auth --deployment-name <deployment> --web-server-url http://localhost:3000
|
||||
```
|
||||
|
||||
This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for your local `.env.local`.
|
||||
For full setup instructions (env vars, GitHub OAuth, JWT keys, database seeding), see [CONTRIBUTING.md](CONTRIBUTING.md).
|
||||
|
||||
## Environment
|
||||
|
||||
@ -95,7 +136,7 @@ This writes `JWT_PRIVATE_KEY` + `JWKS` to the deployment and prints values for y
|
||||
|
||||
## Nix plugins (nixmode skills)
|
||||
|
||||
ClawdHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
ClawHub can store a nix-clawdbot plugin pointer in SKILL frontmatter so the registry knows which
|
||||
Nix package bundle to install. A nix plugin is different from a regular skill pack: it bundles the
|
||||
skill pack, the CLI binary, and its config flags/requirements together.
|
||||
|
||||
@ -105,7 +146,17 @@ Add this to `SKILL.md`:
|
||||
---
|
||||
name: peekaboo
|
||||
description: Capture and automate macOS UI with the Peekaboo CLI.
|
||||
metadata: {"clawdbot":{"nix":{"plugin":"github:clawdbot/nix-steipete-tools?dir=tools/peekaboo","systems":["aarch64-darwin"]}}}
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
{
|
||||
"nix":
|
||||
{
|
||||
"plugin": "github:clawdbot/nix-steipete-tools?dir=tools/peekaboo",
|
||||
"systems": ["aarch64-darwin"],
|
||||
},
|
||||
},
|
||||
}
|
||||
---
|
||||
```
|
||||
|
||||
@ -123,7 +174,18 @@ You can also declare config requirements + an example snippet:
|
||||
---
|
||||
name: padel
|
||||
description: Check padel court availability and manage bookings via Playtomic.
|
||||
metadata: {"clawdbot":{"config":{"requiredEnv":["PADEL_AUTH_FILE"],"stateDirs":[".config/padel"],"example":"config = { env = { PADEL_AUTH_FILE = \\\"/run/agenix/padel-auth\\\"; }; };"}}}
|
||||
metadata:
|
||||
{
|
||||
"clawdbot":
|
||||
{
|
||||
"config":
|
||||
{
|
||||
"requiredEnv": ["PADEL_AUTH_FILE"],
|
||||
"stateDirs": [".config/padel"],
|
||||
"example": "config = { env = { PADEL_AUTH_FILE = \\\"/run/agenix/padel-auth\\\"; }; };",
|
||||
},
|
||||
},
|
||||
}
|
||||
---
|
||||
```
|
||||
|
||||
@ -133,11 +195,34 @@ To show CLI help (recommended for nix plugins), include the `cli --help` output:
|
||||
---
|
||||
name: padel
|
||||
description: Check padel court availability and manage bookings via Playtomic.
|
||||
metadata: {"clawdbot":{"cliHelp":"padel --help\\nUsage: padel [command]\\n"}}
|
||||
metadata: { "clawdbot": { "cliHelp": "padel --help\\nUsage: padel [command]\\n" } }
|
||||
---
|
||||
```
|
||||
|
||||
`metadata.clawdbot` is preferred, but `metadata.clawdis` is accepted as an alias for compatibility.
|
||||
`metadata.clawdbot` is preferred, but `metadata.clawdis` and `metadata.openclaw` are accepted as aliases.
|
||||
|
||||
## Skill metadata
|
||||
|
||||
Skills declare their runtime requirements (env vars, binaries, install specs) in the `SKILL.md` frontmatter. ClawHub's security analysis checks these declarations against actual skill behavior.
|
||||
|
||||
Full reference: [`docs/skill-format.md`](docs/skill-format.md#frontmatter-metadata)
|
||||
|
||||
Quick example:
|
||||
|
||||
```yaml
|
||||
---
|
||||
name: my-skill
|
||||
description: Does a thing with an API.
|
||||
metadata:
|
||||
openclaw:
|
||||
requires:
|
||||
env:
|
||||
- MY_API_KEY
|
||||
bins:
|
||||
- curl
|
||||
primaryEnv: MY_API_KEY
|
||||
---
|
||||
```
|
||||
|
||||
## Scripts
|
||||
|
||||
|
||||
98
VISION.md
Normal file
98
VISION.md
Normal file
@ -0,0 +1,98 @@
|
||||
## OpenClaw Vision
|
||||
|
||||
OpenClaw is the AI that actually does things.
|
||||
It runs on your devices, in your channels, with your rules.
|
||||
|
||||
This document explains the current state and direction of the project.
|
||||
We are still early, so iteration is fast.
|
||||
Project overview and developer docs: [`README.md`](README.md)
|
||||
|
||||
OpenClaw started as my personal playground to learn AI and build something genuinely useful:
|
||||
an assistant that can run real tasks on my computer.
|
||||
It evolved through several names and shells: Warelay -> Clawdbot -> Moltbot -> OpenClaw.
|
||||
|
||||
The goal? A personal assistant that's easy to use, supports a wide range of platforms, and respects your privacy and security.
|
||||
|
||||
The current focus is:
|
||||
|
||||
Priority:
|
||||
|
||||
- Security and safe defaults
|
||||
- Bug fixes and stability
|
||||
- Setup reliability and first-run UX
|
||||
|
||||
Next priorities:
|
||||
|
||||
- Supporting all major model providers
|
||||
- Improving support for major messaging channels (and adding a few high-demand ones)
|
||||
- Performance and test infrastructure
|
||||
- Better computer-use and agent harness capabilities
|
||||
- Ergonomics across CLI and web frontend
|
||||
- Companion apps on macOS, iOS, Android, Windows, and Linux
|
||||
|
||||
## Security
|
||||
|
||||
Security in OpenClaw is a deliberate tradeoff: strong defaults without killing capability.
|
||||
The goal is to stay powerful for real work while making risky paths explicit and operator-controlled.
|
||||
|
||||
Canonical security policy and reporting:
|
||||
|
||||
- https://github.com/openclaw/openclaw/blob/main/SECURITY.md
|
||||
|
||||
We prioritize secure defaults, but we also expose clear knobs for trusted high-power workflows.
|
||||
|
||||
## Plugins & Memory
|
||||
|
||||
OpenClaw has an extensive plugin API.
|
||||
Core stays lean; optional capability should usually ship as plugins.
|
||||
|
||||
Preferred plugin path is npm package distribution plus local extension loading for development.
|
||||
If you build a plugin, please host and maintain it in your own repository.
|
||||
The bar for adding optional plugins to core is intentionally high.
|
||||
|
||||
Memory is a special plugin slot where only one memory plugin can be active at a time.
|
||||
Today we ship multiple memory options; over time we plan to converge on one recommended default path.
|
||||
|
||||
### Skills
|
||||
|
||||
We still ship some bundled skills for baseline UX.
|
||||
New skills should be published to ClawHub first (`clawhub.ai`), not added to core by default.
|
||||
Core skill additions should be rare and require a strong product or security reason.
|
||||
|
||||
### MCP Support
|
||||
|
||||
OpenClaw supports MCP through `mcporter`: https://github.com/steipete/mcporter
|
||||
|
||||
This keeps MCP integration flexible and decoupled from core runtime:
|
||||
|
||||
- add or change MCP servers without restarting the gateway
|
||||
- keep core tool/context surface lean
|
||||
- reduce MCP churn impact on core stability and security
|
||||
|
||||
For now, we prefer this bridge model over building first-class MCP runtime into core.
|
||||
If there is an MCP server or feature `mcporter` does not support yet, please open an issue there.
|
||||
|
||||
### Setup
|
||||
|
||||
OpenClaw is currently terminal-first by design.
|
||||
This keeps setup explicit: users see docs, auth, permissions, and security posture up front.
|
||||
|
||||
Long term, we want easier onboarding flows as hardening matures.
|
||||
We do not want convenience wrappers that hide critical security decisions from users.
|
||||
|
||||
### Why TypeScript?
|
||||
|
||||
OpenClaw is primarily an orchestration system: prompts, tools, protocols, and integrations.
|
||||
TypeScript was chosen to keep OpenClaw hackable by default.
|
||||
It is widely known, fast to iterate in, and easy to read, modify, and extend.
|
||||
|
||||
## What We Will Not Merge (For Now)
|
||||
|
||||
- New core skills when they can live on ClawHub
|
||||
- Commercial service integrations that do not clearly fit the model-provider category
|
||||
- Wrapper channels around already supported channels without a clear capability or security gap
|
||||
- First-class MCP runtime in core when `mcporter` already provides the integration path
|
||||
- Heavy orchestration layers that duplicate existing agent and tool infrastructure
|
||||
|
||||
This list is a roadmap guardrail, not a law of physics.
|
||||
Strong user demand and strong technical rationale can change it.
|
||||
41
biome.json
41
biome.json
@ -1,41 +0,0 @@
|
||||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.3.11/schema.json",
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!**/.cta.json",
|
||||
"!**/.vscode",
|
||||
"!**/node_modules",
|
||||
"!**/dist",
|
||||
"!**/.output",
|
||||
"!**/coverage",
|
||||
"!**/convex/_generated",
|
||||
"!**/test-results",
|
||||
"!**/src/routeTree.gen.ts",
|
||||
"!**/.tanstack",
|
||||
"!**/public",
|
||||
"!**/.devenv",
|
||||
"!**/.devenv"
|
||||
]
|
||||
},
|
||||
"assist": { "actions": { "source": { "organizeImports": "on" } } },
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "space",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"recommended": true
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"formatter": {
|
||||
"quoteStyle": "single",
|
||||
"semicolons": "asNeeded",
|
||||
"trailingCommas": "all"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
clawdhub
9
clawdhub
@ -3,9 +3,10 @@ import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const distCliUrl = new URL('./packages/clawdhub/dist/cli.js', import.meta.url)
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawdhub/src/', import.meta.url))
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawhub/src/', import.meta.url))
|
||||
|
||||
const shouldBuild = await (async () => {
|
||||
if (!existsSync(distCliPath)) return true
|
||||
@ -19,7 +20,8 @@ const shouldBuild = await (async () => {
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bunx', 'tsc', '-p', 'packages/clawdhub/tsconfig.json'], {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
@ -34,6 +36,7 @@ async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
|
||||
49
clawhub
Executable file
49
clawhub
Executable file
@ -0,0 +1,49 @@
|
||||
#!/usr/bin/env bun
|
||||
import { existsSync } from 'node:fs'
|
||||
import { stat } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const packageRootPath = fileURLToPath(new URL('./packages/clawhub/', import.meta.url))
|
||||
const distCliUrl = new URL('./packages/clawhub/dist/cli.js', import.meta.url)
|
||||
const distCliPath = fileURLToPath(distCliUrl)
|
||||
const srcRootPath = fileURLToPath(new URL('./packages/clawhub/src/', import.meta.url))
|
||||
|
||||
const shouldBuild = await (async () => {
|
||||
if (!existsSync(distCliPath)) return true
|
||||
try {
|
||||
const dist = await stat(distCliPath)
|
||||
const latestSrcMtime = await getLatestMtime(srcRootPath)
|
||||
return latestSrcMtime > dist.mtimeMs
|
||||
} catch {
|
||||
return true
|
||||
}
|
||||
})()
|
||||
|
||||
if (shouldBuild) {
|
||||
const proc = Bun.spawn(['bun', 'run', 'build'], {
|
||||
cwd: packageRootPath,
|
||||
stdin: 'inherit',
|
||||
stdout: 'inherit',
|
||||
stderr: 'inherit',
|
||||
})
|
||||
const code = await proc.exited
|
||||
if (code !== 0) process.exit(code)
|
||||
}
|
||||
|
||||
await import(distCliUrl.href)
|
||||
|
||||
async function getLatestMtime(root: string) {
|
||||
let latest = 0
|
||||
const glob = new Bun.Glob('**/*.ts')
|
||||
for await (const rel of glob.scan({ cwd: root, onlyFiles: true })) {
|
||||
if (rel.endsWith('.test.ts')) continue
|
||||
const path = `${root}${root.endsWith('/') ? '' : '/'}${rel}`
|
||||
try {
|
||||
const entry = await stat(path)
|
||||
latest = Math.max(latest, entry.mtimeMs)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": false,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "src/styles.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
14
convex/_generated/ai/ai-files.state.json
Normal file
14
convex/_generated/ai/ai-files.state.json
Normal file
@ -0,0 +1,14 @@
|
||||
{
|
||||
"guidelinesHash": "62d72acb9afcc18f658d88dd772f34b5b1da5fa60ef0402e57a784d97c458e57",
|
||||
"agentsMdSectionHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
|
||||
"claudeMdHash": "bbf30bd25ceea0aefd279d62e1cb2b4c207fcb712b69adf26f3d02b296ffc7b2",
|
||||
"agentSkillsSha": "d0fa8085af313029add5740f67198aa42ca60c8d",
|
||||
"installedSkillNames": [
|
||||
"convex",
|
||||
"convex-create-component",
|
||||
"convex-migration-helper",
|
||||
"convex-performance-audit",
|
||||
"convex-quickstart",
|
||||
"convex-setup-auth"
|
||||
]
|
||||
}
|
||||
365
convex/_generated/ai/guidelines.md
Normal file
365
convex/_generated/ai/guidelines.md
Normal file
@ -0,0 +1,365 @@
|
||||
# Convex guidelines
|
||||
|
||||
## Function guidelines
|
||||
|
||||
### Http endpoint syntax
|
||||
|
||||
- HTTP endpoints are defined in `convex/http.ts` and require an `httpAction` decorator. For example:
|
||||
|
||||
```typescript
|
||||
import { httpRouter } from "convex/server";
|
||||
import { httpAction } from "./_generated/server";
|
||||
const http = httpRouter();
|
||||
http.route({
|
||||
path: "/echo",
|
||||
method: "POST",
|
||||
handler: httpAction(async (ctx, req) => {
|
||||
const body = await req.bytes();
|
||||
return new Response(body, { status: 200 });
|
||||
}),
|
||||
});
|
||||
```
|
||||
|
||||
- HTTP endpoints are always registered at the exact path you specify in the `path` field. For example, if you specify `/api/someRoute`, the endpoint will be registered at `/api/someRoute`.
|
||||
|
||||
### Validators
|
||||
|
||||
- Below is an example of an array validator:
|
||||
|
||||
```typescript
|
||||
import { mutation } from "./_generated/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default mutation({
|
||||
args: {
|
||||
simpleArray: v.array(v.union(v.string(), v.number())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
//...
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Below is an example of a schema with validators that codify a discriminated union type:
|
||||
|
||||
```typescript
|
||||
import { defineSchema, defineTable } from "convex/server";
|
||||
import { v } from "convex/values";
|
||||
|
||||
export default defineSchema({
|
||||
results: defineTable(
|
||||
v.union(
|
||||
v.object({
|
||||
kind: v.literal("error"),
|
||||
errorMessage: v.string(),
|
||||
}),
|
||||
v.object({
|
||||
kind: v.literal("success"),
|
||||
value: v.number(),
|
||||
}),
|
||||
),
|
||||
),
|
||||
});
|
||||
```
|
||||
|
||||
- Here are the valid Convex types along with their respective validators:
|
||||
Convex Type | TS/JS type | Example Usage | Validator for argument validation and schemas | Notes |
|
||||
| ----------- | ------------| -----------------------| -----------------------------------------------| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
|
||||
| Id | string | `doc._id` | `v.id(tableName)` | |
|
||||
| Null | null | `null` | `v.null()` | JavaScript's `undefined` is not a valid Convex value. Functions the return `undefined` or do not return will return `null` when called from a client. Use `null` instead. |
|
||||
| Int64 | bigint | `3n` | `v.int64()` | Int64s only support BigInts between -2^63 and 2^63-1. Convex supports `bigint`s in most modern browsers. |
|
||||
| Float64 | number | `3.1` | `v.number()` | Convex supports all IEEE-754 double-precision floating point numbers (such as NaNs). Inf and NaN are JSON serialized as strings. |
|
||||
| Boolean | boolean | `true` | `v.boolean()` |
|
||||
| String | string | `"abc"` | `v.string()` | Strings are stored as UTF-8 and must be valid Unicode sequences. Strings must be smaller than the 1MB total size limit when encoded as UTF-8. |
|
||||
| Bytes | ArrayBuffer | `new ArrayBuffer(8)` | `v.bytes()` | Convex supports first class bytestrings, passed in as `ArrayBuffer`s. Bytestrings must be smaller than the 1MB total size limit for Convex types. |
|
||||
| Array | Array | `[1, 3.2, "abc"]` | `v.array(values)` | Arrays can have at most 8192 values. |
|
||||
| Object | Object | `{a: "abc"}` | `v.object({property: value})` | Convex only supports "plain old JavaScript objects" (objects that do not have a custom prototype). Objects can have at most 1024 entries. Field names must be nonempty and not start with "$" or "_". |
|
||||
| Record | Record | `{"a": "1", "b": "2"}` | `v.record(keys, values)` | Records are objects at runtime, but can have dynamic keys. Keys must be only ASCII characters, nonempty, and not start with "$" or "\_". |
|
||||
|
||||
### Function registration
|
||||
|
||||
- Use `internalQuery`, `internalMutation`, and `internalAction` to register internal functions. These functions are private and aren't part of an app's API. They can only be called by other Convex functions. These functions are always imported from `./_generated/server`.
|
||||
- Use `query`, `mutation`, and `action` to register public functions. These functions are part of the public API and are exposed to the public Internet. Do NOT use `query`, `mutation`, or `action` to register sensitive internal functions that should be kept private.
|
||||
- You CANNOT register a function through the `api` or `internal` objects.
|
||||
- ALWAYS include argument validators for all Convex functions. This includes all of `query`, `internalQuery`, `mutation`, `internalMutation`, `action`, and `internalAction`.
|
||||
|
||||
### Function calling
|
||||
|
||||
- Use `ctx.runQuery` to call a query from a query, mutation, or action.
|
||||
- Use `ctx.runMutation` to call a mutation from a mutation or action.
|
||||
- Use `ctx.runAction` to call an action from an action.
|
||||
- ONLY call an action from another action if you need to cross runtimes (e.g. from V8 to Node). Otherwise, pull out the shared code into a helper async function and call that directly instead.
|
||||
- Try to use as few calls from actions to queries and mutations as possible. Queries and mutations are transactions, so splitting logic up into multiple calls introduces the risk of race conditions.
|
||||
- All of these calls take in a `FunctionReference`. Do NOT try to pass the callee function directly into one of these calls.
|
||||
- When using `ctx.runQuery`, `ctx.runMutation`, or `ctx.runAction` to call a function in the same file, specify a type annotation on the return value to work around TypeScript circularity limitations. For example,
|
||||
|
||||
```
|
||||
export const f = query({
|
||||
args: { name: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return "Hello " + args.name;
|
||||
},
|
||||
});
|
||||
|
||||
export const g = query({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
const result: string = await ctx.runQuery(api.example.f, { name: "Bob" });
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Function references
|
||||
|
||||
- Use the `api` object defined by the framework in `convex/_generated/api.ts` to call public functions registered with `query`, `mutation`, or `action`.
|
||||
- Use the `internal` object defined by the framework in `convex/_generated/api.ts` to call internal (or private) functions registered with `internalQuery`, `internalMutation`, or `internalAction`.
|
||||
- Convex uses file-based routing, so a public function defined in `convex/example.ts` named `f` has a function reference of `api.example.f`.
|
||||
- A private function defined in `convex/example.ts` named `g` has a function reference of `internal.example.g`.
|
||||
- Functions can also registered within directories nested within the `convex/` folder. For example, a public function `h` defined in `convex/messages/access.ts` has a function reference of `api.messages.access.h`.
|
||||
|
||||
### Pagination
|
||||
|
||||
- Define pagination using the following syntax:
|
||||
|
||||
```ts
|
||||
import { v } from "convex/values";
|
||||
import { query, mutation } from "./_generated/server";
|
||||
import { paginationOptsValidator } from "convex/server";
|
||||
export const listWithExtraArg = query({
|
||||
args: { paginationOpts: paginationOptsValidator, author: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
return await ctx.db
|
||||
.query("messages")
|
||||
.withIndex("by_author", (q) => q.eq("author", args.author))
|
||||
.order("desc")
|
||||
.paginate(args.paginationOpts);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
Note: `paginationOpts` is an object with the following properties:
|
||||
|
||||
- `numItems`: the maximum number of documents to return (the validator is `v.number()`)
|
||||
- `cursor`: the cursor to use to fetch the next page of documents (the validator is `v.union(v.string(), v.null())`)
|
||||
- A query that ends in `.paginate()` returns an object that has the following properties:
|
||||
- page (contains an array of documents that you fetches)
|
||||
- isDone (a boolean that represents whether or not this is the last page of documents)
|
||||
- continueCursor (a string that represents the cursor to use to fetch the next page of documents)
|
||||
|
||||
## Schema guidelines
|
||||
|
||||
- Always define your schema in `convex/schema.ts`.
|
||||
- Always import the schema definition functions from `convex/server`.
|
||||
- System fields are automatically added to all documents and are prefixed with an underscore. The two system fields that are automatically added to all documents are `_creationTime` which has the validator `v.number()` and `_id` which has the validator `v.id(tableName)`.
|
||||
- Always include all index fields in the index name. For example, if an index is defined as `["field1", "field2"]`, the index name should be "by_field1_and_field2".
|
||||
- Index fields must be queried in the same order they are defined. If you want to be able to query by "field1" then "field2" and by "field2" then "field1", you must create separate indexes.
|
||||
- Do not store unbounded lists as an array field inside a document (e.g. `v.array(v.object({...}))`). As the array grows it will hit the 1MB document size limit, and every update rewrites the entire document. Instead, create a separate table for the child items with a foreign key back to the parent.
|
||||
- Separate high-churn operational data (e.g. heartbeats, online status, typing indicators) from stable profile data. Storing frequently updated fields on a shared document forces every write to contend with reads of the entire document. Instead, create a dedicated table for the high-churn data with a foreign key back to the parent record.
|
||||
|
||||
## Authentication guidelines
|
||||
|
||||
- Convex supports JWT-based authentication through `convex/auth.config.ts`. ALWAYS create this file when using authentication. Without it, `ctx.auth.getUserIdentity()` will always return `null`.
|
||||
- Example `convex/auth.config.ts`:
|
||||
|
||||
```typescript
|
||||
export default {
|
||||
providers: [
|
||||
{
|
||||
domain: "https://your-auth-provider.com",
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
The `domain` must be the issuer URL of the JWT provider. Convex fetches `{domain}/.well-known/openid-configuration` to discover the JWKS endpoint. The `applicationID` is checked against the JWT `aud` (audience) claim.
|
||||
|
||||
- Use `ctx.auth.getUserIdentity()` to get the authenticated user's identity in any query, mutation, or action. This returns `null` if the user is not authenticated, or a `UserIdentity` object with fields like `subject`, `issuer`, `name`, `email`, etc. The `subject` field is the unique user identifier.
|
||||
- In Convex `UserIdentity`, `tokenIdentifier` is guaranteed and is the canonical stable identifier for the authenticated identity. For any auth-linked database lookup or ownership check, prefer `identity.tokenIdentifier` over `identity.subject`. Do NOT use `identity.subject` alone as a global identity key.
|
||||
- NEVER accept a `userId` or any user identifier as a function argument for authorization purposes. Always derive the user identity server-side via `ctx.auth.getUserIdentity()`.
|
||||
- When using an external auth provider with Convex on the client, use `ConvexProviderWithAuth` instead of `ConvexProvider`:
|
||||
|
||||
```tsx
|
||||
import { ConvexProviderWithAuth, ConvexReactClient } from "convex/react";
|
||||
|
||||
const convex = new ConvexReactClient(process.env.NEXT_PUBLIC_CONVEX_URL!);
|
||||
|
||||
function App({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<ConvexProviderWithAuth client={convex} useAuth={useYourAuthHook}>
|
||||
{children}
|
||||
</ConvexProviderWithAuth>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
The `useAuth` prop must return `{ isLoading, isAuthenticated, fetchAccessToken }`. Do NOT use plain `ConvexProvider` when authentication is needed — it will not send tokens with requests.
|
||||
|
||||
## Typescript guidelines
|
||||
|
||||
- You can use the helper typescript type `Id` imported from './\_generated/dataModel' to get the type of the id for a given table. For example if there is a table called 'users' you can use `Id<'users'>` to get the type of the id for that table.
|
||||
- Use `Doc<"tableName">` from `./_generated/dataModel` to get the full document type for a table.
|
||||
- Use `QueryCtx`, `MutationCtx`, `ActionCtx` from `./_generated/server` for typing function contexts. NEVER use `any` for ctx parameters — always use the proper context type.
|
||||
- If you need to define a `Record` make sure that you correctly provide the type of the key and value in the type. For example a validator `v.record(v.id('users'), v.string())` would have the type `Record<Id<'users'>, string>`. Below is an example of using `Record` with an `Id` type in a query:
|
||||
|
||||
```ts
|
||||
import { query } from "./_generated/server";
|
||||
import { Doc, Id } from "./_generated/dataModel";
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { userIds: v.array(v.id("users")) },
|
||||
handler: async (ctx, args) => {
|
||||
const idToUsername: Record<Id<"users">, string> = {};
|
||||
for (const userId of args.userIds) {
|
||||
const user = await ctx.db.get("users", userId);
|
||||
if (user) {
|
||||
idToUsername[user._id] = user.username;
|
||||
}
|
||||
}
|
||||
|
||||
return idToUsername;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Be strict with types, particularly around id's of documents. For example, if a function takes in an id for a document in the 'users' table, take in `Id<'users'>` rather than `string`.
|
||||
|
||||
## Full text search guidelines
|
||||
|
||||
- A query for "10 messages in channel '#general' that best match the query 'hello hi' in their body" would look like:
|
||||
|
||||
const messages = await ctx.db
|
||||
.query("messages")
|
||||
.withSearchIndex("search_body", (q) =>
|
||||
q.search("body", "hello hi").eq("channel", "#general"),
|
||||
)
|
||||
.take(10);
|
||||
|
||||
## Query guidelines
|
||||
|
||||
- Do NOT use `filter` in queries. Instead, define an index in the schema and use `withIndex` instead.
|
||||
- If the user does not explicitly tell you to return all results from a query you should ALWAYS return a bounded collection instead. So that is instead of using `.collect()` you should use `.take()` or paginate on database queries. This prevents future performance issues when tables grow in an unbounded way.
|
||||
- Never use `.collect().length` to count rows. Convex has no built-in count operator, so if you need a count that stays efficient at scale, maintain a denormalized counter in a separate document and update it in your mutations.
|
||||
- Convex queries do NOT support `.delete()`. If you need to delete all documents matching a query, use `.take(n)` to read them in batches, iterate over each batch calling `ctx.db.delete(row._id)`, and repeat until no more results are returned.
|
||||
- Convex mutations are transactions with limits on the number of documents read and written. If a mutation needs to process more documents than fit in a single transaction (e.g. bulk deletion on a large table), process a batch with `.take(n)` and then call `ctx.scheduler.runAfter(0, api.myModule.myMutation, args)` to schedule itself to continue. This way each invocation stays within transaction limits.
|
||||
- Use `.unique()` to get a single document from a query. This method will throw an error if there are multiple documents that match the query.
|
||||
- When using async iteration, don't use `.collect()` or `.take(n)` on the result of a query. Instead, use the `for await (const row of query)` syntax.
|
||||
|
||||
### Ordering
|
||||
|
||||
- By default Convex always returns documents in ascending `_creationTime` order.
|
||||
- You can use `.order('asc')` or `.order('desc')` to pick whether a query is in ascending or descending order. If the order isn't specified, it defaults to ascending.
|
||||
- Document queries that use indexes will be ordered based on the columns in the index and can avoid slow table scans.
|
||||
|
||||
## Mutation guidelines
|
||||
|
||||
- Use `ctx.db.replace` to fully replace an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.replace('tasks', taskId, { name: 'Buy milk', completed: false })`
|
||||
- Use `ctx.db.patch` to shallow merge updates into an existing document. This method will throw an error if the document does not exist. Syntax: `await ctx.db.patch('tasks', taskId, { completed: true })`
|
||||
|
||||
## Action guidelines
|
||||
|
||||
- Always add `"use node";` to the top of files containing actions that use Node.js built-in modules.
|
||||
- Never add `"use node";` to a file that also exports queries or mutations. Only actions can run in the Node.js runtime; queries and mutations must stay in the default Convex runtime. If you need Node.js built-ins alongside queries or mutations, put the action in a separate file.
|
||||
- `fetch()` is available in the default Convex runtime. You do NOT need `"use node";` just to use `fetch()`.
|
||||
- Never use `ctx.db` inside of an action. Actions don't have access to the database.
|
||||
- Below is an example of the syntax for an action:
|
||||
|
||||
```ts
|
||||
import { action } from "./_generated/server";
|
||||
|
||||
export const exampleAction = action({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
console.log("This action does not return anything");
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
## Scheduling guidelines
|
||||
|
||||
### Cron guidelines
|
||||
|
||||
- Only use the `crons.interval` or `crons.cron` methods to schedule cron jobs. Do NOT use the `crons.hourly`, `crons.daily`, or `crons.weekly` helpers.
|
||||
- Both cron methods take in a FunctionReference. Do NOT try to pass the function directly into one of these methods.
|
||||
- Define crons by declaring the top-level `crons` object, calling some methods on it, and then exporting it as default. For example,
|
||||
|
||||
```ts
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
import { internalAction } from "./_generated/server";
|
||||
|
||||
const empty = internalAction({
|
||||
args: {},
|
||||
handler: async (ctx, args) => {
|
||||
console.log("empty");
|
||||
},
|
||||
});
|
||||
|
||||
const crons = cronJobs();
|
||||
|
||||
// Run `internal.crons.empty` every two hours.
|
||||
crons.interval("delete inactive users", { hours: 2 }, internal.crons.empty, {});
|
||||
|
||||
export default crons;
|
||||
```
|
||||
|
||||
- You can register Convex functions within `crons.ts` just like any other file.
|
||||
- If a cron calls an internal function, always import the `internal` object from '\_generated/api', even if the internal function is registered in the same file.
|
||||
|
||||
## Testing guidelines
|
||||
|
||||
- Use `convex-test` with `vitest` and `@edge-runtime/vm` to test Convex functions. Always install the latest versions of these packages. Configure vitest with `environment: "edge-runtime"` in `vitest.config.ts`.
|
||||
|
||||
Test files go inside the `convex/` directory. You must pass a module map from `import.meta.glob` to `convexTest`:
|
||||
|
||||
```typescript
|
||||
/// <reference types="vite/client" />
|
||||
import { convexTest } from "convex-test";
|
||||
import { expect, test } from "vitest";
|
||||
import { api } from "./_generated/api";
|
||||
import schema from "./schema";
|
||||
|
||||
const modules = import.meta.glob("./**/*.ts");
|
||||
|
||||
test("some behavior", async () => {
|
||||
const t = convexTest(schema, modules);
|
||||
await t.mutation(api.messages.send, { body: "Hi!", author: "Sarah" });
|
||||
const messages = await t.query(api.messages.list);
|
||||
expect(messages).toMatchObject([{ body: "Hi!", author: "Sarah" }]);
|
||||
});
|
||||
```
|
||||
|
||||
The `modules` argument is required so convex-test can discover and load function files. The `/// <reference types="vite/client" />` directive is needed for TypeScript to recognize `import.meta.glob`.
|
||||
|
||||
## File storage guidelines
|
||||
|
||||
- The `ctx.storage.getUrl()` method returns a signed URL for a given file. It returns `null` if the file doesn't exist.
|
||||
- Do NOT use the deprecated `ctx.storage.getMetadata` call for loading a file's metadata.
|
||||
|
||||
Instead, query the `_storage` system table. For example, you can use `ctx.db.system.get` to get an `Id<"_storage">`.
|
||||
|
||||
```
|
||||
import { query } from "./_generated/server";
|
||||
import { Id } from "./_generated/dataModel";
|
||||
|
||||
type FileMetadata = {
|
||||
_id: Id<"_storage">;
|
||||
_creationTime: number;
|
||||
contentType?: string;
|
||||
sha256: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export const exampleQuery = query({
|
||||
args: { fileId: v.id("_storage") },
|
||||
handler: async (ctx, args) => {
|
||||
const metadata: FileMetadata | null = await ctx.db.system.get("_storage", args.fileId);
|
||||
console.log(metadata);
|
||||
return null;
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
- Convex storage stores items as `Blob` objects. You must convert all items to/from a `Blob` when using Convex storage.
|
||||
144
convex/_generated/api.d.ts
vendored
144
convex/_generated/api.d.ts
vendored
@ -8,42 +8,113 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import type * as appMeta from "../appMeta.js";
|
||||
import type * as auth from "../auth.js";
|
||||
import type * as commentModeration from "../commentModeration.js";
|
||||
import type * as comments from "../comments.js";
|
||||
import type * as crons from "../crons.js";
|
||||
import type * as depRegistryScan from "../depRegistryScan.js";
|
||||
import type * as devSeed from "../devSeed.js";
|
||||
import type * as devSeedExtra from "../devSeedExtra.js";
|
||||
import type * as downloads from "../downloads.js";
|
||||
import type * as functions from "../functions.js";
|
||||
import type * as githubBackups from "../githubBackups.js";
|
||||
import type * as githubBackupsNode from "../githubBackupsNode.js";
|
||||
import type * as githubIdentity from "../githubIdentity.js";
|
||||
import type * as githubImport from "../githubImport.js";
|
||||
import type * as githubRestore from "../githubRestore.js";
|
||||
import type * as githubRestoreMutations from "../githubRestoreMutations.js";
|
||||
import type * as githubSoulBackups from "../githubSoulBackups.js";
|
||||
import type * as githubSoulBackupsNode from "../githubSoulBackupsNode.js";
|
||||
import type * as http from "../http.js";
|
||||
import type * as httpApi from "../httpApi.js";
|
||||
import type * as httpApiV1 from "../httpApiV1.js";
|
||||
import type * as httpApiV1_docsSessionV1 from "../httpApiV1/docsSessionV1.js";
|
||||
import type * as httpApiV1_packagesV1 from "../httpApiV1/packagesV1.js";
|
||||
import type * as httpApiV1_shared from "../httpApiV1/shared.js";
|
||||
import type * as httpApiV1_skillsV1 from "../httpApiV1/skillsV1.js";
|
||||
import type * as httpApiV1_soulsV1 from "../httpApiV1/soulsV1.js";
|
||||
import type * as httpApiV1_starsV1 from "../httpApiV1/starsV1.js";
|
||||
import type * as httpApiV1_transfersV1 from "../httpApiV1/transfersV1.js";
|
||||
import type * as httpApiV1_usersV1 from "../httpApiV1/usersV1.js";
|
||||
import type * as httpApiV1_whoamiV1 from "../httpApiV1/whoamiV1.js";
|
||||
import type * as httpPreflight from "../httpPreflight.js";
|
||||
import type * as leaderboards from "../leaderboards.js";
|
||||
import type * as lib_access from "../lib/access.js";
|
||||
import type * as lib_apiTokenAuth from "../lib/apiTokenAuth.js";
|
||||
import type * as lib_artifactModeration from "../lib/artifactModeration.js";
|
||||
import type * as lib_badges from "../lib/badges.js";
|
||||
import type * as lib_batching from "../lib/batching.js";
|
||||
import type * as lib_changelog from "../lib/changelog.js";
|
||||
import type * as lib_clawpack from "../lib/clawpack.js";
|
||||
import type * as lib_commentScamPrompt from "../lib/commentScamPrompt.js";
|
||||
import type * as lib_contentTypes from "../lib/contentTypes.js";
|
||||
import type * as lib_depRegistryScan from "../lib/depRegistryScan.js";
|
||||
import type * as lib_embeddingVisibility from "../lib/embeddingVisibility.js";
|
||||
import type * as lib_embeddings from "../lib/embeddings.js";
|
||||
import type * as lib_githubAccount from "../lib/githubAccount.js";
|
||||
import type * as lib_githubActionsOidc from "../lib/githubActionsOidc.js";
|
||||
import type * as lib_githubBackup from "../lib/githubBackup.js";
|
||||
import type * as lib_githubIdentity from "../lib/githubIdentity.js";
|
||||
import type * as lib_githubImport from "../lib/githubImport.js";
|
||||
import type * as lib_githubProfileSync from "../lib/githubProfileSync.js";
|
||||
import type * as lib_githubRestoreHelpers from "../lib/githubRestoreHelpers.js";
|
||||
import type * as lib_githubSoulBackup from "../lib/githubSoulBackup.js";
|
||||
import type * as lib_globalStats from "../lib/globalStats.js";
|
||||
import type * as lib_httpHeaders from "../lib/httpHeaders.js";
|
||||
import type * as lib_httpRateLimit from "../lib/httpRateLimit.js";
|
||||
import type * as lib_httpUtils from "../lib/httpUtils.js";
|
||||
import type * as lib_leaderboards from "../lib/leaderboards.js";
|
||||
import type * as lib_manualOverrides from "../lib/manualOverrides.js";
|
||||
import type * as lib_moderation from "../lib/moderation.js";
|
||||
import type * as lib_moderationEngine from "../lib/moderationEngine.js";
|
||||
import type * as lib_moderationReasonCodes from "../lib/moderationReasonCodes.js";
|
||||
import type * as lib_openaiResponse from "../lib/openaiResponse.js";
|
||||
import type * as lib_packageRegistry from "../lib/packageRegistry.js";
|
||||
import type * as lib_packageSearchDigest from "../lib/packageSearchDigest.js";
|
||||
import type * as lib_packageSecurity from "../lib/packageSecurity.js";
|
||||
import type * as lib_public from "../lib/public.js";
|
||||
import type * as lib_publishLimits from "../lib/publishLimits.js";
|
||||
import type * as lib_publishers from "../lib/publishers.js";
|
||||
import type * as lib_reporting from "../lib/reporting.js";
|
||||
import type * as lib_reservedHandles from "../lib/reservedHandles.js";
|
||||
import type * as lib_reservedSlugs from "../lib/reservedSlugs.js";
|
||||
import type * as lib_searchText from "../lib/searchText.js";
|
||||
import type * as lib_securityPrompt from "../lib/securityPrompt.js";
|
||||
import type * as lib_skillBackfill from "../lib/skillBackfill.js";
|
||||
import type * as lib_skillCapabilityTags from "../lib/skillCapabilityTags.js";
|
||||
import type * as lib_skillPublish from "../lib/skillPublish.js";
|
||||
import type * as lib_skillQuality from "../lib/skillQuality.js";
|
||||
import type * as lib_skillSafety from "../lib/skillSafety.js";
|
||||
import type * as lib_skillSearchDigest from "../lib/skillSearchDigest.js";
|
||||
import type * as lib_skillStats from "../lib/skillStats.js";
|
||||
import type * as lib_skillSummary from "../lib/skillSummary.js";
|
||||
import type * as lib_skillZip from "../lib/skillZip.js";
|
||||
import type * as lib_skills from "../lib/skills.js";
|
||||
import type * as lib_soulChangelog from "../lib/soulChangelog.js";
|
||||
import type * as lib_soulPublish from "../lib/soulPublish.js";
|
||||
import type * as lib_staticPublishScan from "../lib/staticPublishScan.js";
|
||||
import type * as lib_tokens from "../lib/tokens.js";
|
||||
import type * as lib_userSearch from "../lib/userSearch.js";
|
||||
import type * as lib_userSkillStats from "../lib/userSkillStats.js";
|
||||
import type * as lib_webhooks from "../lib/webhooks.js";
|
||||
import type * as llmEval from "../llmEval.js";
|
||||
import type * as maintenance from "../maintenance.js";
|
||||
import type * as model_packages_rescans from "../model/packages/rescans.js";
|
||||
import type * as model_rescans_policy from "../model/rescans/policy.js";
|
||||
import type * as model_skills_rescans from "../model/skills/rescans.js";
|
||||
import type * as packagePublishTokens from "../packagePublishTokens.js";
|
||||
import type * as packages from "../packages.js";
|
||||
import type * as publishers from "../publishers.js";
|
||||
import type * as rateLimits from "../rateLimits.js";
|
||||
import type * as rescanRequests from "../rescanRequests.js";
|
||||
import type * as search from "../search.js";
|
||||
import type * as securityDataset from "../securityDataset.js";
|
||||
import type * as securityDatasetNode from "../securityDatasetNode.js";
|
||||
import type * as seed from "../seed.js";
|
||||
import type * as seedSouls from "../seedSouls.js";
|
||||
import type * as skillStatEvents from "../skillStatEvents.js";
|
||||
import type * as skillTransfers from "../skillTransfers.js";
|
||||
import type * as skills from "../skills.js";
|
||||
import type * as soulComments from "../soulComments.js";
|
||||
import type * as soulDownloads from "../soulDownloads.js";
|
||||
@ -55,6 +126,7 @@ import type * as telemetry from "../telemetry.js";
|
||||
import type * as tokens from "../tokens.js";
|
||||
import type * as uploads from "../uploads.js";
|
||||
import type * as users from "../users.js";
|
||||
import type * as vt from "../vt.js";
|
||||
import type * as webhooks from "../webhooks.js";
|
||||
|
||||
import type {
|
||||
@ -64,42 +136,113 @@ import type {
|
||||
} from "convex/server";
|
||||
|
||||
declare const fullApi: ApiFromModules<{
|
||||
appMeta: typeof appMeta;
|
||||
auth: typeof auth;
|
||||
commentModeration: typeof commentModeration;
|
||||
comments: typeof comments;
|
||||
crons: typeof crons;
|
||||
depRegistryScan: typeof depRegistryScan;
|
||||
devSeed: typeof devSeed;
|
||||
devSeedExtra: typeof devSeedExtra;
|
||||
downloads: typeof downloads;
|
||||
functions: typeof functions;
|
||||
githubBackups: typeof githubBackups;
|
||||
githubBackupsNode: typeof githubBackupsNode;
|
||||
githubIdentity: typeof githubIdentity;
|
||||
githubImport: typeof githubImport;
|
||||
githubRestore: typeof githubRestore;
|
||||
githubRestoreMutations: typeof githubRestoreMutations;
|
||||
githubSoulBackups: typeof githubSoulBackups;
|
||||
githubSoulBackupsNode: typeof githubSoulBackupsNode;
|
||||
http: typeof http;
|
||||
httpApi: typeof httpApi;
|
||||
httpApiV1: typeof httpApiV1;
|
||||
"httpApiV1/docsSessionV1": typeof httpApiV1_docsSessionV1;
|
||||
"httpApiV1/packagesV1": typeof httpApiV1_packagesV1;
|
||||
"httpApiV1/shared": typeof httpApiV1_shared;
|
||||
"httpApiV1/skillsV1": typeof httpApiV1_skillsV1;
|
||||
"httpApiV1/soulsV1": typeof httpApiV1_soulsV1;
|
||||
"httpApiV1/starsV1": typeof httpApiV1_starsV1;
|
||||
"httpApiV1/transfersV1": typeof httpApiV1_transfersV1;
|
||||
"httpApiV1/usersV1": typeof httpApiV1_usersV1;
|
||||
"httpApiV1/whoamiV1": typeof httpApiV1_whoamiV1;
|
||||
httpPreflight: typeof httpPreflight;
|
||||
leaderboards: typeof leaderboards;
|
||||
"lib/access": typeof lib_access;
|
||||
"lib/apiTokenAuth": typeof lib_apiTokenAuth;
|
||||
"lib/artifactModeration": typeof lib_artifactModeration;
|
||||
"lib/badges": typeof lib_badges;
|
||||
"lib/batching": typeof lib_batching;
|
||||
"lib/changelog": typeof lib_changelog;
|
||||
"lib/clawpack": typeof lib_clawpack;
|
||||
"lib/commentScamPrompt": typeof lib_commentScamPrompt;
|
||||
"lib/contentTypes": typeof lib_contentTypes;
|
||||
"lib/depRegistryScan": typeof lib_depRegistryScan;
|
||||
"lib/embeddingVisibility": typeof lib_embeddingVisibility;
|
||||
"lib/embeddings": typeof lib_embeddings;
|
||||
"lib/githubAccount": typeof lib_githubAccount;
|
||||
"lib/githubActionsOidc": typeof lib_githubActionsOidc;
|
||||
"lib/githubBackup": typeof lib_githubBackup;
|
||||
"lib/githubIdentity": typeof lib_githubIdentity;
|
||||
"lib/githubImport": typeof lib_githubImport;
|
||||
"lib/githubProfileSync": typeof lib_githubProfileSync;
|
||||
"lib/githubRestoreHelpers": typeof lib_githubRestoreHelpers;
|
||||
"lib/githubSoulBackup": typeof lib_githubSoulBackup;
|
||||
"lib/globalStats": typeof lib_globalStats;
|
||||
"lib/httpHeaders": typeof lib_httpHeaders;
|
||||
"lib/httpRateLimit": typeof lib_httpRateLimit;
|
||||
"lib/httpUtils": typeof lib_httpUtils;
|
||||
"lib/leaderboards": typeof lib_leaderboards;
|
||||
"lib/manualOverrides": typeof lib_manualOverrides;
|
||||
"lib/moderation": typeof lib_moderation;
|
||||
"lib/moderationEngine": typeof lib_moderationEngine;
|
||||
"lib/moderationReasonCodes": typeof lib_moderationReasonCodes;
|
||||
"lib/openaiResponse": typeof lib_openaiResponse;
|
||||
"lib/packageRegistry": typeof lib_packageRegistry;
|
||||
"lib/packageSearchDigest": typeof lib_packageSearchDigest;
|
||||
"lib/packageSecurity": typeof lib_packageSecurity;
|
||||
"lib/public": typeof lib_public;
|
||||
"lib/publishLimits": typeof lib_publishLimits;
|
||||
"lib/publishers": typeof lib_publishers;
|
||||
"lib/reporting": typeof lib_reporting;
|
||||
"lib/reservedHandles": typeof lib_reservedHandles;
|
||||
"lib/reservedSlugs": typeof lib_reservedSlugs;
|
||||
"lib/searchText": typeof lib_searchText;
|
||||
"lib/securityPrompt": typeof lib_securityPrompt;
|
||||
"lib/skillBackfill": typeof lib_skillBackfill;
|
||||
"lib/skillCapabilityTags": typeof lib_skillCapabilityTags;
|
||||
"lib/skillPublish": typeof lib_skillPublish;
|
||||
"lib/skillQuality": typeof lib_skillQuality;
|
||||
"lib/skillSafety": typeof lib_skillSafety;
|
||||
"lib/skillSearchDigest": typeof lib_skillSearchDigest;
|
||||
"lib/skillStats": typeof lib_skillStats;
|
||||
"lib/skillSummary": typeof lib_skillSummary;
|
||||
"lib/skillZip": typeof lib_skillZip;
|
||||
"lib/skills": typeof lib_skills;
|
||||
"lib/soulChangelog": typeof lib_soulChangelog;
|
||||
"lib/soulPublish": typeof lib_soulPublish;
|
||||
"lib/staticPublishScan": typeof lib_staticPublishScan;
|
||||
"lib/tokens": typeof lib_tokens;
|
||||
"lib/userSearch": typeof lib_userSearch;
|
||||
"lib/userSkillStats": typeof lib_userSkillStats;
|
||||
"lib/webhooks": typeof lib_webhooks;
|
||||
llmEval: typeof llmEval;
|
||||
maintenance: typeof maintenance;
|
||||
"model/packages/rescans": typeof model_packages_rescans;
|
||||
"model/rescans/policy": typeof model_rescans_policy;
|
||||
"model/skills/rescans": typeof model_skills_rescans;
|
||||
packagePublishTokens: typeof packagePublishTokens;
|
||||
packages: typeof packages;
|
||||
publishers: typeof publishers;
|
||||
rateLimits: typeof rateLimits;
|
||||
rescanRequests: typeof rescanRequests;
|
||||
search: typeof search;
|
||||
securityDataset: typeof securityDataset;
|
||||
securityDatasetNode: typeof securityDatasetNode;
|
||||
seed: typeof seed;
|
||||
seedSouls: typeof seedSouls;
|
||||
skillStatEvents: typeof skillStatEvents;
|
||||
skillTransfers: typeof skillTransfers;
|
||||
skills: typeof skills;
|
||||
soulComments: typeof soulComments;
|
||||
soulDownloads: typeof soulDownloads;
|
||||
@ -111,6 +254,7 @@ declare const fullApi: ApiFromModules<{
|
||||
tokens: typeof tokens;
|
||||
uploads: typeof uploads;
|
||||
users: typeof users;
|
||||
vt: typeof vt;
|
||||
webhooks: typeof webhooks;
|
||||
}>;
|
||||
|
||||
|
||||
7
convex/apiSurface.typecheck.ts
Normal file
7
convex/apiSurface.typecheck.ts
Normal file
@ -0,0 +1,7 @@
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
// Asserts that the internal-only download counters remain internal-only.
|
||||
// Public exposure is prevented at runtime by `internalMutation`; this file
|
||||
// just pins the public references that *should* exist.
|
||||
void internal.downloads.recordDownloadInternal;
|
||||
void internal.soulDownloads.incrementInternal;
|
||||
14
convex/appMeta.ts
Normal file
14
convex/appMeta.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { query } from "./functions";
|
||||
|
||||
function normalizeEnv(value: string | undefined) {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
export const getDeploymentInfo = query({
|
||||
args: {},
|
||||
handler: async () => ({
|
||||
appBuildSha: normalizeEnv(process.env.APP_BUILD_SHA),
|
||||
deployedAt: normalizeEnv(process.env.APP_DEPLOYED_AT),
|
||||
}),
|
||||
});
|
||||
@ -2,7 +2,7 @@ export default {
|
||||
providers: [
|
||||
{
|
||||
domain: process.env.CONVEX_SITE_URL,
|
||||
applicationID: 'convex',
|
||||
applicationID: "convex",
|
||||
},
|
||||
],
|
||||
}
|
||||
};
|
||||
|
||||
141
convex/auth.test.ts
Normal file
141
convex/auth.test.ts
Normal file
@ -0,0 +1,141 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import {
|
||||
BANNED_REAUTH_MESSAGE,
|
||||
DELETED_ACCOUNT_REAUTH_MESSAGE,
|
||||
handleDeletedUserSignIn,
|
||||
} from "./auth";
|
||||
|
||||
function makeCtx({
|
||||
user,
|
||||
banRecords,
|
||||
}: {
|
||||
user: {
|
||||
deletedAt?: number;
|
||||
deactivatedAt?: number;
|
||||
purgedAt?: number;
|
||||
banReason?: string;
|
||||
} | null;
|
||||
banRecords?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const query = {
|
||||
withIndex: vi.fn().mockReturnValue({
|
||||
collect: vi.fn().mockResolvedValue(banRecords ?? []),
|
||||
}),
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn().mockResolvedValue(user),
|
||||
patch: vi.fn().mockResolvedValue(null),
|
||||
query: vi.fn().mockReturnValue(query),
|
||||
},
|
||||
};
|
||||
return { ctx, query };
|
||||
}
|
||||
|
||||
describe("handleDeletedUserSignIn", () => {
|
||||
const userId = "users:1" as Id<"users">;
|
||||
|
||||
it("skips when user not found", async () => {
|
||||
const { ctx } = makeCtx({ user: null });
|
||||
|
||||
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId });
|
||||
|
||||
expect(ctx.db.get).toHaveBeenCalledWith(userId);
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips active users", async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: undefined, deactivatedAt: undefined } });
|
||||
|
||||
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId });
|
||||
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks sign-in for deactivated users", async () => {
|
||||
const { ctx } = makeCtx({ user: { deactivatedAt: 123, purgedAt: 123 } });
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
||||
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("migrates legacy self-deleted users and blocks sign-in", async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [] });
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: 123,
|
||||
purgedAt: 123,
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("migrates legacy users on fresh login (existingUserId is null)", async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [] });
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: null }),
|
||||
).rejects.toThrow(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(userId, {
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: 123,
|
||||
purgedAt: 123,
|
||||
updatedAt: expect.any(Number),
|
||||
});
|
||||
});
|
||||
|
||||
it("skips mutation when existingUserId does not match userId", async () => {
|
||||
const otherUserId = "users:999" as Id<"users">;
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 } });
|
||||
|
||||
await handleDeletedUserSignIn(ctx as never, { userId, existingUserId: otherUserId });
|
||||
|
||||
expect(ctx.db.query).not.toHaveBeenCalled();
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks banned users with a custom message", async () => {
|
||||
const { ctx } = makeCtx({ user: { deletedAt: 123 }, banRecords: [{ action: "user.ban" }] });
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("blocks users auto-banned for malware", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123, banReason: "malware auto-ban" },
|
||||
banRecords: [{ action: "user.autoban.malware" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(BANNED_REAUTH_MESSAGE);
|
||||
|
||||
expect(ctx.db.patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("includes the moderator ban reason in the sign-in error", async () => {
|
||||
const { ctx } = makeCtx({
|
||||
user: { deletedAt: 123, banReason: "Chargeback fraud" },
|
||||
banRecords: [{ action: "user.ban" }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
handleDeletedUserSignIn(ctx as never, { userId, existingUserId: userId }),
|
||||
).rejects.toThrow(`${BANNED_REAUTH_MESSAGE} Reason: Chargeback fraud`);
|
||||
});
|
||||
});
|
||||
109
convex/auth.ts
109
convex/auth.ts
@ -1,19 +1,116 @@
|
||||
import GitHub from '@auth/core/providers/github'
|
||||
import { convexAuth } from '@convex-dev/auth/server'
|
||||
import GitHub from "@auth/core/providers/github";
|
||||
import { convexAuth } from "@convex-dev/auth/server";
|
||||
import type { GenericMutationCtx } from "convex/server";
|
||||
import { ConvexError } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { DataModel, Id } from "./_generated/dataModel";
|
||||
import { shouldScheduleGitHubProfileSync } from "./lib/githubProfileSync";
|
||||
|
||||
export const BANNED_REAUTH_MESSAGE =
|
||||
"This account has been banned and cannot sign in. If you believe this is a mistake, please contact security@openclaw.ai and we will review it.";
|
||||
export const DELETED_ACCOUNT_REAUTH_MESSAGE =
|
||||
"This account has been permanently deleted and cannot be restored.";
|
||||
|
||||
const REAUTH_BLOCKING_BAN_ACTIONS = new Set(["user.ban", "user.autoban.malware"]);
|
||||
|
||||
function getBannedReauthMessage(reason: string | undefined) {
|
||||
const normalizedReason = reason?.trim();
|
||||
if (!normalizedReason || normalizedReason.toLowerCase() === "malware auto-ban") {
|
||||
return BANNED_REAUTH_MESSAGE;
|
||||
}
|
||||
return `${BANNED_REAUTH_MESSAGE} Reason: ${normalizedReason}`;
|
||||
}
|
||||
|
||||
export async function handleDeletedUserSignIn(
|
||||
ctx: GenericMutationCtx<DataModel>,
|
||||
args: { userId: Id<"users">; existingUserId: Id<"users"> | null },
|
||||
userOverride?: {
|
||||
deletedAt?: number;
|
||||
deactivatedAt?: number;
|
||||
purgedAt?: number;
|
||||
banReason?: string;
|
||||
} | null,
|
||||
) {
|
||||
const user = userOverride !== undefined ? userOverride : await ctx.db.get(args.userId);
|
||||
if (!user?.deletedAt && !user?.deactivatedAt) return;
|
||||
|
||||
// Verify that the incoming identity matches the existing account to prevent bypass.
|
||||
if (args.existingUserId && args.existingUserId !== args.userId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.deactivatedAt) {
|
||||
throw new ConvexError(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
||||
}
|
||||
|
||||
const userId = args.userId;
|
||||
const deletedAt = user.deletedAt ?? Date.now();
|
||||
const banRecords = await ctx.db
|
||||
.query("auditLogs")
|
||||
.withIndex("by_target", (q) => q.eq("targetType", "user").eq("targetId", userId.toString()))
|
||||
.collect();
|
||||
|
||||
const hasBlockingBan = banRecords.some((record) =>
|
||||
REAUTH_BLOCKING_BAN_ACTIONS.has(record.action),
|
||||
);
|
||||
|
||||
if (hasBlockingBan) {
|
||||
throw new ConvexError(getBannedReauthMessage(user.banReason));
|
||||
}
|
||||
|
||||
// Migrate legacy self-deleted accounts (stored in deletedAt) to the new
|
||||
// irreversible state and reject sign-in.
|
||||
await ctx.db.patch(userId, {
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: deletedAt,
|
||||
purgedAt: user.purgedAt ?? deletedAt,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
|
||||
throw new ConvexError(DELETED_ACCOUNT_REAUTH_MESSAGE);
|
||||
}
|
||||
|
||||
export const { auth, signIn, signOut, store, isAuthenticated } = convexAuth({
|
||||
providers: [
|
||||
GitHub({
|
||||
clientId: process.env.AUTH_GITHUB_ID ?? '',
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET ?? '',
|
||||
clientId: process.env.AUTH_GITHUB_ID ?? "",
|
||||
clientSecret: process.env.AUTH_GITHUB_SECRET ?? "",
|
||||
profile(profile) {
|
||||
return {
|
||||
id: String(profile.id),
|
||||
name: profile.login,
|
||||
email: profile.email ?? undefined,
|
||||
image: profile.avatar_url,
|
||||
}
|
||||
};
|
||||
},
|
||||
}),
|
||||
],
|
||||
})
|
||||
callbacks: {
|
||||
/**
|
||||
* Block sign-in for deleted/deactivated users and sync GitHub profile.
|
||||
*
|
||||
* Performance note: This callback runs on every OAuth sign-in, but the
|
||||
* audit log query ONLY executes when a legacy deleted user attempts to sign
|
||||
* in (user.deletedAt is set). For active users, this is a single field check.
|
||||
*
|
||||
* The GitHub profile sync is scheduled as a background action to handle
|
||||
* the case where a user renames their GitHub account (fixes #303).
|
||||
*/
|
||||
async afterUserCreatedOrUpdated(ctx, args) {
|
||||
const user = await ctx.db.get(args.userId);
|
||||
await handleDeletedUserSignIn(ctx, args, user);
|
||||
await ctx.scheduler.runAfter(0, internal.publishers.ensurePersonalPublisherInternal, {
|
||||
userId: args.userId,
|
||||
});
|
||||
|
||||
// Schedule GitHub profile sync to handle username renames (fixes #303)
|
||||
// This runs as a background action so it doesn't block sign-in
|
||||
const now = Date.now();
|
||||
if (shouldScheduleGitHubProfileSync(user, now)) {
|
||||
await ctx.scheduler.runAfter(0, internal.users.syncGitHubProfileAction, {
|
||||
userId: args.userId,
|
||||
});
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
290
convex/commentModeration.test.ts
Normal file
290
convex/commentModeration.test.ts
Normal file
@ -0,0 +1,290 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./_generated/api", () => ({
|
||||
internal: {
|
||||
commentModeration: {
|
||||
getCommentScamBackfillPageInternal: Symbol(
|
||||
"commentModeration.getCommentScamBackfillPageInternal",
|
||||
),
|
||||
applyCommentScamResultInternal: Symbol("commentModeration.applyCommentScamResultInternal"),
|
||||
backfillCommentScamModerationInternal: Symbol(
|
||||
"commentModeration.backfillCommentScamModerationInternal",
|
||||
),
|
||||
continueCommentScamModerationJobInternal: Symbol(
|
||||
"commentModeration.continueCommentScamModerationJobInternal",
|
||||
),
|
||||
},
|
||||
llmEval: {
|
||||
evaluateCommentForScam: Symbol("llmEval.evaluateCommentForScam"),
|
||||
},
|
||||
users: {
|
||||
banUserInternal: Symbol("users.banUserInternal"),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
const { applyCommentScamResultInternalHandler, backfillCommentScamModerationInternalHandler } =
|
||||
await import("./commentModeration");
|
||||
const { internal } = await import("./_generated/api");
|
||||
|
||||
const previousOpenAiApiKey = process.env.OPENAI_API_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OPENAI_API_KEY = "test-key";
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (previousOpenAiApiKey === undefined) {
|
||||
delete process.env.OPENAI_API_KEY;
|
||||
return;
|
||||
}
|
||||
process.env.OPENAI_API_KEY = previousOpenAiApiKey;
|
||||
});
|
||||
|
||||
describe("commentModeration backfill", () => {
|
||||
it("evaluates comments and bans on certain/high scams", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValueOnce({
|
||||
items: [
|
||||
{
|
||||
commentId: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
body: 'echo "mal" | base64 -D | bash',
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: undefined,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
});
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
model: "gpt-5-mini",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
explanation: "Obfuscated shell execution payload.",
|
||||
evidence: ["base64 decode piped to bash"],
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
shouldBan: true,
|
||||
banned: true,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
});
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
dryRun: false,
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result.ok).toBe(true);
|
||||
expect(result.stats.commentsScanned).toBe(1);
|
||||
expect(result.stats.commentsEvaluated).toBe(1);
|
||||
expect(result.stats.certainScams).toBe(1);
|
||||
expect(result.stats.banCandidates).toBe(1);
|
||||
expect(result.stats.usersBanned).toBe(1);
|
||||
expect(runAction).toHaveBeenCalledWith(internal.llmEval.evaluateCommentForScam, {
|
||||
commentId: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
body: 'echo "mal" | base64 -D | bash',
|
||||
});
|
||||
expect(runMutation).toHaveBeenCalledWith(
|
||||
internal.commentModeration.applyCommentScamResultInternal,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
commentId: "comments:1",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
explanation: "Obfuscated shell execution payload.",
|
||||
evidence: ["base64 decode piped to bash"],
|
||||
model: "gpt-5-mini",
|
||||
checkedAt: expect.any(Number),
|
||||
dryRun: false,
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("skips previously scanned comments unless rescan=true", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
commentId: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
body: "something",
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: 123,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
});
|
||||
const runAction = vi.fn();
|
||||
const runMutation = vi.fn();
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result.stats.commentsScanned).toBe(1);
|
||||
expect(result.stats.skippedAlreadyScanned).toBe(1);
|
||||
expect(runAction).not.toHaveBeenCalled();
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("tracks dry-run ban candidates without banning", async () => {
|
||||
const runQuery = vi.fn().mockResolvedValue({
|
||||
items: [
|
||||
{
|
||||
commentId: "comments:9",
|
||||
skillId: "skills:7",
|
||||
userId: "users:5",
|
||||
body: "run this update installer from random domain",
|
||||
softDeletedAt: undefined,
|
||||
scamScanCheckedAt: undefined,
|
||||
},
|
||||
],
|
||||
cursor: null,
|
||||
isDone: true,
|
||||
});
|
||||
const runAction = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
model: "gpt-5-mini",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
explanation: "Social-engineering install command.",
|
||||
evidence: ["unknown update domain"],
|
||||
});
|
||||
const runMutation = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
shouldBan: true,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: true,
|
||||
});
|
||||
|
||||
const result = await backfillCommentScamModerationInternalHandler(
|
||||
{ runQuery, runAction, runMutation } as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
dryRun: true,
|
||||
batchSize: 10,
|
||||
maxBatches: 1,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result.stats.usersBanned).toBe(0);
|
||||
expect(result.stats.usersWouldBeBanned).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("applyCommentScamResultInternalHandler", () => {
|
||||
it("persists scan metadata and triggers ban with bounded reason", async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:2",
|
||||
role: "user",
|
||||
});
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runMutation = vi
|
||||
.fn()
|
||||
.mockResolvedValue({ ok: true, alreadyBanned: false, deletedSkills: 0 });
|
||||
|
||||
const result = await applyCommentScamResultInternalHandler(
|
||||
{ db: { get, patch, insert }, runMutation } as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
commentId: "comments:1",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
explanation: "X".repeat(700),
|
||||
evidence: ["Y".repeat(280), "Z".repeat(280)],
|
||||
model: "gpt-5-mini",
|
||||
checkedAt: 123,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result.banned).toBe(true);
|
||||
expect(insert).toHaveBeenCalledWith("auditLogs", {
|
||||
actorUserId: "users:admin",
|
||||
action: "comment.scam_scan",
|
||||
targetType: "comment",
|
||||
targetId: "comments:1",
|
||||
metadata: {
|
||||
skillId: "skills:1",
|
||||
commentAuthorId: "users:2",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
shouldBan: true,
|
||||
model: "gpt-5-mini",
|
||||
},
|
||||
createdAt: 123,
|
||||
});
|
||||
|
||||
const banCall = runMutation.mock.calls.find(
|
||||
(call) => call[0] === internal.users.banUserInternal,
|
||||
);
|
||||
expect(banCall).toBeTruthy();
|
||||
if (!banCall) throw new Error("Expected ban mutation to be called");
|
||||
expect((banCall[1] as { reason: string }).reason.length).toBeLessThanOrEqual(500);
|
||||
expect(patch).toHaveBeenCalledWith("comments:1", {
|
||||
scamBanTriggeredAt: 123,
|
||||
});
|
||||
});
|
||||
|
||||
it("skips banning moderator/admin accounts", async () => {
|
||||
const get = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
_id: "comments:2",
|
||||
skillId: "skills:2",
|
||||
userId: "users:staff",
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
_id: "users:staff",
|
||||
role: "moderator",
|
||||
});
|
||||
const patch = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const runMutation = vi.fn();
|
||||
|
||||
const result = await applyCommentScamResultInternalHandler(
|
||||
{ db: { get, patch, insert }, runMutation } as never,
|
||||
{
|
||||
actorUserId: "users:admin",
|
||||
commentId: "comments:2",
|
||||
verdict: "certain_scam",
|
||||
confidence: "high",
|
||||
explanation: "Malicious command spam.",
|
||||
evidence: ["base64|bash"],
|
||||
model: "gpt-5-mini",
|
||||
checkedAt: 300,
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(result.protectedRole).toBe(true);
|
||||
expect(runMutation).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
479
convex/commentModeration.ts
Normal file
479
convex/commentModeration.ts
Normal file
@ -0,0 +1,479 @@
|
||||
import { ConvexError, v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx, MutationCtx } from "./_generated/server";
|
||||
import { action, internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import { assertRole, requireUserFromAction } from "./lib/access";
|
||||
import {
|
||||
buildCommentScamBanReason,
|
||||
isCertainScam,
|
||||
type CommentScamConfidence,
|
||||
type CommentScamVerdict,
|
||||
} from "./lib/commentScamPrompt";
|
||||
|
||||
const DEFAULT_BATCH_SIZE = 25;
|
||||
const MAX_BATCH_SIZE = 100;
|
||||
const DEFAULT_MAX_BATCHES = 10;
|
||||
const MAX_MAX_BATCHES = 200;
|
||||
|
||||
type CommentBackfillPageItem = {
|
||||
commentId: Id<"comments">;
|
||||
skillId: Id<"skills">;
|
||||
userId: Id<"users">;
|
||||
body: string;
|
||||
softDeletedAt?: number;
|
||||
scamScanCheckedAt?: number;
|
||||
};
|
||||
|
||||
type CommentBackfillPageResult = {
|
||||
items: CommentBackfillPageItem[];
|
||||
cursor: string | null;
|
||||
isDone: boolean;
|
||||
};
|
||||
|
||||
type ApplyCommentScamResult = {
|
||||
ok: true;
|
||||
shouldBan: boolean;
|
||||
banned: boolean;
|
||||
alreadyBanned: boolean;
|
||||
protectedRole: boolean;
|
||||
wouldBan: boolean;
|
||||
};
|
||||
|
||||
export type CommentScamBackfillStats = {
|
||||
commentsScanned: number;
|
||||
commentsEvaluated: number;
|
||||
certainScams: number;
|
||||
banCandidates: number;
|
||||
usersBanned: number;
|
||||
usersAlreadyBanned: number;
|
||||
usersWouldBeBanned: number;
|
||||
protectedRoleSkips: number;
|
||||
skippedSoftDeleted: number;
|
||||
skippedAlreadyScanned: number;
|
||||
skippedEmptyBody: number;
|
||||
evalErrors: number;
|
||||
};
|
||||
|
||||
export type CommentScamBackfillActionArgs = {
|
||||
actorUserId: Id<"users">;
|
||||
dryRun?: boolean;
|
||||
batchSize?: number;
|
||||
maxBatches?: number;
|
||||
cursor?: string;
|
||||
rescan?: boolean;
|
||||
includeSoftDeleted?: boolean;
|
||||
};
|
||||
|
||||
export type CommentScamBackfillActionResult = {
|
||||
ok: true;
|
||||
stats: CommentScamBackfillStats;
|
||||
isDone: boolean;
|
||||
cursor: string | null;
|
||||
};
|
||||
|
||||
export const getCommentScamBackfillPageInternal = internalQuery({
|
||||
args: {
|
||||
cursor: v.optional(v.string()),
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CommentBackfillPageResult> => {
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const { page, isDone, continueCursor } = await ctx.db
|
||||
.query("comments")
|
||||
.order("asc")
|
||||
.paginate({ cursor: args.cursor ?? null, numItems: batchSize });
|
||||
|
||||
return {
|
||||
items: page.map((comment) => ({
|
||||
commentId: comment._id,
|
||||
skillId: comment.skillId,
|
||||
userId: comment.userId,
|
||||
body: comment.body,
|
||||
softDeletedAt: comment.softDeletedAt,
|
||||
scamScanCheckedAt: comment.scamScanCheckedAt,
|
||||
})),
|
||||
cursor: continueCursor,
|
||||
isDone,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
export async function applyCommentScamResultInternalHandler(
|
||||
ctx: MutationCtx,
|
||||
args: {
|
||||
actorUserId: Id<"users">;
|
||||
commentId: Id<"comments">;
|
||||
verdict: CommentScamVerdict;
|
||||
confidence: CommentScamConfidence;
|
||||
explanation: string;
|
||||
evidence: string[];
|
||||
model: string;
|
||||
checkedAt: number;
|
||||
dryRun?: boolean;
|
||||
},
|
||||
): Promise<ApplyCommentScamResult> {
|
||||
const comment = await ctx.db.get(args.commentId);
|
||||
if (!comment) {
|
||||
throw new ConvexError("Comment not found");
|
||||
}
|
||||
|
||||
const user = await ctx.db.get(comment.userId);
|
||||
if (!user) {
|
||||
throw new ConvexError("Comment author not found");
|
||||
}
|
||||
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const shouldBan = isCertainScam({
|
||||
verdict: args.verdict,
|
||||
confidence: args.confidence,
|
||||
});
|
||||
|
||||
const explanation = args.explanation.trim().slice(0, 1200);
|
||||
const evidence = args.evidence
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean)
|
||||
.slice(0, 5);
|
||||
|
||||
if (!dryRun) {
|
||||
await ctx.db.patch(comment._id, {
|
||||
scamScanVerdict: args.verdict,
|
||||
scamScanConfidence: args.confidence,
|
||||
scamScanExplanation: explanation,
|
||||
scamScanEvidence: evidence,
|
||||
scamScanModel: args.model,
|
||||
scamScanCheckedAt: args.checkedAt,
|
||||
});
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: args.actorUserId,
|
||||
action: "comment.scam_scan",
|
||||
targetType: "comment",
|
||||
targetId: comment._id,
|
||||
metadata: {
|
||||
skillId: comment.skillId,
|
||||
commentAuthorId: comment.userId,
|
||||
verdict: args.verdict,
|
||||
confidence: args.confidence,
|
||||
shouldBan,
|
||||
model: args.model,
|
||||
},
|
||||
createdAt: args.checkedAt,
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldBan) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.role === "admin" || user.role === "moderator") {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: true,
|
||||
wouldBan: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (user.deletedAt || user.deactivatedAt) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: true,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
};
|
||||
}
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: false,
|
||||
alreadyBanned: false,
|
||||
protectedRole: false,
|
||||
wouldBan: true,
|
||||
};
|
||||
}
|
||||
|
||||
const reason = buildCommentScamBanReason({
|
||||
commentId: String(comment._id),
|
||||
skillId: String(comment.skillId),
|
||||
explanation,
|
||||
evidence,
|
||||
});
|
||||
|
||||
const banResult = await ctx.runMutation(internal.users.banUserInternal, {
|
||||
actorUserId: args.actorUserId,
|
||||
targetUserId: comment.userId,
|
||||
reason,
|
||||
});
|
||||
|
||||
if (!banResult.alreadyBanned) {
|
||||
await ctx.db.patch(comment._id, {
|
||||
scamBanTriggeredAt: args.checkedAt,
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
shouldBan,
|
||||
banned: !banResult.alreadyBanned,
|
||||
alreadyBanned: banResult.alreadyBanned,
|
||||
protectedRole: false,
|
||||
wouldBan: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const applyCommentScamResultInternal = internalMutation({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
commentId: v.id("comments"),
|
||||
verdict: v.union(v.literal("not_scam"), v.literal("likely_scam"), v.literal("certain_scam")),
|
||||
confidence: v.union(v.literal("low"), v.literal("medium"), v.literal("high")),
|
||||
explanation: v.string(),
|
||||
evidence: v.array(v.string()),
|
||||
model: v.string(),
|
||||
checkedAt: v.number(),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
},
|
||||
handler: applyCommentScamResultInternalHandler,
|
||||
});
|
||||
|
||||
export async function backfillCommentScamModerationInternalHandler(
|
||||
ctx: ActionCtx,
|
||||
args: CommentScamBackfillActionArgs,
|
||||
): Promise<CommentScamBackfillActionResult> {
|
||||
if (!process.env.OPENAI_API_KEY) {
|
||||
throw new ConvexError("OPENAI_API_KEY not configured");
|
||||
}
|
||||
|
||||
const dryRun = Boolean(args.dryRun);
|
||||
const rescan = Boolean(args.rescan);
|
||||
const includeSoftDeleted = Boolean(args.includeSoftDeleted);
|
||||
const batchSize = clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE);
|
||||
const maxBatches = clampInt(args.maxBatches ?? DEFAULT_MAX_BATCHES, 1, MAX_MAX_BATCHES);
|
||||
|
||||
let cursor: string | null = args.cursor ?? null;
|
||||
let isDone = false;
|
||||
|
||||
const stats: CommentScamBackfillStats = {
|
||||
commentsScanned: 0,
|
||||
commentsEvaluated: 0,
|
||||
certainScams: 0,
|
||||
banCandidates: 0,
|
||||
usersBanned: 0,
|
||||
usersAlreadyBanned: 0,
|
||||
usersWouldBeBanned: 0,
|
||||
protectedRoleSkips: 0,
|
||||
skippedSoftDeleted: 0,
|
||||
skippedAlreadyScanned: 0,
|
||||
skippedEmptyBody: 0,
|
||||
evalErrors: 0,
|
||||
};
|
||||
|
||||
for (let i = 0; i < maxBatches; i++) {
|
||||
const page = (await ctx.runQuery(
|
||||
internal.commentModeration.getCommentScamBackfillPageInternal,
|
||||
{
|
||||
cursor: cursor ?? undefined,
|
||||
batchSize,
|
||||
},
|
||||
)) as CommentBackfillPageResult;
|
||||
|
||||
cursor = page.cursor;
|
||||
isDone = page.isDone;
|
||||
|
||||
for (const comment of page.items) {
|
||||
stats.commentsScanned++;
|
||||
|
||||
if (!includeSoftDeleted && comment.softDeletedAt) {
|
||||
stats.skippedSoftDeleted++;
|
||||
continue;
|
||||
}
|
||||
if (!rescan && comment.scamScanCheckedAt) {
|
||||
stats.skippedAlreadyScanned++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const body = comment.body.trim();
|
||||
if (!body) {
|
||||
stats.skippedEmptyBody++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const evalResult = (await ctx.runAction(internal.llmEval.evaluateCommentForScam, {
|
||||
commentId: comment.commentId,
|
||||
skillId: comment.skillId,
|
||||
userId: comment.userId,
|
||||
body,
|
||||
})) as
|
||||
| {
|
||||
ok: true;
|
||||
model: string;
|
||||
verdict: CommentScamVerdict;
|
||||
confidence: CommentScamConfidence;
|
||||
explanation: string;
|
||||
evidence: string[];
|
||||
}
|
||||
| { ok: false; error: string };
|
||||
|
||||
if (!evalResult.ok) {
|
||||
stats.evalErrors++;
|
||||
continue;
|
||||
}
|
||||
|
||||
stats.commentsEvaluated++;
|
||||
const shouldBan = isCertainScam(evalResult);
|
||||
if (evalResult.verdict === "certain_scam") {
|
||||
stats.certainScams++;
|
||||
}
|
||||
if (shouldBan) {
|
||||
stats.banCandidates++;
|
||||
}
|
||||
|
||||
const applyResult = (await ctx.runMutation(
|
||||
internal.commentModeration.applyCommentScamResultInternal,
|
||||
{
|
||||
actorUserId: args.actorUserId,
|
||||
commentId: comment.commentId,
|
||||
verdict: evalResult.verdict,
|
||||
confidence: evalResult.confidence,
|
||||
explanation: evalResult.explanation,
|
||||
evidence: evalResult.evidence,
|
||||
model: evalResult.model,
|
||||
checkedAt: Date.now(),
|
||||
dryRun,
|
||||
},
|
||||
)) as ApplyCommentScamResult;
|
||||
|
||||
if (applyResult.banned) stats.usersBanned++;
|
||||
if (applyResult.alreadyBanned) stats.usersAlreadyBanned++;
|
||||
if (applyResult.wouldBan) stats.usersWouldBeBanned++;
|
||||
if (applyResult.protectedRole) stats.protectedRoleSkips++;
|
||||
}
|
||||
|
||||
if (isDone) break;
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
stats,
|
||||
isDone,
|
||||
cursor,
|
||||
};
|
||||
}
|
||||
|
||||
export const backfillCommentScamModerationInternal = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: backfillCommentScamModerationInternalHandler,
|
||||
});
|
||||
|
||||
export const backfillCommentScamModeration: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
maxBatches: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<CommentScamBackfillActionResult> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin", "moderator"]);
|
||||
|
||||
return ctx.runAction(internal.commentModeration.backfillCommentScamModerationInternal, {
|
||||
actorUserId: user._id,
|
||||
...args,
|
||||
}) as Promise<CommentScamBackfillActionResult>;
|
||||
},
|
||||
});
|
||||
|
||||
export const continueCommentScamModerationJobInternal = internalAction({
|
||||
args: {
|
||||
actorUserId: v.id("users"),
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
cursor: v.optional(v.string()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const result = await backfillCommentScamModerationInternalHandler(ctx, {
|
||||
actorUserId: args.actorUserId,
|
||||
dryRun: args.dryRun,
|
||||
batchSize: args.batchSize,
|
||||
cursor: args.cursor,
|
||||
maxBatches: 1,
|
||||
rescan: args.rescan,
|
||||
includeSoftDeleted: args.includeSoftDeleted,
|
||||
});
|
||||
|
||||
if (!result.isDone && result.cursor) {
|
||||
await ctx.scheduler.runAfter(
|
||||
2_000,
|
||||
internal.commentModeration.continueCommentScamModerationJobInternal,
|
||||
{
|
||||
actorUserId: args.actorUserId,
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: args.batchSize ?? DEFAULT_BATCH_SIZE,
|
||||
cursor: result.cursor,
|
||||
rescan: Boolean(args.rescan),
|
||||
includeSoftDeleted: Boolean(args.includeSoftDeleted),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
});
|
||||
|
||||
export const scheduleCommentScamModeration: ReturnType<typeof action> = action({
|
||||
args: {
|
||||
dryRun: v.optional(v.boolean()),
|
||||
batchSize: v.optional(v.number()),
|
||||
rescan: v.optional(v.boolean()),
|
||||
includeSoftDeleted: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx, args): Promise<{ ok: true }> => {
|
||||
const { user } = await requireUserFromAction(ctx);
|
||||
assertRole(user, ["admin", "moderator"]);
|
||||
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.commentModeration.continueCommentScamModerationJobInternal,
|
||||
{
|
||||
actorUserId: user._id,
|
||||
dryRun: Boolean(args.dryRun),
|
||||
batchSize: clampInt(args.batchSize ?? DEFAULT_BATCH_SIZE, 1, MAX_BATCH_SIZE),
|
||||
cursor: undefined,
|
||||
rescan: Boolean(args.rescan),
|
||||
includeSoftDeleted: Boolean(args.includeSoftDeleted),
|
||||
},
|
||||
);
|
||||
|
||||
return { ok: true as const };
|
||||
},
|
||||
});
|
||||
|
||||
function clampInt(value: number, min: number, max: number) {
|
||||
return Math.min(Math.max(Math.trunc(value), min), max);
|
||||
}
|
||||
151
convex/comments.handlers.ts
Normal file
151
convex/comments.handlers.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import { assertModerator, requireUser } from "./lib/access";
|
||||
import { requireGitHubAccountAge } from "./lib/githubAccount";
|
||||
import {
|
||||
AUTO_HIDE_REPORT_THRESHOLD,
|
||||
MAX_ACTIVE_REPORTS_PER_USER,
|
||||
MAX_REPORT_REASON_LENGTH,
|
||||
} from "./lib/reporting";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
export async function addHandler(ctx: MutationCtx, args: { skillId: Id<"skills">; body: string }) {
|
||||
const { userId } = await requireUser(ctx);
|
||||
await requireGitHubAccountAge(ctx, userId);
|
||||
|
||||
const body = args.body.trim();
|
||||
if (!body) throw new Error("Comment body required");
|
||||
|
||||
const skill = await ctx.db.get(args.skillId);
|
||||
if (!skill) throw new Error("Skill not found");
|
||||
|
||||
await ctx.db.insert("comments", {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
});
|
||||
|
||||
await insertStatEvent(ctx, { skillId: skill._id, kind: "comment" });
|
||||
}
|
||||
|
||||
export async function removeHandler(ctx: MutationCtx, args: { commentId: Id<"comments"> }) {
|
||||
const { user } = await requireUser(ctx);
|
||||
const comment = await ctx.db.get(args.commentId);
|
||||
if (!comment) throw new Error("Comment not found");
|
||||
if (comment.softDeletedAt) return;
|
||||
|
||||
const isOwner = comment.userId === user._id;
|
||||
if (!isOwner) {
|
||||
assertModerator(user);
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
});
|
||||
|
||||
await insertStatEvent(ctx, { skillId: comment.skillId, kind: "uncomment" });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: user._id,
|
||||
action: "comment.delete",
|
||||
targetType: "comment",
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId },
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
async function countActiveReportsForUser(ctx: MutationCtx, userId: Id<"users">) {
|
||||
const reports = await ctx.db
|
||||
.query("commentReports")
|
||||
.withIndex("by_user", (q) => q.eq("userId", userId))
|
||||
.collect();
|
||||
|
||||
let count = 0;
|
||||
for (const report of reports) {
|
||||
const comment = await ctx.db.get(report.commentId);
|
||||
if (!comment || comment.softDeletedAt) continue;
|
||||
const skill = await ctx.db.get(comment.skillId);
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === "removed") continue;
|
||||
const owner = await ctx.db.get(comment.userId);
|
||||
if (!owner || owner.deletedAt || owner.deactivatedAt) continue;
|
||||
count += 1;
|
||||
if (count >= MAX_ACTIVE_REPORTS_PER_USER) break;
|
||||
}
|
||||
|
||||
return count;
|
||||
}
|
||||
|
||||
export async function reportHandler(
|
||||
ctx: MutationCtx,
|
||||
args: { commentId: Id<"comments">; reason: string },
|
||||
) {
|
||||
const { userId } = await requireUser(ctx);
|
||||
const comment = await ctx.db.get(args.commentId);
|
||||
if (!comment || comment.softDeletedAt) {
|
||||
throw new Error("Comment not found");
|
||||
}
|
||||
const skill = await ctx.db.get(comment.skillId);
|
||||
if (!skill || skill.softDeletedAt || skill.moderationStatus === "removed") {
|
||||
throw new Error("Comment not found");
|
||||
}
|
||||
|
||||
const reason = args.reason.trim();
|
||||
if (!reason) {
|
||||
throw new Error("Report reason required.");
|
||||
}
|
||||
|
||||
const existing = await ctx.db
|
||||
.query("commentReports")
|
||||
.withIndex("by_comment_user", (q) => q.eq("commentId", args.commentId).eq("userId", userId))
|
||||
.unique();
|
||||
if (existing) return { ok: true as const, reported: false, alreadyReported: true };
|
||||
|
||||
const activeReports = await countActiveReportsForUser(ctx, userId);
|
||||
if (activeReports >= MAX_ACTIVE_REPORTS_PER_USER) {
|
||||
throw new Error("Report limit reached. Please wait for moderation before reporting more.");
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
await ctx.db.insert("commentReports", {
|
||||
commentId: args.commentId,
|
||||
skillId: comment.skillId,
|
||||
userId,
|
||||
reason: reason.slice(0, MAX_REPORT_REASON_LENGTH),
|
||||
createdAt: now,
|
||||
});
|
||||
|
||||
const nextReportCount = (comment.reportCount ?? 0) + 1;
|
||||
const shouldAutoHide = nextReportCount > AUTO_HIDE_REPORT_THRESHOLD && !comment.softDeletedAt;
|
||||
const updates: {
|
||||
reportCount: number;
|
||||
lastReportedAt: number;
|
||||
softDeletedAt?: number;
|
||||
} = {
|
||||
reportCount: nextReportCount,
|
||||
lastReportedAt: now,
|
||||
};
|
||||
if (shouldAutoHide) {
|
||||
updates.softDeletedAt = now;
|
||||
}
|
||||
await ctx.db.patch(comment._id, updates);
|
||||
|
||||
if (shouldAutoHide) {
|
||||
await insertStatEvent(ctx, { skillId: comment.skillId, kind: "uncomment" });
|
||||
|
||||
await ctx.db.insert("auditLogs", {
|
||||
actorUserId: userId,
|
||||
action: "comment.auto_hide",
|
||||
targetType: "comment",
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId, reportCount: nextReportCount },
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { ok: true as const, reported: true, alreadyReported: false };
|
||||
}
|
||||
127
convex/comments.query.test.ts
Normal file
127
convex/comments.query.test.ts
Normal file
@ -0,0 +1,127 @@
|
||||
/* @vitest-environment node */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { listBySkillHandler } from "./comments";
|
||||
|
||||
function makeCtx(args: {
|
||||
comments: Array<Record<string, unknown>>;
|
||||
usersById: Record<string, Record<string, unknown> | null>;
|
||||
}) {
|
||||
const get = async (id: string) => args.usersById[id] ?? null;
|
||||
const take = async () => args.comments;
|
||||
const order = () => ({ take });
|
||||
const withIndex = () => ({ order });
|
||||
const query = () => ({ withIndex });
|
||||
return { db: { get, query } } as never;
|
||||
}
|
||||
|
||||
describe("comments.listBySkill", () => {
|
||||
it("skips soft-deleted comments", async () => {
|
||||
const ctx = makeCtx({
|
||||
comments: [
|
||||
{
|
||||
_id: "comments:live",
|
||||
skillId: "skills:1",
|
||||
userId: "users:live",
|
||||
body: "hello",
|
||||
},
|
||||
{
|
||||
_id: "comments:deleted",
|
||||
skillId: "skills:1",
|
||||
userId: "users:live",
|
||||
body: "bye",
|
||||
softDeletedAt: 123,
|
||||
},
|
||||
],
|
||||
usersById: {
|
||||
"users:live": {
|
||||
_id: "users:live",
|
||||
_creationTime: 1,
|
||||
handle: "live",
|
||||
name: "live",
|
||||
displayName: "Live",
|
||||
image: null,
|
||||
bio: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await listBySkillHandler(ctx, {
|
||||
skillId: "skills:1",
|
||||
limit: 50,
|
||||
} as never);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.comment._id).toBe("comments:live");
|
||||
});
|
||||
|
||||
it("skips comments whose author is deleted/deactivated/missing", async () => {
|
||||
const ctx = makeCtx({
|
||||
comments: [
|
||||
{
|
||||
_id: "comments:ok",
|
||||
skillId: "skills:1",
|
||||
userId: "users:ok",
|
||||
body: "ok",
|
||||
},
|
||||
{
|
||||
_id: "comments:deleted-user",
|
||||
skillId: "skills:1",
|
||||
userId: "users:deleted",
|
||||
body: "hidden",
|
||||
},
|
||||
{
|
||||
_id: "comments:deactivated-user",
|
||||
skillId: "skills:1",
|
||||
userId: "users:deactivated",
|
||||
body: "hidden",
|
||||
},
|
||||
{
|
||||
_id: "comments:missing-user",
|
||||
skillId: "skills:1",
|
||||
userId: "users:missing",
|
||||
body: "hidden",
|
||||
},
|
||||
],
|
||||
usersById: {
|
||||
"users:ok": {
|
||||
_id: "users:ok",
|
||||
_creationTime: 1,
|
||||
handle: "ok",
|
||||
name: "ok",
|
||||
displayName: "Ok",
|
||||
image: null,
|
||||
bio: null,
|
||||
},
|
||||
"users:deleted": {
|
||||
_id: "users:deleted",
|
||||
_creationTime: 1,
|
||||
handle: "deleted",
|
||||
name: "deleted",
|
||||
displayName: "Deleted",
|
||||
image: null,
|
||||
bio: null,
|
||||
deletedAt: 123,
|
||||
},
|
||||
"users:deactivated": {
|
||||
_id: "users:deactivated",
|
||||
_creationTime: 1,
|
||||
handle: "deactivated",
|
||||
name: "deactivated",
|
||||
displayName: "Deactivated",
|
||||
image: null,
|
||||
bio: null,
|
||||
deactivatedAt: 456,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const result = await listBySkillHandler(ctx, {
|
||||
skillId: "skills:1",
|
||||
limit: 50,
|
||||
} as never);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.comment._id).toBe("comments:ok");
|
||||
expect(result[0]?.user._id).toBe("users:ok");
|
||||
});
|
||||
});
|
||||
614
convex/comments.test.ts
Normal file
614
convex/comments.test.ts
Normal file
@ -0,0 +1,614 @@
|
||||
/* @vitest-environment node */
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
vi.mock("./lib/access", () => ({
|
||||
assertModerator: vi.fn(),
|
||||
requireUser: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./skillStatEvents", () => ({
|
||||
insertStatEvent: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./lib/githubAccount", () => ({
|
||||
requireGitHubAccountAge: vi.fn(),
|
||||
}));
|
||||
|
||||
const { requireUser, assertModerator } = await import("./lib/access");
|
||||
const { insertStatEvent } = await import("./skillStatEvents");
|
||||
const { requireGitHubAccountAge } = await import("./lib/githubAccount");
|
||||
const { addHandler, removeHandler, reportHandler } = await import("./comments.handlers");
|
||||
|
||||
describe("comments mutations", () => {
|
||||
afterEach(() => {
|
||||
vi.mocked(assertModerator).mockReset();
|
||||
vi.mocked(requireUser).mockReset();
|
||||
vi.mocked(insertStatEvent).mockReset();
|
||||
vi.mocked(requireGitHubAccountAge).mockReset();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("add avoids direct skill patch and records stat event", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
vi.mocked(requireGitHubAccountAge).mockResolvedValue(undefined as never);
|
||||
|
||||
const get = vi.fn().mockResolvedValue({
|
||||
_id: "skills:1",
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = { db: { get, insert, patch } } as never;
|
||||
|
||||
await addHandler(ctx, { skillId: "skills:1", body: " hello " } as never);
|
||||
|
||||
expect(requireGitHubAccountAge).toHaveBeenCalledWith(ctx, "users:1");
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, {
|
||||
skillId: "skills:1",
|
||||
kind: "comment",
|
||||
});
|
||||
});
|
||||
|
||||
it("add blocks new comments when github account age gate fails", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:new",
|
||||
user: { _id: "users:new", role: "user" },
|
||||
} as never);
|
||||
vi.mocked(requireGitHubAccountAge).mockRejectedValue(
|
||||
new Error(
|
||||
"GitHub account must be at least 14 days old to upload skills. Try again in 3 days.",
|
||||
),
|
||||
);
|
||||
|
||||
const get = vi.fn();
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = { db: { get, insert, patch } } as never;
|
||||
|
||||
await expect(addHandler(ctx, { skillId: "skills:1", body: "hello" } as never)).rejects.toThrow(
|
||||
/at least 14 days old/i,
|
||||
);
|
||||
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insertStatEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remove keeps comment soft-delete patch free of updatedAt", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:2",
|
||||
user: { _id: "users:2", role: "moderator" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:1") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = { db: { get, insert, patch } } as never;
|
||||
|
||||
await removeHandler(ctx, { commentId: "comments:1" } as never);
|
||||
|
||||
expect(patch).toHaveBeenCalledTimes(1);
|
||||
const deletePatch = vi.mocked(patch).mock.calls[0]?.[1] as Record<string, unknown>;
|
||||
expect(deletePatch.updatedAt).toBeUndefined();
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, {
|
||||
skillId: "skills:1",
|
||||
kind: "uncomment",
|
||||
});
|
||||
});
|
||||
|
||||
it("remove rejects non-owner without moderator permission", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:3",
|
||||
user: { _id: "users:3", role: "user" },
|
||||
} as never);
|
||||
vi.mocked(assertModerator).mockImplementation(() => {
|
||||
throw new Error("Moderator role required");
|
||||
});
|
||||
|
||||
const comment = {
|
||||
_id: "comments:2",
|
||||
skillId: "skills:2",
|
||||
userId: "users:9",
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const get = vi.fn().mockResolvedValue(comment);
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = { db: { get, insert, patch } } as never;
|
||||
|
||||
await expect(removeHandler(ctx, { commentId: "comments:2" } as never)).rejects.toThrow(
|
||||
"Moderator role required",
|
||||
);
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insertStatEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("remove no-ops for soft-deleted comment", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:4",
|
||||
user: { _id: "users:4", role: "moderator" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:3",
|
||||
skillId: "skills:3",
|
||||
userId: "users:4",
|
||||
softDeletedAt: 123,
|
||||
};
|
||||
const get = vi.fn().mockResolvedValue(comment);
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const ctx = { db: { get, insert, patch } } as never;
|
||||
|
||||
await removeHandler(ctx, { commentId: "comments:3" } as never);
|
||||
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(insertStatEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report increments count and stores reason", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_000);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 1,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:1") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "commentReports") {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") {
|
||||
return { unique: vi.fn().mockResolvedValue(null) };
|
||||
}
|
||||
if (index === "by_user") {
|
||||
return { collect: vi.fn().mockResolvedValue([]) };
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
const result = await reportHandler(ctx, {
|
||||
commentId: "comments:1",
|
||||
reason: " spam ",
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false });
|
||||
expect(insert).toHaveBeenCalledWith("commentReports", {
|
||||
commentId: "comments:1",
|
||||
skillId: "skills:1",
|
||||
userId: "users:1",
|
||||
reason: "spam",
|
||||
createdAt: 1_700_000_000_000,
|
||||
});
|
||||
expect(patch).toHaveBeenCalledWith("comments:1", {
|
||||
reportCount: 2,
|
||||
lastReportedAt: 1_700_000_000_000,
|
||||
});
|
||||
expect(insertStatEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report returns alreadyReported for duplicate reporter/comment pair", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:dup",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:dup") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== "commentReports") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") {
|
||||
return { unique: vi.fn().mockResolvedValue({ _id: "commentReports:existing" }) };
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
const result = await reportHandler(ctx, { commentId: "comments:dup", reason: "spam" } as never);
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: false, alreadyReported: true });
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report rejects empty reason", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:empty",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:empty") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn();
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: "comments:empty", reason: " " } as never),
|
||||
).rejects.toThrow("Report reason required.");
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report rejects comment when parent skill is hidden/removed", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:hidden-parent",
|
||||
skillId: "skills:hidden",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:hidden-parent") return comment;
|
||||
if (id === "skills:hidden") {
|
||||
return { _id: "skills:hidden", softDeletedAt: 123, moderationStatus: "removed" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn();
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: "comments:hidden-parent", reason: "abuse" } as never),
|
||||
).rejects.toThrow("Comment not found");
|
||||
|
||||
expect(query).not.toHaveBeenCalled();
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report truncates long reason to 500 chars", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_050);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:long",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:long") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== "commentReports") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") return { unique: vi.fn().mockResolvedValue(null) };
|
||||
if (index === "by_user") return { collect: vi.fn().mockResolvedValue([]) };
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
await reportHandler(ctx, { commentId: "comments:long", reason: "x".repeat(700) } as never);
|
||||
|
||||
const reportInsert = vi.mocked(insert).mock.calls.find((call) => call[0] === "commentReports");
|
||||
expect(reportInsert?.[1]).toMatchObject({
|
||||
commentId: "comments:long",
|
||||
reason: "x".repeat(500),
|
||||
});
|
||||
});
|
||||
|
||||
it("report active-count filter ignores stale/non-active report targets", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:target2",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const reports = [
|
||||
{
|
||||
_id: "commentReports:1",
|
||||
commentId: "comments:deleted",
|
||||
userId: "users:1",
|
||||
skillId: "skills:1",
|
||||
},
|
||||
{
|
||||
_id: "commentReports:2",
|
||||
commentId: "comments:removed-skill",
|
||||
userId: "users:1",
|
||||
skillId: "skills:removed",
|
||||
},
|
||||
{
|
||||
_id: "commentReports:3",
|
||||
commentId: "comments:deleted-owner",
|
||||
userId: "users:1",
|
||||
skillId: "skills:active",
|
||||
},
|
||||
];
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:target2") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
if (id === "comments:deleted") {
|
||||
return {
|
||||
_id: "comments:deleted",
|
||||
softDeletedAt: 123,
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
};
|
||||
}
|
||||
if (id === "comments:removed-skill") {
|
||||
return {
|
||||
_id: "comments:removed-skill",
|
||||
softDeletedAt: undefined,
|
||||
skillId: "skills:removed",
|
||||
userId: "users:2",
|
||||
};
|
||||
}
|
||||
if (id === "skills:removed") {
|
||||
return { _id: "skills:removed", softDeletedAt: undefined, moderationStatus: "removed" };
|
||||
}
|
||||
if (id === "comments:deleted-owner") {
|
||||
return {
|
||||
_id: "comments:deleted-owner",
|
||||
softDeletedAt: undefined,
|
||||
skillId: "skills:active",
|
||||
userId: "users:deleted-owner",
|
||||
};
|
||||
}
|
||||
if (id === "skills:active") {
|
||||
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
if (id === "users:deleted-owner") {
|
||||
return { _id: "users:deleted-owner", deletedAt: 1, deactivatedAt: undefined };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table !== "commentReports") throw new Error(`Unexpected table ${table}`);
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") return { unique: vi.fn().mockResolvedValue(null) };
|
||||
if (index === "by_user") return { collect: vi.fn().mockResolvedValue(reports) };
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
const result = await reportHandler(ctx, {
|
||||
commentId: "comments:target2",
|
||||
reason: "still allowed",
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false });
|
||||
expect(insert).toHaveBeenCalledWith(
|
||||
"commentReports",
|
||||
expect.objectContaining({ commentId: "comments:target2", userId: "users:1" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("report rejects when active report limit is reached", async () => {
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:1",
|
||||
user: { _id: "users:1", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:target",
|
||||
skillId: "skills:1",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 0,
|
||||
};
|
||||
const reportedComment = {
|
||||
_id: "comments:reported",
|
||||
skillId: "skills:active",
|
||||
userId: "users:owner",
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const reports = Array.from({ length: 20 }, (_, i) => ({
|
||||
_id: `commentReports:${i + 1}`,
|
||||
commentId: `comments:reported-${i + 1}`,
|
||||
userId: "users:1",
|
||||
skillId: "skills:active",
|
||||
createdAt: i + 1,
|
||||
}));
|
||||
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:target") return comment;
|
||||
if (id === "skills:1") {
|
||||
return { _id: "skills:1", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
if (id.startsWith("comments:reported-")) return reportedComment;
|
||||
if (id === "skills:active") {
|
||||
return { _id: "skills:active", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
if (id === "users:owner") {
|
||||
return { _id: "users:owner", deletedAt: undefined, deactivatedAt: undefined };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "commentReports") {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") {
|
||||
return { unique: vi.fn().mockResolvedValue(null) };
|
||||
}
|
||||
if (index === "by_user") {
|
||||
return { collect: vi.fn().mockResolvedValue(reports) };
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
await expect(
|
||||
reportHandler(ctx, { commentId: "comments:target", reason: "abuse" } as never),
|
||||
).rejects.toThrow("Report limit reached. Please wait for moderation before reporting more.");
|
||||
|
||||
expect(insert).not.toHaveBeenCalled();
|
||||
expect(patch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("report auto-hides comment after fourth unique report", async () => {
|
||||
vi.spyOn(Date, "now").mockReturnValue(1_700_000_000_100);
|
||||
vi.mocked(requireUser).mockResolvedValue({
|
||||
userId: "users:3",
|
||||
user: { _id: "users:3", role: "user" },
|
||||
} as never);
|
||||
|
||||
const comment = {
|
||||
_id: "comments:4",
|
||||
skillId: "skills:9",
|
||||
userId: "users:2",
|
||||
softDeletedAt: undefined,
|
||||
reportCount: 3,
|
||||
};
|
||||
const get = vi.fn(async (id: string) => {
|
||||
if (id === "comments:4") return comment;
|
||||
if (id === "skills:9") {
|
||||
return { _id: "skills:9", softDeletedAt: undefined, moderationStatus: "active" };
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const insert = vi.fn();
|
||||
const patch = vi.fn();
|
||||
const query = vi.fn((table: string) => {
|
||||
if (table === "commentReports") {
|
||||
return {
|
||||
withIndex: (index: string) => {
|
||||
if (index === "by_comment_user") {
|
||||
return { unique: vi.fn().mockResolvedValue(null) };
|
||||
}
|
||||
if (index === "by_user") {
|
||||
return { collect: vi.fn().mockResolvedValue([]) };
|
||||
}
|
||||
throw new Error(`Unexpected index ${index}`);
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
});
|
||||
const ctx = { db: { get, insert, patch, query } } as never;
|
||||
|
||||
const result = await reportHandler(ctx, {
|
||||
commentId: "comments:4",
|
||||
reason: " hate ",
|
||||
} as never);
|
||||
|
||||
expect(result).toEqual({ ok: true, reported: true, alreadyReported: false });
|
||||
expect(patch).toHaveBeenCalledWith("comments:4", {
|
||||
reportCount: 4,
|
||||
lastReportedAt: 1_700_000_000_100,
|
||||
softDeletedAt: 1_700_000_000_100,
|
||||
});
|
||||
expect(insertStatEvent).toHaveBeenCalledWith(ctx, {
|
||||
skillId: "skills:9",
|
||||
kind: "uncomment",
|
||||
});
|
||||
expect(insert).toHaveBeenCalledWith("auditLogs", {
|
||||
actorUserId: "users:3",
|
||||
action: "comment.auto_hide",
|
||||
targetType: "comment",
|
||||
targetId: "comments:4",
|
||||
metadata: { skillId: "skills:9", reportCount: 4 },
|
||||
createdAt: 1_700_000_000_100,
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,87 +1,49 @@
|
||||
import { v } from 'convex/values'
|
||||
import type { Doc } from './_generated/dataModel'
|
||||
import { mutation, query } from './_generated/server'
|
||||
import { assertRole, requireUser } from './lib/access'
|
||||
import { v } from "convex/values";
|
||||
import type { Doc } from "./_generated/dataModel";
|
||||
import { addHandler, removeHandler, reportHandler } from "./comments.handlers";
|
||||
import { mutation, query } from "./functions";
|
||||
import { type PublicUser, toPublicUser } from "./lib/public";
|
||||
|
||||
export const listBySkill = query({
|
||||
args: { skillId: v.id('skills'), limit: v.optional(v.number()) },
|
||||
handler: async (ctx, args) => {
|
||||
const limit = args.limit ?? 50
|
||||
const comments = await ctx.db
|
||||
.query('comments')
|
||||
.withIndex('by_skill', (q) => q.eq('skillId', args.skillId))
|
||||
.order('desc')
|
||||
.take(limit)
|
||||
args: { skillId: v.id("skills"), limit: v.optional(v.number()) },
|
||||
handler: listBySkillHandler,
|
||||
});
|
||||
|
||||
const results: Array<{ comment: Doc<'comments'>; user: Doc<'users'> | null }> = []
|
||||
for (const comment of comments) {
|
||||
if (comment.softDeletedAt) continue
|
||||
const user = await ctx.db.get(comment.userId)
|
||||
results.push({ comment, user })
|
||||
}
|
||||
return results
|
||||
},
|
||||
})
|
||||
export async function listBySkillHandler(
|
||||
ctx: import("./_generated/server").QueryCtx,
|
||||
args: { skillId: import("./_generated/dataModel").Id<"skills">; limit?: number },
|
||||
) {
|
||||
const limit = args.limit ?? 50;
|
||||
const comments = await ctx.db
|
||||
.query("comments")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", args.skillId))
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
|
||||
const rows = await Promise.all(
|
||||
comments.map(
|
||||
async (comment): Promise<{ comment: Doc<"comments">; user: PublicUser } | null> => {
|
||||
if (comment.softDeletedAt) return null;
|
||||
const user = toPublicUser(await ctx.db.get(comment.userId));
|
||||
if (!user) return null;
|
||||
return { comment, user };
|
||||
},
|
||||
),
|
||||
);
|
||||
return rows.filter((row): row is { comment: Doc<"comments">; user: PublicUser } => row !== null);
|
||||
}
|
||||
|
||||
export const add = mutation({
|
||||
args: { skillId: v.id('skills'), body: v.string() },
|
||||
handler: async (ctx, args) => {
|
||||
const { userId } = await requireUser(ctx)
|
||||
const body = args.body.trim()
|
||||
if (!body) throw new Error('Comment body required')
|
||||
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) throw new Error('Skill not found')
|
||||
|
||||
await ctx.db.insert('comments', {
|
||||
skillId: args.skillId,
|
||||
userId,
|
||||
body,
|
||||
createdAt: Date.now(),
|
||||
softDeletedAt: undefined,
|
||||
deletedBy: undefined,
|
||||
})
|
||||
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: skill.stats.comments + 1 },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
args: { skillId: v.id("skills"), body: v.string() },
|
||||
handler: addHandler,
|
||||
});
|
||||
|
||||
export const remove = mutation({
|
||||
args: { commentId: v.id('comments') },
|
||||
handler: async (ctx, args) => {
|
||||
const { user } = await requireUser(ctx)
|
||||
const comment = await ctx.db.get(args.commentId)
|
||||
if (!comment) throw new Error('Comment not found')
|
||||
if (comment.softDeletedAt) return
|
||||
args: { commentId: v.id("comments") },
|
||||
handler: removeHandler,
|
||||
});
|
||||
|
||||
const isOwner = comment.userId === user._id
|
||||
if (!isOwner) {
|
||||
assertRole(user, ['admin', 'moderator'])
|
||||
}
|
||||
|
||||
await ctx.db.patch(comment._id, {
|
||||
softDeletedAt: Date.now(),
|
||||
deletedBy: user._id,
|
||||
})
|
||||
|
||||
const skill = await ctx.db.get(comment.skillId)
|
||||
if (skill) {
|
||||
await ctx.db.patch(skill._id, {
|
||||
stats: { ...skill.stats, comments: Math.max(0, skill.stats.comments - 1) },
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
await ctx.db.insert('auditLogs', {
|
||||
actorUserId: user._id,
|
||||
action: 'comment.delete',
|
||||
targetType: 'comment',
|
||||
targetId: comment._id,
|
||||
metadata: { skillId: comment.skillId },
|
||||
createdAt: Date.now(),
|
||||
})
|
||||
},
|
||||
})
|
||||
export const report = mutation({
|
||||
args: { commentId: v.id("comments"), reason: v.string() },
|
||||
handler: reportHandler,
|
||||
});
|
||||
|
||||
@ -1,27 +1,85 @@
|
||||
import { cronJobs } from 'convex/server'
|
||||
import { internal } from './_generated/api'
|
||||
import { cronJobs } from "convex/server";
|
||||
import { internal } from "./_generated/api";
|
||||
|
||||
const crons = cronJobs()
|
||||
const crons = cronJobs();
|
||||
|
||||
crons.interval(
|
||||
'github-backup-sync',
|
||||
"github-backup-sync",
|
||||
{ minutes: 30 },
|
||||
internal.githubBackupsNode.syncGitHubBackupsInternal,
|
||||
{ batchSize: 50, maxBatches: 5 },
|
||||
)
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
'trending-leaderboard',
|
||||
"trending-leaderboard",
|
||||
{ minutes: 60 },
|
||||
internal.leaderboards.rebuildTrendingLeaderboardInternal,
|
||||
internal.leaderboards.rebuildTrendingLeaderboardAction,
|
||||
{ limit: 200 },
|
||||
)
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
'skill-stats-backfill',
|
||||
{ minutes: 10 },
|
||||
"skill-stats-backfill",
|
||||
{ hours: 6 },
|
||||
internal.statsMaintenance.runSkillStatBackfillInternal,
|
||||
{ batchSize: 200, maxBatches: 5 },
|
||||
)
|
||||
);
|
||||
|
||||
export default crons
|
||||
// Runs frequently to keep dailyStats/trending accurate,
|
||||
// but does NOT patch skill documents (only writes to skillDailyStats).
|
||||
crons.interval(
|
||||
"skill-stat-events",
|
||||
{ minutes: 15 },
|
||||
internal.skillStatEvents.processSkillStatEventsAction,
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"package-stat-events",
|
||||
{ minutes: 15 },
|
||||
internal.packages.processPackageStatEventsInternal,
|
||||
{ batchSize: 500 },
|
||||
);
|
||||
|
||||
// Syncs accumulated stat deltas to skill documents every 6 hours.
|
||||
// Runs infrequently to avoid thundering-herd reactive query invalidation.
|
||||
// Uses processedAt field to track progress (independent of the action cursor).
|
||||
crons.interval(
|
||||
"skill-doc-stat-sync",
|
||||
{ hours: 6 },
|
||||
internal.skillStatEvents.processSkillStatEventsInternal,
|
||||
{ batchSize: 100 },
|
||||
);
|
||||
|
||||
crons.interval(
|
||||
"global-stats-update",
|
||||
{ hours: 24 },
|
||||
internal.statsMaintenance.updateGlobalStatsAction,
|
||||
{},
|
||||
);
|
||||
|
||||
crons.interval("vt-pending-scans", { minutes: 5 }, internal.vt.pollPendingScans, {
|
||||
batchSize: 100,
|
||||
});
|
||||
|
||||
crons.interval("vt-cache-backfill", { minutes: 30 }, internal.vt.backfillActiveSkillsVTCache, {
|
||||
batchSize: 100,
|
||||
});
|
||||
|
||||
crons.interval(
|
||||
"package-scan-backfill",
|
||||
{ minutes: 30 },
|
||||
internal.packages.backfillPackageReleaseScansInternal,
|
||||
{ batchSize: 100 },
|
||||
);
|
||||
|
||||
// Daily re-scan of all active skills at 3am UTC
|
||||
crons.daily("vt-daily-rescan", { hourUTC: 3, minuteUTC: 0 }, internal.vt.rescanActiveSkills, {});
|
||||
|
||||
crons.interval(
|
||||
"download-dedupe-prune",
|
||||
{ hours: 24 },
|
||||
internal.downloads.pruneDownloadDedupesInternal,
|
||||
{},
|
||||
);
|
||||
|
||||
export default crons;
|
||||
|
||||
270
convex/depRegistryScan.ts
Normal file
270
convex/depRegistryScan.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Doc, Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation, internalQuery } from "./functions";
|
||||
import {
|
||||
dedupeDeps,
|
||||
depRegistryUrl,
|
||||
parseDependencyFile,
|
||||
SUPPORTED_DEP_REGISTRIES,
|
||||
summarizeDepRegistryChecks,
|
||||
type DepEntry,
|
||||
type DepRegistryResult,
|
||||
type DepRegistryUnresolved,
|
||||
type SupportedDepRegistry,
|
||||
} from "./lib/depRegistryScan";
|
||||
import { readStorageText } from "./lib/packageRegistry";
|
||||
|
||||
const REQUEST_TIMEOUT_MS = 8_000;
|
||||
const MAX_RETRIES = 2;
|
||||
const BACKOFF_BASE_MS = 750;
|
||||
const INTER_REQUEST_DELAY_MS = 100;
|
||||
const MAX_DEPENDENCIES_PER_SCAN = 120;
|
||||
const CACHE_TTL_EXISTS_MS = 30 * 24 * 60 * 60 * 1_000;
|
||||
const CACHE_TTL_NOT_EXISTS_MS = 7 * 24 * 60 * 60 * 1_000;
|
||||
|
||||
const registryValidator = v.union(v.literal("pypi"), v.literal("npm"), v.literal("cargo"));
|
||||
|
||||
type RegistryCheck =
|
||||
| { kind: "found"; httpStatus: number }
|
||||
| { kind: "missing"; httpStatus: number }
|
||||
| { kind: "unresolved"; reason: string };
|
||||
|
||||
function isSupportedRegistry(value: string): value is SupportedDepRegistry {
|
||||
return (SUPPORTED_DEP_REGISTRIES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
async function wait(ms: number) {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function checkRegistry(dep: DepEntry): Promise<RegistryCheck> {
|
||||
const headers: Record<string, string> = { Accept: "application/json" };
|
||||
if (dep.registry === "cargo") {
|
||||
headers["User-Agent"] = "ClawHub-DepRegistryScan/1.0 (https://clawhub.ai)";
|
||||
}
|
||||
|
||||
let lastStatus: number | undefined;
|
||||
for (let attempt = 0; attempt <= MAX_RETRIES; attempt += 1) {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(depRegistryUrl(dep.registry, dep.name), {
|
||||
method: "GET",
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
lastStatus = response.status;
|
||||
if (response.status === 200) return { kind: "found", httpStatus: response.status };
|
||||
if (response.status === 404) return { kind: "missing", httpStatus: response.status };
|
||||
if (response.status !== 429 && response.status < 500) {
|
||||
return {
|
||||
kind: "unresolved",
|
||||
reason: `unexpected HTTP ${response.status}`,
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
clearTimeout(timeout);
|
||||
if (attempt === MAX_RETRIES) {
|
||||
return {
|
||||
kind: "unresolved",
|
||||
reason: error instanceof Error ? error.message : "network error",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (attempt < MAX_RETRIES) {
|
||||
await wait(2 ** attempt * BACKOFF_BASE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "unresolved",
|
||||
reason: lastStatus ? `HTTP ${lastStatus}` : "network error",
|
||||
};
|
||||
}
|
||||
|
||||
async function extractDependencies(ctx: Pick<ActionCtx, "storage">, version: Doc<"skillVersions">) {
|
||||
const entries: DepEntry[] = [];
|
||||
for (const file of version.files) {
|
||||
const basename = file.path.split("/").pop()?.toLowerCase() ?? "";
|
||||
if (
|
||||
basename !== "requirements.txt" &&
|
||||
basename !== "requirements-dev.txt" &&
|
||||
basename !== "requirements_dev.txt" &&
|
||||
basename !== "requirements-test.txt" &&
|
||||
basename !== "requirements_test.txt" &&
|
||||
basename !== "package.json" &&
|
||||
basename !== "cargo.toml" &&
|
||||
basename !== "pyproject.toml"
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const content = await readStorageText(ctx, file.storageId);
|
||||
entries.push(...parseDependencyFile(file.path, content));
|
||||
}
|
||||
return dedupeDeps(entries);
|
||||
}
|
||||
|
||||
export const lookupCacheInternal = internalQuery({
|
||||
args: {
|
||||
registry: registryValidator,
|
||||
name: v.string(),
|
||||
},
|
||||
handler: async (ctx, args): Promise<Doc<"depRegistryCache"> | null> => {
|
||||
return ctx.db
|
||||
.query("depRegistryCache")
|
||||
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
|
||||
.unique();
|
||||
},
|
||||
});
|
||||
|
||||
export const upsertCacheInternal = internalMutation({
|
||||
args: {
|
||||
registry: registryValidator,
|
||||
name: v.string(),
|
||||
exists: v.boolean(),
|
||||
httpStatus: v.number(),
|
||||
checkedAt: v.number(),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("depRegistryCache")
|
||||
.withIndex("by_registry_name", (q) => q.eq("registry", args.registry).eq("name", args.name))
|
||||
.unique();
|
||||
const patch = {
|
||||
registry: args.registry,
|
||||
name: args.name,
|
||||
exists: args.exists,
|
||||
httpStatus: args.httpStatus,
|
||||
checkedAt: args.checkedAt,
|
||||
};
|
||||
if (existing) {
|
||||
await ctx.db.patch(existing._id, patch);
|
||||
} else {
|
||||
await ctx.db.insert("depRegistryCache", patch);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export const getRetryableVersionIdsInternal = internalQuery({
|
||||
args: {
|
||||
limit: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const limit = Math.min(Math.max(args.limit ?? 25, 1), 100);
|
||||
const versions = await ctx.db
|
||||
.query("skillVersions")
|
||||
.withIndex("by_dep_registry_scan_status_and_created", (q) =>
|
||||
q.eq("depRegistryScanStatus", "error"),
|
||||
)
|
||||
.order("desc")
|
||||
.take(limit);
|
||||
return versions.map((version) => version._id);
|
||||
},
|
||||
});
|
||||
|
||||
async function checkWithCache(ctx: ActionCtx, dep: DepEntry) {
|
||||
const now = Date.now();
|
||||
const cached = (await ctx.runQuery(internal.depRegistryScan.lookupCacheInternal, {
|
||||
registry: dep.registry,
|
||||
name: dep.name,
|
||||
})) as Doc<"depRegistryCache"> | null;
|
||||
if (cached) {
|
||||
const ttl = cached.exists ? CACHE_TTL_EXISTS_MS : CACHE_TTL_NOT_EXISTS_MS;
|
||||
if (now - cached.checkedAt < ttl) {
|
||||
return cached.exists
|
||||
? ({ kind: "found", httpStatus: cached.httpStatus } as const)
|
||||
: ({ kind: "missing", httpStatus: cached.httpStatus } as const);
|
||||
}
|
||||
}
|
||||
|
||||
const check = await checkRegistry(dep);
|
||||
if (check.kind !== "unresolved") {
|
||||
await ctx.runMutation(internal.depRegistryScan.upsertCacheInternal, {
|
||||
registry: dep.registry,
|
||||
name: dep.name,
|
||||
exists: check.kind === "found",
|
||||
httpStatus: check.httpStatus,
|
||||
checkedAt: now,
|
||||
});
|
||||
}
|
||||
return check;
|
||||
}
|
||||
|
||||
export const checkDependencyRegistries = internalAction({
|
||||
args: { versionId: v.id("skillVersions") },
|
||||
handler: async (ctx, args) => {
|
||||
const version = (await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: args.versionId,
|
||||
})) as Doc<"skillVersions"> | null;
|
||||
if (!version) return null;
|
||||
if (version.depRegistryAnalysis && version.depRegistryAnalysis.status !== "error") {
|
||||
return version.depRegistryAnalysis;
|
||||
}
|
||||
|
||||
const deps = await extractDependencies(ctx, version);
|
||||
const checkableDeps = deps.slice(0, MAX_DEPENDENCIES_PER_SCAN);
|
||||
const deferredDeps = deps.slice(MAX_DEPENDENCIES_PER_SCAN);
|
||||
const results: DepRegistryResult[] = [];
|
||||
const unresolved: DepRegistryUnresolved[] = deferredDeps.map((dep) => ({
|
||||
...dep,
|
||||
reason: "dependency scan limit reached",
|
||||
}));
|
||||
|
||||
for (const dep of checkableDeps) {
|
||||
if (!isSupportedRegistry(dep.registry)) continue;
|
||||
const check = await checkWithCache(ctx, dep);
|
||||
if (check.kind === "unresolved") {
|
||||
unresolved.push({ ...dep, reason: check.reason });
|
||||
} else {
|
||||
results.push({
|
||||
...dep,
|
||||
exists: check.kind === "found",
|
||||
httpStatus: check.httpStatus,
|
||||
});
|
||||
}
|
||||
await wait(INTER_REQUEST_DELAY_MS);
|
||||
}
|
||||
|
||||
const analysis = summarizeDepRegistryChecks({
|
||||
results,
|
||||
unresolved,
|
||||
checkedAt: Date.now(),
|
||||
});
|
||||
|
||||
await ctx.runMutation(internal.skills.updateVersionDepRegistryAnalysisInternal, {
|
||||
versionId: args.versionId,
|
||||
depRegistryAnalysis: analysis,
|
||||
});
|
||||
|
||||
return analysis;
|
||||
},
|
||||
});
|
||||
|
||||
export const rescanErrorDepRegistryVersions = internalAction({
|
||||
args: {
|
||||
batchSize: v.optional(v.number()),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
const versionIds = (await ctx.runQuery(
|
||||
internal.depRegistryScan.getRetryableVersionIdsInternal,
|
||||
{ limit: args.batchSize ?? 25 },
|
||||
)) as Id<"skillVersions">[];
|
||||
|
||||
let scheduled = 0;
|
||||
for (const versionId of versionIds) {
|
||||
await ctx.scheduler.runAfter(
|
||||
scheduled * 2_000,
|
||||
internal.depRegistryScan.checkDependencyRegistries,
|
||||
{
|
||||
versionId,
|
||||
},
|
||||
);
|
||||
scheduled += 1;
|
||||
}
|
||||
return { scheduled };
|
||||
},
|
||||
});
|
||||
175
convex/devSeed.rescanFixtures.test.ts
Normal file
175
convex/devSeed.rescanFixtures.test.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { seedRescanUxFixturesHandler } from "./devSeed";
|
||||
import { MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE } from "./model/rescans/policy";
|
||||
|
||||
function chainEq(constraints: Record<string, unknown>) {
|
||||
return {
|
||||
eq(field: string, value: unknown) {
|
||||
constraints[field] = value;
|
||||
return chainEq(constraints);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function matches(doc: Record<string, unknown>, constraints: Record<string, unknown>) {
|
||||
return Object.entries(constraints).every(([key, value]) => doc[key] === value);
|
||||
}
|
||||
|
||||
function createDb() {
|
||||
const tables: Record<string, Array<Record<string, unknown> & { _id: string }>> = {};
|
||||
const counters: Record<string, number> = {};
|
||||
|
||||
const list = (table: string) => {
|
||||
tables[table] ??= [];
|
||||
return tables[table];
|
||||
};
|
||||
|
||||
const db = {
|
||||
get: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
return list(table).find((doc) => doc._id === id) ?? null;
|
||||
},
|
||||
insert: async (table: string, doc: Record<string, unknown>) => {
|
||||
counters[table] = (counters[table] ?? 0) + 1;
|
||||
const inserted = {
|
||||
_id: `${table}:${counters[table]}`,
|
||||
_creationTime: counters[table],
|
||||
...doc,
|
||||
};
|
||||
list(table).push(inserted);
|
||||
return inserted._id;
|
||||
},
|
||||
patch: async (id: string, patch: Record<string, unknown>) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const doc = list(table).find((candidate) => candidate._id === id);
|
||||
if (doc) Object.assign(doc, patch);
|
||||
},
|
||||
delete: async (id: string) => {
|
||||
const table = id.split(":")[0] ?? "";
|
||||
const rows = list(table);
|
||||
const index = rows.findIndex((doc) => doc._id === id);
|
||||
if (index !== -1) rows.splice(index, 1);
|
||||
},
|
||||
query: (table: string) => ({
|
||||
withIndex: (_name: string, build: (q: ReturnType<typeof chainEq>) => unknown) => {
|
||||
const constraints: Record<string, unknown> = {};
|
||||
build(chainEq(constraints));
|
||||
const matched = () =>
|
||||
list(table).filter((doc) => matches(doc as Record<string, unknown>, constraints));
|
||||
return {
|
||||
collect: async () => matched(),
|
||||
unique: async () => matched()[0] ?? null,
|
||||
order: () => ({
|
||||
collect: async () => matched(),
|
||||
}),
|
||||
};
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
return { db, tables };
|
||||
}
|
||||
|
||||
describe("devSeed rescan UX fixtures", () => {
|
||||
it("seeds flagged local owner inventory and deterministic rescan counts idempotently", async () => {
|
||||
const { db, tables } = createDb();
|
||||
const args = {
|
||||
flaggedSkillStorageId: "storage:skill",
|
||||
flaggedSkillMd: "# Flagged skill",
|
||||
scannedSkillStorageId: "storage:scanned-skill",
|
||||
scannedSkillMd: "# Scanned skill",
|
||||
flaggedPluginStorageId: "storage:plugin",
|
||||
flaggedPluginReadme: "# Flagged plugin",
|
||||
scannedPluginStorageId: "storage:scanned-plugin",
|
||||
scannedPluginReadme: "# Scanned plugin",
|
||||
};
|
||||
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, args as never);
|
||||
await seedRescanUxFixturesHandler({ db } as never, { ...args, reset: true } as never);
|
||||
|
||||
expect(tables.users).toHaveLength(1);
|
||||
expect(tables.users?.[0]).toEqual(expect.objectContaining({ handle: "local" }));
|
||||
expect(tables.publishers).toHaveLength(1);
|
||||
expect(tables.skills).toHaveLength(2);
|
||||
expect(tables.skills?.find((skill) => skill.slug === "local-flagged-wallet-sync")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
moderationStatus: "hidden",
|
||||
moderationVerdict: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.skills?.find((skill) => skill.slug === "local-agentic-risk-demo")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
moderationStatus: "active",
|
||||
moderationVerdict: "suspicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages).toHaveLength(2);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-flagged-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "malicious",
|
||||
}),
|
||||
);
|
||||
expect(tables.packages?.find((pkg) => pkg.name === "local-scanned-runtime-plugin")).toEqual(
|
||||
expect.objectContaining({
|
||||
ownerUserId: tables.users?.[0]?._id,
|
||||
ownerPublisherId: tables.publishers?.[0]?._id,
|
||||
scanStatus: "suspicious",
|
||||
}),
|
||||
);
|
||||
|
||||
const scannedPackage = tables.packages?.find(
|
||||
(pkg) => pkg.name === "local-scanned-runtime-plugin",
|
||||
);
|
||||
const scannedRelease = tables.packageReleases?.find(
|
||||
(release) => release.packageId === scannedPackage?._id,
|
||||
);
|
||||
expect(scannedRelease).toEqual(
|
||||
expect.objectContaining({
|
||||
sha256hash: "seeded-scanned-plugin-hash",
|
||||
vtAnalysis: expect.objectContaining({ status: "clean" }),
|
||||
llmAnalysis: expect.objectContaining({ status: "suspicious" }),
|
||||
staticScan: expect.objectContaining({ status: "suspicious" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const scannedSkill = tables.skills?.find((skill) => skill.slug === "local-agentic-risk-demo");
|
||||
const scannedSkillVersion = tables.skillVersions?.find(
|
||||
(version) => version.skillId === scannedSkill?._id,
|
||||
);
|
||||
expect(scannedSkillVersion).toEqual(
|
||||
expect.objectContaining({
|
||||
sha256hash: "seeded-agentic-risk-skill-hash",
|
||||
vtAnalysis: expect.objectContaining({ status: "clean" }),
|
||||
llmAnalysis: expect.objectContaining({
|
||||
status: "suspicious",
|
||||
riskSummary: expect.objectContaining({
|
||||
sensitive_data_protection: expect.objectContaining({ status: "concern" }),
|
||||
}),
|
||||
agenticRiskFindings: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
categoryId: "ASI06",
|
||||
riskBucket: "sensitive_data_protection",
|
||||
status: "concern",
|
||||
evidence: expect.objectContaining({ path: "SKILL.md" }),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
staticScan: expect.objectContaining({ status: "suspicious" }),
|
||||
}),
|
||||
);
|
||||
|
||||
const skillRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "skill") ?? [];
|
||||
const pluginRequests =
|
||||
tables.rescanRequests?.filter((request) => request.targetKind === "plugin") ?? [];
|
||||
expect(skillRequests).toHaveLength(1);
|
||||
expect(pluginRequests).toHaveLength(MAX_OWNER_RESCAN_REQUESTS_PER_RELEASE);
|
||||
});
|
||||
});
|
||||
1938
convex/devSeed.ts
1938
convex/devSeed.ts
File diff suppressed because it is too large
Load Diff
557
convex/devSeedExtra.ts
Normal file
557
convex/devSeedExtra.ts
Normal file
@ -0,0 +1,557 @@
|
||||
/**
|
||||
* Extra seed skills for pagination testing.
|
||||
*
|
||||
* This file contains 50 placeholder skills to test pagination behavior.
|
||||
* Run with: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal
|
||||
* Or with reset: bunx convex run internal.devSeedExtra.seedExtraSkillsInternal '{"reset": true}'
|
||||
*/
|
||||
|
||||
import { v } from "convex/values";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { Id } from "./_generated/dataModel";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { internalAction, internalMutation } from "./functions";
|
||||
import { parseClawdisMetadata, parseFrontmatter } from "./lib/skills";
|
||||
|
||||
type SeedSkillSpec = {
|
||||
slug: string;
|
||||
displayName: string;
|
||||
summary: string;
|
||||
version: string;
|
||||
metadata: Record<string, unknown>;
|
||||
rawSkillMd: string;
|
||||
};
|
||||
|
||||
function makeSkill(
|
||||
slug: string,
|
||||
displayName: string,
|
||||
summary: string,
|
||||
envVars: string[] = [],
|
||||
commands: string[] = ["help", "status", "run"],
|
||||
): SeedSkillSpec {
|
||||
const cliHelp = `${slug} - ${summary}
|
||||
|
||||
Usage:
|
||||
${slug} [command]
|
||||
|
||||
Commands:
|
||||
${commands.map((cmd) => ` ${cmd.padEnd(12)} Run ${cmd} operation`).join("\n")}
|
||||
|
||||
Flags:
|
||||
-h, --help Show help
|
||||
--json Output as JSON
|
||||
`;
|
||||
|
||||
const rawSkillMd = `---
|
||||
name: ${slug}
|
||||
description: ${summary}
|
||||
---
|
||||
|
||||
# ${displayName}
|
||||
|
||||
## CLI
|
||||
|
||||
\`\`\`bash
|
||||
${commands.map((cmd) => `${slug} ${cmd}`).join("\n")}
|
||||
\`\`\`
|
||||
|
||||
## Usage
|
||||
|
||||
Use this skill to ${summary.toLowerCase()}.
|
||||
`;
|
||||
|
||||
return {
|
||||
slug,
|
||||
displayName,
|
||||
summary,
|
||||
version: "0.1.0",
|
||||
metadata: {
|
||||
clawdbot: {
|
||||
nix: {
|
||||
plugin: `github:example/${slug}`,
|
||||
systems: ["aarch64-darwin", "x86_64-linux"],
|
||||
},
|
||||
config: {
|
||||
requiredEnv: envVars,
|
||||
},
|
||||
cliHelp,
|
||||
},
|
||||
},
|
||||
rawSkillMd,
|
||||
};
|
||||
}
|
||||
|
||||
// 50 placeholder skills for pagination testing
|
||||
const EXTRA_SEED_SKILLS: SeedSkillSpec[] = [
|
||||
// DevOps & Infrastructure (10)
|
||||
makeSkill(
|
||||
"kubectl-helper",
|
||||
"Kubectl Helper",
|
||||
"Simplified kubectl commands for common Kubernetes operations.",
|
||||
["KUBECONFIG"],
|
||||
["pods", "logs", "exec", "describe", "apply"],
|
||||
),
|
||||
makeSkill(
|
||||
"terraform-runner",
|
||||
"Terraform Runner",
|
||||
"Execute Terraform plans and applies with safety checks.",
|
||||
["TF_VAR_region", "AWS_PROFILE"],
|
||||
["plan", "apply", "destroy", "output", "state"],
|
||||
),
|
||||
makeSkill(
|
||||
"ansible-exec",
|
||||
"Ansible Exec",
|
||||
"Run Ansible playbooks and ad-hoc commands.",
|
||||
["ANSIBLE_INVENTORY"],
|
||||
["playbook", "adhoc", "inventory", "facts", "vault"],
|
||||
),
|
||||
makeSkill(
|
||||
"docker-compose-mgr",
|
||||
"Docker Compose Manager",
|
||||
"Manage Docker Compose stacks and services.",
|
||||
["DOCKER_HOST"],
|
||||
["up", "down", "logs", "ps", "restart"],
|
||||
),
|
||||
makeSkill(
|
||||
"k9s-wrapper",
|
||||
"K9s Wrapper",
|
||||
"Interactive Kubernetes cluster management via K9s.",
|
||||
["KUBECONFIG"],
|
||||
["launch", "contexts", "namespaces", "pods", "logs"],
|
||||
),
|
||||
makeSkill(
|
||||
"helm-charts",
|
||||
"Helm Charts",
|
||||
"Manage Helm chart deployments and releases.",
|
||||
["KUBECONFIG", "HELM_REPO"],
|
||||
["install", "upgrade", "rollback", "list", "search"],
|
||||
),
|
||||
makeSkill(
|
||||
"prometheus-alerts",
|
||||
"Prometheus Alerts",
|
||||
"Query Prometheus metrics and manage alerting rules.",
|
||||
["PROMETHEUS_URL"],
|
||||
["query", "alerts", "rules", "targets", "status"],
|
||||
),
|
||||
makeSkill(
|
||||
"grafana-dash",
|
||||
"Grafana Dashboards",
|
||||
"Create and manage Grafana dashboards programmatically.",
|
||||
["GRAFANA_URL", "GRAFANA_API_KEY"],
|
||||
["list", "export", "import", "create", "delete"],
|
||||
),
|
||||
makeSkill(
|
||||
"nginx-config",
|
||||
"Nginx Config",
|
||||
"Generate and validate Nginx configuration files.",
|
||||
["NGINX_CONF_DIR"],
|
||||
["generate", "validate", "reload", "test", "sites"],
|
||||
),
|
||||
makeSkill(
|
||||
"jenkins-jobs",
|
||||
"Jenkins Jobs",
|
||||
"Manage Jenkins jobs and pipelines.",
|
||||
["JENKINS_URL", "JENKINS_TOKEN"],
|
||||
["list", "build", "status", "logs", "config"],
|
||||
),
|
||||
|
||||
// Productivity (8)
|
||||
makeSkill(
|
||||
"todoist-sync",
|
||||
"Todoist Sync",
|
||||
"Sync and manage Todoist tasks from the command line.",
|
||||
["TODOIST_API_TOKEN"],
|
||||
["list", "add", "complete", "projects", "labels"],
|
||||
),
|
||||
makeSkill(
|
||||
"notion-backup",
|
||||
"Notion Backup",
|
||||
"Export and backup Notion workspaces.",
|
||||
["NOTION_TOKEN"],
|
||||
["export", "backup", "restore", "pages", "databases"],
|
||||
),
|
||||
makeSkill(
|
||||
"gcal-manager",
|
||||
"Google Calendar Manager",
|
||||
"Manage Google Calendar events and schedules.",
|
||||
["GOOGLE_CREDENTIALS_FILE"],
|
||||
["events", "create", "delete", "calendars", "reminders"],
|
||||
),
|
||||
makeSkill(
|
||||
"time-tracker",
|
||||
"Time Tracker",
|
||||
"Track time spent on projects and tasks.",
|
||||
["TIMETRACK_DB"],
|
||||
["start", "stop", "status", "report", "projects"],
|
||||
),
|
||||
makeSkill(
|
||||
"email-digest",
|
||||
"Email Digest",
|
||||
"Generate email digests and summaries.",
|
||||
["IMAP_SERVER", "IMAP_USER"],
|
||||
["fetch", "digest", "search", "folders", "unread"],
|
||||
),
|
||||
makeSkill(
|
||||
"habit-tracker",
|
||||
"Habit Tracker",
|
||||
"Track daily habits and streaks.",
|
||||
["HABITS_DB"],
|
||||
["log", "streak", "stats", "habits", "remind"],
|
||||
),
|
||||
makeSkill(
|
||||
"bookmark-sync",
|
||||
"Bookmark Sync",
|
||||
"Sync bookmarks across browsers and devices.",
|
||||
["BOOKMARKS_DIR"],
|
||||
["sync", "export", "import", "search", "tags"],
|
||||
),
|
||||
makeSkill(
|
||||
"notes-export",
|
||||
"Notes Export",
|
||||
"Export notes to various formats.",
|
||||
["NOTES_DIR"],
|
||||
["export", "convert", "search", "list", "tags"],
|
||||
),
|
||||
|
||||
// Media & Entertainment (6)
|
||||
makeSkill(
|
||||
"spotify-ctl",
|
||||
"Spotify Control",
|
||||
"Control Spotify playback from the terminal.",
|
||||
["SPOTIFY_CLIENT_ID", "SPOTIFY_CLIENT_SECRET"],
|
||||
["play", "pause", "next", "prev", "search"],
|
||||
),
|
||||
makeSkill(
|
||||
"plex-manager",
|
||||
"Plex Manager",
|
||||
"Manage Plex media libraries and playback.",
|
||||
["PLEX_URL", "PLEX_TOKEN"],
|
||||
["libraries", "scan", "search", "play", "sessions"],
|
||||
),
|
||||
makeSkill(
|
||||
"ytdl-wrapper",
|
||||
"YouTube Downloader",
|
||||
"Download videos from YouTube and other platforms.",
|
||||
["YTDL_OUTPUT_DIR"],
|
||||
["download", "info", "playlist", "audio", "formats"],
|
||||
),
|
||||
makeSkill(
|
||||
"podcast-dl",
|
||||
"Podcast Downloader",
|
||||
"Download and manage podcast episodes.",
|
||||
["PODCAST_DIR"],
|
||||
["subscribe", "download", "list", "play", "search"],
|
||||
),
|
||||
makeSkill(
|
||||
"audiobook-player",
|
||||
"Audiobook Player",
|
||||
"Manage and play audiobook collections.",
|
||||
["AUDIOBOOK_DIR"],
|
||||
["play", "pause", "bookmark", "list", "progress"],
|
||||
),
|
||||
makeSkill(
|
||||
"music-lib",
|
||||
"Music Library",
|
||||
"Organize and query local music libraries.",
|
||||
["MUSIC_DIR"],
|
||||
["scan", "search", "play", "playlist", "stats"],
|
||||
),
|
||||
|
||||
// Smart Home (8)
|
||||
makeSkill(
|
||||
"hass-control",
|
||||
"Home Assistant Control",
|
||||
"Control Home Assistant entities and automations.",
|
||||
["HASS_URL", "HASS_TOKEN"],
|
||||
["entities", "services", "automations", "scenes", "history"],
|
||||
),
|
||||
makeSkill(
|
||||
"zigbee-mqtt",
|
||||
"Zigbee2MQTT",
|
||||
"Manage Zigbee devices via MQTT.",
|
||||
["MQTT_BROKER", "ZIGBEE_TOPIC"],
|
||||
["devices", "pair", "remove", "rename", "groups"],
|
||||
),
|
||||
makeSkill(
|
||||
"tasmota-ctl",
|
||||
"Tasmota Control",
|
||||
"Control Tasmota-flashed devices.",
|
||||
["TASMOTA_HOSTS"],
|
||||
["status", "power", "config", "update", "backup"],
|
||||
),
|
||||
makeSkill(
|
||||
"esphome-mgr",
|
||||
"ESPHome Manager",
|
||||
"Manage ESPHome device configurations.",
|
||||
["ESPHOME_DIR"],
|
||||
["compile", "upload", "logs", "dashboard", "config"],
|
||||
),
|
||||
makeSkill(
|
||||
"mqtt-broker",
|
||||
"MQTT Broker",
|
||||
"Interact with MQTT brokers for IoT messaging.",
|
||||
["MQTT_BROKER", "MQTT_USER"],
|
||||
["pub", "sub", "topics", "clients", "stats"],
|
||||
),
|
||||
makeSkill(
|
||||
"hue-lights",
|
||||
"Philips Hue",
|
||||
"Control Philips Hue lights and scenes.",
|
||||
["HUE_BRIDGE_IP", "HUE_API_KEY"],
|
||||
["lights", "scenes", "groups", "schedules", "sensors"],
|
||||
),
|
||||
makeSkill(
|
||||
"smart-thermo",
|
||||
"Smart Thermostat",
|
||||
"Control smart thermostats and HVAC systems.",
|
||||
["THERMOSTAT_API_KEY"],
|
||||
["status", "set", "schedule", "history", "zones"],
|
||||
),
|
||||
makeSkill(
|
||||
"cam-viewer",
|
||||
"Camera Viewer",
|
||||
"View and manage security camera feeds.",
|
||||
["CAMERA_URLS"],
|
||||
["list", "snapshot", "stream", "record", "events"],
|
||||
),
|
||||
|
||||
// Finance (5)
|
||||
makeSkill(
|
||||
"budget-track",
|
||||
"Budget Tracker",
|
||||
"Track budgets and spending across categories.",
|
||||
["BUDGET_DB"],
|
||||
["summary", "add", "categories", "report", "goals"],
|
||||
),
|
||||
makeSkill(
|
||||
"crypto-watch",
|
||||
"Crypto Watcher",
|
||||
"Monitor cryptocurrency prices and portfolios.",
|
||||
["CRYPTO_API_KEY"],
|
||||
["prices", "portfolio", "alerts", "history", "convert"],
|
||||
),
|
||||
makeSkill(
|
||||
"stock-alerts",
|
||||
"Stock Alerts",
|
||||
"Set up stock price alerts and notifications.",
|
||||
["STOCK_API_KEY"],
|
||||
["quote", "watch", "alerts", "portfolio", "news"],
|
||||
),
|
||||
makeSkill(
|
||||
"expense-cat",
|
||||
"Expense Categorizer",
|
||||
"Automatically categorize expenses.",
|
||||
["EXPENSE_DB"],
|
||||
["import", "categorize", "report", "rules", "export"],
|
||||
),
|
||||
makeSkill(
|
||||
"invoice-gen",
|
||||
"Invoice Generator",
|
||||
"Generate and manage invoices.",
|
||||
["INVOICE_DIR", "COMPANY_INFO"],
|
||||
["create", "list", "send", "paid", "overdue"],
|
||||
),
|
||||
|
||||
// Communication (5)
|
||||
makeSkill(
|
||||
"slack-bot",
|
||||
"Slack Bot",
|
||||
"Interact with Slack channels and messages.",
|
||||
["SLACK_TOKEN"],
|
||||
["send", "channels", "users", "search", "files"],
|
||||
),
|
||||
makeSkill(
|
||||
"discord-mgr",
|
||||
"Discord Manager",
|
||||
"Manage Discord servers and messages.",
|
||||
["DISCORD_TOKEN"],
|
||||
["send", "servers", "channels", "members", "roles"],
|
||||
),
|
||||
makeSkill(
|
||||
"telegram-bot",
|
||||
"Telegram Bot",
|
||||
"Send and receive Telegram messages.",
|
||||
["TELEGRAM_BOT_TOKEN"],
|
||||
["send", "receive", "chats", "files", "inline"],
|
||||
),
|
||||
makeSkill(
|
||||
"matrix-cli",
|
||||
"Matrix CLI",
|
||||
"Interact with Matrix chat rooms.",
|
||||
["MATRIX_HOMESERVER", "MATRIX_TOKEN"],
|
||||
["send", "rooms", "join", "leave", "sync"],
|
||||
),
|
||||
makeSkill(
|
||||
"irc-bridge",
|
||||
"IRC Bridge",
|
||||
"Bridge IRC channels to other platforms.",
|
||||
["IRC_SERVER", "IRC_NICK"],
|
||||
["connect", "join", "send", "channels", "users"],
|
||||
),
|
||||
|
||||
// Data & Analytics (5)
|
||||
makeSkill(
|
||||
"pg-queries",
|
||||
"PostgreSQL Queries",
|
||||
"Execute PostgreSQL queries and manage databases.",
|
||||
["DATABASE_URL"],
|
||||
["query", "tables", "schema", "backup", "restore"],
|
||||
),
|
||||
makeSkill(
|
||||
"clickhouse-ql",
|
||||
"ClickHouse Queries",
|
||||
"Run ClickHouse analytics queries.",
|
||||
["CLICKHOUSE_URL"],
|
||||
["query", "tables", "insert", "system", "optimize"],
|
||||
),
|
||||
makeSkill(
|
||||
"redis-cli",
|
||||
"Redis CLI",
|
||||
"Interact with Redis cache and data structures.",
|
||||
["REDIS_URL"],
|
||||
["get", "set", "keys", "info", "flush"],
|
||||
),
|
||||
makeSkill(
|
||||
"elastic-search",
|
||||
"Elasticsearch",
|
||||
"Search and manage Elasticsearch indices.",
|
||||
["ELASTICSEARCH_URL"],
|
||||
["search", "index", "mapping", "cluster", "aliases"],
|
||||
),
|
||||
makeSkill(
|
||||
"mongo-shell",
|
||||
"MongoDB Shell",
|
||||
"Query and manage MongoDB collections.",
|
||||
["MONGODB_URI"],
|
||||
["find", "insert", "update", "delete", "aggregate"],
|
||||
),
|
||||
|
||||
// Security (3)
|
||||
makeSkill(
|
||||
"vault-secrets",
|
||||
"Vault Secrets",
|
||||
"Manage secrets in HashiCorp Vault.",
|
||||
["VAULT_ADDR", "VAULT_TOKEN"],
|
||||
["read", "write", "list", "delete", "seal"],
|
||||
),
|
||||
makeSkill(
|
||||
"gpg-keys",
|
||||
"GPG Keys",
|
||||
"Manage GPG keys and encryption.",
|
||||
["GNUPGHOME"],
|
||||
["list", "generate", "export", "import", "encrypt"],
|
||||
),
|
||||
makeSkill(
|
||||
"ssh-rotate",
|
||||
"SSH Key Rotator",
|
||||
"Rotate and manage SSH keys.",
|
||||
["SSH_KEY_DIR"],
|
||||
["generate", "rotate", "deploy", "list", "revoke"],
|
||||
),
|
||||
|
||||
// CJK Language Support (2)
|
||||
makeSkill(
|
||||
"nihongo-check",
|
||||
"日本語チェッカー",
|
||||
"日本語文章の文法チェックと翻訳支援ツール。Japanese grammar checker and translation assistant.",
|
||||
["NIHONGO_API_KEY"],
|
||||
["check", "translate", "kanji", "grammar", "vocabulary"],
|
||||
),
|
||||
makeSkill(
|
||||
"hangukgeo-helper",
|
||||
"한국어 도우미",
|
||||
"한국어 학습 보조 도구입니다. Korean language learning assistant with vocabulary and grammar support.",
|
||||
["HANGUL_API_KEY"],
|
||||
["learn", "quiz", "vocabulary", "grammar", "pronunciation"],
|
||||
),
|
||||
];
|
||||
|
||||
function injectMetadata(rawSkillMd: string, metadata: Record<string, unknown>) {
|
||||
const frontmatterEnd = rawSkillMd.indexOf("\n---", 3);
|
||||
if (frontmatterEnd === -1) return rawSkillMd;
|
||||
return `${rawSkillMd.slice(0, frontmatterEnd)}\nmetadata: ${JSON.stringify(
|
||||
metadata,
|
||||
)}${rawSkillMd.slice(frontmatterEnd)}`;
|
||||
}
|
||||
|
||||
function randomStats() {
|
||||
return {
|
||||
downloads: Math.floor(Math.random() * 5000),
|
||||
stars: Math.floor(Math.random() * 500),
|
||||
installsCurrent: Math.floor(Math.random() * 200),
|
||||
installsAllTime: Math.floor(Math.random() * 1000),
|
||||
};
|
||||
}
|
||||
|
||||
export const applyRandomStats = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
stats: v.object({
|
||||
downloads: v.number(),
|
||||
stars: v.number(),
|
||||
installsCurrent: v.number(),
|
||||
installsAllTime: v.number(),
|
||||
}),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await ctx.db.patch(args.skillId, {
|
||||
statsDownloads: args.stats.downloads,
|
||||
statsStars: args.stats.stars,
|
||||
statsInstallsCurrent: args.stats.installsCurrent,
|
||||
statsInstallsAllTime: args.stats.installsAllTime,
|
||||
stats: {
|
||||
downloads: args.stats.downloads,
|
||||
stars: args.stats.stars,
|
||||
installsCurrent: args.stats.installsCurrent,
|
||||
installsAllTime: args.stats.installsAllTime,
|
||||
versions: 1,
|
||||
comments: 0,
|
||||
},
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const seedExtraSkillsInternal = internalAction({
|
||||
args: {
|
||||
reset: v.optional(v.boolean()),
|
||||
},
|
||||
handler: async (ctx: ActionCtx, args) => {
|
||||
const results: Array<{ slug: string; ok: boolean; skipped?: boolean }> = [];
|
||||
|
||||
for (const spec of EXTRA_SEED_SKILLS) {
|
||||
const skillMd = injectMetadata(spec.rawSkillMd, spec.metadata);
|
||||
const frontmatter = parseFrontmatter(skillMd);
|
||||
const clawdis = parseClawdisMetadata(frontmatter);
|
||||
const storageId = await ctx.storage.store(new Blob([skillMd], { type: "text/markdown" }));
|
||||
|
||||
const result = (await ctx.runMutation(internal.devSeed.seedSkillMutation, {
|
||||
reset: args.reset,
|
||||
storageId,
|
||||
metadata: spec.metadata,
|
||||
frontmatter,
|
||||
clawdis,
|
||||
skillMd,
|
||||
slug: spec.slug,
|
||||
displayName: spec.displayName,
|
||||
summary: spec.summary,
|
||||
version: spec.version,
|
||||
})) as { ok: boolean; skipped?: boolean; skillId?: string };
|
||||
|
||||
// Apply random stats after creation (only if not skipped)
|
||||
if (result.skillId && !result.skipped) {
|
||||
const stats = randomStats();
|
||||
await ctx.runMutation(internal.devSeedExtra.applyRandomStats, {
|
||||
skillId: result.skillId as Id<"skills">,
|
||||
stats,
|
||||
});
|
||||
}
|
||||
|
||||
results.push({ slug: spec.slug, ok: result.ok, skipped: result.skipped });
|
||||
}
|
||||
|
||||
const created = results.filter((r) => !r.skipped).length;
|
||||
const skipped = results.filter((r) => r.skipped).length;
|
||||
|
||||
return { ok: true, total: results.length, created, skipped };
|
||||
},
|
||||
});
|
||||
144
convex/downloads.test.ts
Normal file
144
convex/downloads.test.ts
Normal file
@ -0,0 +1,144 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ActionCtx } from "./_generated/server";
|
||||
import { __test, downloadZipHandler } from "./downloads";
|
||||
|
||||
type RateLimitArgs = { key: string; limit: number; windowMs: number };
|
||||
|
||||
function isRateLimitArgs(args: unknown): args is RateLimitArgs {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
return (
|
||||
typeof value.key === "string" &&
|
||||
typeof value.limit === "number" &&
|
||||
typeof value.windowMs === "number"
|
||||
);
|
||||
}
|
||||
|
||||
const okRate = () => ({
|
||||
allowed: true,
|
||||
remaining: 10,
|
||||
limit: 100,
|
||||
resetAt: Date.now() + 60_000,
|
||||
});
|
||||
|
||||
describe("downloads helpers", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("calculates hour start boundaries", () => {
|
||||
const hour = 3_600_000;
|
||||
expect(__test.getHourStart(0)).toBe(0);
|
||||
expect(__test.getHourStart(hour - 1)).toBe(0);
|
||||
expect(__test.getHourStart(hour)).toBe(hour);
|
||||
expect(__test.getHourStart(hour + 1)).toBe(hour);
|
||||
});
|
||||
|
||||
it("prefers user identity when token user exists", () => {
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { "cf-connecting-ip": "1.2.3.4" },
|
||||
});
|
||||
expect(__test.getDownloadIdentityValue(request, "users_123")).toBe("user:users_123");
|
||||
});
|
||||
|
||||
it("uses cf-connecting-ip for anonymous identity", () => {
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { "cf-connecting-ip": "1.2.3.4" },
|
||||
});
|
||||
expect(__test.getDownloadIdentityValue(request, null)).toBe("ip:1.2.3.4");
|
||||
});
|
||||
|
||||
it("falls back to forwarded ip when explicitly enabled", () => {
|
||||
vi.stubEnv("TRUST_FORWARDED_IPS", "true");
|
||||
const request = new Request("https://example.com", {
|
||||
headers: { "x-forwarded-for": "10.0.0.1, 10.0.0.2" },
|
||||
});
|
||||
expect(__test.getDownloadIdentityValue(request, null)).toBe("ip:10.0.0.1");
|
||||
});
|
||||
|
||||
it("returns null when user and ip are missing", () => {
|
||||
const request = new Request("https://example.com");
|
||||
expect(__test.getDownloadIdentityValue(request, null)).toBeNull();
|
||||
});
|
||||
|
||||
it("schedules zip download stats outside the response path", async () => {
|
||||
class MockResponse {
|
||||
status: number;
|
||||
headers: Headers;
|
||||
|
||||
constructor(_body?: BodyInit | null, init?: ResponseInit) {
|
||||
this.status = init?.status ?? 200;
|
||||
this.headers = new Headers(init?.headers);
|
||||
}
|
||||
}
|
||||
vi.stubGlobal("Response", MockResponse as unknown as typeof Response);
|
||||
|
||||
const runQuery = vi.fn(async (_query: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
if ("slug" in args) {
|
||||
return {
|
||||
skill: {
|
||||
_id: "skills:1",
|
||||
ownerUserId: "users:1",
|
||||
slug: "demo",
|
||||
tags: {},
|
||||
latestVersionId: "skillVersions:1",
|
||||
},
|
||||
moderationInfo: null,
|
||||
};
|
||||
}
|
||||
if ("versionId" in args) {
|
||||
return {
|
||||
_id: "skillVersions:1",
|
||||
version: "1.0.0",
|
||||
createdAt: 3,
|
||||
files: [{ path: "SKILL.md", storageId: "_storage:1" }],
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const runMutation = vi.fn(async (mutation: unknown, args: Record<string, unknown>) => {
|
||||
if (isRateLimitArgs(args)) return okRate();
|
||||
return { mutation, args };
|
||||
});
|
||||
const runAfter = vi.fn();
|
||||
const storageGet = vi.fn().mockResolvedValue(new Blob(["hello"], { type: "text/markdown" }));
|
||||
|
||||
const response = await downloadZipHandler(
|
||||
{
|
||||
runQuery,
|
||||
runMutation,
|
||||
scheduler: { runAfter },
|
||||
storage: { get: storageGet },
|
||||
} as unknown as ActionCtx,
|
||||
new Request("https://example.com/api/v1/download?slug=demo", {
|
||||
headers: { "cf-connecting-ip": "1.2.3.4" },
|
||||
}),
|
||||
);
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(response.headers.get("Content-Type")).toBe("application/zip");
|
||||
expect(storageGet).toHaveBeenCalledWith("_storage:1");
|
||||
|
||||
const recordCalls = runAfter.mock.calls.filter(([, , args]) => {
|
||||
if (!args || typeof args !== "object") return false;
|
||||
const value = args as Record<string, unknown>;
|
||||
return (
|
||||
value.skillId === "skills:1" &&
|
||||
typeof value.identityHash === "string" &&
|
||||
typeof value.hourStart === "number"
|
||||
);
|
||||
});
|
||||
expect(recordCalls).toHaveLength(1);
|
||||
expect(recordCalls[0]?.[0]).toEqual(expect.any(Number));
|
||||
expect(recordCalls[0]?.[0]).toBeGreaterThanOrEqual(0);
|
||||
expect(recordCalls[0]?.[0]).toBeLessThan(60_000);
|
||||
expect(recordCalls[0]?.[2]).toEqual({
|
||||
skillId: "skills:1",
|
||||
identityHash: expect.any(String),
|
||||
hourStart: expect.any(Number),
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,81 +1,226 @@
|
||||
import { v } from 'convex/values'
|
||||
import { zipSync } from 'fflate'
|
||||
import { api } from './_generated/api'
|
||||
import { httpAction, mutation } from './_generated/server'
|
||||
import { applySkillStatDeltas, bumpDailySkillStats } from './lib/skillStats'
|
||||
import { v } from "convex/values";
|
||||
import { api, internal } from "./_generated/api";
|
||||
import { httpAction, internalMutation } from "./functions";
|
||||
import { getOptionalApiTokenUserId } from "./lib/apiTokenAuth";
|
||||
import { corsHeaders, mergeHeaders } from "./lib/httpHeaders";
|
||||
import { applyRateLimit, getClientIp } from "./lib/httpRateLimit";
|
||||
import { buildDeterministicZip } from "./lib/skillZip";
|
||||
import { hashToken } from "./lib/tokens";
|
||||
import { insertStatEvent } from "./skillStatEvents";
|
||||
|
||||
export const downloadZip = httpAction(async (ctx, request) => {
|
||||
const url = new URL(request.url)
|
||||
const slug = url.searchParams.get('slug')?.trim().toLowerCase()
|
||||
const versionParam = url.searchParams.get('version')?.trim()
|
||||
const tagParam = url.searchParams.get('tag')?.trim()
|
||||
const HOUR_MS = 3_600_000;
|
||||
const DEDUPE_RETENTION_MS = 7 * 24 * HOUR_MS;
|
||||
const PRUNE_BATCH_SIZE = 200;
|
||||
const PRUNE_MAX_BATCHES = 50;
|
||||
const DOWNLOAD_STAT_JITTER_MS = 60_000;
|
||||
|
||||
export async function downloadZipHandler(
|
||||
ctx: Parameters<Parameters<typeof httpAction>[0]>[0],
|
||||
request: Request,
|
||||
) {
|
||||
const url = new URL(request.url);
|
||||
const slug = url.searchParams.get("slug")?.trim().toLowerCase();
|
||||
const versionParam = url.searchParams.get("version")?.trim();
|
||||
const tagParam = url.searchParams.get("tag")?.trim();
|
||||
|
||||
if (!slug) {
|
||||
return new Response('Missing slug', { status: 400 })
|
||||
return new Response("Missing slug", {
|
||||
status: 400,
|
||||
headers: corsHeaders(),
|
||||
});
|
||||
}
|
||||
|
||||
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug })
|
||||
const rate = await applyRateLimit(ctx, request, "download");
|
||||
if (!rate.ok) return rate.response;
|
||||
|
||||
const skillResult = await ctx.runQuery(api.skills.getBySlug, { slug });
|
||||
if (!skillResult?.skill) {
|
||||
return new Response('Skill not found', { status: 404 })
|
||||
return new Response("Skill not found", {
|
||||
status: 404,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
|
||||
const skill = skillResult.skill
|
||||
let version = skillResult.latestVersion
|
||||
// Block downloads based on moderation status.
|
||||
const mod = skillResult.moderationInfo;
|
||||
if (mod?.isMalwareBlocked) {
|
||||
return new Response(
|
||||
"Blocked: this skill has been flagged as malicious by VirusTotal and cannot be downloaded.",
|
||||
{
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (mod?.isPendingScan) {
|
||||
return new Response(
|
||||
"This skill is pending a security scan by VirusTotal. Please try again in a few minutes.",
|
||||
{
|
||||
status: 423,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
},
|
||||
);
|
||||
}
|
||||
if (mod?.isRemoved) {
|
||||
return new Response("This skill has been removed by a moderator.", {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
if (mod?.isHiddenByMod) {
|
||||
return new Response("This skill is currently unavailable.", {
|
||||
status: 403,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
|
||||
const skill = skillResult.skill;
|
||||
let version = skill.latestVersionId
|
||||
? await ctx.runQuery(internal.skills.getVersionByIdInternal, {
|
||||
versionId: skill.latestVersionId,
|
||||
})
|
||||
: null;
|
||||
|
||||
if (versionParam) {
|
||||
version = await ctx.runQuery(api.skills.getVersionBySkillAndVersion, {
|
||||
version = await ctx.runQuery(internal.skills.getVersionBySkillAndVersionInternal, {
|
||||
skillId: skill._id,
|
||||
version: versionParam,
|
||||
})
|
||||
});
|
||||
} else if (tagParam) {
|
||||
const versionId = skill.tags[tagParam]
|
||||
const versionId = skill.tags[tagParam];
|
||||
if (versionId) {
|
||||
version = await ctx.runQuery(api.skills.getVersionById, { versionId })
|
||||
version = await ctx.runQuery(internal.skills.getVersionByIdInternal, { versionId });
|
||||
}
|
||||
}
|
||||
|
||||
if (!version) {
|
||||
return new Response('Version not found', { status: 404 })
|
||||
return new Response("Version not found", {
|
||||
status: 404,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
if (version.softDeletedAt) {
|
||||
return new Response('Version not available', { status: 410 })
|
||||
return new Response("Version not available", {
|
||||
status: 410,
|
||||
headers: mergeHeaders(rate.headers, corsHeaders()),
|
||||
});
|
||||
}
|
||||
|
||||
const files: Record<string, Uint8Array> = {}
|
||||
const entries: Array<{ path: string; bytes: Uint8Array }> = [];
|
||||
for (const file of version.files) {
|
||||
const blob = await ctx.storage.get(file.storageId)
|
||||
if (!blob) continue
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer())
|
||||
files[file.path] = buffer
|
||||
const blob = await ctx.storage.get(file.storageId);
|
||||
if (!blob) continue;
|
||||
const buffer = new Uint8Array(await blob.arrayBuffer());
|
||||
entries.push({ path: file.path, bytes: buffer });
|
||||
}
|
||||
const zipArray = buildDeterministicZip(entries, {
|
||||
ownerId: String(skill.ownerUserId),
|
||||
slug: skill.slug,
|
||||
version: version.version,
|
||||
publishedAt: version.createdAt,
|
||||
});
|
||||
const zipBlob = new Blob([zipArray], { type: "application/zip" });
|
||||
|
||||
const zipData = zipSync(files, { level: 6 })
|
||||
const zipArray = Uint8Array.from(zipData)
|
||||
const zipBlob = new Blob([zipArray], { type: 'application/zip' })
|
||||
|
||||
await ctx.runMutation(api.downloads.increment, { skillId: skill._id })
|
||||
try {
|
||||
const userId = await getOptionalApiTokenUserId(ctx, request);
|
||||
const identity = getDownloadIdentityValue(request, userId ? String(userId) : null);
|
||||
if (identity) {
|
||||
await ctx.scheduler.runAfter(
|
||||
Math.floor(Math.random() * DOWNLOAD_STAT_JITTER_MS),
|
||||
internal.downloads.recordDownloadInternal,
|
||||
{
|
||||
skillId: skill._id,
|
||||
identityHash: await hashToken(identity),
|
||||
hourStart: getHourStart(Date.now()),
|
||||
},
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Best-effort metric path; do not fail downloads.
|
||||
}
|
||||
|
||||
return new Response(zipBlob, {
|
||||
status: 200,
|
||||
headers: {
|
||||
'Content-Type': 'application/zip',
|
||||
'Content-Disposition': `attachment; filename="${slug}-${version.version}.zip"`,
|
||||
'Cache-Control': 'private, max-age=60',
|
||||
},
|
||||
})
|
||||
})
|
||||
headers: mergeHeaders(
|
||||
rate.headers,
|
||||
{
|
||||
"Content-Type": "application/zip",
|
||||
"Content-Disposition": `attachment; filename="${slug}-${version.version}.zip"`,
|
||||
"Cache-Control": "private, max-age=60",
|
||||
},
|
||||
corsHeaders(),
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
export const increment = mutation({
|
||||
args: { skillId: v.id('skills') },
|
||||
handler: async (ctx, args) => {
|
||||
const skill = await ctx.db.get(args.skillId)
|
||||
if (!skill) return
|
||||
const now = Date.now()
|
||||
const patch = applySkillStatDeltas(skill, { downloads: 1 })
|
||||
await ctx.db.patch(skill._id, {
|
||||
...patch,
|
||||
updatedAt: now,
|
||||
})
|
||||
await bumpDailySkillStats(ctx, { skillId: skill._id, now, downloads: 1 })
|
||||
export const downloadZip = httpAction(downloadZipHandler);
|
||||
|
||||
export const recordDownloadInternal = internalMutation({
|
||||
args: {
|
||||
skillId: v.id("skills"),
|
||||
identityHash: v.string(),
|
||||
hourStart: v.number(),
|
||||
},
|
||||
})
|
||||
handler: async (ctx, args) => {
|
||||
const existing = await ctx.db
|
||||
.query("downloadDedupes")
|
||||
.withIndex("by_skill_identity_hour", (q) =>
|
||||
q
|
||||
.eq("skillId", args.skillId)
|
||||
.eq("identityHash", args.identityHash)
|
||||
.eq("hourStart", args.hourStart),
|
||||
)
|
||||
.first();
|
||||
if (existing) return;
|
||||
|
||||
await ctx.db.insert("downloadDedupes", {
|
||||
skillId: args.skillId,
|
||||
identityHash: args.identityHash,
|
||||
hourStart: args.hourStart,
|
||||
createdAt: Date.now(),
|
||||
});
|
||||
|
||||
await insertStatEvent(ctx, {
|
||||
skillId: args.skillId,
|
||||
kind: "download",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
export const pruneDownloadDedupesInternal = internalMutation({
|
||||
args: {},
|
||||
handler: async (ctx) => {
|
||||
const cutoff = Date.now() - DEDUPE_RETENTION_MS;
|
||||
|
||||
for (let batches = 0; batches < PRUNE_MAX_BATCHES; batches += 1) {
|
||||
const stale = await ctx.db
|
||||
.query("downloadDedupes")
|
||||
.withIndex("by_hour", (q) => q.lt("hourStart", cutoff))
|
||||
.take(PRUNE_BATCH_SIZE);
|
||||
|
||||
if (stale.length === 0) break;
|
||||
|
||||
for (const entry of stale) {
|
||||
await ctx.db.delete(entry._id);
|
||||
}
|
||||
|
||||
if (stale.length < PRUNE_BATCH_SIZE) break;
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
export function getHourStart(timestamp: number) {
|
||||
return Math.floor(timestamp / HOUR_MS) * HOUR_MS;
|
||||
}
|
||||
|
||||
export function getDownloadIdentityValue(request: Request, userId: string | null) {
|
||||
if (userId) return `user:${userId}`;
|
||||
const ip = getClientIp(request);
|
||||
if (!ip) return null;
|
||||
return `ip:${ip}`;
|
||||
}
|
||||
|
||||
export const __test = {
|
||||
getHourStart,
|
||||
getDownloadIdentityValue,
|
||||
};
|
||||
|
||||
663
convex/functions.test.ts
Normal file
663
convex/functions.test.ts
Normal file
@ -0,0 +1,663 @@
|
||||
/* @vitest-environment node */
|
||||
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { internal } from "./_generated/api";
|
||||
import {
|
||||
isGitHubMirrorEligibleSkillDoc,
|
||||
repointPackageLatestRelease,
|
||||
scheduleGitHubBackupDeletionForSkill,
|
||||
scheduleOwnerPublisherDigestSync,
|
||||
syncPackageSearchDigestForPackageId,
|
||||
syncPackageSearchDigestsForOwnerPublisherId,
|
||||
syncPackageSearchDigestsForOwnerUserId,
|
||||
syncSkillSearchDigestsForOwnerPublisherId,
|
||||
} from "./functions";
|
||||
|
||||
describe("package digest sync", () => {
|
||||
it("identifies GitHub mirror eligibility from skill visibility fields", () => {
|
||||
expect(isGitHubMirrorEligibleSkillDoc({ softDeletedAt: undefined })).toBe(true);
|
||||
expect(
|
||||
isGitHubMirrorEligibleSkillDoc({
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isGitHubMirrorEligibleSkillDoc({
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
isGitHubMirrorEligibleSkillDoc({
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "removed",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(isGitHubMirrorEligibleSkillDoc({ softDeletedAt: 123 })).toBe(false);
|
||||
});
|
||||
|
||||
it("schedules GitHub mirror deletion for a skill using the owner handle", async () => {
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") {
|
||||
return {
|
||||
_id: "users:owner",
|
||||
handle: "alice",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
})),
|
||||
})),
|
||||
},
|
||||
scheduler: {
|
||||
runAfter: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await scheduleGitHubBackupDeletionForSkill(
|
||||
ctx as never,
|
||||
{
|
||||
slug: "hidden-skill",
|
||||
ownerUserId: "users:owner",
|
||||
ownerPublisherId: undefined,
|
||||
softDeletedAt: 123,
|
||||
moderationStatus: "hidden",
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.githubBackupsNode.deleteGitHubBackupForSlugInternal,
|
||||
{
|
||||
ownerHandle: "alice",
|
||||
slug: "hidden-skill",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears latestVersion when the current package release is soft-deleted", async () => {
|
||||
const pkg = {
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "demo",
|
||||
capabilityTags: ["tools"],
|
||||
executesCode: true,
|
||||
runtimeId: null,
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestReleaseId: "packageReleases:demo-2",
|
||||
latestVersionSummary: { version: "2.0.0" },
|
||||
verification: { tier: "community" },
|
||||
};
|
||||
const latestRelease = {
|
||||
_id: "packageReleases:demo-2",
|
||||
version: "2.0.0",
|
||||
softDeletedAt: 10,
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "packageReleases:demo-2") return latestRelease;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestForPackageId(ctx as never, "packages:demo" as never);
|
||||
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:demo",
|
||||
latestVersion: undefined,
|
||||
ownerHandle: "owner",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves latestVersion when the current package release is active", async () => {
|
||||
const pkg = {
|
||||
_id: "packages:demo",
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "demo",
|
||||
capabilityTags: ["tools"],
|
||||
executesCode: true,
|
||||
runtimeId: null,
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
latestReleaseId: "packageReleases:demo-2",
|
||||
latestVersionSummary: { version: "2.0.0" },
|
||||
verification: { tier: "community" },
|
||||
};
|
||||
const latestRelease = {
|
||||
_id: "packageReleases:demo-2",
|
||||
version: "2.0.0",
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "packageReleases:demo-2") return latestRelease;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
})),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestForPackageId(ctx as never, "packages:demo" as never);
|
||||
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:demo",
|
||||
latestVersion: "2.0.0",
|
||||
ownerHandle: "owner",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("repoints packages to the highest-version active release and restores its summary", async () => {
|
||||
const pkg = {
|
||||
_id: "packages:demo",
|
||||
_creationTime: 1,
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "latest summary",
|
||||
tags: {
|
||||
latest: "packageReleases:demo-2",
|
||||
stable: "packageReleases:demo-2",
|
||||
},
|
||||
latestReleaseId: "packageReleases:demo-2",
|
||||
latestVersionSummary: { version: "2.0.0" },
|
||||
capabilityTags: ["new"],
|
||||
executesCode: true,
|
||||
compatibility: { openclaw: "^2.0.0" },
|
||||
capabilities: { capabilityTags: ["new"], executesCode: true },
|
||||
verification: { tier: "community" },
|
||||
runtimeId: null,
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
const fallbackRelease = {
|
||||
_id: "packageReleases:demo-1",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:demo",
|
||||
version: "1.0.0",
|
||||
changelog: "old stable",
|
||||
summary: "stable summary",
|
||||
compatibility: { openclaw: "^1.0.0" },
|
||||
capabilities: { capabilityTags: ["stable"], executesCode: false },
|
||||
verification: { tier: "verified" },
|
||||
distTags: ["stable"],
|
||||
createdAt: 10,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const legacyHotfixRelease = {
|
||||
_id: "packageReleases:demo-legacy",
|
||||
_creationTime: 20,
|
||||
packageId: "packages:demo",
|
||||
version: "0.9.9",
|
||||
changelog: "legacy hotfix",
|
||||
summary: "legacy summary",
|
||||
compatibility: { openclaw: "^0.9.0" },
|
||||
capabilities: { capabilityTags: ["legacy"], executesCode: false },
|
||||
verification: { tier: "verified" },
|
||||
distTags: ["legacy"],
|
||||
createdAt: 20,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:demo") return pkg;
|
||||
if (id === "packageReleases:demo-1") return fallbackRelease;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
paginate: vi.fn().mockResolvedValue({
|
||||
page: [legacyHotfixRelease, fallbackRelease],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageCapabilitySearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await repointPackageLatestRelease(
|
||||
ctx as never,
|
||||
"packages:demo" as never,
|
||||
"packageReleases:demo-2" as never,
|
||||
);
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith("packageReleases:demo-1", {
|
||||
distTags: ["stable", "latest"],
|
||||
});
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(
|
||||
"packages:demo",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: "packageReleases:demo-1",
|
||||
tags: { latest: "packageReleases:demo-1" },
|
||||
latestVersionSummary: expect.objectContaining({ version: "1.0.0" }),
|
||||
summary: "stable summary",
|
||||
capabilityTags: ["stable"],
|
||||
executesCode: false,
|
||||
}),
|
||||
);
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
expect.objectContaining({
|
||||
latestVersion: "1.0.0",
|
||||
ownerHandle: "owner",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("repoints bundle packages to the newest surviving release, not semver-looking versions", async () => {
|
||||
const pkg = {
|
||||
_id: "packages:bundle",
|
||||
_creationTime: 1,
|
||||
name: "demo-bundle",
|
||||
normalizedName: "demo-bundle",
|
||||
displayName: "Demo Bundle",
|
||||
family: "bundle-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "latest summary",
|
||||
tags: {
|
||||
latest: "packageReleases:bundle-latest",
|
||||
},
|
||||
latestReleaseId: "packageReleases:bundle-latest",
|
||||
latestVersionSummary: { version: "latest" },
|
||||
capabilityTags: ["new"],
|
||||
executesCode: false,
|
||||
compatibility: { hosts: ["openclaw"] },
|
||||
capabilities: { capabilityTags: ["new"], executesCode: false },
|
||||
verification: { tier: "community" },
|
||||
runtimeId: "bundle.runtime",
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
};
|
||||
const semverLookingRelease = {
|
||||
_id: "packageReleases:bundle-semver",
|
||||
_creationTime: 10,
|
||||
packageId: "packages:bundle",
|
||||
version: "2.0.0",
|
||||
changelog: "older semver",
|
||||
summary: "older semver summary",
|
||||
compatibility: { hosts: ["openclaw"] },
|
||||
capabilities: { capabilityTags: ["semver"], executesCode: false },
|
||||
verification: { tier: "verified" },
|
||||
distTags: ["legacy"],
|
||||
createdAt: 10,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const newestRelease = {
|
||||
_id: "packageReleases:bundle-newest",
|
||||
_creationTime: 20,
|
||||
packageId: "packages:bundle",
|
||||
version: "2024-12",
|
||||
changelog: "newest bundle build",
|
||||
summary: "newest bundle summary",
|
||||
compatibility: { hosts: ["openclaw"] },
|
||||
capabilities: { capabilityTags: ["bundle"], executesCode: false },
|
||||
verification: { tier: "verified" },
|
||||
distTags: ["release-2024-12"],
|
||||
createdAt: 20,
|
||||
softDeletedAt: undefined,
|
||||
};
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "owner",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "packages:bundle") return pkg;
|
||||
if (id === "packageReleases:bundle-newest") return newestRelease;
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packageReleases") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
order: vi.fn(() => ({
|
||||
paginate: vi.fn().mockResolvedValue({
|
||||
page: [newestRelease, semverLookingRelease],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
}),
|
||||
})),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageCapabilitySearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await repointPackageLatestRelease(
|
||||
ctx as never,
|
||||
"packages:bundle" as never,
|
||||
"packageReleases:bundle-latest" as never,
|
||||
);
|
||||
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith("packageReleases:bundle-newest", {
|
||||
distTags: ["release-2024-12", "latest"],
|
||||
});
|
||||
expect(ctx.db.patch).toHaveBeenCalledWith(
|
||||
"packages:bundle",
|
||||
expect.objectContaining({
|
||||
latestReleaseId: "packageReleases:bundle-newest",
|
||||
tags: { latest: "packageReleases:bundle-newest" },
|
||||
latestVersionSummary: expect.objectContaining({ version: "2024-12" }),
|
||||
summary: "newest bundle summary",
|
||||
}),
|
||||
);
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
expect.objectContaining({
|
||||
latestVersion: "2024-12",
|
||||
ownerHandle: "owner",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("re-syncs package digests when an owner handle changes", async () => {
|
||||
const owner = {
|
||||
_id: "users:owner",
|
||||
handle: "renamed",
|
||||
deletedAt: undefined,
|
||||
deactivatedAt: undefined,
|
||||
};
|
||||
const pkg = {
|
||||
_id: "packages:demo",
|
||||
_creationTime: 1,
|
||||
name: "demo-plugin",
|
||||
normalizedName: "demo-plugin",
|
||||
displayName: "Demo Plugin",
|
||||
family: "code-plugin",
|
||||
channel: "community",
|
||||
isOfficial: false,
|
||||
ownerUserId: "users:owner",
|
||||
summary: "demo",
|
||||
tags: {},
|
||||
latestReleaseId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
capabilityTags: [],
|
||||
executesCode: false,
|
||||
runtimeId: null,
|
||||
softDeletedAt: undefined,
|
||||
createdAt: 1,
|
||||
updatedAt: 2,
|
||||
verification: undefined,
|
||||
};
|
||||
const paginate = vi.fn().mockResolvedValueOnce({
|
||||
page: [pkg],
|
||||
isDone: true,
|
||||
continueCursor: "",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
get: vi.fn(async (id: string) => {
|
||||
if (id === "users:owner") return owner;
|
||||
return null;
|
||||
}),
|
||||
query: vi.fn((table: string) => {
|
||||
if (table === "packages") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
paginate,
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageSearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (table === "packageCapabilitySearchDigest") {
|
||||
return {
|
||||
withIndex: vi.fn(() => ({
|
||||
unique: vi.fn().mockResolvedValue(null),
|
||||
collect: vi.fn().mockResolvedValue([]),
|
||||
})),
|
||||
};
|
||||
}
|
||||
throw new Error(`Unexpected table ${table}`);
|
||||
}),
|
||||
patch: vi.fn(),
|
||||
insert: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestsForOwnerUserId(ctx as never, "users:owner" as never);
|
||||
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: null, numItems: 100 });
|
||||
expect(ctx.db.insert).toHaveBeenCalledWith(
|
||||
"packageSearchDigest",
|
||||
expect.objectContaining({
|
||||
packageId: "packages:demo",
|
||||
ownerHandle: "renamed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("publisher digest scheduling", () => {
|
||||
it("schedules package and skill digest sync in separate background mutations", async () => {
|
||||
const ctx = {
|
||||
scheduler: {
|
||||
runAfter: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
await scheduleOwnerPublisherDigestSync(ctx as never, "publishers:demo" as never);
|
||||
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledTimes(2);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo" },
|
||||
);
|
||||
});
|
||||
|
||||
it("skips scheduling when the trigger context has no scheduler", async () => {
|
||||
await expect(
|
||||
scheduleOwnerPublisherDigestSync({} as never, "publishers:demo" as never),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("continues owner-publisher package digest sync one page at a time", async () => {
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [],
|
||||
isDone: false,
|
||||
continueCursor: "next-packages",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({ paginate })),
|
||||
})),
|
||||
},
|
||||
scheduler: {
|
||||
runAfter: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
await syncPackageSearchDigestsForOwnerPublisherId(
|
||||
ctx as never,
|
||||
"publishers:demo" as never,
|
||||
"current-packages",
|
||||
);
|
||||
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: "current-packages", numItems: 100 });
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo", cursor: "next-packages" },
|
||||
);
|
||||
});
|
||||
|
||||
it("continues owner-publisher skill digest sync one page at a time", async () => {
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [],
|
||||
isDone: false,
|
||||
continueCursor: "next-skills",
|
||||
});
|
||||
const ctx = {
|
||||
db: {
|
||||
query: vi.fn(() => ({
|
||||
withIndex: vi.fn(() => ({ paginate })),
|
||||
})),
|
||||
},
|
||||
scheduler: {
|
||||
runAfter: vi.fn().mockResolvedValue(undefined),
|
||||
},
|
||||
};
|
||||
|
||||
await syncSkillSearchDigestsForOwnerPublisherId(
|
||||
ctx as never,
|
||||
"publishers:demo" as never,
|
||||
"current-skills",
|
||||
);
|
||||
|
||||
expect(paginate).toHaveBeenCalledTimes(1);
|
||||
expect(paginate).toHaveBeenCalledWith({ cursor: "current-skills", numItems: 100 });
|
||||
expect(ctx.scheduler.runAfter).toHaveBeenCalledWith(
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId: "publishers:demo", cursor: "next-skills" },
|
||||
);
|
||||
});
|
||||
});
|
||||
439
convex/functions.ts
Normal file
439
convex/functions.ts
Normal file
@ -0,0 +1,439 @@
|
||||
import { customCtx, customMutation } from "convex-helpers/server/customFunctions";
|
||||
import { Triggers } from "convex-helpers/server/triggers";
|
||||
import { v } from "convex/values";
|
||||
import semver from "semver";
|
||||
import { internal } from "./_generated/api";
|
||||
import type { DataModel, Doc, Id } from "./_generated/dataModel";
|
||||
import {
|
||||
mutation as rawMutation,
|
||||
internalMutation as rawInternalMutation,
|
||||
query,
|
||||
internalQuery,
|
||||
action,
|
||||
internalAction,
|
||||
httpAction,
|
||||
} from "./_generated/server";
|
||||
import type { MutationCtx } from "./_generated/server";
|
||||
import {
|
||||
deletePackageSearchDigests,
|
||||
extractPackageDigestFields,
|
||||
upsertPackageSearchDigest,
|
||||
} from "./lib/packageSearchDigest";
|
||||
import { getOwnerPublisher } from "./lib/publishers";
|
||||
import { extractDigestFields, upsertSkillSearchDigest } from "./lib/skillSearchDigest";
|
||||
|
||||
const triggers = new Triggers<DataModel>();
|
||||
|
||||
function isMissingTableError(error: unknown, table: string) {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
new RegExp(`unexpected (query )?table:? ${table}`, "i").test(error.message)
|
||||
);
|
||||
}
|
||||
|
||||
type PackageDigestSyncCtx = Pick<MutationCtx, "db">;
|
||||
type OwnerPublisherDigestScheduleCtx = Pick<Partial<MutationCtx>, "scheduler">;
|
||||
type GitHubBackupDeletionCtx = Pick<MutationCtx, "db" | "scheduler">;
|
||||
const OWNER_PUBLISHER_DIGEST_PAGE_SIZE = 100;
|
||||
type LatestPackageRelease = Pick<
|
||||
Doc<"packageReleases">,
|
||||
| "_id"
|
||||
| "createdAt"
|
||||
| "version"
|
||||
| "changelog"
|
||||
| "summary"
|
||||
| "compatibility"
|
||||
| "capabilities"
|
||||
| "verification"
|
||||
| "distTags"
|
||||
> & {
|
||||
scanStatus?: Doc<"packages">["scanStatus"];
|
||||
};
|
||||
|
||||
function toPackageLatestVersionSummary(
|
||||
release: LatestPackageRelease | null,
|
||||
): Doc<"packages">["latestVersionSummary"] {
|
||||
if (!release) return undefined;
|
||||
return {
|
||||
version: release.version,
|
||||
createdAt: release.createdAt,
|
||||
changelog: release.changelog,
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
};
|
||||
}
|
||||
|
||||
function compareFallbackReleases(
|
||||
family: Doc<"packages">["family"],
|
||||
a: LatestPackageRelease,
|
||||
b: LatestPackageRelease,
|
||||
) {
|
||||
if (family === "bundle-plugin") {
|
||||
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
||||
return a._id.localeCompare(b._id);
|
||||
}
|
||||
const aSemver = semver.valid(a.version);
|
||||
const bSemver = semver.valid(b.version);
|
||||
if (aSemver && bSemver) return semver.compare(aSemver, bSemver);
|
||||
if (aSemver) return 1;
|
||||
if (bSemver) return -1;
|
||||
if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt;
|
||||
return a._id.localeCompare(b._id);
|
||||
}
|
||||
|
||||
async function getPreferredFallbackPackageRelease(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
packageId: Id<"packages">,
|
||||
family: Doc<"packages">["family"],
|
||||
): Promise<LatestPackageRelease | null> {
|
||||
let cursor: string | null = null;
|
||||
let best: LatestPackageRelease | null = null;
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("packageReleases")
|
||||
.withIndex("by_package_active_created", (q) =>
|
||||
q.eq("packageId", packageId).eq("softDeletedAt", undefined),
|
||||
)
|
||||
.order("desc")
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const release of page.page) {
|
||||
const candidate: LatestPackageRelease = {
|
||||
_id: release._id,
|
||||
createdAt: release.createdAt,
|
||||
version: release.version,
|
||||
changelog: release.changelog,
|
||||
summary: release.summary,
|
||||
compatibility: release.compatibility,
|
||||
capabilities: release.capabilities,
|
||||
verification: release.verification,
|
||||
scanStatus: release.verification?.scanStatus,
|
||||
distTags: release.distTags,
|
||||
};
|
||||
if (!best || compareFallbackReleases(family, candidate, best) > 0) best = candidate;
|
||||
}
|
||||
if (page.isDone) return best;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncPackageSearchDigest(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
pkg: Doc<"packages"> | null | undefined,
|
||||
) {
|
||||
if (!pkg) return;
|
||||
const latestRelease = pkg.latestReleaseId ? await ctx.db.get(pkg.latestReleaseId) : null;
|
||||
const fields = extractPackageDigestFields(pkg);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: pkg.ownerPublisherId,
|
||||
ownerUserId: pkg.ownerUserId,
|
||||
});
|
||||
await upsertPackageSearchDigest(ctx, {
|
||||
...fields,
|
||||
latestVersion:
|
||||
latestRelease && !latestRelease.softDeletedAt ? latestRelease.version : undefined,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncPackageSearchDigestForPackageId(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
packageId: Id<"packages"> | null | undefined,
|
||||
) {
|
||||
if (!packageId) return;
|
||||
const pkg = await ctx.db.get(packageId);
|
||||
if (!pkg) return;
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
|
||||
export async function syncPackageSearchDigestsForOwnerUserId(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
ownerUserId: Id<"users"> | null | undefined,
|
||||
) {
|
||||
if (!ownerUserId) return;
|
||||
let cursor: string | null = null;
|
||||
try {
|
||||
while (true) {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner", (q) => q.eq("ownerUserId", ownerUserId))
|
||||
.paginate({ cursor, numItems: 100 });
|
||||
for (const pkg of page.page) {
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
if (page.isDone) break;
|
||||
cursor = page.continueCursor;
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "packages")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncPackageSearchDigestsForOwnerPublisherId(
|
||||
ctx: PackageDigestSyncCtx & OwnerPublisherDigestScheduleCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
cursor: string | null = null,
|
||||
) {
|
||||
if (!ownerPublisherId) return;
|
||||
try {
|
||||
const page = await ctx.db
|
||||
.query("packages")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.paginate({ cursor, numItems: OWNER_PUBLISHER_DIGEST_PAGE_SIZE });
|
||||
for (const pkg of page.page) {
|
||||
await syncPackageSearchDigest(ctx, pkg);
|
||||
}
|
||||
if (!page.isDone && ctx.scheduler && page.continueCursor) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId, cursor: page.continueCursor },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "packages")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function syncSkillSearchDigestForSkill(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
skill: Doc<"skills"> | null | undefined,
|
||||
) {
|
||||
if (!skill) return;
|
||||
const fields = extractDigestFields(skill);
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
await upsertSkillSearchDigest(ctx, {
|
||||
...fields,
|
||||
ownerHandle: owner?.handle ?? "",
|
||||
ownerKind: owner?.kind,
|
||||
ownerName: owner?.linkedUserId ? owner.handle : undefined,
|
||||
ownerDisplayName: owner?.displayName,
|
||||
ownerImage: owner?.image,
|
||||
});
|
||||
}
|
||||
|
||||
export function isGitHubMirrorEligibleSkillDoc(
|
||||
skill: Pick<Doc<"skills">, "softDeletedAt" | "moderationStatus"> | null | undefined,
|
||||
) {
|
||||
if (!skill || skill.softDeletedAt) return false;
|
||||
return (
|
||||
skill.moderationStatus === undefined ||
|
||||
skill.moderationStatus === null ||
|
||||
skill.moderationStatus === "active"
|
||||
);
|
||||
}
|
||||
|
||||
export async function scheduleGitHubBackupDeletionForSkill(
|
||||
ctx: GitHubBackupDeletionCtx,
|
||||
skill: Pick<
|
||||
Doc<"skills">,
|
||||
"slug" | "ownerPublisherId" | "ownerUserId" | "softDeletedAt" | "moderationStatus"
|
||||
>,
|
||||
) {
|
||||
const owner = await getOwnerPublisher(ctx, {
|
||||
ownerPublisherId: skill.ownerPublisherId,
|
||||
ownerUserId: skill.ownerUserId,
|
||||
});
|
||||
const ownerHandle = owner?.handle ?? String(skill.ownerPublisherId ?? skill.ownerUserId);
|
||||
await ctx.scheduler.runAfter(0, internal.githubBackupsNode.deleteGitHubBackupForSlugInternal, {
|
||||
ownerHandle,
|
||||
slug: skill.slug,
|
||||
});
|
||||
}
|
||||
|
||||
export async function syncSkillSearchDigestsForOwnerPublisherId(
|
||||
ctx: PackageDigestSyncCtx & OwnerPublisherDigestScheduleCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
cursor: string | null = null,
|
||||
) {
|
||||
if (!ownerPublisherId) return;
|
||||
try {
|
||||
const page = await ctx.db
|
||||
.query("skills")
|
||||
.withIndex("by_owner_publisher", (q) => q.eq("ownerPublisherId", ownerPublisherId))
|
||||
.paginate({ cursor, numItems: OWNER_PUBLISHER_DIGEST_PAGE_SIZE });
|
||||
for (const skill of page.page) {
|
||||
await syncSkillSearchDigestForSkill(ctx, skill);
|
||||
}
|
||||
if (!page.isDone && ctx.scheduler && page.continueCursor) {
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId, cursor: page.continueCursor },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isMissingTableError(error, "skills")) return;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function scheduleOwnerPublisherDigestSync(
|
||||
ctx: OwnerPublisherDigestScheduleCtx,
|
||||
ownerPublisherId: Id<"publishers"> | null | undefined,
|
||||
) {
|
||||
if (!ownerPublisherId || !ctx.scheduler) return;
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncPackageSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
await ctx.scheduler.runAfter(
|
||||
0,
|
||||
internal.functions.syncSkillSearchDigestsForOwnerPublisherIdInternal,
|
||||
{ ownerPublisherId },
|
||||
);
|
||||
}
|
||||
|
||||
export const syncPackageSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
cursor: v.optional(v.union(v.string(), v.null())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncPackageSearchDigestsForOwnerPublisherId(
|
||||
ctx,
|
||||
args.ownerPublisherId,
|
||||
args.cursor ?? null,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export const syncSkillSearchDigestsForOwnerPublisherIdInternal = rawInternalMutation({
|
||||
args: {
|
||||
ownerPublisherId: v.id("publishers"),
|
||||
cursor: v.optional(v.union(v.string(), v.null())),
|
||||
},
|
||||
handler: async (ctx, args) => {
|
||||
await syncSkillSearchDigestsForOwnerPublisherId(
|
||||
ctx,
|
||||
args.ownerPublisherId,
|
||||
args.cursor ?? null,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
export async function repointPackageLatestRelease(
|
||||
ctx: PackageDigestSyncCtx,
|
||||
packageId: Id<"packages"> | null | undefined,
|
||||
affectedReleaseId: Id<"packageReleases"> | null | undefined,
|
||||
) {
|
||||
if (!packageId || !affectedReleaseId) return;
|
||||
const pkg = await ctx.db.get(packageId);
|
||||
if (!pkg) return;
|
||||
|
||||
const nextTags = Object.fromEntries(
|
||||
Object.entries(pkg.tags).filter(([, releaseId]) => releaseId !== affectedReleaseId),
|
||||
) as Doc<"packages">["tags"];
|
||||
const latestPointerAffected =
|
||||
pkg.latestReleaseId === affectedReleaseId || pkg.tags.latest === affectedReleaseId;
|
||||
|
||||
if (!latestPointerAffected && Object.keys(nextTags).length === Object.keys(pkg.tags).length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextLatest = latestPointerAffected
|
||||
? await getPreferredFallbackPackageRelease(ctx, packageId, pkg.family)
|
||||
: null;
|
||||
if (latestPointerAffected && nextLatest && !(nextLatest.distTags ?? []).includes("latest")) {
|
||||
await ctx.db.patch(nextLatest._id, {
|
||||
distTags: [...(nextLatest.distTags ?? []), "latest"],
|
||||
});
|
||||
}
|
||||
|
||||
const patch: Partial<Doc<"packages">> = {
|
||||
tags: latestPointerAffected && nextLatest ? { ...nextTags, latest: nextLatest._id } : nextTags,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
if (latestPointerAffected) {
|
||||
patch.latestReleaseId = nextLatest?._id;
|
||||
patch.latestVersionSummary = toPackageLatestVersionSummary(nextLatest);
|
||||
patch.summary = nextLatest?.summary;
|
||||
patch.capabilityTags = nextLatest?.capabilities?.capabilityTags;
|
||||
patch.executesCode =
|
||||
typeof nextLatest?.capabilities?.executesCode === "boolean"
|
||||
? nextLatest.capabilities.executesCode
|
||||
: undefined;
|
||||
patch.compatibility = nextLatest?.compatibility;
|
||||
patch.capabilities = nextLatest?.capabilities;
|
||||
patch.verification = nextLatest?.verification;
|
||||
patch.scanStatus = nextLatest?.scanStatus;
|
||||
}
|
||||
await ctx.db.patch(pkg._id, patch);
|
||||
await syncPackageSearchDigest(ctx, { ...pkg, ...patch });
|
||||
}
|
||||
|
||||
triggers.register("skills", async (ctx, change) => {
|
||||
if (change.operation === "delete") {
|
||||
await scheduleGitHubBackupDeletionForSkill(ctx, change.oldDoc);
|
||||
const existing = await ctx.db
|
||||
.query("skillSearchDigest")
|
||||
.withIndex("by_skill", (q) => q.eq("skillId", change.id))
|
||||
.unique();
|
||||
if (existing) await ctx.db.delete(existing._id);
|
||||
} else {
|
||||
if (
|
||||
change.operation === "update" &&
|
||||
isGitHubMirrorEligibleSkillDoc(change.oldDoc) &&
|
||||
!isGitHubMirrorEligibleSkillDoc(change.newDoc)
|
||||
) {
|
||||
await scheduleGitHubBackupDeletionForSkill(ctx, change.oldDoc);
|
||||
}
|
||||
await syncSkillSearchDigestForSkill(ctx, change.newDoc);
|
||||
}
|
||||
});
|
||||
|
||||
triggers.register("packages", async (ctx, change) => {
|
||||
if (change.operation === "delete") {
|
||||
await deletePackageSearchDigests(ctx, change.id);
|
||||
return;
|
||||
}
|
||||
|
||||
await syncPackageSearchDigest(ctx, change.newDoc);
|
||||
});
|
||||
|
||||
triggers.register("packageReleases", async (ctx, change) => {
|
||||
if (change.operation === "insert") return;
|
||||
if (
|
||||
change.operation === "update" &&
|
||||
change.oldDoc.softDeletedAt === change.newDoc.softDeletedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const packageId =
|
||||
change.operation === "delete" ? change.oldDoc.packageId : change.newDoc.packageId;
|
||||
const affectedReleaseId = change.operation === "delete" ? change.oldDoc._id : change.newDoc._id;
|
||||
if (change.operation === "delete" || change.newDoc.softDeletedAt) {
|
||||
await repointPackageLatestRelease(ctx, packageId, affectedReleaseId);
|
||||
return;
|
||||
}
|
||||
await syncPackageSearchDigestForPackageId(ctx, packageId);
|
||||
});
|
||||
|
||||
triggers.register("users", async (ctx, change) => {
|
||||
if (
|
||||
change.operation === "update" &&
|
||||
change.oldDoc.handle === change.newDoc.handle &&
|
||||
change.oldDoc.deletedAt === change.newDoc.deletedAt &&
|
||||
change.oldDoc.deactivatedAt === change.newDoc.deactivatedAt
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const ownerUserId = change.operation === "delete" ? change.id : change.newDoc._id;
|
||||
await syncPackageSearchDigestsForOwnerUserId(ctx, ownerUserId);
|
||||
});
|
||||
|
||||
triggers.register("publishers", async (ctx, change) => {
|
||||
const ownerPublisherId = change.operation === "delete" ? change.id : change.newDoc._id;
|
||||
await scheduleOwnerPublisherDigestSync(ctx, ownerPublisherId);
|
||||
});
|
||||
|
||||
export const mutation = customMutation(rawMutation, customCtx(triggers.wrapDB));
|
||||
export const internalMutation = customMutation(rawInternalMutation, customCtx(triggers.wrapDB));
|
||||
export { query, internalQuery, action, internalAction, httpAction };
|
||||
204
convex/githubBackups.test.ts
Normal file
204
convex/githubBackups.test.ts
Normal file
@ -0,0 +1,204 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { getGitHubBackupPageInternal } from "./githubBackups";
|
||||
|
||||
const handler = (getGitHubBackupPageInternal as unknown as { _handler: Function })._handler;
|
||||
|
||||
describe("githubBackups page filtering", () => {
|
||||
it("skips non-public digests (soft-deleted, hidden, removed)", async () => {
|
||||
const activeDigest = {
|
||||
_id: "skillSearchDigest:active",
|
||||
skillId: "skills:active",
|
||||
slug: "active-skill",
|
||||
displayName: "Active Skill",
|
||||
ownerUserId: "users:active",
|
||||
ownerHandle: "alice",
|
||||
latestVersionId: "skillVersions:active",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1_700_000_000_000,
|
||||
changelog: "init",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
};
|
||||
|
||||
const hiddenDigest = {
|
||||
_id: "skillSearchDigest:hidden",
|
||||
skillId: "skills:hidden",
|
||||
slug: "hidden-skill",
|
||||
displayName: "Hidden Skill",
|
||||
ownerUserId: "users:hidden",
|
||||
ownerHandle: "bob",
|
||||
latestVersionId: "skillVersions:hidden",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1_700_000_000_000,
|
||||
changelog: "init",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "hidden",
|
||||
};
|
||||
|
||||
const removedDigest = {
|
||||
_id: "skillSearchDigest:removed",
|
||||
skillId: "skills:removed",
|
||||
slug: "removed-skill",
|
||||
displayName: "Removed Skill",
|
||||
ownerUserId: "users:removed",
|
||||
ownerHandle: "carol",
|
||||
latestVersionId: "skillVersions:removed",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1_700_000_000_000,
|
||||
changelog: "init",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "removed",
|
||||
};
|
||||
|
||||
const softDeletedDigest = {
|
||||
_id: "skillSearchDigest:soft",
|
||||
skillId: "skills:soft",
|
||||
slug: "soft-skill",
|
||||
displayName: "Soft Skill",
|
||||
ownerUserId: "users:soft",
|
||||
ownerHandle: "dave",
|
||||
latestVersionId: "skillVersions:soft",
|
||||
latestVersionSummary: {
|
||||
version: "1.0.0",
|
||||
createdAt: 1_700_000_000_000,
|
||||
changelog: "init",
|
||||
},
|
||||
softDeletedAt: 1,
|
||||
moderationStatus: "active",
|
||||
};
|
||||
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [activeDigest, hiddenDigest, removedDigest, softDeletedDigest],
|
||||
isDone: true,
|
||||
continueCursor: null,
|
||||
});
|
||||
const order = vi.fn().mockReturnValue({ paginate });
|
||||
const query = vi.fn().mockReturnValue({ order });
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
db: { query },
|
||||
} as never,
|
||||
{ batchSize: 50 },
|
||||
);
|
||||
|
||||
expect(query).toHaveBeenCalledWith("skillSearchDigest");
|
||||
expect(result).toMatchObject({
|
||||
isDone: true,
|
||||
cursor: null,
|
||||
items: [
|
||||
{
|
||||
kind: "ok",
|
||||
slug: "active-skill",
|
||||
ownerHandle: "alice",
|
||||
version: "1.0.0",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps legacy digests with undefined moderationStatus eligible", async () => {
|
||||
const legacyDigest = {
|
||||
_id: "skillSearchDigest:legacy",
|
||||
skillId: "skills:legacy",
|
||||
slug: "legacy-skill",
|
||||
displayName: "Legacy Skill",
|
||||
ownerUserId: "users:legacy",
|
||||
ownerHandle: "",
|
||||
latestVersionId: "skillVersions:legacy",
|
||||
latestVersionSummary: {
|
||||
version: "2.0.0",
|
||||
createdAt: 1_700_000_000_100,
|
||||
changelog: "update",
|
||||
},
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: undefined,
|
||||
};
|
||||
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [legacyDigest],
|
||||
isDone: true,
|
||||
continueCursor: null,
|
||||
});
|
||||
const order = vi.fn().mockReturnValue({ paginate });
|
||||
const query = vi.fn().mockReturnValue({ order });
|
||||
|
||||
const result = await handler(
|
||||
{
|
||||
db: { query },
|
||||
} as never,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]).toMatchObject({
|
||||
kind: "ok",
|
||||
slug: "legacy-skill",
|
||||
ownerHandle: "users:legacy",
|
||||
version: "2.0.0",
|
||||
});
|
||||
});
|
||||
|
||||
it("skips digests without ownerHandle or latestVersionSummary", async () => {
|
||||
const noOwnerHandle = {
|
||||
_id: "skillSearchDigest:no-owner",
|
||||
skillId: "skills:no-owner",
|
||||
slug: "no-owner",
|
||||
displayName: "No Owner",
|
||||
ownerUserId: "users:no-owner",
|
||||
ownerHandle: undefined,
|
||||
latestVersionId: "skillVersions:no-owner",
|
||||
latestVersionSummary: { version: "1.0.0", createdAt: 1, changelog: "init" },
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
};
|
||||
const noVersion = {
|
||||
_id: "skillSearchDigest:no-version",
|
||||
skillId: "skills:no-version",
|
||||
slug: "no-version",
|
||||
displayName: "No Version",
|
||||
ownerUserId: "users:no-version",
|
||||
ownerHandle: "frank",
|
||||
latestVersionId: undefined,
|
||||
latestVersionSummary: undefined,
|
||||
softDeletedAt: undefined,
|
||||
moderationStatus: "active",
|
||||
};
|
||||
|
||||
const paginate = vi.fn().mockResolvedValue({
|
||||
page: [noOwnerHandle, noVersion],
|
||||
isDone: true,
|
||||
continueCursor: null,
|
||||
});
|
||||
const order = vi.fn().mockReturnValue({ paginate });
|
||||
const query = vi.fn().mockReturnValue({ order });
|
||||
|
||||
const result = await handler({ db: { query } } as never, {});
|
||||
|
||||
expect(result.items).toEqual([
|
||||
{ kind: "missingOwner", skillId: "skills:no-owner", ownerUserId: "users:no-owner" },
|
||||
{ kind: "missingLatestVersion", skillId: "skills:no-version" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("resets stale skills-table cursors after switching to digest pagination", async () => {
|
||||
const paginate = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("cursor is from a different query"))
|
||||
.mockResolvedValueOnce({ page: [], isDone: true, continueCursor: null });
|
||||
const order = vi.fn().mockReturnValue({ paginate });
|
||||
const query = vi.fn().mockReturnValue({ order });
|
||||
|
||||
const result = await handler({ db: { query } } as never, { cursor: "stale-cursor" });
|
||||
|
||||
expect(result).toMatchObject({ items: [], isDone: true, cursor: null });
|
||||
expect(paginate).toHaveBeenNthCalledWith(1, { cursor: "stale-cursor", numItems: 50 });
|
||||
expect(paginate).toHaveBeenNthCalledWith(2, { cursor: null, numItems: 50 });
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user