Commit

API and MCP server, Rust identity service, registration, site redesign

- apps/api: REST at api.g1t.sh and MCP at mcp.g1t.sh over one set of operations - services/identity: rewritten in Rust (WebAssembly on Workers), with registration - crates/contracts, crates/kit: shared types and plumbing for Rust services - ids are now TypeIDs (prefix + UUIDv7), monotonic within a millisecond - site: GitHub-style header, marketing page with animated illustrations, sign-up, explore and search, profiles, commits, docs, markdown READMEs and syntax highlighting - git front end publishes push events itself - README and plan updates

syntaqxcommitted Parent1b0bfc8Browse files
72 files+6411−9650/72 viewed
+333−58
1414 source = "registry+https://github.com/rust-lang/crates.io-index"
1515 checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99"
1616 dependencies = [
17− "crypto-common",
17+ "crypto-common 0.2.2",
1818 "inout",
1919 ]
2020
2626 dependencies = [
2727 "cipher",
2828 "cpubits",
29− "cpufeatures",
29+ "cpufeatures 0.3.1",
3030 "zeroize",
3131 ]
3232
6868 dependencies = [
6969 "base64ct",
7070 "blake2",
71− "cpufeatures",
71+ "cpufeatures 0.3.1",
7272 "password-hash",
7373 ]
7474
7575 [[package]]
76+name = "async-trait"
77+version = "0.1.92"
78+source = "registry+https://github.com/rust-lang/crates.io-index"
79+checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
80+dependencies = [
81+ "proc-macro2",
82+ "quote",
83+ "syn 3.0.6",
84+]
85+
86+[[package]]
7687 name = "atomic-waker"
7788 version = "1.1.2"
7889 source = "registry+https://github.com/rust-lang/crates.io-index"
116127
117128 [[package]]
118129 name = "base64"
130+version = "0.22.1"
131+source = "registry+https://github.com/rust-lang/crates.io-index"
132+checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
133+
134+[[package]]
135+name = "base64"
119136 version = "0.23.1"
120137 source = "registry+https://github.com/rust-lang/crates.io-index"
121138 checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
133150 checksum = "144e573728da132683b9488acd528274c790e07fc06ff81ee29f9d8f8b1041e0"
134151 dependencies = [
135152 "blowfish",
136− "pbkdf2",
137− "sha2",
153+ "pbkdf2 0.13.0",
154+ "sha2 0.11.0",
138155 ]
139156
140157 [[package]]
149166 source = "registry+https://github.com/rust-lang/crates.io-index"
150167 checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
151168 dependencies = [
152− "digest",
169+ "digest 0.11.3",
170+]
171+
172+[[package]]
173+name = "block-buffer"
174+version = "0.10.4"
175+source = "registry+https://github.com/rust-lang/crates.io-index"
176+checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
177+dependencies = [
178+ "generic-array 0.14.7",
153179 ]
154180
155181 [[package]]
240266 dependencies = [
241267 "cfg-if",
242268 "cipher",
243− "cpufeatures",
269+ "cpufeatures 0.3.1",
244270 "rand_core",
245271 "zeroize",
246272 ]
264290 source = "registry+https://github.com/rust-lang/crates.io-index"
265291 checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
266292 dependencies = [
267− "block-buffer",
268− "crypto-common",
293+ "block-buffer 0.12.1",
294+ "crypto-common 0.2.2",
269295 "inout",
270296 "zeroize",
271297 ]
341367
342368 [[package]]
343369 name = "cpufeatures"
370+version = "0.2.17"
371+source = "registry+https://github.com/rust-lang/crates.io-index"
372+checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280"
373+dependencies = [
374+ "libc",
375+]
376+
377+[[package]]
378+name = "cpufeatures"
344379 version = "0.3.1"
345380 source = "registry+https://github.com/rust-lang/crates.io-index"
346381 checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
376411
377412 [[package]]
378413 name = "crypto-common"
414+version = "0.1.7"
415+source = "registry+https://github.com/rust-lang/crates.io-index"
416+checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
417+dependencies = [
418+ "generic-array 0.14.7",
419+ "typenum",
420+]
421+
422+[[package]]
423+name = "crypto-common"
379424 version = "0.2.2"
380425 source = "registry+https://github.com/rust-lang/crates.io-index"
381426 checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
421466 checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
422467 dependencies = [
423468 "cfg-if",
424− "cpufeatures",
469+ "cpufeatures 0.3.1",
425470 "curve25519-dalek-derive",
426− "digest",
471+ "digest 0.11.3",
427472 "fiat-crypto",
428473 "rand_core",
429474 "rustc_version",
481526
482527 [[package]]
483528 name = "digest"
529+version = "0.10.7"
530+source = "registry+https://github.com/rust-lang/crates.io-index"
531+checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
532+dependencies = [
533+ "block-buffer 0.10.4",
534+ "crypto-common 0.1.7",
535+ "subtle",
536+]
537+
538+[[package]]
539+name = "digest"
484540 version = "0.11.3"
485541 source = "registry+https://github.com/rust-lang/crates.io-index"
486542 checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
487543 dependencies = [
488− "block-buffer",
544+ "block-buffer 0.12.1",
489545 "const-oid",
490− "crypto-common",
546+ "crypto-common 0.2.2",
491547 "ctutils",
492548 ]
493549
515571 checksum = "c0681a4fc24c767085329728d8dfba959af91228aa4610cca4f8ce317ba46ae0"
516572 dependencies = [
517573 "der",
518− "digest",
574+ "digest 0.11.3",
519575 "elliptic-curve",
520576 "rfc6979",
521577 "signature",
543599 "ed25519",
544600 "rand_core",
545601 "serde",
546− "sha2",
602+ "sha2 0.11.0",
547603 "signature",
548604 "subtle",
549605 "zeroize",
557613 dependencies = [
558614 "base16ct",
559615 "crypto-bigint",
560− "crypto-common",
561− "digest",
616+ "crypto-common 0.2.2",
617+ "digest 0.11.3",
562618 "ff",
563619 "group",
564620 "hkdf",
760816 version = "0.1.0"
761817
762818 [[package]]
819+name = "g1t-contracts"
820+version = "0.1.0"
821+dependencies = [
822+ "getrandom 0.2.17",
823+ "serde",
824+ "serde_json",
825+]
826+
827+[[package]]
828+name = "g1t-identity"
829+version = "0.1.0"
830+dependencies = [
831+ "base64 0.22.1",
832+ "g1t-contracts",
833+ "g1t-kit",
834+ "getrandom 0.2.17",
835+ "hex",
836+ "pbkdf2 0.12.2",
837+ "serde",
838+ "serde_json",
839+ "sha2 0.10.9",
840+ "worker",
841+]
842+
843+[[package]]
844+name = "g1t-kit"
845+version = "0.1.0"
846+dependencies = [
847+ "g1t-contracts",
848+ "serde",
849+ "serde_json",
850+ "worker",
851+]
852+
853+[[package]]
763854 name = "g1t-sshd"
764855 version = "0.1.0"
765856 dependencies = [
776867
777868 [[package]]
778869 name = "generic-array"
779−version = "0.14.9"
870+version = "0.14.7"
780871 source = "registry+https://github.com/rust-lang/crates.io-index"
781−checksum = "4bb6743198531e02858aeaea5398fcc883e71851fcbcb5a2f773e2fb6cb1edf2"
872+checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
782873 dependencies = [
783874 "typenum",
784875 "version_check",
790881 source = "registry+https://github.com/rust-lang/crates.io-index"
791882 checksum = "337d46834ee672ab3e48caca2cb0c78cc174fb12b3a68d0d88f99a0519a5e36e"
792883 dependencies = [
793− "generic-array 0.14.9",
884+ "generic-array 0.14.7",
794885 "rustversion",
795886 "typenum",
796887 ]
869960 checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
870961
871962 [[package]]
963+name = "heck"
964+version = "0.5.0"
965+source = "registry+https://github.com/rust-lang/crates.io-index"
966+checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
967+
968+[[package]]
872969 name = "hex"
873970 version = "0.4.3"
874971 source = "registry+https://github.com/rust-lang/crates.io-index"
886983 source = "registry+https://github.com/rust-lang/crates.io-index"
887984 checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
888985 dependencies = [
889− "hmac",
986+ "hmac 0.13.0",
987+]
988+
989+[[package]]
990+name = "hmac"
991+version = "0.12.1"
992+source = "registry+https://github.com/rust-lang/crates.io-index"
993+checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
994+dependencies = [
995+ "digest 0.10.7",
890996 ]
891997
892998 [[package]]
8951001 source = "registry+https://github.com/rust-lang/crates.io-index"
8961002 checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
8971003 dependencies = [
898− "digest",
1004+ "digest 0.11.3",
8991005 ]
9001006
9011007 [[package]]
9911097 source = "registry+https://github.com/rust-lang/crates.io-index"
9921098 checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff"
9931099 dependencies = [
994− "base64",
1100+ "base64 0.23.1",
9951101 "bytes",
9961102 "futures-channel",
9971103 "futures-util",
12481354 checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
12491355 dependencies = [
12501356 "cfg-if",
1251− "cpufeatures",
1357+ "cpufeatures 0.3.1",
12521358 ]
12531359
12541360 [[package]]
12571363 source = "registry+https://github.com/rust-lang/crates.io-index"
12581364 checksum = "01737161ba802849cfd486b5bd209d38ba4943494c249a8126005170c7621edd"
12591365 dependencies = [
1260− "crypto-common",
1366+ "crypto-common 0.2.2",
12611367 "rand_core",
12621368 ]
12631369
12951401 checksum = "4050469837a6ff301cd14c1f8f24f88549e6d548f24f64e2148eb0f72cebc51f"
12961402
12971403 [[package]]
1404+name = "matchit"
1405+version = "0.7.3"
1406+source = "registry+https://github.com/rust-lang/crates.io-index"
1407+checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94"
1408+
1409+[[package]]
12981410 name = "md5"
12991411 version = "0.8.1"
13001412 source = "registry+https://github.com/rust-lang/crates.io-index"
14281540 "elliptic-curve",
14291541 "primefield",
14301542 "primeorder",
1431− "sha2",
1543+ "sha2 0.11.0",
14321544 ]
14331545
14341546 [[package]]
14421554 "fiat-crypto",
14431555 "primefield",
14441556 "primeorder",
1445− "sha2",
1557+ "sha2 0.11.0",
14461558 ]
14471559
14481560 [[package]]
14561568 "elliptic-curve",
14571569 "primefield",
14581570 "primeorder",
1459− "sha2",
1571+ "sha2 0.11.0",
14601572 ]
14611573
14621574 [[package]]
14721584 "futures",
14731585 "log",
14741586 "rand",
1475− "sha2",
1587+ "sha2 0.11.0",
14761588 "thiserror",
14771589 "tokio",
14781590 "windows",
15131625
15141626 [[package]]
15151627 name = "pbkdf2"
1628+version = "0.12.2"
1629+source = "registry+https://github.com/rust-lang/crates.io-index"
1630+checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2"
1631+dependencies = [
1632+ "digest 0.10.7",
1633+ "hmac 0.12.1",
1634+]
1635+
1636+[[package]]
1637+name = "pbkdf2"
15161638 version = "0.13.0"
15171639 source = "registry+https://github.com/rust-lang/crates.io-index"
15181640 checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629"
15191641 dependencies = [
1520− "digest",
1521− "hmac",
1642+ "digest 0.11.3",
1643+ "hmac 0.13.0",
15221644 ]
15231645
15241646 [[package]]
15471669 ]
15481670
15491671 [[package]]
1672+name = "pin-project"
1673+version = "1.1.13"
1674+source = "registry+https://github.com/rust-lang/crates.io-index"
1675+checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924"
1676+dependencies = [
1677+ "pin-project-internal",
1678+]
1679+
1680+[[package]]
1681+name = "pin-project-internal"
1682+version = "1.1.13"
1683+source = "registry+https://github.com/rust-lang/crates.io-index"
1684+checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b"
1685+dependencies = [
1686+ "proc-macro2",
1687+ "quote",
1688+ "syn 2.0.119",
1689+]
1690+
1691+[[package]]
15501692 name = "pin-project-lite"
15511693 version = "0.2.17"
15521694 source = "registry+https://github.com/rust-lang/crates.io-index"
15721714 "aes-gcm",
15731715 "cbc",
15741716 "der",
1575− "pbkdf2",
1717+ "pbkdf2 0.13.0",
15761718 "rand_core",
15771719 "scrypt",
1578− "sha2",
1720+ "sha2 0.11.0",
15791721 "spki",
15801722 ]
15811723
16031745 source = "registry+https://github.com/rust-lang/crates.io-index"
16041746 checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c"
16051747 dependencies = [
1606− "cpufeatures",
1748+ "cpufeatures 0.3.1",
16071749 "universal-hash",
16081750 "zeroize",
16091751 ]
16151757 checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd"
16161758 dependencies = [
16171759 "cpubits",
1618− "cpufeatures",
1760+ "cpufeatures 0.3.1",
16191761 "universal-hash",
16201762 "zeroize",
16211763 ]
16361778 checksum = "c555a6e4eb7d4e158fcb028c835c3b8642206ddc279b5c6b202ef9a8bdb592f4"
16371779 dependencies = [
16381780 "crypto-bigint",
1639− "crypto-common",
1781+ "crypto-common 0.2.2",
16401782 "ff",
16411783 "rand_core",
16421784 "subtle",
17781920 source = "registry+https://github.com/rust-lang/crates.io-index"
17791921 checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029"
17801922 dependencies = [
1781− "base64",
1923+ "base64 0.23.1",
17821924 "bytes",
17831925 "encoding_rs",
17841926 "futures-core",
18111953 "url",
18121954 "wasm-bindgen",
18131955 "wasm-bindgen-futures",
1814− "wasm-streams",
1956+ "wasm-streams 0.5.0",
18151957 "web-sys",
18161958 ]
18171959
18221964 checksum = "b4a459cddafb3fe76b31fd8f1108007566c40301feb64dc7b54656eb7388172b"
18231965 dependencies = [
18241966 "crypto-bigint",
1825− "hmac",
1967+ "hmac 0.13.0",
18261968 ]
18271969
18281970 [[package]]
18481990 "const-oid",
18491991 "crypto-bigint",
18501992 "crypto-primes",
1851− "digest",
1993+ "digest 0.11.3",
18521994 "pkcs1",
18531995 "pkcs8",
18541996 "rand_core",
1855− "sha2",
1997+ "sha2 0.11.0",
18561998 "signature",
18571999 "spki",
18582000 "zeroize",
18782020 "data-encoding",
18792021 "delegate",
18802022 "der",
1881− "digest",
2023+ "digest 0.11.3",
18822024 "ecdsa",
18832025 "ed25519-dalek",
18842026 "elliptic-curve",
18892031 "getrandom 0.4.3",
18902032 "ghash",
18912033 "hex-literal",
1892− "hmac",
2034+ "hmac 0.13.0",
18932035 "inout",
18942036 "keccak",
18952037 "log",
19012043 "p384",
19022044 "p521",
19032045 "pageant",
1904− "pbkdf2",
2046+ "pbkdf2 0.13.0",
19052047 "pkcs1",
19062048 "pkcs5",
19072049 "pkcs8",
19152057 "scrypt",
19162058 "sec1",
19172059 "sha1",
1918− "sha2",
2060+ "sha2 0.11.0",
19192061 "sha3 0.12.0",
19202062 "signature",
19212063 "spki",
20502192 checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
20512193
20522194 [[package]]
2195+name = "ryu"
2196+version = "1.0.23"
2197+source = "registry+https://github.com/rust-lang/crates.io-index"
2198+checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
2199+
2200+[[package]]
20532201 name = "salsa20"
20542202 version = "0.11.0"
20552203 source = "registry+https://github.com/rust-lang/crates.io-index"
20902238 checksum = "d87af57419b594aa23fa95f09f0e06d80d84ba01c26148c43844cad6ff4485f0"
20912239 dependencies = [
20922240 "cfg-if",
2093− "pbkdf2",
2241+ "pbkdf2 0.13.0",
20942242 "salsa20",
2095− "sha2",
2243+ "sha2 0.11.0",
20962244 ]
20972245
20982246 [[package]]
21492297 ]
21502298
21512299 [[package]]
2300+name = "serde-wasm-bindgen"
2301+version = "0.6.5"
2302+source = "registry+https://github.com/rust-lang/crates.io-index"
2303+checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b"
2304+dependencies = [
2305+ "js-sys",
2306+ "serde",
2307+ "wasm-bindgen",
2308+]
2309+
2310+[[package]]
21522311 name = "serde_core"
21532312 version = "1.0.229"
21542313 source = "registry+https://github.com/rust-lang/crates.io-index"
21822341 ]
21832342
21842343 [[package]]
2344+name = "serde_urlencoded"
2345+version = "0.7.1"
2346+source = "registry+https://github.com/rust-lang/crates.io-index"
2347+checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd"
2348+dependencies = [
2349+ "form_urlencoded",
2350+ "itoa",
2351+ "ryu",
2352+ "serde",
2353+]
2354+
2355+[[package]]
21852356 name = "serdect"
21862357 version = "0.4.3"
21872358 source = "registry+https://github.com/rust-lang/crates.io-index"
21982369 checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
21992370 dependencies = [
22002371 "cfg-if",
2201− "cpufeatures",
2202− "digest",
2372+ "cpufeatures 0.3.1",
2373+ "digest 0.11.3",
22032374 ]
22042375
22052376 [[package]]
22062377 name = "sha2"
2378+version = "0.10.9"
2379+source = "registry+https://github.com/rust-lang/crates.io-index"
2380+checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
2381+dependencies = [
2382+ "cfg-if",
2383+ "cpufeatures 0.2.17",
2384+ "digest 0.10.7",
2385+]
2386+
2387+[[package]]
2388+name = "sha2"
22072389 version = "0.11.0"
22082390 source = "registry+https://github.com/rust-lang/crates.io-index"
22092391 checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
22102392 dependencies = [
22112393 "cfg-if",
2212− "cpufeatures",
2213− "digest",
2394+ "cpufeatures 0.3.1",
2395+ "digest 0.11.3",
22142396 ]
22152397
22162398 [[package]]
22192401 source = "registry+https://github.com/rust-lang/crates.io-index"
22202402 checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1"
22212403 dependencies = [
2222− "digest",
2404+ "digest 0.11.3",
22232405 "keccak",
22242406 ]
22252407
22292411 source = "registry+https://github.com/rust-lang/crates.io-index"
22302412 checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
22312413 dependencies = [
2232− "digest",
2414+ "digest 0.11.3",
22332415 "keccak",
22342416 "sponge-cursor",
22352417 ]
22562438 source = "registry+https://github.com/rust-lang/crates.io-index"
22572439 checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
22582440 dependencies = [
2259− "digest",
2441+ "digest 0.11.3",
22602442 "rand_core",
22612443 ]
22622444
23482530 "bytes",
23492531 "crypto-bigint",
23502532 "ctutils",
2351− "digest",
2533+ "digest 0.11.3",
23522534 "pem-rfc7468",
23532535 "zeroize",
23542536 ]
23642546 "ctutils",
23652547 "ed25519-dalek",
23662548 "hex",
2367− "hmac",
2549+ "hmac 0.13.0",
23682550 "p256",
23692551 "p384",
23702552 "p521",
23722554 "rsa",
23732555 "sec1",
23742556 "sha1",
2375− "sha2",
2557+ "sha2 0.11.0",
23762558 "signature",
23772559 "ssh-cipher",
23782560 "ssh-encoding",
23862568 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
23872569
23882570 [[package]]
2571+name = "strum"
2572+version = "0.28.0"
2573+source = "registry+https://github.com/rust-lang/crates.io-index"
2574+checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
2575+dependencies = [
2576+ "strum_macros",
2577+]
2578+
2579+[[package]]
2580+name = "strum_macros"
2581+version = "0.28.0"
2582+source = "registry+https://github.com/rust-lang/crates.io-index"
2583+checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
2584+dependencies = [
2585+ "heck",
2586+ "proc-macro2",
2587+ "quote",
2588+ "syn 2.0.119",
2589+]
2590+
2591+[[package]]
23892592 name = "subtle"
23902593 version = "2.6.1"
23912594 source = "registry+https://github.com/rust-lang/crates.io-index"
26582861 source = "registry+https://github.com/rust-lang/crates.io-index"
26592862 checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96"
26602863 dependencies = [
2661− "crypto-common",
2864+ "crypto-common 0.2.2",
26622865 "ctutils",
26632866 ]
26642867
27932996 ]
27942997
27952998 [[package]]
2999+name = "wasm-streams"
3000+version = "0.7.0"
3001+source = "registry+https://github.com/rust-lang/crates.io-index"
3002+checksum = "94ce9bdac782bbca70d6ac48a3ba18b873c848a706d6dc3bd9a03c1b96f04693"
3003+dependencies = [
3004+ "futures-util",
3005+ "js-sys",
3006+ "wasm-bindgen",
3007+ "wasm-bindgen-futures",
3008+ "web-sys",
3009+]
3010+
3011+[[package]]
27963012 name = "web-sys"
27973013 version = "0.3.106"
27983014 source = "registry+https://github.com/rust-lang/crates.io-index"
30463262 ]
30473263
30483264 [[package]]
3265+name = "worker"
3266+version = "0.8.7"
3267+source = "registry+https://github.com/rust-lang/crates.io-index"
3268+checksum = "4057bce8ec66c80b7e270d884c312e2a4ff60eb997fd02936656106c06a964e5"
3269+dependencies = [
3270+ "async-trait",
3271+ "bytes",
3272+ "chrono",
3273+ "futures-channel",
3274+ "futures-util",
3275+ "http",
3276+ "http-body",
3277+ "js-sys",
3278+ "matchit",
3279+ "pin-project",
3280+ "serde",
3281+ "serde-wasm-bindgen",
3282+ "serde_json",
3283+ "serde_urlencoded",
3284+ "strum",
3285+ "tokio",
3286+ "url",
3287+ "wasm-bindgen",
3288+ "wasm-bindgen-futures",
3289+ "wasm-streams 0.7.0",
3290+ "web-sys",
3291+ "worker-macros",
3292+ "worker-sys",
3293+]
3294+
3295+[[package]]
3296+name = "worker-macros"
3297+version = "0.8.7"
3298+source = "registry+https://github.com/rust-lang/crates.io-index"
3299+checksum = "f3d7389670b55aab89760be149007aeff773cdad7b650d807f7fb556d04bb304"
3300+dependencies = [
3301+ "async-trait",
3302+ "proc-macro2",
3303+ "quote",
3304+ "strum",
3305+ "syn 3.0.6",
3306+ "wasm-bindgen",
3307+ "wasm-bindgen-macro-support",
3308+ "worker-sys",
3309+]
3310+
3311+[[package]]
3312+name = "worker-sys"
3313+version = "0.8.7"
3314+source = "registry+https://github.com/rust-lang/crates.io-index"
3315+checksum = "6a440092e7ae7c5feb5507fbdc6aee0cc241d132757c83e1729766a24cddff82"
3316+dependencies = [
3317+ "cfg-if",
3318+ "js-sys",
3319+ "wasm-bindgen",
3320+ "web-sys",
3321+]
3322+
3323+[[package]]
30493324 name = "writeable"
30503325 version = "0.6.4"
30513326 source = "registry+https://github.com/rust-lang/crates.io-index"
+10−2
11 [workspace]
22 resolver = "3"
3−members = ["crates/*"]
3+members = ["crates/*", "services/identity"]
44
55 [workspace.package]
66 edition = "2024"
77 license = "MIT"
88 repository = "https://g1t.sh/syntaqx/g1t"
99
10+[workspace.dependencies]
11+g1t-contracts = { path = "crates/contracts" }
12+g1t-kit = { path = "crates/kit" }
13+serde = { version = "1", features = ["derive"] }
14+serde_json = "1"
15+worker = { version = "0.8", features = ["d1", "queue"] }
16+
1017 [profile.release]
11−strip = true
18+strip = "debuginfo"
1219 lto = "thin"
20+opt-level = "s"
+96−0
1+# g1t
2+
3+A git forge built for agents, running on Cloudflare Workers and Artifacts.
4+
5+A pull request assumes one author and one change. g1t assumes many agents
6+working at once: you state a goal as an **intent**, any number of agents
7+**attempt** it in parallel, each in its own fork, and the one that works
8+ships.
9+
10+- Site: <https://g1t.sh>
11+- Docs: <https://g1t.sh/docs>
12+- API: <https://api.g1t.sh> · MCP: <https://mcp.g1t.sh>
13+- Plan and design: [docs/PLAN.md](docs/PLAN.md)
14+
15+## Status
16+
17+Working today:
18+
19+- Accounts, access tokens, public and private repositories.
20+- Git over HTTPS, including creating a repository by pushing to it.
21+- Intents, attempts (a copy-on-write fork each) and recorded agent sessions.
22+- A REST API and an MCP server over the same operations.
23+- An event bus: every state change is published, logged and delivered to
24+ subscribers.
25+
26+Not built yet: shipping an attempt through a landing queue, running
27+acceptance checks, hosted agents, git over SSH. See the build order in the
28+plan.
29+
30+## Try it
31+
32+```sh
33+# 1. Create an account at https://g1t.sh/register and an access token in Settings.
34+export G1T_TOKEN=g1t_…
35+
36+# 2. Connect Claude Code.
37+claude mcp add --transport http g1t https://mcp.g1t.sh \
38+ --header "Authorization: Bearer $G1T_TOKEN"
39+
40+# 3. Ask it to start an attempt on an open intent.
41+```
42+
43+[Getting started](https://g1t.sh/docs) walks through this in full.
44+
45+## Layout
46+
47+| Path | What it is |
48+| --- | --- |
49+| `apps/web` | The site: server-rendered React on a Worker. Holds no data. |
50+| `apps/api` | REST API and MCP server. |
51+| `services/identity` | Accounts, sessions, keys and tokens. Rust. |
52+| `services/repos` | Repository registry, contents, forks, git over HTTPS. |
53+| `services/work` | Intents, attempts and sessions. |
54+| `services/events` | The event bus and its log. |
55+| `crates/contracts` | Types and service interfaces for the Rust services. |
56+| `crates/kit` | Plumbing shared by Rust services on Workers. |
57+| `crates/sshd` | Git over SSH, bridged to Artifacts. Not deployed yet. |
58+| `packages/contracts` | The same interfaces for TypeScript callers. |
59+
60+Each service is its own Worker with its own database. They call each other
61+through service bindings and react to each other through events. Services
62+are being moved from TypeScript to Rust one at a time; identity is done.
63+
64+## Run your own
65+
66+You need a Cloudflare account on the Workers Paid plan (Artifacts requires
67+it), Node 22 or newer, and Rust with the `wasm32-unknown-unknown` target.
68+
69+```sh
70+npm install
71+npx wrangler login
72+```
73+
74+Then, once:
75+
76+1. Create the D1 databases (`g1t`, `g1t-repos`, `g1t-work`, `g1t-events`) and
77+ the queues (`g1t-events`, `g1t-events-work`) with `wrangler d1 create` and
78+ `wrangler queues create`.
79+2. Put your own `account_id`, database ids and hostnames in each
80+ `wrangler.jsonc`.
81+3. Apply the migrations: `npx wrangler d1 migrations apply DB --remote` in
82+ each service directory.
83+
84+Deploy everything in dependency order:
85+
86+```sh
87+(cd services/identity && npx wrangler deploy)
88+npm run deploy
89+```
90+
91+Create the first account by registering on your site, or with
92+`node services/identity/scripts/create-user.mjs <username>`.
93+
94+## License
95+
96+[MIT](LICENSE)
+15−0
1+{
2+ "name": "@g1t/api",
3+ "version": "0.1.0",
4+ "private": true,
5+ "type": "module",
6+ "license": "MIT",
7+ "scripts": {
8+ "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
9+ "deploy": "wrangler deploy"
10+ },
11+ "dependencies": {
12+ "@g1t/contracts": "*",
13+ "hono": "^4"
14+ }
15+}
+102−0
1+import { Hono } from "hono";
2+
3+import { type Viewer, httpStatus, identityClient } from "@g1t/contracts";
4+
5+import { handleMcp } from "./mcp";
6+import { type ApiEnv, operations, operationsByName } from "./operations";
7+
8+type Input = Record<string, unknown>;
9+type App = { Bindings: ApiEnv; Variables: { viewer: Viewer } };
10+
11+/**
12+ * REST routes. Each maps an HTTP request onto one operation; `input` builds
13+ * the operation's input from the path, query string and JSON body.
14+ */
15+const ROUTES: {
16+ method: "GET" | "POST";
17+ path: string;
18+ operation: string;
19+ input?: (params: Record<string, string>, query: Input, body: Input) => Input;
20+}[] = [
21+ { method: "GET", path: "/v1/user", operation: "whoami" },
22+ { method: "GET", path: "/v1/repos", operation: "list_repos", input: (_p, q) => ({ query: q.q }) },
23+ { method: "POST", path: "/v1/repos", operation: "create_repo", input: (_p, _q, b) => b },
24+ { method: "GET", path: "/v1/repos/:owner/:name", operation: "get_repo", input: repo },
25+ { method: "GET", path: "/v1/repos/:owner/:name/intents", operation: "list_intents", input: (p, q) => ({ ...repo(p), status: q.status }) },
26+ { method: "POST", path: "/v1/repos/:owner/:name/intents", operation: "open_intent", input: (p, _q, b) => ({ ...b, ...repo(p) }) },
27+ { method: "GET", path: "/v1/repos/:owner/:name/intents/:number", operation: "get_intent", input: (p) => ({ ...repo(p), number: Number(p.number) }) },
28+ { method: "GET", path: "/v1/repos/:owner/:name/events", operation: "list_events", input: (p, q) => ({ ...repo(p), before: q.before }) },
29+ { method: "POST", path: "/v1/intents/:intent_id/attempts", operation: "start_attempt", input: (p, _q, b) => ({ ...b, intent_id: p.intent_id }) },
30+ { method: "GET", path: "/v1/attempts/:attempt_id", operation: "get_attempt", input: (p) => p },
31+ { method: "GET", path: "/v1/attempts/:attempt_id/session", operation: "read_session", input: (p, q) => ({ ...p, after: Number(q.after) || 0 }) },
32+ { method: "POST", path: "/v1/attempts/:attempt_id/session", operation: "record_session", input: (p, _q, b) => ({ ...b, ...p }) },
33+ { method: "POST", path: "/v1/attempts/:attempt_id/submit", operation: "submit_attempt", input: (p, _q, b) => ({ ...b, ...p }) },
34+ { method: "POST", path: "/v1/attempts/:attempt_id/abandon", operation: "abandon_attempt", input: (p) => p },
35+];
36+
37+function repo(params: Record<string, string>): Input {
38+ return { repo: `${params.owner}/${params.name}` };
39+}
40+
41+const app = new Hono<App>();
42+
43+// `Authorization: Bearer g1t_…`. A missing token is an anonymous viewer; a
44+// wrong one is rejected so a typo does not silently look signed out.
45+app.use(async (c, next) => {
46+ const [scheme, token] = (c.req.header("authorization") ?? "").split(" ");
47+ let viewer: Viewer = null;
48+ if (scheme?.toLowerCase() === "bearer" && token) {
49+ viewer = await identityClient(c.env.IDENTITY).userForAccessToken(token);
50+ if (!viewer) {
51+ return c.json(
52+ { error: { code: "unauthenticated", message: "Invalid access token." } },
53+ 401,
54+ );
55+ }
56+ }
57+ c.set("viewer", viewer);
58+ await next();
59+});
60+
61+app.all("*", async (c, next) => {
62+ if (new URL(c.req.url).hostname.startsWith("mcp.")) {
63+ return handleMcp(c.req.raw, c.env, c.get("viewer"));
64+ }
65+ await next();
66+});
67+
68+app.get("/", (c) =>
69+ c.json({
70+ name: "g1t API",
71+ version: "v1",
72+ documentation: "https://g1t.sh/syntaqx/g1t",
73+ operations: operations.map(({ name, description }) => ({ name, description })),
74+ }),
75+);
76+
77+for (const route of ROUTES) {
78+ const operation = operationsByName.get(route.operation)!;
79+ app.on(route.method, route.path, async (c) => {
80+ let body: Input = {};
81+ if (route.method === "POST") {
82+ try {
83+ body = await c.req.json();
84+ } catch {
85+ // An empty or non-JSON body is treated as no input.
86+ }
87+ }
88+ const input = route.input?.(c.req.param(), c.req.query(), body) ?? {};
89+ const outcome = await operation.run(c.env, c.get("viewer"), input);
90+ if (outcome.ok) return c.json(outcome.value);
91+ return c.json(
92+ { error: outcome.error },
93+ httpStatus(outcome.error) as 401 | 403 | 404 | 409 | 422,
94+ );
95+ });
96+}
97+
98+app.notFound((c) =>
99+ c.json({ error: { code: "not_found", message: "No such endpoint." } }, 404),
100+);
101+
102+export default app;
+110−0
1+import type { Viewer } from "@g1t/contracts";
2+
3+import { type ApiEnv, operations, operationsByName } from "./operations";
4+
5+const SUPPORTED_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
6+
7+const INSTRUCTIONS = `g1t is a git forge where agents work on intents.
8+An intent is a goal on a repository. To work on one: get_intent, then
9+start_attempt (you get your own fork to clone and push to), record_session
10+as you work so people can see your reasoning, push your commits, and
11+submit_attempt with a summary.`;
12+
13+type JsonRpcRequest = {
14+ jsonrpc: "2.0";
15+ id?: string | number | null;
16+ method: string;
17+ params?: Record<string, unknown>;
18+};
19+
20+function result(id: JsonRpcRequest["id"], value: unknown): object {
21+ return { jsonrpc: "2.0", id, result: value };
22+}
23+
24+function error(id: JsonRpcRequest["id"], code: number, message: string): object {
25+ return { jsonrpc: "2.0", id: id ?? null, error: { code, message } };
26+}
27+
28+async function handle(
29+ env: ApiEnv,
30+ viewer: Viewer,
31+ request: JsonRpcRequest,
32+): Promise<object | null> {
33+ // Notifications carry no id and get no response.
34+ if (request.id === undefined) return null;
35+
36+ switch (request.method) {
37+ case "initialize": {
38+ const requested = String(request.params?.protocolVersion ?? "");
39+ return result(request.id, {
40+ protocolVersion: SUPPORTED_VERSIONS.includes(requested)
41+ ? requested
42+ : SUPPORTED_VERSIONS[0],
43+ capabilities: { tools: {} },
44+ serverInfo: { name: "g1t", version: "0.1.0" },
45+ instructions: INSTRUCTIONS,
46+ });
47+ }
48+ case "ping":
49+ return result(request.id, {});
50+ case "tools/list":
51+ return result(request.id, {
52+ tools: operations.map(({ name, description, input }) => ({
53+ name,
54+ description,
55+ inputSchema: input,
56+ })),
57+ });
58+ case "tools/call": {
59+ const operation = operationsByName.get(String(request.params?.name));
60+ if (!operation) return error(request.id, -32602, "Unknown tool.");
61+ const input = (request.params?.arguments ?? {}) as Record<string, unknown>;
62+ const outcome = await operation.run(env, viewer, input);
63+ // A failed operation is a tool result the model can read and act on,
64+ // not a protocol error.
65+ return result(request.id, {
66+ content: [
67+ {
68+ type: "text",
69+ text: outcome.ok
70+ ? JSON.stringify(outcome.value, null, 2)
71+ : outcome.error.message,
72+ },
73+ ],
74+ isError: !outcome.ok,
75+ });
76+ }
77+ default:
78+ return error(request.id, -32601, `Method not found: ${request.method}`);
79+ }
80+}
81+
82+/**
83+ * MCP over streamable HTTP. The server keeps no session state, so every
84+ * POST is answered directly with JSON.
85+ */
86+export async function handleMcp(
87+ request: Request,
88+ env: ApiEnv,
89+ viewer: Viewer,
90+): Promise<Response> {
91+ if (request.method !== "POST") {
92+ return new Response(null, { status: 405, headers: { allow: "POST" } });
93+ }
94+ let body: JsonRpcRequest | JsonRpcRequest[];
95+ try {
96+ body = await request.json();
97+ } catch {
98+ return Response.json(error(null, -32700, "Parse error"), { status: 400 });
99+ }
100+ if (Array.isArray(body)) {
101+ const responses = (
102+ await Promise.all(body.map((item) => handle(env, viewer, item)))
103+ ).filter((response) => response !== null);
104+ return responses.length
105+ ? Response.json(responses)
106+ : new Response(null, { status: 202 });
107+ }
108+ const response = await handle(env, viewer, body);
109+ return response ? Response.json(response) : new Response(null, { status: 202 });
110+}
+337−0
1+import {
2+ type EventsApi,
3+ type ServiceBinding,
4+ type NewSessionEntry,
5+ type RepoPath,
6+ type ReposApi,
7+ type Result,
8+ type User,
9+ type Viewer,
10+ type WorkApi,
11+ fail,
12+ ok,
13+} from "@g1t/contracts";
14+
15+export interface ApiEnv {
16+ IDENTITY: ServiceBinding;
17+ REPOS: ReposApi;
18+ WORK: WorkApi;
19+ EVENTS: EventsApi;
20+}
21+
22+type Input = Record<string, unknown>;
23+
24+type JsonSchema = {
25+ type: "object";
26+ properties: Record<string, object>;
27+ required?: string[];
28+};
29+
30+/**
31+ * One thing a client can do. REST routes and MCP tools are both generated
32+ * from this list, so the two surfaces cannot drift apart.
33+ */
34+export type Operation = {
35+ name: string;
36+ description: string;
37+ input: JsonSchema;
38+ run(env: ApiEnv, viewer: Viewer, input: Input): Promise<Result<unknown>>;
39+};
40+
41+const SIGN_IN = fail("unauthenticated", "This needs a g1t access token.");
42+
43+const REPO = {
44+ type: "string",
45+ description: 'Repository as "owner/name", e.g. "syntaqx/hello".',
46+};
47+
48+function text(input: Input, key: string): string {
49+ const value = input[key];
50+ return typeof value === "string" ? value : "";
51+}
52+
53+function repoPath(input: Input): RepoPath | null {
54+ const [namespace, name, ...rest] = text(input, "repo").split("/");
55+ return namespace && name && rest.length === 0 ? { namespace, name } : null;
56+}
57+
58+const BAD_REPO = fail("invalid", 'Give the repository as "owner/name".');
59+
60+/** Wraps an operation that needs a signed-in user. */
61+function authed(
62+ run: (env: ApiEnv, user: User, input: Input) => Promise<Result<unknown>>,
63+): Operation["run"] {
64+ return (env, viewer, input) =>
65+ viewer ? run(env, viewer, input) : Promise.resolve(SIGN_IN);
66+}
67+
68+export const operations: Operation[] = [
69+ {
70+ name: "whoami",
71+ description: "The account the access token belongs to.",
72+ input: { type: "object", properties: {} },
73+ run: authed(async (_env, user) => ok(user)),
74+ },
75+ {
76+ name: "list_repos",
77+ description: "Repositories you can see, optionally filtered by a search query.",
78+ input: {
79+ type: "object",
80+ properties: { query: { type: "string", description: "Matches name or description." } },
81+ },
82+ run: async (env, viewer, input) =>
83+ ok(await env.REPOS.list(viewer, { query: text(input, "query") })),
84+ },
85+ {
86+ name: "get_repo",
87+ description: "One repository's details.",
88+ input: { type: "object", properties: { repo: REPO }, required: ["repo"] },
89+ run: async (env, viewer, input) => {
90+ const path = repoPath(input);
91+ return path ? env.REPOS.get(path, viewer) : BAD_REPO;
92+ },
93+ },
94+ {
95+ name: "create_repo",
96+ description: "Create a repository under your account.",
97+ input: {
98+ type: "object",
99+ properties: {
100+ name: { type: "string" },
101+ description: { type: "string" },
102+ private: { type: "boolean" },
103+ },
104+ required: ["name"],
105+ },
106+ run: authed((env, user, input) =>
107+ env.REPOS.create(user, {
108+ name: text(input, "name"),
109+ description: text(input, "description"),
110+ isPrivate: input.private === true,
111+ }),
112+ ),
113+ },
114+ {
115+ name: "list_intents",
116+ description:
117+ "Intents on a repository. An intent is a goal that agents attempt in parallel; it replaces issues and pull requests.",
118+ input: {
119+ type: "object",
120+ properties: {
121+ repo: REPO,
122+ status: { type: "string", enum: ["open", "shipped", "withdrawn"] },
123+ },
124+ required: ["repo"],
125+ },
126+ run: async (env, viewer, input) => {
127+ const path = repoPath(input);
128+ const status = text(input, "status");
129+ if (!path) return BAD_REPO;
130+ return env.WORK.listIntents(
131+ path,
132+ viewer,
133+ status === "open" || status === "shipped" || status === "withdrawn"
134+ ? status
135+ : undefined,
136+ );
137+ },
138+ },
139+ {
140+ name: "get_intent",
141+ description:
142+ "An intent's brief, its acceptance checks, and every attempt made at it so far. Read this before starting an attempt.",
143+ input: {
144+ type: "object",
145+ properties: { repo: REPO, number: { type: "integer" } },
146+ required: ["repo", "number"],
147+ },
148+ run: async (env, viewer, input) => {
149+ const path = repoPath(input);
150+ return path
151+ ? env.WORK.getIntent(path, Number(input.number), viewer)
152+ : BAD_REPO;
153+ },
154+ },
155+ {
156+ name: "open_intent",
157+ description: "State a new goal for a repository.",
158+ input: {
159+ type: "object",
160+ properties: {
161+ repo: REPO,
162+ title: { type: "string", description: "The goal in one line." },
163+ brief: {
164+ type: "string",
165+ description: "What an agent needs to do the work: outcome, constraints, context.",
166+ },
167+ checks: {
168+ type: "array",
169+ items: { type: "string" },
170+ description: "Commands that must pass for an attempt to be accepted.",
171+ },
172+ },
173+ required: ["repo", "title", "brief"],
174+ },
175+ run: authed(async (env, user, input) => {
176+ const path = repoPath(input);
177+ if (!path) return BAD_REPO;
178+ return env.WORK.openIntent(user, path, {
179+ title: text(input, "title"),
180+ brief: text(input, "brief"),
181+ checks: Array.isArray(input.checks) ? input.checks.map(String) : [],
182+ });
183+ }),
184+ },
185+ {
186+ name: "start_attempt",
187+ description:
188+ "Begin working on an intent. Creates a private fork for this attempt and returns its git remote. Clone it, commit your work there, push, record your session as you go, then call submit_attempt.",
189+ input: {
190+ type: "object",
191+ properties: {
192+ intent_id: { type: "string" },
193+ agent: {
194+ type: "string",
195+ description: 'A label for the agent doing the work, e.g. "claude-code".',
196+ },
197+ },
198+ required: ["intent_id"],
199+ },
200+ run: authed(async (env, user, input) => {
201+ const started = await env.WORK.startAttempt(user, text(input, "intent_id"), {
202+ agent: text(input, "agent") || "agent",
203+ runtime: "external",
204+ });
205+ if (!started.ok) return started;
206+ const { fork } = started.value;
207+ return ok({
208+ attempt: started.value,
209+ git: {
210+ remote: `https://g1t.sh/${fork.namespace}/${fork.name}.git`,
211+ username: user.username,
212+ password: "your g1t access token",
213+ },
214+ });
215+ }),
216+ },
217+ {
218+ name: "get_attempt",
219+ description: "An attempt's status, head commit and the intent it belongs to.",
220+ input: {
221+ type: "object",
222+ properties: { attempt_id: { type: "string" } },
223+ required: ["attempt_id"],
224+ },
225+ run: (env, viewer, input) =>
226+ env.WORK.getAttempt(text(input, "attempt_id"), viewer),
227+ },
228+ {
229+ name: "record_session",
230+ description:
231+ "Append entries to an attempt's session: the prompt you were given, your reasoning, the tools you ran. This is how people later see why a change was made, so record as you work, not only at the end.",
232+ input: {
233+ type: "object",
234+ properties: {
235+ attempt_id: { type: "string" },
236+ entries: {
237+ type: "array",
238+ items: {
239+ type: "object",
240+ properties: {
241+ kind: {
242+ type: "string",
243+ enum: ["prompt", "message", "tool_call", "tool_result", "note"],
244+ },
245+ text: { type: "string" },
246+ tool: { type: "string", description: "Tool name, for tool entries." },
247+ },
248+ required: ["kind", "text"],
249+ },
250+ },
251+ },
252+ required: ["attempt_id", "entries"],
253+ },
254+ run: authed(async (env, user, input) => {
255+ if (!Array.isArray(input.entries)) {
256+ return fail("invalid", "entries must be an array.");
257+ }
258+ return env.WORK.appendSession(
259+ user,
260+ text(input, "attempt_id"),
261+ input.entries as NewSessionEntry[],
262+ );
263+ }),
264+ },
265+ {
266+ name: "read_session",
267+ description: "The recorded session of an attempt, oldest entry first.",
268+ input: {
269+ type: "object",
270+ properties: {
271+ attempt_id: { type: "string" },
272+ after: { type: "integer", description: "Only entries after this sequence number." },
273+ },
274+ required: ["attempt_id"],
275+ },
276+ run: (env, viewer, input) =>
277+ env.WORK.readSession(
278+ text(input, "attempt_id"),
279+ viewer,
280+ Number(input.after) || 0,
281+ ),
282+ },
283+ {
284+ name: "submit_attempt",
285+ description:
286+ "Mark an attempt ready to be compared and shipped. Push your commits first. The summary should say what changed and why.",
287+ input: {
288+ type: "object",
289+ properties: { attempt_id: { type: "string" }, summary: { type: "string" } },
290+ required: ["attempt_id", "summary"],
291+ },
292+ run: authed((env, user, input) =>
293+ env.WORK.submitAttempt(user, text(input, "attempt_id"), text(input, "summary")),
294+ ),
295+ },
296+ {
297+ name: "abandon_attempt",
298+ description: "Give up on an attempt.",
299+ input: {
300+ type: "object",
301+ properties: { attempt_id: { type: "string" } },
302+ required: ["attempt_id"],
303+ },
304+ run: authed((env, user, input) =>
305+ env.WORK.abandonAttempt(user, text(input, "attempt_id")),
306+ ),
307+ },
308+ {
309+ name: "list_events",
310+ description:
311+ "The timeline of a repository: pushes, intents, attempts and session activity, newest first.",
312+ input: {
313+ type: "object",
314+ properties: {
315+ repo: REPO,
316+ before: { type: "string", description: "Event id to page back from." },
317+ },
318+ required: ["repo"],
319+ },
320+ run: async (env, viewer, input) => {
321+ const path = repoPath(input);
322+ if (!path) return BAD_REPO;
323+ const repo = await env.REPOS.get(path, viewer);
324+ if (!repo.ok) return repo;
325+ return ok(
326+ await env.EVENTS.list({
327+ repoId: repo.value.id,
328+ before: text(input, "before") || undefined,
329+ }),
330+ );
331+ },
332+ },
333+];
334+
335+export const operationsByName = new Map(
336+ operations.map((operation) => [operation.name, operation]),
337+);
+4−0
1+{
2+ "extends": "../../tsconfig.base.json",
3+ "include": ["src/**/*", "worker-configuration.d.ts"]
4+}
+21−0
1+{
2+ "$schema": "../../node_modules/wrangler/config-schema.json",
3+ "name": "g1t-api",
4+ "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
5+ "compatibility_date": "2026-09-26",
6+ "main": "./src/index.ts",
7+ "workers_dev": false,
8+ // One Worker, two hostnames: REST on api, MCP on mcp. Both are thin
9+ // adapters over the same operations.
10+ "routes": [
11+ { "pattern": "api.g1t.sh", "custom_domain": true },
12+ { "pattern": "mcp.g1t.sh", "custom_domain": true }
13+ ],
14+ "services": [
15+ { "binding": "IDENTITY", "service": "g1t-identity" },
16+ { "binding": "REPOS", "service": "g1t-repos" },
17+ { "binding": "WORK", "service": "g1t-work" },
18+ { "binding": "EVENTS", "service": "g1t-events" }
19+ ],
20+ "observability": { "enabled": true }
21+}
+0−79
1−# Welcome to React Router!
2−
3−A modern, production-ready template for building full-stack React applications using React Router.
4−
5−## Features
6−
7−- 🚀 Server-side rendering
8−- ⚡️ Hot Module Replacement (HMR)
9−- 📦 Asset bundling and optimization
10−- 🔄 Data loading and mutations
11−- 🔒 TypeScript by default
12−- 🎉 TailwindCSS for styling
13−- 📖 [React Router docs](https://reactrouter.com/)
14−
15−## Getting Started
16−
17−### Installation
18−
19−Install the dependencies:
20−
21−```bash
22−npm install
23−```
24−
25−### Development
26−
27−Start the development server with HMR:
28−
29−```bash
30−npm run dev
31−```
32−
33−Your application will be available at `http://localhost:5173`.
34−
35−## Previewing the Production Build
36−
37−Preview the production build locally:
38−
39−```bash
40−npm run preview
41−```
42−
43−## Building for Production
44−
45−Create a production build:
46−
47−```bash
48−npm run build
49−```
50−
51−## Deployment
52−
53−Deployment is done using the Wrangler CLI.
54−
55−To build and deploy directly to production:
56−
57−```sh
58−npm run deploy
59−```
60−
61−To deploy a preview URL:
62−
63−```sh
64−npx wrangler versions upload
65−```
66−
67−You can then promote a version to production after verification or roll it out progressively.
68−
69−```sh
70−npx wrangler versions deploy
71−```
72−
73−## Styling
74−
75−This template comes with [Tailwind CSS](https://tailwindcss.com/) already configured for a simple default starting experience. You can use whatever CSS framework you prefer.
76−
77−---
78−
79−Built with ❤️ using React Router.
+127−8
66 --font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas,
77 monospace;
88
9− --color-bg: #12110d;
10− --color-surface: #1b1a15;
11− --color-line: #2a2822;
12− --color-fg: #edece6;
13− --color-muted: #8f8c82;
9+ /* Warm near-black, three elevations. */
10+ --color-bg: #0e0d0a;
11+ --color-surface: #16150f;
12+ --color-raised: #1e1c15;
13+ --color-line: #2a2820;
14+ --color-line-strong: #3a372d;
15+
16+ --color-fg: #f0eee6;
17+ --color-muted: #a09c8f;
18+ --color-faint: #6e6a5e;
19+
1420 --color-accent: #b6f24a;
21+ --color-accent-dim: #7fae2c;
22+
23+ /* Status colours, used sparingly. */
24+ --color-info: #7cc4ff;
25+ --color-shipped: #b9a2ff;
26+ --color-warn: #f2c14a;
27+ --color-danger: #ff7a6b;
28+
29+ --animate-fade-up: fade-up 0.6s ease-out both;
30+ --animate-dash: dash 1.2s linear infinite;
31+}
32+
33+@keyframes fade-up {
34+ from {
35+ opacity: 0;
36+ transform: translateY(8px);
37+ }
38+}
39+
40+@keyframes dash {
41+ to {
42+ stroke-dashoffset: -16;
43+ }
1544 }
1645
17−html,
46+html {
47+ color-scheme: dark;
48+ scroll-behavior: smooth;
49+}
50+
1851 body {
19− @apply bg-bg text-fg font-sans antialiased;
20− color-scheme: dark;
52+ @apply bg-bg font-sans text-fg antialiased;
53+ font-feature-settings: "cv11", "ss01";
54+}
55+
56+::selection {
57+ background: color-mix(in srgb, var(--color-accent) 35%, transparent);
58+}
59+
60+:focus-visible {
61+ outline: 2px solid var(--color-accent);
62+ outline-offset: 2px;
63+}
64+
65+@media (prefers-reduced-motion: reduce) {
66+ *,
67+ *::before,
68+ *::after {
69+ animation: none !important;
70+ scroll-behavior: auto !important;
71+ }
72+}
73+
74+/* Rendered markdown: READMEs, briefs and docs. */
75+.prose {
76+ @apply leading-7 text-fg;
77+}
78+.prose > * + * {
79+ @apply mt-4;
80+}
81+.prose h1 {
82+ @apply mt-0 text-3xl font-semibold tracking-tight;
83+}
84+.prose h2 {
85+ @apply mt-10 border-b border-line pb-2 text-xl font-semibold tracking-tight;
86+}
87+.prose h3 {
88+ @apply mt-8 text-base font-semibold;
89+}
90+.prose p,
91+.prose li {
92+ @apply text-[0.9375rem] text-fg/90;
93+}
94+.prose a {
95+ @apply text-accent underline decoration-accent/40 underline-offset-4 hover:decoration-accent;
96+}
97+.prose ul {
98+ @apply list-disc space-y-1.5 pl-6;
99+}
100+.prose ol {
101+ @apply list-decimal space-y-1.5 pl-6;
102+}
103+.prose code {
104+ @apply rounded bg-raised px-1.5 py-0.5 font-mono text-[0.85em];
105+}
106+.prose pre {
107+ @apply overflow-x-auto rounded-lg border border-line bg-surface p-4 font-mono text-sm leading-6;
108+}
109+.prose pre code {
110+ @apply bg-transparent p-0;
111+}
112+.prose blockquote {
113+ @apply border-l-2 border-line-strong pl-4 text-muted;
114+}
115+.prose table {
116+ @apply w-full border-collapse text-sm;
117+}
118+.prose th {
119+ @apply border-b border-line-strong px-3 py-2 text-left font-medium text-muted;
120+}
121+.prose td {
122+ @apply border-b border-line px-3 py-2 align-top;
123+}
124+.prose hr {
125+ @apply border-line;
126+}
127+
128+/* Syntax-highlighted code from Shiki. */
129+.shiki {
130+ @apply bg-transparent! font-mono text-sm leading-6;
131+}
132+.shiki code {
133+ counter-reset: line;
134+ display: block;
135+}
136+.shiki .line::before {
137+ counter-increment: line;
138+ content: counter(line);
139+ @apply mr-5 inline-block w-10 text-right text-faint select-none;
21140 }
+26−0
1+import type { ReactNode } from "react";
2+
3+import { Mark } from "./logo";
4+
5+/** The centred panel shared by the sign-in and sign-up pages. */
6+export function AuthCard({
7+ title,
8+ subtitle,
9+ footer,
10+ children,
11+}: {
12+ title: string;
13+ subtitle: string;
14+ footer: ReactNode;
15+ children: ReactNode;
16+}) {
17+ return (
18+ <main className="mx-auto flex max-w-sm flex-col px-4 pt-20 pb-10">
19+ <Mark className="size-9" />
20+ <h1 className="mt-6 text-2xl font-semibold tracking-tight">{title}</h1>
21+ <p className="text-2xl tracking-tight text-faint">{subtitle}</p>
22+ <div className="mt-8">{children}</div>
23+ <p className="mt-6 text-center text-sm text-muted">{footer}</p>
24+ </main>
25+ );
26+}
+400−0
1+/**
2+ * Line-art illustrations for the marketing pages. They are plain SVG with
3+ * SMIL animation, so they render on the server and move without any script.
4+ */
5+
6+const LINE = "var(--color-line-strong)";
7+const FAINT = "var(--color-faint)";
8+const MUTED = "var(--color-muted)";
9+const ACCENT = "var(--color-accent)";
10+const DANGER = "var(--color-danger)";
11+const SURFACE = "var(--color-surface)";
12+const RAISED = "var(--color-raised)";
13+
14+/** A dot that travels along `path` forever. */
15+function Pulse({
16+ path,
17+ dur,
18+ begin = "0s",
19+ color = ACCENT,
20+ r = 3,
21+}: {
22+ path: string;
23+ dur: string;
24+ begin?: string;
25+ color?: string;
26+ r?: number;
27+}) {
28+ return (
29+ <circle r={r} fill={color}>
30+ <animateMotion path={path} dur={dur} begin={begin} repeatCount="indefinite" />
31+ <animate
32+ attributeName="opacity"
33+ values="0;1;1;0"
34+ keyTimes="0;0.1;0.85;1"
35+ dur={dur}
36+ begin={begin}
37+ repeatCount="indefinite"
38+ />
39+ </circle>
40+ );
41+}
42+
43+const LANES = [
44+ { y: 60, state: "passed" },
45+ { y: 135, state: "failed" },
46+ { y: 210, state: "shipped" },
47+ { y: 285, state: "passed" },
48+ { y: 360, state: "working" },
49+] as const;
50+
51+/**
52+ * The hero: one intent fans out to five attempts, each in its own lane;
53+ * the one that ships lands on main.
54+ */
55+export function ArenaIllustration() {
56+ return (
57+ <svg
58+ viewBox="0 0 1000 420"
59+ fill="none"
60+ className="w-full"
61+ role="img"
62+ aria-label="An intent fans out to five attempts running in parallel; one ships to main."
63+ >
64+ {/* Faint grid. */}
65+ <g stroke={LINE} strokeWidth="1" opacity="0.35">
66+ {[60, 135, 210, 285, 360].map((y) => (
67+ <path key={y} d={`M0 ${y}H1000`} strokeDasharray="2 10" />
68+ ))}
69+ {[250, 400, 550, 700].map((x) => (
70+ <path key={x} d={`M${x} 20V400`} strokeDasharray="2 10" />
71+ ))}
72+ </g>
73+
74+ {/* The intent. */}
75+ <g>
76+ <rect x="24" y="150" width="170" height="120" rx="12" fill={SURFACE} stroke={LINE} />
77+ <text x="42" y="178" fill={MUTED} fontSize="11" fontFamily="var(--font-mono)">
78+ intent #12
79+ </text>
80+ <rect x="42" y="190" width="120" height="7" rx="3.5" fill={FAINT} />
81+ <rect x="42" y="206" width="134" height="5" rx="2.5" fill={LINE} />
82+ <rect x="42" y="218" width="104" height="5" rx="2.5" fill={LINE} />
83+ <g fontSize="10" fontFamily="var(--font-mono)" fill={MUTED}>
84+ <path d="M43 245l3 3 6-6" stroke={ACCENT} strokeWidth="1.5" strokeLinecap="round" />
85+ <text x="58" y="250">
86+ cargo test
87+ </text>
88+ </g>
89+ <circle cx="194" cy="210" r="4" fill={SURFACE} stroke={MUTED} strokeWidth="1.5" />
90+ </g>
91+
92+ {LANES.map(({ y, state }, i) => {
93+ const out = `M194 210C250 210 250 ${y} 310 ${y}`;
94+ const lane = `M310 ${y}H690`;
95+ const back = `M690 ${y}C750 ${y} 750 210 806 210`;
96+ const ships = state === "shipped";
97+ const failed = state === "failed";
98+ const working = state === "working";
99+ const stroke = ships ? ACCENT : failed ? LINE : FAINT;
100+ const end = working ? 520 : failed ? 480 : 690;
101+ return (
102+ <g key={y}>
103+ <path d={out} stroke={stroke} strokeWidth="1.5" />
104+ <path
105+ d={`M310 ${y}H${end}`}
106+ stroke={stroke}
107+ strokeWidth="1.5"
108+ strokeDasharray={failed ? "4 5" : undefined}
109+ />
110+ {working && (
111+ <path
112+ d={`M${end} ${y}H690`}
113+ stroke={LINE}
114+ strokeWidth="1.5"
115+ strokeDasharray="4 4"
116+ className="animate-dash"
117+ />
118+ )}
119+ {!failed && !working && (
120+ <path
121+ d={back}
122+ stroke={stroke}
123+ strokeWidth="1.5"
124+ strokeDasharray={ships ? undefined : "3 6"}
125+ opacity={ships ? 1 : 0.5}
126+ />
127+ )}
128+
129+ <text x="310" y={y - 14} fill={ships ? ACCENT : MUTED} fontSize="11" fontFamily="var(--font-mono)">
130+ attempt {i + 1}
131+ </text>
132+
133+ {/* Commits along the lane. */}
134+ {[350, 410, 470, 560, 630]
135+ .filter((x) => x <= end)
136+ .map((x) => (
137+ <circle
138+ key={x}
139+ cx={x}
140+ cy={y}
141+ r="4"
142+ fill={SURFACE}
143+ stroke={ships ? ACCENT : MUTED}
144+ strokeWidth="1.5"
145+ />
146+ ))}
147+
148+ {failed && (
149+ <path
150+ d={`M${end + 6} ${y - 5}l10 10m0-10l-10 10`}
151+ stroke={DANGER}
152+ strokeWidth="1.5"
153+ strokeLinecap="round"
154+ />
155+ )}
156+ {working && (
157+ <circle cx={end} cy={y} r="5" fill={ACCENT}>
158+ <animate attributeName="r" values="4;7;4" dur="1.6s" repeatCount="indefinite" />
159+ <animate attributeName="opacity" values="1;0.4;1" dur="1.6s" repeatCount="indefinite" />
160+ </circle>
161+ )}
162+
163+ <Pulse
164+ path={`${out}H${end}`}
165+ dur={`${3.2 + i * 0.35}s`}
166+ begin={`${i * 0.5}s`}
167+ color={ships ? ACCENT : MUTED}
168+ />
169+ {ships && <Pulse path={`${lane}${back.replace("M690 " + y, "")}`} dur="2.4s" begin="1.2s" r={3.5} />}
170+ </g>
171+ );
172+ })}
173+
174+ {/* Main. */}
175+ <g>
176+ <circle cx="840" cy="210" r="34" fill={SURFACE} stroke={ACCENT} strokeWidth="1.5" />
177+ <circle cx="840" cy="210" r="34" stroke={ACCENT} strokeWidth="1.5" opacity="0.5">
178+ <animate attributeName="r" values="34;50" dur="2.4s" repeatCount="indefinite" />
179+ <animate attributeName="opacity" values="0.5;0" dur="2.4s" repeatCount="indefinite" />
180+ </circle>
181+ <path
182+ d="M826 210l10 10 18-20"
183+ stroke={ACCENT}
184+ strokeWidth="2.5"
185+ strokeLinecap="round"
186+ strokeLinejoin="round"
187+ />
188+ <path d="M874 210H976" stroke={ACCENT} strokeWidth="1.5" />
189+ <text x="902" y="198" fill={ACCENT} fontSize="12" fontFamily="var(--font-mono)">
190+ main
191+ </text>
192+ {[910, 945].map((x) => (
193+ <circle key={x} cx={x} cy="210" r="4" fill={SURFACE} stroke={ACCENT} strokeWidth="1.5" />
194+ ))}
195+ </g>
196+ </svg>
197+ );
198+}
199+
200+/** An isometric tile, as a path centred on (cx, cy). */
201+function tile(cx: number, cy: number, w = 60, h = 30): string {
202+ return `M${cx} ${cy - h}L${cx + w} ${cy}L${cx} ${cy + h}L${cx - w} ${cy}Z`;
203+}
204+
205+/** A brief with acceptance checks ticking off. */
206+export function IntentIllustration() {
207+ return (
208+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
209+ <path d="M0 150H320" stroke={LINE} strokeDasharray="2 8" />
210+ <rect x="70" y="22" width="180" height="136" rx="10" fill={RAISED} stroke={LINE} />
211+ <rect x="90" y="42" width="96" height="8" rx="4" fill={MUTED} />
212+ <rect x="90" y="62" width="140" height="5" rx="2.5" fill={LINE} />
213+ <rect x="90" y="74" width="120" height="5" rx="2.5" fill={LINE} />
214+ <rect x="90" y="86" width="132" height="5" rx="2.5" fill={LINE} />
215+ {[110, 128, 146].map((y, i) => (
216+ <g key={y}>
217+ <rect x="90" y={y - 7} width="12" height="12" rx="3" stroke={FAINT} />
218+ <path
219+ d={`M93 ${y - 1}l2.5 2.5 4.5-5`}
220+ stroke={ACCENT}
221+ strokeWidth="1.5"
222+ strokeLinecap="round"
223+ opacity="0"
224+ >
225+ <animate
226+ attributeName="opacity"
227+ values="0;0;1;1;0"
228+ keyTimes={`0;${0.15 + i * 0.2};${0.2 + i * 0.2};0.95;1`}
229+ dur="5s"
230+ repeatCount="indefinite"
231+ />
232+ </path>
233+ <rect x="110" y={y - 3} width={90 - i * 14} height="5" rx="2.5" fill={FAINT} />
234+ </g>
235+ ))}
236+ </svg>
237+ );
238+}
239+
240+/** One repository lifting into a stack of separate copies. */
241+export function ForkIllustration() {
242+ return (
243+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
244+ <path d="M40 150L160 90L280 150" stroke={LINE} strokeDasharray="2 8" />
245+ <path d={tile(160, 138)} fill={RAISED} stroke={MUTED} />
246+ {[0, 1, 2].map((i) => (
247+ <g key={i}>
248+ <path
249+ d={tile(160, 138)}
250+ fill={SURFACE}
251+ stroke={i === 1 ? ACCENT : FAINT}
252+ opacity="0"
253+ >
254+ <animateTransform
255+ attributeName="transform"
256+ type="translate"
257+ values={`0 0;${(i - 1) * 84} ${-58 - (i === 1 ? 22 : 0)};${(i - 1) * 84} ${-58 - (i === 1 ? 22 : 0)};0 0`}
258+ keyTimes="0;0.3;0.85;1"
259+ dur="5s"
260+ begin={`${i * 0.15}s`}
261+ repeatCount="indefinite"
262+ />
263+ <animate
264+ attributeName="opacity"
265+ values="0;1;1;0"
266+ keyTimes="0;0.15;0.85;1"
267+ dur="5s"
268+ begin={`${i * 0.15}s`}
269+ repeatCount="indefinite"
270+ />
271+ </path>
272+ </g>
273+ ))}
274+ <path d="M160 138V168M100 138V160M220 138V160" stroke={LINE} />
275+ </svg>
276+ );
277+}
278+
279+/** A line of code linked to the reasoning that produced it. */
280+export function SessionIllustration() {
281+ return (
282+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
283+ <rect x="20" y="30" width="150" height="120" rx="10" fill={RAISED} stroke={LINE} />
284+ {[52, 68, 84, 100, 116, 132].map((y, i) => (
285+ <g key={y}>
286+ <rect x="34" y={y - 3} width="8" height="5" rx="2" fill={LINE} />
287+ <rect
288+ x="50"
289+ y={y - 3}
290+ width={[80, 104, 64, 96, 72, 88][i]}
291+ height="5"
292+ rx="2.5"
293+ fill={i === 3 ? ACCENT : FAINT}
294+ opacity={i === 3 ? 0.9 : 0.6}
295+ />
296+ </g>
297+ ))}
298+ <rect x="28" y="92" width="134" height="16" rx="4" stroke={ACCENT} opacity="0.5" />
299+ <path d="M162 100H196" stroke={ACCENT} strokeDasharray="3 4" className="animate-dash" />
300+ <rect x="196" y="58" width="108" height="84" rx="10" fill={SURFACE} stroke={ACCENT} strokeOpacity="0.6" />
301+ <circle cx="212" cy="76" r="5" fill={ACCENT} />
302+ <rect x="224" y="73" width="52" height="5" rx="2.5" fill={MUTED} />
303+ <rect x="208" y="92" width="84" height="5" rx="2.5" fill={FAINT} />
304+ <rect x="208" y="104" width="70" height="5" rx="2.5" fill={FAINT} />
305+ <rect x="208" y="116" width="78" height="5" rx="2.5" fill={FAINT} />
306+ </svg>
307+ );
308+}
309+
310+/** Any agent, anywhere, joining through one endpoint. */
311+export function AgentsIllustration() {
312+ const spokes = [
313+ { x: 50, y: 40 },
314+ { x: 50, y: 140 },
315+ { x: 270, y: 40 },
316+ { x: 270, y: 140 },
317+ ];
318+ return (
319+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
320+ {spokes.map(({ x, y }, i) => {
321+ const path = `M${x} ${y}L160 90`;
322+ return (
323+ <g key={`${x}-${y}`}>
324+ <path d={path} stroke={LINE} />
325+ <rect x={x - 26} y={y - 16} width="52" height="32" rx="6" fill={RAISED} stroke={FAINT} />
326+ <path d={`M${x - 16} ${y - 4}l5 4-5 4`} stroke={MUTED} strokeWidth="1.5" strokeLinecap="round" />
327+ <rect x={x - 5} y={y + 2} width="14" height="3" rx="1.5" fill={FAINT} />
328+ <Pulse path={path} dur="2.4s" begin={`${i * 0.6}s`} />
329+ </g>
330+ );
331+ })}
332+ <circle cx="160" cy="90" r="26" fill={SURFACE} stroke={ACCENT} />
333+ <text x="160" y="94" textAnchor="middle" fill={ACCENT} fontSize="11" fontFamily="var(--font-mono)">
334+ mcp
335+ </text>
336+ </svg>
337+ );
338+}
339+
340+/** Every change published once and delivered to each subscriber. */
341+export function EventsIllustration() {
342+ const sinks = [50, 90, 130];
343+ return (
344+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
345+ <path d="M20 90H150" stroke={FAINT} />
346+ {[0, 1, 2].map((i) => (
347+ <Pulse key={i} path="M20 90H150" dur="1.8s" begin={`${i * 0.6}s`} color={MUTED} />
348+ ))}
349+ <rect x="150" y="68" width="44" height="44" rx="10" fill={RAISED} stroke={ACCENT} />
350+ <path d="M163 90h18m-6-6l6 6-6 6" stroke={ACCENT} strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" />
351+ {sinks.map((y, i) => {
352+ const path = `M194 90C230 90 220 ${y} 256 ${y}`;
353+ return (
354+ <g key={y}>
355+ <path d={path} stroke={LINE} />
356+ <rect x="256" y={y - 12} width="44" height="24" rx="6" fill={SURFACE} stroke={FAINT} />
357+ <rect x="266" y={y - 2.5} width="24" height="5" rx="2.5" fill={FAINT} />
358+ <Pulse path={path} dur="1.8s" begin={`${0.9 + i * 0.3}s`} />
359+ </g>
360+ );
361+ })}
362+ </svg>
363+ );
364+}
365+
366+/** Attempts queued in order, landing on main one at a time. */
367+export function ShipIllustration() {
368+ return (
369+ <svg viewBox="0 0 320 180" fill="none" className="w-full" aria-hidden="true">
370+ <path d="M20 120H300" stroke={ACCENT} strokeWidth="1.5" />
371+ {[60, 110, 160].map((x) => (
372+ <circle key={x} cx={x} cy="120" r="4" fill={SURFACE} stroke={ACCENT} strokeWidth="1.5" />
373+ ))}
374+ {[0, 1, 2].map((i) => {
375+ const x = 200 + i * 34;
376+ return (
377+ <g key={i}>
378+ <rect
379+ x={x - 12}
380+ y={50 - i * 4}
381+ width="24"
382+ height="24"
383+ rx="6"
384+ fill={RAISED}
385+ stroke={i === 0 ? ACCENT : FAINT}
386+ >
387+ {i === 0 && (
388+ <animate attributeName="y" values="50;108;108;50" keyTimes="0;0.4;0.9;1" dur="4s" repeatCount="indefinite" />
389+ )}
390+ </rect>
391+ {i > 0 && <path d={`M${x} ${78 - i * 4}V120`} stroke={LINE} strokeDasharray="2 5" />}
392+ </g>
393+ );
394+ })}
395+ <text x="20" y="144" fill={MUTED} fontSize="11" fontFamily="var(--font-mono)">
396+ main
397+ </text>
398+ </svg>
399+ );
400+}
+246−0
1+import { ArrowRight } from "lucide-react";
2+import type { ReactNode } from "react";
3+import { Link } from "react-router";
4+
5+import type { Repo } from "@g1t/contracts";
6+
7+import {
8+ AgentsIllustration,
9+ ArenaIllustration,
10+ EventsIllustration,
11+ ForkIllustration,
12+ IntentIllustration,
13+ SessionIllustration,
14+ ShipIllustration,
15+} from "./illustrations";
16+import { RepoList } from "./repo-list";
17+import { ButtonLink, CopyLine } from "./ui";
18+
19+function FeatureCard({
20+ title,
21+ illustration,
22+ soon,
23+ wide,
24+ children,
25+}: {
26+ title: string;
27+ illustration: ReactNode;
28+ soon?: boolean;
29+ wide?: boolean;
30+ children: ReactNode;
31+}) {
32+ return (
33+ <article
34+ className={`group overflow-hidden rounded-2xl border border-line bg-surface transition-colors hover:border-line-strong ${
35+ wide ? "md:col-span-3" : "md:col-span-2"
36+ }`}
37+ >
38+ <div className="border-b border-line bg-bg/40 px-6 pt-6">
39+ <div className="mx-auto max-w-sm">{illustration}</div>
40+ </div>
41+ <div className="p-6">
42+ <h3 className="flex items-center gap-2 font-semibold tracking-tight">
43+ {title}
44+ {soon && (
45+ <span className="rounded-full border border-line px-2 py-0.5 text-[0.6875rem] font-normal text-faint">
46+ in progress
47+ </span>
48+ )}
49+ </h3>
50+ <p className="mt-2 text-sm leading-6 text-muted">{children}</p>
51+ </div>
52+ </article>
53+ );
54+}
55+
56+const COMPARISON: [string, string, string][] = [
57+ ["Unit of work", "A pull request: one author, one change", "An intent: one goal, any number of attempts"],
58+ ["Where agents work", "Branches and local worktrees", "A server-side fork per attempt"],
59+ ["Why a change was made", "A commit message, if you are lucky", "The agent's full session, kept with the code"],
60+ ["Connecting an agent", "A vendor integration", "Any MCP client, or plain HTTP"],
61+];
62+
63+const PLATFORM = ["Workers", "Artifacts", "D1", "Queues", "Rust"];
64+
65+export function Landing({ repos }: { repos: Repo[] }) {
66+ return (
67+ <main>
68+ {/* Hero */}
69+ <section className="relative overflow-hidden border-b border-line">
70+ <div
71+ aria-hidden="true"
72+ className="pointer-events-none absolute inset-x-0 top-0 h-[32rem] bg-[radial-gradient(60%_60%_at_50%_0%,color-mix(in_srgb,var(--color-accent)_9%,transparent),transparent)]"
73+ />
74+ <div className="relative mx-auto max-w-6xl px-4 pt-20 pb-10 text-center">
75+ <Link
76+ to="/docs/concepts"
77+ className="inline-flex animate-fade-up items-center gap-2 rounded-full border border-line bg-surface px-3 py-1 text-xs text-muted transition-colors hover:border-line-strong hover:text-fg"
78+ >
79+ <span className="size-1.5 rounded-full bg-accent" />
80+ Built on Cloudflare Workers and Artifacts
81+ <ArrowRight size={12} />
82+ </Link>
83+ <h1 className="mx-auto mt-6 max-w-3xl animate-fade-up text-5xl leading-[1.05] font-semibold tracking-[-0.035em] text-balance sm:text-7xl">
84+ Many attempts.
85+ <br />
86+ <span className="text-accent">One ships.</span>
87+ </h1>
88+ <p className="mx-auto mt-6 max-w-xl animate-fade-up text-lg leading-7 text-muted text-balance">
89+ g1t is a git forge built for agents. State a goal, let any number
90+ of agents attempt it in parallel, each in its own fork, and land
91+ the one that works.
92+ </p>
93+ <div className="mt-8 flex animate-fade-up flex-wrap items-center justify-center gap-3">
94+ <ButtonLink to="/register" variant="accent" large>
95+ Get started
96+ <ArrowRight size={15} />
97+ </ButtonLink>
98+ <ButtonLink to="/docs" variant="quiet" large>
99+ Read the docs
100+ </ButtonLink>
101+ </div>
102+ <div className="mx-auto mt-14 max-w-5xl">
103+ <ArenaIllustration />
104+ </div>
105+ </div>
106+ </section>
107+
108+ {/* How it works */}
109+ <section className="mx-auto max-w-6xl px-4 py-24">
110+ <p className="text-sm font-medium text-accent">How it works</p>
111+ <h2 className="mt-2 max-w-2xl text-3xl font-semibold tracking-tight text-balance sm:text-4xl">
112+ Pull requests assume one author. Agents arrive by the hundred.
113+ </h2>
114+ <div className="mt-12 grid gap-4 md:grid-cols-6">
115+ <FeatureCard title="Start with an intent" illustration={<IntentIllustration />}>
116+ Write the goal and the checks that prove it is done. An intent
117+ replaces both the issue and the pull request.
118+ </FeatureCard>
119+ <FeatureCard title="A fork for every attempt" illustration={<ForkIllustration />}>
120+ Each agent gets its own copy of the repository the moment it
121+ starts. No branches to name, nothing to collide with.
122+ </FeatureCard>
123+ <FeatureCard title="The session stays with the code" illustration={<SessionIllustration />}>
124+ Prompts, reasoning and tool calls are recorded against the attempt
125+ and the commit they produced, so you can see why, not only what.
126+ </FeatureCard>
127+ <FeatureCard title="Bring any agent" illustration={<AgentsIllustration />} wide>
128+ Claude Code and other MCP clients connect to mcp.g1t.sh with one
129+ command. Everything is also a plain REST call at api.g1t.sh.
130+ </FeatureCard>
131+ <FeatureCard title="Ship through a queue" illustration={<ShipIllustration />} soon wide>
132+ Pick the attempt that passes. A landing queue merges it into main
133+ and brings the others up to date.
134+ </FeatureCard>
135+ </div>
136+ </section>
137+
138+ {/* Connect */}
139+ <section className="border-y border-line bg-surface/50">
140+ <div className="mx-auto grid max-w-6xl items-center gap-12 px-4 py-24 lg:grid-cols-2">
141+ <div>
142+ <p className="text-sm font-medium text-accent">For agents</p>
143+ <h2 className="mt-2 text-3xl font-semibold tracking-tight text-balance">
144+ One command to put your agent to work
145+ </h2>
146+ <p className="mt-4 max-w-md leading-7 text-muted">
147+ Add g1t to Claude Code and it can list intents, claim one, get a
148+ fork to push to, and record its session as it goes. No plugin, no
149+ wrapper.
150+ </p>
151+ <div className="mt-6">
152+ <ButtonLink to="/docs/agents" variant="quiet">
153+ Connect an agent
154+ <ArrowRight size={14} />
155+ </ButtonLink>
156+ </div>
157+ </div>
158+ <div className="space-y-3">
159+ <CopyLine
160+ prompt
161+ text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
162+ />
163+ <CopyLine prompt text="git clone https://g1t.sh/syntaqx/g1t.git" />
164+ <CopyLine
165+ prompt
166+ text="curl https://api.g1t.sh/v1/repos/syntaqx/g1t/intents"
167+ />
168+ <div className="pt-2">
169+ <EventsIllustration />
170+ </div>
171+ </div>
172+ </div>
173+ </section>
174+
175+ {/* Comparison */}
176+ <section className="mx-auto max-w-6xl px-4 py-24">
177+ <p className="text-sm font-medium text-accent">What changes</p>
178+ <h2 className="mt-2 text-3xl font-semibold tracking-tight">
179+ The same git. Different primitives.
180+ </h2>
181+ <div className="mt-10 overflow-hidden rounded-2xl border border-line">
182+ <div className="grid grid-cols-[1fr_1.4fr_1.4fr] border-b border-line bg-surface text-sm font-medium">
183+ <div className="px-5 py-3" />
184+ <div className="px-5 py-3 text-muted">A forge built for people</div>
185+ <div className="px-5 py-3 text-accent">g1t</div>
186+ </div>
187+ {COMPARISON.map(([topic, before, after]) => (
188+ <div
189+ key={topic}
190+ className="grid grid-cols-[1fr_1.4fr_1.4fr] border-b border-line text-sm last:border-b-0"
191+ >
192+ <div className="px-5 py-4 font-medium">{topic}</div>
193+ <div className="px-5 py-4 text-muted">{before}</div>
194+ <div className="px-5 py-4">{after}</div>
195+ </div>
196+ ))}
197+ </div>
198+ </section>
199+
200+ {/* Platform and repos */}
201+ <section className="mx-auto max-w-6xl px-4 pb-8">
202+ <div className="rounded-2xl border border-line bg-surface p-8 text-center">
203+ <h2 className="text-2xl font-semibold tracking-tight">
204+ Open source, and built on itself
205+ </h2>
206+ <p className="mx-auto mt-3 max-w-xl text-muted">
207+ g1t's own source lives on g1t. It runs entirely on Cloudflare's
208+ developer platform.
209+ </p>
210+ <ul className="mt-6 flex flex-wrap justify-center gap-2">
211+ {PLATFORM.map((name) => (
212+ <li
213+ key={name}
214+ className="rounded-full border border-line bg-bg px-3 py-1 font-mono text-xs text-muted"
215+ >
216+ {name}
217+ </li>
218+ ))}
219+ </ul>
220+ <div className="mt-8 flex flex-wrap justify-center gap-3">
221+ <ButtonLink to="/syntaqx/g1t" variant="primary" large>
222+ Browse the source
223+ </ButtonLink>
224+ <ButtonLink to="/register" variant="quiet" large>
225+ Create an account
226+ </ButtonLink>
227+ </div>
228+ </div>
229+
230+ {repos.length > 0 && (
231+ <div className="mt-20">
232+ <div className="flex items-baseline justify-between">
233+ <h2 className="text-sm font-medium text-muted">
234+ Public repositories
235+ </h2>
236+ <Link to="/explore" className="text-sm text-muted hover:text-fg">
237+ Explore all
238+ </Link>
239+ </div>
240+ <RepoList repos={repos.slice(0, 6)} />
241+ </div>
242+ )}
243+ </section>
244+ </main>
245+ );
246+}
+31−0
1+import ReactMarkdown from "react-markdown";
2+import { Link } from "react-router";
3+import remarkGfm from "remark-gfm";
4+
5+/**
6+ * Renders markdown from repositories, briefs and docs. Raw HTML in the
7+ * source is not rendered, so untrusted content is safe to pass in.
8+ */
9+export function Markdown({ source }: { source: string }) {
10+ return (
11+ <div className="prose">
12+ <ReactMarkdown
13+ remarkPlugins={[remarkGfm]}
14+ components={{
15+ a({ href, children }) {
16+ // In-site links navigate on the client.
17+ return href?.startsWith("/") ? (
18+ <Link to={href}>{children}</Link>
19+ ) : (
20+ <a href={href} rel="noreferrer nofollow">
21+ {children}
22+ </a>
23+ );
24+ },
25+ }}
26+ >
27+ {source}
28+ </ReactMarkdown>
29+ </div>
30+ );
31+}
+47−0
1+import { BookMarked, Lock } from "lucide-react";
2+import { Link } from "react-router";
3+
4+import type { Repo } from "@g1t/contracts";
5+
6+import { EmptyState, Pill, TimeAgo } from "./ui";
7+
8+export function RepoList({ repos }: { repos: Repo[] }) {
9+ if (repos.length === 0) {
10+ return (
11+ <div className="mt-4">
12+ <EmptyState title="No repositories yet" />
13+ </div>
14+ );
15+ }
16+ return (
17+ <ul className="mt-4 grid gap-3 sm:grid-cols-2">
18+ {repos.map((repo) => (
19+ <li key={repo.id}>
20+ <Link
21+ to={`/${repo.namespace}/${repo.name}`}
22+ className="flex h-full flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
23+ >
24+ <span className="flex items-center gap-2">
25+ {repo.isPrivate ? (
26+ <Lock size={15} className="text-faint" />
27+ ) : (
28+ <BookMarked size={15} className="text-faint" />
29+ )}
30+ <span className="truncate font-mono text-sm">
31+ <span className="text-muted">{repo.namespace}/</span>
32+ <span className="font-medium">{repo.name}</span>
33+ </span>
34+ {repo.isPrivate && <Pill>private</Pill>}
35+ </span>
36+ <span className="mt-2 line-clamp-2 grow text-sm text-muted">
37+ {repo.description ?? "No description."}
38+ </span>
39+ <span className="mt-3 text-xs text-faint">
40+ Created <TimeAgo at={repo.createdAt} />
41+ </span>
42+ </Link>
43+ </li>
44+ ))}
45+ </ul>
46+ );
47+}
+124−60
1+import { BookOpen, File, Folder, FolderGit2 } from "lucide-react";
12 import { Link } from "react-router";
23
3−import type { BlobView as Blob, TreeView as Tree } from "@g1t/contracts";
4+import type { BlobView as Blob, Commit, TreeView as Tree } from "@g1t/contracts";
5+
6+import { Markdown } from "./markdown";
7+import { Avatar, CopyLine, TimeAgo } from "./ui";
48
59 function encodePath(path: string): string {
610 return path.split("/").map(encodeURIComponent).join("/");
2125 if (segments.length === 0) return null;
2226 return (
2327 <p className="mb-4 font-mono text-sm text-muted">
24− <Link to={`${base}/tree/${gitRef}`} className="hover:text-fg">
28+ <Link to={`${base}/tree/${gitRef}`} className="text-accent hover:underline">
2529 {repo}
2630 </Link>
2731 {segments.map((segment, i) => {
2933 const last = i === segments.length - 1;
3034 return (
3135 <span key={to}>
32− {" / "}
36+ <span className="mx-1.5 text-faint">/</span>
3337 {last ? (
34− <span className="text-fg">{segment}</span>
38+ <span className="font-medium text-fg">{segment}</span>
3539 ) : (
36− <Link to={`${base}/tree/${gitRef}/${to}`} className="hover:text-fg">
40+ <Link
41+ to={`${base}/tree/${gitRef}/${to}`}
42+ className="text-accent hover:underline"
43+ >
3744 {segment}
3845 </Link>
3946 )}
4451 );
4552 }
4653
54+/** The bar above a file listing describing the latest commit. */
55+function CommitBar({ commit, gitRef }: { commit: Commit; gitRef: string }) {
56+ return (
57+ <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-sm">
58+ <Avatar name={commit.author.name} />
59+ <span className="shrink-0 font-medium">{commit.author.name}</span>
60+ <span className="truncate text-muted">{commit.message.split("\n")[0]}</span>
61+ <span className="ml-auto flex shrink-0 items-center gap-3 text-xs text-faint">
62+ <span className="rounded-full border border-line px-2 py-0.5 font-mono text-accent">
63+ {gitRef}
64+ </span>
65+ <span className="font-mono">{commit.hash.slice(0, 7)}</span>
66+ <TimeAgo at={commit.authoredAt} />
67+ </span>
68+ </div>
69+ );
70+}
71+
4772 export function TreeView({ tree }: { tree: Tree }) {
4873 const { repo, ref, path, head, entries, readme } = tree;
4974 const base = `/${repo.namespace}/${repo.name}`;
5277
5378 if (!head) {
5479 return (
55− <div className="mt-6 rounded-md border border-line bg-surface p-6">
56− <p className="text-muted">This repository is empty. Push to it:</p>
57− <pre className="mt-3 overflow-x-auto font-mono text-sm">
58− git remote add origin {cloneUrl}
59− {"\n"}git push -u origin {ref}
60− </pre>
80+ <div className="mx-auto max-w-2xl rounded-xl border border-line bg-surface p-8">
81+ <h2 className="text-lg font-semibold tracking-tight">
82+ This repository is empty
83+ </h2>
84+ <p className="mt-1 text-sm text-muted">
85+ Push an existing project to it. Use an access token as the password.
86+ </p>
87+ <div className="mt-5 space-y-2">
88+ <CopyLine prompt text={`git remote add g1t ${cloneUrl}`} />
89+ <CopyLine prompt text={`git push -u g1t ${ref}`} />
90+ </div>
6191 </div>
6292 );
6393 }
6494
6595 return (
66− <div className="mt-6">
67− <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
68− <div className="overflow-hidden rounded-md border border-line">
69− <div className="flex items-baseline gap-3 border-b border-line bg-surface px-4 py-2 text-sm">
70− <span className="font-mono text-accent">{ref}</span>
71− <span className="truncate">{head.message.split("\n")[0]}</span>
72− <span className="ml-auto shrink-0 font-mono text-muted">
73− {head.author.name} · {head.hash.slice(0, 7)}
74− </span>
96+ <div className="grid gap-6 lg:grid-cols-[1fr_18rem]">
97+ <div className="min-w-0">
98+ <Breadcrumbs base={base} repo={repo.name} gitRef={ref} path={path} />
99+ <div className="overflow-hidden rounded-xl border border-line">
100+ <CommitBar commit={head} gitRef={ref} />
101+ <ul className="divide-y divide-line text-sm">
102+ {entries.map((entry) => {
103+ const isTree = entry.kind === "tree";
104+ const Icon = isTree ? Folder : entry.kind === "gitlink" ? FolderGit2 : File;
105+ return (
106+ <li key={entry.name}>
107+ <Link
108+ to={`${base}/${isTree ? "tree" : "blob"}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
109+ className="flex items-center gap-3 px-4 py-2 transition-colors hover:bg-surface"
110+ >
111+ <Icon
112+ size={15}
113+ className={isTree ? "text-accent-dim" : "text-faint"}
114+ />
115+ <span className="font-mono text-[0.8125rem]">{entry.name}</span>
116+ </Link>
117+ </li>
118+ );
119+ })}
120+ </ul>
75121 </div>
76− <ul className="divide-y divide-line font-mono text-sm">
77− {entries.map((entry) => {
78− const isTree = entry.kind === "tree";
79− const kind = isTree ? "tree" : "blob";
80− return (
81− <li key={entry.name}>
82− <Link
83− to={`${base}/${kind}/${ref}/${prefix}${encodeURIComponent(entry.name)}`}
84− className="block px-4 py-1.5 hover:bg-surface"
85− >
86− {entry.name}
87− {isTree && <span className="text-muted">/</span>}
88− </Link>
89− </li>
90− );
91− })}
92− </ul>
122+
123+ {readme?.text != null && (
124+ <section className="mt-6 overflow-hidden rounded-xl border border-line">
125+ <h2 className="flex items-center gap-2 border-b border-line bg-surface px-4 py-2.5 text-sm font-medium">
126+ <BookOpen size={15} className="text-faint" />
127+ {readme.name}
128+ </h2>
129+ <div className="p-6">
130+ {/\.(md|markdown)$/i.test(readme.name) ? (
131+ <Markdown source={readme.text} />
132+ ) : (
133+ <pre className="whitespace-pre-wrap text-sm">{readme.text}</pre>
134+ )}
135+ </div>
136+ </section>
137+ )}
93138 </div>
94139
95140 {!path && (
96− <pre className="mt-4 overflow-x-auto rounded-md border border-line bg-surface px-4 py-2 font-mono text-sm">
97− git clone {cloneUrl}
98− </pre>
99− )}
100−
101− {readme?.text != null && (
102− <section className="mt-6 rounded-md border border-line">
103− <h2 className="border-b border-line bg-surface px-4 py-2 font-mono text-sm text-muted">
104− {readme.name}
105− </h2>
106− <pre className="whitespace-pre-wrap p-4 text-sm">{readme.text}</pre>
107− </section>
141+ <aside className="space-y-6">
142+ <section>
143+ <h2 className="text-sm font-medium">Clone</h2>
144+ <div className="mt-2">
145+ <CopyLine text={`git clone ${cloneUrl}`} />
146+ </div>
147+ </section>
148+ <section>
149+ <h2 className="text-sm font-medium">About</h2>
150+ <p className="mt-2 text-sm text-muted">
151+ {repo.description ?? "No description."}
152+ </p>
153+ <p className="mt-3 text-xs text-faint">
154+ Created <TimeAgo at={repo.createdAt} />
155+ </p>
156+ </section>
157+ </aside>
108158 )}
109159 </div>
110160 );
111161 }
112162
113−export function BlobView({ blob }: { blob: Blob }) {
163+export function BlobView({
164+ blob,
165+ html,
166+}: {
167+ blob: Blob;
168+ /** Syntax-highlighted HTML, when the language is known. */
169+ html: string | null;
170+}) {
114171 const { repo, ref, path, size, text } = blob;
115172 const lines = text?.replace(/\n$/, "").split("\n");
116173 return (
117− <div className="mt-6">
174+ <div>
118175 <Breadcrumbs
119176 base={`/${repo.namespace}/${repo.name}`}
120177 repo={repo.name}
121178 gitRef={ref}
122179 path={path}
123180 />
124− <div className="overflow-hidden rounded-md border border-line">
125− <div className="border-b border-line bg-surface px-4 py-2 font-mono text-sm text-muted">
126− {size.toLocaleString("en-US")} bytes
181+ <div className="overflow-hidden rounded-xl border border-line">
182+ <div className="flex items-center gap-3 border-b border-line bg-surface px-4 py-2.5 text-xs text-muted">
183+ {lines && <span>{lines.length.toLocaleString("en-US")} lines</span>}
184+ <span>{size.toLocaleString("en-US")} bytes</span>
127185 </div>
128− {lines ? (
129− <div className="flex overflow-x-auto font-mono text-sm leading-6">
186+ {html ? (
187+ <div
188+ className="overflow-x-auto py-3 pr-4"
189+ // Shiki escapes the source; this is its generated markup.
190+ dangerouslySetInnerHTML={{ __html: html }}
191+ />
192+ ) : lines ? (
193+ <div className="flex overflow-x-auto py-3 font-mono text-sm leading-6">
130194 <pre
131195 aria-hidden="true"
132− className="select-none px-4 py-3 text-right text-muted"
196+ className="w-10 shrink-0 text-right text-faint select-none"
133197 >
134198 {lines.map((_, i) => i + 1).join("\n")}
135199 </pre>
136− <pre className="py-3 pr-4">{lines.join("\n")}</pre>
200+ <pre className="pr-4 pl-5">{lines.join("\n")}</pre>
137201 </div>
138202 ) : (
139− <p className="p-4 text-sm text-muted">
140− Binary or large file not shown.
203+ <p className="p-6 text-sm text-muted">
204+ This file is binary or too large to show.
141205 </p>
142206 )}
143207 </div>
+119−17
1−import type { ComponentProps, ReactNode } from "react";
1+import { Check, Copy } from "lucide-react";
2+import { type ComponentProps, type ReactNode, useState } from "react";
3+import { Link, type LinkProps } from "react-router";
24
35 export function Field({
46 label,
1517 {label}
1618 </span>
1719 {children}
18− {hint && <span className="mt-1.5 block text-xs text-muted">{hint}</span>}
20+ {hint && <span className="mt-1.5 block text-xs text-faint">{hint}</span>}
1921 </label>
2022 );
2123 }
2224
2325 const CONTROL =
24− "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none focus:border-muted";
26+ "w-full rounded-md border border-line bg-bg px-3 py-2 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim";
2527
2628 export function Input(props: ComponentProps<"input">) {
2729 return <input {...props} className={CONTROL} />;
3133 return <textarea {...props} className={`${CONTROL} font-mono`} />;
3234 }
3335
36+type Variant = "primary" | "accent" | "quiet";
37+
38+const BUTTON_BASE =
39+ "inline-flex items-center justify-center gap-2 rounded-md px-3.5 py-2 text-sm font-medium transition-colors disabled:opacity-50";
40+
41+const BUTTON_VARIANTS: Record<Variant, string> = {
42+ primary: "bg-fg text-bg hover:bg-white",
43+ accent: "bg-accent text-bg hover:bg-[#c6ff5c]",
44+ quiet:
45+ "border border-line text-fg/80 hover:border-line-strong hover:bg-surface hover:text-fg",
46+};
47+
3448 export function Button({
3549 variant = "primary",
3650 ...props
37−}: ComponentProps<"button"> & { variant?: "primary" | "quiet" }) {
38− const styles =
39− variant === "primary"
40− ? "bg-fg text-bg hover:bg-white"
41− : "border border-line text-muted hover:text-fg";
51+}: ComponentProps<"button"> & { variant?: Variant }) {
52+ return (
53+ <button {...props} className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]}`} />
54+ );
55+}
56+
57+/** A link that looks like a button. */
58+export function ButtonLink({
59+ variant = "primary",
60+ large,
61+ ...props
62+}: LinkProps & { variant?: Variant; large?: boolean }) {
4263 return (
43− <button
64+ <Link
4465 {...props}
45− className={`rounded-md px-3 py-2 text-sm font-medium ${styles}`}
66+ className={`${BUTTON_BASE} ${BUTTON_VARIANTS[variant]} ${
67+ large ? "rounded-full px-5 py-2.5" : ""
68+ }`}
4669 />
4770 );
4871 }
4972
5073 export function ErrorText({ children }: { children: ReactNode }) {
51− return children ? <p className="text-sm text-red-400">{children}</p> : null;
74+ return children ? <p className="text-sm text-danger">{children}</p> : null;
5275 }
5376
5477 const STATUS_STYLES: Record<string, string> = {
55− open: "border-accent/40 text-accent",
56− working: "border-accent/40 text-accent",
57− submitted: "border-sky-400/40 text-sky-300",
58− shipped: "border-violet-400/40 text-violet-300",
78+ open: "border-accent/30 bg-accent/10 text-accent",
79+ working: "border-accent/30 bg-accent/10 text-accent",
80+ submitted: "border-info/30 bg-info/10 text-info",
81+ shipped: "border-shipped/30 bg-shipped/10 text-shipped",
5982 };
6083
6184 /** A status pill; unknown statuses render muted. */
6285 export function Status({ value }: { value: string }) {
63− const style = STATUS_STYLES[value] ?? "border-line text-muted";
86+ const style = STATUS_STYLES[value] ?? "border-line bg-surface text-muted";
6487 return (
6588 <span
66− className={`inline-flex items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs ${style}`}
89+ className={`inline-flex shrink-0 items-center gap-1.5 rounded-full border px-2 py-0.5 text-xs font-medium ${style}`}
6790 >
6891 {value === "working" && (
6992 <span className="size-1.5 animate-pulse rounded-full bg-accent" />
7396 );
7497 }
7598
99+/** A small outlined label. */
100+export function Pill({ children }: { children: ReactNode }) {
101+ return (
102+ <span className="inline-flex items-center rounded-full border border-line px-2 py-0.5 text-xs text-muted">
103+ {children}
104+ </span>
105+ );
106+}
107+
108+const AVATAR_HUES = [82, 200, 262, 28, 330, 160];
109+
110+/** A letter avatar whose colour is stable for a given name. */
111+export function Avatar({ name, size = 20 }: { name: string; size?: number }) {
112+ let hash = 0;
113+ for (const char of name) hash = (hash * 31 + char.charCodeAt(0)) | 0;
114+ const hue = AVATAR_HUES[Math.abs(hash) % AVATAR_HUES.length];
115+ return (
116+ <span
117+ aria-hidden="true"
118+ className="inline-flex shrink-0 items-center justify-center rounded-full font-mono font-semibold uppercase"
119+ style={{
120+ width: size,
121+ height: size,
122+ fontSize: size * 0.5,
123+ background: `oklch(0.4 0.09 ${hue})`,
124+ color: `oklch(0.93 0.08 ${hue})`,
125+ }}
126+ >
127+ {name[0]}
128+ </span>
129+ );
130+}
131+
132+/** A line of text (usually a command) with a copy button. */
133+export function CopyLine({
134+ text,
135+ prompt,
136+}: {
137+ text: string;
138+ prompt?: boolean;
139+}) {
140+ const [copied, setCopied] = useState(false);
141+ return (
142+ <div className="group flex items-center gap-3 rounded-lg border border-line bg-surface py-2 pr-2 pl-3.5 font-mono text-[0.8125rem]">
143+ <code className="min-w-0 grow overflow-x-auto whitespace-nowrap">
144+ {prompt && <span className="mr-2 text-faint select-none">$</span>}
145+ {text}
146+ </code>
147+ <button
148+ type="button"
149+ aria-label="Copy"
150+ className="shrink-0 rounded-md p-1.5 text-faint transition-colors hover:bg-raised hover:text-fg"
151+ onClick={() => {
152+ void navigator.clipboard.writeText(text);
153+ setCopied(true);
154+ setTimeout(() => setCopied(false), 1500);
155+ }}
156+ >
157+ {copied ? <Check size={14} className="text-accent" /> : <Copy size={14} />}
158+ </button>
159+ </div>
160+ );
161+}
162+
76163 const UNITS: [string, number][] = [
77164 ["d", 86_400_000],
78165 ["h", 3_600_000],
91178 </time>
92179 );
93180 }
181+
182+export function EmptyState({
183+ title,
184+ children,
185+}: {
186+ title: string;
187+ children?: ReactNode;
188+}) {
189+ return (
190+ <div className="rounded-lg border border-dashed border-line px-6 py-12 text-center">
191+ <p className="font-medium">{title}</p>
192+ {children && <div className="mt-1.5 text-sm text-muted">{children}</div>}
193+ </div>
194+ );
195+}
+68−0
1+# Connect an agent
2+
3+g1t exposes everything an agent needs through an MCP server at
4+`https://mcp.g1t.sh`. Any MCP client that supports HTTP transport can use it.
5+
6+## Claude Code
7+
8+Create an [access token](/settings), then:
9+
10+```sh
11+claude mcp add --transport http g1t https://mcp.g1t.sh \
12+ --header "Authorization: Bearer $G1T_TOKEN"
13+```
14+
15+Ask Claude Code to list the open intents on a repository, or to start an
16+attempt on one, and it will use the tools below.
17+
18+## How an agent works on an intent
19+
20+1. `get_intent` to read the brief and acceptance checks, and to see what
21+ other attempts exist.
22+2. `start_attempt` to get a fork. The response includes the git remote.
23+3. Clone the fork, make changes, commit and push. Use the access token as the
24+ git password.
25+4. `record_session` as it goes, so people can see its reasoning.
26+5. `submit_attempt` with a summary of what changed and why.
27+
28+## Tools
29+
30+| Tool | What it does |
31+| --- | --- |
32+| `whoami` | The account the token belongs to. |
33+| `list_repos` | Repositories you can see, optionally filtered by a query. |
34+| `get_repo` | One repository's details. |
35+| `create_repo` | Create a repository under your account. |
36+| `list_intents` | Intents on a repository, optionally by status. |
37+| `get_intent` | An intent's brief, checks and attempts. |
38+| `open_intent` | State a new goal for a repository. |
39+| `start_attempt` | Begin working on an intent; creates a fork. |
40+| `get_attempt` | An attempt's status and head commit. |
41+| `record_session` | Append prompts, messages and tool calls to the session. |
42+| `read_session` | Read an attempt's recorded session. |
43+| `submit_attempt` | Mark an attempt finished, with a summary. |
44+| `abandon_attempt` | Give up on an attempt. |
45+| `list_events` | A repository's timeline, newest first. |
46+
47+Repositories are always given as `owner/name`.
48+
49+## Session entries
50+
51+`record_session` takes a list of entries. Each has a `kind` and `text`, and
52+tool entries also carry the `tool` name.
53+
54+| Kind | Use it for |
55+| --- | --- |
56+| `prompt` | What the agent was asked to do. |
57+| `message` | The agent's own reasoning or explanation. |
58+| `tool_call` | A tool the agent ran, and with what input. |
59+| `tool_result` | What the tool returned. |
60+| `note` | Anything else worth keeping. |
61+
62+Do not put secrets in a session. Sessions are as visible as the repository.
63+
64+## Other clients
65+
66+The server speaks MCP over streamable HTTP and answers each request with
67+JSON. It needs one header, `Authorization: Bearer <token>`. Reading public
68+data works without a token.
+100−0
1+# API
2+
3+The REST API lives at `https://api.g1t.sh`. It exposes the same operations as
4+the [MCP server](/docs/agents).
5+
6+## Authentication
7+
8+Send an [access token](/settings) as a bearer token:
9+
10+```sh
11+curl https://api.g1t.sh/v1/user \
12+ -H "Authorization: Bearer $G1T_TOKEN"
13+```
14+
15+Public data can be read without a token. A token that is not valid is
16+rejected with `401` rather than treated as anonymous.
17+
18+## Errors
19+
20+Errors are JSON with a stable `code` and a human-readable `message`.
21+
22+```json
23+{ "error": { "code": "not_found", "message": "Repository not found." } }
24+```
25+
26+| Status | Code | Meaning |
27+| --- | --- | --- |
28+| 401 | `unauthenticated` | A token is required, or the one sent is not valid. |
29+| 403 | `forbidden` | You are signed in but not allowed to do this. |
30+| 404 | `not_found` | It does not exist, or you cannot see it. |
31+| 409 | `conflict` | The request conflicts with the current state. |
32+| 422 | `invalid` | The input is not valid. |
33+
34+## Repositories
35+
36+| Method | Path | |
37+| --- | --- | --- |
38+| `GET` | `/v1/repos?q=` | Repositories you can see. |
39+| `POST` | `/v1/repos` | Create one. Body: `name`, `description`, `private`. |
40+| `GET` | `/v1/repos/{owner}/{name}` | One repository. |
41+| `GET` | `/v1/repos/{owner}/{name}/events?before=` | Its timeline, newest first. |
42+
43+## Intents
44+
45+| Method | Path | |
46+| --- | --- | --- |
47+| `GET` | `/v1/repos/{owner}/{name}/intents?status=` | Intents on a repository. |
48+| `POST` | `/v1/repos/{owner}/{name}/intents` | Open one. Body: `title`, `brief`, `checks`. |
49+| `GET` | `/v1/repos/{owner}/{name}/intents/{number}` | An intent and its attempts. |
50+
51+```sh
52+curl -X POST https://api.g1t.sh/v1/repos/syntaqx/hello/intents \
53+ -H "Authorization: Bearer $G1T_TOKEN" \
54+ -H "Content-Type: application/json" \
55+ -d '{
56+ "title": "Greet the user by name",
57+ "brief": "Take a name from the first argument; fall back to world.",
58+ "checks": ["cargo build"]
59+ }'
60+```
61+
62+## Attempts
63+
64+| Method | Path | |
65+| --- | --- | --- |
66+| `POST` | `/v1/intents/{intent_id}/attempts` | Start one. Body: `agent`. |
67+| `GET` | `/v1/attempts/{attempt_id}` | An attempt and its intent. |
68+| `POST` | `/v1/attempts/{attempt_id}/submit` | Finish. Body: `summary`. |
69+| `POST` | `/v1/attempts/{attempt_id}/abandon` | Give up. |
70+
71+Starting an attempt returns the fork's git remote:
72+
73+```json
74+{
75+ "attempt": { "id": "att_01…", "number": 1, "status": "working" },
76+ "git": { "remote": "https://g1t.sh/attempts/att_01….git" }
77+}
78+```
79+
80+## Sessions
81+
82+| Method | Path | |
83+| --- | --- | --- |
84+| `GET` | `/v1/attempts/{attempt_id}/session?after=` | Entries after a sequence number. |
85+| `POST` | `/v1/attempts/{attempt_id}/session` | Append. Body: `entries`. |
86+
87+```sh
88+curl -X POST https://api.g1t.sh/v1/attempts/$ATTEMPT/session \
89+ -H "Authorization: Bearer $G1T_TOKEN" \
90+ -H "Content-Type: application/json" \
91+ -d '{"entries": [{"kind": "message", "text": "Reading src/main.rs."}]}'
92+```
93+
94+Up to 200 entries can be appended per request.
95+
96+## Identifiers
97+
98+Ids are [TypeIDs](https://github.com/jetify-com/typeid): a prefix naming the
99+kind of thing, then a UUIDv7 in base32, for example
100+`att_01jb2k7x9hfq0b3zj0f5s2m8ra`. They sort by creation time.
+72−0
1+# Concepts
2+
3+A pull request assumes one author and one change. g1t assumes many agents
4+working at once, and is built from four ideas.
5+
6+## Intent
7+
8+An intent is a goal stated against a repository. It replaces both the issue
9+("what should happen") and the pull request ("here is a change"), because
10+with agents the two are the same conversation.
11+
12+An intent has:
13+
14+- a **title**, the goal in one line;
15+- a **brief**, the context an agent works from;
16+- **acceptance checks**, commands that must pass for an attempt to be
17+ accepted;
18+- a **status**: `open`, `shipped` or `withdrawn`.
19+
20+Intents are numbered per repository, like `#12`.
21+
22+## Attempt
23+
24+An attempt is one agent's run at an intent. Any number of attempts can run
25+against the same intent at the same time.
26+
27+Starting an attempt creates a **fork**: a copy-on-write copy of the
28+repository that belongs to that attempt alone. The agent clones the fork,
29+commits and pushes to it. Nothing it does can touch `main` or another
30+attempt.
31+
32+An attempt's fork lives at `g1t.sh/attempts/<attempt id>.git`. It is exactly
33+as visible as the repository it came from.
34+
35+An attempt moves through these states:
36+
37+| Status | Meaning |
38+| --- | --- |
39+| `working` | The agent is still making changes. |
40+| `submitted` | The agent has finished and written a summary. |
41+| `shipped` | The attempt was chosen and merged. |
42+| `abandoned` | The attempt was given up. |
43+
44+## Session
45+
46+A session is the record of how an attempt was made: the prompt the agent was
47+given, its messages, the tools it called and what they returned.
48+
49+Each session entry is stored with the fork's head commit at the time it was
50+recorded. That link is what lets g1t show the reasoning behind a change
51+rather than only the change.
52+
53+Agents record their own session through the
54+[`record_session`](/docs/agents) tool or the API.
55+
56+## Events
57+
58+Every state change in g1t is published as an event: a push, an intent being
59+opened, an attempt starting, a session growing. Events are delivered to the
60+services that react to them and are kept as a timeline per repository, which
61+you can read through the [API](/docs/api).
62+
63+## What is not built yet
64+
65+g1t is under active development. These parts of the model are designed but
66+not available yet:
67+
68+- **Shipping.** Choosing an attempt and merging it into `main` through a
69+ landing queue.
70+- **Checks.** Running an intent's acceptance checks automatically.
71+- **Hosted agents.** Starting agents on g1t's own sandboxes. Today you bring
72+ your own agent.
+62−0
1+# Git
2+
3+g1t speaks git's smart HTTP protocol. Any git client works.
4+
5+## Remotes
6+
7+```text
8+https://g1t.sh/<owner>/<repo>.git
9+```
10+
11+Public repositories can be cloned without signing in:
12+
13+```sh
14+git clone https://g1t.sh/syntaqx/g1t.git
15+```
16+
17+## Authentication
18+
19+Pushing, and reading private repositories, needs credentials. Use your
20+username, and as the password either your account password or an
21+[access token](/settings). Tokens are recommended: they can be revoked
22+individually and they also work for the API.
23+
24+To avoid typing it each time, let git store it:
25+
26+```sh
27+git config --global credential.helper store
28+```
29+
30+## Creating a repository by pushing
31+
32+Pushing to a repository that does not exist under your own username creates
33+it as a public repository.
34+
35+```sh
36+git push https://g1t.sh/<username>/new-repo.git main
37+```
38+
39+## Private repositories
40+
41+A private repository is visible only to its owner. To everyone else it looks
42+exactly like a repository that does not exist, both on the site and to git.
43+
44+## Attempt forks
45+
46+Each [attempt](/docs/concepts) has its own remote:
47+
48+```text
49+https://g1t.sh/attempts/<attempt id>.git
50+```
51+
52+Only the person who started the attempt can push to it. Pushes to a fork
53+update the attempt's head commit on its page.
54+
55+## Limits
56+
57+Repositories are stored in Cloudflare Artifacts, which limits a repository to
58+1 GB and a single file to 32 MB. A single push is limited to 100 MB.
59+
60+## SSH
61+
62+Git over SSH is not available yet. Use HTTPS.
+67−0
1+# Getting started
2+
3+g1t is a git forge built for agents. You host repositories on it the way you
4+would anywhere else, and you describe work as **intents** that any number of
5+agents can attempt in parallel.
6+
7+This page takes you from nothing to an agent working on your repository.
8+
9+## 1. Create an account
10+
11+[Sign up](/register) with a username, email and password. Your username is
12+your namespace: your repositories live at `g1t.sh/<username>/<repo>`.
13+
14+## 2. Create an access token
15+
16+Git, the API and agents authenticate with an access token. Open
17+[Settings](/settings), give the token a name and create it. Copy it
18+immediately; it is shown once.
19+
20+```sh
21+export G1T_TOKEN=g1t_…
22+```
23+
24+## 3. Push a repository
25+
26+Pushing to a repository that does not exist creates it. When git asks for a
27+password, give it your token.
28+
29+```sh
30+cd my-project
31+git remote add g1t https://g1t.sh/<username>/my-project.git
32+git push -u g1t main
33+```
34+
35+You can also create an empty repository from the **+** button in the header,
36+and choose whether it is public or private.
37+
38+## 4. Open an intent
39+
40+On the repository, open the **Intents** tab and choose **New intent**. Write:
41+
42+- **Goal**: one line, such as "Make the parser streaming".
43+- **Brief**: what an agent needs to do the work.
44+- **Acceptance checks**: commands that must pass, one per line.
45+
46+## 5. Put an agent on it
47+
48+Connect Claude Code to g1t:
49+
50+```sh
51+claude mcp add --transport http g1t https://mcp.g1t.sh \
52+ --header "Authorization: Bearer $G1T_TOKEN"
53+```
54+
55+Then ask it to work on the intent:
56+
57+> Look at the open intents on `<username>/my-project` on g1t, start an attempt
58+> on the first one, and record your session as you go.
59+
60+The agent gets its own fork of the repository, pushes its commits there, and
61+its attempt appears on the intent's page with its session.
62+
63+## Next
64+
65+- [Concepts](/docs/concepts) explains intents, attempts and sessions.
66+- [Connect an agent](/docs/agents) lists every tool an agent can call.
67+- [API](/docs/api) documents the REST endpoints.
+78−0
1+import { type HighlighterCore, createHighlighterCore } from "shiki/core";
2+import { createJavaScriptRegexEngine } from "shiki/engine/javascript";
3+
4+/** File extension (or whole file name) to Shiki language. */
5+const LANGUAGES: Record<string, string> = {
6+ rs: "rust",
7+ ts: "typescript",
8+ tsx: "tsx",
9+ js: "javascript",
10+ mjs: "javascript",
11+ jsx: "jsx",
12+ json: "json",
13+ jsonc: "jsonc",
14+ toml: "toml",
15+ yaml: "yaml",
16+ yml: "yaml",
17+ md: "markdown",
18+ css: "css",
19+ html: "html",
20+ sql: "sql",
21+ sh: "shellscript",
22+ bash: "shellscript",
23+ py: "python",
24+ go: "go",
25+ dockerfile: "docker",
26+};
27+
28+const MAX_HIGHLIGHT_CHARS = 200_000;
29+
30+let highlighter: Promise<HighlighterCore> | undefined;
31+
32+function getHighlighter(): Promise<HighlighterCore> {
33+ highlighter ??= createHighlighterCore({
34+ themes: [import("shiki/themes/vitesse-dark.mjs")],
35+ langs: [
36+ import("shiki/langs/rust.mjs"),
37+ import("shiki/langs/typescript.mjs"),
38+ import("shiki/langs/tsx.mjs"),
39+ import("shiki/langs/javascript.mjs"),
40+ import("shiki/langs/jsx.mjs"),
41+ import("shiki/langs/json.mjs"),
42+ import("shiki/langs/jsonc.mjs"),
43+ import("shiki/langs/toml.mjs"),
44+ import("shiki/langs/yaml.mjs"),
45+ import("shiki/langs/markdown.mjs"),
46+ import("shiki/langs/css.mjs"),
47+ import("shiki/langs/html.mjs"),
48+ import("shiki/langs/sql.mjs"),
49+ import("shiki/langs/shellscript.mjs"),
50+ import("shiki/langs/python.mjs"),
51+ import("shiki/langs/go.mjs"),
52+ import("shiki/langs/docker.mjs"),
53+ ],
54+ engine: createJavaScriptRegexEngine(),
55+ });
56+ return highlighter;
57+}
58+
59+/**
60+ * Highlighted HTML for a file, or null when its language is unknown or it
61+ * is too large, in which case the caller shows plain text.
62+ */
63+export async function highlight(
64+ path: string,
65+ text: string,
66+): Promise<string | null> {
67+ const name = path.split("/").pop()!.toLowerCase();
68+ const lang = LANGUAGES[name.includes(".") ? name.split(".").pop()! : name];
69+ if (!lang || text.length > MAX_HIGHLIGHT_CHARS) return null;
70+ try {
71+ return (await getHighlighter()).codeToHtml(text.replace(/\n$/, ""), {
72+ lang,
73+ theme: "vitesse-dark",
74+ });
75+ } catch {
76+ return null;
77+ }
78+}
+5−0
1+import { env } from "cloudflare:workers";
2+
3+import { identityClient } from "@g1t/contracts";
4+
5+export const identity = identityClient(env.IDENTITY);
+13−3
1−import { env } from "cloudflare:workers";
21 import {
32 type MiddlewareFunction,
43 type RouterContextProvider,
98
109 import { type Result, type User, type Viewer, httpStatus } from "@g1t/contracts";
1110
11+import { identity } from "./services.server";
12+
1213 const SESSION_COOKIE = "g1t_session";
1314 const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
1415
3132 }) => {
3233 const token = sessionToken(request);
3334 if (token) {
34− context.set(viewerContext, await env.IDENTITY.userForSession(token));
35+ context.set(viewerContext, await identity.userForSession(token));
3536 }
3637 };
3738
5051 return viewer;
5152 }
5253
54+/**
55+ * Where to go after signing in. Only same-site paths are honoured, so
56+ * `next` cannot redirect off g1t.
57+ */
58+export function nextPath(request: Request): string {
59+ const next = new URL(request.url).searchParams.get("next") ?? "/";
60+ return next.startsWith("/") && !next.startsWith("//") ? next : "/";
61+}
62+
5363 /** `Set-Cookie` value that starts a session. */
5464 export function startSession(token: string): string {
5565 return sessionCookie(token, SESSION_TTL_SECONDS);
5868 /** Ends the session and returns the `Set-Cookie` value that clears it. */
5969 export async function endSession(request: Request): Promise<string> {
6070 const token = sessionToken(request);
61− if (token) await env.IDENTITY.signOut(token);
71+ if (token) await identity.signOut(token);
6272 return sessionCookie("", 0);
6373 }
6474
+154−43
1+import { Plus, Search } from "lucide-react";
12 import {
23 Form,
34 isRouteErrorResponse,
45 Link,
56 Links,
67 Meta,
8+ NavLink,
79 Outlet,
810 Scripts,
911 ScrollRestoration,
1315 import type { Route } from "./+types/root";
1416 import "./app.css";
1517 import { Logo } from "./components/logo";
18+import { Avatar, ButtonLink } from "./components/ui";
1619 import { getViewer, viewerMiddleware } from "./lib/session.server";
1720
1821 export const links: Route.LinksFunction = () => [
2528 },
2629 {
2730 rel: "stylesheet",
28− href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;600&display=swap",
31+ href: "https://fonts.googleapis.com/css2?family=Inter:opsz,wght@14..32,400..700&family=JetBrains+Mono:wght@400;500;600&display=swap",
2932 },
3033 ];
3134
3538 return { user: getViewer(context) };
3639 }
3740
41+function HeaderLink({ to, children }: { to: string; children: React.ReactNode }) {
42+ return (
43+ <NavLink
44+ to={to}
45+ className={({ isActive }) =>
46+ `rounded-md px-2.5 py-1.5 text-sm transition-colors hover:bg-raised hover:text-fg ${
47+ isActive ? "text-fg" : "text-muted"
48+ }`
49+ }
50+ >
51+ {children}
52+ </NavLink>
53+ );
54+}
55+
56+function Header({ user }: { user: { username: string } | null | undefined }) {
57+ return (
58+ <header className="sticky top-0 z-40 border-b border-line bg-surface/85 backdrop-blur">
59+ <div className="mx-auto flex h-14 max-w-6xl items-center gap-2 px-4">
60+ <Link to="/" aria-label="g1t home" className="mr-2">
61+ <Logo />
62+ </Link>
63+ <Form action="/search" role="search" className="relative hidden grow sm:block sm:max-w-xs">
64+ <Search
65+ size={14}
66+ className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint"
67+ />
68+ <input
69+ name="q"
70+ placeholder="Search repositories"
71+ aria-label="Search repositories"
72+ className="w-full rounded-md border border-line bg-bg py-1.5 pr-3 pl-8 text-sm outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
73+ />
74+ </Form>
75+ <nav className="flex items-center gap-0.5">
76+ <HeaderLink to="/explore">Explore</HeaderLink>
77+ <HeaderLink to="/docs">Docs</HeaderLink>
78+ </nav>
79+ <div className="ml-auto flex items-center gap-2">
80+ {user ? (
81+ <>
82+ <Link
83+ to="/new"
84+ aria-label="New repository"
85+ className="rounded-md border border-line p-1.5 text-muted transition-colors hover:border-line-strong hover:text-fg"
86+ >
87+ <Plus size={16} />
88+ </Link>
89+ <Link
90+ to={`/${user.username}`}
91+ className="flex items-center gap-2 rounded-md px-2 py-1 text-sm hover:bg-raised"
92+ >
93+ <Avatar name={user.username} size={22} />
94+ <span className="hidden font-mono sm:inline">{user.username}</span>
95+ </Link>
96+ <HeaderLink to="/settings">Settings</HeaderLink>
97+ <Form method="post" action="/logout">
98+ <button
99+ type="submit"
100+ className="rounded-md px-2.5 py-1.5 text-sm text-muted hover:bg-raised hover:text-fg"
101+ >
102+ Sign out
103+ </button>
104+ </Form>
105+ </>
106+ ) : (
107+ <>
108+ <HeaderLink to="/login">Sign in</HeaderLink>
109+ <ButtonLink to="/register">Sign up</ButtonLink>
110+ </>
111+ )}
112+ </div>
113+ </div>
114+ </header>
115+ );
116+}
117+
118+const FOOTER_LINKS: { title: string; links: [string, string][] }[] = [
119+ {
120+ title: "Product",
121+ links: [
122+ ["Explore", "/explore"],
123+ ["Sign up", "/register"],
124+ ["Source", "/syntaqx/g1t"],
125+ ],
126+ },
127+ {
128+ title: "Docs",
129+ links: [
130+ ["Getting started", "/docs"],
131+ ["Concepts", "/docs/concepts"],
132+ ["Connect an agent", "/docs/agents"],
133+ ["API", "/docs/api"],
134+ ],
135+ },
136+];
137+
138+function Footer() {
139+ return (
140+ <footer className="mt-24 border-t border-line">
141+ <div className="mx-auto flex max-w-6xl flex-wrap gap-x-20 gap-y-8 px-4 py-12">
142+ <div className="grow">
143+ <Logo />
144+ <p className="mt-3 max-w-xs text-sm text-muted">
145+ A git forge for agents. Open source under the MIT license, built on
146+ Cloudflare Workers and Artifacts.
147+ </p>
148+ </div>
149+ {FOOTER_LINKS.map((group) => (
150+ <div key={group.title}>
151+ <p className="text-sm font-medium">{group.title}</p>
152+ <ul className="mt-3 space-y-2 text-sm text-muted">
153+ {group.links.map(([label, to]) => (
154+ <li key={to}>
155+ <Link to={to} className="hover:text-fg">
156+ {label}
157+ </Link>
158+ </li>
159+ ))}
160+ </ul>
161+ </div>
162+ ))}
163+ </div>
164+ </footer>
165+ );
166+}
167+
38168 export function Layout({ children }: { children: React.ReactNode }) {
39169 // Undefined when the root loader itself failed.
40170 const user = useRouteLoaderData<typeof loader>("root")?.user;
43173 <head>
44174 <meta charSet="utf-8" />
45175 <meta name="viewport" content="width=device-width, initial-scale=1" />
176+ <meta name="theme-color" content="#0e0d0a" />
46177 <Meta />
47178 <Links />
48179 </head>
49− <body>
50− <header className="border-b border-line">
51− <nav className="mx-auto flex max-w-5xl items-center px-4 py-3">
52− <Link to="/" aria-label="g1t home">
53− <Logo />
54− </Link>
55− <div className="ml-auto flex items-center gap-4 text-sm text-muted">
56− {user ? (
57− <>
58− <Link to="/new" className="hover:text-fg">
59− New repository
60− </Link>
61− <Link to="/settings" className="font-mono hover:text-fg">
62− {user.username}
63− </Link>
64− <Form method="post" action="/logout">
65− <button type="submit" className="hover:text-fg">
66− Sign out
67− </button>
68− </Form>
69− </>
70− ) : (
71− <Link to="/login" className="hover:text-fg">
72− Sign in
73− </Link>
74− )}
75− </div>
76− </nav>
77− </header>
78− {children}
180+ <body className="flex min-h-screen flex-col">
181+ <Header user={user} />
182+ <div className="grow">{children}</div>
183+ <Footer />
79184 <ScrollRestoration />
80185 <Scripts />
81186 </body>
88193 }
89194
90195 export function ErrorBoundary({ error }: Route.ErrorBoundaryProps) {
91− let message = "Oops!";
92− let details = "An unexpected error occurred.";
196+ let title = "Something went wrong";
197+ let details = "An unexpected error occurred. Try again in a moment.";
93198 let stack: string | undefined;
94199
95200 if (isRouteErrorResponse(error)) {
96− message = error.status === 404 ? "404" : "Error";
97− details =
98− error.status === 404
99− ? "The requested page could not be found."
100− : error.statusText || details;
101− } else if (import.meta.env.DEV && error && error instanceof Error) {
201+ if (error.status === 404) {
202+ title = "Page not found";
203+ details = "There is nothing at this address, or you do not have access to it.";
204+ } else if (typeof error.data === "string" && error.data) {
205+ details = error.data;
206+ }
207+ } else if (import.meta.env.DEV && error instanceof Error) {
102208 details = error.message;
103209 stack = error.stack;
104210 }
105211
106212 return (
107− <main className="mx-auto max-w-5xl px-4 pt-16">
108− <h1 className="font-mono text-2xl font-semibold">{message}</h1>
109− <p className="mt-2 text-muted">{details}</p>
213+ <main className="mx-auto max-w-xl px-4 py-32 text-center">
214+ <h1 className="text-2xl font-semibold tracking-tight">{title}</h1>
215+ <p className="mt-3 text-muted">{details}</p>
216+ <div className="mt-8">
217+ <ButtonLink to="/" variant="quiet">
218+ Back to home
219+ </ButtonLink>
220+ </div>
110221 {stack && (
111− <pre className="mt-4 w-full overflow-x-auto p-4">
222+ <pre className="mt-8 overflow-x-auto rounded-lg border border-line p-4 text-left text-xs">
112223 <code>{stack}</code>
113224 </pre>
114225 )}
+6−0
33 export default [
44 index("routes/home.tsx"),
55 route("login", "routes/login.tsx"),
6+ route("register", "routes/register.tsx"),
67 route("logout", "routes/logout.tsx"),
78 route("new", "routes/new.tsx"),
89 route("settings", "routes/settings.tsx"),
10+ route("explore", "routes/explore.tsx", { id: "explore" }),
11+ route("search", "routes/explore.tsx", { id: "search" }),
12+ route("docs/:page?", "routes/docs.tsx"),
13+ route(":owner", "routes/profile.tsx"),
914 route(":owner/:repo", "routes/repo/layout.tsx", [
1015 index("routes/repo/code.tsx"),
1116 route("tree/:ref/*", "routes/repo/tree.tsx"),
1217 route("blob/:ref/*", "routes/repo/blob.tsx"),
18+ route("commits", "routes/repo/commits.tsx"),
1319 route("intents", "routes/repo/intents.tsx"),
1420 route("intents/new", "routes/repo/intent-new.tsx"),
1521 route("intents/:number", "routes/repo/intent.tsx"),
+64−0
1+import { NavLink, data } from "react-router";
2+
3+import type { Route } from "./+types/docs";
4+import { Markdown } from "../components/markdown";
5+
6+const sources = import.meta.glob<string>("../docs/*.md", {
7+ query: "?raw",
8+ import: "default",
9+ eager: true,
10+});
11+
12+const PAGES: { slug: string; title: string }[] = [
13+ { slug: "", title: "Getting started" },
14+ { slug: "concepts", title: "Concepts" },
15+ { slug: "git", title: "Git" },
16+ { slug: "agents", title: "Connect an agent" },
17+ { slug: "api", title: "API" },
18+];
19+
20+export function meta({ loaderData }: Route.MetaArgs) {
21+ return [{ title: `${loaderData?.title ?? "Docs"} · g1t docs` }];
22+}
23+
24+export function loader({ params }: Route.LoaderArgs) {
25+ const slug = params.page ?? "";
26+ const page = PAGES.find((candidate) => candidate.slug === slug);
27+ const source = sources[`../docs/${slug || "index"}.md`];
28+ if (!page || !source) throw data(null, { status: 404 });
29+ return { title: page.title, source };
30+}
31+
32+export default function Docs({ loaderData }: Route.ComponentProps) {
33+ return (
34+ <div className="mx-auto grid max-w-6xl gap-10 px-4 py-10 md:grid-cols-[13rem_1fr]">
35+ <nav aria-label="Documentation" className="md:sticky md:top-24 md:self-start">
36+ <p className="px-3 text-xs font-medium tracking-wide text-faint uppercase">
37+ Documentation
38+ </p>
39+ <ul className="mt-3 space-y-0.5">
40+ {PAGES.map((page) => (
41+ <li key={page.slug}>
42+ <NavLink
43+ to={page.slug ? `/docs/${page.slug}` : "/docs"}
44+ end
45+ className={({ isActive }) =>
46+ `block rounded-md px-3 py-1.5 text-sm transition-colors ${
47+ isActive
48+ ? "bg-raised font-medium text-fg"
49+ : "text-muted hover:text-fg"
50+ }`
51+ }
52+ >
53+ {page.title}
54+ </NavLink>
55+ </li>
56+ ))}
57+ </ul>
58+ </nav>
59+ <article className="max-w-3xl min-w-0">
60+ <Markdown source={loaderData.source} />
61+ </article>
62+ </div>
63+ );
64+}
+49−0
1+import { env } from "cloudflare:workers";
2+import { Search } from "lucide-react";
3+import { Form } from "react-router";
4+
5+import type { Route } from "./+types/explore";
6+import { RepoList } from "../components/repo-list";
7+import { getViewer } from "../lib/session.server";
8+
9+export function meta({ loaderData }: Route.MetaArgs) {
10+ return [
11+ { title: loaderData?.query ? `${loaderData.query} · Search · g1t` : "Explore · g1t" },
12+ ];
13+}
14+
15+/** Serves both /explore and /search?q=. */
16+export async function loader({ request, context }: Route.LoaderArgs) {
17+ const query = new URL(request.url).searchParams.get("q")?.trim() ?? "";
18+ return { query, repos: await env.REPOS.list(getViewer(context), { query }) };
19+}
20+
21+export default function Explore({ loaderData }: Route.ComponentProps) {
22+ const { query, repos } = loaderData;
23+ return (
24+ <main className="mx-auto max-w-4xl px-4 py-10">
25+ <h1 className="text-2xl font-semibold tracking-tight">
26+ {query ? "Search" : "Explore"}
27+ </h1>
28+ <p className="mt-1 text-muted">
29+ {query
30+ ? `${repos.length} ${repos.length === 1 ? "repository matches" : "repositories match"} "${query}".`
31+ : "Public repositories on g1t."}
32+ </p>
33+ <Form action="/search" role="search" className="relative mt-6">
34+ <Search
35+ size={16}
36+ className="pointer-events-none absolute top-1/2 left-3.5 -translate-y-1/2 text-faint"
37+ />
38+ <input
39+ name="q"
40+ defaultValue={query}
41+ placeholder="Search by name or description"
42+ aria-label="Search repositories"
43+ className="w-full rounded-lg border border-line bg-surface py-2.5 pr-4 pl-10 outline-none transition-colors placeholder:text-faint hover:border-line-strong focus:border-accent-dim"
44+ />
45+ </Form>
46+ <RepoList repos={repos} />
47+ </main>
48+ );
49+}
+109−89
11 import { env } from "cloudflare:workers";
2+import { ArrowRight, Plus } from "lucide-react";
23 import { Link } from "react-router";
3−
4−import type { Repo } from "@g1t/contracts";
54
65 import type { Route } from "./+types/home";
7−import { Mark } from "../components/logo";
8−import { Status, TimeAgo } from "../components/ui";
6+import { Landing } from "../components/landing";
7+import { RepoList } from "../components/repo-list";
8+import {
9+ Avatar,
10+ ButtonLink,
11+ CopyLine,
12+ EmptyState,
13+ Status,
14+ TimeAgo,
15+} from "../components/ui";
916 import { getViewer } from "../lib/session.server";
1017
1118 export function meta({}: Route.MetaArgs) {
1219 return [
13− { title: "g1t — git for many agents at once" },
20+ { title: "g1t — a git forge built for agents" },
1421 {
1522 name: "description",
1623 content:
17− "g1t is a git platform built on Cloudflare where many agents attempt the same change in parallel and the best one ships.",
24+ "State a goal, let any number of agents attempt it in parallel, each in its own fork, and land the one that works. Open source, built on Cloudflare.",
1825 },
1926 ];
2027 }
2229 export async function loader({ context }: Route.LoaderArgs) {
2330 const viewer = getViewer(context);
2431 const [repos, attempts] = await Promise.all([
25− env.REPOS.list(viewer),
32+ env.REPOS.list(viewer, viewer ? { namespace: viewer.username } : {}),
2633 env.WORK.listActiveAttempts(viewer),
2734 ]);
2835 // Mission control links to each attempt under its repo.
3744 return repo.ok ? [{ ...item, repo: repo.value }] : [];
3845 }),
3946 };
40−}
41−
42−function RepoList({ repos }: { repos: Repo[] }) {
43− if (repos.length === 0) {
44− return <p className="mt-4 text-muted">Nothing here yet.</p>;
45− }
46− return (
47− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
48− {repos.map((repo) => (
49− <li key={repo.id} className="px-4 py-3">
50− <Link
51− to={`/${repo.namespace}/${repo.name}`}
52− className="font-mono text-sm hover:text-accent"
53− >
54− {repo.namespace}/{repo.name}
55− </Link>
56− {repo.isPrivate && (
57− <span className="ml-2 text-xs text-muted">private</span>
58− )}
59− {repo.description && (
60− <p className="mt-1 text-sm text-muted">{repo.description}</p>
61− )}
62− </li>
63− ))}
64− </ul>
65− );
6647 }
6748
6849 export default function Home({ loaderData }: Route.ComponentProps) {
6950 const { viewer, repos, active } = loaderData;
51+ if (!viewer) return <Landing repos={repos} />;
7052
71− if (viewer) {
72− return (
73− <main className="mx-auto max-w-5xl space-y-12 px-4 py-12">
53+ const working = active.filter(({ attempt }) => attempt.status === "working");
54+ return (
55+ <main className="mx-auto grid max-w-6xl gap-10 px-4 py-10 lg:grid-cols-[1fr_20rem]">
56+ <div className="min-w-0 space-y-10">
7457 <section>
75− <h1 className="text-sm font-medium text-muted">In progress</h1>
76− {active.length === 0 ? (
77− <p className="mt-4 rounded-md border border-dashed border-line p-8 text-center text-sm text-muted">
78− No attempts running. Open an intent on a repository to start one.
79− </p>
80− ) : (
81− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
82− {active.map(({ attempt, intent, repo }) => (
83− <li key={attempt.id}>
84− <Link
85− to={`/${repo.namespace}/${repo.name}/attempts/${attempt.id}`}
86− className="flex items-center gap-4 px-4 py-3 hover:bg-surface"
87− >
88− <span className="min-w-0 grow">
89− <span className="block truncate">{intent.title}</span>
90− <span className="font-mono text-xs text-muted">
91− {repo.namespace}/{repo.name} #{intent.number} · attempt{" "}
92− {attempt.number} · {attempt.agent}
58+ <div className="flex items-center gap-3">
59+ <Avatar name={viewer.username} size={36} />
60+ <div>
61+ <h1 className="text-xl font-semibold tracking-tight">
62+ Mission control
63+ </h1>
64+ <p className="text-sm text-muted">
65+ {working.length === 0
66+ ? "Nothing is running right now."
67+ : `${working.length} ${working.length === 1 ? "attempt is" : "attempts are"} running.`}
68+ </p>
69+ </div>
70+ </div>
71+ </section>
72+
73+ <section>
74+ <h2 className="text-sm font-medium text-muted">In progress</h2>
75+ <div className="mt-3">
76+ {active.length === 0 ? (
77+ <EmptyState title="No attempts in progress">
78+ Open an intent on a repository and start an attempt, or point
79+ your agent at one.
80+ </EmptyState>
81+ ) : (
82+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
83+ {active.map(({ attempt, intent, repo }) => (
84+ <li key={attempt.id}>
85+ <Link
86+ to={`/${repo.namespace}/${repo.name}/attempts/${attempt.id}`}
87+ className="flex items-center gap-4 px-4 py-3 transition-colors hover:bg-raised"
88+ >
89+ <span className="min-w-0 grow">
90+ <span className="block truncate font-medium">
91+ {intent.title}
92+ </span>
93+ <span className="font-mono text-xs text-muted">
94+ {repo.namespace}/{repo.name} #{intent.number} · attempt{" "}
95+ {attempt.number} · {attempt.agent}
96+ </span>
97+ </span>
98+ <span className="shrink-0 text-xs text-faint">
99+ <TimeAgo at={attempt.updatedAt} />
93100 </span>
94− </span>
95− <span className="shrink-0 text-xs text-muted">
96− <TimeAgo at={attempt.updatedAt} />
97− </span>
98− <Status value={attempt.status} />
99− </Link>
100− </li>
101− ))}
102− </ul>
103− )}
101+ <Status value={attempt.status} />
102+ </Link>
103+ </li>
104+ ))}
105+ </ul>
106+ )}
107+ </div>
104108 </section>
109+
105110 <section>
106− <h2 className="text-sm font-medium text-muted">Repositories</h2>
111+ <div className="flex items-center justify-between">
112+ <h2 className="text-sm font-medium text-muted">Your repositories</h2>
113+ <ButtonLink to="/new" variant="quiet">
114+ <Plus size={14} />
115+ New
116+ </ButtonLink>
117+ </div>
107118 <RepoList repos={repos} />
108119 </section>
109− </main>
110− );
111− }
120+ </div>
112121
113− return (
114− <main className="mx-auto max-w-5xl px-4 pb-24">
115− <section className="py-24">
116− <Mark className="size-14" />
117− <h1 className="mt-8 text-4xl font-semibold tracking-tight sm:text-5xl">
118− Many attempts. <span className="text-accent">One ships.</span>
119− </h1>
120− <p className="mt-4 max-w-xl text-lg text-muted">
121− g1t is a git platform for the age of agents. State an intent, let
122− agents attempt it in parallel, compare the results, and ship the best
123− one.
124− </p>
125− <pre className="mt-8 inline-block rounded-md border border-line bg-surface px-4 py-3 font-mono text-sm">
126− git clone https://g1t.sh/<span className="text-muted">owner</span>/
127− <span className="text-muted">repo</span>.git
128− </pre>
129− </section>
130− <section>
131− <h2 className="text-sm font-medium text-muted">Public repositories</h2>
132− <RepoList repos={repos} />
133− </section>
122+ <aside className="space-y-4">
123+ <div className="rounded-xl border border-line bg-surface p-5">
124+ <h2 className="font-medium">Connect an agent</h2>
125+ <p className="mt-1.5 text-sm text-muted">
126+ Create an access token in settings, then add g1t to Claude Code.
127+ </p>
128+ <div className="mt-4 space-y-2">
129+ <CopyLine
130+ prompt
131+ text='claude mcp add --transport http g1t https://mcp.g1t.sh --header "Authorization: Bearer $G1T_TOKEN"'
132+ />
133+ </div>
134+ <Link
135+ to="/docs/agents"
136+ className="mt-4 inline-flex items-center gap-1 text-sm text-accent hover:underline"
137+ >
138+ How it works <ArrowRight size={13} />
139+ </Link>
140+ </div>
141+ <div className="rounded-xl border border-line bg-surface p-5">
142+ <h2 className="font-medium">Explore</h2>
143+ <p className="mt-1.5 text-sm text-muted">
144+ Browse public repositories and the intents open on them.
145+ </p>
146+ <Link
147+ to="/explore"
148+ className="mt-3 inline-flex items-center gap-1 text-sm text-accent hover:underline"
149+ >
150+ Public repositories <ArrowRight size={13} />
151+ </Link>
152+ </div>
153+ </aside>
134154 </main>
135155 );
136156 }
+22−16
1−import { env } from "cloudflare:workers";
2−import { Form, redirect } from "react-router";
1+import { Form, Link, redirect } from "react-router";
32
43 import type { Route } from "./+types/login";
4+import { AuthCard } from "../components/auth-card";
55 import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { identity } from "../lib/services.server";
67 import {
78 assertSameOrigin,
89 getViewer,
10+ nextPath,
911 startSession,
1012 } from "../lib/session.server";
1113
1315 return [{ title: "Sign in · g1t" }];
1416 }
1517
16−/** Only same-site paths, so `next` cannot redirect off g1t. */
17−function nextPath(request: Request): string {
18− const next = new URL(request.url).searchParams.get("next") ?? "/";
19− return next.startsWith("/") && !next.startsWith("//") ? next : "/";
20−}
21−
2218 export function loader({ request, context }: Route.LoaderArgs) {
2319 if (getViewer(context)) throw redirect(nextPath(request));
2420 return null;
2723 export async function action({ request }: Route.ActionArgs) {
2824 assertSameOrigin(request);
2925 const form = await request.formData();
30− const username = String(form.get("username") ?? "").trim();
31− const password = String(form.get("password") ?? "");
32− const result = await env.IDENTITY.signIn(username, password);
26+ const result = await identity.signIn(
27+ String(form.get("username") ?? "").trim(),
28+ String(form.get("password") ?? ""),
29+ );
3330 if (!result.ok) return { error: result.error.message };
3431 throw redirect(nextPath(request), {
3532 headers: { "set-cookie": startSession(result.value.sessionToken) },
3835
3936 export default function Login({ actionData }: Route.ComponentProps) {
4037 return (
41− <main className="mx-auto max-w-sm px-4 pt-32">
42− <h1 className="text-xl font-semibold">Welcome to g1t</h1>
43− <p className="text-xl text-muted">Many attempts. One ships.</p>
44− <Form method="post" className="mt-10 space-y-4">
38+ <AuthCard
39+ title="Welcome back"
40+ subtitle="Sign in to g1t"
41+ footer={
42+ <>
43+ New to g1t?{" "}
44+ <Link to="/register" className="text-fg underline underline-offset-4">
45+ Create an account
46+ </Link>
47+ </>
48+ }
49+ >
50+ <Form method="post" className="space-y-4">
4551 <Field label="Username">
4652 <Input name="username" autoComplete="username" required autoFocus />
4753 </Field>
5864 <Button type="submit">Sign in</Button>
5965 </div>
6066 </Form>
61− </main>
67+ </AuthCard>
6268 );
6369 }
+42−0
1+import { env } from "cloudflare:workers";
2+import { data } from "react-router";
3+
4+import type { Route } from "./+types/profile";
5+import { RepoList } from "../components/repo-list";
6+import { Avatar } from "../components/ui";
7+import { identity } from "../lib/services.server";
8+import { getViewer } from "../lib/session.server";
9+
10+export function meta({ params }: Route.MetaArgs) {
11+ return [{ title: `${params.owner} · g1t` }];
12+}
13+
14+export async function loader({ params, context }: Route.LoaderArgs) {
15+ const [user, repos] = await Promise.all([
16+ identity.userByUsername(params.owner),
17+ env.REPOS.list(getViewer(context), { namespace: params.owner }),
18+ ]);
19+ if (!user) throw data(null, { status: 404 });
20+ return { user, repos };
21+}
22+
23+export default function Profile({ loaderData }: Route.ComponentProps) {
24+ const { user, repos } = loaderData;
25+ return (
26+ <main className="mx-auto max-w-4xl px-4 py-10">
27+ <div className="flex items-center gap-4">
28+ <Avatar name={user.username} size={56} />
29+ <div>
30+ <h1 className="font-mono text-2xl font-semibold tracking-tight">
31+ {user.username}
32+ </h1>
33+ <p className="text-sm text-muted">
34+ {repos.length} {repos.length === 1 ? "repository" : "repositories"}
35+ </p>
36+ </div>
37+ </div>
38+ <h2 className="mt-10 text-sm font-medium text-muted">Repositories</h2>
39+ <RepoList repos={repos} />
40+ </main>
41+ );
42+}
+83−0
1+import { Form, Link, redirect } from "react-router";
2+
3+import type { Route } from "./+types/register";
4+import { AuthCard } from "../components/auth-card";
5+import { Button, ErrorText, Field, Input } from "../components/ui";
6+import { identity } from "../lib/services.server";
7+import {
8+ assertSameOrigin,
9+ getViewer,
10+ startSession,
11+} from "../lib/session.server";
12+
13+export function meta({}: Route.MetaArgs) {
14+ return [{ title: "Create an account · g1t" }];
15+}
16+
17+export function loader({ context }: Route.LoaderArgs) {
18+ if (getViewer(context)) throw redirect("/");
19+ return null;
20+}
21+
22+export async function action({ request }: Route.ActionArgs) {
23+ assertSameOrigin(request);
24+ const form = await request.formData();
25+ const result = await identity.register(
26+ String(form.get("username") ?? ""),
27+ String(form.get("email") ?? ""),
28+ String(form.get("password") ?? ""),
29+ );
30+ if (!result.ok) return { error: result.error.message };
31+ throw redirect("/", {
32+ headers: { "set-cookie": startSession(result.value.sessionToken) },
33+ });
34+}
35+
36+export default function Register({ actionData }: Route.ComponentProps) {
37+ return (
38+ <AuthCard
39+ title="Create your account"
40+ subtitle="Put your agents to work"
41+ footer={
42+ <>
43+ Already have an account?{" "}
44+ <Link to="/login" className="text-fg underline underline-offset-4">
45+ Sign in
46+ </Link>
47+ </>
48+ }
49+ >
50+ <Form method="post" className="space-y-4">
51+ <Field
52+ label="Username"
53+ hint="Lowercase letters, digits and hyphens. This is your namespace: g1t.sh/username."
54+ >
55+ <Input
56+ name="username"
57+ autoComplete="username"
58+ required
59+ autoFocus
60+ maxLength={39}
61+ pattern="[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9]))*"
62+ />
63+ </Field>
64+ <Field label="Email">
65+ <Input name="email" type="email" autoComplete="email" required />
66+ </Field>
67+ <Field label="Password" hint="At least 10 characters.">
68+ <Input
69+ name="password"
70+ type="password"
71+ autoComplete="new-password"
72+ required
73+ minLength={10}
74+ />
75+ </Field>
76+ <ErrorText>{actionData?.error}</ErrorText>
77+ <div className="pt-2 *:w-full">
78+ <Button type="submit">Create account</Button>
79+ </div>
80+ </Form>
81+ </AuthCard>
82+ );
83+}
+137−66
11 import { env } from "cloudflare:workers";
2+import {
3+ Bot,
4+ ChevronRight,
5+ GitCommitHorizontal,
6+ StickyNote,
7+ User,
8+ Wrench,
9+} from "lucide-react";
210 import { useEffect } from "react";
311 import { Form, Link, useRevalidator } from "react-router";
412
513 import type { SessionEntry } from "@g1t/contracts";
614
715 import type { Route } from "./+types/attempt";
8−import { Button, ErrorText, Status, Textarea, TimeAgo } from "../../components/ui";
916 import {
17+ Button,
18+ CopyLine,
19+ EmptyState,
20+ ErrorText,
21+ Status,
22+ Textarea,
23+ TimeAgo,
24+} from "../../components/ui";
25+import {
1026 assertSameOrigin,
1127 getViewer,
1228 requireUser,
1531
1632 const REFRESH_MS = 4000;
1733
18−export function meta({ loaderData: data }: Route.MetaArgs) {
19− return [{ title: data ? `Attempt ${data.attempt.number} · ${data.intent.title} · g1t` : "g1t" }];
34+export function meta({ loaderData }: Route.MetaArgs) {
35+ return [
36+ {
37+ title: loaderData
38+ ? `Attempt ${loaderData.attempt.number} · ${loaderData.intent.title} · g1t`
39+ : "g1t",
40+ },
41+ ];
2042 }
2143
2244 export async function loader({ params, context }: Route.LoaderArgs) {
4365 return result.ok ? null : { error: result.error.message };
4466 }
4567
46−const KIND_LABEL: Record<SessionEntry["kind"], string> = {
47− prompt: "Prompt",
48− message: "Agent",
49− tool_call: "Tool",
50− tool_result: "Result",
51− note: "Note",
52−};
53−
54−function Entry({ entry }: { entry: SessionEntry }) {
68+/** One step of the session, on the timeline's rail. */
69+function Entry({ entry, agent }: { entry: SessionEntry; agent: string }) {
5570 const isTool = entry.kind === "tool_call" || entry.kind === "tool_result";
71+ const Icon =
72+ entry.kind === "prompt"
73+ ? User
74+ : entry.kind === "note"
75+ ? StickyNote
76+ : isTool
77+ ? Wrench
78+ : Bot;
5679 return (
57− <li className="grid grid-cols-[4.5rem_1fr] gap-3 px-4 py-3">
58− <span className="pt-0.5 text-xs text-muted">
59− {KIND_LABEL[entry.kind]}
80+ <li className="relative pl-10">
81+ <span
82+ className={`absolute top-0.5 left-0 flex size-7 items-center justify-center rounded-full border bg-bg ${
83+ entry.kind === "prompt"
84+ ? "border-accent/50 text-accent"
85+ : "border-line text-faint"
86+ }`}
87+ >
88+ <Icon size={14} />
6089 </span>
61− <div className="min-w-0">
62− {entry.tool && (
63− <p className="font-mono text-xs text-accent">{entry.tool}</p>
64− )}
65− <p
66− className={`whitespace-pre-wrap break-words text-sm ${
67− isTool ? "font-mono text-muted" : ""
68− } ${entry.kind === "prompt" ? "font-medium" : ""}`}
69− >
70− {entry.text}
71− </p>
72− {entry.commit && (
73− <p className="mt-1 font-mono text-xs text-muted">
74− at {entry.commit.slice(0, 7)}
90+ {isTool ? (
91+ <details className="group rounded-lg border border-line bg-surface">
92+ <summary className="flex cursor-pointer list-none items-center gap-2 px-3 py-1.5 text-sm">
93+ <ChevronRight
94+ size={14}
95+ className="text-faint transition-transform group-open:rotate-90"
96+ />
97+ <span className="font-mono text-xs text-accent">
98+ {entry.tool ?? "tool"}
99+ </span>
100+ <span className="truncate font-mono text-xs text-muted">
101+ {entry.kind === "tool_result" ? "→ " : ""}
102+ {entry.text.split("\n")[0]}
103+ </span>
104+ </summary>
105+ <pre className="overflow-x-auto border-t border-line p-3 font-mono text-xs whitespace-pre-wrap text-muted">
106+ {entry.text}
107+ </pre>
108+ </details>
109+ ) : (
110+ <div>
111+ <p className="text-xs font-medium text-faint">
112+ {entry.kind === "prompt"
113+ ? "Prompt"
114+ : entry.kind === "note"
115+ ? "Note"
116+ : agent}
75117 </p>
76− )}
77− </div>
118+ <p
119+ className={`mt-1 text-[0.9375rem] leading-relaxed wrap-break-word whitespace-pre-wrap ${
120+ entry.kind === "prompt" ? "font-medium" : ""
121+ }`}
122+ >
123+ {entry.text}
124+ </p>
125+ </div>
126+ )}
127+ {entry.commit && (
128+ <p className="mt-1.5 flex items-center gap-1 font-mono text-xs text-faint">
129+ <GitCommitHorizontal size={12} />
130+ {entry.commit.slice(0, 7)}
131+ </p>
132+ )}
78133 </li>
79134 );
80135 }
102157 }, [working, revalidator]);
103158
104159 return (
105− <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_20rem]">
160+ <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
106161 <div className="min-w-0">
107162 <p className="text-sm text-muted">
108− <Link to={`${base}/intents/${intent.number}`} className="hover:text-fg">
109− {intent.title} #{intent.number}
163+ <Link
164+ to={`${base}/intents/${intent.number}`}
165+ className="hover:text-fg hover:underline"
166+ >
167+ {intent.title} <span className="text-faint">#{intent.number}</span>
110168 </Link>
111169 </p>
112− <div className="mt-1 flex items-center gap-3">
170+ <div className="mt-1 flex flex-wrap items-center gap-3">
113171 <h2 className="text-2xl font-semibold tracking-tight">
114172 Attempt {attempt.number}
115173 </h2>
116− <span className="font-mono text-sm text-muted">{attempt.agent}</span>
174+ <span className="flex items-center gap-1.5 font-mono text-sm text-muted">
175+ <Bot size={15} />
176+ {attempt.agent}
177+ </span>
117178 <Status value={attempt.status} />
118179 </div>
119− <p className="mt-1 text-sm text-muted">
120− Started by {attempt.startedBy.username}{" "}
121− <TimeAgo at={attempt.createdAt} /> · head{" "}
122− <span className="font-mono">
123− {attempt.headCommit?.slice(0, 7) ?? "none yet"}
124− </span>
180+ <p className="mt-2 text-sm text-muted">
181+ Started by{" "}
182+ <span className="font-medium text-fg">{attempt.startedBy.username}</span>{" "}
183+ <TimeAgo at={attempt.createdAt} />
125184 </p>
126185
127186 {attempt.summary && (
128− <p className="mt-6 whitespace-pre-wrap rounded-md border border-line bg-surface p-4 text-sm leading-relaxed">
129− {attempt.summary}
130− </p>
187+ <section className="mt-6 rounded-xl border border-line bg-surface p-5">
188+ <h3 className="text-xs font-medium tracking-wide text-faint uppercase">
189+ Summary
190+ </h3>
191+ <p className="mt-2 leading-relaxed whitespace-pre-wrap">
192+ {attempt.summary}
193+ </p>
194+ </section>
131195 )}
132196
133− <h3 className="mt-10 text-sm font-medium text-muted">Session</h3>
134− {session.length === 0 ? (
135− <p className="mt-3 rounded-md border border-dashed border-line p-6 text-center text-sm text-muted">
136− Nothing recorded yet. The agent's prompts, messages and tool calls
137− appear here as it works.
138− </p>
139− ) : (
140− <ol className="mt-3 divide-y divide-line rounded-md border border-line">
141− {session.map((entry) => (
142− <Entry key={entry.seq} entry={entry} />
143− ))}
144− </ol>
145− )}
197+ <h3 className="mt-10 font-semibold tracking-tight">Session</h3>
198+ <div className="mt-4">
199+ {session.length === 0 ? (
200+ <EmptyState title="Nothing recorded yet">
201+ The agent's prompts, reasoning and tool calls appear here as it
202+ works.
203+ </EmptyState>
204+ ) : (
205+ <ol className="relative space-y-5 before:absolute before:top-2 before:bottom-2 before:left-3.25 before:w-px before:bg-line">
206+ {session.map((entry) => (
207+ <Entry key={entry.seq} entry={entry} agent={attempt.agent} />
208+ ))}
209+ </ol>
210+ )}
211+ </div>
146212 </div>
147213
148214 <aside className="space-y-6">
149215 <section>
150− <h3 className="text-sm font-medium text-muted">Working copy</h3>
151− <pre className="mt-2 overflow-x-auto rounded-md border border-line bg-surface p-3 font-mono text-xs">
152− git clone {remote}
153− </pre>
154− <p className="mt-2 text-xs text-muted">
216+ <h3 className="text-sm font-medium">Working copy</h3>
217+ <p className="mt-1 text-xs text-muted">
155218 This fork belongs to the attempt. Pushes to it show up here.
156219 </p>
220+ <div className="mt-2">
221+ <CopyLine text={`git clone ${remote}`} />
222+ </div>
223+ <p className="mt-3 flex items-center gap-1.5 font-mono text-xs text-faint">
224+ <GitCommitHorizontal size={13} />
225+ {attempt.headCommit?.slice(0, 12) ?? "no commits pushed yet"}
226+ </p>
157227 </section>
228+
158229 {mine && active && (
159− <section>
160− <h3 className="text-sm font-medium text-muted">
230+ <section className="rounded-xl border border-line bg-surface p-4">
231+ <h3 className="text-sm font-medium">
161232 {attempt.status === "submitted" ? "Update summary" : "Submit"}
162233 </h3>
163− <Form method="post" className="mt-2 space-y-2">
234+ <Form method="post" className="mt-3 space-y-2">
164235 <Textarea
165236 name="summary"
166237 rows={4}
+7−2
22
33 import type { Route } from "./+types/blob";
44 import { BlobView } from "../../components/repo-view";
5+import { highlight } from "../../lib/highlight.server";
56 import { getViewer, unwrap } from "../../lib/session.server";
67
78 export function meta({ params }: Route.MetaArgs) {
1011
1112 export async function loader({ params, context }: Route.LoaderArgs) {
1213 const path = { namespace: params.owner, name: params.repo };
13− return unwrap(
14+ const blob = unwrap(
1415 await env.REPOS.blob(path, getViewer(context), params.ref, params["*"] ?? ""),
1516 );
17+ return {
18+ blob,
19+ html: blob.text == null ? null : await highlight(blob.path, blob.text),
20+ };
1621 }
1722
1823 export default function Blob({ loaderData }: Route.ComponentProps) {
19− return <BlobView blob={loaderData} />;
24+ return <BlobView blob={loaderData.blob} html={loaderData.html} />;
2025 }
+53−0
1+import { env } from "cloudflare:workers";
2+
3+import type { Route } from "./+types/commits";
4+import { Avatar, EmptyState, TimeAgo } from "../../components/ui";
5+import { getViewer, unwrap } from "../../lib/session.server";
6+
7+const PAGE_SIZE = 50;
8+
9+export function meta({ params }: Route.MetaArgs) {
10+ return [{ title: `Commits · ${params.owner}/${params.repo} · g1t` }];
11+}
12+
13+export async function loader({ params, context }: Route.LoaderArgs) {
14+ const path = { namespace: params.owner, name: params.repo };
15+ return {
16+ commits: unwrap(
17+ await env.REPOS.log(path, getViewer(context), null, PAGE_SIZE),
18+ ),
19+ };
20+}
21+
22+export default function Commits({ loaderData }: Route.ComponentProps) {
23+ const { commits } = loaderData;
24+ if (commits.length === 0) {
25+ return <EmptyState title="No commits yet" />;
26+ }
27+ return (
28+ <ol className="divide-y divide-line overflow-hidden rounded-xl border border-line">
29+ {commits.map((commit) => {
30+ const [subject, ...body] = commit.message.split("\n");
31+ return (
32+ <li key={commit.hash} className="flex items-start gap-3 px-4 py-3">
33+ <Avatar name={commit.author.name} size={24} />
34+ <div className="min-w-0 grow">
35+ <p className="truncate font-medium">{subject}</p>
36+ {body.join("\n").trim() && (
37+ <p className="mt-1 line-clamp-2 text-sm whitespace-pre-line text-muted">
38+ {body.join("\n").trim()}
39+ </p>
40+ )}
41+ <p className="mt-1 text-xs text-faint">
42+ {commit.author.name} committed <TimeAgo at={commit.authoredAt} />
43+ </p>
44+ </div>
45+ <span className="shrink-0 rounded-md border border-line px-2 py-0.5 font-mono text-xs text-muted">
46+ {commit.hash.slice(0, 7)}
47+ </span>
48+ </li>
49+ );
50+ })}
51+ </ol>
52+ );
53+}
+1−1
3131
3232 export default function NewIntent({ actionData }: Route.ComponentProps) {
3333 return (
34− <Form method="post" className="mt-6 max-w-2xl space-y-4">
34+ <Form method="post" className="max-w-2xl space-y-4">
3535 <Field label="Goal">
3636 <Input
3737 name="title"
+128−73
11 import { env } from "cloudflare:workers";
2+import { Bot, GitCommitHorizontal, Terminal } from "lucide-react";
23 import { Form, Link, redirect } from "react-router";
34
45 import type { Route } from "./+types/intent";
5−import { Button, ErrorText, Input, Status, TimeAgo } from "../../components/ui";
6+import { Markdown } from "../../components/markdown";
7+import {
8+ Avatar,
9+ Button,
10+ CopyLine,
11+ EmptyState,
12+ ErrorText,
13+ Input,
14+ Status,
15+ TimeAgo,
16+} from "../../components/ui";
617 import {
718 assertSameOrigin,
819 getViewer,
1021 unwrap,
1122 } from "../../lib/session.server";
1223
13−export function meta({ loaderData: data, params }: Route.MetaArgs) {
14− const title = data ? `${data.intent.title} · ` : "";
24+export function meta({ loaderData, params }: Route.MetaArgs) {
25+ const title = loaderData ? `${loaderData.intent.title} · ` : "";
1526 return [{ title: `${title}${params.owner}/${params.repo} · g1t` }];
1627 }
1728
3243 const user = requireUser(context, request);
3344 const form = await request.formData();
3445 const intentId = String(form.get("intentId") ?? "");
35− const base = `/${params.owner}/${params.repo}`;
3646
3747 if (form.get("action") === "withdraw") {
3848 const result = await env.WORK.withdrawIntent(user, intentId);
4353 runtime: "external",
4454 });
4555 if (!result.ok) return { error: result.error.message };
46− throw redirect(`${base}/attempts/${result.value.id}`);
56+ throw redirect(
57+ `/${params.owner}/${params.repo}/attempts/${result.value.id}`,
58+ );
4759 }
4860
4961 export default function IntentPage({
5567 const base = `/${params.owner}/${params.repo}`;
5668 const open = intent.status === "open";
5769 return (
58− <div className="mt-6 grid gap-8 lg:grid-cols-[1fr_20rem]">
70+ <div className="grid gap-8 lg:grid-cols-[1fr_19rem]">
5971 <div className="min-w-0">
6072 <div className="flex items-start gap-3">
61− <h2 className="grow text-2xl font-semibold tracking-tight">
73+ <h2 className="grow text-2xl font-semibold tracking-tight text-balance">
6274 {intent.title}{" "}
63− <span className="font-mono font-normal text-muted">
64− #{intent.number}
65− </span>
75+ <span className="font-normal text-faint">#{intent.number}</span>
6676 </h2>
6777 <Status value={intent.status} />
6878 </div>
69− <p className="mt-1 text-sm text-muted">
70− Opened by {intent.author.username} <TimeAgo at={intent.createdAt} />
79+ <p className="mt-2 flex items-center gap-2 text-sm text-muted">
80+ <Avatar name={intent.author.username} size={18} />
81+ <span>
82+ <span className="font-medium text-fg">{intent.author.username}</span>{" "}
83+ opened this <TimeAgo at={intent.createdAt} />
84+ </span>
7185 </p>
7286
7387 {intent.brief && (
74− <p className="mt-6 whitespace-pre-wrap leading-relaxed">
75− {intent.brief}
76− </p>
88+ <div className="mt-6 rounded-xl border border-line bg-surface p-5">
89+ <Markdown source={intent.brief} />
90+ </div>
7791 )}
7892
79− <h3 className="mt-10 text-sm font-medium text-muted">
80− Attempts ({attempts.length})
81− </h3>
82− {attempts.length === 0 ? (
83− <p className="mt-3 rounded-md border border-dashed border-line p-6 text-center text-sm text-muted">
84− No agent has attempted this yet.
93+ <div className="mt-10 flex items-baseline justify-between">
94+ <h3 className="font-semibold tracking-tight">Attempts</h3>
95+ <p className="text-sm text-muted">
96+ {attempts.length === 0
97+ ? "None yet"
98+ : `${attempts.length} in the arena`}
8599 </p>
86− ) : (
87− <ul className="mt-3 space-y-2">
88− {attempts.map((attempt) => (
89− <li key={attempt.id}>
90− <Link
91− to={`${base}/attempts/${attempt.id}`}
92− className="block rounded-md border border-line px-4 py-3 hover:border-muted"
93− >
94− <div className="flex items-center gap-3">
95− <span className="font-mono text-sm text-muted">
96− {attempt.number}
97− </span>
98− <span className="font-mono text-sm">{attempt.agent}</span>
99− <Status value={attempt.status} />
100− <span className="ml-auto font-mono text-xs text-muted">
101− {attempt.headCommit?.slice(0, 7) ?? "no commits"} ·{" "}
102− <TimeAgo at={attempt.updatedAt} />
103− </span>
104− </div>
105− {attempt.summary && (
106− <p className="mt-2 line-clamp-2 text-sm text-muted">
107− {attempt.summary}
108− </p>
109− )}
110− </Link>
111− </li>
112− ))}
113− </ul>
114− )}
100+ </div>
101+ <div className="mt-3">
102+ {attempts.length === 0 ? (
103+ <EmptyState title="No agent has attempted this yet">
104+ Start an attempt here, or point an agent at intent{" "}
105+ <code className="font-mono">{intent.id}</code>.
106+ </EmptyState>
107+ ) : (
108+ // Each attempt is a lane hanging off a shared rail.
109+ <ol className="relative space-y-3 pl-6 before:absolute before:top-2 before:bottom-2 before:left-1.75 before:w-px before:bg-line">
110+ {attempts.map((attempt) => (
111+ <li key={attempt.id} className="relative">
112+ <span
113+ className={`absolute top-5 -left-6 size-3.75 rounded-full border-2 border-bg ${
114+ attempt.status === "working"
115+ ? "bg-accent"
116+ : attempt.status === "submitted"
117+ ? "bg-info"
118+ : attempt.status === "shipped"
119+ ? "bg-shipped"
120+ : "bg-line-strong"
121+ }`}
122+ />
123+ <Link
124+ to={`${base}/attempts/${attempt.id}`}
125+ className="block rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong"
126+ >
127+ <div className="flex flex-wrap items-center gap-x-3 gap-y-1">
128+ <span className="flex items-center gap-1.5 font-mono text-sm font-medium">
129+ <Bot size={15} className="text-faint" />
130+ {attempt.agent}
131+ </span>
132+ <span className="text-sm text-faint">
133+ attempt {attempt.number}
134+ </span>
135+ <Status value={attempt.status} />
136+ <span className="ml-auto flex items-center gap-3 text-xs text-faint">
137+ <span className="flex items-center gap-1 font-mono">
138+ <GitCommitHorizontal size={13} />
139+ {attempt.headCommit?.slice(0, 7) ?? "no commits"}
140+ </span>
141+ <TimeAgo at={attempt.updatedAt} />
142+ </span>
143+ </div>
144+ {attempt.summary && (
145+ <p className="mt-2.5 line-clamp-2 text-sm text-muted">
146+ {attempt.summary}
147+ </p>
148+ )}
149+ </Link>
150+ </li>
151+ ))}
152+ </ol>
153+ )}
154+ </div>
115155 </div>
116156
117157 <aside className="space-y-6">
118158 {intent.checks.length > 0 && (
119159 <section>
120− <h3 className="text-sm font-medium text-muted">Acceptance checks</h3>
121− <ul className="mt-2 space-y-1 font-mono text-sm">
160+ <h3 className="text-sm font-medium">Acceptance checks</h3>
161+ <ul className="mt-2 space-y-1.5">
122162 {intent.checks.map((check) => (
123− <li key={check} className="rounded bg-surface px-2 py-1">
124− {check}
163+ <li
164+ key={check}
165+ className="flex items-center gap-2 rounded-md border border-line bg-surface px-2.5 py-1.5 font-mono text-xs"
166+ >
167+ <Terminal size={13} className="shrink-0 text-faint" />
168+ <span className="truncate">{check}</span>
125169 </li>
126170 ))}
127171 </ul>
128172 </section>
129173 )}
130− {open && viewer && (
131− <section>
132− <h3 className="text-sm font-medium text-muted">Start an attempt</h3>
133− <Form method="post" className="mt-2 space-y-2">
134− <input type="hidden" name="intentId" value={intent.id} />
135− <Input name="agent" placeholder="claude-code" maxLength={60} />
136− <Button type="submit">Start attempt</Button>
137− </Form>
138− <p className="mt-2 text-xs text-muted">
139− Creates a fork for the agent to push to.
174+
175+ {open && (
176+ <section className="rounded-xl border border-line bg-surface p-4">
177+ <h3 className="text-sm font-medium">Put an agent on it</h3>
178+ <p className="mt-1 text-xs text-muted">
179+ With g1t connected to your agent, give it this intent id.
140180 </p>
181+ <div className="mt-3">
182+ <CopyLine text={intent.id} />
183+ </div>
184+ {viewer ? (
185+ <Form method="post" className="mt-4 space-y-2 border-t border-line pt-4">
186+ <input type="hidden" name="intentId" value={intent.id} />
187+ <p className="text-xs text-muted">
188+ Or start an attempt yourself and get a fork to push to.
189+ </p>
190+ <Input name="agent" placeholder="Agent name, e.g. claude-code" maxLength={60} />
191+ <div className="*:w-full">
192+ <Button type="submit">Start attempt</Button>
193+ </div>
194+ </Form>
195+ ) : (
196+ <p className="mt-4 border-t border-line pt-4 text-sm text-muted">
197+ <Link to="/login" className="text-fg underline underline-offset-4">
198+ Sign in
199+ </Link>{" "}
200+ to start an attempt.
201+ </p>
202+ )}
203+ <ErrorText>{actionData?.error}</ErrorText>
141204 </section>
142205 )}
143− {open && !viewer && (
144− <p className="text-sm text-muted">
145− <Link to="/login" className="text-fg underline">
146− Sign in
147− </Link>{" "}
148− to start an attempt.
149− </p>
150− )}
206+
151207 {open && viewer?.id === intent.author.id && (
152208 <Form method="post">
153209 <input type="hidden" name="intentId" value={intent.id} />
157213 </Button>
158214 </Form>
159215 )}
160− <ErrorText>{actionData?.error}</ErrorText>
161216 </aside>
162217 </div>
163218 );
+62−38
11 import { env } from "cloudflare:workers";
2+import { CircleDot, GitMerge, Plus, XCircle } from "lucide-react";
23 import { Link } from "react-router";
34
5+import type { Intent } from "@g1t/contracts";
6+
47 import type { Route } from "./+types/intents";
5−import { Status, TimeAgo } from "../../components/ui";
8+import { ButtonLink, EmptyState, TimeAgo } from "../../components/ui";
69 import { getViewer, unwrap } from "../../lib/session.server";
710
11+export function meta({ params }: Route.MetaArgs) {
12+ return [{ title: `Intents · ${params.owner}/${params.repo} · g1t` }];
13+}
14+
815 export async function loader({ params, context }: Route.LoaderArgs) {
916 const path = { namespace: params.owner, name: params.repo };
1017 return {
1219 };
1320 }
1421
22+function StatusIcon({ status }: { status: Intent["status"] }) {
23+ if (status === "shipped") return <GitMerge size={16} className="text-shipped" />;
24+ if (status === "withdrawn") return <XCircle size={16} className="text-faint" />;
25+ return <CircleDot size={16} className="text-accent" />;
26+}
27+
1528 export default function Intents({ loaderData, params }: Route.ComponentProps) {
1629 const { intents } = loaderData;
1730 const base = `/${params.owner}/${params.repo}/intents`;
31+ const open = intents.filter((intent) => intent.status === "open").length;
1832 return (
19− <div className="mt-6">
20− <div className="flex items-center justify-between">
33+ <div>
34+ <div className="flex items-center justify-between gap-4">
2135 <p className="text-sm text-muted">
22− An intent is a goal for this repo. Agents attempt it in parallel and
23− the best attempt ships.
36+ <span className="font-medium text-fg">{open} open</span>
37+ <span className="mx-2 text-faint">·</span>
38+ {intents.length - open} closed
2439 </p>
25− <Link
26− to={`${base}/new`}
27− className="shrink-0 rounded-md bg-fg px-3 py-2 text-sm font-medium text-bg hover:bg-white"
28− >
40+ <ButtonLink to={`${base}/new`}>
41+ <Plus size={15} />
2942 New intent
30− </Link>
43+ </ButtonLink>
3144 </div>
32− {intents.length === 0 ? (
33− <p className="mt-10 text-center text-muted">No intents yet.</p>
34− ) : (
35− <ul className="mt-4 divide-y divide-line rounded-md border border-line">
36− {intents.map((intent) => (
37− <li key={intent.id}>
38− <Link
39− to={`${base}/${intent.number}`}
40− className="flex items-center gap-4 px-4 py-3 hover:bg-surface"
41− >
42− <span className="w-10 font-mono text-sm text-muted">
43− #{intent.number}
44− </span>
45− <span className="min-w-0 grow">
46− <span className="block truncate">{intent.title}</span>
47− <span className="text-xs text-muted">
48− {intent.author.username} · <TimeAgo at={intent.createdAt} />
45+ <div className="mt-4">
46+ {intents.length === 0 ? (
47+ <EmptyState title="No intents yet">
48+ An intent is a goal for this repository. Agents attempt it in
49+ parallel, each in its own fork.
50+ </EmptyState>
51+ ) : (
52+ <ul className="divide-y divide-line overflow-hidden rounded-xl border border-line">
53+ {intents.map((intent) => (
54+ <li key={intent.id}>
55+ <Link
56+ to={`${base}/${intent.number}`}
57+ className="flex items-center gap-3 px-4 py-3 transition-colors hover:bg-surface"
58+ >
59+ <StatusIcon status={intent.status} />
60+ <span className="min-w-0 grow">
61+ <span className="block truncate font-medium">
62+ {intent.title}
63+ </span>
64+ <span className="text-xs text-faint">
65+ #{intent.number} opened <TimeAgo at={intent.createdAt} /> by{" "}
66+ {intent.author.username}
67+ </span>
68+ </span>
69+ {/* One dot per attempt: the lanes at a glance. */}
70+ <span className="flex shrink-0 items-center gap-1" aria-hidden="true">
71+ {Array.from({ length: Math.min(intent.attemptCount, 12) }, (_, i) => (
72+ <span key={i} className="size-1.5 rounded-full bg-accent-dim" />
73+ ))}
74+ </span>
75+ <span className="w-20 shrink-0 text-right text-xs text-muted">
76+ {intent.attemptCount}{" "}
77+ {intent.attemptCount === 1 ? "attempt" : "attempts"}
4978 </span>
50− </span>
51− <span className="shrink-0 text-xs text-muted">
52− {intent.attemptCount}{" "}
53− {intent.attemptCount === 1 ? "attempt" : "attempts"}
54− </span>
55− <Status value={intent.status} />
56− </Link>
57− </li>
58− ))}
59− </ul>
60− )}
79+ </Link>
80+ </li>
81+ ))}
82+ </ul>
83+ )}
84+ </div>
6185 </div>
6286 );
6387 }
+64−34
11 import { env } from "cloudflare:workers";
2+import { BookMarked, Code2, History, Lock, Target } from "lucide-react";
3+import type { ReactNode } from "react";
24 import { Link, NavLink, Outlet } from "react-router";
35
46 import type { Route } from "./+types/layout";
7+import { Pill } from "../../components/ui";
58 import { getViewer, unwrap } from "../../lib/session.server";
69
710 export function meta({ params }: Route.MetaArgs) {
2124 };
2225 }
2326
24−function Tab({ to, end, children }: { to: string; end?: boolean; children: React.ReactNode }) {
27+function Tab({
28+ to,
29+ end,
30+ icon,
31+ count,
32+ children,
33+}: {
34+ to: string;
35+ end?: boolean;
36+ icon: ReactNode;
37+ count?: number;
38+ children: ReactNode;
39+}) {
2540 return (
2641 <NavLink
2742 to={to}
2843 end={end}
2944 className={({ isActive }) =>
30− `-mb-px border-b-2 px-1 pb-2.5 text-sm ${
45+ `-mb-px flex items-center gap-2 border-b-2 px-1 pb-3 text-sm transition-colors ${
3146 isActive
32− ? "border-accent text-fg"
47+ ? "border-accent font-medium text-fg"
3348 : "border-transparent text-muted hover:text-fg"
3449 }`
3550 }
3651 >
52+ {icon}
3753 {children}
54+ {count != null && count > 0 && (
55+ <span className="rounded-full bg-raised px-1.5 py-px text-xs text-muted">
56+ {count}
57+ </span>
58+ )}
3859 </NavLink>
3960 );
4061 }
4364 const { repo, openIntents } = loaderData;
4465 const base = `/${repo.namespace}/${repo.name}`;
4566 return (
46− <div className="mx-auto max-w-5xl px-4 py-8">
47− <div className="flex items-center gap-3">
48− <h1 className="font-mono text-xl">
49− <span className="text-muted">{repo.namespace}/</span>
50− <Link to={base} className="font-semibold">
51− {repo.name}
52− </Link>
53− </h1>
54− {repo.isPrivate && (
55− <span className="rounded-full border border-line px-2 py-0.5 text-xs text-muted">
56− private
57− </span>
58− )}
59− </div>
60− {repo.description && (
61− <p className="mt-2 text-sm text-muted">{repo.description}</p>
62− )}
63− <nav className="mt-6 flex gap-6 border-b border-line">
64− <Tab to={base} end>
65− Code
66− </Tab>
67− <Tab to={`${base}/intents`}>
68− Intents
69− {openIntents > 0 && (
70− <span className="ml-1.5 rounded-full bg-surface px-1.5 py-0.5 text-xs">
71− {openIntents}
72− </span>
67+ <>
68+ {/* The repository's own header band, under the site header. */}
69+ <div className="border-b border-line bg-surface/60">
70+ <div className="mx-auto max-w-6xl px-4 pt-6">
71+ <div className="flex flex-wrap items-center gap-2.5">
72+ {repo.isPrivate ? (
73+ <Lock size={17} className="text-faint" />
74+ ) : (
75+ <BookMarked size={17} className="text-faint" />
76+ )}
77+ <h1 className="font-mono text-lg">
78+ <Link to={`/${repo.namespace}`} className="text-muted hover:text-fg">
79+ {repo.namespace}
80+ </Link>
81+ <span className="mx-1 text-faint">/</span>
82+ <Link to={base} className="font-semibold hover:underline">
83+ {repo.name}
84+ </Link>
85+ </h1>
86+ <Pill>{repo.isPrivate ? "private" : "public"}</Pill>
87+ </div>
88+ {repo.description && (
89+ <p className="mt-2 max-w-2xl text-sm text-muted">{repo.description}</p>
7390 )}
74− </Tab>
75− </nav>
76− <Outlet />
77− </div>
91+ <nav className="mt-5 flex gap-6">
92+ <Tab to={base} end icon={<Code2 size={15} />}>
93+ Code
94+ </Tab>
95+ <Tab to={`${base}/intents`} icon={<Target size={15} />} count={openIntents}>
96+ Intents
97+ </Tab>
98+ <Tab to={`${base}/commits`} icon={<History size={15} />}>
99+ Commits
100+ </Tab>
101+ </nav>
102+ </div>
103+ </div>
104+ <div className="mx-auto max-w-6xl px-4 py-6">
105+ <Outlet />
106+ </div>
107+ </>
78108 );
79109 }
+7−7
1−import { env } from "cloudflare:workers";
1+import { identity } from "../lib/services.server";
22 import { Form } from "react-router";
33
44 import type { Route } from "./+types/settings";
1212 export async function loader({ request, context }: Route.LoaderArgs) {
1313 const user = requireUser(context, request);
1414 const [keys, tokens] = await Promise.all([
15− env.IDENTITY.listSshKeys(user),
16− env.IDENTITY.listAccessTokens(user),
15+ identity.listSshKeys(user),
16+ identity.listAccessTokens(user),
1717 ]);
1818 return { user, keys, tokens };
1919 }
2626
2727 switch (form.get("intent")) {
2828 case "add-key": {
29− const result = await env.IDENTITY.addSshKey(
29+ const result = await identity.addSshKey(
3030 user,
3131 String(form.get("title") ?? ""),
3232 String(form.get("key") ?? ""),
3434 return result.ok ? null : { keyError: result.error.message };
3535 }
3636 case "delete-key":
37− await env.IDENTITY.removeSshKey(user, id);
37+ await identity.removeSshKey(user, id);
3838 return null;
3939 case "add-token": {
40− const created = await env.IDENTITY.createAccessToken(
40+ const created = await identity.createAccessToken(
4141 user,
4242 String(form.get("name") ?? ""),
4343 );
4444 return { newToken: created.token };
4545 }
4646 case "delete-token":
47− await env.IDENTITY.removeAccessToken(user, id);
47+ await identity.removeAccessToken(user, id);
4848 return null;
4949 }
5050 return null;
+5−1
1212 "postinstall": "wrangler types --include-env=false"
1313 },
1414 "dependencies": {
15+ "@g1t/contracts": "*",
1516 "isbot": "^5.1.36",
17+ "lucide-react": "^1.49.0",
1618 "react": "^19.2.8",
1719 "react-dom": "^19.2.8",
20+ "react-markdown": "^10.1.0",
1821 "react-router": "^8.4.0",
19− "@g1t/contracts": "*"
22+ "remark-gfm": "^4.0.1",
23+ "shiki": "^4.5.0"
2024 },
2125 "devDependencies": {
2226 "@cloudflare/vite-plugin": "^1.62.4",
+2−2
1−import type { EventsApi, IdentityApi, ReposApi, WorkApi } from "@g1t/contracts";
1+import type { EventsApi, ReposApi, ServiceBinding, WorkApi } from "@g1t/contracts";
22
33 declare global {
44 namespace Cloudflare {
55 interface Env {
6− IDENTITY: IdentityApi;
6+ IDENTITY: ServiceBinding;
77 /** Also serves git over HTTPS through `fetch`. */
88 REPOS: ReposApi & { fetch(request: Request): Promise<Response> };
99 WORK: WorkApi;
+11−0
1+[package]
2+name = "g1t-contracts"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "Types and service interfaces shared by every g1t service."
7+
8+[dependencies]
9+serde.workspace = true
10+serde_json.workspace = true
11+getrandom = { version = "0.2", features = ["js"] }
+119−0
1+//! The identity service: accounts, credentials and sessions.
2+//!
3+//! Each `*Args` struct is the argument of the method of the same name,
4+//! served at `POST /rpc/<method>`.
5+
6+use serde::{Deserialize, Serialize};
7+
8+use crate::User;
9+
10+#[derive(Clone, Debug, Serialize, Deserialize)]
11+#[serde(rename_all = "camelCase")]
12+pub struct SshKey {
13+ pub id: String,
14+ pub title: String,
15+ pub fingerprint: String,
16+ /// Milliseconds since the epoch.
17+ pub created_at: u64,
18+}
19+
20+#[derive(Clone, Debug, Serialize, Deserialize)]
21+#[serde(rename_all = "camelCase")]
22+pub struct AccessToken {
23+ pub id: String,
24+ pub name: String,
25+ pub created_at: u64,
26+}
27+
28+/// `sign_in`: verifies a username and password for website sign-in.
29+/// Returns `Outcome<SignedIn>`.
30+#[derive(Debug, Serialize, Deserialize)]
31+pub struct SignInArgs {
32+ pub username: String,
33+ pub password: String,
34+}
35+
36+#[derive(Debug, Serialize, Deserialize)]
37+#[serde(rename_all = "camelCase")]
38+pub struct SignedIn {
39+ pub user: User,
40+ pub session_token: String,
41+}
42+
43+/// `sign_out` and `user_for_session`.
44+#[derive(Debug, Serialize, Deserialize)]
45+#[serde(rename_all = "camelCase")]
46+pub struct SessionArgs {
47+ pub session_token: String,
48+}
49+
50+/// `user_for_git_credentials`: the account password or an access token.
51+#[derive(Debug, Serialize, Deserialize)]
52+pub struct GitCredentialsArgs {
53+ pub username: String,
54+ pub secret: String,
55+}
56+
57+/// `user_for_access_token`.
58+#[derive(Debug, Serialize, Deserialize)]
59+pub struct TokenArgs {
60+ pub token: String,
61+}
62+
63+/// `user_for_ssh_key`.
64+#[derive(Debug, Serialize, Deserialize)]
65+pub struct FingerprintArgs {
66+ pub fingerprint: String,
67+}
68+
69+/// `user_by_username`.
70+#[derive(Debug, Serialize, Deserialize)]
71+pub struct UsernameArgs {
72+ pub username: String,
73+}
74+
75+/// `list_ssh_keys` and `list_access_tokens`.
76+#[derive(Debug, Serialize, Deserialize)]
77+pub struct UserArgs {
78+ pub user: User,
79+}
80+
81+/// `add_ssh_key`: `public_key` is one line in OpenSSH format.
82+/// Returns `Outcome<SshKey>`.
83+#[derive(Debug, Serialize, Deserialize)]
84+#[serde(rename_all = "camelCase")]
85+pub struct AddSshKeyArgs {
86+ pub user: User,
87+ pub title: String,
88+ pub public_key: String,
89+}
90+
91+/// `remove_ssh_key` and `remove_access_token`.
92+#[derive(Debug, Serialize, Deserialize)]
93+pub struct RemoveArgs {
94+ pub user: User,
95+ pub id: String,
96+}
97+
98+/// `create_access_token`.
99+#[derive(Debug, Serialize, Deserialize)]
100+pub struct CreateAccessTokenArgs {
101+ pub user: User,
102+ pub name: String,
103+}
104+
105+/// The plaintext token is returned once and never stored.
106+#[derive(Debug, Serialize, Deserialize)]
107+pub struct CreatedAccessToken {
108+ pub token: String,
109+ pub info: AccessToken,
110+}
111+
112+/// `register`: creates an account and signs it in.
113+/// Returns `Outcome<SignedIn>`.
114+#[derive(Debug, Serialize, Deserialize)]
115+pub struct RegisterArgs {
116+ pub username: String,
117+ pub email: String,
118+ pub password: String,
119+}
+79−0
1+use std::cell::Cell;
2+
3+const ALPHABET: &[u8; 32] = b"0123456789abcdefghjkmnpqrstvwxyz";
4+
5+thread_local! {
6+ /// The millisecond and counter of the last id made, to keep ids made in
7+ /// the same millisecond in order.
8+ static LAST: Cell<(u64, u16)> = const { Cell::new((0, 0)) };
9+}
10+
11+/// A new id in [TypeID](https://github.com/jetify-com/typeid) format: a type
12+/// prefix, then a UUIDv7 in lowercase Crockford base32, such as
13+/// `att_01jb2k7x9hfq0b3zj0f5s2m8ra`.
14+///
15+/// - The prefix says what the id refers to, so ids cannot be mixed up.
16+/// - Sorting ids as strings sorts them by creation time, which also keeps
17+/// inserts at the end of the primary-key index.
18+/// - The suffix decodes to a standard UUIDv7 for systems that want one.
19+///
20+/// Ids made in the same millisecond by one process increase monotonically:
21+/// the UUID's 12-bit `rand_a` field is used as a counter.
22+pub fn new_id(prefix: &str, now_ms: u64) -> String {
23+ let mut bytes = [0u8; 16];
24+ getrandom::getrandom(&mut bytes).expect("no source of randomness");
25+
26+ let counter = LAST.with(|last| {
27+ let (last_ms, last_counter) = last.get();
28+ let counter = if now_ms == last_ms {
29+ last_counter.wrapping_add(1) & 0x0fff
30+ } else {
31+ // Start in the lower half so there is room to count up.
32+ u16::from_be_bytes([bytes[6], bytes[7]]) & 0x07ff
33+ };
34+ last.set((now_ms, counter));
35+ counter
36+ });
37+
38+ bytes[..6].copy_from_slice(&now_ms.to_be_bytes()[2..]);
39+ bytes[6] = 0x70 | (counter >> 8) as u8; // version 7
40+ bytes[7] = counter as u8;
41+ bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant
42+
43+ let value = u128::from_be_bytes(bytes);
44+ let mut id = String::with_capacity(prefix.len() + 27);
45+ id.push_str(prefix);
46+ id.push('_');
47+ // 128 bits in 26 characters of 5 bits; the first carries only 3.
48+ for index in 0..26 {
49+ let shift = 125 - 5 * index;
50+ id.push(ALPHABET[((value >> shift) & 31) as usize] as char);
51+ }
52+ id
53+}
54+
55+#[cfg(test)]
56+mod tests {
57+ use super::*;
58+
59+ #[test]
60+ fn has_prefix_and_26_character_suffix() {
61+ let id = new_id("att", 1_790_000_000_000);
62+ let (prefix, suffix) = id.split_once('_').unwrap();
63+ assert_eq!(prefix, "att");
64+ assert_eq!(suffix.len(), 26);
65+ assert!(suffix.bytes().all(|byte| ALPHABET.contains(&byte)));
66+ // A 128-bit value never needs more than 3 bits in the first character.
67+ assert!(suffix.as_bytes()[0] <= b'7');
68+ }
69+
70+ #[test]
71+ fn sorts_by_time_then_by_order_made() {
72+ let earlier = new_id("evt", 1_790_000_000_000);
73+ let later = new_id("evt", 1_790_000_000_001);
74+ assert!(earlier < later);
75+
76+ let same_ms: Vec<String> = (0..100).map(|_| new_id("evt", 1_790_000_000_002)).collect();
77+ assert!(same_ms.windows(2).all(|pair| pair[0] < pair[1]));
78+ }
79+}
+25−0
1+//! Types and service interfaces shared by every g1t service.
2+//!
3+//! Each service has a module here holding the data it exchanges and the
4+//! arguments of each of its methods. Services and their callers depend on
5+//! this crate, never on each other's code.
6+
7+pub mod identity;
8+mod ids;
9+mod names;
10+mod outcome;
11+
12+pub use ids::new_id;
13+pub use names::{is_valid_namespace, is_valid_repo_name};
14+pub use outcome::{Failure, FailureCode, Outcome};
15+
16+use serde::{Deserialize, Serialize};
17+
18+#[derive(Clone, Debug, Serialize, Deserialize)]
19+pub struct User {
20+ pub id: String,
21+ pub username: String,
22+}
23+
24+/// Who is asking. Every read and write in every service takes one.
25+pub type Viewer = Option<User>;
+31−0
1+/// Routes and reserved words that may not be registered as usernames.
2+const RESERVED: &[&str] = &[
3+ "api", "mcp", "login", "logout", "register", "new", "settings", "search", "admin", "auth",
4+ "attempts", "oauth", "assets", "docs", "explore", "g1t", "about", "pricing", "terms",
5+ "privacy", "help", "support", "status", "blog",
6+];
7+
8+/// Namespaces follow GitHub's rules: letters, digits and single hyphens,
9+/// not starting or ending with a hyphen, at most 39 characters.
10+pub fn is_valid_namespace(value: &str) -> bool {
11+ let bytes = value.as_bytes();
12+ !bytes.is_empty()
13+ && bytes.len() <= 39
14+ && bytes
15+ .iter()
16+ .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || *byte == b'-')
17+ && !value.starts_with('-')
18+ && !value.ends_with('-')
19+ && !value.contains("--")
20+ && !RESERVED.contains(&value)
21+}
22+
23+pub fn is_valid_repo_name(value: &str) -> bool {
24+ !value.is_empty()
25+ && value.len() <= 100
26+ && value.bytes().all(|byte| {
27+ byte.is_ascii_lowercase() || byte.is_ascii_digit() || matches!(byte, b'.' | b'_' | b'-')
28+ })
29+ && !value.starts_with('.')
30+ && !value.ends_with(".git")
31+}
+95−0
1+use serde::de::DeserializeOwned;
2+use serde::{Deserialize, Deserializer, Serialize, Serializer};
3+
4+#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
5+#[serde(rename_all = "snake_case")]
6+pub enum FailureCode {
7+ NotFound,
8+ Forbidden,
9+ Unauthenticated,
10+ Conflict,
11+ Invalid,
12+}
13+
14+impl FailureCode {
15+ pub fn http_status(self) -> u16 {
16+ match self {
17+ FailureCode::NotFound => 404,
18+ FailureCode::Forbidden => 403,
19+ FailureCode::Unauthenticated => 401,
20+ FailureCode::Conflict => 409,
21+ FailureCode::Invalid => 422,
22+ }
23+ }
24+}
25+
26+#[derive(Clone, Debug, Serialize, Deserialize)]
27+pub struct Failure {
28+ pub code: FailureCode,
29+ pub message: String,
30+}
31+
32+/// The result of a service call. Expected failures cross service boundaries
33+/// as values, so callers have to handle them.
34+///
35+/// On the wire: `{"ok": true, "value": …}` or `{"ok": false, "error": …}`.
36+#[derive(Clone, Debug)]
37+pub enum Outcome<T> {
38+ Ok(T),
39+ Fail(Failure),
40+}
41+
42+impl<T> Outcome<T> {
43+ pub fn fail(code: FailureCode, message: impl Into<String>) -> Self {
44+ Outcome::Fail(Failure {
45+ code,
46+ message: message.into(),
47+ })
48+ }
49+
50+ pub fn into_result(self) -> Result<T, Failure> {
51+ match self {
52+ Outcome::Ok(value) => Ok(value),
53+ Outcome::Fail(failure) => Err(failure),
54+ }
55+ }
56+}
57+
58+#[derive(Serialize, Deserialize)]
59+#[serde(bound(serialize = "T: Serialize", deserialize = "T: DeserializeOwned"))]
60+struct Wire<T> {
61+ ok: bool,
62+ #[serde(default, skip_serializing_if = "Option::is_none")]
63+ value: Option<T>,
64+ #[serde(default, skip_serializing_if = "Option::is_none")]
65+ error: Option<Failure>,
66+}
67+
68+impl<T: Serialize> Serialize for Outcome<T> {
69+ fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
70+ match self {
71+ Outcome::Ok(value) => Wire {
72+ ok: true,
73+ value: Some(value),
74+ error: None,
75+ },
76+ Outcome::Fail(failure) => Wire {
77+ ok: false,
78+ value: None,
79+ error: Some(failure.clone()),
80+ },
81+ }
82+ .serialize(serializer)
83+ }
84+}
85+
86+impl<'de, T: DeserializeOwned> Deserialize<'de> for Outcome<T> {
87+ fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
88+ let wire = Wire::<T>::deserialize(deserializer)?;
89+ match (wire.ok, wire.value, wire.error) {
90+ (true, Some(value), _) => Ok(Outcome::Ok(value)),
91+ (false, _, Some(error)) => Ok(Outcome::Fail(error)),
92+ _ => Err(serde::de::Error::custom("malformed outcome")),
93+ }
94+ }
95+}
+12−0
1+[package]
2+name = "g1t-kit"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "Plumbing shared by g1t services that run on Workers."
7+
8+[dependencies]
9+g1t-contracts.workspace = true
10+serde.workspace = true
11+serde_json.workspace = true
12+worker.workspace = true
+61−0
1+//! Plumbing shared by g1t services that run on Workers.
2+//!
3+//! Services talk to each other over service bindings with a small JSON
4+//! protocol: `POST /rpc/<method>` with the method's arguments as the body,
5+//! answered with the method's return value.
6+
7+use serde::Serialize;
8+use serde::de::DeserializeOwned;
9+use worker::{Date, Fetcher, Headers, Method, Request, RequestInit, Response, Result};
10+
11+/// The current time in milliseconds since the epoch.
12+pub fn now_ms() -> u64 {
13+ Date::now().as_millis()
14+}
15+
16+/// The method name of an RPC request, or `None` if it is not one.
17+pub fn rpc_method(request: &Request) -> Option<String> {
18+ if request.method() != Method::Post {
19+ return None;
20+ }
21+ request
22+ .path()
23+ .strip_prefix("/rpc/")
24+ .map(|method| method.to_owned())
25+}
26+
27+/// Deserializes a method's arguments.
28+pub fn args<A: DeserializeOwned>(body: serde_json::Value) -> Result<A> {
29+ serde_json::from_value(body)
30+ .map_err(|error| worker::Error::RustError(format!("bad arguments: {error}")))
31+}
32+
33+/// Serializes a method's return value as the response body.
34+pub fn reply<R: Serialize>(value: &R) -> Result<Response> {
35+ Response::from_json(value)
36+}
37+
38+/// Calls `method` on another service through its binding.
39+pub async fn call<A: Serialize, R: DeserializeOwned>(
40+ service: &Fetcher,
41+ method: &str,
42+ arguments: &A,
43+) -> Result<R> {
44+ let headers = Headers::new();
45+ headers.set("content-type", "application/json")?;
46+ let mut init = RequestInit::new();
47+ init.with_method(Method::Post)
48+ .with_headers(headers)
49+ .with_body(Some(serde_json::to_string(arguments)?.into()));
50+ // The hostname is ignored; a service binding always reaches its service.
51+ let request = Request::new_with_init(&format!("https://service/rpc/{method}"), &init)?;
52+ let mut response = service.fetch_request(request).await?;
53+ if response.status_code() != 200 {
54+ return Err(worker::Error::RustError(format!(
55+ "{method} failed with status {}: {}",
56+ response.status_code(),
57+ response.text().await.unwrap_or_default()
58+ )));
59+ }
60+ response.json().await
61+}
+9−5
6666 async fn advertise<W: AsyncWrite + Unpin>(&self, writer: &mut W) -> Result<()> {
6767 let service = self.service.as_str();
6868 let response = self
69− .request(reqwest::Method::GET, &format!("info/refs?service={service}"))
69+ .request(
70+ reqwest::Method::GET,
71+ &format!("info/refs?service={service}"),
72+ )
7073 .send()
7174 .await?
7275 .error_for_status()?;
7376 let body = response.bytes().await?;
7477 let mut advertisement = &body[..];
75− if advertisement.get(4..).is_some_and(|rest| rest.starts_with(b"# service=")) {
78+ if advertisement
79+ .get(4..)
80+ .is_some_and(|rest| rest.starts_with(b"# service="))
81+ {
7682 let length = usize::from_str_radix(std::str::from_utf8(&body[..4])?, 16)?;
7783 ensure!(body.len() >= length + 4, "truncated ref advertisement");
7884 advertisement = &body[length + 4..];
232238 byte = read_byte(reader, raw).await?;
233239 }
234240 match kind {
235− OFS_DELTA => {
236− while read_byte(reader, raw).await? & 0x80 != 0 {}
237− }
241+ OFS_DELTA => while read_byte(reader, raw).await? & 0x80 != 0 {},
238242 REF_DELTA => read_into(reader, raw, 20).await?,
239243 _ => {}
240244 }
+3−2
1414
1515 use anyhow::{Context, Result};
1616 use futures_util::{SinkExt, StreamExt};
17+use russh::keys::PrivateKey;
1718 use russh::keys::ssh_key::{HashAlg, PublicKey};
18−use russh::keys::PrivateKey;
1919 use russh::server::{Auth, Config, Handler, Msg, Session};
2020 use russh::{Channel, ChannelId, MethodKind, MethodSet};
2121 use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader};
217217 let (mut read_half, write_half) = channel.split();
218218 let mut reader = BufReader::new(read_half.make_reader());
219219 let mut writer = write_half.make_writer();
220− let result = run_git(&api, &user, &command, protocol_v2, &mut reader, &mut writer).await;
220+ let result =
221+ run_git(&api, &user, &command, protocol_v2, &mut reader, &mut writer).await;
221222 let status = match result {
222223 Ok(()) => 0,
223224 Err(error) => {
+46−0
508508 stays in TypeScript. Rust is used where there is real computation or a real
509509 protocol to implement.
510510
511+## Languages
512+
513+The site is TypeScript. Everything behind it is Rust, compiled to
514+WebAssembly for Workers and natively for containers and the CLI. Services
515+are being ported one at a time; identity is done. Rust services speak a
516+small JSON protocol over service bindings (`POST /rpc/<method>`), with the
517+types in `crates/contracts`.
518+
519+## Identifiers
520+
521+Every id is a [TypeID](https://github.com/jetify-com/typeid): a prefix naming
522+the kind of thing, then a UUIDv7 in lowercase base32, such as
523+`att_01jb2k7x9hfq0b3zj0f5s2m8ra`.
524+
525+- The prefix makes an id self-describing and stops ids of different kinds
526+ being mixed up.
527+- Ids sort by creation time as plain strings. In SQLite (D1 and Durable
528+ Objects) that keeps inserts at the end of the primary-key index instead of
529+ scattering them, and gives time-ordered paging for free.
530+- The suffix decodes to a standard UUIDv7 for any system that wants one.
531+- Ids are made by the service that creates the record, not by the database,
532+ so they work across services and can be assigned before a write.
533+
534+## Events at scale, and audit
535+
536+The current event log is a single D1 database. That is fine for a
537+prototype and wrong for the target: D1 is one writer and 10 GB. The design
538+for volume splits storage by how the data is read.
539+
540+| Tier | Store | Holds | Read by |
541+| --- | --- | --- | --- |
542+| Hot | A Durable Object per repository, with SQLite | Recent events for that repo | Timelines, live pages over WebSocket |
543+| Complete | Cloudflare Pipelines into R2 as Apache Iceberg | Every event, forever, partitioned by day and workspace | Analytics, standups, "ask", export |
544+| Audit | The same R2 store, under object lock | Who did what, from where, with which credential | Compliance, investigation |
545+
546+- **No single hot database.** Each repository's recent events live with that
547+ repository, so load spreads across as many objects as there are repos.
548+- **The complete record is files, not rows.** Iceberg on R2 has no practical
549+ size limit and is queried with SQL.
550+- **Audit is a property of every event.** The envelope carries the actor
551+ (person, agent, token or system), the credential used, the request id and
552+ the source address. Audit entries for a workspace are hash-chained, so a
553+ removed or altered entry is detectable, and are written under a retention
554+ lock.
555+- **Delivery is at least once.** Consumers are idempotent on the event id.
556+
511557 ## Accounts and forge basics
512558
513559 - Registration with email verification, sign-in, forgot password (Cloudflare
+1731−17
1515 "wrangler": "^4.146.0"
1616 }
1717 },
18+ "apps/api": {
19+ "name": "@g1t/api",
20+ "version": "0.1.0",
21+ "license": "MIT",
22+ "dependencies": {
23+ "@g1t/contracts": "*",
24+ "hono": "^4"
25+ }
26+ },
1827 "apps/web": {
1928 "name": "@g1t/web",
2029 "hasInstallScript": true,
2231 "dependencies": {
2332 "@g1t/contracts": "*",
2433 "isbot": "^5.1.36",
34+ "lucide-react": "^1.49.0",
2535 "react": "^19.2.8",
2636 "react-dom": "^19.2.8",
27− "react-router": "^8.4.0"
37+ "react-markdown": "^10.1.0",
38+ "react-router": "^8.4.0",
39+ "remark-gfm": "^4.0.1",
40+ "shiki": "^4.5.0"
2841 },
2942 "devDependencies": {
3043 "@cloudflare/vite-plugin": "^1.62.4",
909922 "node": ">=18"
910923 }
911924 },
925+ "node_modules/@g1t/api": {
926+ "resolved": "apps/api",
927+ "link": true
928+ },
912929 "node_modules/@g1t/contracts": {
913930 "resolved": "packages/contracts",
914931 "link": true
915932 },
916933 "node_modules/@g1t/events": {
917934 "resolved": "services/events",
918− "link": true
919− },
920− "node_modules/@g1t/identity": {
921− "resolved": "services/identity",
922935 "link": true
923936 },
924937 "node_modules/@g1t/repos": {
19982011 "dev": true,
19992012 "license": "MIT"
20002013 },
2014+ "node_modules/@shikijs/core": {
2015+ "version": "4.5.0",
2016+ "resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.5.0.tgz",
2017+ "integrity": "sha512-6CIgwq+bt2JZ3OSjb/nBkkwEf/CfpHcbam+Mg5Pllco7HAGRQ2/EFhZrrJtbyNTgKwknfVS+llsXGygiDGwJWw==",
2018+ "license": "MIT",
2019+ "dependencies": {
2020+ "@shikijs/primitive": "4.5.0",
2021+ "@shikijs/types": "4.5.0",
2022+ "@shikijs/vscode-textmate": "^10.0.2",
2023+ "@types/hast": "^3.0.5",
2024+ "hast-util-to-html": "^9.0.5"
2025+ },
2026+ "engines": {
2027+ "node": ">=20"
2028+ }
2029+ },
2030+ "node_modules/@shikijs/engine-javascript": {
2031+ "version": "4.5.0",
2032+ "resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.5.0.tgz",
2033+ "integrity": "sha512-Ed6UdEj84LyYkwnxM5CvSMWAD+id9ExTYri0/+x41U9zrgNifPSS9lZm0JqBP0VfYI8yr8tY5nbNq9+p9R0kFA==",
2034+ "license": "MIT",
2035+ "dependencies": {
2036+ "@shikijs/types": "4.5.0",
2037+ "@shikijs/vscode-textmate": "^10.0.2",
2038+ "oniguruma-to-es": "^4.3.6"
2039+ },
2040+ "engines": {
2041+ "node": ">=20"
2042+ }
2043+ },
2044+ "node_modules/@shikijs/engine-oniguruma": {
2045+ "version": "4.5.0",
2046+ "resolved": "https://registry.npmjs.org/@shikijs/engine-oniguruma/-/engine-oniguruma-4.5.0.tgz",
2047+ "integrity": "sha512-dKVmbJecVdB9recl8a/CU4jdpNXyjJQrIcesIkpS70s5HS02ohpnCSpMUbotPnEUzbAcLQ+S4hwZYbkBBiTcJw==",
2048+ "license": "MIT",
2049+ "dependencies": {
2050+ "@shikijs/types": "4.5.0",
2051+ "@shikijs/vscode-textmate": "^10.0.2"
2052+ },
2053+ "engines": {
2054+ "node": ">=20"
2055+ }
2056+ },
2057+ "node_modules/@shikijs/langs": {
2058+ "version": "4.5.0",
2059+ "resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.5.0.tgz",
2060+ "integrity": "sha512-wwBTqwduXSz0X4v9Qa2EWeFaYJEw1clCncWjoRNQRbVDu3SPRTRWucnouYapKj3b8Q+sk/41Vbc4B5c8nuYzLA==",
2061+ "license": "MIT",
2062+ "dependencies": {
2063+ "@shikijs/types": "4.5.0"
2064+ },
2065+ "engines": {
2066+ "node": ">=20"
2067+ }
2068+ },
2069+ "node_modules/@shikijs/primitive": {
2070+ "version": "4.5.0",
2071+ "resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.5.0.tgz",
2072+ "integrity": "sha512-UM7ShlA8oz0OWxLLO8BmO9Ip6fVqdkt8yF0EH7nOCqHfrx1eEAzF0ZUVK7L+I8NEgeu1PEWSRlxaiPPeCb4iDg==",
2073+ "license": "MIT",
2074+ "dependencies": {
2075+ "@shikijs/types": "4.5.0",
2076+ "@shikijs/vscode-textmate": "^10.0.2",
2077+ "@types/hast": "^3.0.5"
2078+ },
2079+ "engines": {
2080+ "node": ">=20"
2081+ }
2082+ },
2083+ "node_modules/@shikijs/themes": {
2084+ "version": "4.5.0",
2085+ "resolved": "https://registry.npmjs.org/@shikijs/themes/-/themes-4.5.0.tgz",
2086+ "integrity": "sha512-zGb87H3UBbpuI4yoh5UguB1XbU+2BDU3kflM7UfZ+Cy2hWo9lBtqjSVK20gmLY6ukYl040hAypGXMgud9vul8Q==",
2087+ "license": "MIT",
2088+ "dependencies": {
2089+ "@shikijs/types": "4.5.0"
2090+ },
2091+ "engines": {
2092+ "node": ">=20"
2093+ }
2094+ },
2095+ "node_modules/@shikijs/types": {
2096+ "version": "4.5.0",
2097+ "resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.5.0.tgz",
2098+ "integrity": "sha512-drtPrtD8N0DENyD2cyt6Ajy+IWsiCLgMxODwLTpvH1UsN6a9jf90tNTPjqwHvA+0Lzu9e0txk8DjfqH1p9UJRw==",
2099+ "license": "MIT",
2100+ "dependencies": {
2101+ "@shikijs/vscode-textmate": "^10.0.2",
2102+ "@types/hast": "^3.0.5"
2103+ },
2104+ "engines": {
2105+ "node": ">=20"
2106+ }
2107+ },
2108+ "node_modules/@shikijs/vscode-textmate": {
2109+ "version": "10.0.2",
2110+ "resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
2111+ "integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
2112+ "license": "MIT"
2113+ },
20012114 "node_modules/@sindresorhus/is": {
20022115 "version": "7.2.0",
20032116 "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-7.2.0.tgz",
23022415 "vite": "^5.2.0 || ^6 || ^7 || ^8"
23032416 }
23042417 },
2418+ "node_modules/@types/debug": {
2419+ "version": "4.1.13",
2420+ "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz",
2421+ "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==",
2422+ "license": "MIT",
2423+ "dependencies": {
2424+ "@types/ms": "*"
2425+ }
2426+ },
2427+ "node_modules/@types/estree": {
2428+ "version": "1.0.9",
2429+ "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
2430+ "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
2431+ "license": "MIT"
2432+ },
2433+ "node_modules/@types/estree-jsx": {
2434+ "version": "1.0.5",
2435+ "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz",
2436+ "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==",
2437+ "license": "MIT",
2438+ "dependencies": {
2439+ "@types/estree": "*"
2440+ }
2441+ },
2442+ "node_modules/@types/hast": {
2443+ "version": "3.0.5",
2444+ "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
2445+ "integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
2446+ "license": "MIT",
2447+ "dependencies": {
2448+ "@types/unist": "*"
2449+ }
2450+ },
2451+ "node_modules/@types/mdast": {
2452+ "version": "4.0.4",
2453+ "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
2454+ "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
2455+ "license": "MIT",
2456+ "dependencies": {
2457+ "@types/unist": "*"
2458+ }
2459+ },
2460+ "node_modules/@types/ms": {
2461+ "version": "2.1.0",
2462+ "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz",
2463+ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==",
2464+ "license": "MIT"
2465+ },
23052466 "node_modules/@types/node": {
23062467 "version": "22.20.5",
23072468 "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.5.tgz",
23162477 "version": "19.3.0",
23172478 "resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
23182479 "integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
2319− "dev": true,
23202480 "license": "MIT",
23212481 "dependencies": {
23222482 "csstype": "^3.2.2"
23322492 "@types/react": "^19.3.0"
23332493 }
23342494 },
2495+ "node_modules/@types/unist": {
2496+ "version": "3.0.3",
2497+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
2498+ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
2499+ "license": "MIT"
2500+ },
2501+ "node_modules/@ungap/structured-clone": {
2502+ "version": "1.4.0",
2503+ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.4.0.tgz",
2504+ "integrity": "sha512-1mEZtMKPM09vDmQt5y7YvmN2+DFTP7Tg0EWXdic8/C6VRnpb33e4ghisCIE3WZjsE2N8mf+QV1Zqh7ZFYLWInQ==",
2505+ "license": "ISC"
2506+ },
23352507 "node_modules/babel-dead-code-elimination": {
23362508 "version": "1.0.12",
23372509 "resolved": "https://registry.npmjs.org/babel-dead-code-elimination/-/babel-dead-code-elimination-1.0.12.tgz",
23452517 "@babel/types": "^7.23.6"
23462518 }
23472519 },
2520+ "node_modules/bail": {
2521+ "version": "2.0.2",
2522+ "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz",
2523+ "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==",
2524+ "license": "MIT",
2525+ "funding": {
2526+ "type": "github",
2527+ "url": "https://github.com/sponsors/wooorm"
2528+ }
2529+ },
23482530 "node_modules/baseline-browser-mapping": {
23492531 "version": "2.11.27",
23502532 "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.27.tgz",
24202602 ],
24212603 "license": "CC-BY-4.0"
24222604 },
2605+ "node_modules/ccount": {
2606+ "version": "2.0.1",
2607+ "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
2608+ "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
2609+ "license": "MIT",
2610+ "funding": {
2611+ "type": "github",
2612+ "url": "https://github.com/sponsors/wooorm"
2613+ }
2614+ },
2615+ "node_modules/character-entities": {
2616+ "version": "2.0.2",
2617+ "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz",
2618+ "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==",
2619+ "license": "MIT",
2620+ "funding": {
2621+ "type": "github",
2622+ "url": "https://github.com/sponsors/wooorm"
2623+ }
2624+ },
2625+ "node_modules/character-entities-html4": {
2626+ "version": "2.1.0",
2627+ "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
2628+ "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
2629+ "license": "MIT",
2630+ "funding": {
2631+ "type": "github",
2632+ "url": "https://github.com/sponsors/wooorm"
2633+ }
2634+ },
2635+ "node_modules/character-entities-legacy": {
2636+ "version": "3.0.0",
2637+ "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
2638+ "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
2639+ "license": "MIT",
2640+ "funding": {
2641+ "type": "github",
2642+ "url": "https://github.com/sponsors/wooorm"
2643+ }
2644+ },
2645+ "node_modules/character-reference-invalid": {
2646+ "version": "2.0.1",
2647+ "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz",
2648+ "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==",
2649+ "license": "MIT",
2650+ "funding": {
2651+ "type": "github",
2652+ "url": "https://github.com/sponsors/wooorm"
2653+ }
2654+ },
24232655 "node_modules/chokidar": {
24242656 "version": "5.0.0",
24252657 "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-5.0.0.tgz",
24362668 "url": "https://paulmillr.com/funding/"
24372669 }
24382670 },
2671+ "node_modules/comma-separated-tokens": {
2672+ "version": "2.0.3",
2673+ "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
2674+ "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
2675+ "license": "MIT",
2676+ "funding": {
2677+ "type": "github",
2678+ "url": "https://github.com/sponsors/wooorm"
2679+ }
2680+ },
24392681 "node_modules/confbox": {
24402682 "version": "0.3.1",
24412683 "resolved": "https://registry.npmjs.org/confbox/-/confbox-0.3.1.tgz",
24742716 "version": "3.2.3",
24752717 "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
24762718 "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
2477− "dev": true,
24782719 "license": "MIT"
24792720 },
24802721 "node_modules/debug": {
24812722 "version": "4.4.3",
24822723 "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
24832724 "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
2484− "dev": true,
24852725 "license": "MIT",
24862726 "dependencies": {
24872727 "ms": "^2.1.3"
24952735 }
24962736 }
24972737 },
2738+ "node_modules/decode-named-character-reference": {
2739+ "version": "1.3.0",
2740+ "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz",
2741+ "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==",
2742+ "license": "MIT",
2743+ "dependencies": {
2744+ "character-entities": "^2.0.0"
2745+ },
2746+ "funding": {
2747+ "type": "github",
2748+ "url": "https://github.com/sponsors/wooorm"
2749+ }
2750+ },
24982751 "node_modules/dedent": {
24992752 "version": "1.7.2",
25002753 "resolved": "https://registry.npmjs.org/dedent/-/dedent-1.7.2.tgz",
25102763 }
25112764 }
25122765 },
2766+ "node_modules/dequal": {
2767+ "version": "2.0.3",
2768+ "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
2769+ "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
2770+ "license": "MIT",
2771+ "engines": {
2772+ "node": ">=6"
2773+ }
2774+ },
25132775 "node_modules/detect-libc": {
25142776 "version": "2.1.2",
25152777 "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
25202782 "node": ">=8"
25212783 }
25222784 },
2785+ "node_modules/devlop": {
2786+ "version": "1.1.0",
2787+ "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
2788+ "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
2789+ "license": "MIT",
2790+ "dependencies": {
2791+ "dequal": "^2.0.0"
2792+ },
2793+ "funding": {
2794+ "type": "github",
2795+ "url": "https://github.com/sponsors/wooorm"
2796+ }
2797+ },
25232798 "node_modules/electron-to-chromium": {
25242799 "version": "1.5.444",
25252800 "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.444.tgz",
26102885 "node": ">=6"
26112886 }
26122887 },
2888+ "node_modules/escape-string-regexp": {
2889+ "version": "5.0.0",
2890+ "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz",
2891+ "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==",
2892+ "license": "MIT",
2893+ "engines": {
2894+ "node": ">=12"
2895+ },
2896+ "funding": {
2897+ "url": "https://github.com/sponsors/sindresorhus"
2898+ }
2899+ },
2900+ "node_modules/estree-util-is-identifier-name": {
2901+ "version": "3.0.0",
2902+ "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz",
2903+ "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==",
2904+ "license": "MIT",
2905+ "funding": {
2906+ "type": "opencollective",
2907+ "url": "https://opencollective.com/unified"
2908+ }
2909+ },
26132910 "node_modules/exit-hook": {
26142911 "version": "5.1.0",
26152912 "resolved": "https://registry.npmjs.org/exit-hook/-/exit-hook-5.1.0.tgz",
26302927 "dev": true,
26312928 "license": "MIT"
26322929 },
2930+ "node_modules/extend": {
2931+ "version": "3.0.2",
2932+ "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
2933+ "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
2934+ "license": "MIT"
2935+ },
26332936 "node_modules/fdir": {
26342937 "version": "6.5.0",
26352938 "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
26802983 "dev": true,
26812984 "license": "ISC"
26822985 },
2986+ "node_modules/hast-util-to-html": {
2987+ "version": "9.0.5",
2988+ "resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
2989+ "integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
2990+ "license": "MIT",
2991+ "dependencies": {
2992+ "@types/hast": "^3.0.0",
2993+ "@types/unist": "^3.0.0",
2994+ "ccount": "^2.0.0",
2995+ "comma-separated-tokens": "^2.0.0",
2996+ "hast-util-whitespace": "^3.0.0",
2997+ "html-void-elements": "^3.0.0",
2998+ "mdast-util-to-hast": "^13.0.0",
2999+ "property-information": "^7.0.0",
3000+ "space-separated-tokens": "^2.0.0",
3001+ "stringify-entities": "^4.0.0",
3002+ "zwitch": "^2.0.4"
3003+ },
3004+ "funding": {
3005+ "type": "opencollective",
3006+ "url": "https://opencollective.com/unified"
3007+ }
3008+ },
3009+ "node_modules/hast-util-to-jsx-runtime": {
3010+ "version": "2.3.6",
3011+ "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz",
3012+ "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==",
3013+ "license": "MIT",
3014+ "dependencies": {
3015+ "@types/estree": "^1.0.0",
3016+ "@types/hast": "^3.0.0",
3017+ "@types/unist": "^3.0.0",
3018+ "comma-separated-tokens": "^2.0.0",
3019+ "devlop": "^1.0.0",
3020+ "estree-util-is-identifier-name": "^3.0.0",
3021+ "hast-util-whitespace": "^3.0.0",
3022+ "mdast-util-mdx-expression": "^2.0.0",
3023+ "mdast-util-mdx-jsx": "^3.0.0",
3024+ "mdast-util-mdxjs-esm": "^2.0.0",
3025+ "property-information": "^7.0.0",
3026+ "space-separated-tokens": "^2.0.0",
3027+ "style-to-js": "^1.0.0",
3028+ "unist-util-position": "^5.0.0",
3029+ "vfile-message": "^4.0.0"
3030+ },
3031+ "funding": {
3032+ "type": "opencollective",
3033+ "url": "https://opencollective.com/unified"
3034+ }
3035+ },
3036+ "node_modules/hast-util-whitespace": {
3037+ "version": "3.0.0",
3038+ "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
3039+ "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
3040+ "license": "MIT",
3041+ "dependencies": {
3042+ "@types/hast": "^3.0.0"
3043+ },
3044+ "funding": {
3045+ "type": "opencollective",
3046+ "url": "https://opencollective.com/unified"
3047+ }
3048+ },
3049+ "node_modules/hono": {
3050+ "version": "4.13.12",
3051+ "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.12.tgz",
3052+ "integrity": "sha512-6E2QDAc9Ick9Sq77ZrGS/dk2WUYni91aufTw6LJKpV7w8kW5/GxVUc650FOADOmlwg3K+f7Pun6XlV+pYmW6gw==",
3053+ "license": "MIT",
3054+ "engines": {
3055+ "node": ">=16.9.0"
3056+ }
3057+ },
3058+ "node_modules/html-url-attributes": {
3059+ "version": "3.0.1",
3060+ "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz",
3061+ "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==",
3062+ "license": "MIT",
3063+ "funding": {
3064+ "type": "opencollective",
3065+ "url": "https://opencollective.com/unified"
3066+ }
3067+ },
3068+ "node_modules/html-void-elements": {
3069+ "version": "3.0.0",
3070+ "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
3071+ "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
3072+ "license": "MIT",
3073+ "funding": {
3074+ "type": "github",
3075+ "url": "https://github.com/sponsors/wooorm"
3076+ }
3077+ },
3078+ "node_modules/inline-style-parser": {
3079+ "version": "0.2.7",
3080+ "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz",
3081+ "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==",
3082+ "license": "MIT"
3083+ },
3084+ "node_modules/is-alphabetical": {
3085+ "version": "2.0.1",
3086+ "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz",
3087+ "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==",
3088+ "license": "MIT",
3089+ "funding": {
3090+ "type": "github",
3091+ "url": "https://github.com/sponsors/wooorm"
3092+ }
3093+ },
3094+ "node_modules/is-alphanumerical": {
3095+ "version": "2.0.1",
3096+ "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz",
3097+ "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==",
3098+ "license": "MIT",
3099+ "dependencies": {
3100+ "is-alphabetical": "^2.0.0",
3101+ "is-decimal": "^2.0.0"
3102+ },
3103+ "funding": {
3104+ "type": "github",
3105+ "url": "https://github.com/sponsors/wooorm"
3106+ }
3107+ },
3108+ "node_modules/is-decimal": {
3109+ "version": "2.0.1",
3110+ "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz",
3111+ "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==",
3112+ "license": "MIT",
3113+ "funding": {
3114+ "type": "github",
3115+ "url": "https://github.com/sponsors/wooorm"
3116+ }
3117+ },
3118+ "node_modules/is-hexadecimal": {
3119+ "version": "2.0.1",
3120+ "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz",
3121+ "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==",
3122+ "license": "MIT",
3123+ "funding": {
3124+ "type": "github",
3125+ "url": "https://github.com/sponsors/wooorm"
3126+ }
3127+ },
3128+ "node_modules/is-plain-obj": {
3129+ "version": "4.1.0",
3130+ "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz",
3131+ "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==",
3132+ "license": "MIT",
3133+ "engines": {
3134+ "node": ">=12"
3135+ },
3136+ "funding": {
3137+ "url": "https://github.com/sponsors/sindresorhus"
3138+ }
3139+ },
26833140 "node_modules/isbot": {
26843141 "version": "5.2.2",
26853142 "resolved": "https://registry.npmjs.org/isbot/-/isbot-5.2.2.tgz",
30223479 "dev": true,
30233480 "license": "MIT"
30243481 },
3482+ "node_modules/longest-streak": {
3483+ "version": "3.1.0",
3484+ "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz",
3485+ "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==",
3486+ "license": "MIT",
3487+ "funding": {
3488+ "type": "github",
3489+ "url": "https://github.com/sponsors/wooorm"
3490+ }
3491+ },
30253492 "node_modules/lru-cache": {
30263493 "version": "5.1.1",
30273494 "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
30323499 "yallist": "^3.0.2"
30333500 }
30343501 },
3502+ "node_modules/lucide-react": {
3503+ "version": "1.49.0",
3504+ "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.49.0.tgz",
3505+ "integrity": "sha512-ApfaNM4/7jBrVopqLJ84Jz8TMQ1Q1JeZ1PmKh25aeS3E1FTVcMkSh1bEQB7dfzdIrwXRP8p3z4AJjTIHHb5IIA==",
3506+ "license": "ISC",
3507+ "peerDependencies": {
3508+ "@types/react": "*",
3509+ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
3510+ },
3511+ "peerDependenciesMeta": {
3512+ "@types/react": {
3513+ "optional": true
3514+ }
3515+ }
3516+ },
30353517 "node_modules/magic-string": {
30363518 "version": "0.30.21",
30373519 "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz",
30423524 "@jridgewell/sourcemap-codec": "^1.5.5"
30433525 }
30443526 },
3527+ "node_modules/markdown-table": {
3528+ "version": "3.0.4",
3529+ "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz",
3530+ "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==",
3531+ "license": "MIT",
3532+ "funding": {
3533+ "type": "github",
3534+ "url": "https://github.com/sponsors/wooorm"
3535+ }
3536+ },
3537+ "node_modules/mdast-util-find-and-replace": {
3538+ "version": "3.0.2",
3539+ "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz",
3540+ "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==",
3541+ "license": "MIT",
3542+ "dependencies": {
3543+ "@types/mdast": "^4.0.0",
3544+ "escape-string-regexp": "^5.0.0",
3545+ "unist-util-is": "^6.0.0",
3546+ "unist-util-visit-parents": "^6.0.0"
3547+ },
3548+ "funding": {
3549+ "type": "opencollective",
3550+ "url": "https://opencollective.com/unified"
3551+ }
3552+ },
3553+ "node_modules/mdast-util-from-markdown": {
3554+ "version": "2.0.3",
3555+ "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz",
3556+ "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==",
3557+ "license": "MIT",
3558+ "dependencies": {
3559+ "@types/mdast": "^4.0.0",
3560+ "@types/unist": "^3.0.0",
3561+ "decode-named-character-reference": "^1.0.0",
3562+ "devlop": "^1.0.0",
3563+ "mdast-util-to-string": "^4.0.0",
3564+ "micromark": "^4.0.0",
3565+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
3566+ "micromark-util-decode-string": "^2.0.0",
3567+ "micromark-util-normalize-identifier": "^2.0.0",
3568+ "micromark-util-symbol": "^2.0.0",
3569+ "micromark-util-types": "^2.0.0",
3570+ "unist-util-stringify-position": "^4.0.0"
3571+ },
3572+ "funding": {
3573+ "type": "opencollective",
3574+ "url": "https://opencollective.com/unified"
3575+ }
3576+ },
3577+ "node_modules/mdast-util-gfm": {
3578+ "version": "3.1.0",
3579+ "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz",
3580+ "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==",
3581+ "license": "MIT",
3582+ "dependencies": {
3583+ "mdast-util-from-markdown": "^2.0.0",
3584+ "mdast-util-gfm-autolink-literal": "^2.0.0",
3585+ "mdast-util-gfm-footnote": "^2.0.0",
3586+ "mdast-util-gfm-strikethrough": "^2.0.0",
3587+ "mdast-util-gfm-table": "^2.0.0",
3588+ "mdast-util-gfm-task-list-item": "^2.0.0",
3589+ "mdast-util-to-markdown": "^2.0.0"
3590+ },
3591+ "funding": {
3592+ "type": "opencollective",
3593+ "url": "https://opencollective.com/unified"
3594+ }
3595+ },
3596+ "node_modules/mdast-util-gfm-autolink-literal": {
3597+ "version": "2.0.1",
3598+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz",
3599+ "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==",
3600+ "license": "MIT",
3601+ "dependencies": {
3602+ "@types/mdast": "^4.0.0",
3603+ "ccount": "^2.0.0",
3604+ "devlop": "^1.0.0",
3605+ "mdast-util-find-and-replace": "^3.0.0",
3606+ "micromark-util-character": "^2.0.0"
3607+ },
3608+ "funding": {
3609+ "type": "opencollective",
3610+ "url": "https://opencollective.com/unified"
3611+ }
3612+ },
3613+ "node_modules/mdast-util-gfm-footnote": {
3614+ "version": "2.1.0",
3615+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz",
3616+ "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==",
3617+ "license": "MIT",
3618+ "dependencies": {
3619+ "@types/mdast": "^4.0.0",
3620+ "devlop": "^1.1.0",
3621+ "mdast-util-from-markdown": "^2.0.0",
3622+ "mdast-util-to-markdown": "^2.0.0",
3623+ "micromark-util-normalize-identifier": "^2.0.0"
3624+ },
3625+ "funding": {
3626+ "type": "opencollective",
3627+ "url": "https://opencollective.com/unified"
3628+ }
3629+ },
3630+ "node_modules/mdast-util-gfm-strikethrough": {
3631+ "version": "2.0.1",
3632+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.1.tgz",
3633+ "integrity": "sha512-OuJHqvr455pwu2OaOrir7dbzqs4jlWKOtlu9L6GjcIjjQwNyw02VntQsr/Ek6/A13vgnXfUhBekWRUP1OkNivw==",
3634+ "license": "MIT",
3635+ "dependencies": {
3636+ "@types/mdast": "^4.0.0",
3637+ "mdast-util-from-markdown": "^2.0.0",
3638+ "mdast-util-to-markdown": "^2.0.0"
3639+ },
3640+ "funding": {
3641+ "type": "opencollective",
3642+ "url": "https://opencollective.com/unified"
3643+ }
3644+ },
3645+ "node_modules/mdast-util-gfm-table": {
3646+ "version": "2.0.0",
3647+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz",
3648+ "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==",
3649+ "license": "MIT",
3650+ "dependencies": {
3651+ "@types/mdast": "^4.0.0",
3652+ "devlop": "^1.0.0",
3653+ "markdown-table": "^3.0.0",
3654+ "mdast-util-from-markdown": "^2.0.0",
3655+ "mdast-util-to-markdown": "^2.0.0"
3656+ },
3657+ "funding": {
3658+ "type": "opencollective",
3659+ "url": "https://opencollective.com/unified"
3660+ }
3661+ },
3662+ "node_modules/mdast-util-gfm-task-list-item": {
3663+ "version": "2.0.0",
3664+ "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz",
3665+ "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==",
3666+ "license": "MIT",
3667+ "dependencies": {
3668+ "@types/mdast": "^4.0.0",
3669+ "devlop": "^1.0.0",
3670+ "mdast-util-from-markdown": "^2.0.0",
3671+ "mdast-util-to-markdown": "^2.0.0"
3672+ },
3673+ "funding": {
3674+ "type": "opencollective",
3675+ "url": "https://opencollective.com/unified"
3676+ }
3677+ },
3678+ "node_modules/mdast-util-mdx-expression": {
3679+ "version": "2.0.1",
3680+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz",
3681+ "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==",
3682+ "license": "MIT",
3683+ "dependencies": {
3684+ "@types/estree-jsx": "^1.0.0",
3685+ "@types/hast": "^3.0.0",
3686+ "@types/mdast": "^4.0.0",
3687+ "devlop": "^1.0.0",
3688+ "mdast-util-from-markdown": "^2.0.0",
3689+ "mdast-util-to-markdown": "^2.0.0"
3690+ },
3691+ "funding": {
3692+ "type": "opencollective",
3693+ "url": "https://opencollective.com/unified"
3694+ }
3695+ },
3696+ "node_modules/mdast-util-mdx-jsx": {
3697+ "version": "3.2.0",
3698+ "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz",
3699+ "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==",
3700+ "license": "MIT",
3701+ "dependencies": {
3702+ "@types/estree-jsx": "^1.0.0",
3703+ "@types/hast": "^3.0.0",
3704+ "@types/mdast": "^4.0.0",
3705+ "@types/unist": "^3.0.0",
3706+ "ccount": "^2.0.0",
3707+ "devlop": "^1.1.0",
3708+ "mdast-util-from-markdown": "^2.0.0",
3709+ "mdast-util-to-markdown": "^2.0.0",
3710+ "parse-entities": "^4.0.0",
3711+ "stringify-entities": "^4.0.0",
3712+ "unist-util-stringify-position": "^4.0.0",
3713+ "vfile-message": "^4.0.0"
3714+ },
3715+ "funding": {
3716+ "type": "opencollective",
3717+ "url": "https://opencollective.com/unified"
3718+ }
3719+ },
3720+ "node_modules/mdast-util-mdxjs-esm": {
3721+ "version": "2.0.1",
3722+ "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz",
3723+ "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==",
3724+ "license": "MIT",
3725+ "dependencies": {
3726+ "@types/estree-jsx": "^1.0.0",
3727+ "@types/hast": "^3.0.0",
3728+ "@types/mdast": "^4.0.0",
3729+ "devlop": "^1.0.0",
3730+ "mdast-util-from-markdown": "^2.0.0",
3731+ "mdast-util-to-markdown": "^2.0.0"
3732+ },
3733+ "funding": {
3734+ "type": "opencollective",
3735+ "url": "https://opencollective.com/unified"
3736+ }
3737+ },
3738+ "node_modules/mdast-util-phrasing": {
3739+ "version": "4.1.0",
3740+ "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz",
3741+ "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==",
3742+ "license": "MIT",
3743+ "dependencies": {
3744+ "@types/mdast": "^4.0.0",
3745+ "unist-util-is": "^6.0.0"
3746+ },
3747+ "funding": {
3748+ "type": "opencollective",
3749+ "url": "https://opencollective.com/unified"
3750+ }
3751+ },
3752+ "node_modules/mdast-util-to-hast": {
3753+ "version": "13.2.1",
3754+ "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
3755+ "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
3756+ "license": "MIT",
3757+ "dependencies": {
3758+ "@types/hast": "^3.0.0",
3759+ "@types/mdast": "^4.0.0",
3760+ "@ungap/structured-clone": "^1.0.0",
3761+ "devlop": "^1.0.0",
3762+ "micromark-util-sanitize-uri": "^2.0.0",
3763+ "trim-lines": "^3.0.0",
3764+ "unist-util-position": "^5.0.0",
3765+ "unist-util-visit": "^5.0.0",
3766+ "vfile": "^6.0.0"
3767+ },
3768+ "funding": {
3769+ "type": "opencollective",
3770+ "url": "https://opencollective.com/unified"
3771+ }
3772+ },
3773+ "node_modules/mdast-util-to-markdown": {
3774+ "version": "2.1.3",
3775+ "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.3.tgz",
3776+ "integrity": "sha512-wgyJtgUkUcdU7zci7uuwc/tzoAhm0TswWhaXuvnWolqug+8jY7bgNkBBL0dYBnHgc/tHL7lCzRdPsdUxUKKBqw==",
3777+ "license": "MIT",
3778+ "dependencies": {
3779+ "@types/mdast": "^4.0.0",
3780+ "@types/unist": "^3.0.0",
3781+ "longest-streak": "^3.0.0",
3782+ "mdast-util-phrasing": "^4.0.0",
3783+ "mdast-util-to-string": "^4.0.0",
3784+ "micromark-util-character": "^2.0.0",
3785+ "micromark-util-classify-character": "^2.0.0",
3786+ "micromark-util-decode-string": "^2.0.0",
3787+ "micromark-util-html-tag-name": "^2.0.0",
3788+ "unist-util-visit": "^5.0.0",
3789+ "zwitch": "^2.0.0"
3790+ },
3791+ "funding": {
3792+ "type": "opencollective",
3793+ "url": "https://opencollective.com/unified"
3794+ }
3795+ },
3796+ "node_modules/mdast-util-to-string": {
3797+ "version": "4.0.0",
3798+ "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz",
3799+ "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==",
3800+ "license": "MIT",
3801+ "dependencies": {
3802+ "@types/mdast": "^4.0.0"
3803+ },
3804+ "funding": {
3805+ "type": "opencollective",
3806+ "url": "https://opencollective.com/unified"
3807+ }
3808+ },
3809+ "node_modules/micromark": {
3810+ "version": "4.0.3",
3811+ "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.3.tgz",
3812+ "integrity": "sha512-oGYfQzHSG5dOMovQcJ3fyTmZlWAWpi0XA0sJwJs+i6OT88o1+Jtw/8z0CmdowSLxGhhFK89rQ/oXph/wN02PNw==",
3813+ "funding": [
3814+ {
3815+ "type": "GitHub Sponsors",
3816+ "url": "https://github.com/sponsors/unifiedjs"
3817+ },
3818+ {
3819+ "type": "OpenCollective",
3820+ "url": "https://opencollective.com/unified"
3821+ }
3822+ ],
3823+ "license": "MIT",
3824+ "dependencies": {
3825+ "@types/debug": "^4.0.0",
3826+ "debug": "^4.0.0",
3827+ "decode-named-character-reference": "^1.0.0",
3828+ "devlop": "^1.0.0",
3829+ "micromark-core-commonmark": "^2.0.0",
3830+ "micromark-factory-space": "^2.0.0",
3831+ "micromark-util-character": "^2.0.0",
3832+ "micromark-util-chunked": "^2.0.0",
3833+ "micromark-util-combine-extensions": "^2.0.0",
3834+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
3835+ "micromark-util-edit-map": "^1.0.0",
3836+ "micromark-util-encode": "^2.0.0",
3837+ "micromark-util-normalize-identifier": "^2.0.0",
3838+ "micromark-util-resolve-all": "^2.0.0",
3839+ "micromark-util-sanitize-uri": "^2.0.0",
3840+ "micromark-util-subtokenize": "^2.0.0",
3841+ "micromark-util-symbol": "^2.0.0",
3842+ "micromark-util-types": "^2.0.0"
3843+ }
3844+ },
3845+ "node_modules/micromark-core-commonmark": {
3846+ "version": "2.0.4",
3847+ "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.4.tgz",
3848+ "integrity": "sha512-wxEeE8v8XVvOrxn1TZj74qYhAtTQsSqufHVS3uNVyT1MupXxhyaHk8/9xDNPh9BmL77QNDgWTkZ3RsYHB2ry7w==",
3849+ "funding": [
3850+ {
3851+ "type": "GitHub Sponsors",
3852+ "url": "https://github.com/sponsors/unifiedjs"
3853+ },
3854+ {
3855+ "type": "OpenCollective",
3856+ "url": "https://opencollective.com/unified"
3857+ }
3858+ ],
3859+ "license": "MIT",
3860+ "dependencies": {
3861+ "decode-named-character-reference": "^1.0.0",
3862+ "devlop": "^1.0.0",
3863+ "micromark-factory-destination": "^2.0.0",
3864+ "micromark-factory-label": "^2.0.0",
3865+ "micromark-factory-space": "^2.1.0",
3866+ "micromark-factory-title": "^2.0.0",
3867+ "micromark-factory-whitespace": "^2.0.0",
3868+ "micromark-util-character": "^2.0.0",
3869+ "micromark-util-chunked": "^2.0.0",
3870+ "micromark-util-classify-character": "^2.0.0",
3871+ "micromark-util-edit-map": "^1.0.0",
3872+ "micromark-util-html-tag-name": "^2.0.0",
3873+ "micromark-util-normalize-identifier": "^2.0.0",
3874+ "micromark-util-resolve-all": "^2.0.0",
3875+ "micromark-util-subtokenize": "^2.0.0",
3876+ "micromark-util-symbol": "^2.0.0",
3877+ "micromark-util-types": "^2.0.3"
3878+ }
3879+ },
3880+ "node_modules/micromark-extension-gfm": {
3881+ "version": "3.0.0",
3882+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz",
3883+ "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==",
3884+ "license": "MIT",
3885+ "dependencies": {
3886+ "micromark-extension-gfm-autolink-literal": "^2.0.0",
3887+ "micromark-extension-gfm-footnote": "^2.0.0",
3888+ "micromark-extension-gfm-strikethrough": "^2.0.0",
3889+ "micromark-extension-gfm-table": "^2.0.0",
3890+ "micromark-extension-gfm-tagfilter": "^2.0.0",
3891+ "micromark-extension-gfm-task-list-item": "^2.0.0",
3892+ "micromark-util-combine-extensions": "^2.0.0",
3893+ "micromark-util-types": "^2.0.0"
3894+ },
3895+ "funding": {
3896+ "type": "opencollective",
3897+ "url": "https://opencollective.com/unified"
3898+ }
3899+ },
3900+ "node_modules/micromark-extension-gfm-autolink-literal": {
3901+ "version": "2.1.0",
3902+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz",
3903+ "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==",
3904+ "license": "MIT",
3905+ "dependencies": {
3906+ "micromark-util-character": "^2.0.0",
3907+ "micromark-util-sanitize-uri": "^2.0.0",
3908+ "micromark-util-symbol": "^2.0.0",
3909+ "micromark-util-types": "^2.0.0"
3910+ },
3911+ "funding": {
3912+ "type": "opencollective",
3913+ "url": "https://opencollective.com/unified"
3914+ }
3915+ },
3916+ "node_modules/micromark-extension-gfm-footnote": {
3917+ "version": "2.1.0",
3918+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz",
3919+ "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==",
3920+ "license": "MIT",
3921+ "dependencies": {
3922+ "devlop": "^1.0.0",
3923+ "micromark-core-commonmark": "^2.0.0",
3924+ "micromark-factory-space": "^2.0.0",
3925+ "micromark-util-character": "^2.0.0",
3926+ "micromark-util-normalize-identifier": "^2.0.0",
3927+ "micromark-util-sanitize-uri": "^2.0.0",
3928+ "micromark-util-symbol": "^2.0.0",
3929+ "micromark-util-types": "^2.0.0"
3930+ },
3931+ "funding": {
3932+ "type": "opencollective",
3933+ "url": "https://opencollective.com/unified"
3934+ }
3935+ },
3936+ "node_modules/micromark-extension-gfm-strikethrough": {
3937+ "version": "2.1.0",
3938+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz",
3939+ "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==",
3940+ "license": "MIT",
3941+ "dependencies": {
3942+ "devlop": "^1.0.0",
3943+ "micromark-util-chunked": "^2.0.0",
3944+ "micromark-util-classify-character": "^2.0.0",
3945+ "micromark-util-resolve-all": "^2.0.0",
3946+ "micromark-util-symbol": "^2.0.0",
3947+ "micromark-util-types": "^2.0.0"
3948+ },
3949+ "funding": {
3950+ "type": "opencollective",
3951+ "url": "https://opencollective.com/unified"
3952+ }
3953+ },
3954+ "node_modules/micromark-extension-gfm-table": {
3955+ "version": "2.1.2",
3956+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.2.tgz",
3957+ "integrity": "sha512-pRzm4kDTu0MjlmBkxmS9yYhw60nncfcEwu9NNdPFSQEFXS95ZKyIIyTSHu/o3ReBUrLKYEq+7YaXCRn/bPB4MA==",
3958+ "license": "MIT",
3959+ "dependencies": {
3960+ "devlop": "^1.0.0",
3961+ "micromark-factory-space": "^2.0.0",
3962+ "micromark-util-character": "^2.0.0",
3963+ "micromark-util-symbol": "^2.0.0",
3964+ "micromark-util-types": "^2.0.0"
3965+ },
3966+ "funding": {
3967+ "type": "opencollective",
3968+ "url": "https://opencollective.com/unified"
3969+ }
3970+ },
3971+ "node_modules/micromark-extension-gfm-tagfilter": {
3972+ "version": "2.0.0",
3973+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz",
3974+ "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==",
3975+ "license": "MIT",
3976+ "dependencies": {
3977+ "micromark-util-types": "^2.0.0"
3978+ },
3979+ "funding": {
3980+ "type": "opencollective",
3981+ "url": "https://opencollective.com/unified"
3982+ }
3983+ },
3984+ "node_modules/micromark-extension-gfm-task-list-item": {
3985+ "version": "2.1.0",
3986+ "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz",
3987+ "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==",
3988+ "license": "MIT",
3989+ "dependencies": {
3990+ "devlop": "^1.0.0",
3991+ "micromark-factory-space": "^2.0.0",
3992+ "micromark-util-character": "^2.0.0",
3993+ "micromark-util-symbol": "^2.0.0",
3994+ "micromark-util-types": "^2.0.0"
3995+ },
3996+ "funding": {
3997+ "type": "opencollective",
3998+ "url": "https://opencollective.com/unified"
3999+ }
4000+ },
4001+ "node_modules/micromark-factory-destination": {
4002+ "version": "2.0.1",
4003+ "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz",
4004+ "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==",
4005+ "funding": [
4006+ {
4007+ "type": "GitHub Sponsors",
4008+ "url": "https://github.com/sponsors/unifiedjs"
4009+ },
4010+ {
4011+ "type": "OpenCollective",
4012+ "url": "https://opencollective.com/unified"
4013+ }
4014+ ],
4015+ "license": "MIT",
4016+ "dependencies": {
4017+ "micromark-util-character": "^2.0.0",
4018+ "micromark-util-symbol": "^2.0.0",
4019+ "micromark-util-types": "^2.0.0"
4020+ }
4021+ },
4022+ "node_modules/micromark-factory-label": {
4023+ "version": "2.0.1",
4024+ "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz",
4025+ "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==",
4026+ "funding": [
4027+ {
4028+ "type": "GitHub Sponsors",
4029+ "url": "https://github.com/sponsors/unifiedjs"
4030+ },
4031+ {
4032+ "type": "OpenCollective",
4033+ "url": "https://opencollective.com/unified"
4034+ }
4035+ ],
4036+ "license": "MIT",
4037+ "dependencies": {
4038+ "devlop": "^1.0.0",
4039+ "micromark-util-character": "^2.0.0",
4040+ "micromark-util-symbol": "^2.0.0",
4041+ "micromark-util-types": "^2.0.0"
4042+ }
4043+ },
4044+ "node_modules/micromark-factory-space": {
4045+ "version": "2.1.0",
4046+ "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.1.0.tgz",
4047+ "integrity": "sha512-fS8hnLIjnjvdQIj39Geug8wWsR0HrYZ43KShKxNfwT7t2LOHo/LbWZEzEaSOjwtjdCsjoE6syHhonEzW0zv0+Q==",
4048+ "funding": [
4049+ {
4050+ "type": "GitHub Sponsors",
4051+ "url": "https://github.com/sponsors/unifiedjs"
4052+ },
4053+ {
4054+ "type": "OpenCollective",
4055+ "url": "https://opencollective.com/unified"
4056+ }
4057+ ],
4058+ "license": "MIT",
4059+ "dependencies": {
4060+ "micromark-util-character": "^2.0.0",
4061+ "micromark-util-types": "^2.0.0"
4062+ }
4063+ },
4064+ "node_modules/micromark-factory-title": {
4065+ "version": "2.0.1",
4066+ "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz",
4067+ "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==",
4068+ "funding": [
4069+ {
4070+ "type": "GitHub Sponsors",
4071+ "url": "https://github.com/sponsors/unifiedjs"
4072+ },
4073+ {
4074+ "type": "OpenCollective",
4075+ "url": "https://opencollective.com/unified"
4076+ }
4077+ ],
4078+ "license": "MIT",
4079+ "dependencies": {
4080+ "micromark-factory-space": "^2.0.0",
4081+ "micromark-util-character": "^2.0.0",
4082+ "micromark-util-symbol": "^2.0.0",
4083+ "micromark-util-types": "^2.0.0"
4084+ }
4085+ },
4086+ "node_modules/micromark-factory-whitespace": {
4087+ "version": "2.0.1",
4088+ "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz",
4089+ "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==",
4090+ "funding": [
4091+ {
4092+ "type": "GitHub Sponsors",
4093+ "url": "https://github.com/sponsors/unifiedjs"
4094+ },
4095+ {
4096+ "type": "OpenCollective",
4097+ "url": "https://opencollective.com/unified"
4098+ }
4099+ ],
4100+ "license": "MIT",
4101+ "dependencies": {
4102+ "micromark-factory-space": "^2.0.0",
4103+ "micromark-util-character": "^2.0.0",
4104+ "micromark-util-symbol": "^2.0.0",
4105+ "micromark-util-types": "^2.0.0"
4106+ }
4107+ },
4108+ "node_modules/micromark-util-character": {
4109+ "version": "2.1.1",
4110+ "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
4111+ "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
4112+ "funding": [
4113+ {
4114+ "type": "GitHub Sponsors",
4115+ "url": "https://github.com/sponsors/unifiedjs"
4116+ },
4117+ {
4118+ "type": "OpenCollective",
4119+ "url": "https://opencollective.com/unified"
4120+ }
4121+ ],
4122+ "license": "MIT",
4123+ "dependencies": {
4124+ "micromark-util-symbol": "^2.0.0",
4125+ "micromark-util-types": "^2.0.0"
4126+ }
4127+ },
4128+ "node_modules/micromark-util-chunked": {
4129+ "version": "2.0.1",
4130+ "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz",
4131+ "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==",
4132+ "funding": [
4133+ {
4134+ "type": "GitHub Sponsors",
4135+ "url": "https://github.com/sponsors/unifiedjs"
4136+ },
4137+ {
4138+ "type": "OpenCollective",
4139+ "url": "https://opencollective.com/unified"
4140+ }
4141+ ],
4142+ "license": "MIT",
4143+ "dependencies": {
4144+ "micromark-util-symbol": "^2.0.0"
4145+ }
4146+ },
4147+ "node_modules/micromark-util-classify-character": {
4148+ "version": "2.0.1",
4149+ "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz",
4150+ "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==",
4151+ "funding": [
4152+ {
4153+ "type": "GitHub Sponsors",
4154+ "url": "https://github.com/sponsors/unifiedjs"
4155+ },
4156+ {
4157+ "type": "OpenCollective",
4158+ "url": "https://opencollective.com/unified"
4159+ }
4160+ ],
4161+ "license": "MIT",
4162+ "dependencies": {
4163+ "micromark-util-character": "^2.0.0",
4164+ "micromark-util-symbol": "^2.0.0",
4165+ "micromark-util-types": "^2.0.0"
4166+ }
4167+ },
4168+ "node_modules/micromark-util-combine-extensions": {
4169+ "version": "2.0.1",
4170+ "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz",
4171+ "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==",
4172+ "funding": [
4173+ {
4174+ "type": "GitHub Sponsors",
4175+ "url": "https://github.com/sponsors/unifiedjs"
4176+ },
4177+ {
4178+ "type": "OpenCollective",
4179+ "url": "https://opencollective.com/unified"
4180+ }
4181+ ],
4182+ "license": "MIT",
4183+ "dependencies": {
4184+ "micromark-util-chunked": "^2.0.0",
4185+ "micromark-util-types": "^2.0.0"
4186+ }
4187+ },
4188+ "node_modules/micromark-util-decode-numeric-character-reference": {
4189+ "version": "2.0.2",
4190+ "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz",
4191+ "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==",
4192+ "funding": [
4193+ {
4194+ "type": "GitHub Sponsors",
4195+ "url": "https://github.com/sponsors/unifiedjs"
4196+ },
4197+ {
4198+ "type": "OpenCollective",
4199+ "url": "https://opencollective.com/unified"
4200+ }
4201+ ],
4202+ "license": "MIT",
4203+ "dependencies": {
4204+ "micromark-util-symbol": "^2.0.0"
4205+ }
4206+ },
4207+ "node_modules/micromark-util-decode-string": {
4208+ "version": "2.0.1",
4209+ "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz",
4210+ "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==",
4211+ "funding": [
4212+ {
4213+ "type": "GitHub Sponsors",
4214+ "url": "https://github.com/sponsors/unifiedjs"
4215+ },
4216+ {
4217+ "type": "OpenCollective",
4218+ "url": "https://opencollective.com/unified"
4219+ }
4220+ ],
4221+ "license": "MIT",
4222+ "dependencies": {
4223+ "decode-named-character-reference": "^1.0.0",
4224+ "micromark-util-character": "^2.0.0",
4225+ "micromark-util-decode-numeric-character-reference": "^2.0.0",
4226+ "micromark-util-symbol": "^2.0.0"
4227+ }
4228+ },
4229+ "node_modules/micromark-util-edit-map": {
4230+ "version": "1.0.0",
4231+ "resolved": "https://registry.npmjs.org/micromark-util-edit-map/-/micromark-util-edit-map-1.0.0.tgz",
4232+ "integrity": "sha512-Pa2ljlsEL6sVwFaYeyrOLSYbQt73JvGbPOYFq+9AElXiSnfI5Q4465RRZ1sHv7lDjDOnGRDDaqPXqClqWK/q1Q==",
4233+ "funding": [
4234+ {
4235+ "type": "GitHub Sponsors",
4236+ "url": "https://github.com/sponsors/unifiedjs"
4237+ },
4238+ {
4239+ "type": "OpenCollective",
4240+ "url": "https://opencollective.com/unified"
4241+ }
4242+ ],
4243+ "license": "MIT",
4244+ "dependencies": {
4245+ "micromark-util-types": "^2.0.0"
4246+ }
4247+ },
4248+ "node_modules/micromark-util-encode": {
4249+ "version": "2.0.1",
4250+ "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
4251+ "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
4252+ "funding": [
4253+ {
4254+ "type": "GitHub Sponsors",
4255+ "url": "https://github.com/sponsors/unifiedjs"
4256+ },
4257+ {
4258+ "type": "OpenCollective",
4259+ "url": "https://opencollective.com/unified"
4260+ }
4261+ ],
4262+ "license": "MIT"
4263+ },
4264+ "node_modules/micromark-util-html-tag-name": {
4265+ "version": "2.0.1",
4266+ "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz",
4267+ "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==",
4268+ "funding": [
4269+ {
4270+ "type": "GitHub Sponsors",
4271+ "url": "https://github.com/sponsors/unifiedjs"
4272+ },
4273+ {
4274+ "type": "OpenCollective",
4275+ "url": "https://opencollective.com/unified"
4276+ }
4277+ ],
4278+ "license": "MIT"
4279+ },
4280+ "node_modules/micromark-util-normalize-identifier": {
4281+ "version": "2.0.1",
4282+ "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz",
4283+ "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==",
4284+ "funding": [
4285+ {
4286+ "type": "GitHub Sponsors",
4287+ "url": "https://github.com/sponsors/unifiedjs"
4288+ },
4289+ {
4290+ "type": "OpenCollective",
4291+ "url": "https://opencollective.com/unified"
4292+ }
4293+ ],
4294+ "license": "MIT",
4295+ "dependencies": {
4296+ "micromark-util-symbol": "^2.0.0"
4297+ }
4298+ },
4299+ "node_modules/micromark-util-resolve-all": {
4300+ "version": "2.0.1",
4301+ "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz",
4302+ "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==",
4303+ "funding": [
4304+ {
4305+ "type": "GitHub Sponsors",
4306+ "url": "https://github.com/sponsors/unifiedjs"
4307+ },
4308+ {
4309+ "type": "OpenCollective",
4310+ "url": "https://opencollective.com/unified"
4311+ }
4312+ ],
4313+ "license": "MIT",
4314+ "dependencies": {
4315+ "micromark-util-types": "^2.0.0"
4316+ }
4317+ },
4318+ "node_modules/micromark-util-sanitize-uri": {
4319+ "version": "2.0.1",
4320+ "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
4321+ "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
4322+ "funding": [
4323+ {
4324+ "type": "GitHub Sponsors",
4325+ "url": "https://github.com/sponsors/unifiedjs"
4326+ },
4327+ {
4328+ "type": "OpenCollective",
4329+ "url": "https://opencollective.com/unified"
4330+ }
4331+ ],
4332+ "license": "MIT",
4333+ "dependencies": {
4334+ "micromark-util-character": "^2.0.0",
4335+ "micromark-util-encode": "^2.0.0",
4336+ "micromark-util-symbol": "^2.0.0"
4337+ }
4338+ },
4339+ "node_modules/micromark-util-subtokenize": {
4340+ "version": "2.1.0",
4341+ "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz",
4342+ "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==",
4343+ "funding": [
4344+ {
4345+ "type": "GitHub Sponsors",
4346+ "url": "https://github.com/sponsors/unifiedjs"
4347+ },
4348+ {
4349+ "type": "OpenCollective",
4350+ "url": "https://opencollective.com/unified"
4351+ }
4352+ ],
4353+ "license": "MIT",
4354+ "dependencies": {
4355+ "devlop": "^1.0.0",
4356+ "micromark-util-chunked": "^2.0.0",
4357+ "micromark-util-symbol": "^2.0.0",
4358+ "micromark-util-types": "^2.0.0"
4359+ }
4360+ },
4361+ "node_modules/micromark-util-symbol": {
4362+ "version": "2.0.1",
4363+ "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
4364+ "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
4365+ "funding": [
4366+ {
4367+ "type": "GitHub Sponsors",
4368+ "url": "https://github.com/sponsors/unifiedjs"
4369+ },
4370+ {
4371+ "type": "OpenCollective",
4372+ "url": "https://opencollective.com/unified"
4373+ }
4374+ ],
4375+ "license": "MIT"
4376+ },
4377+ "node_modules/micromark-util-types": {
4378+ "version": "2.0.3",
4379+ "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.3.tgz",
4380+ "integrity": "sha512-oxB2Ik03hI0gv+VNn9tnh1t1YEe9MDPptViAEgfdf3YQHsn0pzGTgCdlSCJXcwhqm8phaEuM7zeEu3QQzVBrPg==",
4381+ "funding": [
4382+ {
4383+ "type": "GitHub Sponsors",
4384+ "url": "https://github.com/sponsors/unifiedjs"
4385+ },
4386+ {
4387+ "type": "OpenCollective",
4388+ "url": "https://opencollective.com/unified"
4389+ }
4390+ ],
4391+ "license": "MIT"
4392+ },
30454393 "node_modules/miniflare": {
30464394 "version": "5.20261001.0-alpha",
30474395 "resolved": "https://registry.npmjs.org/miniflare/-/miniflare-5.20261001.0-alpha.tgz",
30644412 "version": "2.1.3",
30654413 "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
30664414 "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
3067− "dev": true,
30684415 "license": "MIT"
30694416 },
30704417 "node_modules/nanoid": {
30964443 "node": ">=18"
30974444 }
30984445 },
4446+ "node_modules/oniguruma-parser": {
4447+ "version": "0.12.2",
4448+ "resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
4449+ "integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
4450+ "license": "MIT"
4451+ },
4452+ "node_modules/oniguruma-to-es": {
4453+ "version": "4.3.6",
4454+ "resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
4455+ "integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
4456+ "license": "MIT",
4457+ "dependencies": {
4458+ "oniguruma-parser": "^0.12.2",
4459+ "regex": "^6.1.0",
4460+ "regex-recursion": "^6.0.2"
4461+ }
4462+ },
30994463 "node_modules/p-map": {
31004464 "version": "7.0.8",
31014465 "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.8.tgz",
31094473 "url": "https://github.com/sponsors/sindresorhus"
31104474 }
31114475 },
4476+ "node_modules/parse-entities": {
4477+ "version": "4.0.2",
4478+ "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz",
4479+ "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==",
4480+ "license": "MIT",
4481+ "dependencies": {
4482+ "@types/unist": "^2.0.0",
4483+ "character-entities-legacy": "^3.0.0",
4484+ "character-reference-invalid": "^2.0.0",
4485+ "decode-named-character-reference": "^1.0.0",
4486+ "is-alphanumerical": "^2.0.0",
4487+ "is-decimal": "^2.0.0",
4488+ "is-hexadecimal": "^2.0.0"
4489+ },
4490+ "funding": {
4491+ "type": "github",
4492+ "url": "https://github.com/sponsors/wooorm"
4493+ }
4494+ },
4495+ "node_modules/parse-entities/node_modules/@types/unist": {
4496+ "version": "2.0.11",
4497+ "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz",
4498+ "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==",
4499+ "license": "MIT"
4500+ },
31124501 "node_modules/path-to-regexp": {
31134502 "version": "6.3.0",
31144503 "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
31844573 "node": "^10 || ^12 || >=14"
31854574 }
31864575 },
4576+ "node_modules/property-information": {
4577+ "version": "7.2.0",
4578+ "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
4579+ "integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
4580+ "license": "MIT",
4581+ "funding": {
4582+ "type": "github",
4583+ "url": "https://github.com/sponsors/wooorm"
4584+ }
4585+ },
31874586 "node_modules/react": {
31884587 "version": "19.3.0",
31894588 "resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
32054604 "react": "^19.3.0"
32064605 }
32074606 },
4607+ "node_modules/react-markdown": {
4608+ "version": "10.1.0",
4609+ "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz",
4610+ "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==",
4611+ "license": "MIT",
4612+ "dependencies": {
4613+ "@types/hast": "^3.0.0",
4614+ "@types/mdast": "^4.0.0",
4615+ "devlop": "^1.0.0",
4616+ "hast-util-to-jsx-runtime": "^2.0.0",
4617+ "html-url-attributes": "^3.0.0",
4618+ "mdast-util-to-hast": "^13.0.0",
4619+ "remark-parse": "^11.0.0",
4620+ "remark-rehype": "^11.0.0",
4621+ "unified": "^11.0.0",
4622+ "unist-util-visit": "^5.0.0",
4623+ "vfile": "^6.0.0"
4624+ },
4625+ "funding": {
4626+ "type": "opencollective",
4627+ "url": "https://opencollective.com/unified"
4628+ },
4629+ "peerDependencies": {
4630+ "@types/react": ">=18",
4631+ "react": ">=18"
4632+ }
4633+ },
32084634 "node_modules/react-refresh": {
32094635 "version": "0.18.0",
32104636 "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
32514677 "url": "https://paulmillr.com/funding/"
32524678 }
32534679 },
4680+ "node_modules/regex": {
4681+ "version": "6.1.0",
4682+ "resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
4683+ "integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
4684+ "license": "MIT",
4685+ "dependencies": {
4686+ "regex-utilities": "^2.3.0"
4687+ }
4688+ },
4689+ "node_modules/regex-recursion": {
4690+ "version": "6.0.2",
4691+ "resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
4692+ "integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
4693+ "license": "MIT",
4694+ "dependencies": {
4695+ "regex-utilities": "^2.3.0"
4696+ }
4697+ },
4698+ "node_modules/regex-utilities": {
4699+ "version": "2.3.0",
4700+ "resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
4701+ "integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
4702+ "license": "MIT"
4703+ },
4704+ "node_modules/remark-gfm": {
4705+ "version": "4.0.1",
4706+ "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz",
4707+ "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==",
4708+ "license": "MIT",
4709+ "dependencies": {
4710+ "@types/mdast": "^4.0.0",
4711+ "mdast-util-gfm": "^3.0.0",
4712+ "micromark-extension-gfm": "^3.0.0",
4713+ "remark-parse": "^11.0.0",
4714+ "remark-stringify": "^11.0.0",
4715+ "unified": "^11.0.0"
4716+ },
4717+ "funding": {
4718+ "type": "opencollective",
4719+ "url": "https://opencollective.com/unified"
4720+ }
4721+ },
4722+ "node_modules/remark-parse": {
4723+ "version": "11.0.0",
4724+ "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz",
4725+ "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==",
4726+ "license": "MIT",
4727+ "dependencies": {
4728+ "@types/mdast": "^4.0.0",
4729+ "mdast-util-from-markdown": "^2.0.0",
4730+ "micromark-util-types": "^2.0.0",
4731+ "unified": "^11.0.0"
4732+ },
4733+ "funding": {
4734+ "type": "opencollective",
4735+ "url": "https://opencollective.com/unified"
4736+ }
4737+ },
4738+ "node_modules/remark-rehype": {
4739+ "version": "11.1.2",
4740+ "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz",
4741+ "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==",
4742+ "license": "MIT",
4743+ "dependencies": {
4744+ "@types/hast": "^3.0.0",
4745+ "@types/mdast": "^4.0.0",
4746+ "mdast-util-to-hast": "^13.0.0",
4747+ "unified": "^11.0.0",
4748+ "vfile": "^6.0.0"
4749+ },
4750+ "funding": {
4751+ "type": "opencollective",
4752+ "url": "https://opencollective.com/unified"
4753+ }
4754+ },
4755+ "node_modules/remark-stringify": {
4756+ "version": "11.0.0",
4757+ "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz",
4758+ "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==",
4759+ "license": "MIT",
4760+ "dependencies": {
4761+ "@types/mdast": "^4.0.0",
4762+ "mdast-util-to-markdown": "^2.0.0",
4763+ "unified": "^11.0.0"
4764+ },
4765+ "funding": {
4766+ "type": "opencollective",
4767+ "url": "https://opencollective.com/unified"
4768+ }
4769+ },
32544770 "node_modules/rolldown": {
32554771 "version": "1.2.12",
32564772 "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.12.tgz",
33544870 }
33554871 }
33564872 },
4873+ "node_modules/shiki": {
4874+ "version": "4.5.0",
4875+ "resolved": "https://registry.npmjs.org/shiki/-/shiki-4.5.0.tgz",
4876+ "integrity": "sha512-OcRW72ngQvc+MSVWBRIovC/BTW589iUE/Vxav7VeM4RcY9MUjqB+IR6ILG1k9nuW6LiG/nct9uma4/MBtnZbmQ==",
4877+ "license": "MIT",
4878+ "dependencies": {
4879+ "@shikijs/core": "4.5.0",
4880+ "@shikijs/engine-javascript": "4.5.0",
4881+ "@shikijs/engine-oniguruma": "4.5.0",
4882+ "@shikijs/langs": "4.5.0",
4883+ "@shikijs/themes": "4.5.0",
4884+ "@shikijs/types": "4.5.0",
4885+ "@shikijs/vscode-textmate": "^10.0.2",
4886+ "@types/hast": "^3.0.5"
4887+ },
4888+ "engines": {
4889+ "node": ">=20"
4890+ }
4891+ },
33574892 "node_modules/source-map-js": {
33584893 "version": "1.2.2",
33594894 "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
33644899 "node": ">=0.10.0"
33654900 }
33664901 },
4902+ "node_modules/space-separated-tokens": {
4903+ "version": "2.0.2",
4904+ "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
4905+ "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
4906+ "license": "MIT",
4907+ "funding": {
4908+ "type": "github",
4909+ "url": "https://github.com/sponsors/wooorm"
4910+ }
4911+ },
4912+ "node_modules/stringify-entities": {
4913+ "version": "4.0.4",
4914+ "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
4915+ "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
4916+ "license": "MIT",
4917+ "dependencies": {
4918+ "character-entities-html4": "^2.0.0",
4919+ "character-entities-legacy": "^3.0.0"
4920+ },
4921+ "funding": {
4922+ "type": "github",
4923+ "url": "https://github.com/sponsors/wooorm"
4924+ }
4925+ },
4926+ "node_modules/style-to-js": {
4927+ "version": "1.1.21",
4928+ "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
4929+ "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==",
4930+ "license": "MIT",
4931+ "dependencies": {
4932+ "style-to-object": "1.0.14"
4933+ }
4934+ },
4935+ "node_modules/style-to-object": {
4936+ "version": "1.0.14",
4937+ "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz",
4938+ "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==",
4939+ "license": "MIT",
4940+ "dependencies": {
4941+ "inline-style-parser": "0.2.7"
4942+ }
4943+ },
33674944 "node_modules/supports-color": {
33684945 "version": "10.2.2",
33694946 "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
34154992 "url": "https://github.com/sponsors/SuperchupuDev"
34164993 }
34174994 },
4995+ "node_modules/trim-lines": {
4996+ "version": "3.0.1",
4997+ "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
4998+ "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
4999+ "license": "MIT",
5000+ "funding": {
5001+ "type": "github",
5002+ "url": "https://github.com/sponsors/wooorm"
5003+ }
5004+ },
5005+ "node_modules/trough": {
5006+ "version": "2.2.0",
5007+ "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz",
5008+ "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==",
5009+ "license": "MIT",
5010+ "funding": {
5011+ "type": "github",
5012+ "url": "https://github.com/sponsors/wooorm"
5013+ }
5014+ },
34185015 "node_modules/tslib": {
34195016 "version": "2.8.1",
34205017 "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
34645061 "pathe": "^2.0.3"
34655062 }
34665063 },
5064+ "node_modules/unified": {
5065+ "version": "11.0.5",
5066+ "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
5067+ "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==",
5068+ "license": "MIT",
5069+ "dependencies": {
5070+ "@types/unist": "^3.0.0",
5071+ "bail": "^2.0.0",
5072+ "devlop": "^1.0.0",
5073+ "extend": "^3.0.0",
5074+ "is-plain-obj": "^4.0.0",
5075+ "trough": "^2.0.0",
5076+ "vfile": "^6.0.0"
5077+ },
5078+ "funding": {
5079+ "type": "opencollective",
5080+ "url": "https://opencollective.com/unified"
5081+ }
5082+ },
5083+ "node_modules/unist-util-is": {
5084+ "version": "6.0.1",
5085+ "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
5086+ "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
5087+ "license": "MIT",
5088+ "dependencies": {
5089+ "@types/unist": "^3.0.0"
5090+ },
5091+ "funding": {
5092+ "type": "opencollective",
5093+ "url": "https://opencollective.com/unified"
5094+ }
5095+ },
5096+ "node_modules/unist-util-position": {
5097+ "version": "5.0.0",
5098+ "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
5099+ "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
5100+ "license": "MIT",
5101+ "dependencies": {
5102+ "@types/unist": "^3.0.0"
5103+ },
5104+ "funding": {
5105+ "type": "opencollective",
5106+ "url": "https://opencollective.com/unified"
5107+ }
5108+ },
5109+ "node_modules/unist-util-stringify-position": {
5110+ "version": "4.0.0",
5111+ "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
5112+ "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
5113+ "license": "MIT",
5114+ "dependencies": {
5115+ "@types/unist": "^3.0.0"
5116+ },
5117+ "funding": {
5118+ "type": "opencollective",
5119+ "url": "https://opencollective.com/unified"
5120+ }
5121+ },
5122+ "node_modules/unist-util-visit": {
5123+ "version": "5.1.0",
5124+ "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
5125+ "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
5126+ "license": "MIT",
5127+ "dependencies": {
5128+ "@types/unist": "^3.0.0",
5129+ "unist-util-is": "^6.0.0",
5130+ "unist-util-visit-parents": "^6.0.0"
5131+ },
5132+ "funding": {
5133+ "type": "opencollective",
5134+ "url": "https://opencollective.com/unified"
5135+ }
5136+ },
5137+ "node_modules/unist-util-visit-parents": {
5138+ "version": "6.0.2",
5139+ "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
5140+ "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
5141+ "license": "MIT",
5142+ "dependencies": {
5143+ "@types/unist": "^3.0.0",
5144+ "unist-util-is": "^6.0.0"
5145+ },
5146+ "funding": {
5147+ "type": "opencollective",
5148+ "url": "https://opencollective.com/unified"
5149+ }
5150+ },
34675151 "node_modules/update-browserslist-db": {
34685152 "version": "1.3.3",
34695153 "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.3.tgz",
35105194 }
35115195 }
35125196 },
5197+ "node_modules/vfile": {
5198+ "version": "6.0.3",
5199+ "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
5200+ "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
5201+ "license": "MIT",
5202+ "dependencies": {
5203+ "@types/unist": "^3.0.0",
5204+ "vfile-message": "^4.0.0"
5205+ },
5206+ "funding": {
5207+ "type": "opencollective",
5208+ "url": "https://opencollective.com/unified"
5209+ }
5210+ },
5211+ "node_modules/vfile-message": {
5212+ "version": "4.0.3",
5213+ "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
5214+ "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
5215+ "license": "MIT",
5216+ "dependencies": {
5217+ "@types/unist": "^3.0.0",
5218+ "unist-util-stringify-position": "^4.0.0"
5219+ },
5220+ "funding": {
5221+ "type": "opencollective",
5222+ "url": "https://opencollective.com/unified"
5223+ }
5224+ },
35135225 "node_modules/vite": {
35145226 "version": "8.3.2",
35155227 "resolved": "https://registry.npmjs.org/vite/-/vite-8.3.2.tgz",
39725684 "error-stack-parser-es": "^1.0.5"
39735685 }
39745686 },
5687+ "node_modules/zwitch": {
5688+ "version": "2.0.4",
5689+ "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
5690+ "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
5691+ "license": "MIT",
5692+ "funding": {
5693+ "type": "github",
5694+ "url": "https://github.com/sponsors/wooorm"
5695+ }
5696+ },
39755697 "packages/contracts": {
39765698 "name": "@g1t/contracts",
39775699 "version": "0.1.0",
39795701 },
39805702 "services/events": {
39815703 "name": "@g1t/events",
3982− "version": "0.1.0",
3983− "license": "MIT",
3984− "dependencies": {
3985− "@g1t/contracts": "*"
3986− }
3987− },
3988− "services/identity": {
3989− "name": "@g1t/identity",
39905704 "version": "0.1.0",
39915705 "license": "MIT",
39925706 "dependencies": {
+1−1
55 "workspaces": ["apps/*", "services/*", "packages/*"],
66 "scripts": {
77 "typecheck": "npm run typecheck --workspaces --if-present",
8− "deploy": "npm run deploy -w @g1t/identity -w @g1t/events -w @g1t/repos -w @g1t/work -w @g1t/web"
8+ "deploy": "npm run deploy -w @g1t/events -w @g1t/repos -w @g1t/work -w @g1t/api -w @g1t/web"
99 },
1010 "devDependencies": {
1111 "typescript": "^5.9.3",
+50−0
1+import type { IdentityApi } from "./identity";
2+
3+/** A service binding, as far as these clients need it. */
4+export type ServiceBinding = {
5+ fetch(input: string, init?: RequestInit): Promise<Response>;
6+};
7+
8+/**
9+ * Calls a method on a service that speaks the JSON protocol used by the
10+ * Rust services: `POST /rpc/<method>` with the arguments as the body.
11+ */
12+async function rpc<T>(
13+ service: ServiceBinding,
14+ method: string,
15+ args: object,
16+): Promise<T> {
17+ // The hostname is ignored; a service binding always reaches its service.
18+ const response = await service.fetch(`https://service/rpc/${method}`, {
19+ method: "POST",
20+ headers: { "content-type": "application/json" },
21+ body: JSON.stringify(args),
22+ });
23+ if (!response.ok) {
24+ throw new Error(`${method} failed with status ${response.status}`);
25+ }
26+ return response.json();
27+}
28+
29+export function identityClient(service: ServiceBinding): IdentityApi {
30+ const call = <T>(method: string, args: object) => rpc<T>(service, method, args);
31+ return {
32+ register: (username, email, password) =>
33+ call("register", { username, email, password }),
34+ signIn: (username, password) => call("sign_in", { username, password }),
35+ signOut: (sessionToken) => call("sign_out", { sessionToken }),
36+ userForSession: (sessionToken) => call("user_for_session", { sessionToken }),
37+ userForGitCredentials: (username, secret) =>
38+ call("user_for_git_credentials", { username, secret }),
39+ userForAccessToken: (token) => call("user_for_access_token", { token }),
40+ userForSshKey: (fingerprint) => call("user_for_ssh_key", { fingerprint }),
41+ userByUsername: (username) => call("user_by_username", { username }),
42+ listSshKeys: (user) => call("list_ssh_keys", { user }),
43+ addSshKey: (user, title, publicKey) =>
44+ call("add_ssh_key", { user, title, publicKey }),
45+ removeSshKey: (user, id) => call("remove_ssh_key", { user, id }),
46+ listAccessTokens: (user) => call("list_access_tokens", { user }),
47+ createAccessToken: (user, name) => call("create_access_token", { user, name }),
48+ removeAccessToken: (user, id) => call("remove_access_token", { user, id }),
49+ };
50+}
+4−0
1616
1717 /** Accounts, credentials and sessions. */
1818 export interface IdentityApi {
19+ /** Creates an account and signs it in. */
20+ register(username: string, email: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>;
1921 /** Verifies a username and password for website sign-in. */
2022 signIn(username: string, password: string): Promise<Result<{ user: User; sessionToken: string }>>;
2123 signOut(sessionToken: string): Promise<void>;
2325
2426 /** Verifies git credentials: the account password or an access token. */
2527 userForGitCredentials(username: string, secret: string): Promise<Viewer>;
28+ /** Resolves a `g1t_…` access token, as sent to the API and MCP server. */
29+ userForAccessToken(token: string): Promise<Viewer>;
2630 userForSshKey(fingerprint: string): Promise<Viewer>;
2731 userByUsername(username: string): Promise<Viewer>;
2832
+31−10
22
33 export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt";
44
5+let lastMs = 0;
6+let lastCounter = 0;
7+
58 /**
6− * A prefixed, time-sortable id such as `att_01jb2…`: 48 bits of millisecond
7− * timestamp then 80 random bits, in lowercase Crockford base32. Sorting ids
8− * as strings sorts them by creation time.
9+ * A new id in TypeID format (https://github.com/jetify-com/typeid): a type
10+ * prefix, then a UUIDv7 in lowercase Crockford base32, such as
11+ * `att_01jb2k7x9hfq0b3zj0f5s2m8ra`. Sorting ids as strings sorts them by
12+ * creation time. Must stay identical to `new_id` in crates/contracts.
913 */
1014 export function newId(prefix: IdPrefix, now: number = Date.now()): string {
11− let time = "";
12− for (let i = 0, t = now; i < 10; i++, t = Math.floor(t / 32)) {
13− time = ALPHABET[t % 32] + time;
15+ const bytes = crypto.getRandomValues(new Uint8Array(16));
16+
17+ // Ids made in the same millisecond count up in the 12-bit rand_a field.
18+ const counter =
19+ now === lastMs
20+ ? (lastCounter + 1) & 0x0fff
21+ : ((bytes[6] << 8) | bytes[7]) & 0x07ff;
22+ lastMs = now;
23+ lastCounter = counter;
24+
25+ const time = BigInt(now);
26+ for (let i = 0; i < 6; i++) {
27+ bytes[i] = Number((time >> BigInt(40 - 8 * i)) & 0xffn);
1428 }
15− let random = "";
16− for (const byte of crypto.getRandomValues(new Uint8Array(16))) {
17− random += ALPHABET[byte % 32];
29+ bytes[6] = 0x70 | (counter >> 8); // version 7
30+ bytes[7] = counter & 0xff;
31+ bytes[8] = 0x80 | (bytes[8] & 0x3f); // RFC 9562 variant
32+
33+ let value = 0n;
34+ for (const byte of bytes) value = (value << 8n) | BigInt(byte);
35+ let suffix = "";
36+ // 128 bits in 26 characters of 5 bits; the first carries only 3.
37+ for (let i = 0; i < 26; i++) {
38+ suffix += ALPHABET[Number((value >> BigInt(125 - 5 * i)) & 31n)];
1839 }
19− return `${prefix}_${time}${random}`;
40+ return `${prefix}_${suffix}`;
2041 }
+1−0
1+export * from "./clients";
12 export * from "./events";
23 export * from "./identity";
34 export * from "./ids";
+1−0
1+build/
+21−0
1+[package]
2+name = "g1t-identity"
3+version = "0.1.0"
4+edition.workspace = true
5+license.workspace = true
6+description = "Accounts, sessions, SSH keys and access tokens."
7+
8+[lib]
9+crate-type = ["cdylib"]
10+
11+[dependencies]
12+g1t-contracts.workspace = true
13+g1t-kit.workspace = true
14+serde.workspace = true
15+serde_json.workspace = true
16+worker.workspace = true
17+base64 = "0.22"
18+getrandom = { version = "0.2", features = ["js"] }
19+hex = "0.4"
20+pbkdf2 = { version = "0.12", default-features = false, features = ["hmac"] }
21+sha2 = "0.10"
+1−0
1+CREATE UNIQUE INDEX users_email ON users (email) WHERE email IS NOT NULL;
+0−16
1−{
2− "name": "@g1t/identity",
3− "version": "0.1.0",
4− "private": true,
5− "type": "module",
6− "license": "MIT",
7− "scripts": {
8− "types": "wrangler types --include-env=false",
9− "typecheck": "wrangler types --include-env=false && tsc -p tsconfig.json",
10− "deploy": "wrangler deploy",
11− "migrate": "wrangler d1 migrations apply DB --remote"
12− },
13− "dependencies": {
14− "@g1t/contracts": "*"
15− }
16−}
+96−0
1+use base64::Engine;
2+use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD};
3+use sha2::{Digest, Sha256};
4+
5+// Kept at the cap the Workers WebCrypto API imposes, so hashes made by
6+// either implementation verify under the other.
7+const PBKDF2_ITERATIONS: u32 = 100_000;
8+
9+pub fn sha256_hex(value: &str) -> String {
10+ hex::encode(Sha256::digest(value.as_bytes()))
11+}
12+
13+pub fn random_hex(bytes: usize) -> String {
14+ let mut buffer = vec![0u8; bytes];
15+ getrandom::getrandom(&mut buffer).expect("no source of randomness");
16+ hex::encode(buffer)
17+}
18+
19+fn derive(password: &str, salt: &[u8], iterations: u32) -> [u8; 32] {
20+ let mut hash = [0u8; 32];
21+ pbkdf2::pbkdf2_hmac::<Sha256>(password.as_bytes(), salt, iterations, &mut hash);
22+ hash
23+}
24+
25+/// Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`.
26+pub fn hash_password(password: &str) -> String {
27+ let mut salt = [0u8; 16];
28+ getrandom::getrandom(&mut salt).expect("no source of randomness");
29+ let hash = derive(password, &salt, PBKDF2_ITERATIONS);
30+ format!(
31+ "pbkdf2${PBKDF2_ITERATIONS}${}${}",
32+ STANDARD.encode(salt),
33+ STANDARD.encode(hash)
34+ )
35+}
36+
37+pub fn verify_password(password: &str, stored: &str) -> bool {
38+ let parts: Vec<&str> = stored.split('$').collect();
39+ let [scheme, iterations, salt, hash] = parts[..] else {
40+ return false;
41+ };
42+ let (Ok(iterations), Ok(salt), Ok(expected)) = (
43+ iterations.parse::<u32>(),
44+ STANDARD.decode(salt),
45+ STANDARD.decode(hash),
46+ ) else {
47+ return false;
48+ };
49+ if scheme != "pbkdf2" || expected.len() != 32 {
50+ return false;
51+ }
52+ let given = derive(password, &salt, iterations);
53+ // Constant-time comparison.
54+ given
55+ .iter()
56+ .zip(&expected)
57+ .fold(0u8, |diff, (a, b)| diff | (a ^ b))
58+ == 0
59+}
60+
61+pub struct ParsedKey {
62+ /// `<type> <base64 blob>`, without the comment.
63+ pub public_key: String,
64+ /// Matches `ssh-keygen -lf`: `SHA256:` then unpadded base64.
65+ pub fingerprint: String,
66+ pub comment: String,
67+}
68+
69+/// Parses one line in OpenSSH public key format.
70+pub fn parse_ssh_key(line: &str) -> Option<ParsedKey> {
71+ let mut parts = line.split_whitespace();
72+ let kind = parts.next()?;
73+ let blob = parts.next()?;
74+ let supported = matches!(
75+ kind,
76+ "ssh-ed25519"
77+ | "ssh-rsa"
78+ | "ecdsa-sha2-nistp256"
79+ | "ecdsa-sha2-nistp384"
80+ | "ecdsa-sha2-nistp521"
81+ );
82+ if !supported {
83+ return None;
84+ }
85+ let bytes = STANDARD.decode(blob).ok()?;
86+ // The blob starts with its own length-prefixed copy of the key type.
87+ let length = u32::from_be_bytes(bytes.get(..4)?.try_into().ok()?) as usize;
88+ if bytes.get(4..4 + length)? != kind.as_bytes() {
89+ return None;
90+ }
91+ Some(ParsedKey {
92+ public_key: format!("{kind} {blob}"),
93+ fingerprint: format!("SHA256:{}", STANDARD_NO_PAD.encode(Sha256::digest(&bytes))),
94+ comment: parts.collect::<Vec<_>>().join(" "),
95+ })
96+}
+0−98
1−// Workers cap PBKDF2 at 100,000 iterations.
2−const PBKDF2_ITERATIONS = 100_000;
3−
4−const encoder = new TextEncoder();
5−
6−export function toHex(bytes: ArrayBuffer | Uint8Array): string {
7− return [...new Uint8Array(bytes)]
8− .map((byte) => byte.toString(16).padStart(2, "0"))
9− .join("");
10−}
11−
12−export function fromBase64(value: string): Uint8Array<ArrayBuffer> {
13− return Uint8Array.from(atob(value), (char) => char.charCodeAt(0));
14−}
15−
16−export function toBase64(bytes: ArrayBuffer | Uint8Array): string {
17− return btoa(String.fromCharCode(...new Uint8Array(bytes)));
18−}
19−
20−export async function sha256Hex(value: string): Promise<string> {
21− return toHex(await crypto.subtle.digest("SHA-256", encoder.encode(value)));
22−}
23−
24−export function randomHex(bytes: number): string {
25− return toHex(crypto.getRandomValues(new Uint8Array(bytes)));
26−}
27−
28−async function pbkdf2(
29− password: string,
30− salt: Uint8Array<ArrayBuffer>,
31− iterations: number,
32−): Promise<Uint8Array> {
33− const key = await crypto.subtle.importKey(
34− "raw",
35− encoder.encode(password),
36− "PBKDF2",
37− false,
38− ["deriveBits"],
39− );
40− return new Uint8Array(
41− await crypto.subtle.deriveBits(
42− { name: "PBKDF2", hash: "SHA-256", salt, iterations },
43− key,
44− 256,
45− ),
46− );
47−}
48−
49−/** Format: `pbkdf2$<iterations>$<salt base64>$<hash base64>`. */
50−export async function hashPassword(password: string): Promise<string> {
51− const salt = crypto.getRandomValues(new Uint8Array(16));
52− const hash = await pbkdf2(password, salt, PBKDF2_ITERATIONS);
53− return `pbkdf2$${PBKDF2_ITERATIONS}$${toBase64(salt)}$${toBase64(hash)}`;
54−}
55−
56−export async function verifyPassword(
57− password: string,
58− stored: string,
59−): Promise<boolean> {
60− const [scheme, iterations, salt, hash] = stored.split("$");
61− if (scheme !== "pbkdf2") return false;
62− const expected = fromBase64(hash);
63− const given = await pbkdf2(password, fromBase64(salt), Number(iterations));
64− // Constant-time comparison.
65− let diff = given.length ^ expected.length;
66− for (let i = 0; i < expected.length; i++) diff |= given[i] ^ expected[i];
67− return diff === 0;
68−}
69−
70−/**
71− * Parses an OpenSSH public key line. The fingerprint matches
72− * `ssh-keygen -lf` (SHA256, unpadded base64).
73− */
74−export async function parseSshKey(
75− line: string,
76−): Promise<{ publicKey: string; fingerprint: string; comment: string } | null> {
77− const [type, blob, ...comment] = line.trim().split(/\s+/);
78− if (!/^(ssh-(ed25519|rsa)|ecdsa-sha2-nistp(256|384|521))$/.test(type ?? "")) {
79− return null;
80− }
81− let bytes: Uint8Array<ArrayBuffer>;
82− try {
83− bytes = fromBase64(blob ?? "");
84− // The blob starts with its own length-prefixed copy of the key type.
85− const typeLength = new DataView(bytes.buffer).getUint32(0);
86− if (new TextDecoder().decode(bytes.slice(4, 4 + typeLength)) !== type) {
87− return null;
88− }
89− } catch {
90− return null;
91− }
92− const digest = await crypto.subtle.digest("SHA-256", bytes);
93− return {
94− publicKey: `${type} ${blob}`,
95− fingerprint: `SHA256:${toBase64(digest).replace(/=+$/, "")}`,
96− comment: comment.join(" "),
97− };
98−}
+0−209
1−import { WorkerEntrypoint } from "cloudflare:workers";
2−
3−import {
4− type AccessToken,
5− type IdentityApi,
6− type Result,
7− type SshKey,
8− type User,
9− type Viewer,
10− fail,
11− newId,
12− ok,
13−} from "@g1t/contracts";
14−
15−import {
16− parseSshKey,
17− randomHex,
18− sha256Hex,
19− verifyPassword,
20−} from "./crypto";
21−
22−export interface IdentityEnv {
23− DB: D1Database;
24−}
25−
26−const SESSION_TTL_SECONDS = 30 * 24 * 60 * 60;
27−const TOKEN_PREFIX = "g1t_";
28−
29−type SshKeyRow = { id: string; title: string; fingerprint: string; created_at: number };
30−type TokenRow = { id: string; name: string; created_at: number };
31−
32−function toSshKey(row: SshKeyRow): SshKey {
33− return {
34− id: row.id,
35− title: row.title,
36− fingerprint: row.fingerprint,
37− createdAt: row.created_at * 1000,
38− };
39−}
40−
41−function toAccessToken(row: TokenRow): AccessToken {
42− return { id: row.id, name: row.name, createdAt: row.created_at * 1000 };
43−}
44−
45−export default class IdentityService
46− extends WorkerEntrypoint<IdentityEnv>
47− implements IdentityApi
48−{
49− private get db(): D1Database {
50− return this.env.DB;
51− }
52−
53− /** A Worker must have an event handler; this service is RPC-only. */
54− fetch(): Response {
55− return new Response("Not found\n", { status: 404 });
56− }
57−
58− private async userForPassword(username: string, password: string): Promise<Viewer> {
59− const row = await this.db
60− .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
61− .bind(username.toLowerCase())
62− .first<User & { password_hash: string }>();
63− if (!row || !(await verifyPassword(password, row.password_hash))) return null;
64− return { id: row.id, username: row.username };
65− }
66−
67− async signIn(
68− username: string,
69− password: string,
70− ): Promise<Result<{ user: User; sessionToken: string }>> {
71− const user = await this.userForPassword(username, password);
72− if (!user) return fail("unauthenticated", "Incorrect username or password.");
73− const sessionToken = randomHex(32);
74− await this.db
75− .prepare(
76− "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)",
77− )
78− .bind(await sha256Hex(sessionToken), user.id, SESSION_TTL_SECONDS)
79− .run();
80− return ok({ user, sessionToken });
81− }
82−
83− async signOut(sessionToken: string): Promise<void> {
84− await this.db
85− .prepare("DELETE FROM sessions WHERE id = ?")
86− .bind(await sha256Hex(sessionToken))
87− .run();
88− }
89−
90− async userForSession(sessionToken: string): Promise<Viewer> {
91− return this.db
92− .prepare(
93− `SELECT users.id, users.username FROM sessions
94− JOIN users ON users.id = sessions.user_id
95− WHERE sessions.id = ? AND sessions.expires_at > unixepoch()`,
96− )
97− .bind(await sha256Hex(sessionToken))
98− .first<User>();
99− }
100−
101− async userForGitCredentials(username: string, secret: string): Promise<Viewer> {
102− if (!secret.startsWith(TOKEN_PREFIX)) {
103− return this.userForPassword(username, secret);
104− }
105− // Like GitHub, a token alone identifies its user.
106− return this.db
107− .prepare(
108− `SELECT users.id, users.username FROM access_tokens
109− JOIN users ON users.id = access_tokens.user_id
110− WHERE token_hash = ?`,
111− )
112− .bind(await sha256Hex(secret))
113− .first<User>();
114− }
115−
116− async userForSshKey(fingerprint: string): Promise<Viewer> {
117− return this.db
118− .prepare(
119− `SELECT users.id, users.username FROM ssh_keys
120− JOIN users ON users.id = ssh_keys.user_id
121− WHERE fingerprint = ?`,
122− )
123− .bind(fingerprint)
124− .first<User>();
125− }
126−
127− async userByUsername(username: string): Promise<Viewer> {
128− return this.db
129− .prepare("SELECT id, username FROM users WHERE username = ?")
130− .bind(username.toLowerCase())
131− .first<User>();
132− }
133−
134− async listSshKeys(user: User): Promise<SshKey[]> {
135− const { results } = await this.db
136− .prepare(
137− "SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id",
138− )
139− .bind(user.id)
140− .all<SshKeyRow>();
141− return results.map(toSshKey);
142− }
143−
144− async addSshKey(user: User, title: string, publicKey: string): Promise<Result<SshKey>> {
145− const key = await parseSshKey(publicKey);
146− if (!key) return fail("invalid", "That is not a valid OpenSSH public key.");
147− const taken = await this.db
148− .prepare("SELECT 1 FROM ssh_keys WHERE fingerprint = ?")
149− .bind(key.fingerprint)
150− .first();
151− if (taken) return fail("conflict", "That key is already registered.");
152− const row: SshKeyRow = {
153− id: newId("key"),
154− title: title.trim() || key.comment || "SSH key",
155− fingerprint: key.fingerprint,
156− created_at: Math.floor(Date.now() / 1000),
157− };
158− await this.db
159− .prepare(
160− "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at) VALUES (?, ?, ?, ?, ?, ?)",
161− )
162− .bind(row.id, user.id, row.title, key.publicKey, row.fingerprint, row.created_at)
163− .run();
164− return ok(toSshKey(row));
165− }
166−
167− async removeSshKey(user: User, id: string): Promise<void> {
168− await this.db
169− .prepare("DELETE FROM ssh_keys WHERE id = ? AND user_id = ?")
170− .bind(id, user.id)
171− .run();
172− }
173−
174− async listAccessTokens(user: User): Promise<AccessToken[]> {
175− const { results } = await this.db
176− .prepare(
177− "SELECT id, name, created_at FROM access_tokens WHERE user_id = ? ORDER BY id",
178− )
179− .bind(user.id)
180− .all<TokenRow>();
181− return results.map(toAccessToken);
182− }
183−
184− async createAccessToken(
185− user: User,
186− name: string,
187− ): Promise<{ token: string; info: AccessToken }> {
188− const token = TOKEN_PREFIX + randomHex(20);
189− const row: TokenRow = {
190− id: newId("tok"),
191− name: name.trim() || "Access token",
192− created_at: Math.floor(Date.now() / 1000),
193− };
194− await this.db
195− .prepare(
196− "INSERT INTO access_tokens (id, user_id, name, token_hash, created_at) VALUES (?, ?, ?, ?, ?)",
197− )
198− .bind(row.id, user.id, row.name, await sha256Hex(token), row.created_at)
199− .run();
200− return { token, info: toAccessToken(row) };
201− }
202−
203− async removeAccessToken(user: User, id: string): Promise<void> {
204− await this.db
205− .prepare("DELETE FROM access_tokens WHERE id = ? AND user_id = ?")
206− .bind(id, user.id)
207− .run();
208− }
209−}
+369−0
1+//! The identity service: accounts, sessions, SSH keys and access tokens.
2+//!
3+//! Reached only through service bindings; see `g1t_contracts::identity` for
4+//! the methods and their arguments.
5+
6+mod crypto;
7+
8+use g1t_contracts::identity::*;
9+use g1t_contracts::{FailureCode, Outcome, User, Viewer, is_valid_namespace, new_id};
10+use g1t_kit::{args, now_ms, reply, rpc_method};
11+use serde::Deserialize;
12+use worker::wasm_bindgen::JsValue;
13+use worker::{Context, D1Database, Env, Request, Response, Result, event};
14+
15+const SESSION_TTL_SECONDS: u32 = 30 * 24 * 60 * 60;
16+const TOKEN_PREFIX: &str = "g1t_";
17+const MIN_PASSWORD_LENGTH: usize = 10;
18+
19+#[derive(Deserialize)]
20+struct UserRow {
21+ id: String,
22+ username: String,
23+ password_hash: String,
24+}
25+
26+#[derive(Deserialize)]
27+struct KeyRow {
28+ id: String,
29+ title: String,
30+ fingerprint: String,
31+ created_at: u64,
32+}
33+
34+impl From<KeyRow> for SshKey {
35+ fn from(row: KeyRow) -> Self {
36+ SshKey {
37+ id: row.id,
38+ title: row.title,
39+ fingerprint: row.fingerprint,
40+ created_at: row.created_at * 1000,
41+ }
42+ }
43+}
44+
45+#[derive(Deserialize)]
46+struct TokenRow {
47+ id: String,
48+ name: String,
49+ created_at: u64,
50+}
51+
52+impl From<TokenRow> for AccessToken {
53+ fn from(row: TokenRow) -> Self {
54+ AccessToken {
55+ id: row.id,
56+ name: row.name,
57+ created_at: row.created_at * 1000,
58+ }
59+ }
60+}
61+
62+struct Identity {
63+ db: D1Database,
64+}
65+
66+impl Identity {
67+ /// Runs a query that returns at most one user.
68+ async fn find_user(&self, sql: &str, param: &str) -> Result<Viewer> {
69+ self.db
70+ .prepare(sql)
71+ .bind(&[JsValue::from(param)])?
72+ .first::<User>(None)
73+ .await
74+ }
75+
76+ async fn user_for_password(&self, username: &str, password: &str) -> Result<Viewer> {
77+ let row = self
78+ .db
79+ .prepare("SELECT id, username, password_hash FROM users WHERE username = ?")
80+ .bind(&[JsValue::from(username.to_lowercase())])?
81+ .first::<UserRow>(None)
82+ .await?;
83+ Ok(row
84+ .filter(|row| crypto::verify_password(password, &row.password_hash))
85+ .map(|row| User {
86+ id: row.id,
87+ username: row.username,
88+ }))
89+ }
90+
91+ async fn register(&self, a: RegisterArgs) -> Result<Outcome<SignedIn>> {
92+ let username = a.username.trim().to_lowercase();
93+ let email = a.email.trim().to_lowercase();
94+ let invalid = |message: &str| Ok(Outcome::fail(FailureCode::Invalid, message));
95+ if !is_valid_namespace(&username) {
96+ return invalid(
97+ "Usernames use lowercase letters, digits and single hyphens, up to 39 characters.",
98+ );
99+ }
100+ let well_formed_email = email
101+ .split_once('@')
102+ .is_some_and(|(local, domain)| !local.is_empty() && domain.contains('.'))
103+ && !email.contains(char::is_whitespace);
104+ if !well_formed_email {
105+ return invalid("Enter a valid email address.");
106+ }
107+ if a.password.chars().count() < MIN_PASSWORD_LENGTH {
108+ return invalid("Use a password of at least 10 characters.");
109+ }
110+ let taken = self
111+ .db
112+ .prepare("SELECT username FROM users WHERE username = ? OR email = ?")
113+ .bind(&[username.as_str().into(), email.as_str().into()])?
114+ .first::<serde_json::Value>(None)
115+ .await?;
116+ if taken.is_some() {
117+ return Ok(Outcome::fail(
118+ FailureCode::Conflict,
119+ "That username or email is already registered.",
120+ ));
121+ }
122+ let user = User {
123+ id: new_id("usr", now_ms()),
124+ username,
125+ };
126+ self.db
127+ .prepare("INSERT INTO users (id, username, email, password_hash) VALUES (?, ?, ?, ?)")
128+ .bind(&[
129+ user.id.as_str().into(),
130+ user.username.as_str().into(),
131+ email.into(),
132+ crypto::hash_password(&a.password).into(),
133+ ])?
134+ .run()
135+ .await?;
136+ self.start_session(user).await
137+ }
138+
139+ async fn sign_in(&self, a: SignInArgs) -> Result<Outcome<SignedIn>> {
140+ let Some(user) = self.user_for_password(&a.username, &a.password).await? else {
141+ return Ok(Outcome::fail(
142+ FailureCode::Unauthenticated,
143+ "Incorrect username or password.",
144+ ));
145+ };
146+ self.start_session(user).await
147+ }
148+
149+ async fn start_session(&self, user: User) -> Result<Outcome<SignedIn>> {
150+ let session_token = crypto::random_hex(32);
151+ self.db
152+ .prepare(
153+ "INSERT INTO sessions (id, user_id, expires_at) VALUES (?, ?, unixepoch() + ?)",
154+ )
155+ .bind(&[
156+ crypto::sha256_hex(&session_token).into(),
157+ user.id.as_str().into(),
158+ SESSION_TTL_SECONDS.into(),
159+ ])?
160+ .run()
161+ .await?;
162+ Ok(Outcome::Ok(SignedIn {
163+ user,
164+ session_token,
165+ }))
166+ }
167+
168+ async fn sign_out(&self, a: SessionArgs) -> Result<()> {
169+ self.db
170+ .prepare("DELETE FROM sessions WHERE id = ?")
171+ .bind(&[crypto::sha256_hex(&a.session_token).into()])?
172+ .run()
173+ .await?;
174+ Ok(())
175+ }
176+
177+ async fn user_for_session(&self, a: SessionArgs) -> Result<Viewer> {
178+ self.find_user(
179+ "SELECT users.id, users.username FROM sessions
180+ JOIN users ON users.id = sessions.user_id
181+ WHERE sessions.id = ? AND sessions.expires_at > unixepoch()",
182+ &crypto::sha256_hex(&a.session_token),
183+ )
184+ .await
185+ }
186+
187+ async fn user_for_access_token(&self, token: &str) -> Result<Viewer> {
188+ if !token.starts_with(TOKEN_PREFIX) {
189+ return Ok(None);
190+ }
191+ self.find_user(
192+ "SELECT users.id, users.username FROM access_tokens
193+ JOIN users ON users.id = access_tokens.user_id
194+ WHERE token_hash = ?",
195+ &crypto::sha256_hex(token),
196+ )
197+ .await
198+ }
199+
200+ async fn user_for_git_credentials(&self, a: GitCredentialsArgs) -> Result<Viewer> {
201+ // Like GitHub, a token alone identifies its user.
202+ if a.secret.starts_with(TOKEN_PREFIX) {
203+ self.user_for_access_token(&a.secret).await
204+ } else {
205+ self.user_for_password(&a.username, &a.secret).await
206+ }
207+ }
208+
209+ async fn user_for_ssh_key(&self, a: FingerprintArgs) -> Result<Viewer> {
210+ self.find_user(
211+ "SELECT users.id, users.username FROM ssh_keys
212+ JOIN users ON users.id = ssh_keys.user_id
213+ WHERE fingerprint = ?",
214+ &a.fingerprint,
215+ )
216+ .await
217+ }
218+
219+ async fn user_by_username(&self, a: UsernameArgs) -> Result<Viewer> {
220+ self.find_user(
221+ "SELECT id, username FROM users WHERE username = ?",
222+ &a.username.to_lowercase(),
223+ )
224+ .await
225+ }
226+
227+ async fn list_ssh_keys(&self, a: UserArgs) -> Result<Vec<SshKey>> {
228+ let rows = self
229+ .db
230+ .prepare("SELECT id, title, fingerprint, created_at FROM ssh_keys WHERE user_id = ? ORDER BY id")
231+ .bind(&[a.user.id.into()])?
232+ .all()
233+ .await?
234+ .results::<KeyRow>()?;
235+ Ok(rows.into_iter().map(SshKey::from).collect())
236+ }
237+
238+ async fn add_ssh_key(&self, a: AddSshKeyArgs) -> Result<Outcome<SshKey>> {
239+ let Some(key) = crypto::parse_ssh_key(&a.public_key) else {
240+ return Ok(Outcome::fail(
241+ FailureCode::Invalid,
242+ "That is not a valid OpenSSH public key.",
243+ ));
244+ };
245+ let taken = self
246+ .db
247+ .prepare("SELECT id FROM ssh_keys WHERE fingerprint = ?")
248+ .bind(&[key.fingerprint.as_str().into()])?
249+ .first::<serde_json::Value>(None)
250+ .await?;
251+ if taken.is_some() {
252+ return Ok(Outcome::fail(
253+ FailureCode::Conflict,
254+ "That key is already registered.",
255+ ));
256+ }
257+ let now = now_ms();
258+ let title = [a.title.trim(), key.comment.as_str(), "SSH key"]
259+ .into_iter()
260+ .find(|candidate| !candidate.is_empty())
261+ .unwrap_or_default()
262+ .to_owned();
263+ let row = KeyRow {
264+ id: new_id("key", now),
265+ title,
266+ fingerprint: key.fingerprint,
267+ created_at: now / 1000,
268+ };
269+ self.db
270+ .prepare(
271+ "INSERT INTO ssh_keys (id, user_id, title, public_key, fingerprint, created_at)
272+ VALUES (?, ?, ?, ?, ?, ?)",
273+ )
274+ .bind(&[
275+ row.id.as_str().into(),
276+ a.user.id.into(),
277+ row.title.as_str().into(),
278+ key.public_key.into(),
279+ row.fingerprint.as_str().into(),
280+ (row.created_at as f64).into(),
281+ ])?
282+ .run()
283+ .await?;
284+ Ok(Outcome::Ok(row.into()))
285+ }
286+
287+ async fn list_access_tokens(&self, a: UserArgs) -> Result<Vec<AccessToken>> {
288+ let rows = self
289+ .db
290+ .prepare("SELECT id, name, created_at FROM access_tokens WHERE user_id = ? ORDER BY id")
291+ .bind(&[a.user.id.into()])?
292+ .all()
293+ .await?
294+ .results::<TokenRow>()?;
295+ Ok(rows.into_iter().map(AccessToken::from).collect())
296+ }
297+
298+ async fn create_access_token(&self, a: CreateAccessTokenArgs) -> Result<CreatedAccessToken> {
299+ let token = format!("{TOKEN_PREFIX}{}", crypto::random_hex(20));
300+ let now = now_ms();
301+ let name = match a.name.trim() {
302+ "" => "Access token",
303+ name => name,
304+ };
305+ let row = TokenRow {
306+ id: new_id("tok", now),
307+ name: name.to_owned(),
308+ created_at: now / 1000,
309+ };
310+ self.db
311+ .prepare(
312+ "INSERT INTO access_tokens (id, user_id, name, token_hash, created_at)
313+ VALUES (?, ?, ?, ?, ?)",
314+ )
315+ .bind(&[
316+ row.id.as_str().into(),
317+ a.user.id.into(),
318+ row.name.as_str().into(),
319+ crypto::sha256_hex(&token).into(),
320+ (row.created_at as f64).into(),
321+ ])?
322+ .run()
323+ .await?;
324+ Ok(CreatedAccessToken {
325+ token,
326+ info: row.into(),
327+ })
328+ }
329+
330+ /// Deletes a row the user owns from `table`.
331+ async fn remove(&self, table: &str, a: RemoveArgs) -> Result<()> {
332+ self.db
333+ .prepare(format!("DELETE FROM {table} WHERE id = ? AND user_id = ?"))
334+ .bind(&[a.id.into(), a.user.id.into()])?
335+ .run()
336+ .await?;
337+ Ok(())
338+ }
339+}
340+
341+#[event(fetch)]
342+async fn fetch(mut request: Request, env: Env, _ctx: Context) -> Result<Response> {
343+ let Some(method) = rpc_method(&request) else {
344+ return Response::error("Not found", 404);
345+ };
346+ let body: serde_json::Value = request.json().await?;
347+ let identity = Identity { db: env.d1("DB")? };
348+
349+ match method.as_str() {
350+ "register" => reply(&identity.register(args(body)?).await?),
351+ "sign_in" => reply(&identity.sign_in(args(body)?).await?),
352+ "sign_out" => reply(&identity.sign_out(args(body)?).await?),
353+ "user_for_session" => reply(&identity.user_for_session(args(body)?).await?),
354+ "user_for_git_credentials" => reply(&identity.user_for_git_credentials(args(body)?).await?),
355+ "user_for_access_token" => {
356+ let a: TokenArgs = args(body)?;
357+ reply(&identity.user_for_access_token(&a.token).await?)
358+ }
359+ "user_for_ssh_key" => reply(&identity.user_for_ssh_key(args(body)?).await?),
360+ "user_by_username" => reply(&identity.user_by_username(args(body)?).await?),
361+ "list_ssh_keys" => reply(&identity.list_ssh_keys(args(body)?).await?),
362+ "add_ssh_key" => reply(&identity.add_ssh_key(args(body)?).await?),
363+ "remove_ssh_key" => reply(&identity.remove("ssh_keys", args(body)?).await?),
364+ "list_access_tokens" => reply(&identity.list_access_tokens(args(body)?).await?),
365+ "create_access_token" => reply(&identity.create_access_token(args(body)?).await?),
366+ "remove_access_token" => reply(&identity.remove("access_tokens", args(body)?).await?),
367+ _ => Response::error("Unknown method", 404),
368+ }
369+}
+0−4
1−{
2− "extends": "../../tsconfig.base.json",
3− "include": ["src/**/*", "worker-configuration.d.ts"]
4−}
+2−1
33 "name": "g1t-identity",
44 "account_id": "1e6f2cffa3f445920836e8ebe446bb58",
55 "compatibility_date": "2026-09-26",
6− "main": "./src/index.ts",
6+ "main": "build/index.js",
7+ "build": { "command": "cargo install -q worker-build@0.8.7 && worker-build --release" },
78 // Reached only through service bindings.
89 "workers_dev": false,
910 "d1_databases": [
+4−3
77 type EventsApi,
88 type GitAccess,
99 type GitService,
10− type IdentityApi,
10+ type ServiceBinding,
1111 type NewEvent,
1212 type Repo,
1313 type RepoPath,
1717 type User,
1818 type Viewer,
1919 fail,
20+ identityClient,
2021 isValidNamespace,
2122 isValidRepoName,
2223 newId,
3637 export interface ReposEnv {
3738 DB: D1Database;
3839 ARTIFACTS: Artifacts;
39− IDENTITY: IdentityApi;
40+ IDENTITY: ServiceBinding;
4041 EVENTS: EventsApi;
4142 }
4243
277278
278279 /** Git over HTTPS. */
279280 async fetch(request: Request): Promise<Response> {
280− const response = await handleGitHttp(request, this.env.IDENTITY, this, (path) =>
281+ const response = await handleGitHttp(request, identityClient(this.env.IDENTITY), this, (path) =>
281282 this.ctx.waitUntil(this.pushed(path)),
282283 );
283284 return response ?? new Response("Not found\n", { status: 404 });